feat: implemented Monochrome downloading
This commit is contained in:
181
monochrome/api.py
Normal file
181
monochrome/api.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Monochrome API integration for Votify Web.
|
||||
|
||||
Orchestrates the Spotify URL → Tidal ID → download pipeline
|
||||
for use from app.py background threads.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
from monochrome import discover_instances
|
||||
from monochrome.spotify_to_ids import (
|
||||
parse_spotify_url,
|
||||
fetch_spotify_embed,
|
||||
extract_collection_name,
|
||||
extract_tracks,
|
||||
search_monochrome,
|
||||
find_best_match,
|
||||
)
|
||||
from monochrome.download import (
|
||||
get_stream_url_tidal,
|
||||
get_stream_url_qobuz,
|
||||
download_file,
|
||||
fetch_cover_art,
|
||||
embed_metadata,
|
||||
sanitize_filename,
|
||||
convert_to_mp3,
|
||||
)
|
||||
|
||||
|
||||
def download_spotify_url(spotify_url, quality, output_dir, log=None, cancel_check=None):
|
||||
"""Download tracks from a Spotify URL via Monochrome.
|
||||
|
||||
Args:
|
||||
spotify_url: Spotify track/album/playlist URL
|
||||
quality: One of HI_RES_LOSSLESS, LOSSLESS, HIGH, LOW, MP3_320
|
||||
output_dir: Directory to save downloaded files
|
||||
log: Callback (str) -> None for progress messages
|
||||
cancel_check: Callback () -> bool, returns True if cancelled
|
||||
|
||||
Returns:
|
||||
(success_count, total_tracks)
|
||||
"""
|
||||
if log is None:
|
||||
log = print
|
||||
if cancel_check is None:
|
||||
cancel_check = lambda: False
|
||||
|
||||
want_mp3 = quality == "MP3_320"
|
||||
api_quality = "LOSSLESS" if want_mp3 else quality
|
||||
|
||||
# Step 1: Discover instances
|
||||
log("[monochrome] Discovering API instances...")
|
||||
instances = discover_instances(log=log)
|
||||
|
||||
# Step 2: Parse Spotify URL
|
||||
sp_type, sp_id = parse_spotify_url(spotify_url)
|
||||
if not sp_type:
|
||||
log(f"[monochrome] Invalid Spotify URL: {spotify_url}")
|
||||
return 0, 0
|
||||
|
||||
# Step 3: Fetch track list from Spotify
|
||||
log(f"[monochrome] Fetching Spotify {sp_type}: {sp_id}")
|
||||
embed_data = fetch_spotify_embed(sp_type, sp_id)
|
||||
tracks = extract_tracks(embed_data, sp_type, sp_id)
|
||||
|
||||
if not tracks:
|
||||
log(f"[monochrome] Could not extract tracks from {spotify_url}")
|
||||
return 0, 0
|
||||
|
||||
total = len(tracks)
|
||||
log(f"[monochrome] Found {total} track(s) on Spotify")
|
||||
|
||||
# Create subfolder for albums/playlists
|
||||
dl_dir = output_dir
|
||||
if total > 1:
|
||||
collection_name = extract_collection_name(embed_data, sp_type)
|
||||
if collection_name:
|
||||
folder_name = sanitize_filename(collection_name)
|
||||
else:
|
||||
folder_name = sanitize_filename(f"{sp_type}_{sp_id}")
|
||||
dl_dir = os.path.join(output_dir, folder_name)
|
||||
os.makedirs(dl_dir, exist_ok=True)
|
||||
log(f"[monochrome] Saving to folder: {folder_name}")
|
||||
|
||||
success = 0
|
||||
failed_tracks = []
|
||||
|
||||
for i, track in enumerate(tracks):
|
||||
if cancel_check():
|
||||
log("[monochrome] Cancelled")
|
||||
break
|
||||
|
||||
query = f"{track['artist']} {track['title']}".strip()
|
||||
log(f"[monochrome] Track {i + 1}/{total}: {query}")
|
||||
|
||||
# Search and match
|
||||
results = search_monochrome(instances, query, log=log)
|
||||
match, score = find_best_match(results, track["title"], track["artist"])
|
||||
|
||||
if not match:
|
||||
log(f"[monochrome] No match found for: {query}")
|
||||
failed_tracks.append(query)
|
||||
if i < total - 1:
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
|
||||
track_id = match.get("id")
|
||||
m_title = match.get("title", "?")
|
||||
m_artist_obj = match.get("artist", {})
|
||||
m_artist = m_artist_obj.get("name", "?") if isinstance(m_artist_obj, dict) else str(m_artist_obj)
|
||||
log(f"[monochrome] Matched: {m_artist} - {m_title} (score: {score:.2f})")
|
||||
|
||||
# Get stream URL
|
||||
stream_url, track_data = get_stream_url_tidal(instances, track_id, api_quality, log=log)
|
||||
|
||||
if not stream_url:
|
||||
log("[monochrome] Tidal failed, trying Qobuz...")
|
||||
stream_url = get_stream_url_qobuz(track_id, api_quality, log=log)
|
||||
|
||||
if not stream_url:
|
||||
log(f"[monochrome] Failed to get stream for: {query}")
|
||||
failed_tracks.append(query)
|
||||
if i < total - 1:
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
|
||||
# Build metadata from match info
|
||||
info = match
|
||||
if track_data and isinstance(track_data, dict):
|
||||
# Merge: track_data may have more detail
|
||||
for k, v in track_data.items():
|
||||
if k not in info or not info[k]:
|
||||
info[k] = v
|
||||
|
||||
# Determine file extension and path
|
||||
if want_mp3:
|
||||
ext = ".flac"
|
||||
elif api_quality in ("HIGH", "LOW"):
|
||||
ext = ".m4a"
|
||||
else:
|
||||
ext = ".flac"
|
||||
|
||||
filename = sanitize_filename(f"{m_artist} - {m_title}{ext}")
|
||||
file_path = os.path.join(dl_dir, filename)
|
||||
|
||||
# Download
|
||||
try:
|
||||
download_file(stream_url, file_path, log=log)
|
||||
except Exception as e:
|
||||
log(f"[monochrome] Download failed for {query}: {e}")
|
||||
failed_tracks.append(query)
|
||||
if i < total - 1:
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
|
||||
# Cover art and metadata
|
||||
cover_data = fetch_cover_art(info.get("album"), log=log)
|
||||
embed_metadata(file_path, info, cover_data, log=log)
|
||||
|
||||
# Convert to MP3 if requested
|
||||
if want_mp3:
|
||||
mp3_filename = 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):
|
||||
embed_metadata(mp3_path, info, cover_data, log=log)
|
||||
|
||||
success += 1
|
||||
|
||||
# Rate limit between tracks
|
||||
if i < total - 1:
|
||||
time.sleep(0.5)
|
||||
|
||||
# Summary
|
||||
if failed_tracks:
|
||||
log(f"[monochrome] Failed tracks ({len(failed_tracks)}):")
|
||||
for ft in failed_tracks:
|
||||
log(f"[monochrome] - {ft}")
|
||||
|
||||
log(f"[monochrome] Complete: {success}/{total} tracks downloaded")
|
||||
return success, total
|
||||
Reference in New Issue
Block a user