feat: implemented unified downloading
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
/config/
|
/config/
|
||||||
/downloads/
|
/downloads/
|
||||||
.env
|
.env
|
||||||
|
__pycache__/
|
||||||
|
|||||||
@@ -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
193
app.py
@@ -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"}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|
||||||
|
|||||||
@@ -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/... 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">← 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">← 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
60
utils.py
Normal 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()
|
||||||
Reference in New Issue
Block a user