commit 3e7c66d841308d79f1bcf1c7300d3fa5cdc3e54a Author: Benjamin Hardy Date: Sat Mar 7 00:34:25 2026 +0100 Initial commit diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..2079082 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:raw.githubusercontent.com)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..280e44e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/downloads/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..af483ad --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + git \ + curl \ + unzip \ + && rm -rf /var/lib/apt/lists/* + +# Install Bento4 (mp4decrypt) +RUN curl -L -o /tmp/bento4.zip https://www.bok.net/Bento4/binaries/Bento4-SDK-1-6-0-641.x86_64-unknown-linux.zip \ + && unzip /tmp/bento4.zip -d /tmp/bento4 \ + && cp /tmp/bento4/Bento4-SDK-1-6-0-641.x86_64-unknown-linux/bin/mp4decrypt /usr/local/bin/ \ + && chmod +x /usr/local/bin/mp4decrypt \ + && rm -rf /tmp/bento4 /tmp/bento4.zip + +# Install votify-fix +RUN pip install --no-cache-dir websocket-client git+https://github.com/GladistonXD/votify-fix.git + +# Install web app dependencies +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY app.py /app/app.py +COPY templates /app/templates +COPY static /app/static + +WORKDIR /app + +RUN mkdir -p /downloads /config /tmp/votify + +EXPOSE 5000 + +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "1", "--threads", "4", "app:app"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..1cb70e9 --- /dev/null +++ b/app.py @@ -0,0 +1,349 @@ +import io +import os +import shutil +import subprocess +import threading +import time +import uuid +import zipfile +from pathlib import Path + +from flask import ( + Flask, + jsonify, + redirect, + render_template, + request, + send_from_directory, + session, + url_for, +) + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY", os.urandom(32).hex()) + +# Auth +APP_PASSWORD = os.environ.get("PASSWORD", "") + + +@app.before_request +def require_login(): + if not APP_PASSWORD: + return + if request.endpoint in ("login", "static"): + return + if not session.get("authenticated"): + if request.path.startswith("/api/"): + return jsonify({"error": "Unauthorized"}), 401 + return redirect(url_for("login")) + + +# Paths +DOWNLOADS_DIR = Path(os.environ.get("DOWNLOADS_DIR", "/downloads")) +COOKIES_PATH = Path(os.environ.get("COOKIES_PATH", "/config/cookies.txt")) +CONFIG_DIR = Path(os.environ.get("CONFIG_DIR", "/config")) +TEMP_DIR = Path("/tmp/votify") + +DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True) +TEMP_DIR.mkdir(parents=True, exist_ok=True) + +# In-memory job tracking +jobs: dict[str, dict] = {} +jobs_lock = threading.Lock() + + +def snapshot_audio_files(directory: Path) -> set[Path]: + extensions = {".m4a", ".ogg", ".opus"} + files = set() + for ext in extensions: + files.update(directory.rglob(f"*{ext}")) + return files + + +def convert_to_mp3(job_id: str, before: set[Path]): + after = snapshot_audio_files(DOWNLOADS_DIR) + new_files = after - before + if not new_files: + return + + def log(msg): + with jobs_lock: + jobs[job_id]["output"] = jobs[job_id].get("output", [])[-500:] + [msg] + + log(f"[mp3] Converting {len(new_files)} file(s) to MP3...") + for src in sorted(new_files): + dst = src.with_suffix(".mp3") + log(f"[mp3] Converting: {src.name}") + result = subprocess.run( + ["ffmpeg", "-y", "-i", str(src), "-codec:v", "copy", "-q:a", "2", str(dst)], + capture_output=True, text=True, + ) + if result.returncode == 0: + src.unlink() + log(f"[mp3] Done: {dst.name}") + else: + log(f"[mp3] Failed: {src.name} - {result.stderr.strip()[-200:]}") + + log("[mp3] Conversion complete.") + + +def run_download(job_id: str, urls: list[str], options: dict): + cmd = ["votify"] + + cmd.extend(["--cookies-path", str(COOKIES_PATH)]) + cmd.extend(["--output-path", str(DOWNLOADS_DIR)]) + cmd.extend(["--temp-path", str(TEMP_DIR)]) + + quality = options.get("audio_quality", "aac-medium") + if quality: + cmd.extend(["--audio-quality", quality]) + + download_mode = options.get("download_mode", "ytdlp") + if download_mode: + cmd.extend(["--download-mode", download_mode]) + + video_format = options.get("video_format", "mp4") + if video_format: + cmd.extend(["--video-format", video_format]) + + cover_size = options.get("cover_size", "large") + if cover_size: + cmd.extend(["--cover-size", cover_size]) + + if options.get("save_cover"): + cmd.append("--save-cover") + + if options.get("save_playlist"): + cmd.append("--save-playlist") + + if options.get("overwrite"): + cmd.append("--overwrite") + + if options.get("download_music_videos"): + cmd.append("--download-music-videos") + + if options.get("save_lrc"): + cmd.append("--lrc-only") + + if options.get("no_lrc"): + cmd.append("--no-lrc") + + truncate = options.get("truncate") + if truncate: + cmd.extend(["--truncate", str(truncate)]) + + cmd.extend(urls) + + want_mp3 = options.get("output_format") == "mp3" + files_before = snapshot_audio_files(DOWNLOADS_DIR) if want_mp3 else None + + with jobs_lock: + jobs[job_id]["status"] = "running" + jobs[job_id]["command"] = " ".join(cmd) + + try: + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + output_lines = [] + for line in process.stdout: + line = line.rstrip("\n") + output_lines.append(line) + with jobs_lock: + jobs[job_id]["output"] = output_lines[-500:] + + process.wait() + + if process.returncode == 0 and want_mp3: + convert_to_mp3(job_id, files_before) + + with jobs_lock: + jobs[job_id]["status"] = "completed" if process.returncode == 0 else "failed" + jobs[job_id]["return_code"] = process.returncode + except Exception as e: + with jobs_lock: + jobs[job_id]["status"] = "failed" + jobs[job_id]["output"] = jobs[job_id].get("output", []) + [str(e)] + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + password = request.form.get("password", "") + if password == APP_PASSWORD: + session["authenticated"] = True + return redirect(url_for("index")) + return render_template("login.html", error="Incorrect password") + return render_template("login.html", error=None) + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +@app.route("/") +def index(): + return render_template("index.html", auth_enabled=bool(APP_PASSWORD)) + + +@app.route("/api/download", methods=["POST"]) +def start_download(): + data = request.json + urls = [u.strip() for u in data.get("urls", "").split("\n") if u.strip()] + if not urls: + return jsonify({"error": "No URLs provided"}), 400 + + if not COOKIES_PATH.exists(): + return jsonify({"error": "cookies.txt not found. Mount it to /config/cookies.txt"}), 400 + + options = { + "audio_quality": data.get("audio_quality", "aac-medium"), + "download_mode": data.get("download_mode", "ytdlp"), + "video_format": data.get("video_format", "mp4"), + "cover_size": data.get("cover_size", "large"), + "save_cover": data.get("save_cover", False), + "save_playlist": data.get("save_playlist", False), + "overwrite": data.get("overwrite", False), + "download_music_videos": data.get("download_music_videos", False), + "save_lrc": data.get("save_lrc", False), + "no_lrc": data.get("no_lrc", False), + "truncate": data.get("truncate"), + "output_format": data.get("output_format", "original"), + } + + job_id = str(uuid.uuid4())[:8] + with jobs_lock: + jobs[job_id] = { + "id": job_id, + "urls": urls, + "options": options, + "status": "queued", + "output": [], + "created_at": time.time(), + } + + thread = threading.Thread(target=run_download, args=(job_id, urls, options), daemon=True) + thread.start() + + return jsonify({"job_id": job_id}) + + +@app.route("/api/jobs") +def list_jobs(): + with jobs_lock: + return jsonify(list(jobs.values())) + + +@app.route("/api/jobs/") +def get_job(job_id): + with jobs_lock: + job = jobs.get(job_id) + if not job: + return jsonify({"error": "Job not found"}), 404 + return jsonify(job) + + +@app.route("/api/jobs/", methods=["DELETE"]) +def delete_job(job_id): + with jobs_lock: + if job_id in jobs: + del jobs[job_id] + return jsonify({"ok": True}) + + +@app.route("/api/files") +def list_files(): + rel_path = request.args.get("path", "") + target = DOWNLOADS_DIR / rel_path + if not target.exists(): + return jsonify([]) + + items = [] + try: + for entry in sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())): + rel = entry.relative_to(DOWNLOADS_DIR) + items.append({ + "name": entry.name, + "path": str(rel).replace("\\", "/"), + "is_dir": entry.is_dir(), + "size": entry.stat().st_size if entry.is_file() else None, + }) + except PermissionError: + pass + return jsonify(items) + + +@app.route("/api/files/download") +def download_file(): + rel_path = request.args.get("path", "") + target = DOWNLOADS_DIR / rel_path + if not target.is_file(): + return jsonify({"error": "File not found"}), 404 + return send_from_directory(target.parent, target.name, as_attachment=True) + + +@app.route("/api/files/download-folder") +def download_folder(): + rel_path = request.args.get("path", "") + target = DOWNLOADS_DIR / rel_path + if not target.is_dir(): + return jsonify({"error": "Folder not found"}), 404 + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for file in target.rglob("*"): + if file.is_file(): + zf.write(file, file.relative_to(target)) + buf.seek(0) + + folder_name = target.name or "downloads" + return app.response_class( + buf.getvalue(), + mimetype="application/zip", + headers={"Content-Disposition": f'attachment; filename="{folder_name}.zip"'}, + ) + + +@app.route("/api/files/delete", methods=["DELETE"]) +def delete_path(): + rel_path = request.args.get("path", "") + if not rel_path: + return jsonify({"error": "Cannot delete root"}), 400 + target = DOWNLOADS_DIR / rel_path + if not target.exists(): + return jsonify({"error": "Not found"}), 404 + if not str(target.resolve()).startswith(str(DOWNLOADS_DIR.resolve())): + return jsonify({"error": "Invalid path"}), 403 + try: + if target.is_dir(): + shutil.rmtree(target) + else: + target.unlink() + except Exception as e: + return jsonify({"error": str(e)}), 500 + return jsonify({"ok": True}) + + +@app.route("/api/cookies", methods=["GET"]) +def check_cookies(): + return jsonify({"exists": COOKIES_PATH.exists()}) + + +@app.route("/api/cookies", methods=["POST"]) +def upload_cookies(): + if "file" not in request.files: + return jsonify({"error": "No file uploaded"}), 400 + file = request.files["file"] + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + file.save(COOKIES_PATH) + return jsonify({"ok": True}) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000, debug=False) diff --git a/config/cookies.txt b/config/cookies.txt new file mode 100644 index 0000000..446a316 --- /dev/null +++ b/config/cookies.txt @@ -0,0 +1,22 @@ +# Netscape HTTP Cookie File +# https://curl.haxx.se/rfc/cookie_spec.html +# This is a generated file! Do not edit. + +.spotify.com TRUE / TRUE 1804370105 sp_t 5090c304-1e4d-4352-aea1-16fe4ae9207c +.spotify.com TRUE / TRUE 1772920453 sp_new 1 +.spotify.com TRUE / TRUE 1772920453 sp_landing https%3A%2F%2Fopen.spotify.com%2F +.spotify.com TRUE / TRUE 1772920453 sp_landingref https%3A%2F%2Fwww.bing.com%2F +.spotify.com TRUE / FALSE 1804370056 OptanonAlertBoxClosed 2026-03-06T21:54:16.012Z +.spotify.com TRUE / FALSE 1804370056 eupubconsent-v2 CQgpG1gQgpG1gAcABBENCVFgAP_AAEOAAAYgJnABxC4URAFAaSIyAJIgMAAUgABAQAAQAAIBAAABCBgEQAQAkAAgBACABAACGAAAIAAAAAAACAAAAEAAAIAAJADAAAAEIAAAIAAAABAAAAAAAAAgEAAAAAAAgAAEAAAAiAAAAJIAEEAAAAAAAAAAIAAAAAAAAACAAAAAAAAAAQAAQCgAAAAAAAAAAAAAARAAAAAAAAAAAAAAAAAQTOgUQAKAAsACoAFwAPAAgABIACgAGQANIAeAB6AD8AJwAXgA_ACcAFcAMoAc8A7gDvAH4AQgAiYBFgCSwFeAV8A4gB7YD9gP4Ah2BKoErALYAXYAvMBiwDGQGTAMsAgKBGYCZwARSAkAAsACoAIIAZABoADwAPwAygBzgDvAH4ARYAkoB7QEOgLYAXmAywCZxQAOABcAEgAnAB3AHbAYsAyY.f_gACHAAAAAA.IJnABxC4URAFAaSIyAJIgMAAUgABAQAAQAAIBAAABCBgEQAQAkAAgBACABAACGAAAIAAAAAAACAAAAEAAAIAAJADAAAAEIAAAIAAAADAAAAAAAAAgEAAAAAAAgAAEAABAiAAAAJIAEEAAAAAAAAAAIAAAAAAAAACAAAAAAAAAAQAAQCgAAAAAAAAAAAAAARAAAAAAAAAAAAAAAAAQAAA +.spotify.com TRUE / TRUE 1772835856 _cs_mk_ga 0.3535578042278994_1772834056026 +open.spotify.com FALSE / FALSE 0 sss 1 +.spotify.com TRUE / FALSE 1772920505 _gid GA1.2.1328783107.1772834056 +.spotify.com TRUE / TRUE 1804370106 sp_adid ff3f915f-9589-4b4c-a755-2e1431a4cd63 +.spotify.com TRUE / TRUE 1772920461 sp_m nl +.spotify.com TRUE / FALSE 1807394104 _ga_S35RN5WNT2 GS2.1.s1772834061$o1$g1$t1772834103$j18$l0$h0 +.spotify.com TRUE / TRUE 1774043704 sp_dc AQAER3Lqcs1oCTqLdVMsph_dzJBd6ywwfIuFsTycqxoUrvj77I7KgQWTdnkOFZrw51GUURjj-wFXyAziah0ljXB3YSvXcugyoaz-N2ryPxh4e78XsDeJpJtPOGs9bncokmLK6W2RLbHFeTwcSawXf6LM6JixmRrH_f35MI0chLo-GbGxle9Jqf1-FmTGYt8dSXh-ryX2575p-IhWIg +.spotify.com TRUE / TRUE 1778018105 sp_gaid 0088fca431da2488813d802a0cd113719429847133955a017c6d57 +.spotify.com TRUE / FALSE 1807394106 _ga GA1.1.139841860.1772834056 +.spotify.com TRUE / FALSE 1807394106 _ga_ZWG1NSHWD8 GS2.1.s1772834056$o1$g1$t1772834105$j11$l0$h0 +.spotify.com TRUE / FALSE 1804370105 OptanonConsent isGpcEnabled=0&datestamp=Fri+Mar+06+2026+22%3A55%3A05+GMT%2B0100+(Central+European+Standard+Time)&version=202601.2.0&browserGpcFlag=0&isIABGlobal=false&hosts=&consentId=14d166da-7df6-43b2-bc25-f18987870a49&interactionCount=1&isAnonUser=1&prevHadToken=0&landingPath=NotLandingPage&groups=s00%3A1%2Cf00%3A1%2Cm00%3A1%2Ct00%3A1%2Cf11%3A1%2CID01%3A1%2Ci00%3A1%2CV2STACK3%3A1%2CV2STACK11%3A1%2CV2STACK20%3A1%2Cm03%3A1&intType=1&crTime=1772834056251&geolocation=NL%3BZH&AwaitingReconsent=false +.spotify.com TRUE / FALSE 1807394106 _ga_BMC5VGR8YS GS2.2.s1772834056$o1$g1$t1772834105$j11$l0$h0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bef2249 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + votify: + build: . + container_name: votify-web + ports: + - "5000:5000" + environment: + - PASSWORD=test # Remove or leave empty to disable auth + volumes: + - ./downloads:/downloads + - ./config:/config + restart: unless-stopped diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..398de43 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask==3.1.0 +gunicorn==23.0.0 diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..f2df8e8 Binary files /dev/null and b/static/favicon.ico differ diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..0a07c0d --- /dev/null +++ b/templates/index.html @@ -0,0 +1,519 @@ + + + + + + Votify Web + + + + +
+

Votify Web

+
+ + + + + {% if auth_enabled %}Logout{% endif %} +
+
+ +
+ +
+
+

New Download

+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + + + +
+ + +
+
+ + +
+
+

Download Jobs

+
No jobs yet
+
+
+ + +
+
+

Downloaded Files

+ +
    No files yet
+
+
+ + +
+
+

Cookies

+ +
+ + +

+ +
+
+
+ + + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..caeb61d --- /dev/null +++ b/templates/login.html @@ -0,0 +1,44 @@ + + + + + + Votify Web - Login + + + + + + +