Initial commit
This commit is contained in:
7
.claude/settings.json
Normal file
7
.claude/settings.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebFetch(domain:raw.githubusercontent.com)"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/downloads/
|
||||
34
Dockerfile
Normal file
34
Dockerfile
Normal 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
349
app.py
Normal 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
22
config/cookies.txt
Normal 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
12
docker-compose.yml
Normal 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
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
flask==3.1.0
|
||||
gunicorn==23.0.0
|
||||
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
519
templates/index.html
Normal file
519
templates/index.html
Normal 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/... 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' : ''}">▶</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">📁</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">⬇</button>
|
||||
<button class="btn-icon delete" onclick="deletePath(this.dataset.p, true)" data-p="${safePath}" title="Delete folder">🗑</button>
|
||||
</div>
|
||||
</li>`;
|
||||
} else {
|
||||
const size = formatSize(f.size);
|
||||
return `<li class="file-item">
|
||||
<span class="file-icon">🎵</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">🗑</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,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
// 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
44
templates/login.html
Normal 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>
|
||||
Reference in New Issue
Block a user