feat: implemented unified downloading
This commit is contained in:
193
app.py
193
app.py
@@ -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"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user