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

193
app.py
View File

@@ -1,4 +1,5 @@
import io
import json
import os
import shutil
import subprocess
@@ -8,6 +9,8 @@ import uuid
import zipfile
from pathlib import Path
from utils import rename_from_metadata, sanitize_filename, cleanup_empty_dirs
from flask import (
Flask,
jsonify,
@@ -88,15 +91,67 @@ def convert_to_mp3(job_id: str, before: set[Path]):
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.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)])
if WVD_PATH.exists():
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")
if quality:
cmd.extend(["--audio-quality", quality])
@@ -174,8 +229,11 @@ def run_download(job_id: str, urls: list[str], options: dict):
with jobs_lock:
jobs[job_id]["output"] = jobs[job_id].get("output", []) + ["[cancelled] Job was cancelled by user."]
else:
if process.returncode == 0 and want_mp3:
convert_to_mp3(job_id, files_before)
if process.returncode == 0:
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:
jobs[job_id]["status"] = "completed" if process.returncode == 0 else "failed"
jobs[job_id]["return_code"] = process.returncode
@@ -273,7 +331,7 @@ def run_monochrome_download(job_id: str, url: str, quality: str):
try:
from monochrome.api import download_spotify_url
success, total = download_spotify_url(
success, total, fail_info = download_spotify_url(
spotify_url=url,
quality=quality,
output_dir=str(DOWNLOADS_DIR),
@@ -323,6 +381,131 @@ def start_monochrome_download():
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):
return {k: v for k, v in job.items() if k != "process"}