Initial commit

This commit is contained in:
2026-03-07 00:34:25 +01:00
commit 3e7c66d841
10 changed files with 990 additions and 0 deletions

7
.claude/settings.json Normal file
View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"WebFetch(domain:raw.githubusercontent.com)"
]
}
}

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/downloads/

34
Dockerfile Normal file
View File

@@ -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"]

349
app.py Normal file
View File

@@ -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/<job_id>")
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/<job_id>", 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)

22
config/cookies.txt Normal file
View File

@@ -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

12
docker-compose.yml Normal file
View File

@@ -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

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
flask==3.1.0
gunicorn==23.0.0

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

519
templates/index.html Normal file
View File

@@ -0,0 +1,519 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Votify Web</title>
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #121212; --surface: #1e1e1e; --surface2: #2a2a2a;
--accent: #1db954; --accent-hover: #1ed760;
--text: #ffffff; --text2: #b3b3b3;
--danger: #e74c3c; --warning: #f39c12;
--radius: 8px;
}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
.header { background: var(--surface); padding: 16px 24px; display: flex; align-items: center; gap: 16px; border-bottom: 1px solid var(--surface2); }
.header h1 { font-size: 1.4rem; font-weight: 700; }
.header h1 span { color: var(--accent); }
.tabs { display: flex; gap: 4px; margin-left: auto; }
.tab { background: none; border: none; color: var(--text2); padding: 8px 16px; cursor: pointer; border-radius: var(--radius); font-size: 0.9rem; transition: all 0.2s; }
.tab:hover { color: var(--text); background: var(--surface2); }
.tab.active { color: var(--accent); background: var(--surface2); }
.container { max-width: 960px; margin: 0 auto; padding: 24px; }
.page { display: none; }
.page.active { display: block; }
.card { background: var(--surface); border-radius: var(--radius); padding: 20px; margin-bottom: 16px; }
.card h2 { font-size: 1.1rem; margin-bottom: 16px; color: var(--text2); }
label { display: block; font-size: 0.85rem; color: var(--text2); margin-bottom: 6px; }
textarea, select, input[type="number"] {
width: 100%; background: var(--surface2); border: 1px solid #333; color: var(--text);
border-radius: var(--radius); padding: 10px 12px; font-size: 0.9rem; font-family: inherit;
transition: border-color 0.2s;
}
textarea:focus, select:focus, input:focus { outline: none; border-color: var(--accent); }
textarea { resize: vertical; min-height: 80px; }
.form-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 12px; }
.form-group { margin-bottom: 12px; }
.checkbox-row { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 12px; }
.checkbox-label { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 0.9rem; color: var(--text2); }
.checkbox-label input { accent-color: var(--accent); width: 16px; height: 16px; }
.btn { background: var(--accent); color: #000; border: none; padding: 10px 24px; border-radius: 20px; font-size: 0.95rem; font-weight: 600; cursor: pointer; transition: all 0.2s; }
.btn:hover { background: var(--accent-hover); transform: scale(1.02); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.btn-sm { padding: 6px 14px; font-size: 0.8rem; }
.btn-danger { background: var(--danger); color: #fff; }
.btn-secondary { background: var(--surface2); color: var(--text); }
.status-badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; }
.status-queued { background: #333; color: var(--text2); }
.status-running { background: #1a3a2a; color: var(--accent); }
.status-completed { background: #1a3a1a; color: #4caf50; }
.status-failed { background: #3a1a1a; color: var(--danger); }
.job-card { background: var(--surface); border-radius: var(--radius); padding: 16px; margin-bottom: 12px; }
.job-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.job-urls { font-size: 0.8rem; color: var(--text2); word-break: break-all; margin-bottom: 8px; }
.job-progress { height: 4px; background: var(--surface2); border-radius: 2px; margin-bottom: 8px; overflow: hidden; }
.job-progress-bar { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s ease; }
.job-toggle { background: none; border: none; color: var(--text2); cursor: pointer; font-size: 0.78rem; padding: 4px 0; display: flex; align-items: center; gap: 6px; font-family: inherit; transition: color 0.15s; }
.job-toggle:hover { color: var(--text); }
.job-toggle .arrow { display: inline-block; transition: transform 0.2s; font-size: 0.7rem; }
.job-toggle .arrow.open { transform: rotate(90deg); }
.job-preview { color: var(--text2); opacity: 0.6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 500px; font-family: 'Cascadia Code', 'Fira Code', monospace; }
.job-output-wrapper { max-height: 0; overflow: hidden; transition: max-height 0.3s ease; }
.job-output-wrapper.open { max-height: 300px; overflow-y: auto; }
.job-output { background: #0d0d0d; border-radius: 4px; padding: 12px; font-family: 'Cascadia Code', 'Fira Code', monospace; font-size: 0.78rem; line-height: 1.5; color: var(--text2); white-space: pre-wrap; word-break: break-all; }
.job-actions { display: flex; gap: 8px; margin-top: 8px; }
.file-list { list-style: none; }
.file-item { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: var(--radius); transition: background 0.15s; }
.file-item:hover { background: var(--surface2); }
.file-icon { font-size: 1.2rem; width: 24px; text-align: center; }
.file-name { flex: 1; font-size: 0.9rem; cursor: pointer; }
.file-size { font-size: 0.8rem; color: var(--text2); }
.file-actions { display: flex; gap: 4px; }
.file-actions .btn-icon { background: none; border: none; color: var(--text2); cursor: pointer; padding: 4px 8px; border-radius: 4px; font-size: 0.85rem; transition: all 0.15s; }
.file-actions .btn-icon:hover { background: var(--surface2); color: var(--text); }
.file-actions .btn-icon.delete:hover { color: var(--danger); }
.breadcrumb { display: flex; gap: 4px; align-items: center; margin-bottom: 16px; font-size: 0.85rem; flex-wrap: wrap; }
.breadcrumb a { color: var(--accent); text-decoration: none; }
.breadcrumb span { color: var(--text2); }
.cookie-status { display: flex; align-items: center; gap: 12px; }
.dot { width: 10px; height: 10px; border-radius: 50%; }
.dot-green { background: var(--accent); }
.dot-red { background: var(--danger); }
.empty { text-align: center; padding: 40px; color: var(--text2); }
@media (max-width: 600px) {
.form-row { grid-template-columns: 1fr; }
.header { flex-wrap: wrap; }
.tabs { margin-left: 0; width: 100%; }
}
</style>
</head>
<body>
<div class="header">
<h1 style="cursor:pointer" onclick="showPage('download')"><span>Votify</span> Web</h1>
<div class="tabs">
<button class="tab active" onclick="showPage('download')">Download</button>
<button class="tab" onclick="showPage('jobs')">Jobs</button>
<button class="tab" onclick="showPage('files')">Files</button>
<button class="tab" onclick="showPage('settings')">Settings</button>
{% if auth_enabled %}<a href="/logout" class="tab" style="text-decoration:none">Logout</a>{% endif %}
</div>
</div>
<div class="container">
<!-- DOWNLOAD PAGE -->
<div id="page-download" class="page active">
<div class="card">
<h2>New Download</h2>
<div class="form-group">
<label for="urls">Spotify URLs (one per line)</label>
<textarea id="urls" placeholder="https://open.spotify.com/track/...&#10;https://open.spotify.com/album/..."></textarea>
</div>
<div class="form-row">
<div>
<label for="audio_quality">Audio Quality</label>
<select id="audio_quality">
<option value="aac-medium">AAC 128kbps</option>
<option value="aac-high">AAC 256kbps (Premium)</option>
<option value="vorbis-low">Vorbis 96kbps</option>
<option value="vorbis-medium">Vorbis 160kbps</option>
<option value="vorbis-high">Vorbis 320kbps (Premium)</option>
</select>
</div>
<div>
<label for="output_format">Output Format</label>
<select id="output_format">
<option value="original">Keep Original</option>
<option value="mp3" selected>MP3</option>
</select>
</div>
<div>
<label for="video_format">Video Format</label>
<select id="video_format">
<option value="mp4">MP4</option>
<option value="webm">WebM</option>
</select>
</div>
<div>
<label for="cover_size">Cover Size</label>
<select id="cover_size">
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large" selected>Large</option>
<option value="extra-large">Extra Large</option>
</select>
</div>
<div>
<label for="download_mode">Download Mode</label>
<select id="download_mode">
<option value="ytdlp">yt-dlp</option>
<option value="aria2c">aria2c</option>
</select>
</div>
</div>
<div class="checkbox-row">
<label class="checkbox-label"><input type="checkbox" id="save_cover"> Save Cover Art</label>
<label class="checkbox-label"><input type="checkbox" id="save_playlist"> Save Playlist File</label>
<label class="checkbox-label"><input type="checkbox" id="overwrite"> Overwrite Existing</label>
<label class="checkbox-label"><input type="checkbox" id="download_music_videos"> Download Music Videos</label>
<label class="checkbox-label"><input type="checkbox" id="no_lrc"> No Lyrics</label>
</div>
<button class="btn" id="btn-download" onclick="startDownload()">Start Download</button>
</div>
</div>
<!-- JOBS PAGE -->
<div id="page-jobs" class="page">
<div class="card">
<h2>Download Jobs</h2>
<div id="jobs-list"><div class="empty">No jobs yet</div></div>
</div>
</div>
<!-- FILES PAGE -->
<div id="page-files" class="page">
<div class="card">
<h2>Downloaded Files</h2>
<div class="breadcrumb" id="breadcrumb"></div>
<ul class="file-list" id="file-list"><div class="empty">No files yet</div></ul>
</div>
</div>
<!-- SETTINGS PAGE -->
<div id="page-settings" class="page">
<div class="card">
<h2>Cookies</h2>
<div class="cookie-status" id="cookie-status">
<div class="dot dot-red"></div>
<span>Checking...</span>
</div>
<br>
<label>Upload cookies.txt (Netscape format)</label>
<input type="file" id="cookie-file" accept=".txt" style="margin-top:8px">
<br><br>
<button class="btn btn-sm btn-secondary" onclick="uploadCookies()">Upload</button>
</div>
</div>
</div>
<script>
let currentPath = "";
let jobPollInterval = null;
const expandedJobs = new Set();
function showPage(name) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.getElementById('page-' + name).classList.add('active');
const matchingTab = document.querySelector(`.tab[onclick="showPage('${name}')"]`);
if (matchingTab) matchingTab.classList.add('active');
if (name === 'jobs') loadJobs();
if (name === 'files') loadFiles("");
if (name === 'settings') checkCookies();
if (jobPollInterval) clearInterval(jobPollInterval);
if (name === 'jobs') jobPollInterval = setInterval(loadJobs, 3000);
}
async function startDownload() {
const btn = document.getElementById('btn-download');
btn.disabled = true;
const data = {
urls: document.getElementById('urls').value,
audio_quality: document.getElementById('audio_quality').value,
download_mode: document.getElementById('download_mode').value,
video_format: document.getElementById('video_format').value,
cover_size: document.getElementById('cover_size').value,
save_cover: document.getElementById('save_cover').checked,
save_playlist: document.getElementById('save_playlist').checked,
overwrite: document.getElementById('overwrite').checked,
download_music_videos: document.getElementById('download_music_videos').checked,
no_lrc: document.getElementById('no_lrc').checked,
output_format: document.getElementById('output_format').value,
};
saveSettings();
try {
const res = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
if (res.ok) {
document.getElementById('urls').value = '';
document.querySelector('[onclick="showPage(\'jobs\')"]').click();
} else {
alert(result.error || 'Failed to start download');
}
} catch (e) {
alert('Error: ' + e.message);
}
btn.disabled = false;
}
function parseProgress(output) {
if (!output || output.length === 0) return null;
let current = 0, total = 0, dlPct = 0;
for (const line of output) {
const tm = line.match(/Track (\d+)\/(\d+)/);
if (tm) { current = parseInt(tm[1]); total = parseInt(tm[2]); }
const dm = line.match(/\[download\]\s+([\d.]+)%/);
if (dm) dlPct = parseFloat(dm[1]);
}
if (total <= 0) return null;
// Combine track progress with current download percentage
const pct = ((current - 1 + dlPct / 100) / total) * 100;
return { current, total, pct: Math.min(Math.round(pct), 100) };
}
function toggleJobLog(jobId, currentlyOpen) {
if (currentlyOpen) expandedJobs.delete(jobId);
else expandedJobs.add(jobId);
loadJobs();
}
let loadJobsInFlight = false;
async function loadJobs() {
if (loadJobsInFlight) return;
loadJobsInFlight = true;
try {
const res = await fetch('/api/jobs');
const jobs = await res.json();
const container = document.getElementById('jobs-list');
if (jobs.length === 0) {
container.innerHTML = '<div class="empty">No jobs yet</div>';
return;
}
const html = jobs.slice().reverse().map(job => {
const hasOutput = job.output && job.output.length > 0;
const isOpen = expandedJobs.has(job.id);
const lastLine = hasOutput ? job.output[job.output.length - 1] : '';
const progress = job.status === 'running' ? parseProgress(job.output) : null;
let progressHtml = '';
if (progress) {
progressHtml = `<div class="job-progress"><div class="job-progress-bar" style="width:${progress.pct}%"></div></div>`;
}
let logHtml = '';
if (hasOutput) {
logHtml = `<button class="job-toggle" onclick="toggleJobLog('${job.id}', ${isOpen})">
<span class="arrow ${isOpen ? 'open' : ''}">&#9654;</span>
${isOpen ? 'Hide Log' : 'Show Log'}
${!isOpen && lastLine ? `<span class="job-preview">${escapeHtml(lastLine)}</span>` : ''}
</button>
<div class="job-output-wrapper ${isOpen ? 'open' : ''}">
<div class="job-output">${escapeHtml(job.output.join('\n'))}</div>
</div>`;
}
return `<div class="job-card">
<div class="job-header">
<strong>Job ${job.id}</strong>
<span class="status-badge status-${job.status}">${job.status}</span>
</div>
<div class="job-urls">${job.urls.join(', ')}</div>
${progressHtml}
${logHtml}
<div class="job-actions">
${job.status !== 'running' ? `<button class="btn btn-sm btn-danger" onclick="deleteJob('${job.id}')">Remove</button>` : ''}
</div>
</div>`;
}).join('');
container.innerHTML = html;
// Auto-scroll open outputs
container.querySelectorAll('.job-output-wrapper.open .job-output').forEach(el => {
el.scrollTop = el.scrollHeight;
});
} catch (e) {
console.error('Failed to load jobs', e);
} finally {
loadJobsInFlight = false;
}
}
async function deleteJob(id) {
expandedJobs.delete(id);
await fetch('/api/jobs/' + id, { method: 'DELETE' });
loadJobs();
}
async function loadFiles(path) {
currentPath = path;
try {
const res = await fetch('/api/files?path=' + encodeURIComponent(path));
const files = await res.json();
// Breadcrumb
const bc = document.getElementById('breadcrumb');
const parts = path ? path.split('/') : [];
let bcHtml = '<a href="#" onclick="loadFiles(\'\');return false">Root</a>';
let accumulated = '';
for (const part of parts) {
accumulated += (accumulated ? '/' : '') + part;
bcHtml += ` <span>/</span> <a href="#" onclick="loadFiles(this.dataset.p);return false" data-p="${escapeAttr(accumulated)}">${escapeHtml(part)}</a>`;
}
bc.innerHTML = bcHtml;
// File list
const list = document.getElementById('file-list');
if (files.length === 0) {
list.innerHTML = '<div class="empty">No files yet</div>';
return;
}
list.innerHTML = files.map(f => {
const safePath = escapeAttr(f.path);
const encodedPath = encodeURIComponent(f.path);
if (f.is_dir) {
return `<li class="file-item">
<span class="file-icon">&#128193;</span>
<span class="file-name" onclick="loadFiles(this.dataset.p)" data-p="${safePath}">${escapeHtml(f.name)}</span>
<div class="file-actions">
<button class="btn-icon" onclick="window.location='/api/files/download-folder?path=${encodedPath}'" title="Download as ZIP">&#11015;</button>
<button class="btn-icon delete" onclick="deletePath(this.dataset.p, true)" data-p="${safePath}" title="Delete folder">&#128465;</button>
</div>
</li>`;
} else {
const size = formatSize(f.size);
return `<li class="file-item">
<span class="file-icon">&#127925;</span>
<span class="file-name" onclick="window.location='/api/files/download?path=${encodedPath}'">${escapeHtml(f.name)}</span>
<span class="file-size">${size}</span>
<div class="file-actions">
<button class="btn-icon delete" onclick="deletePath(this.dataset.p, false)" data-p="${safePath}" title="Delete file">&#128465;</button>
</div>
</li>`;
}
}).join('');
} catch (e) {
console.error('Failed to load files', e);
}
}
async function deletePath(path, isDir) {
const kind = isDir ? 'folder and all its contents' : 'file';
if (!confirm(`Are you sure you want to delete this ${kind}?\n\n${path}`)) return;
try {
const res = await fetch('/api/files/delete?path=' + encodeURIComponent(path), { method: 'DELETE' });
if (res.ok) {
loadFiles(currentPath);
} else {
let msg = 'Delete failed';
try { const data = await res.json(); msg = data.error || msg; } catch (_) {}
alert(msg);
}
} catch (e) {
alert('Error: ' + e.message);
}
}
async function checkCookies() {
try {
const res = await fetch('/api/cookies');
const data = await res.json();
const el = document.getElementById('cookie-status');
if (data.exists) {
el.innerHTML = '<div class="dot dot-green"></div><span>cookies.txt found</span>';
} else {
el.innerHTML = '<div class="dot dot-red"></div><span>cookies.txt not found - upload or mount to /config/cookies.txt</span>';
}
} catch (e) {
console.error(e);
}
}
async function uploadCookies() {
const file = document.getElementById('cookie-file').files[0];
if (!file) { alert('Select a file first'); return; }
const form = new FormData();
form.append('file', file);
try {
const res = await fetch('/api/cookies', { method: 'POST', body: form });
if (res.ok) {
alert('Cookies uploaded successfully');
checkCookies();
} else {
const data = await res.json();
alert(data.error || 'Upload failed');
}
} catch (e) {
alert('Error: ' + e.message);
}
}
function formatSize(bytes) {
if (!bytes) return '';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
return bytes.toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function escapeAttr(str) {
return str.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// Remember settings
const SETTINGS_KEY = 'votify-settings';
const SETTING_IDS = ['audio_quality', 'output_format', 'video_format', 'cover_size', 'download_mode', 'save_cover', 'save_playlist', 'overwrite', 'download_music_videos', 'no_lrc'];
function saveSettings() {
const settings = {};
for (const id of SETTING_IDS) {
const el = document.getElementById(id);
settings[id] = el.type === 'checkbox' ? el.checked : el.value;
}
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
}
function loadSettings() {
try {
const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY));
if (!saved) return;
for (const id of SETTING_IDS) {
if (!(id in saved)) continue;
const el = document.getElementById(id);
if (el.type === 'checkbox') el.checked = saved[id];
else el.value = saved[id];
}
} catch (_) {}
}
loadSettings();
checkCookies();
</script>
</body>
</html>

44
templates/login.html Normal file
View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Votify Web - Login</title>
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #121212; --surface: #1e1e1e; --surface2: #2a2a2a;
--accent: #1db954; --accent-hover: #1ed760;
--text: #ffffff; --text2: #b3b3b3;
--danger: #e74c3c; --radius: 8px;
}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.login-card { background: var(--surface); border-radius: var(--radius); padding: 40px; width: 100%; max-width: 380px; }
.login-card h1 { font-size: 1.4rem; margin-bottom: 8px; }
.login-card h1 span { color: var(--accent); }
.login-card p { color: var(--text2); font-size: 0.85rem; margin-bottom: 24px; }
.login-card input[type="password"] {
width: 100%; background: var(--surface2); border: 1px solid #333; color: var(--text);
border-radius: var(--radius); padding: 12px 14px; font-size: 0.95rem; font-family: inherit;
margin-bottom: 16px; transition: border-color 0.2s;
}
.login-card input:focus { outline: none; border-color: var(--accent); }
.login-card button {
width: 100%; background: var(--accent); color: #000; border: none; padding: 12px;
border-radius: 20px; font-size: 0.95rem; font-weight: 600; cursor: pointer; transition: all 0.2s;
}
.login-card button:hover { background: var(--accent-hover); }
.error { color: var(--danger); font-size: 0.85rem; margin-bottom: 12px; }
</style>
</head>
<body>
<form class="login-card" method="POST" action="/login">
<h1><span>Votify</span> Web</h1>
<p>Enter password to continue</p>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
<input type="password" name="password" placeholder="Password" autofocus required>
<button type="submit">Sign In</button>
</form>
</body>
</html>