feat: implemented unified downloading

This commit is contained in:
2026-03-08 22:39:22 +01:00
parent f4dee850f3
commit 4609112d07
8 changed files with 465 additions and 49 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
/config/ /config/
/downloads/ /downloads/
.env .env
__pycache__/

View File

@@ -23,6 +23,7 @@ COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt RUN pip install --no-cache-dir -r /app/requirements.txt
COPY app.py /app/app.py COPY app.py /app/app.py
COPY utils.py /app/utils.py
COPY templates /app/templates COPY templates /app/templates
COPY static /app/static COPY static /app/static
COPY monochrome /app/monochrome COPY monochrome /app/monochrome

193
app.py
View File

@@ -1,4 +1,5 @@
import io import io
import json
import os import os
import shutil import shutil
import subprocess import subprocess
@@ -8,6 +9,8 @@ import uuid
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from utils import rename_from_metadata, sanitize_filename, cleanup_empty_dirs
from flask import ( from flask import (
Flask, Flask,
jsonify, jsonify,
@@ -88,15 +91,67 @@ def convert_to_mp3(job_id: str, before: set[Path]):
log("[mp3] Conversion complete.") log("[mp3] Conversion complete.")
def run_download(job_id: str, urls: list[str], options: dict): AUDIO_EXTS = {".m4a", ".ogg", ".opus", ".mp3", ".flac"}
def post_process_votify_files(target_dir, job_id):
"""Flatten subdirs, rename files from metadata, wrap single tracks."""
target_dir = Path(target_dir)
def log(msg):
with jobs_lock:
jobs[job_id]["output"] = jobs[job_id].get("output", [])[-500:] + [msg]
# 1. Move any files in subdirectories up to target_dir
for ext in AUDIO_EXTS:
for f in list(target_dir.rglob(f"*{ext}")):
if f.parent != target_dir:
dest = target_dir / f.name
counter = 2
while dest.exists():
dest = target_dir / f"{f.stem} ({counter}){f.suffix}"
counter += 1
shutil.move(str(f), str(dest))
# 2. Rename from metadata
renamed = []
for ext in AUDIO_EXTS:
for f in list(target_dir.glob(f"*{ext}")):
new_path = rename_from_metadata(f)
renamed.append(new_path)
if new_path != f:
log(f"[post] Renamed: {new_path.name}")
# 3. Clean up empty subdirs
cleanup_empty_dirs(target_dir)
# 4. Single-track wrapping (only if downloading to root downloads dir)
if len(renamed) == 1 and target_dir == DOWNLOADS_DIR:
f = renamed[0]
folder_name = sanitize_filename(f.stem)
wrapper = target_dir / folder_name
wrapper.mkdir(exist_ok=True)
shutil.move(str(f), str(wrapper / f.name))
log(f"[post] Wrapped single track in folder: {folder_name}")
return renamed
def run_download(job_id: str, urls: list[str], options: dict, output_path: str = None):
cmd = ["votify"] cmd = ["votify"]
cmd.extend(["--cookies-path", str(COOKIES_PATH)]) cmd.extend(["--cookies-path", str(COOKIES_PATH)])
cmd.extend(["--output-path", str(DOWNLOADS_DIR)]) cmd.extend(["--output-path", output_path or str(DOWNLOADS_DIR)])
cmd.extend(["--temp-path", str(TEMP_DIR)]) cmd.extend(["--temp-path", str(TEMP_DIR)])
if WVD_PATH.exists(): if WVD_PATH.exists():
cmd.extend(["--wvd-path", str(WVD_PATH)]) cmd.extend(["--wvd-path", str(WVD_PATH)])
# Flatten folder structure — no nested Artist/Album subdirectories
cmd.extend(["--template-folder-album", "."])
cmd.extend(["--template-folder-compilation", "."])
cmd.extend(["--template-folder-episode", "."])
cmd.extend(["--template-folder-music-video", "."])
quality = options.get("audio_quality", "aac-medium") quality = options.get("audio_quality", "aac-medium")
if quality: if quality:
cmd.extend(["--audio-quality", quality]) cmd.extend(["--audio-quality", quality])
@@ -174,8 +229,11 @@ def run_download(job_id: str, urls: list[str], options: dict):
with jobs_lock: with jobs_lock:
jobs[job_id]["output"] = jobs[job_id].get("output", []) + ["[cancelled] Job was cancelled by user."] jobs[job_id]["output"] = jobs[job_id].get("output", []) + ["[cancelled] Job was cancelled by user."]
else: else:
if process.returncode == 0 and want_mp3: if process.returncode == 0:
convert_to_mp3(job_id, files_before) target = Path(output_path) if output_path else DOWNLOADS_DIR
post_process_votify_files(target, job_id)
if want_mp3:
convert_to_mp3(job_id, files_before)
with jobs_lock: with jobs_lock:
jobs[job_id]["status"] = "completed" if process.returncode == 0 else "failed" jobs[job_id]["status"] = "completed" if process.returncode == 0 else "failed"
jobs[job_id]["return_code"] = process.returncode jobs[job_id]["return_code"] = process.returncode
@@ -273,7 +331,7 @@ def run_monochrome_download(job_id: str, url: str, quality: str):
try: try:
from monochrome.api import download_spotify_url from monochrome.api import download_spotify_url
success, total = download_spotify_url( success, total, fail_info = download_spotify_url(
spotify_url=url, spotify_url=url,
quality=quality, quality=quality,
output_dir=str(DOWNLOADS_DIR), output_dir=str(DOWNLOADS_DIR),
@@ -323,6 +381,131 @@ def start_monochrome_download():
return jsonify({"job_id": job_id}) return jsonify({"job_id": job_id})
# --- Unified download (Monochrome → Votify fallback) ---
SETTINGS_PATH = CONFIG_DIR / "settings.json"
def load_settings():
if SETTINGS_PATH.exists():
try:
return json.loads(SETTINGS_PATH.read_text())
except (json.JSONDecodeError, OSError):
pass
return {"fallback_quality": "aac-medium"}
def run_unified_download(job_id: str, url: str):
with jobs_lock:
jobs[job_id]["status"] = "running"
def log(msg):
with jobs_lock:
jobs[job_id]["output"] = jobs[job_id].get("output", [])[-500:] + [msg]
def is_cancelled():
with jobs_lock:
return jobs[job_id]["status"] == "cancelled"
try:
from monochrome.api import download_spotify_url
success, total, fail_info = download_spotify_url(
spotify_url=url,
quality="MP3_320",
output_dir=str(DOWNLOADS_DIR),
log=log,
cancel_check=is_cancelled,
)
with jobs_lock:
if jobs[job_id]["status"] != "cancelled":
jobs[job_id]["status"] = "completed" if success > 0 else "failed"
jobs[job_id]["return_code"] = 0 if success > 0 else 1
except Exception as e:
with jobs_lock:
jobs[job_id]["status"] = "failed"
jobs[job_id]["output"] = jobs[job_id].get("output", []) + [f"[error] {e}"]
jobs[job_id]["return_code"] = 1
return
# Check if we should spawn Votify fallback
with jobs_lock:
cancelled = jobs[job_id]["status"] == "cancelled"
failed_urls = fail_info.get("failed_urls", [])
if cancelled or not failed_urls:
return
# Spawn Votify fallback job for failed tracks
settings = load_settings()
fallback_quality = settings.get("fallback_quality", "aac-medium")
subfolder = fail_info.get("subfolder")
output_path = str(DOWNLOADS_DIR / subfolder) if subfolder else str(DOWNLOADS_DIR)
votify_job_id = str(uuid.uuid4())[:8]
with jobs_lock:
jobs[votify_job_id] = {
"id": votify_job_id,
"urls": failed_urls,
"options": {"audio_quality": fallback_quality, "source": "votify-fallback"},
"status": "queued",
"output": [],
"created_at": time.time(),
}
log(f"[monochrome] {len(failed_urls)} track(s) failed — starting Votify fallback (job {votify_job_id})")
run_download(votify_job_id, failed_urls, {
"audio_quality": fallback_quality,
"output_format": "mp3",
}, output_path=output_path)
@app.route("/api/unified/download", methods=["POST"])
def start_unified_download():
data = request.json
url = data.get("url", "").strip()
if not url:
return jsonify({"error": "No URL provided"}), 400
job_id = str(uuid.uuid4())[:8]
with jobs_lock:
jobs[job_id] = {
"id": job_id,
"urls": [url],
"options": {"source": "unified"},
"status": "queued",
"output": [],
"created_at": time.time(),
}
thread = threading.Thread(
target=run_unified_download, args=(job_id, url), daemon=True
)
thread.start()
return jsonify({"job_id": job_id})
@app.route("/api/settings", methods=["GET"])
def get_settings():
return jsonify(load_settings())
@app.route("/api/settings", methods=["POST"])
def save_settings():
data = request.json
allowed_qualities = ["aac-medium", "aac-high"]
quality = data.get("fallback_quality", "aac-medium")
if quality not in allowed_qualities:
quality = "aac-medium"
settings = {"fallback_quality": quality}
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
SETTINGS_PATH.write_text(json.dumps(settings))
return jsonify({"ok": True})
def job_to_dict(job): def job_to_dict(job):
return {k: v for k, v in job.items() if k != "process"} return {k: v for k, v in job.items() if k != "process"}

View File

@@ -6,7 +6,9 @@ for use from app.py background threads.
""" """
import os import os
import shutil
import time import time
from pathlib import Path
from monochrome import discover_instances from monochrome import discover_instances
from monochrome.spotify_to_ids import ( from monochrome.spotify_to_ids import (
@@ -16,6 +18,8 @@ from monochrome.spotify_to_ids import (
extract_tracks, extract_tracks,
search_monochrome, search_monochrome,
find_best_match, find_best_match,
similarity,
normalize,
) )
from monochrome.download import ( from monochrome.download import (
get_stream_url_tidal, get_stream_url_tidal,
@@ -23,9 +27,59 @@ from monochrome.download import (
download_file, download_file,
fetch_cover_art, fetch_cover_art,
embed_metadata, embed_metadata,
sanitize_filename,
convert_to_mp3, convert_to_mp3,
) )
from utils import sanitize_filename, rename_from_metadata
def verify_match(spotify_track, tidal_info, search_match, log):
"""Cross-reference Spotify metadata against Tidal track data.
Falls back to search_match fields when tidal_info has empty values.
Returns True if the track is a valid match."""
sp_title = spotify_track.get("title", "")
# Prefer tidal_info title, fall back to search match title
ti_title = tidal_info.get("title", "") if tidal_info else ""
if not ti_title:
ti_title = search_match.get("title", "") if search_match else ""
if sp_title and ti_title:
title_sim = similarity(sp_title, ti_title)
if title_sim < 0.5:
log(f"[monochrome] Title mismatch: '{sp_title}' vs '{ti_title}' (sim={title_sim:.2f})")
return False
sp_artist = spotify_track.get("artist", "")
# Prefer tidal_info artist, fall back to search match artist
ti_artist = ""
for src in (tidal_info, search_match):
if not src:
continue
artist_obj = src.get("artist", {})
ti_artist = artist_obj.get("name", "") if isinstance(artist_obj, dict) else str(artist_obj)
if ti_artist:
break
if sp_artist and ti_artist:
artist_sim = similarity(sp_artist, ti_artist)
# For multi-artist tracks, Tidal may only return the primary artist.
# Check if Tidal artist tokens are a subset of Spotify artist tokens.
sp_tokens = set(normalize(sp_artist).split())
ti_tokens = set(normalize(ti_artist).split())
is_subset = ti_tokens and ti_tokens.issubset(sp_tokens)
if artist_sim < 0.4 and not is_subset:
log(f"[monochrome] Artist mismatch: '{sp_artist}' vs '{ti_artist}' (sim={artist_sim:.2f})")
return False
# Duration check (strongest signal)
sp_dur = spotify_track.get("duration") # milliseconds
ti_dur = (tidal_info or {}).get("duration") # seconds
if sp_dur and ti_dur:
sp_seconds = sp_dur / 1000
if abs(sp_seconds - ti_dur) > 5:
log(f"[monochrome] Duration mismatch: {sp_seconds:.0f}s vs {ti_dur}s")
return False
return True
def download_spotify_url(spotify_url, quality, output_dir, log=None, cancel_check=None): def download_spotify_url(spotify_url, quality, output_dir, log=None, cancel_check=None):
@@ -39,7 +93,8 @@ def download_spotify_url(spotify_url, quality, output_dir, log=None, cancel_chec
cancel_check: Callback () -> bool, returns True if cancelled cancel_check: Callback () -> bool, returns True if cancelled
Returns: Returns:
(success_count, total_tracks) (success_count, total_tracks, fail_info) where fail_info is:
{"failed_urls": [...], "subfolder": "name" or None}
""" """
if log is None: if log is None:
log = print log = print
@@ -54,10 +109,12 @@ def download_spotify_url(spotify_url, quality, output_dir, log=None, cancel_chec
instances = discover_instances(log=log) instances = discover_instances(log=log)
# Step 2: Parse Spotify URL # Step 2: Parse Spotify URL
fail_info = {"failed_urls": [], "subfolder": None}
sp_type, sp_id = parse_spotify_url(spotify_url) sp_type, sp_id = parse_spotify_url(spotify_url)
if not sp_type: if not sp_type:
log(f"[monochrome] Invalid Spotify URL: {spotify_url}") log(f"[monochrome] Invalid Spotify URL: {spotify_url}")
return 0, 0 return 0, 0, fail_info
# Step 3: Fetch track list from Spotify # Step 3: Fetch track list from Spotify
log(f"[monochrome] Fetching Spotify {sp_type}: {sp_id}") log(f"[monochrome] Fetching Spotify {sp_type}: {sp_id}")
@@ -66,25 +123,29 @@ def download_spotify_url(spotify_url, quality, output_dir, log=None, cancel_chec
if not tracks: if not tracks:
log(f"[monochrome] Could not extract tracks from {spotify_url}") log(f"[monochrome] Could not extract tracks from {spotify_url}")
return 0, 0 return 0, 0, fail_info
total = len(tracks) total = len(tracks)
log(f"[monochrome] Found {total} track(s) on Spotify") log(f"[monochrome] Found {total} track(s) on Spotify")
# Create subfolder for albums/playlists # Create subfolder for albums/playlists
dl_dir = output_dir dl_dir = output_dir
subfolder_name = None
if total > 1: if total > 1:
collection_name = extract_collection_name(embed_data, sp_type) collection_name = extract_collection_name(embed_data, sp_type)
if collection_name: if collection_name:
folder_name = sanitize_filename(collection_name) subfolder_name = sanitize_filename(collection_name)
else: else:
folder_name = sanitize_filename(f"{sp_type}_{sp_id}") subfolder_name = sanitize_filename(f"{sp_type}_{sp_id}")
dl_dir = os.path.join(output_dir, folder_name) dl_dir = os.path.join(output_dir, subfolder_name)
os.makedirs(dl_dir, exist_ok=True) os.makedirs(dl_dir, exist_ok=True)
log(f"[monochrome] Saving to folder: {folder_name}") log(f"[monochrome] Saving to folder: {subfolder_name}")
fail_info["subfolder"] = subfolder_name
success = 0 success = 0
failed_tracks = [] failed_tracks = []
failed_urls = []
last_final_path = None
for i, track in enumerate(tracks): for i, track in enumerate(tracks):
if cancel_check(): if cancel_check():
@@ -101,6 +162,9 @@ def download_spotify_url(spotify_url, quality, output_dir, log=None, cancel_chec
if not match: if not match:
log(f"[monochrome] No match found for: {query}") log(f"[monochrome] No match found for: {query}")
failed_tracks.append(query) failed_tracks.append(query)
sp_id = track.get("sp_id")
if sp_id:
failed_urls.append(f"https://open.spotify.com/track/{sp_id}")
if i < total - 1: if i < total - 1:
time.sleep(0.5) time.sleep(0.5)
continue continue
@@ -121,6 +185,9 @@ def download_spotify_url(spotify_url, quality, output_dir, log=None, cancel_chec
if not stream_url: if not stream_url:
log(f"[monochrome] Failed to get stream for: {query}") log(f"[monochrome] Failed to get stream for: {query}")
failed_tracks.append(query) failed_tracks.append(query)
sp_id = track.get("sp_id")
if sp_id:
failed_urls.append(f"https://open.spotify.com/track/{sp_id}")
if i < total - 1: if i < total - 1:
time.sleep(0.5) time.sleep(0.5)
continue continue
@@ -133,7 +200,17 @@ def download_spotify_url(spotify_url, quality, output_dir, log=None, cancel_chec
if k not in info or not info[k]: if k not in info or not info[k]:
info[k] = v info[k] = v
# Determine file extension and path # Verify match against Spotify metadata
if not verify_match(track, track_data, match, log):
failed_tracks.append(query)
sp_id = track.get("sp_id")
if sp_id:
failed_urls.append(f"https://open.spotify.com/track/{sp_id}")
if i < total - 1:
time.sleep(0.5)
continue
# Determine file extension and path (temp name until metadata rename)
if want_mp3: if want_mp3:
ext = ".flac" ext = ".flac"
elif api_quality in ("HIGH", "LOW"): elif api_quality in ("HIGH", "LOW"):
@@ -150,6 +227,9 @@ def download_spotify_url(spotify_url, quality, output_dir, log=None, cancel_chec
except Exception as e: except Exception as e:
log(f"[monochrome] Download failed for {query}: {e}") log(f"[monochrome] Download failed for {query}: {e}")
failed_tracks.append(query) failed_tracks.append(query)
sp_id = track.get("sp_id")
if sp_id:
failed_urls.append(f"https://open.spotify.com/track/{sp_id}")
if i < total - 1: if i < total - 1:
time.sleep(0.5) time.sleep(0.5)
continue continue
@@ -160,17 +240,32 @@ def download_spotify_url(spotify_url, quality, output_dir, log=None, cancel_chec
# Convert to MP3 if requested # Convert to MP3 if requested
if want_mp3: if want_mp3:
mp3_filename = sanitize_filename(f"{m_artist} - {m_title}.mp3") mp3_path = os.path.join(dl_dir, sanitize_filename(f"{m_artist} - {m_title}.mp3"))
mp3_path = os.path.join(dl_dir, mp3_filename)
if convert_to_mp3(file_path, mp3_path, log=log): if convert_to_mp3(file_path, mp3_path, log=log):
embed_metadata(mp3_path, info, cover_data, log=log) embed_metadata(mp3_path, info, cover_data, log=log)
file_path = mp3_path
# Rename from embedded metadata for consistent naming
final_path = rename_from_metadata(file_path)
log(f"[monochrome] Saved: {final_path.name}")
success += 1 success += 1
last_final_path = final_path
# Rate limit between tracks # Rate limit between tracks
if i < total - 1: if i < total - 1:
time.sleep(0.5) time.sleep(0.5)
# Wrap single tracks in a Title - Artist folder
if total == 1 and success == 1 and last_final_path:
folder_name = sanitize_filename(last_final_path.stem)
wrapper_dir = os.path.join(output_dir, folder_name)
os.makedirs(wrapper_dir, exist_ok=True)
shutil.move(str(last_final_path), os.path.join(wrapper_dir, last_final_path.name))
subfolder_name = folder_name
fail_info["subfolder"] = subfolder_name
log(f"[monochrome] Saved to folder: {folder_name}")
# Summary # Summary
if failed_tracks: if failed_tracks:
log(f"[monochrome] Failed tracks ({len(failed_tracks)}):") log(f"[monochrome] Failed tracks ({len(failed_tracks)}):")
@@ -178,4 +273,5 @@ def download_spotify_url(spotify_url, quality, output_dir, log=None, cancel_chec
log(f"[monochrome] - {ft}") log(f"[monochrome] - {ft}")
log(f"[monochrome] Complete: {success}/{total} tracks downloaded") log(f"[monochrome] Complete: {success}/{total} tracks downloaded")
return success, total fail_info["failed_urls"] = failed_urls
return success, total, fail_info

View File

@@ -26,6 +26,7 @@ import subprocess
import sys import sys
from monochrome import fetch, fetch_json, discover_instances, SSL_CTX, QOBUZ_API from monochrome import fetch, fetch_json, discover_instances, SSL_CTX, QOBUZ_API
from utils import sanitize_filename
def extract_stream_url_from_manifest(manifest_b64, log=None): def extract_stream_url_from_manifest(manifest_b64, log=None):
@@ -360,9 +361,6 @@ def embed_metadata(file_path, info, cover_data=None, log=None):
log(f"[!] Failed to embed metadata: {e}") log(f"[!] Failed to embed metadata: {e}")
def sanitize_filename(name):
return re.sub(r'[<>:"/\\|?*]', '_', name)
def convert_to_mp3(input_path, output_path, bitrate="320k", log=None): def convert_to_mp3(input_path, output_path, bitrate="320k", log=None):
"""Convert audio file to MP3 using ffmpeg.""" """Convert audio file to MP3 using ffmpeg."""

View File

@@ -102,7 +102,7 @@ def extract_tracks(embed_data, sp_type, sp_id):
else: else:
artist = entity.get("subtitle", "") artist = entity.get("subtitle", "")
if title: if title:
return [{"title": title, "artist": artist}] return [{"title": title, "artist": artist, "sp_id": sp_id, "duration": entity.get("duration")}]
elif sp_type in ("album", "playlist"): elif sp_type in ("album", "playlist"):
track_list = entity.get("trackList", []) track_list = entity.get("trackList", [])
@@ -111,8 +111,15 @@ def extract_tracks(embed_data, sp_type, sp_id):
for t in track_list: for t in track_list:
title = t.get("title", "") title = t.get("title", "")
artist = t.get("subtitle", "") artist = t.get("subtitle", "")
# Prefer uri (contains real Spotify track ID) over uid (internal hex UID)
track_uid = None
uri = t.get("uri", "")
if uri.startswith("spotify:track:"):
track_uid = uri.split(":")[-1]
if not track_uid:
track_uid = t.get("uid")
if title: if title:
tracks.append({"title": title, "artist": artist}) tracks.append({"title": title, "artist": artist, "sp_id": track_uid, "duration": t.get("duration")})
if tracks: if tracks:
return tracks return tracks
except (KeyError, TypeError, IndexError): except (KeyError, TypeError, IndexError):
@@ -123,7 +130,7 @@ def extract_tracks(embed_data, sp_type, sp_id):
oembed_title = fetch_spotify_oembed(sp_type, sp_id) oembed_title = fetch_spotify_oembed(sp_type, sp_id)
if oembed_title: if oembed_title:
print(f'[*] Using oEmbed fallback: "{oembed_title}"', file=sys.stderr) print(f'[*] Using oEmbed fallback: "{oembed_title}"', file=sys.stderr)
return [{"title": oembed_title, "artist": ""}] return [{"title": oembed_title, "artist": "", "sp_id": sp_id, "duration": None}]
return [] return []

View File

@@ -145,7 +145,7 @@
.card { padding: 16px; } .card { padding: 16px; }
body { padding-bottom: 64px; } body { padding-bottom: 64px; }
textarea, select, input { font-size: 1rem; } textarea, select, input { font-size: 1rem; }
#btn-download, #btn-monochrome { width: 100%; border-radius: var(--radius); } #btn-download, #btn-monochrome, #btn-unified { width: 100%; border-radius: var(--radius); }
.job-preview { max-width: calc(100vw - 120px); } .job-preview { max-width: calc(100vw - 120px); }
#toast { left: 16px; right: 16px; bottom: 72px; max-width: none; } #toast { left: 16px; right: 16px; bottom: 72px; max-width: none; }
#bottom-nav { display: flex; } #bottom-nav { display: flex; }
@@ -154,10 +154,9 @@
</head> </head>
<body> <body>
<div class="header"> <div class="header">
<h1 style="cursor:pointer" onclick="showPage('download')"><span>Votify</span> Web</h1> <h1 style="cursor:pointer" onclick="showPage('unified')"><span>Votify</span> Web</h1>
<div class="tabs"> <div class="tabs">
<button class="tab active" onclick="showPage('download')">Votify</button> <button class="tab active" onclick="showPage('unified')">Download</button>
<button class="tab" onclick="showPage('monochrome')">Monochrome</button>
<button class="tab" onclick="showPage('jobs')">Jobs</button> <button class="tab" onclick="showPage('jobs')">Jobs</button>
<button class="tab" onclick="showPage('files')">Files</button> <button class="tab" onclick="showPage('files')">Files</button>
<button class="tab" onclick="showPage('settings')">Settings</button> <button class="tab" onclick="showPage('settings')">Settings</button>
@@ -165,9 +164,26 @@
</div> </div>
<div class="container"> <div class="container">
<!-- DOWNLOAD PAGE --> <!-- UNIFIED DOWNLOAD PAGE -->
<div id="page-download" class="page active"> <div id="page-unified" class="page active">
<div class="card"> <div class="card">
<h2>Download</h2>
<div class="form-group">
<label for="unified-urls">Spotify URLs (one per line)</label>
<textarea id="unified-urls" placeholder="https://open.spotify.com/track/...&#10;https://open.spotify.com/album/..."></textarea>
</div>
<button class="btn" id="btn-unified" onclick="startUnifiedDownload()">Download</button>
<div style="margin-top: 16px; display: flex; gap: 8px;">
<button class="btn btn-sm btn-secondary" onclick="showPage('download')">Votify (Spotify)</button>
<button class="btn btn-sm btn-secondary" onclick="showPage('monochrome')">Monochrome (Tidal)</button>
</div>
</div>
</div>
<!-- VOTIFY PAGE (hidden, accessible via button) -->
<div id="page-download" class="page">
<div class="card">
<button class="btn btn-sm btn-secondary" onclick="showPage('unified')" style="margin-bottom:12px">&larr; Back</button>
<h2>Votify Download</h2> <h2>Votify Download</h2>
<div class="form-group"> <div class="form-group">
<label for="urls">Spotify URLs (one per line)</label> <label for="urls">Spotify URLs (one per line)</label>
@@ -239,6 +255,7 @@
<!-- MONOCHROME PAGE --> <!-- MONOCHROME PAGE -->
<div id="page-monochrome" class="page"> <div id="page-monochrome" class="page">
<div class="card"> <div class="card">
<button class="btn btn-sm btn-secondary" onclick="showPage('unified')" style="margin-bottom:12px">&larr; Back</button>
<h2>Monochrome Download</h2> <h2>Monochrome Download</h2>
<div class="form-group"> <div class="form-group">
<label for="mono-url">Spotify URL (track, album, or playlist)</label> <label for="mono-url">Spotify URL (track, album, or playlist)</label>
@@ -285,6 +302,14 @@
<a href="/logout" class="btn btn-danger btn-sm" style="text-decoration:none">Logout</a> <a href="/logout" class="btn btn-danger btn-sm" style="text-decoration:none">Logout</a>
</div> </div>
{% endif %} {% endif %}
<div class="card">
<h2>Fallback Quality</h2>
<p style="font-size:0.85rem; color:var(--text2); margin-bottom:12px">Quality for Votify fallback when Monochrome can't find a track</p>
<select id="fallback-quality">
<option value="aac-medium">AAC 128kbps</option>
<option value="aac-high">AAC 256kbps (Premium)</option>
</select>
</div>
<div class="card"> <div class="card">
<h2>Cookies</h2> <h2>Cookies</h2>
<div class="cookie-status" id="cookie-status"> <div class="cookie-status" id="cookie-status">
@@ -313,13 +338,9 @@
</div> </div>
<nav id="bottom-nav"> <nav id="bottom-nav">
<button class="bottom-tab active" data-page="download" onclick="showPage('download')"> <button class="bottom-tab active" data-page="unified" onclick="showPage('unified')">
<span class="btab-icon"><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg></span> <span class="btab-icon"><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg></span>
<span>Votify</span> <span>Download</span>
</button>
<button class="bottom-tab" data-page="monochrome" onclick="showPage('monochrome')">
<span class="btab-icon"><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="5.5" cy="17.5" r="2.5"/><circle cx="17.5" cy="15.5" r="2.5"/><path d="M8 17V5l12-2v12"/></svg></span>
<span>Mono</span>
</button> </button>
<button class="bottom-tab" data-page="jobs" onclick="showPage('jobs')"> <button class="bottom-tab" data-page="jobs" onclick="showPage('jobs')">
<span class="btab-icon"><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg></span> <span class="btab-icon"><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg></span>
@@ -346,13 +367,16 @@
document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.getElementById('page-' + name).classList.add('active'); document.getElementById('page-' + name).classList.add('active');
const matchingTab = document.querySelector(`.tab[onclick="showPage('${name}')"]`);
// For hidden pages (download/monochrome), highlight the "Download" tab
const tabName = (name === 'download' || name === 'monochrome') ? 'unified' : name;
const matchingTab = document.querySelector(`.tab[onclick="showPage('${tabName}')"]`);
if (matchingTab) matchingTab.classList.add('active'); if (matchingTab) matchingTab.classList.add('active');
document.querySelectorAll('.bottom-tab').forEach(t => t.classList.toggle('active', t.dataset.page === name)); document.querySelectorAll('.bottom-tab').forEach(t => t.classList.toggle('active', t.dataset.page === tabName));
if (name === 'jobs') loadJobs(); if (name === 'jobs') loadJobs();
if (name === 'files') loadFiles(""); if (name === 'files') loadFiles("");
if (name === 'settings') { checkCookies(); checkWvd(); } if (name === 'settings') { checkCookies(); checkWvd(); loadFallbackQuality(); }
if (jobPollInterval) clearInterval(jobPollInterval); if (jobPollInterval) clearInterval(jobPollInterval);
if (name === 'jobs') jobPollInterval = setInterval(loadJobs, 3000); if (name === 'jobs') jobPollInterval = setInterval(loadJobs, 3000);
@@ -387,7 +411,7 @@
const result = await res.json(); const result = await res.json();
if (res.ok) { if (res.ok) {
document.getElementById('urls').value = ''; document.getElementById('urls').value = '';
document.querySelector('[onclick="showPage(\'jobs\')"]').click(); showPage('jobs');
} else { } else {
showToast(result.error || 'Failed to start download', 'error'); showToast(result.error || 'Failed to start download', 'error');
} }
@@ -425,6 +449,51 @@
btn.disabled = false; btn.disabled = false;
} }
async function startUnifiedDownload() {
const btn = document.getElementById('btn-unified');
btn.disabled = true;
const url = document.getElementById('unified-urls').value.trim();
if (!url) { showToast('Enter a Spotify URL', 'error'); btn.disabled = false; return; }
try {
const res = await fetch('/api/unified/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const result = await res.json();
if (res.ok) {
document.getElementById('unified-urls').value = '';
showPage('jobs');
} else {
showToast(result.error || 'Failed to start download', 'error');
}
} catch (e) {
showToast('Error: ' + e.message, 'error');
}
btn.disabled = false;
}
async function loadFallbackQuality() {
try {
const res = await fetch('/api/settings');
const data = await res.json();
document.getElementById('fallback-quality').value = data.fallback_quality || 'aac-medium';
} catch (_) {}
}
document.getElementById('fallback-quality').addEventListener('change', async function() {
try {
await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fallback_quality: this.value })
});
showToast('Fallback quality saved');
} catch (e) {
showToast('Error saving setting', 'error');
}
});
function parseProgress(output) { function parseProgress(output) {
if (!output || output.length === 0) return null; if (!output || output.length === 0) return null;
let current = 0, total = 0, dlPct = 0; let current = 0, total = 0, dlPct = 0;
@@ -724,22 +793,23 @@
if (!lines.length) return; if (!lines.length) return;
// Drop onto active page's textarea // Drop onto active page's textarea
const monoActive = document.getElementById('page-monochrome').classList.contains('active'); const monoActive = document.getElementById('page-monochrome').classList.contains('active');
const ta = document.getElementById(monoActive ? 'mono-url' : 'urls'); const votifyActive = document.getElementById('page-download').classList.contains('active');
let taId = 'unified-urls';
if (monoActive) taId = 'mono-url';
else if (votifyActive) taId = 'urls';
const ta = document.getElementById(taId);
ta.value = (ta.value.trim() ? ta.value.trim() + '\n' : '') + lines.join('\n'); ta.value = (ta.value.trim() ? ta.value.trim() + '\n' : '') + lines.join('\n');
ta.classList.remove('drop-flash'); ta.classList.remove('drop-flash');
void ta.offsetWidth; void ta.offsetWidth;
ta.classList.add('drop-flash'); ta.classList.add('drop-flash');
setTimeout(() => ta.classList.remove('drop-flash'), 650); setTimeout(() => ta.classList.remove('drop-flash'), 650);
if (!monoActive) showPage('download');
}); });
const urlTextarea = document.getElementById('urls'); for (const id of ['urls', 'mono-url', 'unified-urls']) {
urlTextarea.addEventListener('dragover', () => urlTextarea.classList.add('drag-over')); const ta = document.getElementById(id);
urlTextarea.addEventListener('dragleave', () => urlTextarea.classList.remove('drag-over')); ta.addEventListener('dragover', () => ta.classList.add('drag-over'));
urlTextarea.addEventListener('drop', () => urlTextarea.classList.remove('drag-over')); ta.addEventListener('dragleave', () => ta.classList.remove('drag-over'));
const monoTextarea = document.getElementById('mono-url'); ta.addEventListener('drop', () => ta.classList.remove('drag-over'));
monoTextarea.addEventListener('dragover', () => monoTextarea.classList.add('drag-over')); }
monoTextarea.addEventListener('dragleave', () => monoTextarea.classList.remove('drag-over'));
monoTextarea.addEventListener('drop', () => monoTextarea.classList.remove('drag-over'));
// Remember settings // Remember settings
const SETTINGS_KEY = 'votify-settings'; const SETTINGS_KEY = 'votify-settings';

60
utils.py Normal file
View File

@@ -0,0 +1,60 @@
"""Shared utilities for file naming and organization."""
import os
import re
from pathlib import Path
import mutagen
def sanitize_filename(name):
"""Remove characters prohibited in Windows filenames."""
name = re.sub(r'[<>:"/\\|?*]', '_', name)
return name.strip().strip('.')
def rename_from_metadata(file_path):
"""Rename an audio file to 'Title - Artist1, Artist2.ext' using embedded metadata.
Returns the new Path (or original if rename not possible).
"""
file_path = Path(file_path)
audio = mutagen.File(str(file_path), easy=True)
if audio is None:
return file_path
try:
title = audio.get("title", [None])[0]
artist = audio.get("artist", [None])[0]
except (IndexError, KeyError):
return file_path
if not title or not artist:
return file_path
new_name = sanitize_filename(f"{title} - {artist}{file_path.suffix}")
new_path = file_path.parent / new_name
if new_path == file_path:
return file_path
# Handle collisions
counter = 2
base_stem = new_path.stem
while new_path.exists():
new_path = new_path.with_stem(f"{base_stem} ({counter})")
counter += 1
file_path.rename(new_path)
return new_path
def cleanup_empty_dirs(directory):
"""Remove empty subdirectories (bottom-up)."""
directory = Path(directory)
for dirpath, dirnames, filenames in os.walk(str(directory), topdown=False):
dirpath = Path(dirpath)
if dirpath == directory:
continue
if not any(dirpath.iterdir()):
dirpath.rmdir()