commit
fd4b051bcc
246 changed files with 27821 additions and 5745 deletions
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
|
|
@ -9,9 +9,9 @@ on:
|
|||
workflow_dispatch:
|
||||
inputs:
|
||||
version_tag:
|
||||
description: 'Version tag (e.g. 2.4.1)'
|
||||
description: 'Version tag (e.g. 2.4.2)'
|
||||
required: true
|
||||
default: '2.4.1'
|
||||
default: '2.4.2'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ def register_routes(bp):
|
|||
|
||||
try:
|
||||
from utils.async_helpers import run_async
|
||||
soulseek = current_app.soulsync.get("soulseek_client")
|
||||
soulseek = current_app.soulsync.get("download_orchestrator")
|
||||
if not soulseek:
|
||||
return api_error("NOT_AVAILABLE", "Soulseek client not configured.", 503)
|
||||
|
||||
|
|
@ -138,7 +138,7 @@ def register_routes(bp):
|
|||
"""Cancel all active downloads and clear completed ones."""
|
||||
try:
|
||||
from utils.async_helpers import run_async
|
||||
soulseek = current_app.soulsync.get("soulseek_client")
|
||||
soulseek = current_app.soulsync.get("download_orchestrator")
|
||||
if not soulseek:
|
||||
return api_error("NOT_AVAILABLE", "Soulseek client not configured.", 503)
|
||||
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ def _run_search_and_download(request_id, query, notify_url):
|
|||
if request_id in _pending_requests:
|
||||
_pending_requests[request_id]['status'] = 'searching'
|
||||
|
||||
soulseek = current_app._get_current_object().soulsync.get('soulseek_client')
|
||||
soulseek = current_app._get_current_object().soulsync.get('download_orchestrator')
|
||||
if not soulseek:
|
||||
with _requests_lock:
|
||||
if request_id in _pending_requests:
|
||||
|
|
|
|||
|
|
@ -2,10 +2,14 @@
|
|||
Search endpoints — search external sources (Spotify, iTunes, Hydrabase).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import request, current_app
|
||||
from .auth import require_api_key
|
||||
from .helpers import api_success, api_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_routes(bp):
|
||||
|
||||
|
|
@ -38,8 +42,8 @@ def register_routes(bp):
|
|||
if hydra_results:
|
||||
tracks = [_serialize_track(t) for t in hydra_results]
|
||||
return api_success({"tracks": tracks, "source": "hydrabase"})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("hydrabase search failed: %s", e)
|
||||
|
||||
spotify = ctx.get("spotify_client")
|
||||
from core.metadata_service import get_primary_source, get_primary_client
|
||||
|
|
|
|||
|
|
@ -2,12 +2,15 @@
|
|||
System endpoints — status, activity feed, stats.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from flask import current_app
|
||||
from .auth import require_api_key
|
||||
from .helpers import api_success, api_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_routes(bp):
|
||||
|
||||
|
|
@ -26,7 +29,7 @@ def register_routes(bp):
|
|||
spotify = ctx.get("spotify_client")
|
||||
spotify_ok = bool(spotify and spotify.is_authenticated())
|
||||
|
||||
soulseek = ctx.get("soulseek_client")
|
||||
soulseek = ctx.get("download_orchestrator")
|
||||
soulseek_ok = bool(soulseek)
|
||||
|
||||
hydrabase = ctx.get("hydrabase_client")
|
||||
|
|
@ -35,8 +38,8 @@ def register_routes(bp):
|
|||
try:
|
||||
ws, _ = hydrabase.get_ws_and_lock()
|
||||
hydrabase_ok = ws is not None and ws.connected
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("hydrabase status probe failed: %s", e)
|
||||
|
||||
return api_success({
|
||||
"uptime": f"{hours}h {minutes}m {seconds}s",
|
||||
|
|
|
|||
|
|
@ -2612,7 +2612,7 @@ class BeatportUnifiedScraper:
|
|||
result['title'] = title
|
||||
|
||||
except Exception as e:
|
||||
pass # Silently handle URL extraction errors
|
||||
logger.debug("URL slug extraction: %s", e)
|
||||
|
||||
return result
|
||||
|
||||
|
|
@ -3186,8 +3186,8 @@ class BeatportUnifiedScraper:
|
|||
except Exception:
|
||||
continue
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("parse release tracks failed: %s", e)
|
||||
|
||||
return tracks
|
||||
|
||||
|
|
|
|||
|
|
@ -495,6 +495,16 @@ class ConfigManager:
|
|||
"hifi_download": {
|
||||
"quality": "lossless", # Options: "low", "high", "lossless", "hires"
|
||||
},
|
||||
"hifi": {
|
||||
"embed_tags": True,
|
||||
"tags": {
|
||||
"track_id": True,
|
||||
"artist_id": True,
|
||||
"isrc": True,
|
||||
"bpm": True,
|
||||
"copyright": True,
|
||||
}
|
||||
},
|
||||
"lidarr_download": {
|
||||
"url": "",
|
||||
"api_key": "",
|
||||
|
|
@ -502,6 +512,13 @@ class ConfigManager:
|
|||
"quality_profile": "Any",
|
||||
"cleanup_after_import": True,
|
||||
},
|
||||
"soundcloud_download": {
|
||||
# Anonymous-only for now — SoundCloud Go+ OAuth tier could be
|
||||
# added later, with credentials living under a "session" subkey
|
||||
# alongside Tidal/Qobuz. No quality knob: anonymous SoundCloud
|
||||
# caps at the upload's transcoding (typically 128 kbps MP3 or
|
||||
# AAC). yt-dlp resolves bestaudio at download time.
|
||||
},
|
||||
"listenbrainz": {
|
||||
"base_url": "",
|
||||
"token": "",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from typing import Optional, Dict, Any, Tuple, List
|
|||
from enum import Enum
|
||||
from utils.logging_config import get_logger
|
||||
from core.acoustid_client import AcoustIDClient
|
||||
from core.matching_engine import MusicMatchingEngine
|
||||
from core.musicbrainz_client import MusicBrainzClient
|
||||
|
||||
logger = get_logger("acoustid.verification")
|
||||
|
|
@ -24,6 +25,25 @@ MIN_ACOUSTID_SCORE = 0.80 # Minimum AcoustID fingerprint score to trust
|
|||
TITLE_MATCH_THRESHOLD = 0.70 # Title similarity needed to consider a match
|
||||
ARTIST_MATCH_THRESHOLD = 0.60 # Artist similarity needed to consider a match
|
||||
|
||||
# Single matching-engine instance so version detection reuses the same patterns
|
||||
# used by the pre-download Soulseek matcher (remix / live / acoustic /
|
||||
# instrumental / etc). detect_version_type doesn't use self state, so one
|
||||
# shared instance is fine.
|
||||
_match_engine_for_version = MusicMatchingEngine()
|
||||
|
||||
|
||||
def _detect_title_version(title: str) -> str:
|
||||
"""Return version label for a track title.
|
||||
|
||||
Returns ``'original'`` when no version marker is detected, otherwise one
|
||||
of the labels produced by ``MusicMatchingEngine.detect_version_type``
|
||||
(``'instrumental'``, ``'live'``, ``'acoustic'``, ``'remix'``, etc).
|
||||
"""
|
||||
if not title:
|
||||
return 'original'
|
||||
version_type, _ = _match_engine_for_version.detect_version_type(title)
|
||||
return version_type
|
||||
|
||||
|
||||
class VerificationResult(Enum):
|
||||
"""Possible outcomes of audio verification."""
|
||||
|
|
@ -286,6 +306,33 @@ class AcoustIDVerification:
|
|||
f"(title_sim={title_sim:.2f}, artist_sim={artist_sim:.2f})"
|
||||
)
|
||||
|
||||
# Step 4b: Version-mismatch gate.
|
||||
#
|
||||
# The ``_normalize`` step deliberately strips parentheticals and
|
||||
# version tags ("(Instrumental)", "- Live", etc) so that legit
|
||||
# name variations don't fail the title-similarity comparison.
|
||||
# That same stripping made it impossible to tell a vocal track
|
||||
# apart from its instrumental: "In My Feelings" and "In My
|
||||
# Feelings (Instrumental)" both normalize to "in my feelings",
|
||||
# the title sim ends up 1.0, and the file passes verification
|
||||
# even though it's the wrong cut.
|
||||
#
|
||||
# Detect the version on each side BEFORE normalization runs.
|
||||
# If the expected track and the AcoustID-matched recording
|
||||
# disagree on version (one is original, the other is
|
||||
# instrumental / live / remix / acoustic / etc), reject — the
|
||||
# fingerprint identified a real song but it's not the one the
|
||||
# caller asked for.
|
||||
expected_version = _detect_title_version(expected_track_name)
|
||||
matched_version = _detect_title_version(matched_title)
|
||||
if expected_version != matched_version:
|
||||
msg = (
|
||||
f"Version mismatch: expected '{expected_track_name}' ({expected_version}) "
|
||||
f"but file is '{matched_title}' ({matched_version})"
|
||||
)
|
||||
logger.warning(f"AcoustID verification FAILED (version mismatch) - {msg}")
|
||||
return VerificationResult.FAIL, msg
|
||||
|
||||
# Step 5: Decide pass/fail based on similarity
|
||||
if title_sim >= TITLE_MATCH_THRESHOLD and artist_sim >= ARTIST_MATCH_THRESHOLD:
|
||||
msg = (
|
||||
|
|
@ -343,9 +390,15 @@ class AcoustIDVerification:
|
|||
|
||||
# Title doesn't match — check ALL recordings for any title/artist match
|
||||
# (the best combined match might not be the right one if there are many results)
|
||||
# Skip recordings whose version (instrumental/live/etc) disagrees with
|
||||
# what the caller asked for — the version mismatch above checked
|
||||
# only the best recording, but a wrong-version variant could still
|
||||
# win this fallback scan if its bare title matched.
|
||||
for rec in recordings:
|
||||
t = rec.get('title') or ''
|
||||
a = rec.get('artist') or ''
|
||||
if _detect_title_version(t) != expected_version:
|
||||
continue
|
||||
if (_similarity(expected_track_name, t) >= TITLE_MATCH_THRESHOLD and
|
||||
_similarity(expected_artist_name, a) >= ARTIST_MATCH_THRESHOLD):
|
||||
msg = (
|
||||
|
|
@ -356,27 +409,61 @@ class AcoustIDVerification:
|
|||
return VerificationResult.PASS, msg
|
||||
|
||||
# No match found — but if fingerprint score is very high (≥0.95)
|
||||
# AND there's partial similarity in title or artist, the mismatch is
|
||||
# likely a language/script difference (e.g. Japanese kanji vs English).
|
||||
# Skip rather than quarantine a correct file.
|
||||
# But if both title AND artist similarity are very low, the download
|
||||
# source gave us a completely wrong file — fail it.
|
||||
if best_score >= 0.95 and (title_sim >= 0.55 or artist_sim >= ARTIST_MATCH_THRESHOLD):
|
||||
top = recordings[0]
|
||||
# AND we have evidence the mismatch is a language/script case
|
||||
# (rather than two genuinely different songs by the same artist),
|
||||
# skip rather than quarantine a correct file. Two routes:
|
||||
#
|
||||
# (a) Either side of the comparison contains non-ASCII characters
|
||||
# — strong signal of transliteration / kanji↔roman cases.
|
||||
# Artist must still be a strong match to use this path.
|
||||
# (b) Both title AND artist similarity are very high (the song
|
||||
# is recognizably the same with minor punctuation / casing
|
||||
# differences that fell below the strict match thresholds).
|
||||
#
|
||||
# The OLD logic was ``title_sim >= 0.55 OR artist_sim >= match``.
|
||||
# That fired for English-vs-English songs by the same artist that
|
||||
# share NO actual content — e.g. "R.O.T.C (Interlude)" by
|
||||
# Kendrick Lamar getting accepted as "Rich (Interlude)" by
|
||||
# Kendrick Lamar because the artist matched perfectly and
|
||||
# "interlude" was shared in both titles. Reported by user when
|
||||
# downloading Mr. Morale: three tracks (Rich Interlude, Savior
|
||||
# Interlude, Savior) all received the wrong R.O.T.C audio file
|
||||
# because of this leak.
|
||||
top = recordings[0]
|
||||
top_title = top.get('title', '?') or ''
|
||||
top_artist = top.get('artist', '?') or ''
|
||||
has_non_ascii = (
|
||||
any(ord(c) > 127 for c in (expected_track_name or ''))
|
||||
or any(ord(c) > 127 for c in top_title)
|
||||
)
|
||||
language_script_skip = (
|
||||
best_score >= 0.95
|
||||
and has_non_ascii
|
||||
and artist_sim >= ARTIST_MATCH_THRESHOLD
|
||||
)
|
||||
high_confidence_strong_match_skip = (
|
||||
best_score >= 0.95
|
||||
and title_sim >= 0.80
|
||||
and artist_sim >= ARTIST_MATCH_THRESHOLD
|
||||
)
|
||||
if language_script_skip or high_confidence_strong_match_skip:
|
||||
reason = (
|
||||
"likely same song in different language/script"
|
||||
if language_script_skip
|
||||
else "title/artist match within tolerance"
|
||||
)
|
||||
msg = (
|
||||
f"Title/artist mismatch but fingerprint confidence very high ({best_score:.2f}): "
|
||||
f"AcoustID='{top.get('title', '?')}' by '{top.get('artist', '?')}', "
|
||||
f"AcoustID='{top_title}' by '{top_artist}', "
|
||||
f"expected '{expected_track_name}' by '{expected_artist_name}' — "
|
||||
f"likely same song in different language/script"
|
||||
f"{reason}"
|
||||
)
|
||||
logger.info(f"AcoustID verification SKIPPED (high confidence) - {msg}")
|
||||
return VerificationResult.SKIP, msg
|
||||
|
||||
# Low fingerprint score + no metadata match — file is likely wrong
|
||||
top = recordings[0]
|
||||
top_title = top.get('title', '?')
|
||||
top_artist = top.get('artist', '?')
|
||||
|
||||
# `top`, `top_title`, `top_artist` already resolved above for the
|
||||
# skip-eligibility check.
|
||||
msg = (
|
||||
f"Audio mismatch: file identified as '{top_title}' by '{top_artist}', "
|
||||
f"expected '{expected_track_name}' by '{expected_artist_name}' "
|
||||
|
|
|
|||
|
|
@ -195,8 +195,8 @@ def _find_best_release(album_name, artist_name, track_count, mb_service):
|
|||
sr_id = sr.get('id', '')
|
||||
if sr_id and sr_id not in candidate_mbids:
|
||||
candidate_mbids.append(sr_id)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("search_release fallback failed: %s", e)
|
||||
|
||||
if not candidate_mbids:
|
||||
logger.info(f"No MB release found for '{album_name}' by '{artist_name}'")
|
||||
|
|
|
|||
|
|
@ -302,8 +302,8 @@ class ApiCallTracker:
|
|||
try:
|
||||
if os.path.exists(_PERSIST_PATH + '.tmp'):
|
||||
os.remove(_PERSIST_PATH + '.tmp')
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("remove stale tmp file failed: %s", e)
|
||||
|
||||
def _load(self):
|
||||
"""Restore 24h minute history from disk. Called on init."""
|
||||
|
|
|
|||
|
|
@ -89,20 +89,20 @@ def _match_liked_artists_to_all_sources(database, profile_id: int):
|
|||
search_clients['spotify'] = spotify_client
|
||||
try:
|
||||
search_clients['itunes'] = _get_itunes_client()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("itunes client init failed: %s", e)
|
||||
try:
|
||||
search_clients['deezer'] = _get_deezer_client()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("deezer client init failed: %s", e)
|
||||
try:
|
||||
dc = _get_discogs_client()
|
||||
# Only use Discogs if token is configured
|
||||
from config.settings import config_manager as _cm
|
||||
if _cm.get('discogs.token', ''):
|
||||
search_clients['discogs'] = dc
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("discogs client init failed: %s", e)
|
||||
|
||||
# Reuse watchlist scanner's fuzzy matching logic
|
||||
from core.watchlist_scanner import WatchlistScanner
|
||||
|
|
@ -167,8 +167,8 @@ def _match_liked_artists_to_all_sources(database, profile_id: int):
|
|||
harvested_ids[col] = str(r[col])
|
||||
if _valid_image(r.get('thumb_url')):
|
||||
best_image = r['thumb_url']
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("library artist lookup failed: %s", e)
|
||||
|
||||
# 2. Watchlist artists
|
||||
try:
|
||||
|
|
@ -186,8 +186,8 @@ def _match_liked_artists_to_all_sources(database, profile_id: int):
|
|||
harvested_ids[col] = str(wl[col])
|
||||
if _valid_image(wl.get('image_url')) and not best_image:
|
||||
best_image = wl['image_url']
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("watchlist artist lookup failed: %s", e)
|
||||
|
||||
# 3. Metadata cache (all sources)
|
||||
try:
|
||||
|
|
@ -203,8 +203,8 @@ def _match_liked_artists_to_all_sources(database, profile_id: int):
|
|||
harvested_ids[col] = row['entity_id']
|
||||
if _valid_image(row['image_url']) and not best_image:
|
||||
best_image = row['image_url']
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("metadata cache lookup failed: %s", e)
|
||||
|
||||
# --- API STRATEGIES (search each missing source) ---
|
||||
# Same pattern as watchlist scanner's _backfill_missing_ids
|
||||
|
|
@ -287,8 +287,8 @@ def _backfill_liked_artist_images(database, profile_id: int, search_clients: dic
|
|||
artist_data = sp.sp.artist(r['spotify_artist_id'])
|
||||
if artist_data and artist_data.get('images'):
|
||||
image_url = artist_data['images'][0]['url']
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("spotify artist image fetch failed: %s", e)
|
||||
|
||||
# Try Deezer (direct image URL from ID)
|
||||
if not image_url and r.get('deezer_artist_id'):
|
||||
|
|
@ -302,8 +302,8 @@ def _backfill_liked_artist_images(database, profile_id: int, search_clients: dic
|
|||
(image_url, r['id'])
|
||||
)
|
||||
filled += 1
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("liked artist image update failed: %s", e)
|
||||
time.sleep(0.3)
|
||||
|
||||
conn.commit()
|
||||
|
|
|
|||
|
|
@ -158,8 +158,8 @@ def get_artist_map_data():
|
|||
if r.get('genres'):
|
||||
try:
|
||||
genres = json.loads(r['genres'])
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("similar node genres parse failed: %s", e)
|
||||
nodes.append({
|
||||
'id': idx,
|
||||
'name': r['similar_artist_name'],
|
||||
|
|
@ -232,8 +232,8 @@ def get_artist_map_data():
|
|||
if cr['genres']:
|
||||
try:
|
||||
genres = json.loads(cr['genres']) if isinstance(cr['genres'], str) else []
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("backfill cache genres parse failed: %s", e)
|
||||
cache_by_name[cn][source] = {
|
||||
'id': cr['entity_id'],
|
||||
'image_url': cr['image_url'] or '',
|
||||
|
|
@ -280,8 +280,8 @@ def get_artist_map_data():
|
|||
an = _norm(r['artist_name'])
|
||||
if an and an not in _album_art:
|
||||
_album_art[an] = r['image_url']
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("artist map album-art cache build failed: %s", e)
|
||||
for n in nodes:
|
||||
if not n.get('image_url') or not n['image_url'].startswith('http'):
|
||||
nn = _norm(n['name'])
|
||||
|
|
@ -330,8 +330,8 @@ def get_artist_map_genre_list():
|
|||
if g and isinstance(g, str):
|
||||
gl = g.lower().strip()
|
||||
genre_counts[gl] = genre_counts.get(gl, 0) + 1
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("genre count row parse failed: %s", e)
|
||||
|
||||
# Sort by count descending
|
||||
sorted_genres = sorted(genre_counts.items(), key=lambda x: -x[1])
|
||||
|
|
@ -404,8 +404,8 @@ def get_artist_map_genres():
|
|||
if r['genres']:
|
||||
try:
|
||||
genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("cache artist genres parse failed: %s", e)
|
||||
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id'}
|
||||
kwargs = {src_map.get(r['source'], 'spotify_id'): r['entity_id']}
|
||||
_add(r['name'], image_url=r['image_url'], genres=genres, source='cache', popularity=r['popularity'] or 0, **kwargs)
|
||||
|
|
@ -421,8 +421,8 @@ def get_artist_map_genres():
|
|||
if r['genres']:
|
||||
try:
|
||||
genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("similar artist genres parse failed: %s", e)
|
||||
_add(r['similar_artist_name'], image_url=r['image_url'], genres=genres,
|
||||
spotify_id=r['similar_artist_spotify_id'], itunes_id=r['similar_artist_itunes_id'],
|
||||
deezer_id=r['similar_artist_deezer_id'], source='similar', popularity=r['popularity'] or 0)
|
||||
|
|
@ -445,8 +445,8 @@ def get_artist_map_genres():
|
|||
if r['genres']:
|
||||
try:
|
||||
genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("library artist genres parse failed: %s", e)
|
||||
img = r['thumb_url'] if r['thumb_url'] and r['thumb_url'].startswith('http') else None
|
||||
_add(r['name'], image_url=img, genres=genres, source='library')
|
||||
|
||||
|
|
@ -550,8 +550,8 @@ def get_artist_map_genres():
|
|||
nn = (r['artist_name'] or '').lower().strip()
|
||||
if nn and nn not in _album_art_cache:
|
||||
_album_art_cache[nn] = r['image_url']
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("genre map cache build failed: %s", e)
|
||||
|
||||
for n in nodes:
|
||||
img = n.get('image_url', '')
|
||||
|
|
@ -650,8 +650,8 @@ def get_artist_map_explore():
|
|||
if row['genres']:
|
||||
try:
|
||||
center_genres = json.loads(row['genres']) if isinstance(row['genres'], str) else []
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("initial center genres parse failed: %s", e)
|
||||
|
||||
# Check watchlist + library if not in cache
|
||||
if not artist_found and not artist_id:
|
||||
|
|
@ -722,8 +722,8 @@ def get_artist_map_explore():
|
|||
if r['genres'] and not center_genres:
|
||||
try:
|
||||
center_genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("center genres parse failed: %s", e)
|
||||
|
||||
# Add center node
|
||||
center_idx = 0
|
||||
|
|
@ -787,8 +787,8 @@ def get_artist_map_explore():
|
|||
popularity=sa.get('popularity', 0),
|
||||
similar_artist_deezer_id=sa.get('deezer_id')
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("similar artist insert failed: %s", e)
|
||||
# Re-query from DB to get consistent format
|
||||
if id_values:
|
||||
placeholders = ','.join(['?'] * len(id_values))
|
||||
|
|
@ -828,8 +828,8 @@ def get_artist_map_explore():
|
|||
if r['genres']:
|
||||
try:
|
||||
genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("ring1 genres parse failed: %s", e)
|
||||
img = r['image_url'] if r['image_url'] and r['image_url'].startswith('http') else ''
|
||||
nodes.append({
|
||||
'id': idx, 'name': r['similar_artist_name'], 'image_url': img,
|
||||
|
|
@ -889,8 +889,8 @@ def get_artist_map_explore():
|
|||
if r['genres']:
|
||||
try:
|
||||
genres = json.loads(r['genres']) if isinstance(r['genres'], str) else []
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("ring2 genres parse failed: %s", e)
|
||||
img = r['image_url'] if r['image_url'] and r['image_url'].startswith('http') else ''
|
||||
nodes.append({
|
||||
'id': idx, 'name': r['similar_artist_name'], 'image_url': img,
|
||||
|
|
@ -928,8 +928,8 @@ def get_artist_map_explore():
|
|||
if not n['genres'] and cr['genres']:
|
||||
try:
|
||||
n['genres'] = json.loads(cr['genres'])[:5] if isinstance(cr['genres'], str) else []
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("explorer node genres parse failed: %s", e)
|
||||
# Harvest missing IDs from cache
|
||||
src_map = {'spotify': 'spotify_id', 'itunes': 'itunes_id', 'deezer': 'deezer_id', 'discogs': 'discogs_id'}
|
||||
k = src_map.get(cr['source'])
|
||||
|
|
@ -951,8 +951,8 @@ def get_artist_map_explore():
|
|||
n['image_url'] = artist_data['images'][0]['url']
|
||||
if not n['genres'] and artist_data.get('genres'):
|
||||
n['genres'] = artist_data['genres'][:5]
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("spotify artist image fallback failed: %s", e)
|
||||
|
||||
# Album art fallback (iTunes artists have no artist images)
|
||||
if not n['image_url']:
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
`enhance_artist_quality(artist_id, track_ids, deps)` is the route-handler
|
||||
body for the `/api/library/artist/<artist_id>/enhance` endpoint. It walks
|
||||
the user's selected tracks, finds the best Spotify (preferred) or iTunes
|
||||
(fallback) match for each, and queues high-quality re-downloads on the
|
||||
the user's selected tracks, finds the best metadata match against the
|
||||
configured primary source, and queues high-quality re-downloads on the
|
||||
wishlist with `source_type='enhance'`.
|
||||
|
||||
Per-track flow:
|
||||
|
|
@ -12,13 +12,43 @@ Per-track flow:
|
|||
front from `database.get_artist_full_detail`).
|
||||
2. Read current quality tier from the file extension.
|
||||
3. Build `matched_track_data` for the wishlist entry, in priority order:
|
||||
- Direct Spotify lookup via stored `spotify_track_id` (preferred).
|
||||
- Spotify search fallback using matching_engine queries.
|
||||
- iTunes/fallback source search.
|
||||
4. Add to wishlist via `wishlist_service.add_spotify_track_to_wishlist`
|
||||
- **Direct lookup using stored source IDs** — for every source the
|
||||
user has configured, if the library track has the corresponding
|
||||
stored ID (`spotify_track_id` / `deezer_id` / `itunes_track_id` /
|
||||
`soul_id`), call `client.get_track_details(stored_id)` and convert
|
||||
the result to the wishlist payload. First success wins; the user's
|
||||
configured primary source is tried first. Mirrors what Download
|
||||
Discography does — stable IDs straight to the source's API, no
|
||||
fuzzy text matching.
|
||||
- **Multi-source parallel text search fallback** — if no stored ID
|
||||
resolved, run the shared `core.metadata.multi_source_search`
|
||||
against every configured source in parallel and pick the best
|
||||
cross-source match (auto-accept threshold 0.7).
|
||||
4. Validate the match has non-empty title, album, and artists. Reject
|
||||
matches with empty fields — those propagated as
|
||||
"unknown artist - unknown album - unknown track" wishlist entries
|
||||
pre-fix because the wishlist payload normalizer's truthy-check
|
||||
passthrough accepted dicts with empty string fields.
|
||||
5. Add to wishlist via `wishlist_service.add_spotify_track_to_wishlist`
|
||||
with `source_type='enhance'` and a `source_context` carrying the
|
||||
original file path, format tier, bitrate, and artist name.
|
||||
5. Tally `enhanced_count` / `failed_count` / per-track failure reasons.
|
||||
6. Tally `enhanced_count` / `failed_count` / per-track failure reasons.
|
||||
|
||||
The flow originally had Spotify-only logic with an iTunes search-only
|
||||
fallback. Two failure modes drove the rewrite:
|
||||
|
||||
- Users with neither Spotify nor Deezer connected got silent failures
|
||||
("unknown artist - unknown album - unknown track" wishlist entries)
|
||||
because iTunes's text search returned junk matches with empty fields
|
||||
that cleared the 0.7 confidence threshold.
|
||||
- Library tracks with messy tags ("Title (Live)", featured artists in
|
||||
the artist field, etc.) failed fuzzy text search even when a perfect
|
||||
stored ID was available — Download Discography had no such problem
|
||||
because it resolves albums by stable ID.
|
||||
|
||||
Direct-lookup-via-stored-ID matches the Download Discography contract
|
||||
for every source where we have an ID column. Text search is only the
|
||||
fallback now.
|
||||
|
||||
Returns `(payload_dict, http_status_code)` so the route wrapper can
|
||||
`jsonify()` and return.
|
||||
|
|
@ -26,28 +56,286 @@ Returns `(payload_dict, http_status_code)` so the route wrapper can
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger('artists.quality')
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArtistQualityDeps:
|
||||
"""Bundle of cross-cutting deps the artist quality enhancement needs."""
|
||||
spotify_client: Any
|
||||
matching_engine: Any
|
||||
get_database: Callable[[], Any]
|
||||
get_wishlist_service: Callable[[], Any]
|
||||
get_current_profile_id: Callable[[], int]
|
||||
get_quality_tier_from_extension: Callable
|
||||
get_metadata_fallback_client: Callable[[], Any]
|
||||
# Returns ``[(source_name, client), ...]`` for every metadata source
|
||||
# the user has configured. Powers both the direct-lookup fast path
|
||||
# (resolves stored source IDs straight from each source's API,
|
||||
# like Download Discography) and the multi-source parallel text
|
||||
# search fallback (shared with Track Redownload via
|
||||
# ``core.metadata.multi_source_search``).
|
||||
get_metadata_search_sources: Callable[[], list]
|
||||
|
||||
|
||||
def _has_complete_metadata(payload: Optional[dict]) -> bool:
|
||||
"""Reject matches with empty / missing core fields. Pre-fix, iTunes
|
||||
returned matches that cleared the 0.7 confidence threshold while
|
||||
having empty artist / album / title — those propagated as junk
|
||||
wishlist entries displayed as 'unknown artist - unknown album -
|
||||
unknown track'."""
|
||||
if not payload:
|
||||
return False
|
||||
if not (payload.get('name') or '').strip():
|
||||
return False
|
||||
artists = payload.get('artists') or []
|
||||
has_artist = any(
|
||||
(a.get('name') or '').strip() if isinstance(a, dict) else (a or '').strip()
|
||||
for a in artists
|
||||
)
|
||||
if not has_artist:
|
||||
return False
|
||||
album = payload.get('album') or {}
|
||||
if isinstance(album, dict):
|
||||
if not (album.get('name') or '').strip():
|
||||
return False
|
||||
elif not (album or '').strip():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _build_payload_from_track(track_obj) -> dict:
|
||||
"""Build a Spotify-shaped wishlist payload from any metadata source's
|
||||
Track-shaped object (Spotify Track, iTunes Track, Deezer Track,
|
||||
Discogs Track — they all have the same .id / .name / .artists /
|
||||
.album / .duration_ms / etc shape because each client mimics
|
||||
Spotify's surface).
|
||||
|
||||
The wishlist's downstream pipeline expects Spotify shape; this helper
|
||||
is the single place that knows how to produce it. Replaces the
|
||||
duplicated payload construction that used to live in the Spotify
|
||||
search path AND the iTunes fallback path.
|
||||
|
||||
Does NOT substitute defaults for missing artists / album / title —
|
||||
``_has_complete_metadata`` rejects empty matches downstream so the
|
||||
user sees a clear failure instead of a junk wishlist entry with
|
||||
fabricated values.
|
||||
"""
|
||||
image_url = getattr(track_obj, 'image_url', '') or ''
|
||||
album_images = (
|
||||
[{'url': image_url, 'height': 600, 'width': 600}]
|
||||
if image_url else []
|
||||
)
|
||||
artist_names = list(getattr(track_obj, 'artists', None) or [])
|
||||
return {
|
||||
'id': getattr(track_obj, 'id', ''),
|
||||
'name': getattr(track_obj, 'name', '') or '',
|
||||
'artists': [{'name': a} for a in artist_names],
|
||||
'album': {
|
||||
'name': getattr(track_obj, 'album', '') or '',
|
||||
'artists': [{'name': a} for a in artist_names],
|
||||
'album_type': getattr(track_obj, 'album_type', None) or 'album',
|
||||
'images': album_images,
|
||||
'release_date': getattr(track_obj, 'release_date', '') or '',
|
||||
'total_tracks': 1,
|
||||
},
|
||||
'duration_ms': getattr(track_obj, 'duration_ms', 0) or 0,
|
||||
'track_number': getattr(track_obj, 'track_number', None) or 1,
|
||||
'disc_number': getattr(track_obj, 'disc_number', None) or 1,
|
||||
'popularity': getattr(track_obj, 'popularity', None) or 0,
|
||||
'preview_url': getattr(track_obj, 'preview_url', None),
|
||||
'external_urls': getattr(track_obj, 'external_urls', None) or {},
|
||||
}
|
||||
|
||||
|
||||
# Map metadata source name → DB column on the ``tracks`` table that
|
||||
# stores that source's native track ID. Used to drive the direct-lookup
|
||||
# fast path: when a library track has a stored ID for source X and the
|
||||
# user has source X configured, skip fuzzy text search and resolve
|
||||
# straight from X's API. Mirrors what Download Discography does — stable
|
||||
# IDs all the way, no fuzzy text matching.
|
||||
#
|
||||
# Discogs is release-based and has no per-track ID column; not listed
|
||||
# here, so direct lookup never tries Discogs (search-fallback still
|
||||
# runs for Discogs as one of the parallel sources).
|
||||
_STORED_ID_COLUMNS = {
|
||||
'spotify': 'spotify_track_id',
|
||||
'deezer': 'deezer_id',
|
||||
'itunes': 'itunes_track_id',
|
||||
'hydrabase': 'soul_id',
|
||||
}
|
||||
|
||||
|
||||
def _enhanced_to_wishlist_payload(enhanced: dict,
|
||||
fallback_title: str,
|
||||
fallback_artist: str,
|
||||
fallback_album: str) -> Optional[dict]:
|
||||
"""Convert a ``get_track_details`` enhanced-shape dict to the
|
||||
Spotify-shape wishlist payload.
|
||||
|
||||
Every metadata source's ``get_track_details`` returns the same
|
||||
"enhanced" intermediate shape (top-level ``id``, ``name``,
|
||||
``artists`` as a list of strings, ``album.artists`` as strings),
|
||||
documented and pinned across spotify_client / itunes_client /
|
||||
deezer_client / hydrabase_client. The wishlist downstream expects
|
||||
Spotify's native shape (``artists`` as ``[{'name': ...}]``), so
|
||||
this helper does the conversion in one place.
|
||||
|
||||
Spotify's ``raw_data`` field is already in wishlist shape (the
|
||||
raw Spotify API response), so we return it as-is when detected,
|
||||
preserving full ``album.images`` and ``external_urls`` that the
|
||||
enhanced top-level fields drop. Other sources' ``raw_data`` is
|
||||
in source-native shape and gets ignored.
|
||||
"""
|
||||
if not enhanced:
|
||||
return None
|
||||
raw = enhanced.get('raw_data')
|
||||
if isinstance(raw, dict):
|
||||
raw_artists = raw.get('artists')
|
||||
if (isinstance(raw_artists, list) and raw_artists
|
||||
and isinstance(raw_artists[0], dict)):
|
||||
return raw
|
||||
|
||||
artists = enhanced.get('artists') or [fallback_artist]
|
||||
album_data = enhanced.get('album') or {}
|
||||
album_artists = album_data.get('artists') or artists
|
||||
|
||||
def _to_dict_artists(seq):
|
||||
return [a if isinstance(a, dict) else {'name': a} for a in seq]
|
||||
|
||||
image_url = enhanced.get('image_url') or ''
|
||||
album_images_field = album_data.get('images')
|
||||
if isinstance(album_images_field, list) and album_images_field:
|
||||
album_images = album_images_field
|
||||
elif image_url:
|
||||
album_images = [{'url': image_url, 'height': 600, 'width': 600}]
|
||||
else:
|
||||
album_images = []
|
||||
|
||||
return {
|
||||
'id': str(enhanced.get('id', '')),
|
||||
'name': enhanced.get('name') or fallback_title,
|
||||
'artists': _to_dict_artists(artists),
|
||||
'album': {
|
||||
'id': str(album_data.get('id', '')),
|
||||
'name': album_data.get('name') or fallback_album,
|
||||
'album_type': album_data.get('album_type', 'album'),
|
||||
'release_date': album_data.get('release_date', ''),
|
||||
'total_tracks': album_data.get('total_tracks', 1),
|
||||
'artists': _to_dict_artists(album_artists),
|
||||
'images': album_images,
|
||||
},
|
||||
'duration_ms': enhanced.get('duration_ms', 0),
|
||||
'track_number': enhanced.get('track_number', 1),
|
||||
'disc_number': enhanced.get('disc_number', 1),
|
||||
'popularity': enhanced.get('popularity', 0),
|
||||
'preview_url': enhanced.get('preview_url'),
|
||||
'external_urls': enhanced.get('external_urls', {}),
|
||||
}
|
||||
|
||||
|
||||
def _try_direct_lookup_all_sources(track: dict,
|
||||
sources: list,
|
||||
preferred_source: Optional[str],
|
||||
title: str,
|
||||
artist_name: str,
|
||||
album_title: str
|
||||
) -> tuple:
|
||||
"""Try direct ID-based lookup on every source where the library
|
||||
track has a stored ID. Returns ``(payload, source_name)`` on first
|
||||
success, or ``(None, None)`` if no source has a stored ID with a
|
||||
successful lookup.
|
||||
|
||||
Mirrors what Download Discography does — stable IDs straight to the
|
||||
source's API, no fuzzy text matching. Avoids the failure mode where
|
||||
library text tags don't match the source's canonical title (the
|
||||
Discord report case: track tagged "Title (Live)" and source has
|
||||
"Title" → fuzzy search misses, but stored ID resolves directly).
|
||||
|
||||
Preferred source attempted first when present in ``sources``,
|
||||
typically the user's configured primary metadata source — so a
|
||||
Deezer-primary user gets Deezer art / album shape on the wishlist
|
||||
entry instead of whichever source happened to have a stored ID
|
||||
first in iteration order.
|
||||
"""
|
||||
def _priority(entry):
|
||||
name = entry[0]
|
||||
return 0 if name == preferred_source else 1
|
||||
ordered = sorted(sources, key=_priority)
|
||||
|
||||
for source_name, client in ordered:
|
||||
column = _STORED_ID_COLUMNS.get(source_name)
|
||||
if not column:
|
||||
continue
|
||||
stored_id = track.get(column)
|
||||
if not stored_id:
|
||||
continue
|
||||
if not hasattr(client, 'get_track_details'):
|
||||
continue
|
||||
try:
|
||||
enhanced = client.get_track_details(str(stored_id))
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"[Enhance] {source_name} direct lookup failed for "
|
||||
f"ID {stored_id}: {exc}"
|
||||
)
|
||||
continue
|
||||
if not enhanced:
|
||||
continue
|
||||
payload = _enhanced_to_wishlist_payload(
|
||||
enhanced, title, artist_name, album_title,
|
||||
)
|
||||
if _has_complete_metadata(payload):
|
||||
logger.info(
|
||||
f"[Enhance] Direct lookup matched: {source_name} "
|
||||
f"ID {stored_id} → '{payload.get('name')}'"
|
||||
)
|
||||
return payload, source_name
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
# Minimum match-score threshold for accepting a search-fallback match
|
||||
# without user confirmation. Mirrors the legacy threshold the enhance
|
||||
# flow has always used.
|
||||
_AUTO_ACCEPT_SCORE_THRESHOLD = 0.7
|
||||
|
||||
|
||||
def enhance_artist_quality(artist_id, track_ids, deps: ArtistQualityDeps):
|
||||
"""Add selected tracks to wishlist for quality enhancement re-download."""
|
||||
"""Add selected tracks to wishlist for quality enhancement re-download.
|
||||
|
||||
Per-track flow:
|
||||
|
||||
1. **Direct lookup using stored source IDs** (mirrors what Download
|
||||
Discography does — stable IDs straight to the source's API, no
|
||||
fuzzy text matching). For each source the user has configured,
|
||||
if the library track has the corresponding stored ID
|
||||
(``spotify_track_id`` / ``deezer_id`` / ``itunes_track_id`` /
|
||||
``soul_id``), call ``client.get_track_details(stored_id)`` and
|
||||
convert to wishlist payload. First success wins; preferred
|
||||
source (user's configured primary) tried first.
|
||||
|
||||
2. **Multi-source parallel text search fallback** (via the shared
|
||||
``core.metadata.multi_source_search`` module — same code path
|
||||
Track Redownload uses) for tracks with no stored IDs / lookup
|
||||
misses.
|
||||
|
||||
3. **Validation**: reject matches with empty title / album / artists
|
||||
so the user sees a clear failure instead of an "unknown artist"
|
||||
wishlist entry.
|
||||
|
||||
Pre-refactor: only Spotify had a direct-lookup fast path; everything
|
||||
else went through fuzzy text search. Discogs / Hydrabase / Deezer-
|
||||
primary users got far worse coverage than Download Discography
|
||||
despite both flows asking the same question.
|
||||
"""
|
||||
from core.metadata.multi_source_search import TrackQuery, search_all_sources
|
||||
from core.metadata.registry import get_primary_source
|
||||
|
||||
try:
|
||||
if not track_ids:
|
||||
return {"success": False, "error": "No track IDs provided"}, 400
|
||||
|
|
@ -73,6 +361,18 @@ def enhance_artist_quality(artist_id, track_ids, deps: ArtistQualityDeps):
|
|||
track['_album_id'] = album.get('id')
|
||||
track_lookup[tid] = track
|
||||
|
||||
# Resolve every configured metadata source up front.
|
||||
search_sources = deps.get_metadata_search_sources()
|
||||
|
||||
# User's configured primary source — direct-lookup tries this
|
||||
# first so Deezer-primary users get Deezer payloads on the
|
||||
# wishlist entry (correct cover art / album shape) even when
|
||||
# other sources also have stored IDs for the same track.
|
||||
try:
|
||||
preferred_source = get_primary_source()
|
||||
except Exception:
|
||||
preferred_source = None
|
||||
|
||||
enhanced_count = 0
|
||||
failed_count = 0
|
||||
failed_tracks = []
|
||||
|
|
@ -95,200 +395,67 @@ def enhance_artist_quality(artist_id, track_ids, deps: ArtistQualityDeps):
|
|||
title = track.get('title', '') or ''
|
||||
if not title.strip():
|
||||
title = os.path.splitext(os.path.basename(file_path))[0]
|
||||
spotify_tid = track.get('spotify_track_id')
|
||||
album_title = track.get('_album_title', '')
|
||||
|
||||
# Build Spotify track data for wishlist
|
||||
matched_track_data = None
|
||||
chosen_source = None
|
||||
|
||||
if spotify_tid and deps.spotify_client:
|
||||
# Direct lookup via stored Spotify ID — raw_data has full Spotify API format
|
||||
# 1. Direct lookup via every stored source ID — like Download
|
||||
# Discography. Stable IDs, no fuzzy text matching.
|
||||
if search_sources:
|
||||
matched_track_data, chosen_source = _try_direct_lookup_all_sources(
|
||||
track, search_sources, preferred_source,
|
||||
title, artist_name, album_title,
|
||||
)
|
||||
|
||||
# 2. Multi-source parallel text search fallback — for tracks
|
||||
# with no stored IDs / lookup misses.
|
||||
if not matched_track_data and search_sources:
|
||||
try:
|
||||
track_details = deps.spotify_client.get_track_details(spotify_tid)
|
||||
if track_details and track_details.get('raw_data'):
|
||||
matched_track_data = track_details['raw_data']
|
||||
elif track_details:
|
||||
# Enhanced format — rebuild with images for wishlist compatibility
|
||||
album_data = track_details.get('album', {})
|
||||
album_images = []
|
||||
# Try to get album art from a full album lookup
|
||||
if album_data.get('id'):
|
||||
try:
|
||||
full_album = deps.spotify_client.get_album(album_data['id'])
|
||||
if full_album and full_album.get('images'):
|
||||
album_images = full_album['images']
|
||||
except Exception:
|
||||
pass
|
||||
matched_track_data = {
|
||||
'id': spotify_tid,
|
||||
'name': track_details.get('name', title),
|
||||
'artists': [{'name': a} for a in track_details.get('artists', [artist_name])],
|
||||
'album': {
|
||||
'id': album_data.get('id', ''),
|
||||
'name': album_data.get('name', track.get('_album_title', '')),
|
||||
'album_type': album_data.get('album_type', 'album'),
|
||||
'release_date': album_data.get('release_date', ''),
|
||||
'total_tracks': album_data.get('total_tracks', 1),
|
||||
'artists': [{'name': a} for a in album_data.get('artists', [artist_name])],
|
||||
'images': album_images,
|
||||
},
|
||||
'duration_ms': track_details.get('duration_ms', track.get('duration', 0)),
|
||||
'track_number': track_details.get('track_number', track.get('track_number', 1)),
|
||||
'disc_number': track_details.get('disc_number', 1),
|
||||
'popularity': 0,
|
||||
'preview_url': None,
|
||||
'external_urls': {},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[Enhance] Spotify lookup failed for {spotify_tid}: {e}")
|
||||
|
||||
if not matched_track_data and deps.spotify_client:
|
||||
# Fallback: Spotify search matching — need full track data for wishlist
|
||||
try:
|
||||
temp_track = type('TempTrack', (), {
|
||||
'name': title, 'artists': [artist_name],
|
||||
'album': track.get('_album_title', '')
|
||||
})()
|
||||
search_queries = deps.matching_engine.generate_download_queries(temp_track)
|
||||
best_match = None
|
||||
best_match_raw = None
|
||||
best_confidence = 0.0
|
||||
|
||||
for search_query in search_queries[:3]: # Limit queries
|
||||
try:
|
||||
results = deps.spotify_client.search_tracks(search_query, limit=5)
|
||||
if not results:
|
||||
continue
|
||||
for sp_track in results:
|
||||
artist_conf = max(
|
||||
(deps.matching_engine.similarity_score(
|
||||
deps.matching_engine.normalize_string(artist_name),
|
||||
deps.matching_engine.normalize_string(a)
|
||||
) for a in (sp_track.artists or [artist_name])),
|
||||
default=0
|
||||
)
|
||||
title_conf = deps.matching_engine.similarity_score(
|
||||
deps.matching_engine.normalize_string(title),
|
||||
deps.matching_engine.normalize_string(sp_track.name)
|
||||
)
|
||||
combined = artist_conf * 0.5 + title_conf * 0.5
|
||||
# Small bonus for album tracks over singles
|
||||
_at = getattr(sp_track, 'album_type', None) or ''
|
||||
if _at == 'album':
|
||||
combined += 0.02
|
||||
elif _at == 'ep':
|
||||
combined += 0.01
|
||||
if combined > best_confidence and combined >= 0.7:
|
||||
best_confidence = combined
|
||||
best_match = sp_track
|
||||
if best_confidence >= 0.9:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if best_match:
|
||||
# Fetch full track data from Spotify for proper wishlist format
|
||||
try:
|
||||
full_details = deps.spotify_client.get_track_details(best_match.id)
|
||||
if full_details and full_details.get('raw_data'):
|
||||
matched_track_data = full_details['raw_data']
|
||||
else:
|
||||
raise ValueError("No raw_data from get_track_details")
|
||||
except Exception:
|
||||
# Build from Track dataclass with image
|
||||
album_images = [{'url': best_match.image_url}] if best_match.image_url else []
|
||||
matched_track_data = {
|
||||
'id': best_match.id,
|
||||
'name': best_match.name,
|
||||
'artists': [{'name': a} for a in best_match.artists],
|
||||
'album': {
|
||||
'name': best_match.album,
|
||||
'artists': [{'name': a} for a in best_match.artists],
|
||||
'album_type': 'album',
|
||||
'release_date': getattr(best_match, 'release_date', '') or '',
|
||||
'images': album_images,
|
||||
},
|
||||
'duration_ms': best_match.duration_ms,
|
||||
'popularity': best_match.popularity or 0,
|
||||
'preview_url': best_match.preview_url,
|
||||
'external_urls': best_match.external_urls or {},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[Enhance] Search match failed for {title}: {e}")
|
||||
|
||||
# Fallback source when Spotify unavailable or no match found
|
||||
if not matched_track_data:
|
||||
try:
|
||||
fallback_client = deps.get_metadata_fallback_client()
|
||||
itunes_best = None
|
||||
itunes_best_conf = 0.0
|
||||
|
||||
itunes_queries = deps.matching_engine.generate_download_queries(
|
||||
type('TempTrack', (), {
|
||||
'name': title, 'artists': [artist_name],
|
||||
'album': track.get('_album_title', '')
|
||||
})()
|
||||
track_query = TrackQuery(
|
||||
title=title,
|
||||
artist=artist_name,
|
||||
album=album_title,
|
||||
duration_ms=track.get('duration', 0) or 0,
|
||||
spotify_track_id=track.get('spotify_track_id'),
|
||||
deezer_id=track.get('deezer_id'),
|
||||
)
|
||||
multi_result = search_all_sources(track_query, search_sources)
|
||||
if multi_result.best_match and multi_result.best_match['score'] >= _AUTO_ACCEPT_SCORE_THRESHOLD:
|
||||
chosen_source = multi_result.best_match['source']
|
||||
best_track_obj = multi_result.best_track()
|
||||
if best_track_obj:
|
||||
matched_track_data = _build_payload_from_track(best_track_obj)
|
||||
except Exception as exc:
|
||||
logger.error(f"[Enhance] Multi-source search failed for {title}: {exc}")
|
||||
|
||||
for search_query in itunes_queries[:3]:
|
||||
try:
|
||||
itunes_results = fallback_client.search_tracks(search_query, limit=5)
|
||||
if not itunes_results:
|
||||
continue
|
||||
for it_track in itunes_results:
|
||||
artist_conf = max(
|
||||
(deps.matching_engine.similarity_score(
|
||||
deps.matching_engine.normalize_string(artist_name),
|
||||
deps.matching_engine.normalize_string(a)
|
||||
) for a in (it_track.artists or [artist_name])),
|
||||
default=0
|
||||
)
|
||||
title_conf = deps.matching_engine.similarity_score(
|
||||
deps.matching_engine.normalize_string(title),
|
||||
deps.matching_engine.normalize_string(it_track.name)
|
||||
)
|
||||
combined = artist_conf * 0.5 + title_conf * 0.5
|
||||
# Small bonus for album tracks over singles
|
||||
_at = getattr(it_track, 'album_type', None) or ''
|
||||
if _at == 'album':
|
||||
combined += 0.02
|
||||
elif _at == 'ep':
|
||||
combined += 0.01
|
||||
if combined > itunes_best_conf and combined >= 0.7:
|
||||
itunes_best_conf = combined
|
||||
itunes_best = it_track
|
||||
if itunes_best_conf >= 0.9:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if itunes_best:
|
||||
album_images = [{'url': itunes_best.image_url, 'height': 600, 'width': 600}] if itunes_best.image_url else []
|
||||
matched_track_data = {
|
||||
'id': itunes_best.id,
|
||||
'name': itunes_best.name,
|
||||
'artists': [{'name': a} for a in itunes_best.artists],
|
||||
'album': {
|
||||
'name': itunes_best.album,
|
||||
'artists': [{'name': a} for a in itunes_best.artists],
|
||||
'album_type': 'album',
|
||||
'images': album_images,
|
||||
'release_date': itunes_best.release_date or '',
|
||||
'total_tracks': 1,
|
||||
},
|
||||
'duration_ms': itunes_best.duration_ms,
|
||||
'track_number': itunes_best.track_number or 1,
|
||||
'disc_number': itunes_best.disc_number or 1,
|
||||
'popularity': itunes_best.popularity or 0,
|
||||
'preview_url': itunes_best.preview_url,
|
||||
'external_urls': itunes_best.external_urls or {},
|
||||
}
|
||||
logger.warning(f"[Enhance] Fallback match for {title}: {itunes_best.artists[0]} - {itunes_best.name} (conf: {itunes_best_conf:.3f})")
|
||||
except Exception as e:
|
||||
logger.error(f"[Enhance] Fallback source failed for {title}: {e}")
|
||||
# 3. Reject matches with empty / missing core fields.
|
||||
if not _has_complete_metadata(matched_track_data):
|
||||
if matched_track_data:
|
||||
logger.warning(
|
||||
f"[Enhance] {chosen_source} match for '{title}' rejected — "
|
||||
f"empty title / album / artists (would render as 'unknown')"
|
||||
)
|
||||
matched_track_data = None
|
||||
|
||||
if not matched_track_data:
|
||||
failed_count += 1
|
||||
failed_tracks.append({'track_id': track_id, 'title': title, 'reason': 'No Spotify or fallback match'})
|
||||
source_list = ', '.join(name for name, _ in (search_sources or []))
|
||||
if not source_list:
|
||||
reason = (
|
||||
'No metadata source configured — connect Spotify / '
|
||||
'iTunes / Deezer / Discogs / Hydrabase to enable enhance'
|
||||
)
|
||||
else:
|
||||
reason = (
|
||||
f'No usable match across {source_list} — '
|
||||
f'try connecting an additional metadata source'
|
||||
)
|
||||
failed_tracks.append({
|
||||
'track_id': track_id,
|
||||
'title': title,
|
||||
'reason': reason,
|
||||
})
|
||||
continue
|
||||
|
||||
# Add to wishlist with enhance source
|
||||
|
|
|
|||
|
|
@ -140,8 +140,8 @@ class AudioDBWorker:
|
|||
itype = item.get('type', '')
|
||||
table = 'artists' if 'artist' in itype else ('albums' if 'album' in itype else 'tracks')
|
||||
# Can't mark status without an ID — just skip
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("null id table resolve failed: %s", e)
|
||||
continue
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,11 @@ class FolderCandidate:
|
|||
disc_structure: Dict[int, List[str]] = field(default_factory=dict) # disc_num -> files
|
||||
folder_hash: str = ''
|
||||
is_single: bool = False # True for loose files in staging root
|
||||
# True when the candidate "folder" is the staging root itself (user dropped
|
||||
# disc folders directly into staging without an album wrapper). The name is
|
||||
# meaningless ("Staging", "Music", etc.) — folder-name identification must
|
||||
# be skipped or it will false-match against random albums.
|
||||
is_staging_root: bool = False
|
||||
|
||||
|
||||
def _compute_folder_hash(audio_files: List[str]) -> str:
|
||||
|
|
@ -58,7 +63,11 @@ def _read_file_tags(file_path: str) -> Dict[str, Any]:
|
|||
if audio and audio.tags:
|
||||
tags = audio.tags
|
||||
result['title'] = (tags.get('title', [''])[0] or '').strip()
|
||||
result['artist'] = (tags.get('artist', [''])[0] or tags.get('albumartist', [''])[0] or '').strip()
|
||||
# Prefer albumartist for album-level identification (per-track artist
|
||||
# often includes features like "Kendrick Lamar, Drake" which fragment
|
||||
# consensus when grouping tracks into an album). Fall back to artist
|
||||
# for files that lack albumartist.
|
||||
result['artist'] = (tags.get('albumartist', [''])[0] or tags.get('artist', [''])[0] or '').strip()
|
||||
result['album'] = (tags.get('album', [''])[0] or '').strip()
|
||||
# Date/year — try 'date' first, fall back to 'year'
|
||||
date_str = (tags.get('date', [''])[0] or tags.get('year', [''])[0] or '').strip()
|
||||
|
|
@ -137,7 +146,15 @@ class AutoImportWorker:
|
|||
self._folder_snapshots: Dict[str, float] = {} # path -> mtime_sum
|
||||
self._processing_paths: set = set() # Paths currently being processed (skip on rescan)
|
||||
self._current_folder = ''
|
||||
self._current_status = 'idle'
|
||||
self._current_status = 'idle' # 'idle' | 'scanning' | 'processing'
|
||||
# Live per-track progress so the UI can show "Processing Speak Now
|
||||
# (3/14: Mine)" while a multi-track album is being post-processed.
|
||||
# Without this, auto-import goes silent for the entire processing
|
||||
# window (which can be 5+ minutes for a full album) since
|
||||
# ``_record_result`` only fires after every track is done.
|
||||
self._current_track_index = 0
|
||||
self._current_track_total = 0
|
||||
self._current_track_name = ''
|
||||
self._stats = {'scanned': 0, 'auto_processed': 0, 'pending_review': 0, 'failed': 0}
|
||||
self._last_scan_time = None
|
||||
|
||||
|
|
@ -173,6 +190,9 @@ class AutoImportWorker:
|
|||
'paused': self.paused,
|
||||
'current_folder': self._current_folder,
|
||||
'current_status': self._current_status,
|
||||
'current_track_index': self._current_track_index,
|
||||
'current_track_total': self._current_track_total,
|
||||
'current_track_name': self._current_track_name,
|
||||
'stats': self._stats.copy(),
|
||||
'last_scan_time': self._last_scan_time,
|
||||
}
|
||||
|
|
@ -287,11 +307,19 @@ class AutoImportWorker:
|
|||
has_strong_individual_matches = len(high_conf_matches) > 0
|
||||
|
||||
if (confidence >= threshold or has_strong_individual_matches) and auto_process:
|
||||
# Phase 5: Auto-process — process all tracks that matched
|
||||
# Phase 5: Auto-process — insert an in-progress row
|
||||
# so the UI sees the import the moment it starts,
|
||||
# then update it with the final status when done.
|
||||
effective_conf = max(confidence, min(m['confidence'] for m in high_conf_matches) if high_conf_matches else 0)
|
||||
logger.info(f"[Auto-Import] Processing {candidate.name} — "
|
||||
f"overall: {confidence:.0%}, {len(high_conf_matches)} strong matches, "
|
||||
f"{match_result.get('matched_count', 0)}/{match_result.get('total_tracks', '?')} tracks")
|
||||
|
||||
in_progress_row_id = self._record_in_progress(
|
||||
candidate, identification, match_result,
|
||||
)
|
||||
self._current_status = 'processing'
|
||||
|
||||
success = self._process_matches(candidate, identification, match_result)
|
||||
status = 'completed' if success else 'failed'
|
||||
confidence = max(confidence, effective_conf)
|
||||
|
|
@ -299,22 +327,38 @@ class AutoImportWorker:
|
|||
self._stats['auto_processed'] += 1
|
||||
else:
|
||||
self._stats['failed'] += 1
|
||||
|
||||
# Reset live progress state regardless of outcome
|
||||
self._current_track_index = 0
|
||||
self._current_track_total = 0
|
||||
self._current_track_name = ''
|
||||
self._current_status = 'scanning' if not self.should_stop else 'idle'
|
||||
|
||||
# Update the in-progress row in place — UI shows the
|
||||
# final result without a separate insert race.
|
||||
self._finalize_result(in_progress_row_id, status, confidence)
|
||||
elif confidence >= 0.7:
|
||||
status = 'pending_review'
|
||||
self._stats['pending_review'] += 1
|
||||
logger.info(f"[Auto-Import] Medium confidence ({confidence:.0%}) — pending review: {candidate.name}")
|
||||
self._record_result(candidate, status, confidence,
|
||||
album_id=identification.get('album_id'),
|
||||
album_name=identification.get('album_name'),
|
||||
artist_name=identification.get('artist_name'),
|
||||
image_url=identification.get('image_url'),
|
||||
identification_method=identification.get('method'),
|
||||
match_data=match_result)
|
||||
else:
|
||||
status = 'needs_identification'
|
||||
self._stats['failed'] += 1
|
||||
logger.info(f"[Auto-Import] Low confidence ({confidence:.0%}) — needs manual ID: {candidate.name}")
|
||||
|
||||
self._record_result(candidate, status, confidence,
|
||||
album_id=identification.get('album_id'),
|
||||
album_name=identification.get('album_name'),
|
||||
artist_name=identification.get('artist_name'),
|
||||
image_url=identification.get('image_url'),
|
||||
identification_method=identification.get('method'),
|
||||
match_data=match_result)
|
||||
self._record_result(candidate, status, confidence,
|
||||
album_id=identification.get('album_id'),
|
||||
album_name=identification.get('album_name'),
|
||||
artist_name=identification.get('artist_name'),
|
||||
image_url=identification.get('image_url'),
|
||||
identification_method=identification.get('method'),
|
||||
match_data=match_result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Auto-Import] Error processing {candidate.name}: {e}")
|
||||
|
|
@ -322,6 +366,12 @@ class AutoImportWorker:
|
|||
self._stats['failed'] += 1
|
||||
finally:
|
||||
self._processing_paths.discard(candidate.path)
|
||||
# Defensive: if the inner code path didn't reset live
|
||||
# progress (early raise, etc.), clear it so the UI
|
||||
# doesn't show stale "processing track 3/14" forever.
|
||||
self._current_track_index = 0
|
||||
self._current_track_total = 0
|
||||
self._current_track_name = ''
|
||||
|
||||
# Rate limit between folders
|
||||
if self._interruptible_sleep(2):
|
||||
|
|
@ -344,10 +394,10 @@ class AutoImportWorker:
|
|||
def _enumerate_folders(self, staging: str) -> List[FolderCandidate]:
|
||||
"""Find album folder and single file candidates in staging directory (recursive)."""
|
||||
candidates = []
|
||||
self._scan_directory(staging, candidates)
|
||||
self._scan_directory(staging, candidates, staging_root=staging)
|
||||
return candidates
|
||||
|
||||
def _scan_directory(self, directory: str, candidates: List[FolderCandidate]):
|
||||
def _scan_directory(self, directory: str, candidates: List[FolderCandidate], staging_root: str = ''):
|
||||
"""Recursively scan a directory for album folders and loose audio files."""
|
||||
try:
|
||||
entries = sorted(os.listdir(directory))
|
||||
|
|
@ -403,12 +453,45 @@ class AutoImportWorker:
|
|||
disc_structure=disc_structure, folder_hash=folder_hash
|
||||
))
|
||||
else:
|
||||
# No audio files here — recurse into subdirectories
|
||||
for sub_name, sub_path in subdirs:
|
||||
# Skip disc folders at this level (they'll be handled by the parent album)
|
||||
if DISC_FOLDER_RE.match(sub_name):
|
||||
continue
|
||||
self._scan_directory(sub_path, candidates)
|
||||
# No loose audio files. If the only subdirs are disc folders,
|
||||
# treat THIS directory as the album candidate (multi-disc album
|
||||
# with no album-level loose files — common when a user drops
|
||||
# `Album/Disc 1/`, `Album/Disc 2/` straight into staging, or
|
||||
# drops `Disc 1/`, `Disc 2/` with the staging dir itself as
|
||||
# the album root).
|
||||
disc_subdirs = [(n, p) for n, p in subdirs if DISC_FOLDER_RE.match(n)]
|
||||
non_disc_subdirs = [(n, p) for n, p in subdirs if not DISC_FOLDER_RE.match(n)]
|
||||
|
||||
if disc_subdirs and not non_disc_subdirs:
|
||||
disc_structure = {}
|
||||
audio_files = []
|
||||
for sub_name, sub_path in disc_subdirs:
|
||||
disc_num = int(DISC_FOLDER_RE.match(sub_name).group(1))
|
||||
try:
|
||||
disc_files = [os.path.join(sub_path, f) for f in sorted(os.listdir(sub_path))
|
||||
if os.path.isfile(os.path.join(sub_path, f))
|
||||
and os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS]
|
||||
except OSError:
|
||||
disc_files = []
|
||||
if disc_files:
|
||||
disc_structure[disc_num] = disc_files
|
||||
audio_files.extend(disc_files)
|
||||
|
||||
if audio_files:
|
||||
folder_name = os.path.basename(directory)
|
||||
folder_hash = _compute_folder_hash(audio_files)
|
||||
is_staging_root = bool(staging_root) and os.path.normpath(directory) == os.path.normpath(staging_root)
|
||||
candidates.append(FolderCandidate(
|
||||
path=directory, name=folder_name, audio_files=audio_files,
|
||||
disc_structure=disc_structure, folder_hash=folder_hash,
|
||||
is_staging_root=is_staging_root,
|
||||
))
|
||||
return
|
||||
|
||||
# Otherwise recurse into non-disc subdirs (disc folders only
|
||||
# ever attach to a parent album, never stand alone).
|
||||
for _sub_name, sub_path in non_disc_subdirs:
|
||||
self._scan_directory(sub_path, candidates, staging_root=staging_root)
|
||||
|
||||
def _is_folder_stable(self, candidate: FolderCandidate) -> bool:
|
||||
"""Check if folder contents have stopped changing."""
|
||||
|
|
@ -450,10 +533,15 @@ class AutoImportWorker:
|
|||
if tag_result:
|
||||
return tag_result
|
||||
|
||||
# Strategy 2: Parse folder name
|
||||
folder_result = self._identify_from_folder_name(candidate)
|
||||
if folder_result:
|
||||
return folder_result
|
||||
# Strategy 2: Parse folder name (skip when the candidate is the staging
|
||||
# root itself — the folder name is meaningless and will false-match
|
||||
# against random albums in the metadata source).
|
||||
if candidate.is_staging_root:
|
||||
logger.info(f"[Auto-Import] Skipping folder-name identification for staging root '{candidate.name}' — would false-match. Falling through to AcoustID.")
|
||||
else:
|
||||
folder_result = self._identify_from_folder_name(candidate)
|
||||
if folder_result:
|
||||
return folder_result
|
||||
|
||||
# Strategy 3: AcoustID fingerprint
|
||||
acoustid_result = self._identify_from_acoustid(candidate)
|
||||
|
|
@ -508,8 +596,8 @@ class AutoImportWorker:
|
|||
# Keep weak AcoustID result as fallback
|
||||
if fp_result2 and (not result or fp_result2.get('identification_confidence', 0) > result.get('identification_confidence', 0)):
|
||||
result = fp_result2
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("acoustid fingerprint fallback failed: %s", e)
|
||||
|
||||
# If we have good tag data (artist + title), prefer tag-based identification
|
||||
# over a weak metadata/AcoustID result — tags from post-processed files are reliable
|
||||
|
|
@ -643,29 +731,47 @@ class AutoImportWorker:
|
|||
def _identify_from_tags(self, candidate: FolderCandidate) -> Optional[Dict]:
|
||||
"""Try to identify album from embedded file tags."""
|
||||
tags_list = []
|
||||
for f in candidate.audio_files[:20]: # Cap at 20 files
|
||||
sampled = candidate.audio_files[:20] # Cap at 20 files
|
||||
for f in sampled:
|
||||
tags = _read_file_tags(f)
|
||||
if tags['album'] and tags['artist']:
|
||||
tags_list.append(tags)
|
||||
|
||||
if len(tags_list) < max(1, len(candidate.audio_files) * 0.5):
|
||||
if len(tags_list) < max(1, len(sampled) * 0.5):
|
||||
logger.info(f"[Auto-Import] Tag identification rejected for '{candidate.name}' — only {len(tags_list)}/{len(sampled)} files have album+artist tags (need >=50%)")
|
||||
return None # Less than 50% of files have usable tags
|
||||
|
||||
# Check consistency — most common album+artist
|
||||
album_artist_counts = {}
|
||||
# Group by album first (album-level identity). Per-track artist often
|
||||
# varies due to features ("Artist", "Artist, Drake", etc.) so grouping
|
||||
# by (album, artist) fragments consensus on a real album. Pick the
|
||||
# dominant album, then within that album pick the most-common artist
|
||||
# (which will usually be the album's primary artist).
|
||||
album_counts = {}
|
||||
for t in tags_list:
|
||||
key = (t['album'].lower().strip(), t['artist'].lower().strip())
|
||||
album_artist_counts[key] = album_artist_counts.get(key, 0) + 1
|
||||
album_key = t['album'].lower().strip()
|
||||
album_counts[album_key] = album_counts.get(album_key, 0) + 1
|
||||
|
||||
if not album_artist_counts:
|
||||
if not album_counts:
|
||||
return None
|
||||
|
||||
best_key, best_count = max(album_artist_counts.items(), key=lambda x: x[1])
|
||||
if best_count < len(tags_list) * 0.6:
|
||||
return None # Tags too inconsistent
|
||||
best_album, best_album_count = max(album_counts.items(), key=lambda x: x[1])
|
||||
if best_album_count < len(tags_list) * 0.6:
|
||||
sample = ', '.join([f"'{a}' x{c}" for a, c in sorted(album_counts.items(), key=lambda x: -x[1])[:3]])
|
||||
logger.info(f"[Auto-Import] Tag identification rejected for '{candidate.name}' — best album '{best_album}' only {best_album_count}/{len(tags_list)} files (need >=60%). Top albums: {sample}")
|
||||
return None
|
||||
|
||||
album_name, artist_name = best_key
|
||||
return self._search_metadata_source(artist_name, album_name, 'tags', candidate)
|
||||
# Most-common artist among files matching the dominant album
|
||||
artist_counts = {}
|
||||
for t in tags_list:
|
||||
if t['album'].lower().strip() == best_album:
|
||||
a = t['artist'].lower().strip()
|
||||
if a:
|
||||
artist_counts[a] = artist_counts.get(a, 0) + 1
|
||||
if not artist_counts:
|
||||
return None
|
||||
artist_name, _ = max(artist_counts.items(), key=lambda x: x[1])
|
||||
|
||||
return self._search_metadata_source(artist_name, best_album, 'tags', candidate)
|
||||
|
||||
def _identify_from_folder_name(self, candidate: FolderCandidate) -> Optional[Dict]:
|
||||
"""Try to identify album from folder name."""
|
||||
|
|
@ -994,8 +1100,8 @@ class AutoImportWorker:
|
|||
if folder_artist and folder_artist.lower() != artist_name.lower():
|
||||
logger.info(f"[Auto-Import] Parent folder artist '{folder_artist}' differs from tag artist '{artist_name}' — using folder artist")
|
||||
artist_name = folder_artist
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("folder artist override failed: %s", e)
|
||||
release_date = identification.get('release_date', '') or album_data.get('release_date', '')
|
||||
|
||||
# Compute total discs
|
||||
|
|
@ -1005,21 +1111,31 @@ class AutoImportWorker:
|
|||
|
||||
processed = 0
|
||||
errors = []
|
||||
all_matches = list(match_result.get('matches', []))
|
||||
# Surface track total for the UI's live-progress widget. Matches
|
||||
# the loop denominator so users see "3/14" while it's working.
|
||||
self._current_track_total = len(all_matches)
|
||||
|
||||
for match in match_result.get('matches', []):
|
||||
for index, match in enumerate(all_matches, start=1):
|
||||
track = match['track']
|
||||
file_path = match['file']
|
||||
|
||||
track_name = track.get('name', 'Unknown')
|
||||
track_number = track.get('track_number', 1)
|
||||
disc_number = track.get('disc_number', 1)
|
||||
track_id = track.get('id', '')
|
||||
|
||||
# Update live progress BEFORE the per-track work so the UI
|
||||
# sees the right "now processing track N: <name>" the
|
||||
# moment polling fires (every 5s).
|
||||
self._current_track_index = index
|
||||
self._current_track_name = track_name
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
errors.append(f"File not found: {os.path.basename(file_path)}")
|
||||
continue
|
||||
|
||||
try:
|
||||
track_name = track.get('name', 'Unknown')
|
||||
track_number = track.get('track_number', 1)
|
||||
disc_number = track.get('disc_number', 1)
|
||||
track_id = track.get('id', '')
|
||||
|
||||
# Build context matching the manual import format
|
||||
context_key = f"auto_import_{candidate.folder_hash}_{track_number}"
|
||||
context = {
|
||||
|
|
@ -1086,34 +1202,107 @@ class AutoImportWorker:
|
|||
'completed_tracks': str(processed),
|
||||
'failed_tracks': str(len(errors)),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("automation emit failed: %s", e)
|
||||
|
||||
return processed > 0
|
||||
|
||||
# ── Database ──
|
||||
|
||||
def _record_in_progress(self, candidate: FolderCandidate, identification: Dict,
|
||||
match_result: Dict) -> Optional[int]:
|
||||
"""Insert a status='processing' row up-front so the UI can see
|
||||
an in-flight import while it's still running. Returns the row's
|
||||
id so ``_finalize_result`` can update the same row when done.
|
||||
|
||||
Without this, auto-import goes silent for the entire processing
|
||||
window (5+ minutes for a full album) — the existing
|
||||
``_record_result`` only fires after every track is post-
|
||||
processed, so the UI sees nothing in history while the user
|
||||
waits.
|
||||
"""
|
||||
try:
|
||||
match_json = self._serialize_match_data(match_result)
|
||||
conn = self.database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO auto_import_history
|
||||
(folder_name, folder_path, folder_hash, status, confidence, album_id, album_name,
|
||||
artist_name, image_url, total_files, matched_files, match_data,
|
||||
identification_method, error_message, processed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
candidate.name, candidate.path, candidate.folder_hash,
|
||||
'processing', match_result.get('confidence', 0.0),
|
||||
identification.get('album_id'), identification.get('album_name'),
|
||||
identification.get('artist_name'), identification.get('image_url'),
|
||||
len(candidate.audio_files),
|
||||
match_result.get('matched_count', 0),
|
||||
match_json, identification.get('method'), None, None,
|
||||
))
|
||||
row_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return row_id
|
||||
except Exception as e:
|
||||
logger.error(f"Error recording in-progress auto-import row: {e}")
|
||||
return None
|
||||
|
||||
def _finalize_result(self, row_id: int, status: str, confidence: float,
|
||||
error_message: Optional[str] = None) -> None:
|
||||
"""Update the in-progress row created by ``_record_in_progress``
|
||||
with the final outcome. Idempotent — safe to call even if the
|
||||
row creation failed (row_id is None)."""
|
||||
if not row_id:
|
||||
return
|
||||
try:
|
||||
conn = self.database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE auto_import_history
|
||||
SET status = ?, confidence = ?, error_message = ?, processed_at = ?
|
||||
WHERE id = ?
|
||||
""", (
|
||||
status, confidence, error_message,
|
||||
datetime.now().isoformat() if status == 'completed' else None,
|
||||
row_id,
|
||||
))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error finalizing auto-import row {row_id}: {e}")
|
||||
|
||||
def _serialize_match_data(self, match_data: Optional[Dict]) -> Optional[str]:
|
||||
"""Serialize match_result for storage. Strips the non-JSON-safe
|
||||
``album_data`` reference and per-match track dicts down to just
|
||||
the fields the review UI uses."""
|
||||
if not match_data:
|
||||
return None
|
||||
try:
|
||||
serializable = {
|
||||
'matches': [{'track_name': m['track']['name'],
|
||||
'track_number': m['track'].get('track_number', 0),
|
||||
'file': os.path.basename(m['file']),
|
||||
'confidence': m['confidence']} for m in match_data.get('matches', [])],
|
||||
'unmatched_files': [os.path.basename(f) for f in match_data.get('unmatched_files', [])],
|
||||
'total_tracks': match_data.get('total_tracks', 0),
|
||||
'matched_count': match_data.get('matched_count', 0),
|
||||
'coverage': match_data.get('coverage', 0),
|
||||
}
|
||||
return json.dumps(serializable)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _record_result(self, candidate: FolderCandidate, status: str, confidence: float,
|
||||
album_id: str = None, album_name: str = None, artist_name: str = None,
|
||||
image_url: str = None, identification_method: str = None,
|
||||
match_data: Dict = None, error_message: str = None):
|
||||
"""Record auto-import result to database."""
|
||||
"""Record auto-import result to database (one-shot, no in-progress
|
||||
upsert). Used for early-failure paths that never enter the
|
||||
per-track processing loop (identification failures, match
|
||||
failures, low-confidence skips)."""
|
||||
try:
|
||||
# Serialize match data (strip non-serializable album_data)
|
||||
match_json = None
|
||||
if match_data:
|
||||
serializable = {
|
||||
'matches': [{'track_name': m['track']['name'],
|
||||
'track_number': m['track'].get('track_number', 0),
|
||||
'file': os.path.basename(m['file']),
|
||||
'confidence': m['confidence']} for m in match_data.get('matches', [])],
|
||||
'unmatched_files': [os.path.basename(f) for f in match_data.get('unmatched_files', [])],
|
||||
'total_tracks': match_data.get('total_tracks', 0),
|
||||
'matched_count': match_data.get('matched_count', 0),
|
||||
'coverage': match_data.get('coverage', 0),
|
||||
}
|
||||
match_json = json.dumps(serializable)
|
||||
|
||||
match_json = self._serialize_match_data(match_data)
|
||||
conn = self.database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
|
|
|
|||
|
|
@ -77,8 +77,8 @@ def update_progress(
|
|||
if socketio_emit is not None:
|
||||
try:
|
||||
socketio_emit('automation:progress', {str(automation_id): dict(state)})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("socketio progress emit: %s", e)
|
||||
|
||||
|
||||
def get_running_progress() -> dict[str, dict]:
|
||||
|
|
@ -121,8 +121,8 @@ def record_history(
|
|||
t0 = datetime.fromisoformat(started_at)
|
||||
t1 = datetime.fromisoformat(finished_at)
|
||||
duration = (t1 - t0).total_seconds()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("duration parse: %s", e)
|
||||
|
||||
r_status = result.get('status', 'completed') if result else 'completed'
|
||||
if r_status == 'error':
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ names from the saved automation set so the builder UI can autocomplete.
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def collect_known_signals(database) -> list[str]:
|
||||
|
|
@ -38,6 +41,6 @@ def collect_known_signals(database) -> list[str]:
|
|||
signals.add(sig)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("collect known signals failed: %s", e)
|
||||
return sorted(signals)
|
||||
|
|
|
|||
|
|
@ -455,8 +455,10 @@ class AutomationEngine:
|
|||
if delay_minutes and delay_minutes > 0:
|
||||
# Initialize progress BEFORE delay so card glows during wait
|
||||
if self._progress_init_fn:
|
||||
try: self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
||||
except Exception: pass
|
||||
try:
|
||||
self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
||||
except Exception as e:
|
||||
logger.debug("event progress init (delay): %s", e)
|
||||
_delay_already_inited = True
|
||||
|
||||
delay_seconds = int(delay_minutes) * 60
|
||||
|
|
@ -486,13 +488,17 @@ class AutomationEngine:
|
|||
logger.info(f"Event automation '{auto.get('name')}' skipped — {action_type} busy")
|
||||
# If progress was initialized during delay, finalize it
|
||||
if _delay_already_inited and self._progress_finish_fn:
|
||||
try: self._progress_finish_fn(automation_id, result)
|
||||
except Exception: pass
|
||||
try:
|
||||
self._progress_finish_fn(automation_id, result)
|
||||
except Exception as e:
|
||||
logger.debug("event progress finish (skipped): %s", e)
|
||||
else:
|
||||
# Initialize progress tracking (skip if already done during delay)
|
||||
if not _delay_already_inited and self._progress_init_fn:
|
||||
try: self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
||||
except Exception: pass
|
||||
try:
|
||||
self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
||||
except Exception as e:
|
||||
logger.debug("event progress init: %s", e)
|
||||
try:
|
||||
result = handler_info['handler'](action_config) or {}
|
||||
logger.info(f"Event automation '{auto.get('name')}' executed: {result.get('status', 'ok')}")
|
||||
|
|
@ -501,8 +507,10 @@ class AutomationEngine:
|
|||
logger.error(f"Event automation '{auto.get('name')}' action failed: {e}")
|
||||
# Finalize progress tracking
|
||||
if self._progress_finish_fn:
|
||||
try: self._progress_finish_fn(automation_id, result)
|
||||
except Exception: pass
|
||||
try:
|
||||
self._progress_finish_fn(automation_id, result)
|
||||
except Exception as e:
|
||||
logger.debug("event progress finish: %s", e)
|
||||
|
||||
# Merge event data into result for then-action variables
|
||||
merged = {**event_data, **result}
|
||||
|
|
@ -531,8 +539,8 @@ class AutomationEngine:
|
|||
if self._history_record_fn:
|
||||
try:
|
||||
self._history_record_fn(automation_id, result)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("history record failed: %s", e)
|
||||
|
||||
# --- Schedule Execution (timer-based) ---
|
||||
|
||||
|
|
@ -580,8 +588,10 @@ class AutomationEngine:
|
|||
if not skip_delay and delay_minutes and delay_minutes > 0:
|
||||
# Initialize progress BEFORE delay so card glows during wait
|
||||
if self._progress_init_fn:
|
||||
try: self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
||||
except Exception: pass
|
||||
try:
|
||||
self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
||||
except Exception as e:
|
||||
logger.debug("scheduled progress init (delay): %s", e)
|
||||
_delay_already_inited = True
|
||||
|
||||
delay_seconds = int(delay_minutes) * 60
|
||||
|
|
@ -603,15 +613,19 @@ class AutomationEngine:
|
|||
logger.info(f"Automation '{auto['name']}' skipped — {action_type} already running")
|
||||
# If progress was initialized during delay, finalize it
|
||||
if _delay_already_inited and self._progress_finish_fn:
|
||||
try: self._progress_finish_fn(automation_id, result)
|
||||
except Exception: pass
|
||||
try:
|
||||
self._progress_finish_fn(automation_id, result)
|
||||
except Exception as e:
|
||||
logger.debug("scheduled progress finish (skipped): %s", e)
|
||||
self._finish_run(auto, automation_id, result, error=None)
|
||||
return
|
||||
|
||||
# Initialize progress tracking (skip if already done during delay)
|
||||
if not _delay_already_inited and self._progress_init_fn:
|
||||
try: self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
||||
except Exception: pass
|
||||
try:
|
||||
self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
||||
except Exception as e:
|
||||
logger.debug("scheduled progress init: %s", e)
|
||||
|
||||
# Execute the action
|
||||
error = None
|
||||
|
|
@ -637,8 +651,10 @@ class AutomationEngine:
|
|||
|
||||
# Finalize progress tracking
|
||||
if self._progress_finish_fn:
|
||||
try: self._progress_finish_fn(automation_id, result)
|
||||
except Exception: pass
|
||||
try:
|
||||
self._progress_finish_fn(automation_id, result)
|
||||
except Exception as e:
|
||||
logger.debug("scheduled progress finish: %s", e)
|
||||
|
||||
# Execute then-actions (notifications + fire_signal)
|
||||
try:
|
||||
|
|
@ -673,8 +689,8 @@ class AutomationEngine:
|
|||
delay = self._calc_delay_seconds(trigger_config)
|
||||
if delay:
|
||||
next_run_str = _utc_after(delay)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("next run calc failed: %s", e)
|
||||
|
||||
last_result = json.dumps(result) if result else None
|
||||
self.db.update_automation_run(automation_id, next_run=next_run_str, error=error, last_result=last_result)
|
||||
|
|
@ -682,8 +698,8 @@ class AutomationEngine:
|
|||
if self._history_record_fn:
|
||||
try:
|
||||
self._history_record_fn(automation_id, result)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("history record failed: %s", e)
|
||||
|
||||
if self._running:
|
||||
self.schedule_automation(automation_id)
|
||||
|
|
|
|||
|
|
@ -79,9 +79,9 @@ def run_detection(server_type):
|
|||
api_response = requests.get(api_url, timeout=1)
|
||||
if api_response.status_code == 200 and 'MediaContainer' in api_response.text:
|
||||
return f"http://{ip}:{port}"
|
||||
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("plex probe %s: %s", ip, e)
|
||||
return None
|
||||
|
||||
def test_jellyfin_server(ip, port=8096):
|
||||
|
|
@ -101,9 +101,9 @@ def run_detection(server_type):
|
|||
web_response = requests.get(web_url, timeout=1)
|
||||
if web_response.status_code == 200 and 'jellyfin' in web_response.text.lower():
|
||||
return f"http://{ip}:{port}"
|
||||
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("jellyfin probe %s: %s", ip, e)
|
||||
return None
|
||||
|
||||
def test_slskd_server(ip, port=5030):
|
||||
|
|
@ -117,8 +117,8 @@ def run_detection(server_type):
|
|||
if response.status_code in [200, 401]:
|
||||
return f"http://{ip}:{port}"
|
||||
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("slskd probe %s: %s", ip, e)
|
||||
return None
|
||||
|
||||
def test_navidrome_server(ip, port=4533):
|
||||
|
|
@ -140,8 +140,8 @@ def run_detection(server_type):
|
|||
# Check for Subsonic/Navidrome API response structure
|
||||
if 'subsonic-response' in data:
|
||||
return f"http://{ip}:{port}"
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("navidrome json parse: %s", e)
|
||||
|
||||
# Also try the web interface
|
||||
web_url = f"http://{ip}:{port}/"
|
||||
|
|
@ -149,8 +149,8 @@ def run_detection(server_type):
|
|||
if web_response.status_code == 200 and 'navidrome' in web_response.text.lower():
|
||||
return f"http://{ip}:{port}"
|
||||
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("navidrome probe %s: %s", ip, e)
|
||||
return None
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Service connection test — lifted from web_server.py.
|
||||
|
||||
The function body is byte-identical to the original. soulseek_client,
|
||||
The function body is byte-identical to the original. download_orchestrator,
|
||||
qobuz_enrichment_worker, hydrabase_client, docker_resolve_url, and
|
||||
docker_resolve_path are injected at runtime because they live in
|
||||
web_server.py and are constructed there.
|
||||
|
|
@ -27,7 +27,7 @@ def _get_metadata_fallback_source():
|
|||
|
||||
|
||||
# Injected at runtime via init().
|
||||
soulseek_client = None
|
||||
download_orchestrator = None
|
||||
qobuz_enrichment_worker = None
|
||||
hydrabase_client = None
|
||||
docker_resolve_url = None
|
||||
|
|
@ -35,16 +35,16 @@ docker_resolve_path = None
|
|||
|
||||
|
||||
def init(
|
||||
soulseek_client_obj,
|
||||
download_orchestrator_obj,
|
||||
qobuz_worker,
|
||||
hydrabase_client_obj,
|
||||
docker_resolve_url_fn,
|
||||
docker_resolve_path_fn,
|
||||
):
|
||||
"""Bind web_server-side helpers/globals so the lifted body can resolve them."""
|
||||
global soulseek_client, qobuz_enrichment_worker, hydrabase_client
|
||||
global download_orchestrator, qobuz_enrichment_worker, hydrabase_client
|
||||
global docker_resolve_url, docker_resolve_path
|
||||
soulseek_client = soulseek_client_obj
|
||||
download_orchestrator = download_orchestrator_obj
|
||||
qobuz_enrichment_worker = qobuz_worker
|
||||
hydrabase_client = hydrabase_client_obj
|
||||
docker_resolve_url = docker_resolve_url_fn
|
||||
|
|
@ -177,13 +177,13 @@ def run_service_test(service, test_config):
|
|||
else:
|
||||
return False, f"Output folder not found: {transfer_path}"
|
||||
elif service == "soulseek":
|
||||
if soulseek_client is None:
|
||||
if download_orchestrator is None:
|
||||
return False, "Download orchestrator failed to initialize. Check server logs for startup errors."
|
||||
|
||||
# Test the orchestrator's configured download source (not just Soulseek)
|
||||
download_mode = config_manager.get('download_source.mode', 'hybrid')
|
||||
|
||||
if run_async(soulseek_client.check_connection()):
|
||||
if run_async(download_orchestrator.check_connection()):
|
||||
# Success message based on active mode
|
||||
mode_messages = {
|
||||
'soulseek': "Successfully connected to Soulseek network via slskd.",
|
||||
|
|
@ -379,6 +379,24 @@ def run_service_test(service, test_config):
|
|||
return False, "Hydrabase not connected. Configure URL + API key and click Connect."
|
||||
except Exception as e:
|
||||
return False, f"Hydrabase connection error: {str(e)}"
|
||||
elif service == "soundcloud":
|
||||
# Anonymous SoundCloud has no auth, so "test" really means
|
||||
# "is yt-dlp installed and can it reach SoundCloud right now."
|
||||
# This mirrors the /api/soundcloud/status check.
|
||||
try:
|
||||
from core.soundcloud_client import SoundcloudClient
|
||||
sc = SoundcloudClient()
|
||||
if not sc.is_available():
|
||||
return False, "SoundCloud unavailable — yt-dlp not installed."
|
||||
# Run a tiny live probe via asyncio so the dashboard test
|
||||
# gives a meaningful pass/fail.
|
||||
import asyncio
|
||||
reachable = asyncio.new_event_loop().run_until_complete(sc.check_connection())
|
||||
if reachable:
|
||||
return True, "SoundCloud reachable (anonymous)"
|
||||
return False, "SoundCloud unreachable — search probe failed. Try again."
|
||||
except Exception as e:
|
||||
return False, f"SoundCloud connection error: {str(e)}"
|
||||
return False, "Unknown service."
|
||||
except AttributeError as e:
|
||||
# This specifically catches the error you reported for Jellyfin
|
||||
|
|
|
|||
|
|
@ -407,9 +407,14 @@ class DatabaseUpdateWorker:
|
|||
logger.error(f"Could not connect to {self.server_type} server — check URL, credentials, and network (Docker users: use container name or host.docker.internal instead of host IP)")
|
||||
return []
|
||||
|
||||
# Check for music library (Plex-specific check)
|
||||
if self.server_type == "plex" and not self.media_client.music_library:
|
||||
logger.error("No music library found in Plex")
|
||||
# Check for music library (Plex-specific check). Routes
|
||||
# through ``is_fully_configured`` so all-libraries mode (in
|
||||
# which ``music_library`` is None but ``_all_libraries_mode``
|
||||
# is True) counts as configured. Pre-fix this bailed out on
|
||||
# the bare music_library None check, silently aborting the
|
||||
# deep scan for any all-libraries-mode user.
|
||||
if self.server_type == "plex" and not self.media_client.is_fully_configured():
|
||||
logger.error("No music library configured in Plex")
|
||||
return []
|
||||
|
||||
# Check if database has enough content for incremental updates (server-specific)
|
||||
|
|
@ -1046,8 +1051,8 @@ class DatabaseUpdateWorker:
|
|||
batch + [self.server_type])
|
||||
cascade_album_ids.update(row[0] for row in cursor.fetchall())
|
||||
removed_album_ids -= cascade_album_ids
|
||||
except Exception:
|
||||
pass # If this optimization fails, double-delete is harmless
|
||||
except Exception as e:
|
||||
logger.debug("cascade album cleanup optimization: %s", e)
|
||||
|
||||
if not removed_artist_ids and not removed_album_ids:
|
||||
logger.info("Removal detection: no stale content found")
|
||||
|
|
@ -1084,24 +1089,31 @@ class DatabaseUpdateWorker:
|
|||
return []
|
||||
|
||||
def _get_recent_albums_plex(self) -> List:
|
||||
"""Get recently added and updated albums from Plex"""
|
||||
"""Get recently added and updated albums from Plex.
|
||||
|
||||
Routes through ``PlexClient.get_recently_added_albums`` and
|
||||
``get_recently_updated_albums`` so the all-libraries mode union
|
||||
works (pre-fix this reached ``self.media_client.music_library.X``
|
||||
directly which crashed when music_library is None in all-
|
||||
libraries mode).
|
||||
"""
|
||||
all_recent_content = []
|
||||
|
||||
|
||||
try:
|
||||
# Get recently added albums (up to 400 to catch more recent content)
|
||||
try:
|
||||
recently_added = self.media_client.music_library.recentlyAdded(libtype='album', maxresults=400)
|
||||
# Get recently added albums (up to 400 to catch more recent content)
|
||||
recently_added = self.media_client.get_recently_added_albums(maxresults=400, libtype='album')
|
||||
if recently_added:
|
||||
all_recent_content.extend(recently_added)
|
||||
logger.info(f"Found {len(recently_added)} recently added albums")
|
||||
except:
|
||||
# Fallback to general recently added
|
||||
recently_added = self.media_client.music_library.recentlyAdded(maxresults=400)
|
||||
all_recent_content.extend(recently_added)
|
||||
logger.info(f"Found {len(recently_added)} recently added items (mixed types)")
|
||||
|
||||
else:
|
||||
# Fallback to mixed-type recents.
|
||||
recently_added = self.media_client.get_recently_added_albums(maxresults=400, libtype=None)
|
||||
all_recent_content.extend(recently_added or [])
|
||||
logger.info(f"Found {len(recently_added or [])} recently added items (mixed types)")
|
||||
|
||||
# Get recently updated albums (catches metadata corrections)
|
||||
try:
|
||||
recently_updated = self.media_client.music_library.search(sort='updatedAt:desc', libtype='album', limit=400)
|
||||
recently_updated = self.media_client.get_recently_updated_albums(limit=400)
|
||||
# Remove duplicates (items that are both recently added and updated)
|
||||
added_keys = {getattr(item, 'ratingKey', None) for item in all_recent_content}
|
||||
unique_updated = [item for item in recently_updated if getattr(item, 'ratingKey', None) not in added_keys]
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ download_batches = None
|
|||
sync_states = None
|
||||
youtube_playlist_states = None
|
||||
tidal_discovery_states = None
|
||||
soulseek_client = None
|
||||
download_orchestrator = None
|
||||
_log_path = None
|
||||
_log_dir = None
|
||||
app = None
|
||||
|
|
@ -80,7 +80,7 @@ def init(
|
|||
sync_states_dict,
|
||||
youtube_playlist_states_dict,
|
||||
tidal_discovery_states_dict,
|
||||
soulseek_client_obj,
|
||||
download_orchestrator_obj,
|
||||
log_path,
|
||||
log_dir,
|
||||
flask_app,
|
||||
|
|
@ -90,7 +90,7 @@ def init(
|
|||
"""Bind shared state/helpers from web_server."""
|
||||
global SOULSYNC_VERSION, _DIRECT_RUN, _status_cache, qobuz_enrichment_worker
|
||||
global download_batches, sync_states, youtube_playlist_states
|
||||
global tidal_discovery_states, soulseek_client, _log_path, _log_dir
|
||||
global tidal_discovery_states, download_orchestrator, _log_path, _log_dir
|
||||
global app, get_database, _get_tidal_client
|
||||
SOULSYNC_VERSION = soulsync_version
|
||||
_DIRECT_RUN = direct_run
|
||||
|
|
@ -100,7 +100,7 @@ def init(
|
|||
sync_states = sync_states_dict
|
||||
youtube_playlist_states = youtube_playlist_states_dict
|
||||
tidal_discovery_states = tidal_discovery_states_dict
|
||||
soulseek_client = soulseek_client_obj
|
||||
download_orchestrator = download_orchestrator_obj
|
||||
_log_path = log_path
|
||||
_log_dir = log_dir
|
||||
app = flask_app
|
||||
|
|
@ -260,8 +260,8 @@ def get_debug_info():
|
|||
for _pid, st in list(tidal_discovery_states.items()):
|
||||
if st.get('phase') == 'syncing':
|
||||
active_syncs += 1
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("count active syncs failed: %s", e)
|
||||
info['active_downloads'] = active_downloads
|
||||
info['active_syncs'] = active_syncs
|
||||
|
||||
|
|
@ -292,31 +292,32 @@ def get_debug_info():
|
|||
|
||||
# Download client init failures
|
||||
info['download_client_failures'] = []
|
||||
if soulseek_client and hasattr(soulseek_client, '_init_failures'):
|
||||
info['download_client_failures'] = soulseek_client._init_failures
|
||||
elif not soulseek_client:
|
||||
if download_orchestrator and hasattr(download_orchestrator, '_init_failures'):
|
||||
info['download_client_failures'] = download_orchestrator._init_failures
|
||||
elif not download_orchestrator:
|
||||
info['download_client_failures'] = ['ALL (orchestrator failed to initialize)']
|
||||
|
||||
# API rate monitor — current calls/min, 24h totals, peaks, rate limit events
|
||||
try:
|
||||
from core.api_call_tracker import api_call_tracker
|
||||
from core.metadata.status import get_spotify_status
|
||||
rates = api_call_tracker.get_all_rates()
|
||||
info['api_rates'] = rates
|
||||
# Rich 24h debug summary with peaks, totals, per-endpoint breakdown, events
|
||||
info['api_debug_summary'] = api_call_tracker.get_debug_summary()
|
||||
# Spotify rate limit details
|
||||
if spotify_client:
|
||||
rl_info = spotify_client.get_rate_limit_info()
|
||||
if rl_info:
|
||||
info['spotify_rate_limit'] = {
|
||||
'active': True,
|
||||
'remaining_seconds': rl_info.get('remaining_seconds', 0),
|
||||
'retry_after': rl_info.get('retry_after', 0),
|
||||
'endpoint': rl_info.get('endpoint', ''),
|
||||
'expires_at': rl_info.get('expires_at', ''),
|
||||
}
|
||||
else:
|
||||
info['spotify_rate_limit'] = {'active': False}
|
||||
spotify_status = get_spotify_status(spotify_client=spotify_client)
|
||||
rl_info = spotify_status.get('rate_limit')
|
||||
if spotify_status.get('rate_limited') and rl_info:
|
||||
info['spotify_rate_limit'] = {
|
||||
'active': True,
|
||||
'remaining_seconds': rl_info.get('remaining_seconds', 0),
|
||||
'retry_after': rl_info.get('retry_after', 0),
|
||||
'endpoint': rl_info.get('endpoint', ''),
|
||||
'expires_at': rl_info.get('expires_at', ''),
|
||||
}
|
||||
else:
|
||||
info['spotify_rate_limit'] = {'active': False}
|
||||
except Exception:
|
||||
info['api_rates'] = {}
|
||||
info['api_debug_summary'] = {}
|
||||
|
|
|
|||
|
|
@ -307,8 +307,8 @@ class DeezerClient:
|
|||
for raw in cached_results:
|
||||
try:
|
||||
tracks.append(Track.from_deezer_track(raw))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("Track.from_deezer_track cache parse: %s", e)
|
||||
if tracks:
|
||||
return tracks
|
||||
|
||||
|
|
@ -341,8 +341,8 @@ class DeezerClient:
|
|||
for raw in cached_results:
|
||||
try:
|
||||
artists.append(Artist.from_deezer_artist(raw))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("Artist.from_deezer_artist cache parse: %s", e)
|
||||
if artists:
|
||||
return artists
|
||||
|
||||
|
|
@ -375,8 +375,8 @@ class DeezerClient:
|
|||
for raw in cached_results:
|
||||
try:
|
||||
albums.append(Album.from_deezer_album(raw))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("Album.from_deezer_album cache parse: %s", e)
|
||||
if albums:
|
||||
return albums
|
||||
|
||||
|
|
@ -599,6 +599,74 @@ class DeezerClient:
|
|||
|
||||
return result
|
||||
|
||||
def get_artist_top_tracks(self, artist_id: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""Return the artist's top tracks in Spotify-compatible dict format.
|
||||
|
||||
Wraps Deezer's `/artist/{id}/top?limit=N`. Returns dicts with the same
|
||||
shape Spotify's `artist_top_tracks` produces — id, name, artists, album
|
||||
(with album_type / total_tracks / release_date / images), duration_ms,
|
||||
track_number, disc_number — so callers don't need to branch on source.
|
||||
"""
|
||||
if not artist_id:
|
||||
return []
|
||||
try:
|
||||
limit = max(1, min(int(limit or 10), 100))
|
||||
except (TypeError, ValueError):
|
||||
limit = 10
|
||||
|
||||
data = self._api_get(f'artist/{artist_id}/top', {'limit': limit})
|
||||
if not data or 'data' not in data:
|
||||
return []
|
||||
|
||||
tracks = []
|
||||
for track_data in data['data']:
|
||||
if not isinstance(track_data, dict):
|
||||
continue
|
||||
artist_data = track_data.get('artist') or {}
|
||||
album_data = track_data.get('album') or {}
|
||||
|
||||
# Build images list from any cover sizes Deezer returned for the album
|
||||
images = []
|
||||
if isinstance(album_data, dict):
|
||||
for size_key, dim in [('cover_xl', 1000), ('cover_big', 500),
|
||||
('cover_medium', 250), ('cover_small', 56)]:
|
||||
if album_data.get(size_key):
|
||||
images.append({'url': album_data[size_key], 'height': dim, 'width': dim})
|
||||
|
||||
# Deezer `/artist/{id}/top` results don't include record_type on the
|
||||
# nested album object; we don't have a track-count to infer from
|
||||
# either. Default 'album' so the path-builder template variable
|
||||
# always has something to substitute (existing behavior elsewhere).
|
||||
album_payload = {
|
||||
'id': str(album_data.get('id', '')) if isinstance(album_data, dict) else '',
|
||||
'name': album_data.get('title', '') if isinstance(album_data, dict) else '',
|
||||
'album_type': 'album',
|
||||
'images': images,
|
||||
'release_date': '',
|
||||
'total_tracks': 0,
|
||||
'artists': [{'name': artist_data.get('name', '')}] if isinstance(artist_data, dict) else [],
|
||||
}
|
||||
|
||||
tracks.append({
|
||||
'id': str(track_data.get('id', '')),
|
||||
'name': track_data.get('title', ''),
|
||||
'artists': [{
|
||||
'id': str(artist_data.get('id', '')) if isinstance(artist_data, dict) else '',
|
||||
'name': artist_data.get('name', '') if isinstance(artist_data, dict) else '',
|
||||
}],
|
||||
'album': album_payload,
|
||||
'duration_ms': (track_data.get('duration') or 0) * 1000, # Deezer is seconds
|
||||
'popularity': track_data.get('rank', 0),
|
||||
'preview_url': track_data.get('preview'),
|
||||
'external_urls': {'deezer': track_data['link']} if track_data.get('link') else {},
|
||||
'track_number': track_data.get('track_position'),
|
||||
'disc_number': track_data.get('disk_number', 1),
|
||||
'explicit': bool(track_data.get('explicit_lyrics', False)),
|
||||
'_source': 'deezer',
|
||||
})
|
||||
|
||||
return tracks
|
||||
|
||||
def get_artist_info(self, artist_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get full artist details — returns Spotify-compatible dict (metadata source interface).
|
||||
|
||||
|
|
@ -842,8 +910,8 @@ class DeezerClient:
|
|||
try:
|
||||
cache = get_metadata_cache()
|
||||
cache.store_entity('deezer', 'artist', str(result.get('id', '')), result)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("cache store_entity artist search: %s", e)
|
||||
logger.debug(f"Found artist for query: {artist_name}")
|
||||
return result
|
||||
|
||||
|
|
@ -887,8 +955,8 @@ class DeezerClient:
|
|||
try:
|
||||
cache = get_metadata_cache()
|
||||
cache.store_entity('deezer', 'album', str(result.get('id', '')), result)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("cache store_entity album search: %s", e)
|
||||
logger.debug(f"Found album for query: {artist_name} - {album_title}")
|
||||
return result
|
||||
|
||||
|
|
@ -932,8 +1000,8 @@ class DeezerClient:
|
|||
try:
|
||||
cache = get_metadata_cache()
|
||||
cache.store_entity('deezer', 'track', str(result.get('id', '')), result)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("cache store_entity track search: %s", e)
|
||||
logger.debug(f"Found track for query: {artist_name} - {track_title}")
|
||||
return result
|
||||
|
||||
|
|
@ -965,8 +1033,8 @@ class DeezerClient:
|
|||
# Cache hit with full details (has label = was a get_album response, not just search)
|
||||
logger.debug(f"Cache hit for album {album_id}")
|
||||
return cached
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("cache get_entity album: %s", e)
|
||||
|
||||
try:
|
||||
response = self.session.get(
|
||||
|
|
@ -984,8 +1052,8 @@ class DeezerClient:
|
|||
try:
|
||||
cache = get_metadata_cache()
|
||||
cache.store_entity('deezer', 'album', str(album_id), data)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("cache store_entity album full: %s", e)
|
||||
logger.debug(f"Got full album details for ID: {album_id}")
|
||||
return data
|
||||
|
||||
|
|
@ -1013,8 +1081,8 @@ class DeezerClient:
|
|||
if cached and cached.get('bpm'):
|
||||
logger.debug(f"Cache hit for track {track_id}")
|
||||
return cached
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("cache get_entity track: %s", e)
|
||||
|
||||
try:
|
||||
response = self.session.get(
|
||||
|
|
@ -1032,8 +1100,8 @@ class DeezerClient:
|
|||
try:
|
||||
cache = get_metadata_cache()
|
||||
cache.store_entity('deezer', 'track', str(track_id), data)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("cache store_entity track full: %s", e)
|
||||
logger.debug(f"Got full track details for ID: {track_id}")
|
||||
return data
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|||
|
||||
import requests
|
||||
|
||||
from core.soulseek_client import AlbumResult, DownloadStatus, TrackResult
|
||||
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("deezer_download")
|
||||
|
|
@ -79,7 +79,10 @@ def _decrypt_chunk(chunk: bytes, key: bytes) -> bytes:
|
|||
) from exc
|
||||
|
||||
|
||||
class DeezerDownloadClient:
|
||||
from core.download_plugins.base import DownloadSourcePlugin
|
||||
|
||||
|
||||
class DeezerDownloadClient(DownloadSourcePlugin):
|
||||
"""Deezer download client using ARL token authentication."""
|
||||
|
||||
def __init__(self, download_path: str = None):
|
||||
|
|
@ -91,9 +94,9 @@ class DeezerDownloadClient:
|
|||
self.download_path = Path(download_path)
|
||||
self.download_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download tracking (same pattern as Tidal/Qobuz/HiFi)
|
||||
self.active_downloads: Dict[str, Dict[str, Any]] = {}
|
||||
self._download_lock = threading.Lock()
|
||||
# Engine reference is populated by set_engine() at registration
|
||||
# time. None until orchestrator wires the registry.
|
||||
self._engine = None
|
||||
|
||||
# Shutdown check callback (set by web_server)
|
||||
self.shutdown_check = None
|
||||
|
|
@ -125,6 +128,10 @@ class DeezerDownloadClient:
|
|||
|
||||
logger.info(f"Deezer download client initialized (download path: {self.download_path})")
|
||||
|
||||
def set_engine(self, engine):
|
||||
"""Engine callback — wires the central thread worker + state store."""
|
||||
self._engine = engine
|
||||
|
||||
# ─── Authentication ──────────────────────────────────────────
|
||||
|
||||
def _authenticate(self, arl: str) -> bool:
|
||||
|
|
@ -423,8 +430,8 @@ class DeezerDownloadClient:
|
|||
if cached and cached.get('release_date'):
|
||||
album_release_dates[aid] = cached['release_date']
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("cache get_entity album release_date: %s", e)
|
||||
# Cache miss — fetch from API
|
||||
try:
|
||||
time.sleep(0.3) # Respect rate limits
|
||||
|
|
@ -436,10 +443,10 @@ class DeezerDownloadClient:
|
|||
if cache:
|
||||
try:
|
||||
cache.store_entity('deezer', 'album', aid, a_data)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("cache store_entity album release_date: %s", e)
|
||||
except Exception as e:
|
||||
logger.debug("fetch deezer album release_date %s: %s", aid, e)
|
||||
|
||||
tracks = []
|
||||
for i, t in enumerate(raw_tracks, start=1):
|
||||
|
|
@ -605,87 +612,67 @@ class DeezerDownloadClient:
|
|||
if not self._authenticated:
|
||||
logger.error("Deezer not authenticated — cannot download")
|
||||
return None
|
||||
if self._engine is None:
|
||||
# Raise rather than return None so the orchestrator's
|
||||
# download_with_fallback surfaces a real warning + tries
|
||||
# the next source. Returning None silently dropped the
|
||||
# download with no user feedback (per JohnBaumb).
|
||||
raise RuntimeError("Deezer client has no engine reference — cannot dispatch download")
|
||||
|
||||
# Parse filename: "track_id||display_name"
|
||||
parts = filename.split('||', 1)
|
||||
track_id = parts[0]
|
||||
display_name = parts[1] if len(parts) > 1 else f"Track {track_id}"
|
||||
|
||||
download_id = str(uuid.uuid4())
|
||||
|
||||
with self._download_lock:
|
||||
self.active_downloads[download_id] = {
|
||||
'id': download_id,
|
||||
return self._engine.worker.dispatch(
|
||||
source_name='deezer',
|
||||
target_id=track_id,
|
||||
display_name=display_name,
|
||||
original_filename=filename,
|
||||
impl_callable=self._download_sync,
|
||||
extra_record_fields={
|
||||
'track_id': track_id,
|
||||
'display_name': display_name,
|
||||
'filename': filename,
|
||||
'username': 'deezer_dl',
|
||||
'state': 'Initializing',
|
||||
'progress': 0.0,
|
||||
'size': file_size,
|
||||
'transferred': 0,
|
||||
'speed': 0,
|
||||
'file_path': None,
|
||||
'error': None,
|
||||
}
|
||||
|
||||
thread = threading.Thread(
|
||||
target=self._download_thread_worker,
|
||||
args=(download_id, track_id, display_name),
|
||||
daemon=True,
|
||||
name=f'deezer-dl-{track_id}'
|
||||
},
|
||||
# Legacy username slot — frontend status indicators key off
|
||||
# ``deezer_dl``, not the canonical ``deezer``.
|
||||
username_override='deezer_dl',
|
||||
# Diagnostic thread name for multi-thread debugging.
|
||||
thread_name=f'deezer-dl-{track_id}',
|
||||
)
|
||||
thread.start()
|
||||
|
||||
logger.info(f"Started Deezer download {download_id}: {display_name}")
|
||||
return download_id
|
||||
def _set_error(self, download_id: str, message: str) -> None:
|
||||
"""Helper: set the engine record's `error` slot. No-op if
|
||||
engine isn't wired or record was already removed."""
|
||||
if self._engine is None:
|
||||
return
|
||||
self._engine.update_record('deezer', download_id, {'error': message})
|
||||
|
||||
def _download_thread_worker(self, download_id: str, track_id: str, display_name: str):
|
||||
"""Background worker for a single download."""
|
||||
try:
|
||||
result_path = self._download_sync(download_id, track_id, display_name)
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
dl = self.active_downloads[download_id]
|
||||
if dl['state'] == 'Cancelled':
|
||||
return
|
||||
if result_path:
|
||||
dl['state'] = 'Completed, Succeeded'
|
||||
dl['progress'] = 100.0
|
||||
dl['file_path'] = result_path
|
||||
logger.info(f"Deezer download {download_id} completed: {result_path}")
|
||||
else:
|
||||
dl['state'] = 'Errored'
|
||||
logger.error(f"Deezer download {download_id} failed: {dl.get('error', 'unknown')}")
|
||||
except Exception as e:
|
||||
logger.error(f"Deezer download thread error: {e}")
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['state'] = 'Errored'
|
||||
self.active_downloads[download_id]['error'] = str(e)
|
||||
def _is_cancelled(self, download_id: str) -> bool:
|
||||
if self._engine is None:
|
||||
return False
|
||||
record = self._engine.get_record('deezer', download_id)
|
||||
return record is not None and record.get('state') == 'Cancelled'
|
||||
|
||||
def _download_sync(self, download_id: str, track_id: str, display_name: str) -> Optional[str]:
|
||||
"""Synchronous download: get URL, download, decrypt, save."""
|
||||
# Check for shutdown
|
||||
if self.shutdown_check and self.shutdown_check():
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['state'] = 'Aborted'
|
||||
if self._engine is not None:
|
||||
self._engine.update_record('deezer', download_id, {'state': 'Aborted'})
|
||||
return None
|
||||
|
||||
# Get track data from private API
|
||||
track_data = self._get_track_data(track_id)
|
||||
if not track_data:
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['error'] = 'Failed to get track data'
|
||||
self._set_error(download_id, 'Failed to get track data')
|
||||
return None
|
||||
|
||||
track_token = track_data.get('TRACK_TOKEN', '')
|
||||
if not track_token:
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['error'] = 'No track token available'
|
||||
self._set_error(download_id, 'No track token available')
|
||||
return None
|
||||
|
||||
# Determine quality and get media URL with fallback
|
||||
|
|
@ -695,7 +682,6 @@ class DeezerDownloadClient:
|
|||
|
||||
if allow_fallback:
|
||||
quality_order = _QUALITY_ORDER.copy()
|
||||
# Start from user's preferred quality
|
||||
try:
|
||||
pref_idx = quality_order.index(self._quality)
|
||||
quality_order = quality_order[pref_idx:] + quality_order[:pref_idx]
|
||||
|
|
@ -712,27 +698,16 @@ class DeezerDownloadClient:
|
|||
break
|
||||
|
||||
if not media_url:
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['error'] = 'No media URL available (may require higher subscription tier)'
|
||||
self._set_error(download_id, 'No media URL available (may require higher subscription tier)')
|
||||
return None
|
||||
|
||||
if actual_quality != self._quality:
|
||||
logger.info(f"Quality fallback: {self._quality} → {actual_quality} for {display_name}")
|
||||
|
||||
# Determine file extension
|
||||
ext = '.flac' if actual_quality == 'flac' else '.mp3'
|
||||
|
||||
# Sanitize filename
|
||||
safe_name = self._sanitize_filename(display_name)
|
||||
out_path = str(self.download_path / f"{safe_name}{ext}")
|
||||
|
||||
# Update state
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
dl = self.active_downloads[download_id]
|
||||
dl['state'] = 'InProgress, Downloading'
|
||||
|
||||
# Download and decrypt
|
||||
try:
|
||||
bf_key = _get_blowfish_key(track_id)
|
||||
|
|
@ -740,9 +715,8 @@ class DeezerDownloadClient:
|
|||
resp.raise_for_status()
|
||||
|
||||
total_size = int(resp.headers.get('content-length', 0))
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['size'] = total_size
|
||||
if self._engine is not None:
|
||||
self._engine.update_record('deezer', download_id, {'size': total_size})
|
||||
|
||||
downloaded = 0
|
||||
chunk_index = 0
|
||||
|
|
@ -755,23 +729,20 @@ class DeezerDownloadClient:
|
|||
|
||||
# Check for cancellation/shutdown
|
||||
if self.shutdown_check and self.shutdown_check():
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['state'] = 'Aborted'
|
||||
if self._engine is not None:
|
||||
self._engine.update_record('deezer', download_id, {'state': 'Aborted'})
|
||||
try:
|
||||
os.remove(out_path)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
if self.active_downloads[download_id]['state'] == 'Cancelled':
|
||||
try:
|
||||
os.remove(out_path)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
if self._is_cancelled(download_id):
|
||||
try:
|
||||
os.remove(out_path)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
# Decrypt every 3rd chunk (Deezer's encryption pattern)
|
||||
if chunk_index % 3 == 0 and len(raw_chunk) == _CHUNK_SIZE:
|
||||
|
|
@ -788,12 +759,12 @@ class DeezerDownloadClient:
|
|||
speed = int(downloaded / elapsed) if elapsed > 0 else 0
|
||||
progress = (downloaded / total_size * 100) if total_size > 0 else 0
|
||||
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
dl = self.active_downloads[download_id]
|
||||
dl['transferred'] = downloaded
|
||||
dl['progress'] = min(progress, 99.9)
|
||||
dl['speed'] = speed
|
||||
if self._engine is not None:
|
||||
self._engine.update_record('deezer', download_id, {
|
||||
'transferred': downloaded,
|
||||
'progress': min(progress, 99.9),
|
||||
'speed': speed,
|
||||
})
|
||||
|
||||
# Validate file size
|
||||
file_size = os.path.getsize(out_path)
|
||||
|
|
@ -803,9 +774,7 @@ class DeezerDownloadClient:
|
|||
os.remove(out_path)
|
||||
except OSError:
|
||||
pass
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['error'] = f'File too small ({file_size} bytes)'
|
||||
self._set_error(download_id, f'File too small ({file_size} bytes)')
|
||||
return None
|
||||
|
||||
logger.info(f"Deezer download complete: {out_path} ({file_size / 1048576:.1f} MB, {actual_quality})")
|
||||
|
|
@ -817,59 +786,58 @@ class DeezerDownloadClient:
|
|||
os.remove(out_path)
|
||||
except OSError:
|
||||
pass
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['error'] = str(e)
|
||||
self._set_error(download_id, str(e))
|
||||
return None
|
||||
|
||||
# ─── Download Status ─────────────────────────────────────────
|
||||
|
||||
def _record_to_status(self, record: dict) -> DownloadStatus:
|
||||
return DownloadStatus(
|
||||
id=record['id'],
|
||||
filename=record['filename'],
|
||||
username=record['username'],
|
||||
state=record['state'],
|
||||
progress=record['progress'],
|
||||
size=record.get('size', 0),
|
||||
transferred=record.get('transferred', 0),
|
||||
speed=record.get('speed', 0),
|
||||
file_path=record.get('file_path'),
|
||||
)
|
||||
|
||||
async def get_all_downloads(self) -> List[DownloadStatus]:
|
||||
"""Return all active downloads."""
|
||||
with self._download_lock:
|
||||
return [self._to_status(dl) for dl in self.active_downloads.values()]
|
||||
if self._engine is None:
|
||||
return []
|
||||
return [
|
||||
self._record_to_status(record)
|
||||
for record in self._engine.iter_records_for_source('deezer')
|
||||
]
|
||||
|
||||
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
||||
"""Get status of a specific download."""
|
||||
with self._download_lock:
|
||||
dl = self.active_downloads.get(download_id)
|
||||
return self._to_status(dl) if dl else None
|
||||
if self._engine is None:
|
||||
return None
|
||||
record = self._engine.get_record('deezer', download_id)
|
||||
return self._record_to_status(record) if record is not None else None
|
||||
|
||||
async def cancel_download(self, download_id: str, username: str = None,
|
||||
remove: bool = False) -> bool:
|
||||
"""Cancel a download."""
|
||||
with self._download_lock:
|
||||
dl = self.active_downloads.get(download_id)
|
||||
if not dl:
|
||||
return False
|
||||
dl['state'] = 'Cancelled'
|
||||
if remove:
|
||||
del self.active_downloads[download_id]
|
||||
if self._engine is None:
|
||||
return False
|
||||
if self._engine.get_record('deezer', download_id) is None:
|
||||
return False
|
||||
self._engine.update_record('deezer', download_id, {'state': 'Cancelled'})
|
||||
if remove:
|
||||
self._engine.remove_record('deezer', download_id)
|
||||
return True
|
||||
|
||||
async def clear_all_completed_downloads(self) -> bool:
|
||||
"""Remove all terminal downloads."""
|
||||
terminal_states = {'Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted'}
|
||||
with self._download_lock:
|
||||
to_remove = [k for k, v in self.active_downloads.items() if v['state'] in terminal_states]
|
||||
for k in to_remove:
|
||||
del self.active_downloads[k]
|
||||
if self._engine is None:
|
||||
return True
|
||||
terminal = {'Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted'}
|
||||
for record in list(self._engine.iter_records_for_source('deezer')):
|
||||
if record.get('state') in terminal:
|
||||
self._engine.remove_record('deezer', record['id'])
|
||||
return True
|
||||
|
||||
def _to_status(self, dl: dict) -> DownloadStatus:
|
||||
"""Convert internal dict to DownloadStatus."""
|
||||
return DownloadStatus(
|
||||
id=dl['id'],
|
||||
filename=dl['filename'],
|
||||
username=dl['username'],
|
||||
state=dl['state'],
|
||||
progress=dl['progress'],
|
||||
size=dl['size'],
|
||||
transferred=dl['transferred'],
|
||||
speed=dl['speed'],
|
||||
file_path=dl.get('file_path'),
|
||||
)
|
||||
|
||||
# ─── Utilities ───────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from utils.logging_config import get_logger
|
|||
from database.music_database import MusicDatabase
|
||||
from core.deezer_client import DeezerClient
|
||||
from core.worker_utils import interruptible_sleep, set_album_api_track_count
|
||||
from core.enrichment.manual_match_honoring import honor_stored_match
|
||||
|
||||
logger = get_logger("deezer_worker")
|
||||
|
||||
|
|
@ -140,8 +141,8 @@ class DeezerWorker:
|
|||
itype = item.get('type', '')
|
||||
table = 'artists' if 'artist' in itype else ('albums' if 'album' in itype else 'tracks')
|
||||
# Can't mark status without an ID — just skip
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("null id table resolve failed: %s", e)
|
||||
continue
|
||||
|
||||
|
||||
|
|
@ -383,11 +384,33 @@ class DeezerWorker:
|
|||
self.stats['not_found'] += 1
|
||||
logger.debug(f"No match for artist '{artist_name}'")
|
||||
|
||||
def _refresh_album_via_stored_id(self, album_id, stored_id, full_album_dict):
|
||||
"""Issue #501 callback. Stored ID exists → fetched full Deezer
|
||||
album payload. Use it as both args to ``_update_album`` (search-
|
||||
result and full-data shapes overlap on the fields we need —
|
||||
artist verification skipped since manual match presumably
|
||||
already vetted)."""
|
||||
self._update_album(album_id, full_album_dict, full_album_dict)
|
||||
|
||||
def _refresh_track_via_stored_id(self, track_id, stored_id, full_track_dict):
|
||||
"""Issue #501 callback for tracks — same pattern as albums."""
|
||||
self._update_track(track_id, full_track_dict, full_track_dict)
|
||||
|
||||
def _process_album(self, album_id: int, album_name: str, artist_name: str, item: Dict[str, Any]):
|
||||
"""Process an album: search Deezer, verify, fetch full details, store metadata"""
|
||||
existing_id = self._get_existing_id('album', album_id)
|
||||
if existing_id:
|
||||
logger.debug(f"Preserving existing Deezer ID for album '{album_name}': {existing_id}")
|
||||
# Issue #501: honor manual matches. Pre-fix this method just
|
||||
# SKIPPED when a stored ID was present (preserved the ID but
|
||||
# never refreshed metadata). Now it goes through the full
|
||||
# refresh path via the stored ID, picking up label / genres /
|
||||
# explicit updates without ever overwriting the manual match.
|
||||
if honor_stored_match(
|
||||
db=self.db, entity_table='albums', entity_id=album_id,
|
||||
id_column='deezer_id',
|
||||
client_fetch_fn=self.client.get_album_raw,
|
||||
on_match_fn=self._refresh_album_via_stored_id,
|
||||
log_prefix='Deezer',
|
||||
):
|
||||
self.stats['matched'] += 1
|
||||
return
|
||||
|
||||
result = self.client.search_album(artist_name, album_name)
|
||||
|
|
@ -430,9 +453,15 @@ class DeezerWorker:
|
|||
|
||||
def _process_track(self, track_id: int, track_name: str, artist_name: str, item: Dict[str, Any]):
|
||||
"""Process a track: search Deezer, verify, fetch full details for BPM, store metadata"""
|
||||
existing_id = self._get_existing_id('track', track_id)
|
||||
if existing_id:
|
||||
logger.debug(f"Preserving existing Deezer ID for track '{track_name}': {existing_id}")
|
||||
# Issue #501: honor manual matches (see _process_album).
|
||||
if honor_stored_match(
|
||||
db=self.db, entity_table='tracks', entity_id=track_id,
|
||||
id_column='deezer_id',
|
||||
client_fetch_fn=self.client.get_track_raw,
|
||||
on_match_fn=self._refresh_track_via_stored_id,
|
||||
log_prefix='Deezer',
|
||||
):
|
||||
self.stats['matched'] += 1
|
||||
return
|
||||
|
||||
result = self.client.search_track(artist_name, track_name)
|
||||
|
|
|
|||
|
|
@ -320,8 +320,8 @@ class DiscogsClient:
|
|||
try:
|
||||
from config.settings import config_manager
|
||||
self.token = config_manager.get('discogs.token', '')
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("load discogs.token from config: %s", e)
|
||||
|
||||
if self.token:
|
||||
self.session.headers['Authorization'] = f'Discogs token={self.token}'
|
||||
|
|
@ -369,6 +369,125 @@ class DiscogsClient:
|
|||
logger.error(f"Discogs API error ({endpoint}): {e}")
|
||||
return None
|
||||
|
||||
# --- User Collection (powers Your Albums Discogs source) ---
|
||||
|
||||
def get_authenticated_username(self) -> Optional[str]:
|
||||
"""Resolve the username for the configured personal token.
|
||||
|
||||
Discogs's `/oauth/identity` endpoint returns the user's
|
||||
username when called with a valid token. Cached on the
|
||||
instance so subsequent calls don't re-hit the API.
|
||||
"""
|
||||
if hasattr(self, '_cached_username'):
|
||||
return self._cached_username
|
||||
if not self.is_authenticated():
|
||||
self._cached_username = None
|
||||
return None
|
||||
data = self._api_get('/oauth/identity')
|
||||
username = data.get('username') if data else None
|
||||
self._cached_username = username
|
||||
return username
|
||||
|
||||
def get_user_collection(self, username: Optional[str] = None,
|
||||
folder_id: int = 0,
|
||||
per_page: int = 100,
|
||||
max_pages: int = 50) -> List[Dict[str, Any]]:
|
||||
"""Fetch a Discogs user's collection (folder 0 = "All").
|
||||
|
||||
Returns a list of normalized release dicts ready for
|
||||
``database.upsert_liked_album``:
|
||||
{
|
||||
'album_name': str,
|
||||
'artist_name': str,
|
||||
'release_id': int, # Discogs release id
|
||||
'image_url': str | None,
|
||||
'release_date': str, # 'YYYY' (Discogs only stores year)
|
||||
'total_tracks': int,
|
||||
}
|
||||
|
||||
Pagination caps at ``max_pages`` to bound runtime — at 100/page
|
||||
that's 5000 releases, more than enough for typical collections.
|
||||
Authenticated calls only (Discogs collection is private).
|
||||
"""
|
||||
if not self.is_authenticated():
|
||||
logger.warning("Discogs collection fetch attempted without token")
|
||||
return []
|
||||
|
||||
if not username:
|
||||
username = self.get_authenticated_username()
|
||||
if not username:
|
||||
logger.warning("Could not resolve Discogs username for token")
|
||||
return []
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
page = 1
|
||||
while page <= max_pages:
|
||||
data = self._api_get(
|
||||
f'/users/{username}/collection/folders/{folder_id}/releases',
|
||||
{'page': page, 'per_page': per_page, 'sort': 'added', 'sort_order': 'desc'},
|
||||
)
|
||||
if not data:
|
||||
break
|
||||
|
||||
releases = data.get('releases', []) or []
|
||||
if not releases:
|
||||
break
|
||||
|
||||
for entry in releases:
|
||||
info = entry.get('basic_information') or {}
|
||||
release_id = entry.get('id') or info.get('id')
|
||||
if not release_id:
|
||||
continue
|
||||
title = info.get('title') or ''
|
||||
# Discogs `artists` is a list of {name, id, ...}; first is primary.
|
||||
artists = info.get('artists') or []
|
||||
artist_name = ''
|
||||
if artists and isinstance(artists[0], dict):
|
||||
artist_name = (artists[0].get('name') or '').strip()
|
||||
# Strip trailing "(N)" disambiguation suffix Discogs adds.
|
||||
artist_name = re.sub(r'\s*\(\d+\)$', '', artist_name)
|
||||
if not title or not artist_name:
|
||||
continue
|
||||
|
||||
# Image URLs: cover_image is the primary, also has thumb.
|
||||
image_url = (info.get('cover_image')
|
||||
or info.get('thumb')
|
||||
or '')
|
||||
|
||||
year = info.get('year')
|
||||
release_date = str(year) if year and year > 0 else ''
|
||||
|
||||
results.append({
|
||||
'album_name': title.strip(),
|
||||
'artist_name': artist_name,
|
||||
'release_id': int(release_id),
|
||||
'image_url': image_url or None,
|
||||
'release_date': release_date,
|
||||
'total_tracks': 0, # Not in basic_information; populated via get_release if needed
|
||||
})
|
||||
|
||||
pagination = data.get('pagination') or {}
|
||||
if page >= int(pagination.get('pages') or 1):
|
||||
break
|
||||
page += 1
|
||||
|
||||
logger.info(f"Discogs collection: fetched {len(results)} releases for {username}")
|
||||
return results
|
||||
|
||||
def get_release(self, release_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Fetch full Discogs release detail including tracklist.
|
||||
|
||||
Returns the raw API response so callers can render rich
|
||||
Discogs context (year, format, label, country, tracklist).
|
||||
"""
|
||||
if not release_id:
|
||||
return None
|
||||
try:
|
||||
release_id = int(release_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return self._api_get(f'/releases/{release_id}')
|
||||
|
||||
# --- Search Methods (same signatures as iTunes/Deezer) ---
|
||||
|
||||
def search_artists(self, query: str, limit: int = 10) -> List[Artist]:
|
||||
|
|
@ -380,8 +499,8 @@ class DiscogsClient:
|
|||
for raw in cached_results:
|
||||
try:
|
||||
artists.append(Artist.from_discogs_artist(raw))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("Artist.from_discogs_artist cache parse: %s", e)
|
||||
if artists:
|
||||
return artists
|
||||
|
||||
|
|
@ -417,8 +536,8 @@ class DiscogsClient:
|
|||
for raw in cached_results:
|
||||
try:
|
||||
albums.append(Album.from_discogs_release(raw))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("Album.from_discogs_release cache parse: %s", e)
|
||||
if albums:
|
||||
return albums
|
||||
|
||||
|
|
|
|||
|
|
@ -298,8 +298,8 @@ class DiscogsWorker:
|
|||
self.stats['errors'] += 1
|
||||
try:
|
||||
self._mark_status(item['type'], item['id'], 'error')
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("mark item status error failed: %s", e)
|
||||
|
||||
def _get_existing_id(self, entity_type: str, entity_id) -> Optional[str]:
|
||||
"""Check if entity already has a discogs_id."""
|
||||
|
|
|
|||
|
|
@ -196,8 +196,8 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD
|
|||
current_item=track_name,
|
||||
log_line=f'{track_name} → {cached_match.get("name", "?")} (cache)', log_type='success')
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("discovery cache lookup failed: %s", e)
|
||||
|
||||
# Step 2: Generate search queries
|
||||
try:
|
||||
|
|
@ -252,8 +252,8 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD
|
|||
if match and confidence > best_confidence:
|
||||
best_confidence = confidence
|
||||
best_match = match
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("extended discovery search failed: %s", e)
|
||||
|
||||
# Step 4: Store results
|
||||
if best_match and best_confidence >= min_confidence:
|
||||
|
|
@ -290,8 +290,8 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD
|
|||
if _raw:
|
||||
track_number = _raw.get('track_number')
|
||||
disc_number = _raw.get('disc_number')
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("metadata cache lookup for album enrichment failed: %s", e)
|
||||
|
||||
matched_data = {
|
||||
'id': best_match.id if hasattr(best_match, 'id') else '',
|
||||
|
|
@ -323,8 +323,8 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD
|
|||
best_confidence, matched_data,
|
||||
track_name, artist_name
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("save discovery cache match failed: %s", e)
|
||||
|
||||
logger.info(f"[{i+1}/{len(undiscovered_tracks)}] {track_name} → {matched_data['name']} ({best_confidence:.2f})")
|
||||
deps.update_automation_progress(automation_id,
|
||||
|
|
@ -366,8 +366,8 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD
|
|||
'failed_count': str(total_failed),
|
||||
'skipped_count': str(total_skipped),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("discovery_completed emit failed: %s", e)
|
||||
|
||||
logger.error(f"Playlist discovery complete: {total_discovered} discovered, {total_failed} failed, {total_skipped} skipped")
|
||||
deps.update_automation_progress(automation_id, status='finished', progress=100,
|
||||
|
|
|
|||
|
|
@ -32,14 +32,30 @@ import logging
|
|||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from core.metadata.registry import get_client_for_source, get_primary_source, get_source_priority
|
||||
from core.metadata.types import Album
|
||||
from core.wishlist.payloads import ensure_wishlist_track_format
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Per-source typed converter dispatch — same registry pattern as
|
||||
# the metadata builders. Quality-scanner result normalization routes
|
||||
# the embedded ``track.album`` blob through Album.from_<source>_dict()
|
||||
# when provider is known. Falls back to legacy duck-typed extraction.
|
||||
_TYPED_ALBUM_CONVERTERS: Dict[str, Callable[[Dict[str, Any]], Album]] = {
|
||||
'spotify': Album.from_spotify_dict,
|
||||
'itunes': Album.from_itunes_dict,
|
||||
'deezer': Album.from_deezer_dict,
|
||||
'discogs': Album.from_discogs_dict,
|
||||
'musicbrainz': Album.from_musicbrainz_dict,
|
||||
'hydrabase': Album.from_hydrabase_dict,
|
||||
'qobuz': Album.from_qobuz_dict,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualityScannerDeps:
|
||||
"""Bundle of cross-cutting deps the quality scanner needs."""
|
||||
|
|
@ -140,7 +156,16 @@ def _normalize_image_entries(image_value: Any) -> list[dict]:
|
|||
return normalized
|
||||
|
||||
|
||||
def _normalize_track_album(track_item: Any) -> dict:
|
||||
def _normalize_track_album(track_item: Any, provider: Optional[str] = None) -> dict:
|
||||
"""Normalize a track's embedded album blob into a flat dict.
|
||||
|
||||
When ``provider`` is provided AND maps to a registered typed Album
|
||||
converter, routes through the typed path to seed canonical fields
|
||||
on ``album_data`` before legacy fallback chains fill any gaps.
|
||||
Falls back to legacy duck-typed extraction on unknown provider /
|
||||
non-dict input / typed converter error — same pattern as the
|
||||
metadata builders.
|
||||
"""
|
||||
album = _extract_lookup_value(track_item, 'album', default={})
|
||||
if isinstance(album, dict):
|
||||
album_data = dict(album)
|
||||
|
|
@ -152,6 +177,27 @@ def _normalize_track_album(track_item: Any) -> dict:
|
|||
'release_date': _extract_lookup_value(album, 'release_date', default='') or '',
|
||||
}
|
||||
|
||||
if provider and isinstance(album, dict):
|
||||
converter = _TYPED_ALBUM_CONVERTERS.get(provider.strip().lower())
|
||||
if converter is not None:
|
||||
try:
|
||||
typed_album = converter(album)
|
||||
if typed_album.name:
|
||||
album_data.setdefault('name', typed_album.name)
|
||||
if typed_album.album_type:
|
||||
album_data.setdefault('album_type', typed_album.album_type)
|
||||
if typed_album.total_tracks:
|
||||
album_data.setdefault('total_tracks', typed_album.total_tracks)
|
||||
if typed_album.release_date:
|
||||
album_data.setdefault('release_date', typed_album.release_date)
|
||||
if typed_album.id:
|
||||
album_data.setdefault('id', typed_album.id)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Typed album converter failed for provider %s in quality "
|
||||
"scanner normalize, falling back to legacy: %s", provider, exc,
|
||||
)
|
||||
|
||||
album_data.setdefault('name', _extract_lookup_value(track_item, 'album_name', default='Unknown Album') or 'Unknown Album')
|
||||
album_data.setdefault('album_type', _extract_lookup_value(track_item, 'album_type', default='album') or 'album')
|
||||
album_data.setdefault('total_tracks', _extract_lookup_value(track_item, 'total_tracks', 'track_count', default=0) or 0)
|
||||
|
|
@ -189,7 +235,7 @@ def _normalize_track_match(track_item: Any, provider: str) -> dict:
|
|||
'id': _extract_lookup_value(track_item, 'id', 'track_id', default='') or '',
|
||||
'name': _extract_lookup_value(track_item, 'name', 'title', default='Unknown Track') or 'Unknown Track',
|
||||
'artists': _normalize_track_artists(track_item),
|
||||
'album': _normalize_track_album(track_item),
|
||||
'album': _normalize_track_album(track_item, provider=provider),
|
||||
'image_url': _extract_lookup_value(track_item, 'image_url', 'album_cover_url', default=None),
|
||||
'duration_ms': _extract_lookup_value(track_item, 'duration_ms', default=0) or 0,
|
||||
'track_number': _extract_lookup_value(track_item, 'track_number', default=1) or 1,
|
||||
|
|
@ -602,8 +648,8 @@ def run_quality_scanner(scope='watchlist', profile_id=1, deps: QualityScannerDep
|
|||
'low_quality': str(deps.quality_scanner_state.get('low_quality', 0)),
|
||||
'total_scanned': str(deps.quality_scanner_state.get('processed', 0)),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("emit quality_scan_completed failed: %s", e)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Quality Scanner] Critical error: {e}")
|
||||
|
|
|
|||
|
|
@ -39,8 +39,7 @@ class SyncDeps:
|
|||
"""Bundle of cross-cutting deps the sync worker needs."""
|
||||
config_manager: Any
|
||||
sync_service: Any
|
||||
plex_client: Any
|
||||
jellyfin_client: Any
|
||||
media_server_engine: Any
|
||||
automation_engine: Any
|
||||
run_async: Callable[..., Any]
|
||||
record_sync_history_start: Callable
|
||||
|
|
@ -227,8 +226,9 @@ def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, p
|
|||
|
||||
# Check sync service components
|
||||
logger.info(f" spotify_client: {sync_service.spotify_client is not None}")
|
||||
logger.info(f" deps.plex_client: {sync_service.plex_client is not None}")
|
||||
logger.info(f" deps.jellyfin_client: {sync_service.jellyfin_client is not None}")
|
||||
_ms_engine = getattr(sync_service, '_engine', None)
|
||||
logger.info(f" plex_client: {(_ms_engine.client('plex') if _ms_engine else None) is not None}")
|
||||
logger.info(f" jellyfin_client: {(_ms_engine.client('jellyfin') if _ms_engine else None) is not None}")
|
||||
|
||||
# Check media server connection before starting
|
||||
from config.settings import config_manager
|
||||
|
|
@ -290,8 +290,8 @@ def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, p
|
|||
logger.debug(f"Sync cache hit: '{original_title}' → server track {cached['server_track_id']}")
|
||||
return DatabaseTrackCached(db_track_check), cached['confidence']
|
||||
logger.warning(f"Sync cache stale for '{original_title}' — track gone")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("sync match cache fast-path failed: %s", e)
|
||||
# --- End cache fast-path ---
|
||||
|
||||
# Try each artist (same logic as original)
|
||||
|
|
@ -322,8 +322,8 @@ def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, p
|
|||
spotify_id, me.clean_title(original_title), me.clean_artist(artist_name),
|
||||
active_server, db_track.id, db_track.title, confidence
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("save sync match cache failed: %s", e)
|
||||
|
||||
# Create mock track object for playlist creation
|
||||
class DatabaseTrackMock:
|
||||
|
|
@ -404,11 +404,12 @@ def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, p
|
|||
try:
|
||||
active_server = deps.config_manager.get_active_media_server()
|
||||
logger.info(f"[PLAYLIST IMAGE] active_server={active_server}")
|
||||
if active_server == 'plex' and deps.plex_client:
|
||||
ok = deps.plex_client.set_playlist_image(playlist_name, playlist_image_url)
|
||||
_engine = deps.media_server_engine
|
||||
if active_server == 'plex' and _engine and _engine.client('plex'):
|
||||
ok = _engine.client('plex').set_playlist_image(playlist_name, playlist_image_url)
|
||||
logger.info(f"[PLAYLIST IMAGE] Plex upload result: {ok}")
|
||||
elif active_server in ('jellyfin', 'emby') and deps.jellyfin_client:
|
||||
ok = deps.jellyfin_client.set_playlist_image(playlist_name, playlist_image_url)
|
||||
elif active_server in ('jellyfin', 'emby') and _engine and _engine.client('jellyfin'):
|
||||
ok = _engine.client('jellyfin').set_playlist_image(playlist_name, playlist_image_url)
|
||||
logger.info(f"[PLAYLIST IMAGE] Jellyfin upload result: {ok}")
|
||||
# Navidrome doesn't support custom playlist images
|
||||
except Exception as img_err:
|
||||
|
|
@ -428,8 +429,8 @@ def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, p
|
|||
entry = db.get_sync_history_entry(_resync_entry_id)
|
||||
if entry:
|
||||
target_batch_id = entry.get('batch_id', sync_batch_id)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("resync history lookup failed: %s", e)
|
||||
else:
|
||||
db.update_sync_history_completion(sync_batch_id, matched, synced, failed)
|
||||
|
||||
|
|
@ -465,8 +466,8 @@ def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, p
|
|||
'synced_tracks': str(getattr(result, 'synced_tracks', 0)),
|
||||
'failed_tracks': str(getattr(result, 'failed_tracks', 0)),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("playlist_synced emit failed: %s", e)
|
||||
|
||||
# Save sync status with match counts and track hash for smart-skip on next scheduled sync
|
||||
import hashlib as _hl
|
||||
|
|
|
|||
31
core/download_engine/__init__.py
Normal file
31
core/download_engine/__init__.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"""Download Engine — central owner of cross-source download state,
|
||||
thread workers, search retry, rate-limits, and fallback chains.
|
||||
|
||||
This is the second leg of the multi-source download dispatcher
|
||||
refactor (the first leg, ``core/download_plugins/``, defined the
|
||||
contract). The engine takes ownership of everything that used to
|
||||
be duplicated across the per-source clients (background thread
|
||||
workers, active_downloads dicts, search retry ladders, quality
|
||||
filtering, hybrid fallback). Clients become DUMB — just hit the
|
||||
API for their source, manage their own auth state, and let the
|
||||
engine drive everything else.
|
||||
|
||||
This package is built up in phases (see
|
||||
``docs/download-engine-refactor-plan.md`` for the full plan):
|
||||
|
||||
- Phase B (current) — engine skeleton + state lift.
|
||||
- Phase C — background download worker.
|
||||
- Phase D — search retry + quality filter.
|
||||
- Phase E — rate-limit pool.
|
||||
- Phase F — fallback chain.
|
||||
|
||||
Each phase is purely additive at first (engine grows, clients
|
||||
unchanged). Migration to the new shape happens one source per
|
||||
commit so behavior never breaks across the suite.
|
||||
"""
|
||||
|
||||
from core.download_engine.engine import DownloadEngine
|
||||
from core.download_engine.rate_limit import RateLimitPolicy
|
||||
from core.download_engine.worker import BackgroundDownloadWorker
|
||||
|
||||
__all__ = ["DownloadEngine", "BackgroundDownloadWorker", "RateLimitPolicy"]
|
||||
457
core/download_engine/engine.py
Normal file
457
core/download_engine/engine.py
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
"""DownloadEngine — central owner of cross-source download state.
|
||||
|
||||
Phase B scope: skeleton only. The engine exposes a place for
|
||||
plugins to register, a single ``active_downloads`` dict keyed by
|
||||
``(source, download_id)``, and per-source RLocks that guard mutations
|
||||
without serializing workers across different sources.
|
||||
|
||||
Subsequent phases bolt more capability on top:
|
||||
- ``dispatch_download(plugin, target_id)`` (Phase C — replaces every
|
||||
client's ``_download_thread_worker`` boilerplate).
|
||||
- ``search(query, source_chain)`` (Phase D — replaces every client's
|
||||
retry ladder + quality filter).
|
||||
- ``rate_limit.acquire(source)`` (Phase E — replaces every client's
|
||||
semaphore + last-download-timestamp dance).
|
||||
- ``search_with_fallback`` / ``download_with_fallback`` (Phase F —
|
||||
unifies hybrid mode across search and download).
|
||||
|
||||
The engine is constructed by ``DownloadOrchestrator.__init__`` and
|
||||
each plugin from the registry is registered with it. In Phase B
|
||||
nothing in the existing code paths goes through the engine yet —
|
||||
this commit is pure additive scaffolding so subsequent commits can
|
||||
introduce engine-driven behavior one piece at a time without a
|
||||
big-bang switchover.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("download_engine")
|
||||
|
||||
|
||||
# Type alias for the per-download state dict. Today's clients each
|
||||
# define their own slightly-different shape (see Phase A pinning
|
||||
# tests); the engine stores them as opaque dicts and the per-plugin
|
||||
# accessor preserves the source-specific fields.
|
||||
DownloadRecord = Dict[str, Any]
|
||||
|
||||
|
||||
class DownloadEngine:
|
||||
"""Central state for every active download across every source.
|
||||
|
||||
State is keyed by ``(source_name, download_id)`` so the same
|
||||
UUID could hypothetically appear in two sources without
|
||||
collision (in practice each source generates its own UUID4
|
||||
so collisions are negligible — the source qualifier exists
|
||||
so the engine can answer "which plugin owns this download" in
|
||||
O(1) without iterating every plugin).
|
||||
|
||||
Thread safety: per-source lock sharding. Each source gets its own
|
||||
RLock — progress callbacks on Deezer don't block Tidal's worker
|
||||
and vice versa, matching the pre-refactor behavior where each
|
||||
client owned its own download lock. Read-only accessors
|
||||
(``get_record``, ``iter_records_for_source``) take the source's
|
||||
lock briefly and return a SHALLOW COPY so the caller can iterate
|
||||
without holding the lock. Callers that need to mutate a record
|
||||
should use ``update_record`` which takes the lock and applies the
|
||||
patch atomically.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Nested dict: source_name → {download_id → record}. Replaces
|
||||
# the original single-dict composite-key layout so
|
||||
# ``iter_records_for_source`` is O(source_records) instead of
|
||||
# O(total_records).
|
||||
self._records: Dict[str, Dict[str, DownloadRecord]] = {}
|
||||
# Per-source RLocks. Each source gets its own so progress
|
||||
# updates on one source never block writes on another. RLock
|
||||
# so a plugin's worker callback can re-enter while holding the
|
||||
# lock for its own update. Lazily created via ``_source_lock``;
|
||||
# the meta-lock guards creation against the create-race window
|
||||
# where two threads could both miss + both create.
|
||||
self._source_locks: Dict[str, threading.RLock] = {}
|
||||
self._source_locks_lock = threading.Lock()
|
||||
# Plugins that have registered with the engine. Source name
|
||||
# → plugin instance.
|
||||
self._plugins: Dict[str, Any] = {}
|
||||
# Alias → canonical-name map. Lets engine resolve legacy
|
||||
# source-name strings (e.g. ``'deezer_dl'`` for Deezer) to
|
||||
# the canonical key in ``_plugins``. Cin's review caught
|
||||
# that engine.cancel_download(source_hint='deezer_dl')
|
||||
# silently fell through to Soulseek because alias resolution
|
||||
# only existed at the registry, not on the engine.
|
||||
self._aliases: Dict[str, str] = {}
|
||||
# Background download worker — lives on the engine because
|
||||
# it owns the cross-source state the worker mutates. Lazy
|
||||
# import keeps the engine module standalone.
|
||||
from core.download_engine.worker import BackgroundDownloadWorker
|
||||
self.worker = BackgroundDownloadWorker(self)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Plugin registration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def register_plugin(self, source_name: str, plugin: Any,
|
||||
aliases: Tuple[str, ...] = ()) -> None:
|
||||
"""Register a plugin under its canonical source name. Called
|
||||
once per source by the orchestrator after the registry's
|
||||
``initialize`` builds the client instances.
|
||||
|
||||
``aliases`` is the list of legacy source-name strings that
|
||||
should resolve to this plugin (e.g. ``'deezer_dl'`` for
|
||||
Deezer). Without alias resolution the engine couldn't route
|
||||
cancel/lookup calls that came in with the legacy name.
|
||||
|
||||
If the plugin exposes ``set_engine(engine)``, the engine
|
||||
passes a self-reference so the plugin can dispatch into
|
||||
``engine.worker`` / read state / etc. Plugins that haven't
|
||||
been migrated to the engine yet simply don't define
|
||||
``set_engine`` — they keep their pre-engine behavior
|
||||
unchanged.
|
||||
|
||||
Also reads the plugin's declared ``RateLimitPolicy`` (via
|
||||
the ``rate_limit_policy()`` method or ``RATE_LIMIT_POLICY``
|
||||
class attribute) and applies it to the worker. Plugins that
|
||||
don't declare a policy get the conservative default
|
||||
(concurrency=1, delay=0).
|
||||
"""
|
||||
if source_name in self._plugins:
|
||||
logger.warning("Plugin %s already registered with engine — overwriting", source_name)
|
||||
self._plugins[source_name] = plugin
|
||||
for alias in aliases:
|
||||
self._aliases[alias] = source_name
|
||||
|
||||
# Apply the plugin's rate-limit policy BEFORE set_engine so
|
||||
# set_engine callbacks can override per-source if they need
|
||||
# config-driven values (e.g. YouTube's user-tunable delay).
|
||||
from core.download_engine.rate_limit import resolve_policy
|
||||
policy = resolve_policy(plugin)
|
||||
self.worker.set_concurrency(source_name, policy.download_concurrency)
|
||||
self.worker.set_delay(source_name, policy.download_delay_seconds)
|
||||
|
||||
set_engine = getattr(plugin, 'set_engine', None)
|
||||
if callable(set_engine):
|
||||
try:
|
||||
set_engine(self)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Plugin %s set_engine callback failed: %s", source_name, exc,
|
||||
)
|
||||
|
||||
def get_plugin(self, source_name: str) -> Optional[Any]:
|
||||
"""Return the plugin instance for the given source name.
|
||||
Resolves through aliases — e.g. ``get_plugin('deezer_dl')``
|
||||
returns the same instance as ``get_plugin('deezer')``."""
|
||||
if source_name in self._plugins:
|
||||
return self._plugins[source_name]
|
||||
canonical = self._aliases.get(source_name)
|
||||
if canonical:
|
||||
return self._plugins.get(canonical)
|
||||
return None
|
||||
|
||||
def _resolve_canonical(self, source_name: str) -> Optional[str]:
|
||||
"""Return the canonical source name for an input that may be
|
||||
an alias. Returns None if the input matches neither a
|
||||
canonical name nor an alias."""
|
||||
if source_name in self._plugins:
|
||||
return source_name
|
||||
return self._aliases.get(source_name)
|
||||
|
||||
def registered_sources(self) -> List[str]:
|
||||
return list(self._plugins.keys())
|
||||
|
||||
def _source_lock(self, source_name: str) -> threading.RLock:
|
||||
"""Return the per-source RLock, lazy-creating it on first use.
|
||||
The meta-lock around the cache lookup closes the create-race
|
||||
window where two threads both miss + both create a fresh lock.
|
||||
"""
|
||||
with self._source_locks_lock:
|
||||
lock = self._source_locks.get(source_name)
|
||||
if lock is None:
|
||||
lock = threading.RLock()
|
||||
self._source_locks[source_name] = lock
|
||||
return lock
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Active-downloads state — Phase B core surface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_record(self, source_name: str, download_id: str, record: DownloadRecord) -> None:
|
||||
"""Insert a fresh download record. Used by clients (today
|
||||
directly via their own dicts; Phase B2 routes them through
|
||||
here)."""
|
||||
with self._source_lock(source_name):
|
||||
source_bucket = self._records.setdefault(source_name, {})
|
||||
if download_id in source_bucket:
|
||||
logger.warning("Replacing existing download record for %s/%s", source_name, download_id)
|
||||
source_bucket[download_id] = dict(record)
|
||||
|
||||
def update_record(self, source_name: str, download_id: str, patch: DownloadRecord) -> None:
|
||||
"""Apply a partial patch to an existing record. No-op if the
|
||||
record was already removed (e.g. cancelled mid-update)."""
|
||||
with self._source_lock(source_name):
|
||||
existing = self._records.get(source_name, {}).get(download_id)
|
||||
if existing is None:
|
||||
return
|
||||
existing.update(patch)
|
||||
|
||||
def update_record_unless_state(self, source_name: str, download_id: str,
|
||||
patch: DownloadRecord,
|
||||
skip_if_state_in: Tuple[str, ...] = ()) -> bool:
|
||||
"""Atomically check the record's state and apply ``patch`` only
|
||||
if the current state is NOT in ``skip_if_state_in``. Returns
|
||||
True if the patch was applied, False if it was skipped (or
|
||||
the record didn't exist).
|
||||
|
||||
Used by the background download worker's ``_mark_terminal``
|
||||
to avoid the read-then-write race Cin flagged: a cancel
|
||||
landing between the snapshot and update could be overwritten
|
||||
back to Errored / Completed. Holding the source's lock across
|
||||
the check + write closes the window.
|
||||
"""
|
||||
with self._source_lock(source_name):
|
||||
existing = self._records.get(source_name, {}).get(download_id)
|
||||
if existing is None:
|
||||
return False
|
||||
if existing.get('state') in skip_if_state_in:
|
||||
return False
|
||||
existing.update(patch)
|
||||
return True
|
||||
|
||||
def remove_record(self, source_name: str, download_id: str) -> Optional[DownloadRecord]:
|
||||
"""Delete a record (cancellation cleanup). Returns the
|
||||
removed record or None if not found."""
|
||||
with self._source_lock(source_name):
|
||||
source_bucket = self._records.get(source_name)
|
||||
if not source_bucket:
|
||||
return None
|
||||
removed = source_bucket.pop(download_id, None)
|
||||
# Drop the empty source bucket so iteration / membership
|
||||
# checks don't see a stale source key.
|
||||
if not source_bucket:
|
||||
self._records.pop(source_name, None)
|
||||
return removed
|
||||
|
||||
def get_record(self, source_name: str, download_id: str) -> Optional[DownloadRecord]:
|
||||
"""Return a SHALLOW COPY of the record. Caller mutations
|
||||
don't affect engine state — use ``update_record`` for that."""
|
||||
with self._source_lock(source_name):
|
||||
record = self._records.get(source_name, {}).get(download_id)
|
||||
return dict(record) if record is not None else None
|
||||
|
||||
def iter_records_for_source(self, source_name: str) -> Iterator[DownloadRecord]:
|
||||
"""Yield SHALLOW COPIES of every record owned by a source.
|
||||
Holds the source's lock briefly to snapshot, then yields
|
||||
outside the lock so callers can spend arbitrary time on each
|
||||
record.
|
||||
|
||||
With the nested-dict layout this is O(source_records) — only
|
||||
touches the bucket for the requested source, not every record
|
||||
across every source.
|
||||
"""
|
||||
with self._source_lock(source_name):
|
||||
source_bucket = self._records.get(source_name, {})
|
||||
snapshot = [dict(record) for record in source_bucket.values()]
|
||||
for record in snapshot:
|
||||
yield record
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cross-source query dispatch — Phase B2 surface
|
||||
# ------------------------------------------------------------------
|
||||
#
|
||||
# The orchestrator historically iterated every plugin in its own
|
||||
# ``get_all_downloads`` / ``get_download_status`` / ``cancel_download``
|
||||
# methods (with hand-maintained client lists, before the registry
|
||||
# came along). That iteration logic moves into the engine here so
|
||||
# the orchestrator becomes a thin pass-through (Phase B3).
|
||||
#
|
||||
# In Phase B these methods iterate the registered plugins and call
|
||||
# their existing ``get_all_downloads`` / ``cancel_download``
|
||||
# methods — same behavior as today, just in a new home. Phase C/D
|
||||
# will replace plugin-iteration with direct engine-state queries
|
||||
# once the thread worker is also lifted.
|
||||
#
|
||||
# All methods are async to match the per-plugin contract.
|
||||
|
||||
async def get_all_downloads(self, exclude: Tuple[str, ...] = ()):
|
||||
"""Aggregated view across every registered plugin's active
|
||||
downloads. Per-plugin exceptions are swallowed (one source
|
||||
failing shouldn't take down cross-source aggregation) but
|
||||
logged at debug level — same defensive shape the legacy
|
||||
orchestrator had.
|
||||
|
||||
``exclude`` skips named sources entirely. The download monitor
|
||||
passes ``('soulseek',)`` so it doesn't double-fetch slskd
|
||||
transfers (it already pulled them via the slskd transfers
|
||||
endpoint earlier in the same loop).
|
||||
"""
|
||||
all_downloads = []
|
||||
for source_name, plugin in self._plugins.items():
|
||||
if plugin is None or source_name in exclude:
|
||||
continue
|
||||
try:
|
||||
all_downloads.extend(await plugin.get_all_downloads())
|
||||
except Exception as exc:
|
||||
logger.debug("%s get_all_downloads failed: %s", source_name, exc)
|
||||
return all_downloads
|
||||
|
||||
async def get_download_status(self, download_id: str):
|
||||
"""Find a download_id across every plugin. Returns the first
|
||||
plugin's response or None if no plugin owns it."""
|
||||
for source_name, plugin in self._plugins.items():
|
||||
if plugin is None:
|
||||
continue
|
||||
try:
|
||||
status = await plugin.get_download_status(download_id)
|
||||
if status:
|
||||
return status
|
||||
except Exception as exc:
|
||||
logger.debug("%s get_download_status failed: %s", source_name, exc)
|
||||
return None
|
||||
|
||||
async def cancel_download(self, download_id: str,
|
||||
source_hint: Optional[str] = None,
|
||||
remove: bool = False) -> bool:
|
||||
"""Cancel a download. ``source_hint`` is the source name (or
|
||||
legacy alias like ``'deezer_dl'``, or a real Soulseek peer
|
||||
username) — when provided, routes directly to that plugin.
|
||||
When omitted, every plugin is asked in turn until one accepts.
|
||||
|
||||
Cin's review caught a bug here: legacy alias strings like
|
||||
``'deezer_dl'`` weren't resolved to the canonical ``'deezer'``
|
||||
plugin name, so the cancel silently fell through to Soulseek.
|
||||
Resolution now goes through ``_resolve_canonical`` first.
|
||||
"""
|
||||
# Direct routing when the caller knows the source.
|
||||
if source_hint:
|
||||
canonical = self._resolve_canonical(source_hint)
|
||||
# Streaming source names (or aliases) resolve to a
|
||||
# registered plugin. Anything else (real Soulseek peer
|
||||
# name not in our registry) routes to Soulseek.
|
||||
if canonical and canonical != 'soulseek':
|
||||
target_plugin = self._plugins.get(canonical)
|
||||
if target_plugin is not None:
|
||||
try:
|
||||
return await target_plugin.cancel_download(
|
||||
download_id, source_hint, remove,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("%s cancel_download failed: %s", canonical, exc)
|
||||
return False
|
||||
soulseek = self._plugins.get('soulseek')
|
||||
if soulseek is not None:
|
||||
try:
|
||||
return await soulseek.cancel_download(download_id, source_hint, remove)
|
||||
except Exception as exc:
|
||||
logger.debug("soulseek cancel_download failed: %s", exc)
|
||||
return False
|
||||
|
||||
# No hint → ask every plugin until one cancels successfully.
|
||||
for source_name, plugin in self._plugins.items():
|
||||
if plugin is None:
|
||||
continue
|
||||
try:
|
||||
if await plugin.cancel_download(download_id, source_hint, remove):
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.debug("%s cancel_download failed: %s", source_name, exc)
|
||||
return False
|
||||
|
||||
async def clear_all_completed_downloads(self) -> bool:
|
||||
"""Best-effort cleanup of every plugin's completed-downloads
|
||||
list. Skips plugins that report not-configured (saves API
|
||||
calls + log noise)."""
|
||||
results = []
|
||||
for source_name, plugin in self._plugins.items():
|
||||
if plugin is None:
|
||||
continue
|
||||
if hasattr(plugin, 'is_configured') and not plugin.is_configured():
|
||||
logger.debug("Skipping %s clear_all_completed_downloads (not configured)", source_name)
|
||||
continue
|
||||
try:
|
||||
results.append(await plugin.clear_all_completed_downloads())
|
||||
except Exception as exc:
|
||||
logger.warning("%s clear_all_completed_downloads failed: %s", source_name, exc)
|
||||
results.append(False)
|
||||
return all(results) if results else True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hybrid fallback — Phase F surface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def search_with_fallback(self, query: str, source_chain,
|
||||
timeout=None, progress_callback=None):
|
||||
"""Try each source in ``source_chain`` until one returns
|
||||
tracks. Skips unconfigured / unregistered sources, swallows
|
||||
per-source exceptions. Returns the first non-empty
|
||||
(tracks, albums) tuple, or ``([], [])`` when every source
|
||||
in the chain is exhausted.
|
||||
|
||||
Replaces orchestrator's hand-rolled hybrid search loop. The
|
||||
chain is ordered (most-preferred first).
|
||||
"""
|
||||
for i, source_name in enumerate(source_chain):
|
||||
plugin = self._plugins.get(source_name)
|
||||
if plugin is None:
|
||||
logger.info(f"Skipping {source_name} (not available)")
|
||||
continue
|
||||
if hasattr(plugin, 'is_configured') and not plugin.is_configured():
|
||||
logger.info(f"Skipping {source_name} (not configured)")
|
||||
continue
|
||||
|
||||
try:
|
||||
logger.info(f"Trying {source_name} (priority {i+1}): {query}")
|
||||
tracks, albums = await plugin.search(query, timeout, progress_callback)
|
||||
if tracks:
|
||||
logger.info(f"{source_name} found {len(tracks)} tracks")
|
||||
return (tracks, albums)
|
||||
except Exception as e:
|
||||
logger.warning(f"{source_name} search failed: {e}")
|
||||
|
||||
logger.warning(
|
||||
"Hybrid search: all sources (%s) found nothing for: %s",
|
||||
', '.join(source_chain), query,
|
||||
)
|
||||
return ([], [])
|
||||
|
||||
async def download_with_fallback(self, username: str, filename: str,
|
||||
file_size: int, source_chain) -> Optional[str]:
|
||||
"""Try each source in ``source_chain`` until one accepts the
|
||||
download (returns a non-None download_id). Fixes the legacy
|
||||
bug where hybrid mode silently routed to a single source via
|
||||
the username hint with no retry on failure.
|
||||
|
||||
``username`` is treated as a hint when it matches a source
|
||||
name in the chain — that source is tried FIRST regardless of
|
||||
chain order. Anything else (e.g. a real Soulseek peer name)
|
||||
routes through the chain in declared order.
|
||||
"""
|
||||
# Promote a matching source-name hint to the head of the chain.
|
||||
ordered_chain = list(source_chain)
|
||||
if username and username in ordered_chain:
|
||||
ordered_chain.remove(username)
|
||||
ordered_chain.insert(0, username)
|
||||
|
||||
for source_name in ordered_chain:
|
||||
plugin = self._plugins.get(source_name)
|
||||
if plugin is None:
|
||||
continue
|
||||
if hasattr(plugin, 'is_configured') and not plugin.is_configured():
|
||||
continue
|
||||
try:
|
||||
download_id = await plugin.download(username, filename, file_size)
|
||||
if download_id is not None:
|
||||
return download_id
|
||||
logger.info(f"{source_name} declined download — trying next in chain")
|
||||
except Exception as e:
|
||||
logger.warning(f"{source_name} download raised — trying next in chain: {e}")
|
||||
|
||||
logger.warning(
|
||||
"Hybrid download: every source in chain (%s) refused %r",
|
||||
', '.join(ordered_chain), filename,
|
||||
)
|
||||
return None
|
||||
76
core/download_engine/rate_limit.py
Normal file
76
core/download_engine/rate_limit.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""Per-source rate-limit policy declarations.
|
||||
|
||||
Today's per-source download throttling is scattered:
|
||||
|
||||
- YouTube: ``self._download_delay = config_manager.get('youtube.download_delay', 3)``
|
||||
set in ``__init__``, applied in ``set_engine`` via worker.set_delay.
|
||||
- Qobuz: module-level ``_qobuz_api_lock`` + ``_QOBUZ_MIN_INTERVAL`` for
|
||||
search-side throttling, no download-side throttle.
|
||||
- Other sources: no explicit declarations — default to 0s delay /
|
||||
concurrency=1, which works because the streaming APIs have their
|
||||
own gateway-level rate limits.
|
||||
|
||||
Phase E centralizes this into one place: each plugin declares a
|
||||
``RateLimitPolicy`` (either as a class attribute or returned from a
|
||||
``rate_limit_policy()`` method), and the engine reads + applies the
|
||||
policy to ``engine.worker`` at ``register_plugin`` time.
|
||||
|
||||
Adding a new source = declaring its policy alongside the rest of
|
||||
the source's auth/config — no longer a hidden line in __init__ or a
|
||||
module-level constant in the client file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RateLimitPolicy:
|
||||
"""Per-source download throttling policy.
|
||||
|
||||
Attributes:
|
||||
download_concurrency: Max number of concurrent downloads
|
||||
from this source. Default 1 (serial). Most streaming
|
||||
APIs prefer serial transfers because parallel just
|
||||
trades rate-limit errors for thread overhead.
|
||||
download_delay_seconds: Minimum gap between successive
|
||||
downloads from this source. YouTube uses 3s today
|
||||
(legacy ``_download_delay`` config key) to avoid
|
||||
yt-dlp 429s. Most other sources use 0.
|
||||
"""
|
||||
|
||||
download_concurrency: int = 1
|
||||
download_delay_seconds: float = 0.0
|
||||
|
||||
|
||||
# Sentinel default — most plugins want this. Plugins that need
|
||||
# tighter throttling override by exposing ``RATE_LIMIT_POLICY`` as
|
||||
# a class attribute or returning a custom one from
|
||||
# ``rate_limit_policy()``.
|
||||
DEFAULT_POLICY = RateLimitPolicy()
|
||||
|
||||
|
||||
def resolve_policy(plugin) -> RateLimitPolicy:
|
||||
"""Read a plugin's declared rate-limit policy. Checks (in order):
|
||||
1. ``plugin.rate_limit_policy()`` method (returns a RateLimitPolicy)
|
||||
2. ``plugin.RATE_LIMIT_POLICY`` class attribute
|
||||
3. ``DEFAULT_POLICY``
|
||||
"""
|
||||
method = getattr(plugin, 'rate_limit_policy', None)
|
||||
if callable(method):
|
||||
try:
|
||||
policy = method()
|
||||
if isinstance(policy, RateLimitPolicy):
|
||||
return policy
|
||||
except Exception as e:
|
||||
logger.debug("plugin rate_limit_policy() call failed: %s", e)
|
||||
|
||||
declared = getattr(plugin, 'RATE_LIMIT_POLICY', None)
|
||||
if isinstance(declared, RateLimitPolicy):
|
||||
return declared
|
||||
|
||||
return DEFAULT_POLICY
|
||||
315
core/download_engine/worker.py
Normal file
315
core/download_engine/worker.py
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
"""BackgroundDownloadWorker — engine-owned thread spawning + state
|
||||
lifecycle for downloads.
|
||||
|
||||
Today every streaming download client (YouTube, Tidal, Qobuz, HiFi,
|
||||
Deezer, SoundCloud) hand-rolls the same thread-spawn pattern:
|
||||
|
||||
```python
|
||||
async def download(self, ...):
|
||||
download_id = str(uuid.uuid4())
|
||||
with self._download_lock:
|
||||
self.active_downloads[download_id] = {...initial state...}
|
||||
threading.Thread(
|
||||
target=self._download_thread_worker,
|
||||
args=(download_id, target_id, display_name, ...),
|
||||
daemon=True,
|
||||
).start()
|
||||
return download_id
|
||||
|
||||
def _download_thread_worker(self, download_id, target_id, display_name, ...):
|
||||
with self._download_semaphore:
|
||||
# rate-limit sleep
|
||||
# update state to 'InProgress, Downloading'
|
||||
file_path = self._download_sync(...) # the source-specific atomic op
|
||||
# update state to 'Completed, Succeeded' / 'Errored'
|
||||
```
|
||||
|
||||
That pattern is duplicated 6+ times across the codebase (~70 LOC
|
||||
each, ~490 total). The worker class lifts it into the engine — each
|
||||
plugin only has to provide the atomic op (``impl_callable``) and
|
||||
declare its rate-limit policy. Adding a new download source becomes
|
||||
a much smaller patch.
|
||||
|
||||
Phase C1 scope: introduce the worker. No client migrated yet — the
|
||||
worker just exists for C2–C7 to migrate sources one at a time, each
|
||||
under a passing pinning test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("download_engine.worker")
|
||||
|
||||
|
||||
# Type aliases for clarity. ``ImplCallable`` is the per-plugin
|
||||
# atomic download operation — synchronous, returns a file path on
|
||||
# success or raises (or returns None) on failure.
|
||||
ImplCallable = Callable[[str, Any, str], Optional[str]]
|
||||
|
||||
|
||||
class BackgroundDownloadWorker:
|
||||
"""Engine-owned thread spawner for per-source downloads.
|
||||
|
||||
State-machine semantics (preserved verbatim from the legacy
|
||||
per-client workers so consumers reading these fields keep
|
||||
working):
|
||||
|
||||
- ``Initializing`` — set on dispatch, before the thread starts.
|
||||
- ``InProgress, Downloading`` — set when the worker thread
|
||||
acquires the semaphore and is about to call the impl.
|
||||
- ``Completed, Succeeded`` — set when impl returns a non-None
|
||||
file path. ``progress=100.0`` and ``file_path=<the path>``
|
||||
also written.
|
||||
- ``Errored`` — set when impl returns None OR raises. The
|
||||
record is left in place so downstream consumers can inspect
|
||||
what failed.
|
||||
|
||||
Per-source serialization: each source gets a ``threading.Semaphore``
|
||||
(default size 1, configurable per-source via ``set_concurrency``).
|
||||
Same shape the existing clients use today (each source defines
|
||||
its own semaphore). Engine owning them centrally lets a future
|
||||
Phase E rate-limiter swap the semaphore for a smarter pool.
|
||||
|
||||
Per-source delay-between-downloads: default 0 seconds (most
|
||||
sources don't need it). YouTube currently uses 3s, Qobuz uses
|
||||
1s — the legacy values get configured in via ``set_delay``
|
||||
when the source registers.
|
||||
"""
|
||||
|
||||
def __init__(self, engine: Any) -> None:
|
||||
self._engine = engine
|
||||
# Per-source semaphores + delay state. The first dispatch
|
||||
# for a source auto-creates a semaphore with concurrency=1
|
||||
# if the source hasn't been configured explicitly.
|
||||
self._semaphores: Dict[str, threading.Semaphore] = {}
|
||||
self._delays: Dict[str, float] = {}
|
||||
self._last_download_at: Dict[str, float] = {}
|
||||
self._config_lock = threading.Lock()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Per-source rate-limit configuration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_concurrency(self, source_name: str, max_concurrent: int) -> None:
|
||||
"""Set the max number of concurrent downloads for a source.
|
||||
Default is 1 (serial). Most sources will keep the default —
|
||||
the streaming APIs all rate-limit at the API gateway level
|
||||
anyway, parallel downloads just trade rate-limit errors for
|
||||
thread overhead."""
|
||||
with self._config_lock:
|
||||
self._semaphores[source_name] = threading.Semaphore(max_concurrent)
|
||||
|
||||
def set_delay(self, source_name: str, seconds: float) -> None:
|
||||
"""Set a minimum delay between successive downloads from the
|
||||
same source. YouTube uses 3s today (avoid yt-dlp 429s),
|
||||
Qobuz uses 1s. Other sources use 0 (no delay)."""
|
||||
with self._config_lock:
|
||||
self._delays[source_name] = float(seconds)
|
||||
|
||||
def _get_semaphore(self, source_name: str) -> threading.Semaphore:
|
||||
with self._config_lock:
|
||||
sem = self._semaphores.get(source_name)
|
||||
if sem is None:
|
||||
sem = threading.Semaphore(1)
|
||||
self._semaphores[source_name] = sem
|
||||
return sem
|
||||
|
||||
def _get_delay(self, source_name: str) -> float:
|
||||
with self._config_lock:
|
||||
return self._delays.get(source_name, 0.0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dispatch — public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def dispatch(
|
||||
self,
|
||||
source_name: str,
|
||||
target_id: Any,
|
||||
display_name: str,
|
||||
original_filename: str,
|
||||
impl_callable: ImplCallable,
|
||||
extra_record_fields: Optional[Dict[str, Any]] = None,
|
||||
username_override: Optional[str] = None,
|
||||
thread_name: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Kick off a background download.
|
||||
|
||||
Args:
|
||||
source_name: Canonical source name (e.g. 'youtube',
|
||||
'tidal'). Used as the engine state key + the
|
||||
username slot in the record (unless overridden).
|
||||
target_id: Source-specific identifier (track_id, video_id,
|
||||
permalink_url, album_foreign_id, etc.). Passed
|
||||
verbatim to ``impl_callable``.
|
||||
display_name: Human-readable label for logs / UI.
|
||||
original_filename: The encoded filename the orchestrator
|
||||
received (e.g. ``'12345||Song Title'``). Stored in
|
||||
the record's ``filename`` slot for context-key lookups.
|
||||
impl_callable: Synchronous function that performs the
|
||||
actual download. Signature:
|
||||
``impl_callable(download_id, target_id, display_name) -> Optional[str]``.
|
||||
Returns the final file path on success or None /
|
||||
raises on failure.
|
||||
extra_record_fields: Per-source extras to merge into the
|
||||
initial record (e.g. ``{'video_id': '...', 'url':
|
||||
'...', 'title': '...'}`` for YouTube). Used to
|
||||
preserve source-specific slots that downstream
|
||||
consumers + status APIs read.
|
||||
username_override: Use this instead of ``source_name``
|
||||
in the record's ``username`` slot. Required for
|
||||
Deezer (legacy ``'deezer_dl'``) — every other source
|
||||
uses the canonical name.
|
||||
thread_name: Optional thread name for diagnostics. Deezer
|
||||
uses ``'deezer-dl-<track_id>'`` — Phase A pinning
|
||||
tests catch any drift in this convention.
|
||||
|
||||
Returns:
|
||||
download_id (UUID4 string). The orchestrator polls via
|
||||
``engine.get_download_status(download_id)`` for progress.
|
||||
"""
|
||||
download_id = str(uuid.uuid4())
|
||||
|
||||
record: Dict[str, Any] = {
|
||||
'id': download_id,
|
||||
'filename': original_filename,
|
||||
'username': username_override or source_name,
|
||||
'state': 'Initializing',
|
||||
'progress': 0.0,
|
||||
'size': 0,
|
||||
'transferred': 0,
|
||||
'speed': 0,
|
||||
'time_remaining': None,
|
||||
'file_path': None,
|
||||
}
|
||||
if extra_record_fields:
|
||||
record.update(extra_record_fields)
|
||||
|
||||
self._engine.add_record(source_name, download_id, record)
|
||||
|
||||
thread = threading.Thread(
|
||||
target=self._worker_loop,
|
||||
args=(source_name, download_id, target_id, display_name, impl_callable),
|
||||
daemon=True,
|
||||
name=thread_name,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return download_id
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Worker thread — the lifted boilerplate
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _worker_loop(
|
||||
self,
|
||||
source_name: str,
|
||||
download_id: str,
|
||||
target_id: Any,
|
||||
display_name: str,
|
||||
impl_callable: ImplCallable,
|
||||
) -> None:
|
||||
"""Runs on the spawned daemon thread. Handles semaphore
|
||||
acquisition, rate-limit sleep, state lifecycle, exception
|
||||
capture. The plugin-specific work happens entirely inside
|
||||
``impl_callable``."""
|
||||
try:
|
||||
with self._get_semaphore(source_name):
|
||||
# Rate-limit delay against the LAST download from
|
||||
# this source (not just this worker — semaphore
|
||||
# ensures serial access while delay is configured).
|
||||
delay = self._get_delay(source_name)
|
||||
if delay > 0:
|
||||
last_at = self._last_download_at.get(source_name, 0.0)
|
||||
elapsed = time.time() - last_at
|
||||
if last_at > 0 and elapsed < delay:
|
||||
wait_time = delay - elapsed
|
||||
logger.info(
|
||||
"Rate-limit delay for %s: waiting %.1fs before next download",
|
||||
source_name, wait_time,
|
||||
)
|
||||
time.sleep(wait_time)
|
||||
|
||||
self._engine.update_record(source_name, download_id, {
|
||||
'state': 'InProgress, Downloading',
|
||||
})
|
||||
|
||||
try:
|
||||
file_path = impl_callable(download_id, target_id, display_name)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"%s download %s failed (impl raised): %s",
|
||||
source_name, download_id, exc,
|
||||
)
|
||||
self._mark_terminal(
|
||||
source_name, download_id,
|
||||
success=False, error=str(exc),
|
||||
)
|
||||
return
|
||||
|
||||
self._last_download_at[source_name] = time.time()
|
||||
|
||||
if file_path:
|
||||
# Atomic write — preserve Cancelled if user cancelled
|
||||
# between impl returning and this write. Same guard
|
||||
# _mark_terminal uses; Cin flagged both split sites.
|
||||
self._engine.update_record_unless_state(
|
||||
source_name, download_id,
|
||||
{
|
||||
'state': 'Completed, Succeeded',
|
||||
'progress': 100.0,
|
||||
'file_path': file_path,
|
||||
},
|
||||
skip_if_state_in=('Cancelled',),
|
||||
)
|
||||
logger.info(
|
||||
"%s download %s completed: %s",
|
||||
source_name, download_id, file_path,
|
||||
)
|
||||
else:
|
||||
self._mark_terminal(source_name, download_id, success=False)
|
||||
logger.error(
|
||||
"%s download %s failed (impl returned None)",
|
||||
source_name, download_id,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
# Defensive — semaphore / sleep shouldn't blow up the
|
||||
# thread, but if they do the record needs SOME terminal
|
||||
# state or it sits at 'Initializing' forever.
|
||||
logger.exception(
|
||||
"%s worker_loop crashed for download %s: %s",
|
||||
source_name, download_id, exc,
|
||||
)
|
||||
self._mark_terminal(
|
||||
source_name, download_id,
|
||||
success=False, error=f'worker crash: {exc}',
|
||||
)
|
||||
|
||||
def _mark_terminal(self, source_name: str, download_id: str,
|
||||
success: bool, error: Optional[str] = None) -> None:
|
||||
"""Write a terminal state, but DON'T clobber an explicit
|
||||
'Cancelled' state set by the user via cancel_download.
|
||||
Mirrors the legacy per-client guard
|
||||
(``if state != 'Cancelled': state = 'Errored'``) every
|
||||
client used to hand-roll inside its thread worker.
|
||||
|
||||
Uses ``update_record_unless_state`` so the check + write are
|
||||
atomic under the engine's per-source lock. Cin caught a race
|
||||
where a cancel landing between the read-snapshot + write
|
||||
could overwrite Cancelled back to Errored / Completed.
|
||||
"""
|
||||
patch: Dict[str, Any] = {
|
||||
'state': 'Completed, Succeeded' if success else 'Errored',
|
||||
}
|
||||
if error is not None:
|
||||
patch['error'] = error
|
||||
self._engine.update_record_unless_state(
|
||||
source_name, download_id, patch, skip_if_state_in=('Cancelled',),
|
||||
)
|
||||
|
|
@ -1,15 +1,22 @@
|
|||
"""
|
||||
Download Orchestrator
|
||||
Routes downloads between Soulseek, YouTube, Tidal, Qobuz, HiFi, and Deezer based on configuration.
|
||||
Routes downloads between Soulseek, YouTube, Tidal, Qobuz, HiFi, Deezer, and SoundCloud based on configuration.
|
||||
|
||||
Supports seven modes:
|
||||
Supports eight modes:
|
||||
- Soulseek Only: Traditional behavior
|
||||
- YouTube Only: YouTube-exclusive downloads
|
||||
- Tidal Only: Tidal-exclusive downloads
|
||||
- Qobuz Only: Qobuz-exclusive downloads
|
||||
- HiFi Only: Free lossless downloads via public hifi-api instances
|
||||
- Deezer Only: Deezer downloads via ARL authentication
|
||||
- SoundCloud Only: Anonymous SoundCloud downloads (DJ mixes, removed/exclusive tracks)
|
||||
- Hybrid: Try primary source first, fallback to others
|
||||
|
||||
The orchestrator dispatches through ``core.download_plugins.registry``
|
||||
instead of hardcoded per-source ``[self.soulseek, self.youtube, ...]``
|
||||
lists. External callers reach individual clients via the generic
|
||||
``orchestrator.client('<name>')`` accessor (alias-aware), not direct
|
||||
attribute access.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
|
@ -18,13 +25,9 @@ from pathlib import Path
|
|||
|
||||
from utils.logging_config import get_logger
|
||||
from config.settings import config_manager
|
||||
from core.soulseek_client import SoulseekClient, TrackResult, AlbumResult, DownloadStatus
|
||||
from core.youtube_client import YouTubeClient
|
||||
from core.tidal_download_client import TidalDownloadClient
|
||||
from core.qobuz_client import QobuzClient
|
||||
from core.hifi_client import HiFiClient
|
||||
from core.deezer_download_client import DeezerDownloadClient
|
||||
from core.lidarr_download_client import LidarrDownloadClient
|
||||
from core.download_engine import DownloadEngine
|
||||
from core.download_plugins.registry import DownloadPluginRegistry, build_default_registry
|
||||
from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus
|
||||
|
||||
logger = get_logger("download_orchestrator")
|
||||
|
||||
|
|
@ -37,18 +40,31 @@ class DownloadOrchestrator:
|
|||
Routes requests to the appropriate client(s) based on configured mode.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize orchestrator with all clients.
|
||||
Each client is initialized independently — one failing client doesn't prevent others from working."""
|
||||
self._init_failures = []
|
||||
def __init__(self, registry: Optional[DownloadPluginRegistry] = None,
|
||||
engine: Optional[DownloadEngine] = None):
|
||||
"""Initialize orchestrator with a plugin registry. Each plugin
|
||||
is built and registered independently — one failing plugin
|
||||
doesn't prevent others from working. The ``registry`` arg
|
||||
exists so tests can inject a registry with mock plugins; in
|
||||
production callers leave it None and get the default.
|
||||
|
||||
self.soulseek = self._safe_init('Soulseek', SoulseekClient)
|
||||
self.youtube = self._safe_init('YouTube', YouTubeClient)
|
||||
self.tidal = self._safe_init('Tidal', TidalDownloadClient)
|
||||
self.qobuz = self._safe_init('Qobuz', QobuzClient)
|
||||
self.hifi = self._safe_init('HiFi', HiFiClient)
|
||||
self.deezer_dl = self._safe_init('Deezer', DeezerDownloadClient)
|
||||
self.lidarr = self._safe_init('Lidarr', LidarrDownloadClient)
|
||||
``engine`` is the cross-source state owner. Phase B introduces
|
||||
it as a held reference; it isn't on any code path yet — Phase
|
||||
C/D/E/F migrate behavior into it incrementally.
|
||||
"""
|
||||
self.registry = registry if registry is not None else build_default_registry()
|
||||
self.registry.initialize()
|
||||
self._init_failures = self.registry.init_failures
|
||||
|
||||
# Engine — owns cross-source state, threading, search retry,
|
||||
# rate-limits, fallback. Built in subsequent phases. For Phase
|
||||
# B it's just an empty registry of plugins so future phases
|
||||
# can route through it without further orchestrator changes.
|
||||
self.engine = engine if engine is not None else DownloadEngine()
|
||||
for source_name, plugin in self.registry.all_plugins():
|
||||
spec = self.registry.get_spec(source_name)
|
||||
aliases = spec.aliases if spec else ()
|
||||
self.engine.register_plugin(source_name, plugin, aliases=aliases)
|
||||
|
||||
if self._init_failures:
|
||||
logger.warning(f"Download clients failed to initialize: {', '.join(self._init_failures)}")
|
||||
|
|
@ -68,15 +84,6 @@ class DownloadOrchestrator:
|
|||
self.hybrid_secondary,
|
||||
)
|
||||
|
||||
def _safe_init(self, name, cls):
|
||||
"""Initialize a download client, returning None on failure instead of crashing."""
|
||||
try:
|
||||
return cls()
|
||||
except Exception as e:
|
||||
logger.error(f"{name} download client failed to initialize: {e}")
|
||||
self._init_failures.append(name)
|
||||
return None
|
||||
|
||||
def reload_settings(self):
|
||||
"""Reload settings from config (call after settings change)"""
|
||||
self.mode = config_manager.get('download_source.mode', 'soulseek')
|
||||
|
|
@ -85,20 +92,28 @@ class DownloadOrchestrator:
|
|||
self.hybrid_order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek'])
|
||||
|
||||
# Reload underlying client configs (SLSKD URL, API key, etc.)
|
||||
if self.soulseek:
|
||||
self.soulseek._setup_client()
|
||||
soulseek = self.client('soulseek')
|
||||
if soulseek:
|
||||
soulseek._setup_client()
|
||||
logger.info("Soulseek client config reloaded")
|
||||
|
||||
# Reconnect Deezer if ARL changed
|
||||
deezer_arl = config_manager.get('deezer_download.arl', '')
|
||||
if deezer_arl and self.deezer_dl:
|
||||
self.deezer_dl.reconnect(deezer_arl)
|
||||
self.deezer_dl._quality = config_manager.get('deezer_download.quality', 'flac')
|
||||
deezer_dl = self.client('deezer_dl')
|
||||
if deezer_arl and deezer_dl:
|
||||
deezer_dl.reconnect(deezer_arl)
|
||||
deezer_dl._quality = config_manager.get('deezer_download.quality', 'flac')
|
||||
|
||||
# Reload download path for all clients that cache it
|
||||
# Reload download path for all clients that cache it.
|
||||
# Soulseek owns the path config and is reloaded above; every
|
||||
# other source mirrors that path so files all land in one
|
||||
# tree. Sources without a `download_path` attribute (e.g.
|
||||
# Lidarr — pulls into Lidarr's own tree) silently skip.
|
||||
new_path = Path(config_manager.get('soulseek.download_path', './downloads'))
|
||||
for client in [self.youtube, self.tidal, self.qobuz, self.hifi, self.deezer_dl]:
|
||||
if client and hasattr(client, 'download_path') and client.download_path != new_path:
|
||||
for name, client in self.registry.all_plugins():
|
||||
if name == 'soulseek':
|
||||
continue
|
||||
if hasattr(client, 'download_path') and client.download_path != new_path:
|
||||
client.download_path = new_path
|
||||
client.download_path.mkdir(parents=True, exist_ok=True)
|
||||
# YouTube also caches path in yt-dlp opts
|
||||
|
|
@ -108,11 +123,61 @@ class DownloadOrchestrator:
|
|||
|
||||
logger.info(f"Download Orchestrator settings reloaded - Mode: {self.mode}")
|
||||
|
||||
def _client(self, name):
|
||||
"""Get a client by name, returning None if not initialized."""
|
||||
return {'soulseek': self.soulseek, 'youtube': self.youtube, 'tidal': self.tidal,
|
||||
'qobuz': self.qobuz, 'hifi': self.hifi, 'deezer_dl': self.deezer_dl,
|
||||
'lidarr': self.lidarr}.get(name)
|
||||
def client(self, name):
|
||||
"""Generic accessor for a download source client by name.
|
||||
|
||||
Cin's review feedback: external callers should reach into
|
||||
per-source clients via this method (``orch.client('hifi')``)
|
||||
instead of attribute access (``orch.hifi``). Resolves both
|
||||
canonical names (``deezer``) and legacy aliases (``deezer_dl``)
|
||||
via the registry. Returns None if the source isn't registered
|
||||
or failed to initialize.
|
||||
"""
|
||||
return self.registry.get(name)
|
||||
|
||||
# Internal alias kept for legacy callers inside this file.
|
||||
_client = client
|
||||
|
||||
def configured_clients(self) -> dict:
|
||||
"""Return ``{source_name: client}`` for every download source
|
||||
that's both initialized AND reports is_configured() == True.
|
||||
|
||||
Replaces the legacy per-source iteration pattern Cin called
|
||||
out — `if hasattr(orch, 'soulseek') and orch.soulseek and
|
||||
orch.soulseek.is_configured(): download_clients['soulseek']
|
||||
= orch.soulseek` repeated for each source.
|
||||
"""
|
||||
result = {}
|
||||
for name, client in self.registry.all_plugins():
|
||||
try:
|
||||
if not hasattr(client, 'is_configured') or client.is_configured():
|
||||
result[name] = client
|
||||
except Exception as exc:
|
||||
logger.debug("%s is_configured raised: %s", name, exc)
|
||||
return result
|
||||
|
||||
def reload_instances(self, source: str = None) -> bool:
|
||||
"""Reload a source's instance config (e.g. HiFi instance list,
|
||||
Qobuz session restore). Generic dispatch — caller passes the
|
||||
source name instead of reaching for ``orch.hifi.reload_instances()``.
|
||||
|
||||
When ``source`` is None, reloads every source that has a
|
||||
``reload_instances`` method.
|
||||
"""
|
||||
sources = [source] if source else list(self.registry.names())
|
||||
ok = True
|
||||
for name in sources:
|
||||
client = self.client(name)
|
||||
if client is None:
|
||||
continue
|
||||
if not hasattr(client, 'reload_instances'):
|
||||
continue
|
||||
try:
|
||||
client.reload_instances()
|
||||
except Exception as exc:
|
||||
logger.warning("%s reload_instances failed: %s", name, exc)
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""
|
||||
|
|
@ -129,12 +194,18 @@ class DownloadOrchestrator:
|
|||
return False
|
||||
|
||||
def get_source_status(self) -> dict:
|
||||
"""Return configured status for each download source."""
|
||||
return {name: (c.is_configured() if c else False)
|
||||
for name, c in [('soulseek', self.soulseek), ('youtube', self.youtube),
|
||||
('tidal', self.tidal), ('qobuz', self.qobuz),
|
||||
('hifi', self.hifi), ('deezer_dl', self.deezer_dl),
|
||||
('lidarr', self.lidarr)]}
|
||||
"""Return configured status for each download source.
|
||||
|
||||
Keys preserve the legacy ``deezer_dl`` alias used by the
|
||||
frontend status indicators and per-source dispatch strings,
|
||||
so callers reading specific keys keep working unchanged.
|
||||
"""
|
||||
status = {}
|
||||
for name in self.registry.names():
|
||||
client = self.registry.get(name)
|
||||
key = 'deezer_dl' if name == 'deezer' else name
|
||||
status[key] = client.is_configured() if client else False
|
||||
return status
|
||||
|
||||
async def check_connection(self) -> bool:
|
||||
"""
|
||||
|
|
@ -146,7 +217,7 @@ class DownloadOrchestrator:
|
|||
if client and self.mode != 'hybrid':
|
||||
return await client.check_connection()
|
||||
elif self.mode == 'hybrid':
|
||||
sources_to_check = self.hybrid_order if self.hybrid_order else ['soulseek', 'youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr']
|
||||
sources_to_check = self.hybrid_order if self.hybrid_order else self.registry.names()
|
||||
results = {}
|
||||
for source in sources_to_check:
|
||||
client = self._client(source)
|
||||
|
|
@ -165,76 +236,62 @@ class DownloadOrchestrator:
|
|||
|
||||
return False
|
||||
|
||||
def _normalize_source_name(self, name: str) -> Optional[str]:
|
||||
"""Convert a possibly-aliased source name (e.g. legacy
|
||||
``'deezer_dl'``) to the canonical registry name (``'deezer'``).
|
||||
Returns None if the input matches neither a canonical name
|
||||
nor an alias.
|
||||
|
||||
Cin's review caught a bug where legacy alias values from
|
||||
config (hybrid_order containing ``'deezer_dl'``) silently
|
||||
dropped Deezer from hybrid mode because the canonical-name
|
||||
membership check rejected the alias.
|
||||
"""
|
||||
spec = self.registry.get_spec(name) if name else None
|
||||
return spec.name if spec else None
|
||||
|
||||
def _resolve_source_chain(self) -> List[str]:
|
||||
"""Order the configured sources for hybrid mode. Prefers
|
||||
``hybrid_order`` config; falls back to legacy
|
||||
primary/secondary pair when no order set. Normalizes alias
|
||||
names through the registry so legacy ``deezer_dl`` config
|
||||
values resolve correctly to the canonical ``deezer`` plugin."""
|
||||
if self.hybrid_order:
|
||||
chain = []
|
||||
seen = set()
|
||||
for raw in self.hybrid_order:
|
||||
canonical = self._normalize_source_name(raw)
|
||||
if canonical and canonical not in seen:
|
||||
chain.append(canonical)
|
||||
seen.add(canonical)
|
||||
return chain
|
||||
primary = self._normalize_source_name(self.hybrid_primary) or 'soulseek'
|
||||
secondary = self._normalize_source_name(self.hybrid_secondary) or 'soulseek'
|
||||
if secondary == primary:
|
||||
secondary = next(
|
||||
(name for name in self.registry.names() if name != primary),
|
||||
'soulseek',
|
||||
)
|
||||
chain = [primary, secondary]
|
||||
if not chain:
|
||||
chain = ['soulseek']
|
||||
return chain
|
||||
|
||||
async def search(self, query: str, timeout: int = None, progress_callback=None) -> Tuple[List[TrackResult], List[AlbumResult]]:
|
||||
"""
|
||||
Search for tracks using configured source(s).
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
timeout: Search timeout (for Soulseek)
|
||||
progress_callback: Progress callback (for Soulseek)
|
||||
|
||||
Returns:
|
||||
Tuple of (track_results, album_results)
|
||||
"""
|
||||
source_names = {'soulseek': 'Soulseek', 'youtube': 'YouTube', 'tidal': 'Tidal',
|
||||
'qobuz': 'Qobuz', 'hifi': 'HiFi', 'deezer_dl': 'Deezer', 'lidarr': 'Lidarr'}
|
||||
|
||||
"""Search for tracks using configured source(s). Single-source
|
||||
modes route directly; hybrid mode delegates to
|
||||
``engine.search_with_fallback`` which tries the chain in order."""
|
||||
if self.mode != 'hybrid':
|
||||
client = self._client(self.mode)
|
||||
if not client:
|
||||
logger.error(f"{source_names.get(self.mode, self.mode)} client not available (failed to initialize)")
|
||||
logger.error(f"{self.registry.display_name(self.mode)} client not available (failed to initialize)")
|
||||
return [], []
|
||||
logger.info(f"Searching {source_names.get(self.mode, self.mode)}: {query}")
|
||||
logger.info(f"Searching {self.registry.display_name(self.mode)}: {query}")
|
||||
return await client.search(query, timeout, progress_callback)
|
||||
|
||||
elif self.mode == 'hybrid':
|
||||
clients = {name: self._client(name) for name in source_names}
|
||||
|
||||
# Build ordered source list: prefer hybrid_order, fall back to legacy primary/secondary
|
||||
if self.hybrid_order:
|
||||
source_order = [s for s in self.hybrid_order if s in clients]
|
||||
else:
|
||||
primary = self.hybrid_primary if self.hybrid_primary in clients else 'soulseek'
|
||||
secondary = self.hybrid_secondary if self.hybrid_secondary in clients else 'soulseek'
|
||||
if secondary == primary:
|
||||
secondary = next((name for name in clients if name != primary), 'soulseek')
|
||||
source_order = [primary, secondary]
|
||||
|
||||
if not source_order:
|
||||
source_order = ['soulseek']
|
||||
|
||||
logger.info(f"Hybrid search ({' → '.join(source_order)}): {query}")
|
||||
|
||||
# Try each source in priority order (skip unconfigured/unavailable ones)
|
||||
for i, source_name in enumerate(source_order):
|
||||
client = clients.get(source_name)
|
||||
if not client:
|
||||
logger.info(f"Skipping {source_name} (not available)")
|
||||
continue
|
||||
if hasattr(client, 'is_configured') and not client.is_configured():
|
||||
logger.info(f"Skipping {source_name} (not configured)")
|
||||
continue
|
||||
|
||||
try:
|
||||
if i == 0:
|
||||
logger.info(f"Trying {source_name} (priority {i+1}): {query}")
|
||||
else:
|
||||
logger.info(f"Trying {source_name} (priority {i+1}): {query}")
|
||||
|
||||
tracks, albums = await client.search(query, timeout, progress_callback)
|
||||
if tracks:
|
||||
logger.info(f"{source_name} found {len(tracks)} tracks")
|
||||
return (tracks, albums)
|
||||
except Exception as e:
|
||||
logger.warning(f"{source_name} search failed: {e}")
|
||||
|
||||
# Nothing found from any source
|
||||
logger.warning(f"Hybrid search: all sources ({', '.join(source_order)}) found nothing for: {query}")
|
||||
return ([], [])
|
||||
|
||||
# Fallback: empty results
|
||||
return ([], [])
|
||||
chain = self._resolve_source_chain()
|
||||
logger.info(f"Hybrid search ({' → '.join(chain)}): {query}")
|
||||
return await self.engine.search_with_fallback(query, chain, timeout, progress_callback)
|
||||
|
||||
async def search_and_download_best(self, query: str, expected_track=None) -> Optional[str]:
|
||||
"""
|
||||
|
|
@ -262,7 +319,7 @@ class DownloadOrchestrator:
|
|||
return None
|
||||
|
||||
# 2. Filter and validate results
|
||||
_streaming_sources = ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr')
|
||||
_streaming_sources = ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud')
|
||||
is_streaming = tracks[0].username in _streaming_sources if tracks else False
|
||||
|
||||
if is_streaming and expected_track:
|
||||
|
|
@ -312,7 +369,8 @@ class DownloadOrchestrator:
|
|||
elif is_streaming:
|
||||
filtered_results = tracks
|
||||
else:
|
||||
filtered_results = self.soulseek.filter_results_by_quality_preference(tracks) if self.soulseek else tracks
|
||||
soulseek = self.client('soulseek')
|
||||
filtered_results = soulseek.filter_results_by_quality_preference(tracks) if soulseek else tracks
|
||||
|
||||
if not filtered_results:
|
||||
logger.warning(f"No suitable quality results found for: {query}")
|
||||
|
|
@ -335,102 +393,47 @@ class DownloadOrchestrator:
|
|||
Download a track using the appropriate client.
|
||||
|
||||
Args:
|
||||
username: Username (or "youtube" for YouTube)
|
||||
filename: Filename or YouTube video ID
|
||||
username: Source-name string for streaming sources
|
||||
(e.g. ``'youtube'``, ``'tidal'``, ``'deezer_dl'``)
|
||||
OR the actual slskd peer username for Soulseek.
|
||||
filename: Filename / video ID / track ID encoding (source-specific)
|
||||
file_size: File size estimate
|
||||
|
||||
Returns:
|
||||
download_id: Unique download ID for tracking
|
||||
"""
|
||||
# Detect which client to use based on username
|
||||
source_map = {'youtube': self.youtube, 'tidal': self.tidal, 'qobuz': self.qobuz,
|
||||
'hifi': self.hifi, 'deezer_dl': self.deezer_dl, 'lidarr': self.lidarr}
|
||||
source_names = {'youtube': 'YouTube', 'tidal': 'Tidal', 'qobuz': 'Qobuz',
|
||||
'hifi': 'HiFi', 'deezer_dl': 'Deezer', 'lidarr': 'Lidarr'}
|
||||
|
||||
if username in source_map:
|
||||
client = source_map[username]
|
||||
# Streaming sources are dispatched by name match; anything
|
||||
# unrecognized falls through to Soulseek (peer username case).
|
||||
spec = self.registry.get_spec(username) if username else None
|
||||
if spec is not None and spec.name != 'soulseek':
|
||||
client = self.registry.get(spec.name)
|
||||
if not client:
|
||||
raise RuntimeError(f"{source_names[username]} download client not available (failed to initialize)")
|
||||
logger.info(f"Downloading from {source_names[username]}: {filename}")
|
||||
raise RuntimeError(f"{spec.display_name} download client not available (failed to initialize)")
|
||||
logger.info(f"Downloading from {spec.display_name}: {filename}")
|
||||
return await client.download(username, filename, file_size)
|
||||
else:
|
||||
if not self.soulseek:
|
||||
raise RuntimeError("Soulseek client not available (failed to initialize)")
|
||||
logger.info(f"Downloading from Soulseek: {filename}")
|
||||
return await self.soulseek.download(username, filename, file_size)
|
||||
|
||||
soulseek = self.registry.get('soulseek')
|
||||
if not soulseek:
|
||||
raise RuntimeError("Soulseek client not available (failed to initialize)")
|
||||
logger.info(f"Downloading from Soulseek: {filename}")
|
||||
return await soulseek.download(username, filename, file_size)
|
||||
|
||||
async def get_all_downloads(self) -> List[DownloadStatus]:
|
||||
"""
|
||||
Get all active downloads from all sources.
|
||||
|
||||
Returns:
|
||||
List of DownloadStatus objects
|
||||
"""
|
||||
# Get downloads from all available sources
|
||||
all_downloads = []
|
||||
for client in [self.soulseek, self.youtube, self.tidal, self.qobuz, self.hifi, self.deezer_dl, self.lidarr]:
|
||||
if client:
|
||||
try:
|
||||
all_downloads.extend(await client.get_all_downloads())
|
||||
except Exception:
|
||||
pass
|
||||
return all_downloads
|
||||
"""Aggregated view across every source. Delegates to the
|
||||
engine, which iterates registered plugins."""
|
||||
return await self.engine.get_all_downloads()
|
||||
|
||||
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
||||
"""
|
||||
Get status of a specific download.
|
||||
|
||||
Args:
|
||||
download_id: Download ID to query
|
||||
|
||||
Returns:
|
||||
DownloadStatus object or None if not found
|
||||
"""
|
||||
# Try each source until we find the download
|
||||
for client in [self.soulseek, self.youtube, self.tidal, self.qobuz, self.hifi, self.deezer_dl, self.lidarr]:
|
||||
if not client:
|
||||
continue
|
||||
try:
|
||||
status = await client.get_download_status(download_id)
|
||||
if status:
|
||||
return status
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
"""Find a download by id across every source. Delegates to
|
||||
the engine."""
|
||||
return await self.engine.get_download_status(download_id)
|
||||
|
||||
async def cancel_download(self, download_id: str, username: str = None, remove: bool = False) -> bool:
|
||||
"""
|
||||
Cancel an active download.
|
||||
|
||||
Args:
|
||||
download_id: Download ID to cancel
|
||||
username: Username hint (optional)
|
||||
remove: Whether to remove from active downloads
|
||||
|
||||
Returns:
|
||||
True if cancelled successfully
|
||||
"""
|
||||
# If username is provided, route directly to that source
|
||||
source_map = {'youtube': self.youtube, 'tidal': self.tidal, 'qobuz': self.qobuz,
|
||||
'hifi': self.hifi, 'deezer_dl': self.deezer_dl, 'lidarr': self.lidarr}
|
||||
if username in source_map:
|
||||
client = source_map[username]
|
||||
return await client.cancel_download(download_id, username, remove) if client else False
|
||||
elif username:
|
||||
return await self.soulseek.cancel_download(download_id, username, remove) if self.soulseek else False
|
||||
|
||||
# Otherwise, try all available sources
|
||||
for client in [self.soulseek, self.youtube, self.tidal, self.qobuz, self.hifi, self.deezer_dl, self.lidarr]:
|
||||
if not client:
|
||||
continue
|
||||
try:
|
||||
if await client.cancel_download(download_id, username, remove):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
"""Cancel an active download. Delegates to the engine, which
|
||||
handles source-hint routing (streaming source name → direct
|
||||
plugin, unknown name → Soulseek as peer username, no hint →
|
||||
try every plugin)."""
|
||||
return await self.engine.cancel_download(download_id, username, remove)
|
||||
|
||||
async def signal_download_completion(self, download_id: str, username: str, remove: bool = True) -> bool:
|
||||
"""
|
||||
|
|
@ -445,39 +448,16 @@ class DownloadOrchestrator:
|
|||
True if successful
|
||||
"""
|
||||
# This is Soulseek-specific, so only call on Soulseek client
|
||||
if not self.soulseek:
|
||||
soulseek = self.client('soulseek')
|
||||
if not soulseek:
|
||||
return False
|
||||
return await self.soulseek.signal_download_completion(download_id, username, remove)
|
||||
return await soulseek.signal_download_completion(download_id, username, remove)
|
||||
|
||||
async def clear_all_completed_downloads(self) -> bool:
|
||||
"""
|
||||
Clear all completed downloads from both sources.
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
results = []
|
||||
for name, client in [
|
||||
("soulseek", self.soulseek),
|
||||
("youtube", self.youtube),
|
||||
("tidal", self.tidal),
|
||||
("qobuz", self.qobuz),
|
||||
("hifi", self.hifi),
|
||||
("deezer_dl", self.deezer_dl),
|
||||
("lidarr", self.lidarr),
|
||||
]:
|
||||
if not client:
|
||||
continue
|
||||
if hasattr(client, "is_configured") and not client.is_configured():
|
||||
logger.debug("Skipping %s clear_all_completed_downloads (not configured)", name)
|
||||
continue
|
||||
try:
|
||||
results.append(await client.clear_all_completed_downloads())
|
||||
except Exception as exc:
|
||||
logger.warning("%s clear_all_completed_downloads failed: %s", name, exc)
|
||||
results.append(False)
|
||||
|
||||
return all(results) if results else True
|
||||
"""Clear completed downloads from every source. Delegates
|
||||
to the engine, which skips unconfigured plugins and treats
|
||||
per-plugin failures as False (not an exception)."""
|
||||
return await self.engine.clear_all_completed_downloads()
|
||||
|
||||
# ===== Soulseek-specific methods (for backwards compatibility) =====
|
||||
# These are internal methods that some parts of the codebase use directly
|
||||
|
|
@ -495,9 +475,10 @@ class DownloadOrchestrator:
|
|||
Returns:
|
||||
API response
|
||||
"""
|
||||
if not self.soulseek:
|
||||
soulseek = self.client('soulseek')
|
||||
if not soulseek:
|
||||
raise RuntimeError("Soulseek client not available (failed to initialize)")
|
||||
return await self.soulseek._make_request(method, endpoint, **kwargs)
|
||||
return await soulseek._make_request(method, endpoint, **kwargs)
|
||||
|
||||
async def _make_direct_request(self, method: str, endpoint: str, **kwargs):
|
||||
"""
|
||||
|
|
@ -512,9 +493,10 @@ class DownloadOrchestrator:
|
|||
Returns:
|
||||
API response
|
||||
"""
|
||||
if not self.soulseek:
|
||||
soulseek = self.client('soulseek')
|
||||
if not soulseek:
|
||||
raise RuntimeError("Soulseek client not available (failed to initialize)")
|
||||
return await self.soulseek._make_direct_request(method, endpoint, **kwargs)
|
||||
return await soulseek._make_direct_request(method, endpoint, **kwargs)
|
||||
|
||||
async def clear_all_searches(self) -> bool:
|
||||
"""
|
||||
|
|
@ -523,7 +505,8 @@ class DownloadOrchestrator:
|
|||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
return await self.soulseek.clear_all_searches() if self.soulseek else True
|
||||
soulseek = self.client('soulseek')
|
||||
return await soulseek.clear_all_searches() if soulseek else True
|
||||
|
||||
async def maintain_search_history_with_buffer(self, keep_searches: int = 50, trigger_threshold: int = 200) -> bool:
|
||||
"""
|
||||
|
|
@ -536,15 +519,54 @@ class DownloadOrchestrator:
|
|||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
return await self.soulseek.maintain_search_history_with_buffer(keep_searches, trigger_threshold) if self.soulseek else True
|
||||
soulseek = self.client('soulseek')
|
||||
return await soulseek.maintain_search_history_with_buffer(keep_searches, trigger_threshold) if soulseek else True
|
||||
|
||||
async def cancel_all_downloads(self) -> bool:
|
||||
"""Cancel and remove all downloads from all sources."""
|
||||
"""Cancel and remove all downloads from all sources.
|
||||
|
||||
Note: YouTube is intentionally excluded from this loop in the
|
||||
legacy implementation — preserved here. (yt-dlp downloads
|
||||
run as detached subprocesses and don't share the
|
||||
``cancel_all_downloads`` semantics the streaming sources
|
||||
use.) Sources without ``cancel_all_downloads`` fall back to
|
||||
``clear_all_completed_downloads``.
|
||||
"""
|
||||
ok = True
|
||||
for client in [self.soulseek, self.tidal, self.qobuz, self.hifi, self.deezer_dl, self.lidarr]:
|
||||
if client:
|
||||
try:
|
||||
await client.cancel_all_downloads() if hasattr(client, 'cancel_all_downloads') else await client.clear_all_completed_downloads()
|
||||
except Exception:
|
||||
ok = False
|
||||
for name, client in self.registry.all_plugins():
|
||||
if name == 'youtube':
|
||||
continue
|
||||
try:
|
||||
await client.cancel_all_downloads() if hasattr(client, 'cancel_all_downloads') else await client.clear_all_completed_downloads()
|
||||
except Exception:
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Singleton accessor — mirrors Cin's metadata engine pattern
|
||||
# (``get_metadata_engine()``). Callers that don't need a custom
|
||||
# registry use this instead of instantiating DownloadOrchestrator
|
||||
# directly. web_server.py constructs the singleton at startup and
|
||||
# exposes it via the ``download_orchestrator`` global.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_default_orchestrator: Optional['DownloadOrchestrator'] = None
|
||||
|
||||
|
||||
def get_download_orchestrator() -> 'DownloadOrchestrator':
|
||||
"""Return (lazily creating) the process-wide DownloadOrchestrator
|
||||
singleton. Mirrors the ``get_metadata_engine()`` pattern Cin used
|
||||
for the metadata engine refactor."""
|
||||
global _default_orchestrator
|
||||
if _default_orchestrator is None:
|
||||
_default_orchestrator = DownloadOrchestrator()
|
||||
return _default_orchestrator
|
||||
|
||||
|
||||
def set_download_orchestrator(orchestrator: 'DownloadOrchestrator') -> None:
|
||||
"""Set the process-wide singleton. Used by web_server.py at boot
|
||||
to install the orchestrator it constructs as the default for
|
||||
callers that grab via ``get_download_orchestrator()``."""
|
||||
global _default_orchestrator
|
||||
_default_orchestrator = orchestrator
|
||||
|
|
|
|||
34
core/download_plugins/__init__.py
Normal file
34
core/download_plugins/__init__.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"""Download source plugin contract + registry.
|
||||
|
||||
This package defines the canonical interface every download source
|
||||
(Soulseek, YouTube, Tidal, Qobuz, HiFi, Deezer, Lidarr, SoundCloud,
|
||||
and future additions like Usenet) must satisfy. The orchestrator
|
||||
dispatches through this contract instead of hardcoded
|
||||
`if self.youtube ... elif self.tidal ...` chains.
|
||||
|
||||
This is the foundation step of a multi-commit refactor. Subsequent
|
||||
commits extract shared logic (background download worker, search
|
||||
query normalization, post-processing context building) into the
|
||||
contract so adding a new source becomes a one-class plugin instead
|
||||
of a 700+ LOC copy-paste loop.
|
||||
|
||||
See `core/download_plugins/base.py` for the protocol contract and
|
||||
`core/download_plugins/registry.py` for the dispatch entry point.
|
||||
"""
|
||||
|
||||
from core.download_plugins.base import DownloadSourcePlugin
|
||||
|
||||
# NOTE: DownloadPluginRegistry is intentionally NOT re-exported here.
|
||||
# Importing the registry triggers eager imports of every client class
|
||||
# (see registry.py for why eager — test fixtures inject mock modules
|
||||
# at collection time and we need real bindings before that). Clients
|
||||
# inherit from DownloadSourcePlugin (Cin's review feedback — visible
|
||||
# contract conformance), so importing the package via ``from
|
||||
# core.download_plugins import DownloadSourcePlugin`` from a client
|
||||
# file would create a circular import if registry came along for the
|
||||
# ride. Callers that need the registry import it directly:
|
||||
# from core.download_plugins.registry import DownloadPluginRegistry
|
||||
|
||||
__all__ = [
|
||||
"DownloadSourcePlugin",
|
||||
]
|
||||
134
core/download_plugins/base.py
Normal file
134
core/download_plugins/base.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"""Canonical contract every download source plugin must satisfy.
|
||||
|
||||
`DownloadSourcePlugin` is a structural Protocol — any class that
|
||||
implements these methods with matching signatures is automatically
|
||||
treated as a download source. No inheritance required, no manual
|
||||
registration required beyond the registry entry.
|
||||
|
||||
The protocol is intentionally narrow — only the methods the
|
||||
orchestrator dispatches generically across all sources. Source-
|
||||
specific extras (Soulseek's slskd internals, Lidarr's album-only
|
||||
flow, etc.) stay on the underlying client and are accessed through
|
||||
the registry's typed accessor.
|
||||
|
||||
This file is the FOUNDATION step. Existing client classes
|
||||
(SoulseekClient, YouTubeClient, TidalDownloadClient, etc.) already
|
||||
conform structurally — they grew the same shape independently
|
||||
because every consumer site needed the same calls. This file just
|
||||
makes the implicit contract explicit so:
|
||||
|
||||
- Type checkers can flag drift if a new source forgets a method.
|
||||
- The orchestrator can iterate plugins generically instead of
|
||||
hardcoding `[self.soulseek, self.youtube, ...]` everywhere.
|
||||
- Future PRs can move shared logic INTO the contract (a base
|
||||
class with default implementations) without changing the
|
||||
signature surface every consumer already depends on.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, List, Optional, Protocol, Tuple, runtime_checkable
|
||||
|
||||
# core.download_plugins.types owns the canonical TrackResult /
|
||||
# AlbumResult / DownloadStatus dataclasses (lifted out of
|
||||
# core.soulseek_client so plugins don't import from a sibling source
|
||||
# just to satisfy the contract). We only need them for type
|
||||
# annotations on this protocol; TYPE_CHECKING avoids a circular
|
||||
# import once the clients themselves inherit from DownloadSourcePlugin
|
||||
# (Cin's review feedback — clients explicitly declare conformance
|
||||
# instead of relying on structural typing).
|
||||
if TYPE_CHECKING:
|
||||
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DownloadSourcePlugin(Protocol):
|
||||
"""Structural contract for a download source.
|
||||
|
||||
`runtime_checkable` lets `isinstance(client, DownloadSourcePlugin)`
|
||||
work for the conformance test, but it ONLY checks method names —
|
||||
not signatures or async-ness. The conformance test in
|
||||
``tests/test_download_plugin_conformance.py`` does the deeper
|
||||
signature check.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Configuration / lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Return True iff this source has the credentials / settings
|
||||
it needs to function. Used by the orchestrator to skip
|
||||
unconfigured sources during hybrid fallback."""
|
||||
...
|
||||
|
||||
async def check_connection(self) -> bool:
|
||||
"""Probe the source's API / endpoint. Return True if the
|
||||
source is reachable. May make a live network call."""
|
||||
...
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Search
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
timeout: Optional[int] = None,
|
||||
progress_callback=None,
|
||||
) -> Tuple[List[TrackResult], List[AlbumResult]]:
|
||||
"""Search the source for tracks (and albums where supported).
|
||||
|
||||
Returns a tuple of (track_results, album_results). Either
|
||||
list may be empty. Sources that don't expose album-level
|
||||
search return ``[]`` as the second element.
|
||||
"""
|
||||
...
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Download
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def download(
|
||||
self,
|
||||
username: str,
|
||||
filename: str,
|
||||
file_size: int = 0,
|
||||
) -> Optional[str]:
|
||||
"""Kick off a download. Returns a download_id string the
|
||||
caller can poll via ``get_download_status``. Returns ``None``
|
||||
if the source can't / won't handle this download.
|
||||
|
||||
``username`` is the source-name string for streaming sources
|
||||
(e.g. ``'youtube'``, ``'tidal'``) and the actual slskd peer
|
||||
username for Soulseek. ``filename`` is source-specific —
|
||||
Soulseek file path, YouTube ``video_id||title``, Tidal /
|
||||
Qobuz ``track_id||display``, etc.
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_all_downloads(self) -> List[DownloadStatus]:
|
||||
"""Return live status of all downloads currently tracked by
|
||||
this source. The orchestrator concatenates results from
|
||||
every plugin to build the global download list."""
|
||||
...
|
||||
|
||||
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
||||
"""Return status for a single download or ``None`` if this
|
||||
source doesn't know about that download_id."""
|
||||
...
|
||||
|
||||
async def cancel_download(
|
||||
self,
|
||||
download_id: str,
|
||||
username: Optional[str] = None,
|
||||
remove: bool = False,
|
||||
) -> bool:
|
||||
"""Cancel an active download. ``remove=True`` also drops
|
||||
the row from the source's active-downloads tracking."""
|
||||
...
|
||||
|
||||
async def clear_all_completed_downloads(self) -> bool:
|
||||
"""Drop completed downloads from active tracking. Sources
|
||||
that don't keep completed history return True with no-op."""
|
||||
...
|
||||
192
core/download_plugins/registry.py
Normal file
192
core/download_plugins/registry.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
"""Plugin registry — single source of truth for which download
|
||||
sources exist, what their canonical names are, and which client
|
||||
class implements each.
|
||||
|
||||
Replaces the orchestrator's hardcoded ``[self.soulseek,
|
||||
self.youtube, self.tidal, ...]`` lists and ``source_map`` dicts
|
||||
that historically had to be touched in 6+ places to add a source.
|
||||
With the registry:
|
||||
|
||||
- One ``register()`` call adds a source to every dispatch path.
|
||||
- Iteration helpers replace hand-maintained lists.
|
||||
- The orchestrator stays oblivious to source-specific quirks.
|
||||
- Adding Usenet (planned) becomes a one-line registry entry plus
|
||||
the new client class — no orchestrator changes.
|
||||
|
||||
This is the foundation step. Subsequent commits move shared logic
|
||||
(thread workers, search query normalization, post-processing
|
||||
context building) out of the orchestrator and the per-source
|
||||
clients into helpers the registry exposes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Dict, Iterator, List, Optional, Tuple
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
from core.download_plugins.base import DownloadSourcePlugin
|
||||
|
||||
# Eager client imports — keep the import-order behavior the orchestrator
|
||||
# had before this refactor. Some integration tests inject mock modules
|
||||
# into ``sys.modules`` at collection time (see
|
||||
# ``tests/test_tidal_search_shortening.py``); making the registry's
|
||||
# factories lazy-import would cause tidalapi-dependent code to bind to
|
||||
# whichever ``tidalapi`` object happens to be in ``sys.modules`` at the
|
||||
# moment ``DownloadOrchestrator()`` is constructed — which is later
|
||||
# than the legacy module-top imports here. Importing everything at
|
||||
# registry-load time pins the bindings the same way the legacy
|
||||
# orchestrator did.
|
||||
from core.deezer_download_client import DeezerDownloadClient
|
||||
from core.hifi_client import HiFiClient
|
||||
from core.lidarr_download_client import LidarrDownloadClient
|
||||
from core.qobuz_client import QobuzClient
|
||||
from core.soulseek_client import SoulseekClient
|
||||
from core.soundcloud_client import SoundcloudClient
|
||||
from core.tidal_download_client import TidalDownloadClient
|
||||
from core.youtube_client import YouTubeClient
|
||||
|
||||
logger = get_logger("download_plugins.registry")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginSpec:
|
||||
"""Static descriptor for a download source. The ``factory`` is
|
||||
a zero-arg callable that builds the client instance — kept as a
|
||||
callable rather than a class so each source can do its own
|
||||
setup (e.g. SoulseekClient calls ``_setup_client`` after init,
|
||||
Deezer reads ARL from config). ``aliases`` lets the registry
|
||||
accept multiple historical names (e.g. ``deezer_dl`` is the
|
||||
legacy alias for ``deezer``)."""
|
||||
|
||||
name: str
|
||||
factory: Callable[[], DownloadSourcePlugin]
|
||||
display_name: str
|
||||
aliases: Tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
class DownloadPluginRegistry:
|
||||
"""Holds the live plugin instances + name → instance lookup.
|
||||
|
||||
Construction is two-phase:
|
||||
1. Specs are registered (cheap — just stores callable refs).
|
||||
2. ``initialize()`` calls each factory once and stores the
|
||||
resulting client. Failures are caught and logged so one
|
||||
broken source doesn't take down the orchestrator (mirrors
|
||||
the existing ``_safe_init`` behavior).
|
||||
|
||||
Iteration helpers (``all_plugins``, ``configured_plugins``)
|
||||
replace the hand-maintained lists scattered across the
|
||||
orchestrator's ``get_all_downloads``, ``cancel_all_downloads``,
|
||||
etc. so adding a source touches the registry alone.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._specs: Dict[str, PluginSpec] = {}
|
||||
self._instances: Dict[str, Optional[DownloadSourcePlugin]] = {}
|
||||
self._init_failures: List[str] = []
|
||||
|
||||
def register(self, spec: PluginSpec) -> None:
|
||||
"""Register a plugin spec under its canonical name + each alias.
|
||||
Aliases all resolve to the same instance after ``initialize``."""
|
||||
if spec.name in self._specs:
|
||||
raise ValueError(f"Plugin already registered: {spec.name}")
|
||||
self._specs[spec.name] = spec
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Build every registered plugin's instance. Failures captured
|
||||
in ``init_failures`` and the slot is set to None so the
|
||||
orchestrator can skip unavailable sources without crashing."""
|
||||
for spec in self._specs.values():
|
||||
try:
|
||||
instance = spec.factory()
|
||||
self._instances[spec.name] = instance
|
||||
except Exception as exc:
|
||||
logger.error("%s download client failed to initialize: %s", spec.display_name, exc)
|
||||
self._init_failures.append(spec.display_name)
|
||||
self._instances[spec.name] = None
|
||||
|
||||
@property
|
||||
def init_failures(self) -> List[str]:
|
||||
return list(self._init_failures)
|
||||
|
||||
def get(self, name: str) -> Optional[DownloadSourcePlugin]:
|
||||
"""Resolve a name (or alias) to its plugin instance, or
|
||||
None if the source failed to initialize / isn't registered."""
|
||||
if not name:
|
||||
return None
|
||||
# Direct hit
|
||||
if name in self._instances:
|
||||
return self._instances[name]
|
||||
# Alias lookup
|
||||
for spec in self._specs.values():
|
||||
if name in spec.aliases:
|
||||
return self._instances.get(spec.name)
|
||||
return None
|
||||
|
||||
def get_spec(self, name: str) -> Optional[PluginSpec]:
|
||||
if name in self._specs:
|
||||
return self._specs[name]
|
||||
for spec in self._specs.values():
|
||||
if name in spec.aliases:
|
||||
return spec
|
||||
return None
|
||||
|
||||
def display_name(self, name: str) -> str:
|
||||
spec = self.get_spec(name)
|
||||
return spec.display_name if spec else name
|
||||
|
||||
def names(self) -> List[str]:
|
||||
"""Canonical names of every registered source (regardless of
|
||||
whether it initialized successfully)."""
|
||||
return list(self._specs.keys())
|
||||
|
||||
def all_plugins(self) -> Iterator[Tuple[str, DownloadSourcePlugin]]:
|
||||
"""Yield (name, plugin) for every successfully-initialized
|
||||
plugin. Replaces the orchestrator's hand-maintained client
|
||||
lists in get_all_downloads / cancel_all_downloads / etc."""
|
||||
for name, instance in self._instances.items():
|
||||
if instance is not None:
|
||||
yield name, instance
|
||||
|
||||
def configured_plugins(self) -> Iterator[Tuple[str, DownloadSourcePlugin]]:
|
||||
"""Yield (name, plugin) for every initialized AND configured
|
||||
plugin. Useful for hybrid mode and any operation that should
|
||||
skip sources the user hasn't set up."""
|
||||
for name, instance in self.all_plugins():
|
||||
try:
|
||||
if instance.is_configured():
|
||||
yield name, instance
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
def build_default_registry() -> DownloadPluginRegistry:
|
||||
"""Construct the registry with SoulSync's eight built-in download
|
||||
sources. Called once during orchestrator init.
|
||||
|
||||
Adding a new source (Usenet, etc.) means adding one ``register``
|
||||
call here — no orchestrator changes required.
|
||||
|
||||
The factory itself is just the class constructor — clients are
|
||||
imported eagerly at the top of this module so they bind to the
|
||||
real third-party libs (tidalapi, etc.) at import time, not at
|
||||
factory-call time. See the import-block comment above for why.
|
||||
"""
|
||||
registry = DownloadPluginRegistry()
|
||||
|
||||
registry.register(PluginSpec(name='soulseek', factory=SoulseekClient, display_name='Soulseek'))
|
||||
registry.register(PluginSpec(name='youtube', factory=YouTubeClient, display_name='YouTube'))
|
||||
registry.register(PluginSpec(name='tidal', factory=TidalDownloadClient, display_name='Tidal'))
|
||||
registry.register(PluginSpec(name='qobuz', factory=QobuzClient, display_name='Qobuz'))
|
||||
registry.register(PluginSpec(name='hifi', factory=HiFiClient, display_name='HiFi'))
|
||||
# 'deezer_dl' is the legacy name used in config + per-source dispatch
|
||||
# strings (e.g. orchestrator's ``source_map``). Canonical name is
|
||||
# ``deezer`` so future-facing code reads naturally.
|
||||
registry.register(PluginSpec(name='deezer', factory=DeezerDownloadClient, display_name='Deezer',
|
||||
aliases=('deezer_dl',)))
|
||||
registry.register(PluginSpec(name='lidarr', factory=LidarrDownloadClient, display_name='Lidarr'))
|
||||
registry.register(PluginSpec(name='soundcloud',factory=SoundcloudClient, display_name='SoundCloud'))
|
||||
|
||||
return registry
|
||||
199
core/download_plugins/types.py
Normal file
199
core/download_plugins/types.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
"""Shared dataclasses for the download-plugin contract.
|
||||
|
||||
Every download source returns the same shape for search hits and
|
||||
download status — the four classes here are the canonical types
|
||||
that the ``DownloadSourcePlugin`` Protocol exchanges. Living in
|
||||
this neutral module (rather than ``core/soulseek_client.py`` where
|
||||
they grew up by accident) means a new plugin doesn't have to import
|
||||
from a sibling source just to satisfy the contract.
|
||||
|
||||
Move history: extracted from ``core.soulseek_client`` so plugins
|
||||
import from a neutral package per Cin's contract-first standard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.imports.filename import parse_filename_metadata
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
"""Base class for search results"""
|
||||
username: str
|
||||
filename: str
|
||||
size: int
|
||||
bitrate: Optional[int]
|
||||
duration: Optional[int] # Duration in milliseconds (converted from slskd's seconds)
|
||||
quality: str
|
||||
free_upload_slots: int
|
||||
upload_speed: int
|
||||
queue_length: int
|
||||
result_type: str = "track" # "track" or "album"
|
||||
|
||||
@property
|
||||
def quality_score(self) -> float:
|
||||
quality_weights = {
|
||||
'flac': 1.0,
|
||||
'mp3': 0.8,
|
||||
'ogg': 0.7,
|
||||
'aac': 0.6,
|
||||
'wma': 0.5
|
||||
}
|
||||
|
||||
base_score = quality_weights.get(self.quality.lower(), 0.3)
|
||||
|
||||
if self.bitrate:
|
||||
if self.bitrate >= 320:
|
||||
base_score += 0.2
|
||||
elif self.bitrate >= 256:
|
||||
base_score += 0.1
|
||||
elif self.bitrate < 128:
|
||||
base_score -= 0.2
|
||||
|
||||
# Free upload slots
|
||||
if self.free_upload_slots == 0:
|
||||
base_score -= 0.15
|
||||
elif self.free_upload_slots > 0:
|
||||
base_score += 0.05
|
||||
|
||||
# Upload speed in bytes/sec (tiered)
|
||||
if self.upload_speed >= 5_000_000: # ~5 MB/s / 40 Mbps
|
||||
base_score += 0.15
|
||||
elif self.upload_speed >= 1_000_000: # ~1 MB/s / 8 Mbps
|
||||
base_score += 0.10
|
||||
elif self.upload_speed >= 500_000: # ~500 KB/s / 4 Mbps
|
||||
base_score += 0.05
|
||||
elif self.upload_speed < 100_000: # ~100 KB/s / 800 kbps
|
||||
base_score -= 0.05
|
||||
|
||||
# Queue length (graduated penalty)
|
||||
if self.queue_length > 50:
|
||||
base_score -= 0.25
|
||||
elif self.queue_length > 20:
|
||||
base_score -= 0.15
|
||||
elif self.queue_length > 10:
|
||||
base_score -= 0.10
|
||||
|
||||
return min(base_score, 1.0)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackResult(SearchResult):
|
||||
"""Individual track search result"""
|
||||
artist: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
album: Optional[str] = None
|
||||
track_number: Optional[int] = None
|
||||
_source_metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
def __post_init__(self):
|
||||
self.result_type = "track"
|
||||
# Try to extract metadata from filename if not provided
|
||||
if not self.title or not self.artist:
|
||||
self._parse_filename_metadata()
|
||||
|
||||
def _parse_filename_metadata(self):
|
||||
"""Extract artist, title, album from filename patterns"""
|
||||
parsed = parse_filename_metadata(self.filename)
|
||||
if not self.artist and parsed.get("artist"):
|
||||
self.artist = parsed["artist"]
|
||||
if not self.title and parsed.get("title"):
|
||||
self.title = parsed["title"]
|
||||
if not self.album and parsed.get("album"):
|
||||
self.album = parsed["album"]
|
||||
if self.track_number is None:
|
||||
track_number = parsed.get("track_number")
|
||||
if track_number is not None:
|
||||
self.track_number = track_number
|
||||
|
||||
|
||||
@dataclass
|
||||
class AlbumResult:
|
||||
"""Album/folder search result containing multiple tracks"""
|
||||
username: str
|
||||
album_path: str # Directory path
|
||||
album_title: str
|
||||
artist: Optional[str]
|
||||
track_count: int
|
||||
total_size: int
|
||||
tracks: List[TrackResult]
|
||||
dominant_quality: str # Most common quality in album
|
||||
year: Optional[str] = None
|
||||
free_upload_slots: int = 0
|
||||
upload_speed: int = 0
|
||||
queue_length: int = 0
|
||||
result_type: str = "album"
|
||||
|
||||
@property
|
||||
def quality_score(self) -> float:
|
||||
"""Calculate album quality score based on dominant quality and track count"""
|
||||
quality_weights = {
|
||||
'flac': 1.0,
|
||||
'mp3': 0.8,
|
||||
'ogg': 0.7,
|
||||
'aac': 0.6,
|
||||
'wma': 0.5
|
||||
}
|
||||
|
||||
base_score = quality_weights.get(self.dominant_quality.lower(), 0.3)
|
||||
|
||||
# Bonus for complete albums (typically 8-15 tracks)
|
||||
if 8 <= self.track_count <= 20:
|
||||
base_score += 0.1
|
||||
elif self.track_count > 20:
|
||||
base_score += 0.05
|
||||
|
||||
# Free upload slots
|
||||
if self.free_upload_slots == 0:
|
||||
base_score -= 0.15
|
||||
elif self.free_upload_slots > 0:
|
||||
base_score += 0.05
|
||||
|
||||
# Upload speed in bytes/sec (tiered)
|
||||
if self.upload_speed >= 5_000_000: # ~5 MB/s / 40 Mbps
|
||||
base_score += 0.15
|
||||
elif self.upload_speed >= 1_000_000: # ~1 MB/s / 8 Mbps
|
||||
base_score += 0.10
|
||||
elif self.upload_speed >= 500_000: # ~500 KB/s / 4 Mbps
|
||||
base_score += 0.05
|
||||
elif self.upload_speed < 100_000: # ~100 KB/s / 800 kbps
|
||||
base_score -= 0.05
|
||||
|
||||
# Queue length (graduated penalty)
|
||||
if self.queue_length > 50:
|
||||
base_score -= 0.25
|
||||
elif self.queue_length > 20:
|
||||
base_score -= 0.15
|
||||
elif self.queue_length > 10:
|
||||
base_score -= 0.10
|
||||
|
||||
return min(base_score, 1.0)
|
||||
|
||||
@property
|
||||
def size_mb(self) -> int:
|
||||
"""Album size in MB"""
|
||||
return self.total_size // (1024 * 1024)
|
||||
|
||||
@property
|
||||
def average_track_size_mb(self) -> float:
|
||||
"""Average track size in MB"""
|
||||
if self.track_count > 0:
|
||||
return self.size_mb / self.track_count
|
||||
return 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadStatus:
|
||||
id: str
|
||||
filename: str
|
||||
username: str
|
||||
state: str
|
||||
progress: float
|
||||
size: int
|
||||
transferred: int
|
||||
speed: int
|
||||
time_remaining: Optional[int] = None
|
||||
file_path: Optional[str] = None
|
||||
|
|
@ -42,31 +42,31 @@ _TERMINAL_STATUSES = {
|
|||
}
|
||||
|
||||
|
||||
def cancel_single_download(soulseek_client, run_async: Callable,
|
||||
def cancel_single_download(download_orchestrator, run_async: Callable,
|
||||
download_id: str, username: str) -> bool:
|
||||
"""Cancel one specific slskd download (with `remove=True`)."""
|
||||
return run_async(soulseek_client.cancel_download(download_id, username, remove=True))
|
||||
return run_async(download_orchestrator.cancel_download(download_id, username, remove=True))
|
||||
|
||||
|
||||
def cancel_all_active(soulseek_client, run_async: Callable,
|
||||
def cancel_all_active(download_orchestrator, run_async: Callable,
|
||||
sweep_callback: Callable[[], None]) -> tuple[bool, str]:
|
||||
"""Cancel every active slskd download, clear the resulting ones, sweep dirs.
|
||||
|
||||
Returns `(success, message)` so the route can map to the right HTTP shape.
|
||||
"""
|
||||
cancel_success = run_async(soulseek_client.cancel_all_downloads())
|
||||
cancel_success = run_async(download_orchestrator.cancel_all_downloads())
|
||||
if not cancel_success:
|
||||
return False, "Failed to cancel active downloads."
|
||||
|
||||
run_async(soulseek_client.clear_all_completed_downloads())
|
||||
run_async(download_orchestrator.clear_all_completed_downloads())
|
||||
sweep_callback()
|
||||
return True, "All downloads cancelled and cleared."
|
||||
|
||||
|
||||
def clear_finished_active(soulseek_client, run_async: Callable,
|
||||
def clear_finished_active(download_orchestrator, run_async: Callable,
|
||||
sweep_callback: Callable[[], None]) -> bool:
|
||||
"""Clear all terminal transfers from slskd, sweep dirs on success."""
|
||||
success = run_async(soulseek_client.clear_all_completed_downloads())
|
||||
success = run_async(download_orchestrator.clear_all_completed_downloads())
|
||||
if success:
|
||||
sweep_callback()
|
||||
return success
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ and notifies the lifecycle via `on_download_completed(success=False)` so the
|
|||
worker slot frees up.
|
||||
|
||||
Lifted verbatim from web_server.py. Wide dependency surface
|
||||
(soulseek_client, spotify_client, lifecycle callback, context-key helper,
|
||||
(download_orchestrator, spotify_client, lifecycle callback, context-key helper,
|
||||
status updater, DB) all injected via `CandidatesDeps`.
|
||||
"""
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ logger = logging.getLogger(__name__)
|
|||
@dataclass
|
||||
class CandidatesDeps:
|
||||
"""Bundle of cross-cutting deps the candidate-fallback logic needs."""
|
||||
soulseek_client: Any
|
||||
download_orchestrator: Any
|
||||
spotify_client: Any
|
||||
run_async: Callable[..., Any]
|
||||
get_database: Callable[[], Any]
|
||||
|
|
@ -97,8 +97,8 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
|
|||
if _bl_db.is_blacklisted(candidate.username, candidate.filename):
|
||||
logger.info(f"[Modal Worker] Skipping blacklisted source: {source_key}")
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("blacklist check failed: %s", e)
|
||||
|
||||
# CRITICAL: Add source to used_sources IMMEDIATELY to prevent race conditions
|
||||
# This must happen BEFORE starting download to prevent multiple retries from picking same source
|
||||
|
|
@ -209,7 +209,7 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
|
|||
|
||||
# Initiate download
|
||||
logger.info(f"[Modal Worker] Starting download: {username} / {os.path.basename(filename)}")
|
||||
download_id = deps.run_async(deps.soulseek_client.download(username, filename, size))
|
||||
download_id = deps.run_async(deps.download_orchestrator.download(username, filename, size))
|
||||
|
||||
if download_id:
|
||||
# Store context for post-processing with complete Spotify metadata (GUI PARITY)
|
||||
|
|
@ -330,7 +330,7 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
|
|||
logger.warning(f"[Modal Worker] Task {task_id} cancelled after download {download_id} started - attempting to cancel download")
|
||||
# Try to cancel the download immediately
|
||||
try:
|
||||
deps.run_async(deps.soulseek_client.cancel_download(download_id, username, remove=True))
|
||||
deps.run_async(deps.download_orchestrator.cancel_download(download_id, username, remove=True))
|
||||
logger.warning(f"Successfully cancelled active download {download_id}")
|
||||
except Exception as cancel_error:
|
||||
logger.error(f"Failed to cancel active download {download_id}: {cancel_error}")
|
||||
|
|
@ -340,8 +340,8 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
|
|||
deps.on_download_completed(batch_id, task_id, success=False)
|
||||
return False
|
||||
|
||||
# Store download information - use real download ID from soulseek_client
|
||||
# CRITICAL FIX: Trust the download ID returned by soulseek_client.download()
|
||||
# Store download information - use real download ID from download_orchestrator
|
||||
# CRITICAL FIX: Trust the download ID returned by download_orchestrator.download()
|
||||
download_tasks[task_id]['download_id'] = download_id
|
||||
|
||||
download_tasks[task_id]['username'] = username
|
||||
|
|
|
|||
|
|
@ -233,8 +233,8 @@ def on_download_completed(batch_id: str, task_id: str, success: bool, deps: Life
|
|||
'title': track_info.get('track_name', ''),
|
||||
'reason': track_info.get('failure_reason', 'Unknown'),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("download_failed emit failed: %s", e)
|
||||
|
||||
# WISHLIST REMOVAL: Handle successful downloads for wishlist removal
|
||||
if success and task_id in download_tasks:
|
||||
|
|
@ -364,8 +364,8 @@ def on_download_completed(batch_id: str, task_id: str, success: bool, deps: Life
|
|||
'completed_tracks': str(successful_downloads),
|
||||
'failed_tracks': str(failed_count),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("batch_complete emit failed: %s", e)
|
||||
|
||||
# Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist
|
||||
playlist_id = batch.get('playlist_id')
|
||||
|
|
@ -569,8 +569,8 @@ def check_batch_completion_v2(batch_id: str, deps: LifecycleDeps) -> Optional[bo
|
|||
'completed_tracks': str(successful_downloads),
|
||||
'failed_tracks': str(failed_count),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("batch_complete emit failed: %s", e)
|
||||
else:
|
||||
logger.warning(f"[Completion Check V2] Batch {batch_id} already marked complete - skipping duplicate processing")
|
||||
return True # Already complete
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
|
|||
class MasterDeps:
|
||||
"""Bundle of cross-cutting deps the master worker needs."""
|
||||
config_manager: Any
|
||||
soulseek_client: Any
|
||||
download_orchestrator: Any
|
||||
run_async: Callable[..., Any]
|
||||
mb_worker: Any
|
||||
mb_release_cache: dict
|
||||
|
|
@ -296,8 +296,8 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
})
|
||||
if track_results:
|
||||
db_sh.update_sync_history_track_results(batch_id, json.dumps(track_results))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("update sync_history track results failed: %s", e)
|
||||
|
||||
is_auto_batch = False
|
||||
with tasks_lock:
|
||||
|
|
@ -372,7 +372,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
_sr.info(f"[Album Pre-flight] Searching for '{artist_name} {album_name}'")
|
||||
logger.info(f"[Album Pre-flight] Searching Soulseek for complete album: '{artist_name} - {album_name}'")
|
||||
|
||||
slsk = deps.soulseek_client.soulseek if hasattr(deps.soulseek_client, 'soulseek') else deps.soulseek_client
|
||||
slsk = deps.download_orchestrator.client('soulseek') if hasattr(deps.download_orchestrator, 'client') else deps.download_orchestrator
|
||||
|
||||
# Try multiple query variations (banned keywords in artist/album name can return 0 results)
|
||||
album_queries = [f"{artist_name} {album_name}"]
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ _run_post_processing_worker = None
|
|||
_start_next_batch_of_downloads = None
|
||||
_orphaned_download_keys = None
|
||||
missing_download_executor = None
|
||||
soulseek_client = None
|
||||
download_orchestrator = None
|
||||
|
||||
|
||||
def init(
|
||||
|
|
@ -44,12 +44,12 @@ def init(
|
|||
start_next_batch_of_downloads,
|
||||
orphaned_download_keys,
|
||||
missing_download_executor_obj,
|
||||
soulseek_client_obj,
|
||||
download_orchestrator_obj,
|
||||
):
|
||||
"""Bind web_server-side helpers/globals so the class body can resolve them."""
|
||||
global _make_context_key, _on_download_completed, _download_track_worker
|
||||
global _run_post_processing_worker, _start_next_batch_of_downloads
|
||||
global _orphaned_download_keys, missing_download_executor, soulseek_client
|
||||
global _orphaned_download_keys, missing_download_executor, download_orchestrator
|
||||
_make_context_key = make_context_key
|
||||
_on_download_completed = on_download_completed
|
||||
_download_track_worker = download_track_worker
|
||||
|
|
@ -57,7 +57,7 @@ def init(
|
|||
_start_next_batch_of_downloads = start_next_batch_of_downloads
|
||||
_orphaned_download_keys = orphaned_download_keys
|
||||
missing_download_executor = missing_download_executor_obj
|
||||
soulseek_client = soulseek_client_obj
|
||||
download_orchestrator = download_orchestrator_obj
|
||||
|
||||
|
||||
class WebUIDownloadMonitor:
|
||||
|
|
@ -199,7 +199,7 @@ class WebUIDownloadMonitor:
|
|||
if op[0] == 'cancel_download':
|
||||
_, download_id, username = op
|
||||
logger.debug(f"[Deferred] Cancelling download: {download_id} from {username}")
|
||||
run_async(soulseek_client.cancel_download(download_id, username, remove=True))
|
||||
run_async(download_orchestrator.cancel_download(download_id, username, remove=True))
|
||||
logger.debug(f"[Deferred] Successfully cancelled download {download_id}")
|
||||
elif op[0] == 'cleanup_orphan':
|
||||
_, context_key = op
|
||||
|
|
@ -254,8 +254,9 @@ class WebUIDownloadMonitor:
|
|||
|
||||
# Get Soulseek downloads from API
|
||||
transfers_data = None
|
||||
if soulseek_active and soulseek_client and getattr(soulseek_client, 'soulseek', None) and soulseek_client.soulseek.base_url:
|
||||
transfers_data = run_async(soulseek_client._make_request('GET', 'transfers/downloads'))
|
||||
_slsk = download_orchestrator.client('soulseek') if download_orchestrator and hasattr(download_orchestrator, 'client') else None
|
||||
if soulseek_active and _slsk and _slsk.base_url:
|
||||
transfers_data = run_async(download_orchestrator._make_request('GET', 'transfers/downloads'))
|
||||
if transfers_data:
|
||||
for user_data in transfers_data:
|
||||
username = user_data.get('username', 'Unknown')
|
||||
|
|
@ -266,30 +267,34 @@ class WebUIDownloadMonitor:
|
|||
key = _make_context_key(username, file_info.get('filename', ''))
|
||||
live_transfers[key] = file_info
|
||||
|
||||
# Also get non-Soulseek downloads (YouTube/Tidal/Qobuz/HiFi/Deezer/Lidarr)
|
||||
# Call each client directly to avoid redundant slskd API call through orchestrator
|
||||
# Also get non-Soulseek downloads via the engine — single
|
||||
# cross-source aggregation, no per-source iteration.
|
||||
try:
|
||||
all_downloads = []
|
||||
for _dl_client in [soulseek_client.youtube, soulseek_client.tidal, soulseek_client.qobuz,
|
||||
soulseek_client.hifi, soulseek_client.deezer_dl, soulseek_client.lidarr]:
|
||||
if _dl_client:
|
||||
try:
|
||||
all_downloads.extend(run_async(_dl_client.get_all_downloads()))
|
||||
except Exception:
|
||||
pass
|
||||
if download_orchestrator and hasattr(download_orchestrator, 'engine'):
|
||||
try:
|
||||
# Exclude soulseek — slskd transfers were already
|
||||
# pulled via the transfers/downloads endpoint above.
|
||||
# Without the exclude both fetch paths run, doubling
|
||||
# the per-tick slskd API hit.
|
||||
all_downloads = run_async(
|
||||
download_orchestrator.engine.get_all_downloads(exclude=('soulseek',))
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("get_all_downloads failed: %s", e)
|
||||
for download in all_downloads:
|
||||
key = _make_context_key(download.username, download.filename)
|
||||
# Convert DownloadStatus to transfer dict format for monitor compatibility
|
||||
live_transfers[key] = {
|
||||
'id': download.id,
|
||||
'filename': download.filename,
|
||||
'username': download.username,
|
||||
'state': download.state,
|
||||
'percentComplete': download.progress,
|
||||
'size': download.size,
|
||||
'bytesTransferred': download.transferred,
|
||||
'averageSpeed': download.speed,
|
||||
}
|
||||
key = _make_context_key(download.username, download.filename)
|
||||
# Convert DownloadStatus to transfer dict format for monitor compatibility
|
||||
live_transfers[key] = {
|
||||
'id': download.id,
|
||||
'filename': download.filename,
|
||||
'username': download.username,
|
||||
'state': download.state,
|
||||
'percentComplete': download.progress,
|
||||
'size': download.size,
|
||||
'bytesTransferred': download.transferred,
|
||||
'averageSpeed': download.speed,
|
||||
}
|
||||
except Exception as yt_error:
|
||||
logger.error(f"Monitor: Could not fetch streaming source downloads: {yt_error}")
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ class PostProcessDeps:
|
|||
always live (no caching of pre-init Spotify clients etc).
|
||||
"""
|
||||
config_manager: Any
|
||||
soulseek_client: Any
|
||||
download_orchestrator: Any
|
||||
run_async: Callable
|
||||
docker_resolve_path: Callable[[str], str]
|
||||
extract_filename: Callable[[str], str]
|
||||
|
|
@ -196,7 +196,7 @@ def run_post_processing_worker(task_id: str, batch_id: str, deps: PostProcessDep
|
|||
# Query the download orchestrator for the status which contains the real file path
|
||||
# CRITICAL FIX: Use the actual download_id designated by the client, not the internal task_id
|
||||
actual_download_id = task.get('download_id') or task_id
|
||||
status = deps.run_async(deps.soulseek_client.get_download_status(actual_download_id))
|
||||
status = deps.run_async(deps.download_orchestrator.get_download_status(actual_download_id))
|
||||
if status and status.file_path:
|
||||
real_path = status.file_path
|
||||
if os.path.exists(real_path):
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
|
|||
@dataclass
|
||||
class TaskWorkerDeps:
|
||||
"""Bundle of cross-cutting deps the per-task download worker needs."""
|
||||
soulseek_client: Any
|
||||
download_orchestrator: Any
|
||||
matching_engine: Any
|
||||
run_async: Callable
|
||||
try_source_reuse: Callable # (task_id, batch_id, track) -> bool
|
||||
|
|
@ -210,7 +210,7 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
|
|||
|
||||
try:
|
||||
# Perform search with timeout
|
||||
tracks_result, _ = deps.run_async(deps.soulseek_client.search(query, timeout=30))
|
||||
tracks_result, _ = deps.run_async(deps.download_orchestrator.search(query, timeout=30))
|
||||
logger.debug(f"Search completed for task {task_id}, got {len(tracks_result) if tracks_result else 0} results")
|
||||
|
||||
# CRITICAL: Check cancellation immediately after search returns
|
||||
|
|
@ -260,7 +260,13 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
|
|||
search_diagnostics.append(f'"{query}": {result_count} results, {len(candidates)} passed filters but download failed to start')
|
||||
else:
|
||||
search_diagnostics.append(f'"{query}": {result_count} results but none passed quality/artist filters')
|
||||
all_raw_results.extend(tracks_result[:20]) # Keep top results for review
|
||||
# Strip SoundCloud preview snippets before caching for the
|
||||
# review modal — the user can't pick something useful from
|
||||
# a 30s preview clip, and clicking one bypasses validation
|
||||
# and downloads it anyway.
|
||||
from core.downloads.validation import filter_soundcloud_previews
|
||||
_filtered_raw = filter_soundcloud_previews(tracks_result[:20], track)
|
||||
all_raw_results.extend(_filtered_raw)
|
||||
else:
|
||||
search_diagnostics.append(f'"{query}": no results found')
|
||||
|
||||
|
|
@ -272,22 +278,23 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
|
|||
# === HYBRID FALLBACK: If primary source failed, try remaining sources directly ===
|
||||
# The orchestrator's hybrid search stops at the first source with results, even if
|
||||
# those results all fail quality filtering. Try remaining sources individually.
|
||||
if getattr(deps.soulseek_client, 'mode', '') == 'hybrid':
|
||||
if getattr(deps.download_orchestrator, 'mode', '') == 'hybrid':
|
||||
try:
|
||||
orch = deps.soulseek_client
|
||||
orch = deps.download_orchestrator
|
||||
hybrid_order = getattr(orch, 'hybrid_order', None) or []
|
||||
if not hybrid_order:
|
||||
primary = getattr(orch, 'hybrid_primary', 'soulseek')
|
||||
secondary = getattr(orch, 'hybrid_secondary', '')
|
||||
hybrid_order = [primary, secondary] if secondary and secondary != primary else [primary]
|
||||
|
||||
# Resolve via the orchestrator's generic accessor — the
|
||||
# legacy per-source attrs were dropped in the registry
|
||||
# refactor, so getattr(orch, 'soulseek', None) etc. all
|
||||
# silently returned None and the fallback never fired.
|
||||
source_clients = {
|
||||
'soulseek': getattr(orch, 'soulseek', None),
|
||||
'youtube': getattr(orch, 'youtube', None),
|
||||
'tidal': getattr(orch, 'tidal', None),
|
||||
'qobuz': getattr(orch, 'qobuz', None),
|
||||
'hifi': getattr(orch, 'hifi', None),
|
||||
'deezer_dl': getattr(orch, 'deezer_dl', None),
|
||||
name: orch.client(name)
|
||||
for name in ('soulseek', 'youtube', 'tidal', 'qobuz',
|
||||
'hifi', 'deezer_dl', 'lidarr', 'soundcloud')
|
||||
}
|
||||
|
||||
# The orchestrator tried sources in order but stopped at the first with results.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Soulseek/streaming candidate validation — lifted from web_server.py.
|
||||
|
||||
Body is byte-identical to the original. ``matching_engine`` and
|
||||
``soulseek_client`` are injected via init() because both are
|
||||
``download_orchestrator`` are injected via init() because both are
|
||||
constructed in web_server.py and referenced by name throughout
|
||||
the body.
|
||||
"""
|
||||
|
|
@ -14,14 +14,51 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
# Injected at runtime via init().
|
||||
matching_engine = None
|
||||
soulseek_client = None
|
||||
download_orchestrator = None
|
||||
|
||||
|
||||
def init(matching_engine_obj, soulseek_client_obj):
|
||||
def init(matching_engine_obj, download_orchestrator_obj):
|
||||
"""Bind the matching engine and download orchestrator from web_server."""
|
||||
global matching_engine, soulseek_client
|
||||
global matching_engine, download_orchestrator
|
||||
matching_engine = matching_engine_obj
|
||||
soulseek_client = soulseek_client_obj
|
||||
download_orchestrator = download_orchestrator_obj
|
||||
|
||||
|
||||
def filter_soundcloud_previews(results, expected_track):
|
||||
"""Drop SoundCloud preview snippets so they never reach the cache,
|
||||
the modal, or the auto-download attempt.
|
||||
|
||||
SoundCloud serves a ~30s preview clip for tracks gated behind Go+ /
|
||||
login. yt-dlp accepts the preview as the download payload, the
|
||||
integrity check catches the truncated file, but the user just sees
|
||||
"all candidates failed" with previews still listed in the modal
|
||||
(and clickable for manual retry, which downloads another preview).
|
||||
|
||||
Filter at every spot raw search results enter the task: validation
|
||||
scoring, modal-cache fallback when validation drops everything,
|
||||
and the not-found raw-results cache. Keep candidates that genuinely
|
||||
are short (intros, sound effects) when the expected track is also
|
||||
short.
|
||||
"""
|
||||
if not results or not expected_track:
|
||||
return results
|
||||
expected_ms = getattr(expected_track, 'duration_ms', 0) or 0
|
||||
if expected_ms <= 0:
|
||||
return results
|
||||
expected_secs = expected_ms / 1000.0
|
||||
if expected_secs <= 60:
|
||||
return results
|
||||
|
||||
def _is_preview(r):
|
||||
if getattr(r, 'username', None) != 'soundcloud':
|
||||
return False
|
||||
cand_ms = getattr(r, 'duration', None) or 0
|
||||
if cand_ms <= 0:
|
||||
return False
|
||||
cand_secs = cand_ms / 1000.0
|
||||
return cand_secs < 35 or cand_secs < expected_secs * 0.5
|
||||
|
||||
return [r for r in results if not _is_preview(r)]
|
||||
|
||||
|
||||
def get_valid_candidates(results, spotify_track, query):
|
||||
|
|
@ -33,9 +70,16 @@ def get_valid_candidates(results, spotify_track, query):
|
|||
if not results:
|
||||
return []
|
||||
|
||||
# Streaming sources (YouTube, Tidal, Qobuz, HiFi, Deezer) return structured API results
|
||||
# Pre-filter: drop SoundCloud preview snippets when expected
|
||||
# duration is non-trivially long. Same helper is also applied at
|
||||
# the modal-cache fallback path so previews never reach the UI.
|
||||
results = filter_soundcloud_previews(results, spotify_track)
|
||||
if not results:
|
||||
return []
|
||||
|
||||
# Streaming sources (YouTube, Tidal, Qobuz, HiFi, Deezer, SoundCloud) return structured API results
|
||||
# with proper artist/title metadata — score using the same matching engine as Soulseek
|
||||
_streaming_sources = ("youtube", "tidal", "qobuz", "hifi", "deezer_dl")
|
||||
_streaming_sources = ("youtube", "tidal", "qobuz", "hifi", "deezer_dl", "soundcloud")
|
||||
if results[0].username in _streaming_sources:
|
||||
source_label = results[0].username.replace('_dl', '').title()
|
||||
expected_artists = spotify_track.artists if spotify_track else []
|
||||
|
|
@ -45,7 +89,12 @@ def get_valid_candidates(results, spotify_track, query):
|
|||
# Detect if the expected track is a specific version (live, remix, acoustic, etc.)
|
||||
expected_title_lower = (expected_title or '').lower()
|
||||
_version_keywords = ['remix', 'live', 'acoustic', 'instrumental', 'radio edit',
|
||||
'extended', 'slowed', 'sped up', 'reverb', 'karaoke']
|
||||
'extended', 'slowed', 'sped up', 'reverb', 'karaoke',
|
||||
# Producer-tag noise common on SoundCloud — "type
|
||||
# beat" is an instrumental track produced in
|
||||
# someone's style, tagged with the artist name to
|
||||
# game search. NEVER the real song.
|
||||
'type beat']
|
||||
expected_is_version = any(kw in expected_title_lower for kw in _version_keywords)
|
||||
|
||||
scored = []
|
||||
|
|
@ -151,8 +200,8 @@ def get_valid_candidates(results, spotify_track, query):
|
|||
quality_filtered_candidates = initial_candidates
|
||||
else:
|
||||
# Filter by user's quality profile before artist verification (Soulseek only)
|
||||
# Use existing soulseek_client to avoid re-initializing (which accesses download_path filesystem)
|
||||
quality_filtered_candidates = soulseek_client.soulseek.filter_results_by_quality_preference(initial_candidates)
|
||||
# Use existing download_orchestrator to avoid re-initializing (which accesses download_path filesystem)
|
||||
quality_filtered_candidates = download_orchestrator.client('soulseek').filter_results_by_quality_preference(initial_candidates)
|
||||
|
||||
# IMPORTANT: Respect empty results from quality filter
|
||||
# If user has strict quality requirements (e.g., FLAC-only with fallback disabled),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Body is byte-identical to the original. Wishlist helpers are
|
||||
direct imports from core.wishlist.*; runtime state comes from
|
||||
core.runtime_state; automation_engine, soulseek_client, and the
|
||||
core.runtime_state; automation_engine, download_orchestrator, and the
|
||||
sweep helper are injected via init() because they are constructed
|
||||
in web_server.py.
|
||||
"""
|
||||
|
|
@ -29,15 +29,15 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
# Injected at runtime via init().
|
||||
automation_engine = None
|
||||
soulseek_client = None
|
||||
download_orchestrator = None
|
||||
_sweep_empty_download_directories = None
|
||||
|
||||
|
||||
def init(engine, soulseek_client_obj, sweep_fn):
|
||||
def init(engine, download_orchestrator_obj, sweep_fn):
|
||||
"""Bind shared singletons + the sweep helper from web_server."""
|
||||
global automation_engine, soulseek_client, _sweep_empty_download_directories
|
||||
global automation_engine, download_orchestrator, _sweep_empty_download_directories
|
||||
automation_engine = engine
|
||||
soulseek_client = soulseek_client_obj
|
||||
download_orchestrator = download_orchestrator_obj
|
||||
_sweep_empty_download_directories = sweep_fn
|
||||
|
||||
|
||||
|
|
@ -146,8 +146,8 @@ def _process_failed_tracks_to_wishlist_exact(batch_id):
|
|||
'title': track_name,
|
||||
'reason': failed_track_info.get('failure_reason', ''),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("emit wishlist_item_added failed: %s", e)
|
||||
else:
|
||||
logger.error(f"[Wishlist Processing] Failed to add {track_name} to wishlist")
|
||||
|
||||
|
|
@ -185,7 +185,7 @@ def _process_failed_tracks_to_wishlist_exact(batch_id):
|
|||
# Auto-cleanup: Clear completed downloads from slskd
|
||||
try:
|
||||
logger.info(f"[Auto-Cleanup] Clearing completed downloads from slskd after batch {batch_id}")
|
||||
run_async(soulseek_client.clear_all_completed_downloads())
|
||||
run_async(download_orchestrator.clear_all_completed_downloads())
|
||||
logger.info("[Auto-Cleanup] Completed downloads cleared from slskd")
|
||||
except Exception as cleanup_error:
|
||||
logger.warning(f"[Auto-Cleanup] Failed to clear completed downloads: {cleanup_error}")
|
||||
|
|
|
|||
0
core/enrichment/__init__.py
Normal file
0
core/enrichment/__init__.py
Normal file
156
core/enrichment/api.py
Normal file
156
core/enrichment/api.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"""Generic Flask routes for enrichment-bubble status / pause / resume.
|
||||
|
||||
Replaces 30 near-identical per-service routes that web_server.py used
|
||||
to hand-roll. The blueprint reads the registry in ``core.enrichment.services``
|
||||
and dispatches:
|
||||
|
||||
GET /api/enrichment/<service_id>/status
|
||||
POST /api/enrichment/<service_id>/pause
|
||||
POST /api/enrichment/<service_id>/resume
|
||||
|
||||
A 404 is returned for unknown service ids. Per-service quirks (Spotify
|
||||
rate-limit guard, auto-pause token cleanup, persisted-pause config keys)
|
||||
are encoded as data on the ``EnrichmentService`` descriptor — there is
|
||||
no branching on service id inside this module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
from core.enrichment.services import EnrichmentService, get_service
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
|
||||
logger = get_logger("enrichment.api")
|
||||
|
||||
|
||||
# Hooks the host wires up so the blueprint can persist pause state and
|
||||
# clean up auto-pause / yield-override sets without circular imports.
|
||||
_config_set: Optional[Callable[[str, Any], None]] = None
|
||||
_auto_paused_discard: Optional[Callable[[str], None]] = None
|
||||
_yield_override_add: Optional[Callable[[str], None]] = None
|
||||
|
||||
|
||||
def configure(
|
||||
*,
|
||||
config_set: Optional[Callable[[str, Any], None]] = None,
|
||||
auto_paused_discard: Optional[Callable[[str], None]] = None,
|
||||
yield_override_add: Optional[Callable[[str], None]] = None,
|
||||
) -> None:
|
||||
"""Wire host-side mutators that the generic routes call after pause/resume.
|
||||
|
||||
Each is optional — pass None for hosts that don't have a corresponding
|
||||
mechanism (e.g. tests).
|
||||
"""
|
||||
global _config_set, _auto_paused_discard, _yield_override_add
|
||||
_config_set = config_set
|
||||
_auto_paused_discard = auto_paused_discard
|
||||
_yield_override_add = yield_override_add
|
||||
|
||||
|
||||
def _persist_paused(service: EnrichmentService, paused: bool) -> None:
|
||||
if not service.config_paused_key or _config_set is None:
|
||||
return
|
||||
try:
|
||||
_config_set(service.config_paused_key, paused)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Persisting pause flag for %s failed: %s", service.id, e
|
||||
)
|
||||
|
||||
|
||||
def _drop_auto_pause_marker(service: EnrichmentService) -> None:
|
||||
if service.auto_pause_token is None or _auto_paused_discard is None:
|
||||
return
|
||||
try:
|
||||
_auto_paused_discard(service.auto_pause_token)
|
||||
except Exception as e:
|
||||
logger.debug("auto-pause marker discard: %s", e)
|
||||
|
||||
|
||||
def _add_yield_override(service: EnrichmentService) -> None:
|
||||
if service.auto_pause_token is None or _yield_override_add is None:
|
||||
return
|
||||
try:
|
||||
_yield_override_add(service.auto_pause_token)
|
||||
except Exception as e:
|
||||
logger.debug("yield override add: %s", e)
|
||||
|
||||
|
||||
def create_blueprint() -> Blueprint:
|
||||
"""Build the Flask blueprint — call once during host startup."""
|
||||
bp = Blueprint('enrichment_api', __name__)
|
||||
|
||||
@bp.route('/api/enrichment/<service_id>/status', methods=['GET'])
|
||||
def enrichment_status(service_id: str):
|
||||
service = get_service(service_id)
|
||||
if service is None:
|
||||
return jsonify({'error': f'Unknown enrichment service: {service_id}'}), 404
|
||||
try:
|
||||
worker = service.get_worker()
|
||||
if worker is None:
|
||||
return jsonify(service.fallback_status()), 200
|
||||
return jsonify(worker.get_stats()), 200
|
||||
except Exception as e:
|
||||
logger.error("Error getting %s enrichment status: %s", service.id, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@bp.route('/api/enrichment/<service_id>/pause', methods=['POST'])
|
||||
def enrichment_pause(service_id: str):
|
||||
service = get_service(service_id)
|
||||
if service is None:
|
||||
return jsonify({'error': f'Unknown enrichment service: {service_id}'}), 404
|
||||
worker = service.get_worker()
|
||||
if worker is None:
|
||||
return jsonify({
|
||||
'error': f'{service.display_name} enrichment worker not initialized',
|
||||
}), 400
|
||||
try:
|
||||
worker.pause()
|
||||
_persist_paused(service, True)
|
||||
_drop_auto_pause_marker(service)
|
||||
logger.info("%s worker paused via UI", service.display_name)
|
||||
return jsonify({'status': 'paused'}), 200
|
||||
except Exception as e:
|
||||
logger.error("Error pausing %s worker: %s", service.id, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@bp.route('/api/enrichment/<service_id>/resume', methods=['POST'])
|
||||
def enrichment_resume(service_id: str):
|
||||
service = get_service(service_id)
|
||||
if service is None:
|
||||
return jsonify({'error': f'Unknown enrichment service: {service_id}'}), 404
|
||||
worker = service.get_worker()
|
||||
if worker is None:
|
||||
return jsonify({
|
||||
'error': f'{service.display_name} enrichment worker not initialized',
|
||||
}), 400
|
||||
# Pre-resume guard (e.g. Spotify rate-limit ban). Returns
|
||||
# (http_status, error_message) when blocking, None when ok.
|
||||
if service.pre_resume_check is not None:
|
||||
try:
|
||||
blocked = service.pre_resume_check()
|
||||
except Exception as e:
|
||||
logger.error("Pre-resume check for %s raised: %s", service.id, e)
|
||||
blocked = None
|
||||
if blocked is not None:
|
||||
http_status, message = blocked
|
||||
payload: dict = {'error': message}
|
||||
if http_status == 429:
|
||||
payload['rate_limited'] = True
|
||||
return jsonify(payload), http_status
|
||||
try:
|
||||
worker.resume()
|
||||
_persist_paused(service, False)
|
||||
_drop_auto_pause_marker(service)
|
||||
_add_yield_override(service)
|
||||
logger.info("%s worker resumed via UI", service.display_name)
|
||||
return jsonify({'status': 'running'}), 200
|
||||
except Exception as e:
|
||||
logger.error("Error resuming %s worker: %s", service.id, e)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
return bp
|
||||
128
core/enrichment/manual_match_honoring.py
Normal file
128
core/enrichment/manual_match_honoring.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""Honor manually-matched source IDs in per-source enrichment workers.
|
||||
|
||||
GitHub issue #501 (@Tacobell444): every per-source enrichment worker's
|
||||
``_process_*_individual`` method ran a fuzzy text search on the album /
|
||||
track name and overwrote the stored source ID with whatever the search
|
||||
returned. If the user had manually matched an album to a specific source
|
||||
ID (e.g. set ``albums.spotify_album_id = 'ABC'`` via the match-chip UI),
|
||||
the next "Enrich" click would search by name → pick a different result
|
||||
→ overwrite the manual match with the wrong ID, OR fail to match
|
||||
anything and revert the status to ``not_found``.
|
||||
|
||||
This module lifts the "honor stored ID" fast path into one shared
|
||||
helper. Each per-source worker (Spotify / iTunes / Deezer / Discogs /
|
||||
MusicBrainz / AudioDB / Tidal / Qobuz) calls it before falling back
|
||||
to its existing search-by-name flow. Same fix in 8 workers gets
|
||||
exactly one implementation; per-worker variability (column name,
|
||||
client fetch method, response shape) plugs in via callbacks.
|
||||
|
||||
Lift what's truly shared. Caller knows its own column + client
|
||||
method + update logic; the helper just orchestrates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("enrichment.manual_match_honoring")
|
||||
|
||||
|
||||
def _read_id_column(db, entity_table: str, entity_id, id_column: str) -> Optional[str]:
|
||||
"""Read the stored source ID for one entity. Returns None when the
|
||||
column is empty / unset."""
|
||||
if entity_table not in ('albums', 'tracks', 'artists'):
|
||||
# Defensive: we only operate on these three. Avoids SQL injection
|
||||
# via a bad table name (id_column is also restricted to known
|
||||
# column names by callers but defense in depth never hurts).
|
||||
return None
|
||||
conn = db._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
f"SELECT {id_column} FROM {entity_table} WHERE id = ?",
|
||||
(entity_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
value = row[0] if not hasattr(row, 'keys') else row[id_column]
|
||||
return str(value).strip() if value else None
|
||||
|
||||
|
||||
def honor_stored_match(
|
||||
*,
|
||||
db,
|
||||
entity_table: str,
|
||||
entity_id,
|
||||
id_column: str,
|
||||
client_fetch_fn: Callable[[str], Any],
|
||||
on_match_fn: Callable[[Any, str, Any], None],
|
||||
log_prefix: str = '',
|
||||
) -> bool:
|
||||
"""Fast-path enrichment via a stored source ID — preserves manual
|
||||
matches.
|
||||
|
||||
Args:
|
||||
db: ``MusicDatabase`` instance (for the column read).
|
||||
entity_table: ``'albums'``, ``'tracks'``, or ``'artists'``.
|
||||
entity_id: Library DB ID of the entity to enrich.
|
||||
id_column: Column on ``entity_table`` that stores the source-
|
||||
specific ID (``spotify_album_id`` / ``itunes_album_id`` /
|
||||
``deezer_id`` / etc).
|
||||
client_fetch_fn: Callable taking the stored ID and returning
|
||||
the source's raw response (Album dataclass, dict, or
|
||||
whatever the client returns). Typically
|
||||
``self.client.get_album`` or ``self.client.get_track``.
|
||||
on_match_fn: Worker callback invoked with
|
||||
``(entity_id, stored_id, api_response)`` to apply the
|
||||
metadata refresh. Worker knows the response shape; helper
|
||||
doesn't.
|
||||
log_prefix: Display name for log lines (``'Spotify'`` /
|
||||
``'iTunes'`` / etc).
|
||||
|
||||
Returns:
|
||||
True if a stored ID was found AND the fetch returned data AND
|
||||
the on-match callback ran. Caller skips its search-by-name
|
||||
flow and counts a match.
|
||||
|
||||
False if no stored ID is set, the fetch failed, or the fetch
|
||||
returned empty. Caller falls through to its existing search-
|
||||
by-name flow (the legacy behavior for un-matched entities).
|
||||
|
||||
Notes:
|
||||
- Exceptions in ``client_fetch_fn`` are caught and logged at
|
||||
warning level — caller falls through to search.
|
||||
- Exceptions in ``on_match_fn`` propagate (those are real
|
||||
DB errors the worker should know about).
|
||||
"""
|
||||
stored_id = _read_id_column(db, entity_table, entity_id, id_column)
|
||||
if not stored_id:
|
||||
return False
|
||||
|
||||
try:
|
||||
api_data = client_fetch_fn(stored_id)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"[{log_prefix}] Stored-ID fetch failed for "
|
||||
f"{entity_table[:-1]} #{entity_id} (id={stored_id}): {exc}"
|
||||
)
|
||||
return False
|
||||
|
||||
if not api_data:
|
||||
logger.debug(
|
||||
f"[{log_prefix}] Stored ID {stored_id} for "
|
||||
f"{entity_table[:-1]} #{entity_id} returned empty data — "
|
||||
f"falling through to search-by-name"
|
||||
)
|
||||
return False
|
||||
|
||||
on_match_fn(entity_id, stored_id, api_data)
|
||||
logger.info(
|
||||
f"[{log_prefix}] Honored manual match: "
|
||||
f"{entity_table[:-1]} #{entity_id} → {id_column}={stored_id}"
|
||||
)
|
||||
return True
|
||||
125
core/enrichment/services.py
Normal file
125
core/enrichment/services.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Registry of enrichment workers exposed via the dashboard bubble UI.
|
||||
|
||||
Every "bubble" on the dashboard (MusicBrainz, Spotify, iTunes, Last.fm,
|
||||
Genius, Deezer, Discogs, AudioDB, Tidal, Qobuz) used to have its own
|
||||
copy-pasted ``status`` / ``pause`` / ``resume`` Flask routes — 30 routes
|
||||
that differed only in the worker reference and a couple of per-service
|
||||
quirks. This module collapses them into a single ``EnrichmentService``
|
||||
descriptor + registry so the generic blueprint in ``core.enrichment.api``
|
||||
can drive every bubble from one place.
|
||||
|
||||
Hydrabase (P2P mirror) and SoulID (entity ID generation) are intentionally
|
||||
out of scope here — their workers report fundamentally different status
|
||||
shapes and don't share the bubble pause/resume contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
# Default status payload shape returned when a worker isn't initialized.
|
||||
# Mirrors the shape every per-service route used to inline before this
|
||||
# refactor; UI consumers depend on these exact keys.
|
||||
_DEFAULT_STATUS_FALLBACK: Dict[str, Any] = {
|
||||
'enabled': False,
|
||||
'running': False,
|
||||
'paused': False,
|
||||
'current_item': None,
|
||||
'stats': {'matched': 0, 'not_found': 0, 'pending': 0, 'errors': 0},
|
||||
'progress': {},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnrichmentService:
|
||||
"""Descriptor for one enrichment worker exposed via the dashboard.
|
||||
|
||||
The dashboard talks to every worker through three identical-looking
|
||||
endpoints (status / pause / resume). The variation between services
|
||||
is captured here as data, not branching code:
|
||||
|
||||
- ``worker_getter`` returns the live worker reference (or None when
|
||||
initialization failed). Lazy so the registry can be defined before
|
||||
web_server.py finishes module-level imports.
|
||||
- ``config_paused_key`` is the ``config_manager`` key that persists
|
||||
the user's pause / resume choice across restarts. Empty string
|
||||
means "do not persist" (Hydrabase historically did this).
|
||||
- ``pre_resume_check`` runs before resume — return ``(http_status,
|
||||
error_message)`` to short-circuit (Spotify uses this for the
|
||||
rate-limit guard).
|
||||
- ``auto_pause_token`` matches an entry in
|
||||
``_download_auto_paused`` / ``_download_yield_override`` so the
|
||||
pause/resume routes can clean those up correctly. None means
|
||||
this service doesn't participate in the auto-pause-during-download
|
||||
mechanism.
|
||||
- ``extra_status_defaults`` is merged into the fallback status
|
||||
payload (Tidal / Qobuz add ``'authenticated': False``).
|
||||
"""
|
||||
|
||||
id: str
|
||||
display_name: str
|
||||
worker_getter: Callable[[], Any]
|
||||
config_paused_key: str = ''
|
||||
pre_resume_check: Optional[Callable[[], Optional[Tuple[int, str]]]] = None
|
||||
auto_pause_token: Optional[str] = None
|
||||
extra_status_defaults: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def get_worker(self) -> Any:
|
||||
"""Resolve the worker reference (None if init failed)."""
|
||||
try:
|
||||
return self.worker_getter()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def fallback_status(self) -> Dict[str, Any]:
|
||||
"""Return the shape we serve when the worker isn't initialized."""
|
||||
payload = dict(_DEFAULT_STATUS_FALLBACK)
|
||||
# stats dict is shared — copy so callers can't mutate the module
|
||||
# default.
|
||||
payload['stats'] = dict(_DEFAULT_STATUS_FALLBACK['stats'])
|
||||
if self.extra_status_defaults:
|
||||
payload.update(self.extra_status_defaults)
|
||||
return payload
|
||||
|
||||
|
||||
# Module-level registry. Populated by ``register_services`` so the host
|
||||
# (web_server.py) can wire its module-local worker globals + downstream
|
||||
# state collections (auto-pause sets, rate-limit guard) without circular
|
||||
# imports.
|
||||
_REGISTRY: Dict[str, EnrichmentService] = {}
|
||||
|
||||
|
||||
def register_services(services: List[EnrichmentService]) -> None:
|
||||
"""Replace the active service registry.
|
||||
|
||||
The host registers all services in one call after its workers are
|
||||
initialized. Re-registering is allowed (used by tests) — clears the
|
||||
previous set.
|
||||
"""
|
||||
_REGISTRY.clear()
|
||||
for svc in services:
|
||||
if not svc.id:
|
||||
raise ValueError("EnrichmentService.id must be non-empty")
|
||||
_REGISTRY[svc.id] = svc
|
||||
|
||||
|
||||
def get_service(service_id: str) -> Optional[EnrichmentService]:
|
||||
"""Return the registered service with this id, or None."""
|
||||
return _REGISTRY.get(service_id)
|
||||
|
||||
|
||||
def all_services() -> List[EnrichmentService]:
|
||||
"""Return every registered service in registration order."""
|
||||
return list(_REGISTRY.values())
|
||||
|
||||
|
||||
def all_service_ids() -> List[str]:
|
||||
"""Return the ids of every registered service."""
|
||||
return list(_REGISTRY.keys())
|
||||
|
||||
|
||||
def clear_registry() -> None:
|
||||
"""Wipe the registry. Test-only — production code uses ``register_services``."""
|
||||
_REGISTRY.clear()
|
||||
|
|
@ -154,8 +154,8 @@ class GeniusWorker:
|
|||
itype = item.get('type', '')
|
||||
table = 'artists' if 'artist' in itype else ('albums' if 'album' in itype else 'tracks')
|
||||
# Can't mark status without an ID — just skip
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("null-id item type lookup: %s", e)
|
||||
continue
|
||||
|
||||
self._process_item(item)
|
||||
|
|
@ -367,8 +367,8 @@ class GeniusWorker:
|
|||
if song_url:
|
||||
try:
|
||||
lyrics = self.client.get_lyrics(song_url)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _e:
|
||||
logger.debug("genius lyrics scrape: %s", _e)
|
||||
self._update_track(track_id, full_song, full_song, lyrics)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Enriched track '{track_name}' from existing Genius ID: {existing_id}")
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import requests as http_requests
|
|||
|
||||
from utils.logging_config import get_logger
|
||||
from config.settings import config_manager
|
||||
from core.soulseek_client import TrackResult, AlbumResult, DownloadStatus
|
||||
from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus
|
||||
|
||||
logger = get_logger("hifi_client")
|
||||
|
||||
|
|
@ -86,7 +86,10 @@ DEFAULT_INSTANCES = [
|
|||
]
|
||||
|
||||
|
||||
class HiFiClient:
|
||||
from core.download_plugins.base import DownloadSourcePlugin
|
||||
|
||||
|
||||
class HiFiClient(DownloadSourcePlugin):
|
||||
"""
|
||||
HiFi API client for searching and downloading lossless music.
|
||||
Uses public hifi-api instances (Tidal backend) — no auth required.
|
||||
|
|
@ -110,9 +113,7 @@ class HiFiClient:
|
|||
'Accept': 'application/json',
|
||||
})
|
||||
|
||||
self.active_downloads: Dict[str, Dict[str, Any]] = {}
|
||||
self._download_lock = threading.Lock()
|
||||
|
||||
self._engine = None
|
||||
self.shutdown_check = None
|
||||
|
||||
self._last_api_call = 0
|
||||
|
|
@ -125,6 +126,9 @@ class HiFiClient:
|
|||
def set_shutdown_check(self, check_callable):
|
||||
self.shutdown_check = check_callable
|
||||
|
||||
def set_engine(self, engine):
|
||||
self._engine = engine
|
||||
|
||||
def _load_instances_from_db(self):
|
||||
try:
|
||||
from database.music_database import get_database
|
||||
|
|
@ -282,24 +286,30 @@ class HiFiClient:
|
|||
|
||||
def _parse_track(self, item: dict) -> Dict:
|
||||
artist_name = 'Unknown Artist'
|
||||
artist_id = None
|
||||
artists_raw = item.get('artists', item.get('artist'))
|
||||
if isinstance(artists_raw, list):
|
||||
names = []
|
||||
for a in artists_raw:
|
||||
if isinstance(a, dict):
|
||||
names.append(a.get('name', ''))
|
||||
if artist_id is None:
|
||||
artist_id = a.get('id')
|
||||
elif isinstance(a, str):
|
||||
names.append(a)
|
||||
artist_name = ', '.join(n for n in names if n) or 'Unknown Artist'
|
||||
elif isinstance(artists_raw, dict):
|
||||
artist_name = artists_raw.get('name', 'Unknown Artist')
|
||||
artist_id = artists_raw.get('id')
|
||||
elif isinstance(artists_raw, str):
|
||||
artist_name = artists_raw
|
||||
|
||||
album_raw = item.get('album', {})
|
||||
album_name = ''
|
||||
album_id = None
|
||||
if isinstance(album_raw, dict):
|
||||
album_name = album_raw.get('title', album_raw.get('name', ''))
|
||||
album_id = album_raw.get('id')
|
||||
elif isinstance(album_raw, str):
|
||||
album_name = album_raw
|
||||
|
||||
|
|
@ -308,12 +318,16 @@ class HiFiClient:
|
|||
|
||||
return {
|
||||
'id': item.get('id'),
|
||||
'artist_id': artist_id,
|
||||
'album_id': album_id,
|
||||
'title': item.get('title', item.get('name', 'Unknown')),
|
||||
'artist': artist_name,
|
||||
'album': album_name,
|
||||
'duration_ms': int(duration_ms) if duration_ms else 0,
|
||||
'track_number': item.get('trackNumber', item.get('track_number')),
|
||||
'isrc': item.get('isrc'),
|
||||
'bpm': item.get('bpm'),
|
||||
'copyright': item.get('copyright'),
|
||||
'explicit': item.get('explicit', False),
|
||||
'quality': item.get('audioQuality', item.get('quality', '')),
|
||||
}
|
||||
|
|
@ -549,74 +563,46 @@ class HiFiClient:
|
|||
title=track.get('title'),
|
||||
album=track.get('album'),
|
||||
track_number=track.get('track_number'),
|
||||
_source_metadata={
|
||||
'source': 'hifi',
|
||||
'track_id': track.get('id'),
|
||||
'artist_id': track.get('artist_id'),
|
||||
'album_id': track.get('album_id'),
|
||||
'isrc': track.get('isrc'),
|
||||
'bpm': track.get('bpm'),
|
||||
'copyright': track.get('copyright'),
|
||||
},
|
||||
)
|
||||
|
||||
async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]:
|
||||
if '||' not in filename:
|
||||
logger.error(f"Invalid filename format: {filename}")
|
||||
return None
|
||||
if self._engine is None:
|
||||
# Raise rather than return None so the orchestrator's
|
||||
# download_with_fallback surfaces a real warning + tries
|
||||
# the next source. Returning None silently dropped the
|
||||
# download with no user feedback (per JohnBaumb).
|
||||
raise RuntimeError("HiFi client has no engine reference — cannot dispatch download")
|
||||
|
||||
track_id_str, display_name = filename.split('||', 1)
|
||||
try:
|
||||
if '||' not in filename:
|
||||
logger.error(f"Invalid filename format: {filename}")
|
||||
return None
|
||||
|
||||
track_id_str, display_name = filename.split('||', 1)
|
||||
try:
|
||||
track_id = int(track_id_str)
|
||||
except ValueError:
|
||||
logger.error(f"Invalid track ID: {track_id_str}")
|
||||
return None
|
||||
|
||||
download_id = str(uuid.uuid4())
|
||||
|
||||
with self._download_lock:
|
||||
self.active_downloads[download_id] = {
|
||||
'id': download_id,
|
||||
'filename': filename,
|
||||
'username': 'hifi',
|
||||
'state': 'Initializing',
|
||||
'progress': 0.0,
|
||||
'size': 0,
|
||||
'transferred': 0,
|
||||
'speed': 0,
|
||||
'time_remaining': None,
|
||||
'track_id': track_id,
|
||||
'display_name': display_name,
|
||||
'file_path': None,
|
||||
}
|
||||
|
||||
thread = threading.Thread(
|
||||
target=self._download_worker,
|
||||
args=(download_id, track_id, display_name),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return download_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start HiFi download: {e}")
|
||||
track_id = int(track_id_str)
|
||||
except ValueError:
|
||||
logger.error(f"Invalid track ID: {track_id_str}")
|
||||
return None
|
||||
|
||||
def _download_worker(self, download_id: str, track_id: int, display_name: str):
|
||||
try:
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['state'] = 'InProgress, Downloading'
|
||||
|
||||
file_path = self._download_sync(download_id, track_id, display_name)
|
||||
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
if file_path:
|
||||
self.active_downloads[download_id]['state'] = 'Completed, Succeeded'
|
||||
self.active_downloads[download_id]['progress'] = 100.0
|
||||
self.active_downloads[download_id]['file_path'] = file_path
|
||||
else:
|
||||
self.active_downloads[download_id]['state'] = 'Errored'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"HiFi download worker failed for {download_id}: {e}")
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['state'] = 'Errored'
|
||||
return self._engine.worker.dispatch(
|
||||
source_name='hifi',
|
||||
target_id=track_id,
|
||||
display_name=display_name,
|
||||
original_filename=filename,
|
||||
impl_callable=self._download_sync,
|
||||
extra_record_fields={
|
||||
'track_id': track_id,
|
||||
'display_name': display_name,
|
||||
},
|
||||
)
|
||||
|
||||
def _download_sync(self, download_id: str, track_id: int, display_name: str) -> Optional[str]:
|
||||
quality_key = config_manager.get('hifi_download.quality', 'lossless')
|
||||
|
|
@ -657,9 +643,8 @@ class HiFiClient:
|
|||
speed_start = time.time()
|
||||
segments_completed = 0
|
||||
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['size'] = 0
|
||||
if self._engine is not None:
|
||||
self._engine.update_record('hifi', download_id, {'size': 0})
|
||||
|
||||
with intermediate_path.open('wb') as output_file:
|
||||
if init_uri:
|
||||
|
|
@ -758,80 +743,77 @@ class HiFiClient:
|
|||
def _update_download_progress(self, download_id: str, downloaded: int,
|
||||
segments_completed: int, total_segments: int,
|
||||
speed_start: float):
|
||||
with self._download_lock:
|
||||
if download_id not in self.active_downloads:
|
||||
return
|
||||
info = self.active_downloads[download_id]
|
||||
info['transferred'] = downloaded
|
||||
if self._engine is None:
|
||||
return
|
||||
record = self._engine.get_record('hifi', download_id)
|
||||
if record is None:
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
elapsed_total = now - speed_start
|
||||
speed = int(downloaded / elapsed_total) if elapsed_total > 0 else 0
|
||||
info['speed'] = speed
|
||||
now = time.time()
|
||||
elapsed_total = now - speed_start
|
||||
speed = int(downloaded / elapsed_total) if elapsed_total > 0 else 0
|
||||
|
||||
if total_segments > 0:
|
||||
progress = (segments_completed / total_segments) * 100
|
||||
info['progress'] = round(min(progress, 99.9), 1)
|
||||
progress = record.get('progress', 0.0)
|
||||
if total_segments > 0:
|
||||
progress = round(min((segments_completed / total_segments) * 100, 99.9), 1)
|
||||
|
||||
time_remaining = None
|
||||
if speed > 0:
|
||||
remaining_bytes = downloaded * (total_segments / max(segments_completed, 1)) - downloaded
|
||||
if remaining_bytes > 0:
|
||||
time_remaining = int(remaining_bytes / speed)
|
||||
info['time_remaining'] = time_remaining
|
||||
time_remaining = None
|
||||
if speed > 0:
|
||||
remaining_bytes = downloaded * (total_segments / max(segments_completed, 1)) - downloaded
|
||||
if remaining_bytes > 0:
|
||||
time_remaining = int(remaining_bytes / speed)
|
||||
|
||||
self._engine.update_record('hifi', download_id, {
|
||||
'transferred': downloaded,
|
||||
'speed': speed,
|
||||
'progress': progress,
|
||||
'time_remaining': time_remaining,
|
||||
})
|
||||
|
||||
def _record_to_status(self, record):
|
||||
return DownloadStatus(
|
||||
id=record['id'],
|
||||
filename=record['filename'],
|
||||
username=record['username'],
|
||||
state=record['state'],
|
||||
progress=record['progress'],
|
||||
size=record.get('size', 0),
|
||||
transferred=record.get('transferred', 0),
|
||||
speed=record.get('speed', 0),
|
||||
time_remaining=record.get('time_remaining'),
|
||||
file_path=record.get('file_path'),
|
||||
)
|
||||
|
||||
async def get_all_downloads(self) -> List[DownloadStatus]:
|
||||
statuses = []
|
||||
with self._download_lock:
|
||||
for _dl_id, info in self.active_downloads.items():
|
||||
statuses.append(DownloadStatus(
|
||||
id=info['id'],
|
||||
filename=info['filename'],
|
||||
username=info['username'],
|
||||
state=info['state'],
|
||||
progress=info['progress'],
|
||||
size=info['size'],
|
||||
transferred=info['transferred'],
|
||||
speed=info['speed'],
|
||||
time_remaining=info.get('time_remaining'),
|
||||
file_path=info.get('file_path'),
|
||||
))
|
||||
return statuses
|
||||
if self._engine is None:
|
||||
return []
|
||||
return [
|
||||
self._record_to_status(record)
|
||||
for record in self._engine.iter_records_for_source('hifi')
|
||||
]
|
||||
|
||||
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
||||
with self._download_lock:
|
||||
info = self.active_downloads.get(download_id)
|
||||
if not info:
|
||||
return None
|
||||
return DownloadStatus(
|
||||
id=info['id'],
|
||||
filename=info['filename'],
|
||||
username=info['username'],
|
||||
state=info['state'],
|
||||
progress=info['progress'],
|
||||
size=info['size'],
|
||||
transferred=info['transferred'],
|
||||
speed=info['speed'],
|
||||
time_remaining=info.get('time_remaining'),
|
||||
file_path=info.get('file_path'),
|
||||
)
|
||||
if self._engine is None:
|
||||
return None
|
||||
record = self._engine.get_record('hifi', download_id)
|
||||
return self._record_to_status(record) if record is not None else None
|
||||
|
||||
async def cancel_download(self, download_id: str, username: str = None,
|
||||
remove: bool = False) -> bool:
|
||||
with self._download_lock:
|
||||
if download_id not in self.active_downloads:
|
||||
return False
|
||||
self.active_downloads[download_id]['state'] = 'Cancelled'
|
||||
if remove:
|
||||
del self.active_downloads[download_id]
|
||||
if self._engine is None:
|
||||
return False
|
||||
if self._engine.get_record('hifi', download_id) is None:
|
||||
return False
|
||||
self._engine.update_record('hifi', download_id, {'state': 'Cancelled'})
|
||||
if remove:
|
||||
self._engine.remove_record('hifi', download_id)
|
||||
return True
|
||||
|
||||
async def clear_all_completed_downloads(self) -> bool:
|
||||
with self._download_lock:
|
||||
to_remove = [
|
||||
did for did, info in self.active_downloads.items()
|
||||
if info.get('state', '') in ('Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted')
|
||||
]
|
||||
for did in to_remove:
|
||||
del self.active_downloads[did]
|
||||
if self._engine is None:
|
||||
return True
|
||||
terminal = {'Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted'}
|
||||
for record in list(self._engine.iter_records_for_source('hifi')):
|
||||
if record.get('state') in terminal:
|
||||
self._engine.remove_record('hifi', record['id'])
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -525,8 +525,8 @@ class HydrabaseClient:
|
|||
for album in albums:
|
||||
if album.image_url:
|
||||
return album.image_url
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("get artist image from albums failed: %s", e)
|
||||
return None
|
||||
|
||||
def get_artist_albums(self, artist_id: str, album_type: str = 'album,single', limit: int = 50) -> List[Album]:
|
||||
|
|
|
|||
|
|
@ -32,6 +32,20 @@ def _first_id_value(*values: Any) -> str:
|
|||
return ""
|
||||
|
||||
|
||||
def _first_source_aware_id(source: str, *values: Any) -> str:
|
||||
source_name = (source or "").strip().lower()
|
||||
for value in values:
|
||||
if value in (None, ""):
|
||||
continue
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
continue
|
||||
if source_name.startswith("spotify") and text.isdigit():
|
||||
continue
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def extract_artist_name(artist: Any) -> str:
|
||||
if isinstance(artist, dict):
|
||||
return str(artist.get("name", "") or "")
|
||||
|
|
@ -209,6 +223,7 @@ def get_import_has_full_metadata(context: Optional[Dict[str, Any]]) -> bool:
|
|||
|
||||
|
||||
def get_import_source_ids(context: Optional[Dict[str, Any]]) -> Dict[str, str]:
|
||||
source = get_import_source(context)
|
||||
track_info = get_import_track_info(context)
|
||||
original_search = get_import_original_search(context)
|
||||
search_result = get_import_search_result(context)
|
||||
|
|
@ -216,7 +231,8 @@ def get_import_source_ids(context: Optional[Dict[str, Any]]) -> Dict[str, str]:
|
|||
album = get_import_context_album(context)
|
||||
|
||||
return {
|
||||
"track_id": _first_id_value(
|
||||
"track_id": _first_source_aware_id(
|
||||
source,
|
||||
_first_value(track_info, "id", "track_id", "trackId", "source_track_id", default=""),
|
||||
_first_value(track_info, "spotify_track_id", "itunes_track_id", "deezer_id", "deezer_track_id", "discogs_id", "soul_id", default=""),
|
||||
_first_value(original_search, "id", "track_id", "source_track_id", default=""),
|
||||
|
|
@ -224,7 +240,8 @@ def get_import_source_ids(context: Optional[Dict[str, Any]]) -> Dict[str, str]:
|
|||
_first_value(search_result, "id", "track_id", "source_track_id", default=""),
|
||||
_first_value(search_result, "spotify_track_id", "itunes_track_id", "deezer_id", "deezer_track_id", "discogs_id", "soul_id", default=""),
|
||||
),
|
||||
"artist_id": _first_id_value(
|
||||
"artist_id": _first_source_aware_id(
|
||||
source,
|
||||
_first_value(artist, "id", "artist_id", "source_artist_id", default=""),
|
||||
_first_value(artist, "spotify_artist_id", "itunes_artist_id", "deezer_id", "deezer_artist_id", "discogs_id", "soul_id", default=""),
|
||||
_first_value(original_search, "artist_id", "source_artist_id", default=""),
|
||||
|
|
@ -232,7 +249,8 @@ def get_import_source_ids(context: Optional[Dict[str, Any]]) -> Dict[str, str]:
|
|||
_first_value(search_result, "artist_id", "source_artist_id", default=""),
|
||||
_first_value(search_result, "spotify_artist_id", "itunes_artist_id", "deezer_id", "deezer_artist_id", "discogs_id", "soul_id", default=""),
|
||||
),
|
||||
"album_id": _first_id_value(
|
||||
"album_id": _first_source_aware_id(
|
||||
source,
|
||||
_first_value(album, "id", "album_id", "collectionId", "source_album_id", default=""),
|
||||
_first_value(album, "spotify_album_id", "itunes_album_id", "deezer_id", "deezer_album_id", "discogs_id", "soul_id", "album_soul_id", "hydrabase_album_id", default=""),
|
||||
_first_value(original_search, "album_id", "source_album_id", default=""),
|
||||
|
|
@ -257,6 +275,8 @@ def get_source_tag_names(source: str) -> Dict[str, Optional[str]]:
|
|||
return {"track": None, "artist": None, "album": None}
|
||||
if source_name == "discogs":
|
||||
return {"track": None, "artist": None, "album": None}
|
||||
if source_name == "hifi":
|
||||
return {"track": "HIFI_TRACK_ID", "artist": "HIFI_ARTIST_ID", "album": None}
|
||||
return {"track": None, "artist": None, "album": None}
|
||||
|
||||
|
||||
|
|
@ -272,6 +292,8 @@ def get_library_source_id_columns(source: str) -> Dict[str, Optional[str]]:
|
|||
return {"artist": "soul_id", "album": "soul_id", "track": "soul_id", "track_album": "album_soul_id"}
|
||||
if source_name == "discogs":
|
||||
return {"artist": "discogs_id", "album": "discogs_id", "track": None}
|
||||
if source_name == "hifi":
|
||||
return {"artist": "hifi_artist_id", "album": None, "track": "hifi_track_id"}
|
||||
return {}
|
||||
|
||||
|
||||
|
|
|
|||
192
core/imports/file_integrity.py
Normal file
192
core/imports/file_integrity.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
"""Audio file integrity checks for downloaded files.
|
||||
|
||||
slskd (and other download sources) sometimes ship broken files: truncated
|
||||
transfers, corrupted FLAC frames, mp3s with bad headers, or wrong files
|
||||
that share a name with the target. These slip past the slskd "completed"
|
||||
status and only get caught later (often by Plex/Jellyfin failing to scan
|
||||
the file, or by users hearing dead air).
|
||||
|
||||
Verification runs after the slskd transfer settles but before the heavy
|
||||
post-processing work (tagging, copying, server sync). Failed files get
|
||||
quarantined and the slot is freed for a retry from another candidate.
|
||||
|
||||
Three checks, in order from cheapest to most expensive:
|
||||
|
||||
1. **File-size sanity** — anything below ~10KB is almost certainly a
|
||||
stub, broken transfer, or non-audio masquerading as audio.
|
||||
2. **Mutagen parse** — catches truncated headers, corrupted streamheaders,
|
||||
wrong-format files (mp3 with .flac extension, etc). If mutagen can't
|
||||
parse the audio info block, the file won't import cleanly downstream.
|
||||
3. **Duration agreement** — if the caller provides an expected duration
|
||||
(Spotify/MusicBrainz `duration_ms`), the decoded length must agree
|
||||
within tolerance. Catches truncated files whose headers parse fine
|
||||
but whose audio is incomplete, and "wrong file" cases the slskd
|
||||
transfer matched on a similarly-named track.
|
||||
|
||||
This is the "tier 1" integrity layer — universal across formats, no
|
||||
external binary dep. A future tier could verify the FLAC STREAMINFO MD5
|
||||
by actually decoding the audio (requires `flac` binary or libflac
|
||||
wrapper); skipped for now since tier 1 catches the vast majority of
|
||||
real-world corruption.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
|
||||
logger = get_logger("imports.file_integrity")
|
||||
|
||||
# Minimum plausible audio file size. A 1-second 64kbps mp3 is ~8KB; a
|
||||
# 1-second FLAC is much larger. Anything under this is a broken stub.
|
||||
_MIN_FILE_SIZE_BYTES = 10 * 1024
|
||||
|
||||
# Default tolerance for duration agreement. Most legitimate length
|
||||
# variations (intro silence, encoder padding, live recording trims) sit
|
||||
# inside 3 seconds. Goes up to 5s if the expected duration is itself
|
||||
# long (>10 minutes) since absolute drift scales with length.
|
||||
_DEFAULT_LENGTH_TOLERANCE_S = 3.0
|
||||
_LENGTH_TOLERANCE_LONG_TRACK_S = 5.0
|
||||
_LONG_TRACK_THRESHOLD_S = 600.0 # 10 minutes
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntegrityResult:
|
||||
"""Outcome of an integrity check.
|
||||
|
||||
`ok` is the single bit the caller cares about. `reason` is the
|
||||
human-readable explanation when `ok` is False (suitable for
|
||||
quarantine sidecar / log lines / UI). `checks` carries the
|
||||
per-check details — useful for debugging and tests.
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
reason: str = ""
|
||||
checks: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def check_audio_integrity(
|
||||
file_path: str,
|
||||
expected_duration_ms: Optional[int] = None,
|
||||
*,
|
||||
length_tolerance_s: Optional[float] = None,
|
||||
min_file_size_bytes: int = _MIN_FILE_SIZE_BYTES,
|
||||
) -> IntegrityResult:
|
||||
"""Verify a downloaded audio file is not broken.
|
||||
|
||||
Args:
|
||||
file_path: Path to the audio file on disk.
|
||||
expected_duration_ms: Expected track length from the metadata
|
||||
source (Spotify/MB/etc). If None, the duration check is
|
||||
skipped and only the size + parse checks run.
|
||||
length_tolerance_s: Override the default tolerance for the
|
||||
duration check. None uses the auto-scaled default
|
||||
(3s for normal tracks, 5s for >10min tracks).
|
||||
min_file_size_bytes: Override the minimum size threshold.
|
||||
|
||||
Returns:
|
||||
IntegrityResult with `ok`, `reason`, and per-check details.
|
||||
Never raises — all errors become `ok=False` with an explanatory
|
||||
reason, so callers can rely on a clean boolean.
|
||||
"""
|
||||
import os
|
||||
|
||||
checks: Dict[str, Any] = {}
|
||||
|
||||
# --- Check 1: file size ---
|
||||
try:
|
||||
size = os.path.getsize(file_path)
|
||||
except OSError as exc:
|
||||
return IntegrityResult(ok=False, reason=f"Cannot stat file: {exc}",
|
||||
checks={"size": "stat_failed"})
|
||||
|
||||
checks["size_bytes"] = size
|
||||
if size < min_file_size_bytes:
|
||||
return IntegrityResult(
|
||||
ok=False,
|
||||
reason=f"File too small ({size} bytes, minimum {min_file_size_bytes}) — "
|
||||
"likely truncated transfer or empty stub",
|
||||
checks=checks,
|
||||
)
|
||||
|
||||
# --- Check 2: mutagen parse ---
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
except ImportError:
|
||||
# mutagen is a hard dep elsewhere in the codebase, but degrade
|
||||
# gracefully if it's somehow missing — pass with a warning
|
||||
# rather than failing every download.
|
||||
logger.warning("[Integrity] mutagen unavailable — skipping parse check")
|
||||
checks["mutagen_parse"] = "unavailable"
|
||||
return IntegrityResult(ok=True, checks=checks)
|
||||
|
||||
try:
|
||||
audio = MutagenFile(file_path)
|
||||
except Exception as exc:
|
||||
return IntegrityResult(
|
||||
ok=False,
|
||||
reason=f"Mutagen could not parse file: {exc}",
|
||||
checks={**checks, "mutagen_parse": "exception"},
|
||||
)
|
||||
|
||||
if audio is None:
|
||||
return IntegrityResult(
|
||||
ok=False,
|
||||
reason="Mutagen could not identify file format — likely corrupted "
|
||||
"or wrong file extension",
|
||||
checks={**checks, "mutagen_parse": "unidentified"},
|
||||
)
|
||||
|
||||
if audio.info is None:
|
||||
return IntegrityResult(
|
||||
ok=False,
|
||||
reason="Mutagen parsed file but found no audio info block — "
|
||||
"header damage suspected",
|
||||
checks={**checks, "mutagen_parse": "no_info"},
|
||||
)
|
||||
|
||||
actual_length_s = float(getattr(audio.info, "length", 0) or 0)
|
||||
checks["actual_length_s"] = actual_length_s
|
||||
|
||||
if actual_length_s <= 0:
|
||||
return IntegrityResult(
|
||||
ok=False,
|
||||
reason="Mutagen reports zero-length audio — file has no playable "
|
||||
"audio data",
|
||||
checks={**checks, "mutagen_parse": "zero_length"},
|
||||
)
|
||||
|
||||
# --- Check 3: duration agreement (optional) ---
|
||||
if expected_duration_ms is None or expected_duration_ms <= 0:
|
||||
checks["length_check"] = "skipped"
|
||||
return IntegrityResult(ok=True, checks=checks)
|
||||
|
||||
expected_length_s = expected_duration_ms / 1000.0
|
||||
checks["expected_length_s"] = expected_length_s
|
||||
|
||||
if length_tolerance_s is None:
|
||||
length_tolerance_s = (
|
||||
_LENGTH_TOLERANCE_LONG_TRACK_S
|
||||
if expected_length_s > _LONG_TRACK_THRESHOLD_S
|
||||
else _DEFAULT_LENGTH_TOLERANCE_S
|
||||
)
|
||||
checks["length_tolerance_s"] = length_tolerance_s
|
||||
|
||||
drift_s = abs(actual_length_s - expected_length_s)
|
||||
checks["length_drift_s"] = drift_s
|
||||
|
||||
if drift_s > length_tolerance_s:
|
||||
return IntegrityResult(
|
||||
ok=False,
|
||||
reason=f"Duration mismatch: file is {actual_length_s:.1f}s, "
|
||||
f"expected {expected_length_s:.1f}s "
|
||||
f"(drift {drift_s:.1f}s > tolerance {length_tolerance_s:.1f}s) — "
|
||||
"likely truncated download or wrong file matched",
|
||||
checks=checks,
|
||||
)
|
||||
|
||||
checks["length_check"] = "passed"
|
||||
return IntegrityResult(ok=True, checks=checks)
|
||||
|
|
@ -362,8 +362,8 @@ def downsample_hires_flac(final_path, context):
|
|||
if os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _e:
|
||||
logger.debug("cleanup downsample temp: %s", _e)
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -440,14 +440,36 @@ def create_lossy_copy(final_path):
|
|||
audio.save()
|
||||
except Exception as tag_err:
|
||||
logger.error(f"[Lossy Copy] Could not update QUALITY tag: {tag_err}")
|
||||
|
||||
# Honor the lossy_copy.delete_original setting — without this
|
||||
# the original FLAC was always kept alongside the converted
|
||||
# MP3/OPUS/AAC even when the user explicitly opted into a
|
||||
# lossy-only library (Discord-reported by CAL).
|
||||
if config_manager.get("lossy_copy.delete_original", False):
|
||||
if os.path.normpath(out_path) != os.path.normpath(final_path):
|
||||
try:
|
||||
os.remove(final_path)
|
||||
logger.info(
|
||||
f"[Lossy Copy] Deleted original lossless source after conversion: "
|
||||
f"{os.path.basename(final_path)}"
|
||||
)
|
||||
except FileNotFoundError:
|
||||
# Already gone — concurrent cleanup or another worker
|
||||
# handled it. Not an error.
|
||||
pass
|
||||
except Exception as del_err:
|
||||
logger.error(
|
||||
f"[Lossy Copy] Could not delete original after conversion "
|
||||
f"({os.path.basename(final_path)}): {del_err}"
|
||||
)
|
||||
return out_path
|
||||
|
||||
logger.error(f"[Lossy Copy] ffmpeg failed: {result.stderr[:200]}")
|
||||
if os.path.exists(out_path):
|
||||
try:
|
||||
os.remove(out_path)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _e:
|
||||
logger.debug("cleanup lossy copy artifact: %s", _e)
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(f"[Lossy Copy] Conversion timed out for: {os.path.basename(final_path)}")
|
||||
|
|
|
|||
|
|
@ -82,8 +82,8 @@ def move_to_quarantine(file_path: str, context: dict, reason: str, automation_en
|
|||
"reason": reason or "Unknown",
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("emit download_quarantined failed: %s", e)
|
||||
|
||||
return str(quarantine_path)
|
||||
|
||||
|
|
|
|||
|
|
@ -188,8 +188,8 @@ def _replace_template_variables(template: str, context: dict) -> str:
|
|||
resolved = resolved_client.resolve_primary_artist(itunes_artist_id)
|
||||
if resolved and resolved != album_artist_value:
|
||||
album_artist_value = resolved
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("resolve primary artist failed: %s", e)
|
||||
|
||||
# $cdnum — smart CD label for multi-disc filenames. Produces "CD01" /
|
||||
# "CD02" etc. when the album has 2+ discs, empty string otherwise.
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from core.imports.context import (
|
|||
get_import_track_info,
|
||||
normalize_import_context,
|
||||
)
|
||||
from core.imports.file_integrity import check_audio_integrity
|
||||
from core.imports.filename import extract_track_number_from_filename
|
||||
from core.imports.guards import check_flac_bit_depth, move_to_quarantine
|
||||
from core.imports.side_effects import (
|
||||
|
|
@ -143,6 +144,68 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
|
|||
else:
|
||||
logger.info(f"File may still be writing after stability checks: {_basename} ({_prev_size} bytes)")
|
||||
|
||||
# File integrity check: catches broken slskd transfers (truncated,
|
||||
# corrupted, wrong file masquerading as the target) before we burn
|
||||
# cycles on AcoustID + tagging + library sync. Universal across
|
||||
# formats; failed files get quarantined and the slot freed.
|
||||
try:
|
||||
_normalized_for_duration = normalize_import_context(context)
|
||||
_duration_track = get_import_track_info(_normalized_for_duration)
|
||||
_expected_duration_ms = int(_duration_track.get("duration_ms", 0) or 0) or None
|
||||
except Exception:
|
||||
_expected_duration_ms = None
|
||||
|
||||
try:
|
||||
integrity = check_audio_integrity(file_path, _expected_duration_ms)
|
||||
except Exception as integrity_error:
|
||||
logger.error(f"[Integrity] Check raised unexpectedly (continuing): {integrity_error}")
|
||||
integrity = None
|
||||
|
||||
if integrity is not None and not integrity.ok:
|
||||
logger.error(f"[Integrity] Rejected {_basename}: {integrity.reason}")
|
||||
context['_integrity_failure_msg'] = integrity.reason
|
||||
context['_integrity_checks'] = integrity.checks
|
||||
try:
|
||||
quarantine_path = move_to_quarantine(
|
||||
file_path,
|
||||
context,
|
||||
f"Integrity check failed: {integrity.reason}",
|
||||
automation_engine,
|
||||
)
|
||||
logger.error(f"File quarantined due to integrity failure: {quarantine_path}")
|
||||
except Exception as quarantine_error:
|
||||
logger.error(f"Quarantine failed ({quarantine_error}), deleting broken file: {file_path}")
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except Exception as del_error:
|
||||
logger.error(f"Could not delete broken file either: {del_error}")
|
||||
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
del matched_downloads_context[context_key]
|
||||
|
||||
task_id = context.get('task_id')
|
||||
batch_id = context.get('batch_id')
|
||||
if task_id:
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = (
|
||||
f"File integrity check failed: {integrity.reason}"
|
||||
)
|
||||
|
||||
if task_id and batch_id:
|
||||
_notify_download_completed(batch_id, task_id, success=False)
|
||||
return
|
||||
|
||||
if integrity is not None:
|
||||
logger.info(
|
||||
f"[Integrity] {_basename} passed "
|
||||
f"(size={integrity.checks.get('size_bytes', '?')}b, "
|
||||
f"length={integrity.checks.get('actual_length_s', 0):.1f}s, "
|
||||
f"drift={integrity.checks.get('length_drift_s', 'n/a')})"
|
||||
)
|
||||
|
||||
_skip_acoustid = False
|
||||
try:
|
||||
from core.acoustid_verification import AcoustIDVerification, VerificationResult
|
||||
|
|
@ -371,8 +434,8 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
|
|||
logger.error(f"Quarantine failed ({quarantine_error}), deleting file: {file_path}")
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("delete quarantine fallback: %s", e)
|
||||
|
||||
context['_bitdepth_rejected'] = True
|
||||
with matched_context_lock:
|
||||
|
|
@ -499,8 +562,8 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
|
|||
logger.error(f"Quarantine failed ({quarantine_error}), deleting file: {file_path}")
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("delete quarantine fallback: %s", e)
|
||||
|
||||
context['_bitdepth_rejected'] = True
|
||||
with matched_context_lock:
|
||||
|
|
@ -864,6 +927,50 @@ def post_process_matched_download_with_verification(context_key, context, file_p
|
|||
_notify_download_completed(batch_id, task_id, success=False)
|
||||
return
|
||||
|
||||
# Integrity rejection — the inner pipeline quarantined the file
|
||||
# because audio integrity (size / parse / duration) failed. Wrapper
|
||||
# was previously falling through to "assuming success" because
|
||||
# quarantined files have no _final_processed_path, which left the
|
||||
# task showing ✅ Completed in the UI even though the file is in
|
||||
# quarantine. Reported by user when downloading Mr. Morale: 3
|
||||
# tracks (Rich Interlude, Savior Interlude, Savior) showed
|
||||
# Completed in the modal but were missing on disk because their
|
||||
# source files failed integrity and were quarantined.
|
||||
if context.get('_integrity_failure_msg'):
|
||||
failure_msg = context.get('_integrity_failure_msg', 'unknown')
|
||||
logger.error(
|
||||
f"Task {task_id} failed integrity check — marking failed: {failure_msg}"
|
||||
)
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = (
|
||||
f"File integrity check failed: {failure_msg}"
|
||||
)
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
del matched_downloads_context[context_key]
|
||||
_notify_download_completed(batch_id, task_id, success=False)
|
||||
return
|
||||
|
||||
# Race guard failure — inner code set this when the source file
|
||||
# disappeared and there was no known destination to fall back on
|
||||
# (vs the legitimate race-guard skip where a sibling thread
|
||||
# already moved the file to its destination).
|
||||
if context.get('_race_guard_failed'):
|
||||
logger.error(f"Task {task_id} failed race guard — source file gone with no known destination")
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = (
|
||||
"Source file disappeared before post-processing could complete"
|
||||
)
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
del matched_downloads_context[context_key]
|
||||
_notify_download_completed(batch_id, task_id, success=False)
|
||||
return
|
||||
|
||||
expected_final_path = context.get('_final_processed_path')
|
||||
if not expected_final_path:
|
||||
logger.info(f"No _final_processed_path in context for task {task_id} — cannot verify, assuming success")
|
||||
|
|
|
|||
|
|
@ -2,12 +2,30 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from core.metadata import registry as metadata_registry
|
||||
from core.metadata.types import Album
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
|
||||
# Per-source typed converter dispatch — same registry pattern as
|
||||
# ``core/metadata/album_tracks.py``. When the embedded ``album`` blob in
|
||||
# a track response is dispatched through the typed converter for that
|
||||
# provider, the resulting Album fields drive the album_payload below.
|
||||
# Falls through to the legacy duck-typed path when source is empty,
|
||||
# unknown, or the converter raises.
|
||||
_TYPED_ALBUM_CONVERTERS: Dict[str, Callable[[Dict[str, Any]], Album]] = {
|
||||
'spotify': Album.from_spotify_dict,
|
||||
'itunes': Album.from_itunes_dict,
|
||||
'deezer': Album.from_deezer_dict,
|
||||
'discogs': Album.from_discogs_dict,
|
||||
'musicbrainz': Album.from_musicbrainz_dict,
|
||||
'hydrabase': Album.from_hydrabase_dict,
|
||||
'qobuz': Album.from_qobuz_dict,
|
||||
}
|
||||
|
||||
|
||||
logger = get_logger("imports.resolution")
|
||||
|
||||
|
||||
|
|
@ -156,17 +174,62 @@ def _build_single_import_context_payload(
|
|||
album_artists = _normalize_context_artists(_extract_lookup_value(track_data, 'album_artists', 'artists', default=[]))
|
||||
|
||||
if isinstance(album_data, dict):
|
||||
album_name = _extract_lookup_value(album_data, 'name', 'title', 'collectionName', default=album_name) or album_name
|
||||
album_id = str(_extract_lookup_value(album_data, 'id', 'album_id', 'collectionId', default=album_id) or album_id)
|
||||
release_date = str(_extract_lookup_value(album_data, 'release_date', default=release_date) or release_date)
|
||||
album_type = str(_extract_lookup_value(album_data, 'album_type', default=album_type) or album_type)
|
||||
total_tracks = int(_extract_lookup_value(album_data, 'total_tracks', 'track_count', 'nb_tracks', default=total_tracks) or total_tracks)
|
||||
album_images = _extract_lookup_value(album_data, 'images', default=[]) or []
|
||||
if not album_image_url:
|
||||
album_image_url = str(_extract_lookup_value(album_data, 'image_url', 'thumb_url', default='') or '')
|
||||
if not album_image_url and album_images:
|
||||
album_image_url = str(_extract_lookup_value(album_images[0], 'url', default='') or '')
|
||||
album_artists = _normalize_context_artists(_extract_lookup_value(album_data, 'artists', default=[]))
|
||||
# Typed dispatch: when the source is a known provider, route the
|
||||
# embedded album blob through Album.from_<source>_dict() and read
|
||||
# canonical fields off the typed result. Falls back to the
|
||||
# legacy duck-typed extraction below on unknown/missing source
|
||||
# OR if the converter raises (so a converter bug can't break
|
||||
# import context resolution).
|
||||
typed_album: Optional[Album] = None
|
||||
source_key = (source or '').strip().lower()
|
||||
if source_key:
|
||||
converter = _TYPED_ALBUM_CONVERTERS.get(source_key)
|
||||
if converter is not None:
|
||||
try:
|
||||
typed_album = converter(album_data)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Typed album converter failed for source %s in import "
|
||||
"context build, falling back to legacy: %s", source, exc,
|
||||
)
|
||||
typed_album = None
|
||||
|
||||
if typed_album is not None:
|
||||
if typed_album.name:
|
||||
album_name = typed_album.name
|
||||
if typed_album.id:
|
||||
album_id = typed_album.id
|
||||
if typed_album.release_date:
|
||||
release_date = typed_album.release_date
|
||||
if typed_album.album_type:
|
||||
album_type = typed_album.album_type
|
||||
if typed_album.total_tracks:
|
||||
total_tracks = typed_album.total_tracks
|
||||
# Preserve raw images list verbatim (legacy behavior — some
|
||||
# downstream consumers iterate the full multi-resolution
|
||||
# array to pick a different size).
|
||||
raw_images = album_data.get('images')
|
||||
if isinstance(raw_images, list) and raw_images:
|
||||
album_images = raw_images
|
||||
if not album_image_url:
|
||||
album_image_url = typed_album.image_url or ''
|
||||
if not album_image_url and album_images:
|
||||
album_image_url = str(_extract_lookup_value(album_images[0], 'url', default='') or '')
|
||||
album_artists = _normalize_context_artists(
|
||||
[{'name': name} for name in typed_album.artists]
|
||||
)
|
||||
else:
|
||||
album_name = _extract_lookup_value(album_data, 'name', 'title', 'collectionName', default=album_name) or album_name
|
||||
album_id = str(_extract_lookup_value(album_data, 'id', 'album_id', 'collectionId', default=album_id) or album_id)
|
||||
release_date = str(_extract_lookup_value(album_data, 'release_date', default=release_date) or release_date)
|
||||
album_type = str(_extract_lookup_value(album_data, 'album_type', default=album_type) or album_type)
|
||||
total_tracks = int(_extract_lookup_value(album_data, 'total_tracks', 'track_count', 'nb_tracks', default=total_tracks) or total_tracks)
|
||||
album_images = _extract_lookup_value(album_data, 'images', default=[]) or []
|
||||
if not album_image_url:
|
||||
album_image_url = str(_extract_lookup_value(album_data, 'image_url', 'thumb_url', default='') or '')
|
||||
if not album_image_url and album_images:
|
||||
album_image_url = str(_extract_lookup_value(album_images[0], 'url', default='') or '')
|
||||
album_artists = _normalize_context_artists(_extract_lookup_value(album_data, 'artists', default=[]))
|
||||
elif album_data:
|
||||
album_name = album_name or str(album_data)
|
||||
|
||||
|
|
@ -344,8 +407,8 @@ def get_single_track_import_context(
|
|||
'genres',
|
||||
default=[],
|
||||
) or []
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("override artist genres: %s", e)
|
||||
return payload
|
||||
except Exception as exc:
|
||||
logger.debug("Override track lookup failed on %s for %s: %s", chosen_source, override_id, exc)
|
||||
|
|
@ -398,8 +461,8 @@ def get_single_track_import_context(
|
|||
'genres',
|
||||
default=[],
|
||||
) or []
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("artist genres lookup: %s", e)
|
||||
return payload
|
||||
|
||||
return _build_single_import_fallback_context(title, artist, source_priority)
|
||||
|
|
|
|||
|
|
@ -72,8 +72,8 @@ def emit_track_downloaded(context: Dict[str, Any], automation_engine=None) -> No
|
|||
"quality": context.get("_audio_quality", "Unknown"),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("track_downloaded emit failed: %s", e)
|
||||
|
||||
|
||||
def record_library_history_download(context: Dict[str, Any]) -> None:
|
||||
|
|
@ -88,6 +88,7 @@ def record_library_history_download(context: Dict[str, Any]) -> None:
|
|||
"hifi": "HiFi",
|
||||
"deezer_dl": "Deezer",
|
||||
"lidarr": "Lidarr",
|
||||
"soundcloud": "SoundCloud",
|
||||
}
|
||||
download_source = source_map.get(username, "Soulseek")
|
||||
|
||||
|
|
@ -119,7 +120,7 @@ def record_library_history_download(context: Dict[str, Any]) -> None:
|
|||
source_track_id = search_result.get("track_id", "") or search_result.get("id", "") or ti.get("id", "")
|
||||
source_track_title = search_result.get("title", "") or search_result.get("name", "")
|
||||
source_artist = search_result.get("artist", "")
|
||||
if source_filename and "||" in source_filename and username in ("tidal", "youtube", "qobuz", "hifi", "deezer_dl", "lidarr"):
|
||||
if source_filename and "||" in source_filename and username in ("tidal", "youtube", "qobuz", "hifi", "deezer_dl", "lidarr", "soundcloud"):
|
||||
stream_id = source_filename.split("||")[0]
|
||||
if stream_id and not source_track_id:
|
||||
source_track_id = stream_id
|
||||
|
|
@ -142,8 +143,8 @@ def record_library_history_download(context: Dict[str, Any]) -> None:
|
|||
acoustid_result=acoustid_result,
|
||||
source_artist=source_artist,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("library history record failed: %s", e)
|
||||
|
||||
|
||||
def record_download_provenance(context: Dict[str, Any]) -> None:
|
||||
|
|
@ -159,6 +160,7 @@ def record_download_provenance(context: Dict[str, Any]) -> None:
|
|||
"hifi": "hifi",
|
||||
"deezer_dl": "deezer",
|
||||
"lidarr": "lidarr",
|
||||
"soundcloud": "soundcloud",
|
||||
}.get(username, "soulseek")
|
||||
|
||||
ti = context.get("track_info") or context.get("search_result") or {}
|
||||
|
|
@ -186,8 +188,32 @@ def record_download_provenance(context: Dict[str, Any]) -> None:
|
|||
sample_rate = getattr(audio.info, "sample_rate", None)
|
||||
bitrate = getattr(audio.info, "bitrate", None)
|
||||
bit_depth = getattr(audio.info, "bits_per_sample", None)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("audio info probe failed: %s", e)
|
||||
|
||||
# Pull the metadata-source IDs out of context. ``embed_source_ids``
|
||||
# in core/metadata/source.py wrote them to ``_embedded_id_tags``
|
||||
# at the end of post-processing — we persist them here so the
|
||||
# watchlist scanner can recognize freshly downloaded files
|
||||
# without waiting for the async enrichment workers.
|
||||
embedded = context.get("_embedded_id_tags") or {}
|
||||
|
||||
def _embedded(*keys):
|
||||
for k in keys:
|
||||
v = embedded.get(k)
|
||||
if v:
|
||||
return str(v)
|
||||
return None
|
||||
|
||||
spotify_track_id = _embedded("SPOTIFY_TRACK_ID")
|
||||
itunes_track_id = _embedded("ITUNES_TRACK_ID")
|
||||
deezer_track_id = _embedded("DEEZER_TRACK_ID")
|
||||
tidal_track_id = _embedded("TIDAL_TRACK_ID")
|
||||
qobuz_track_id = _embedded("QOBUZ_TRACK_ID")
|
||||
musicbrainz_recording_id = _embedded("MUSICBRAINZ_RECORDING_ID")
|
||||
audiodb_id = _embedded("AUDIODB_TRACK_ID")
|
||||
soul_id = _embedded("SOUL_ID")
|
||||
isrc = context.get("_isrc")
|
||||
|
||||
db = get_database()
|
||||
db.record_track_download(
|
||||
|
|
@ -203,9 +229,18 @@ def record_download_provenance(context: Dict[str, Any]) -> None:
|
|||
bit_depth=bit_depth,
|
||||
sample_rate=sample_rate,
|
||||
bitrate=bitrate,
|
||||
spotify_track_id=spotify_track_id,
|
||||
itunes_track_id=itunes_track_id,
|
||||
deezer_track_id=deezer_track_id,
|
||||
tidal_track_id=tidal_track_id,
|
||||
qobuz_track_id=qobuz_track_id,
|
||||
musicbrainz_recording_id=musicbrainz_recording_id,
|
||||
audiodb_id=audiodb_id,
|
||||
soul_id=soul_id,
|
||||
isrc=isrc,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("record_download_provenance failed: %s", e)
|
||||
|
||||
|
||||
def record_soulsync_library_entry(context: Dict[str, Any], artist_context: Dict[str, Any], album_info: Dict[str, Any]) -> None:
|
||||
|
|
@ -286,7 +321,18 @@ def record_soulsync_library_entry(context: Dict[str, Any], artist_context: Dict[
|
|||
audio = MutagenFile(final_path)
|
||||
if audio and hasattr(audio, "info") and audio.info and hasattr(audio.info, "bitrate"):
|
||||
bitrate = int(audio.info.bitrate / 1000) if audio.info.bitrate else 0
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug("bitrate read failed: %s", e)
|
||||
|
||||
# File size on disk (powers Library Disk Usage card on Stats).
|
||||
# SoulSync standalone is the only path where the file is local
|
||||
# at insert time, so we read it directly via os.path.getsize.
|
||||
# Mirrors what JellyfinTrack/NavidromeTrack pull from API
|
||||
# responses for the media-server flows.
|
||||
file_size = None
|
||||
try:
|
||||
file_size = os.path.getsize(final_path) or None
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
artist_id = _stable_soulsync_id(artist_name.lower().strip())
|
||||
|
|
@ -325,8 +371,8 @@ def record_soulsync_library_entry(context: Dict[str, Any], artist_context: Dict[
|
|||
f"UPDATE artists SET {artist_source_col} = ? WHERE id = ?",
|
||||
(artist_source_id, artist_id),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("artist source-id update failed: %s", e)
|
||||
|
||||
cursor.execute("SELECT id FROM albums WHERE id = ? AND server_source = 'soulsync'", (album_id,))
|
||||
if not cursor.fetchone():
|
||||
|
|
@ -356,8 +402,8 @@ def record_soulsync_library_entry(context: Dict[str, Any], artist_context: Dict[
|
|||
f"UPDATE albums SET {album_source_col} = ? WHERE id = ?",
|
||||
(album_source_id, album_id),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("album source-id update failed: %s", e)
|
||||
|
||||
track_artist = None
|
||||
track_artists_list = track_info.get("artists", []) or original_search.get("artists", [])
|
||||
|
|
@ -375,9 +421,9 @@ def record_soulsync_library_entry(context: Dict[str, Any], artist_context: Dict[
|
|||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO tracks (id, album_id, artist_id, title, track_number,
|
||||
duration, file_path, bitrate, track_artist, server_source,
|
||||
duration, file_path, bitrate, file_size, track_artist, server_source,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'soulsync', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'soulsync', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
""",
|
||||
(
|
||||
track_id,
|
||||
|
|
@ -388,6 +434,7 @@ def record_soulsync_library_entry(context: Dict[str, Any], artist_context: Dict[
|
|||
duration_ms,
|
||||
final_path,
|
||||
bitrate,
|
||||
file_size,
|
||||
track_artist,
|
||||
),
|
||||
)
|
||||
|
|
@ -404,8 +451,8 @@ def record_soulsync_library_entry(context: Dict[str, Any], artist_context: Dict[
|
|||
f"UPDATE tracks SET {track_album_col} = ? WHERE id = ?",
|
||||
(album_source_id, track_id),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("track source-id update failed: %s", e)
|
||||
|
||||
conn.commit()
|
||||
logger.info("[SoulSync Library] Added: %s / %s / %s", artist_name, album_name, track_name)
|
||||
|
|
|
|||
|
|
@ -380,8 +380,8 @@ class iTunesClient:
|
|||
for raw in cached_results:
|
||||
try:
|
||||
tracks.append(Track.from_itunes_track(raw))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("Track.from_itunes_track cache parse: %s", e)
|
||||
if tracks:
|
||||
return tracks
|
||||
|
||||
|
|
@ -569,8 +569,8 @@ class iTunesClient:
|
|||
for raw in cached_results:
|
||||
try:
|
||||
albums.append(Album.from_itunes_album(raw))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("Album.from_itunes_album cache parse: %s", e)
|
||||
if albums:
|
||||
return albums
|
||||
|
||||
|
|
@ -893,8 +893,8 @@ class iTunesClient:
|
|||
for item in results:
|
||||
if item.get('wrapperType') == 'artist' and item.get('artistName'):
|
||||
return item['artistName']
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("itunes lookup artistId %s: %s", artist_id, e)
|
||||
return None
|
||||
|
||||
def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
|
||||
|
|
@ -911,8 +911,8 @@ class iTunesClient:
|
|||
for raw in cached_results:
|
||||
try:
|
||||
artists.append(Artist.from_itunes_artist(raw))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("Artist.from_itunes_artist cache parse: %s", e)
|
||||
if artists:
|
||||
return artists
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@ import re
|
|||
import threading
|
||||
import time
|
||||
from difflib import SequenceMatcher
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime, timedelta
|
||||
from utils.logging_config import get_logger
|
||||
from database.music_database import MusicDatabase
|
||||
from core.itunes_client import iTunesClient
|
||||
from core.worker_utils import interruptible_sleep, set_album_api_track_count
|
||||
from core.enrichment.manual_match_honoring import honor_stored_match
|
||||
|
||||
logger = get_logger("itunes_worker")
|
||||
|
||||
|
|
@ -142,8 +144,8 @@ class iTunesWorker:
|
|||
itype = item.get('type', '')
|
||||
table = 'artists' if 'artist' in itype else ('albums' if 'album' in itype else 'tracks')
|
||||
# Can't mark status without an ID — just skip
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("null id table resolve failed: %s", e)
|
||||
continue
|
||||
|
||||
self._process_item(item)
|
||||
|
|
@ -526,11 +528,49 @@ class iTunesWorker:
|
|||
|
||||
# ── Individual fallback processing ─────────────────────────────────
|
||||
|
||||
def _refresh_album_via_stored_id(self, album_id, stored_id, api_album_dict):
|
||||
"""Issue #501 callback. Convert ``client.get_album()`` dict into
|
||||
the Album-shaped object ``_update_album`` expects, then call it.
|
||||
Preserves the manual match — never overwrites the stored ID
|
||||
with a different name-search result."""
|
||||
images = api_album_dict.get('images') or []
|
||||
image_url = ''
|
||||
if images and isinstance(images[0], dict):
|
||||
image_url = images[0].get('url', '') or ''
|
||||
adapter = SimpleNamespace(
|
||||
id=api_album_dict.get('id') or stored_id,
|
||||
name=api_album_dict.get('name', ''),
|
||||
image_url=image_url,
|
||||
album_type=api_album_dict.get('album_type', 'album'),
|
||||
release_date=api_album_dict.get('release_date', ''),
|
||||
total_tracks=api_album_dict.get('total_tracks', 0),
|
||||
)
|
||||
self._update_album(album_id, adapter)
|
||||
|
||||
def _refresh_track_via_stored_id(self, track_id, stored_id, api_track_dict):
|
||||
"""Track-level callback — track update only writes ID + status,
|
||||
no metadata backfill, so the dict shape is irrelevant beyond
|
||||
carrying the stored ID through."""
|
||||
adapter = SimpleNamespace(id=api_track_dict.get('id') or stored_id)
|
||||
self._update_track_from_search(track_id, adapter)
|
||||
|
||||
def _process_album_individual(self, item: Dict[str, Any]):
|
||||
album_id = item['id']
|
||||
album_name = item['name']
|
||||
artist_name = item.get('artist', '')
|
||||
|
||||
# Issue #501: honor manual matches (see SpotifyWorker for full
|
||||
# explanation — same pattern across every per-source worker).
|
||||
if honor_stored_match(
|
||||
db=self.db, entity_table='albums', entity_id=album_id,
|
||||
id_column='itunes_album_id',
|
||||
client_fetch_fn=self.client.get_album,
|
||||
on_match_fn=self._refresh_album_via_stored_id,
|
||||
log_prefix='iTunes',
|
||||
):
|
||||
self.stats['matched'] += 1
|
||||
return
|
||||
|
||||
query = f"{artist_name} {album_name}" if artist_name else album_name
|
||||
results = self.client.search_albums(query, limit=5)
|
||||
|
||||
|
|
@ -561,6 +601,17 @@ class iTunesWorker:
|
|||
track_name = item['name']
|
||||
artist_name = item.get('artist', '')
|
||||
|
||||
# Issue #501: honor manual matches.
|
||||
if honor_stored_match(
|
||||
db=self.db, entity_table='tracks', entity_id=track_id,
|
||||
id_column='itunes_track_id',
|
||||
client_fetch_fn=self.client.get_track_details,
|
||||
on_match_fn=self._refresh_track_via_stored_id,
|
||||
log_prefix='iTunes',
|
||||
):
|
||||
self.stats['matched'] += 1
|
||||
return
|
||||
|
||||
query = f"{artist_name} {track_name}" if artist_name else track_name
|
||||
results = self.client.search_tracks(query, limit=5)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,33 +1,19 @@
|
|||
import requests
|
||||
import time
|
||||
from typing import List, Optional, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import json
|
||||
from utils.logging_config import get_logger
|
||||
from config.settings import config_manager
|
||||
|
||||
# Shared dataclasses live in the neutral media_server package — every
|
||||
# server client used to define a near-identical XTrackInfo /
|
||||
# XPlaylistInfo. Lifted to one canonical type so consumers (matching
|
||||
# engine, sync service) get a single import.
|
||||
from core.media_server.types import TrackInfo, PlaylistInfo
|
||||
|
||||
logger = get_logger("jellyfin_client")
|
||||
|
||||
@dataclass
|
||||
class JellyfinTrackInfo:
|
||||
id: str
|
||||
title: str
|
||||
artist: str
|
||||
album: str
|
||||
duration: int
|
||||
track_number: Optional[int] = None
|
||||
year: Optional[int] = None
|
||||
rating: Optional[float] = None
|
||||
|
||||
@dataclass
|
||||
class JellyfinPlaylistInfo:
|
||||
id: str
|
||||
title: str
|
||||
description: Optional[str]
|
||||
duration: int
|
||||
leaf_count: int
|
||||
tracks: List[JellyfinTrackInfo]
|
||||
|
||||
class JellyfinArtist:
|
||||
"""Wrapper class to mimic Plex artist object interface"""
|
||||
|
|
@ -116,9 +102,15 @@ class JellyfinTrack:
|
|||
|
||||
# File path and media info (used by quality scanner and DB update)
|
||||
self.path = jellyfin_data.get('Path')
|
||||
# Extract bitrate from MediaSources if available
|
||||
# Extract bitrate + file size from MediaSources if available.
|
||||
# `file_size` powers the Library Disk Usage card on the Stats
|
||||
# page — populated free during the deep scan from data Jellyfin
|
||||
# already returns in MediaSources[].
|
||||
media_sources = jellyfin_data.get('MediaSources', [])
|
||||
self.bitRate = (media_sources[0].get('Bitrate') or 0) // 1000 if media_sources else None # Convert bps to kbps
|
||||
self.file_size = (media_sources[0].get('Size') or 0) if media_sources else None
|
||||
if self.file_size == 0:
|
||||
self.file_size = None
|
||||
|
||||
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
|
||||
if not date_str:
|
||||
|
|
@ -140,7 +132,10 @@ class JellyfinTrack:
|
|||
return self._client.get_album_by_id(self._album_id)
|
||||
return None
|
||||
|
||||
class JellyfinClient:
|
||||
from core.media_server.contract import MediaServerClient
|
||||
|
||||
|
||||
class JellyfinClient(MediaServerClient):
|
||||
def __init__(self):
|
||||
self.base_url: Optional[str] = None
|
||||
self.api_key: Optional[str] = None
|
||||
|
|
@ -1195,7 +1190,7 @@ class JellyfinClient:
|
|||
|
||||
return stats
|
||||
|
||||
def get_all_playlists(self) -> List[JellyfinPlaylistInfo]:
|
||||
def get_all_playlists(self) -> List[PlaylistInfo]:
|
||||
"""Get all playlists from Jellyfin server"""
|
||||
if not self.ensure_connection():
|
||||
return []
|
||||
|
|
@ -1212,7 +1207,7 @@ class JellyfinClient:
|
|||
|
||||
playlists = []
|
||||
for item in response.get('Items', []):
|
||||
playlist_info = JellyfinPlaylistInfo(
|
||||
playlist_info = PlaylistInfo(
|
||||
id=item.get('Id', ''),
|
||||
title=item.get('Name', 'Unknown Playlist'),
|
||||
description=item.get('Overview'),
|
||||
|
|
@ -1229,7 +1224,7 @@ class JellyfinClient:
|
|||
logger.error(f"Error getting playlists from Jellyfin: {e}")
|
||||
return []
|
||||
|
||||
def get_playlist_by_name(self, name: str) -> Optional[JellyfinPlaylistInfo]:
|
||||
def get_playlist_by_name(self, name: str) -> Optional[PlaylistInfo]:
|
||||
"""Get a specific playlist by name"""
|
||||
playlists = self.get_all_playlists()
|
||||
for playlist in playlists:
|
||||
|
|
@ -1446,8 +1441,8 @@ class JellyfinClient:
|
|||
response = requests.delete(url, headers=headers, timeout=10)
|
||||
if response.status_code in [200, 204]:
|
||||
logger.info(f"Deleted existing backup playlist '{target_name}'")
|
||||
except Exception:
|
||||
pass # Target doesn't exist, which is fine
|
||||
except Exception as e:
|
||||
logger.debug("backup playlist precheck: %s", e)
|
||||
|
||||
# Create new playlist with copied tracks
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -155,8 +155,8 @@ class LastFMWorker:
|
|||
itype = item.get('type', '')
|
||||
table = 'artists' if 'artist' in itype else ('albums' if 'album' in itype else 'tracks')
|
||||
# Can't mark status without an ID — just skip
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("null id table resolve failed: %s", e)
|
||||
continue
|
||||
|
||||
self._process_item(item)
|
||||
|
|
|
|||
|
|
@ -209,8 +209,8 @@ def _run_duplicate_cleaner():
|
|||
'duplicates_found': str(duplicates_found),
|
||||
'space_freed': f"{space_mb:.1f} MB",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("emit duplicate_scan_completed failed: %s", e)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Duplicate Cleaner] Critical error: {e}")
|
||||
|
|
|
|||
173
core/library/path_resolver.py
Normal file
173
core/library/path_resolver.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"""Resolve database-stored file paths to actual files on disk.
|
||||
|
||||
Database track rows store file paths as the media server reported them
|
||||
(`/music/Artist/Album/track.flac`, `H:\\Music\\Artist\\...`, etc). When
|
||||
SoulSync runs in Docker, those paths don't exist as-is inside the
|
||||
container — the user's library is bind-mounted at a container path
|
||||
(commonly `/music`) that has nothing to do with what Plex/Jellyfin
|
||||
recorded. Same problem for native installs that point at a NAS via SMB:
|
||||
the path the media server scanned isn't the path SoulSync reads.
|
||||
|
||||
The resolver tries the raw path first (cheap happy-path), then walks
|
||||
progressively shorter suffixes against every configured base directory:
|
||||
the transfer folder, the slskd download folder, every configured Plex
|
||||
library location, and every entry in the user's `library.music_paths`
|
||||
config. The first existing match wins.
|
||||
|
||||
This module replaces four duplicated copies of the same function (each
|
||||
with the same incomplete logic) that lived in
|
||||
`core/repair_worker.py` and three modules under `core/repair_jobs/`.
|
||||
The duplicates only checked the transfer + download folders and
|
||||
silently returned None for files in the actual media-server library —
|
||||
which is why, for example, the Album Completeness "Auto-Fill" button
|
||||
returned ``Could not determine album folder from existing tracks`` for
|
||||
every Docker user (issue #476).
|
||||
|
||||
The web server has its own near-duplicate at
|
||||
``web_server.py:_resolve_library_file_path`` which already covers the
|
||||
full search space; this module is the lifted, shared version usable
|
||||
from any background worker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
|
||||
logger = get_logger("library.path_resolver")
|
||||
|
||||
|
||||
def _docker_resolve_path(path_str: Any) -> Optional[str]:
|
||||
"""Translate Windows-style paths to the Docker container layout.
|
||||
|
||||
Mirrors ``core/imports/paths.docker_resolve_path`` but kept local to
|
||||
avoid a cross-package import in case this module is consumed early
|
||||
in a job startup. Returns the input unchanged outside Docker.
|
||||
"""
|
||||
if not isinstance(path_str, str):
|
||||
return None
|
||||
if (
|
||||
os.path.exists("/.dockerenv")
|
||||
and len(path_str) >= 3
|
||||
and path_str[1] == ":"
|
||||
and path_str[0].isalpha()
|
||||
):
|
||||
drive_letter = path_str[0].lower()
|
||||
rest = path_str[2:].replace("\\", "/")
|
||||
return f"/host/mnt/{drive_letter}{rest}"
|
||||
return path_str
|
||||
|
||||
|
||||
def _collect_base_dirs(
|
||||
transfer_folder: Optional[str],
|
||||
download_folder: Optional[str],
|
||||
config_manager: Any,
|
||||
plex_client: Any,
|
||||
) -> list[str]:
|
||||
"""Build the ordered list of base directories to probe."""
|
||||
candidates: list[Optional[str]] = []
|
||||
|
||||
if transfer_folder:
|
||||
candidates.append(_docker_resolve_path(transfer_folder))
|
||||
if download_folder:
|
||||
candidates.append(_docker_resolve_path(download_folder))
|
||||
|
||||
if config_manager is not None:
|
||||
try:
|
||||
transfer_cfg = config_manager.get("soulseek.transfer_path", "") or ""
|
||||
download_cfg = config_manager.get("soulseek.download_path", "") or ""
|
||||
if transfer_cfg:
|
||||
candidates.append(_docker_resolve_path(transfer_cfg))
|
||||
if download_cfg:
|
||||
candidates.append(_docker_resolve_path(download_cfg))
|
||||
except Exception as e:
|
||||
logger.debug("soulseek paths read failed: %s", e)
|
||||
|
||||
# Plex-reported library locations (handles "Plex scanned at /music but
|
||||
# SoulSync mounts at /library" cases).
|
||||
if plex_client is not None:
|
||||
try:
|
||||
server = getattr(plex_client, "server", None)
|
||||
music_library = getattr(plex_client, "music_library", None)
|
||||
if server is not None and music_library is not None:
|
||||
for loc in getattr(music_library, "locations", []) or []:
|
||||
if loc:
|
||||
candidates.append(loc)
|
||||
except Exception as e:
|
||||
logger.debug("plex locations read failed: %s", e)
|
||||
|
||||
# User-configured library music paths (Settings → Library → Music Paths).
|
||||
if config_manager is not None:
|
||||
try:
|
||||
music_paths = config_manager.get("library.music_paths", []) or []
|
||||
if isinstance(music_paths, Iterable):
|
||||
for p in music_paths:
|
||||
if isinstance(p, str) and p.strip():
|
||||
candidates.append(_docker_resolve_path(p.strip()))
|
||||
except Exception as e:
|
||||
logger.debug("music paths read failed: %s", e)
|
||||
|
||||
# De-duplicate while preserving order, drop empties / non-existent dirs.
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for c in candidates:
|
||||
if not c or c in seen:
|
||||
continue
|
||||
seen.add(c)
|
||||
if os.path.isdir(c):
|
||||
out.append(c)
|
||||
return out
|
||||
|
||||
|
||||
def resolve_library_file_path(
|
||||
file_path: Any,
|
||||
*,
|
||||
transfer_folder: Optional[str] = None,
|
||||
download_folder: Optional[str] = None,
|
||||
config_manager: Any = None,
|
||||
plex_client: Any = None,
|
||||
) -> Optional[str]:
|
||||
"""Resolve a stored DB path to an actual file on disk.
|
||||
|
||||
Args:
|
||||
file_path: The path as recorded in the database (may not exist
|
||||
as-is in the current process's filesystem view).
|
||||
transfer_folder: Optional explicit transfer-folder override
|
||||
(bypasses the config_manager lookup). Useful when the caller
|
||||
already cached one.
|
||||
download_folder: Optional explicit download-folder override.
|
||||
config_manager: When provided, the resolver also pulls
|
||||
``soulseek.transfer_path``, ``soulseek.download_path``, and
|
||||
``library.music_paths`` from config to expand the search.
|
||||
plex_client: When provided, every Plex-reported music-library
|
||||
location is added to the search.
|
||||
|
||||
Returns:
|
||||
The first existing path on disk, or None when no match is found.
|
||||
Never raises — failure is the None return.
|
||||
"""
|
||||
if not isinstance(file_path, str) or not file_path:
|
||||
return None
|
||||
|
||||
if os.path.exists(file_path):
|
||||
return file_path
|
||||
|
||||
path_parts = file_path.replace("\\", "/").split("/")
|
||||
base_dirs = _collect_base_dirs(transfer_folder, download_folder, config_manager, plex_client)
|
||||
if not base_dirs:
|
||||
return None
|
||||
|
||||
# Skip index 0 to avoid drive-letter / leading-slash artifacts
|
||||
# (e.g. "E:" or "" from a leading "/").
|
||||
for base in base_dirs:
|
||||
for i in range(1, len(path_parts)):
|
||||
candidate = os.path.join(base, *path_parts[i:])
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
__all__ = ["resolve_library_file_path"]
|
||||
|
|
@ -208,7 +208,7 @@ def redownload_start(track_id):
|
|||
# Build a TrackResult-like candidate and submit to download
|
||||
def _run_redownload():
|
||||
try:
|
||||
from core.soulseek_client import TrackResult
|
||||
from core.download_plugins.types import TrackResult
|
||||
from core.itunes_client import Track as MetaTrack
|
||||
tr = TrackResult(
|
||||
username=candidate['username'],
|
||||
|
|
|
|||
|
|
@ -265,8 +265,8 @@ def execute_retag(group_id, album_id, deps: RetagDeps):
|
|||
try:
|
||||
os.remove(old_cover)
|
||||
logger.warning("[Retag] Removed orphaned cover.jpg from old directory")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("remove orphaned cover failed: %s", e)
|
||||
|
||||
# Cleanup old empty directories
|
||||
transfer_dir = deps.docker_resolve_path(deps.config_manager.get('soulseek.transfer_path', './Transfer'))
|
||||
|
|
|
|||
300
core/library/track_identity.py
Normal file
300
core/library/track_identity.py
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
"""Match a metadata-source track against the library by stable external IDs.
|
||||
|
||||
Discord-reported (CAL): the watchlist scanner re-downloaded a track that
|
||||
already existed on disk because the library DB had stale album metadata
|
||||
(track tagged on album "Left Alone" while Spotify reported it as on the
|
||||
"NPC" single). The matching logic relied on title + artist + album fuzzy
|
||||
comparison; the album fuzzy correctly said the names didn't match, the
|
||||
scanner declared the track missing, and the wishlist re-added + re-
|
||||
downloaded it on every scan.
|
||||
|
||||
The track has a stable external identity though — every download embeds
|
||||
Spotify / iTunes / Deezer / Tidal / Qobuz / MusicBrainz / AudioDB /
|
||||
Hydrabase / ISRC IDs as both file tags AND DB columns. This module pulls
|
||||
those IDs off either side and asks: do we already have a row in the
|
||||
``tracks`` table whose external-ID column matches one of the source
|
||||
track's IDs? If yes, the track is NOT missing, regardless of how the
|
||||
album metadata drifted between sources.
|
||||
|
||||
Provider-neutral by design — no spotify-only paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("library.track_identity")
|
||||
|
||||
|
||||
# Maps the conceptual ID name (used in the source-track dict we extract
|
||||
# below) to the column name on the library ``tracks`` table where that
|
||||
# ID is persisted. Keep the column names in sync with the schema in
|
||||
# ``database/music_database.py``.
|
||||
EXTERNAL_ID_COLUMNS: Dict[str, str] = {
|
||||
'spotify_id': 'spotify_track_id',
|
||||
'itunes_id': 'itunes_track_id',
|
||||
'deezer_id': 'deezer_id',
|
||||
'tidal_id': 'tidal_id',
|
||||
'qobuz_id': 'qobuz_id',
|
||||
'mbid': 'musicbrainz_recording_id',
|
||||
'audiodb_id': 'audiodb_id',
|
||||
'soul_id': 'soul_id',
|
||||
'isrc': 'isrc',
|
||||
}
|
||||
|
||||
|
||||
def _coerce(value: Any) -> Optional[str]:
|
||||
"""Return value as a non-empty string, or None for empty / missing."""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _get(track: Any, *names: str) -> Optional[str]:
|
||||
"""Read the first non-empty attribute / dict key from ``names`` off
|
||||
``track``. Accepts both dict-style and dataclass / object tracks."""
|
||||
for name in names:
|
||||
try:
|
||||
value = track[name] if isinstance(track, dict) else getattr(track, name, None)
|
||||
except (TypeError, KeyError):
|
||||
value = None
|
||||
coerced = _coerce(value)
|
||||
if coerced is not None:
|
||||
return coerced
|
||||
return None
|
||||
|
||||
|
||||
def extract_external_ids(track: Any, source_hint: Optional[str] = None) -> Dict[str, str]:
|
||||
"""Pull every recognized external ID off a metadata-source track.
|
||||
|
||||
Handles the source-source naming drift: Spotify tracks expose ``id``
|
||||
as the Spotify track ID; Deezer tracks expose ``id`` as the Deezer
|
||||
track ID; iTunes tracks may use ``trackId`` or ``id``. The disamb-
|
||||
iguating field is ``provider`` / ``source`` / ``_source``. Tracks
|
||||
coming from a SoulSync internal pipeline often carry every known ID
|
||||
set to its source-specific value — we just collect whatever's there.
|
||||
|
||||
``source_hint`` is the caller's known answer to "where did this
|
||||
track dict come from?" — used as a fallback when the track itself
|
||||
doesn't carry a provider / source / _source field. Spotify and
|
||||
iTunes return raw API responses without provider tags, so the
|
||||
watchlist scanner passes ``get_primary_source()`` here to make sure
|
||||
a Spotify-primary scan isn't silently no-opping just because the
|
||||
raw API track has no provider key.
|
||||
|
||||
Returns a dict mapping conceptual ID name → ID value. Keys present
|
||||
in ``EXTERNAL_ID_COLUMNS``. Empty dict when no IDs are available.
|
||||
"""
|
||||
if track is None:
|
||||
return {}
|
||||
|
||||
ids: Dict[str, str] = {}
|
||||
|
||||
# Provider-neutral fields that carry their own name regardless of
|
||||
# source. Most internal SoulSync tracks have these set; external
|
||||
# source responses usually only have one of them populated.
|
||||
direct_id_fields = {
|
||||
'spotify_id': ('spotify_id', 'spotify_track_id', 'SPOTIFY_TRACK_ID'),
|
||||
'itunes_id': ('itunes_id', 'itunes_track_id', 'trackId', 'ITUNES_TRACK_ID'),
|
||||
'deezer_id': ('deezer_id', 'deezer_track_id', 'DEEZER_TRACK_ID'),
|
||||
'tidal_id': ('tidal_id', 'tidal_track_id', 'TIDAL_TRACK_ID'),
|
||||
'qobuz_id': ('qobuz_id', 'qobuz_track_id', 'QOBUZ_TRACK_ID'),
|
||||
'mbid': ('musicbrainz_recording_id', 'mbid', 'MUSICBRAINZ_RECORDING_ID'),
|
||||
'audiodb_id': ('audiodb_id', 'idTrack', 'AUDIODB_TRACK_ID'),
|
||||
'soul_id': ('soul_id', 'SOUL_ID'),
|
||||
'isrc': ('isrc', 'ISRC'),
|
||||
}
|
||||
for name, candidates in direct_id_fields.items():
|
||||
value = _get(track, *candidates)
|
||||
if value:
|
||||
ids[name] = value
|
||||
|
||||
# Provider field tells us which native ``id`` belongs to. Without
|
||||
# this, a Deezer track's ``id`` field would be silently ignored
|
||||
# (we wouldn't know to map it to deezer_id). Convention varies by
|
||||
# client: Spotify-shaped tracks usually have no provider field,
|
||||
# Deezer / Discogs / Hydrabase clients tag tracks with ``_source``,
|
||||
# internal pipeline normalization may use ``source`` or ``provider``.
|
||||
# Fall back to the caller's source_hint when the track has no
|
||||
# provider field of its own (Spotify / iTunes raw API responses).
|
||||
provider = (_get(track, 'provider', 'source', '_source') or source_hint or '').lower()
|
||||
native_id = _get(track, 'id')
|
||||
if native_id and provider:
|
||||
provider_to_key = {
|
||||
'spotify': 'spotify_id',
|
||||
'itunes': 'itunes_id',
|
||||
'deezer': 'deezer_id',
|
||||
'tidal': 'tidal_id',
|
||||
'qobuz': 'qobuz_id',
|
||||
'musicbrainz': 'mbid',
|
||||
'audiodb': 'audiodb_id',
|
||||
'hydrabase': 'soul_id',
|
||||
}
|
||||
key = provider_to_key.get(provider)
|
||||
if key and key not in ids:
|
||||
ids[key] = native_id
|
||||
|
||||
return ids
|
||||
|
||||
|
||||
def find_library_track_by_external_id(
|
||||
db: Any,
|
||||
*,
|
||||
external_ids: Dict[str, str],
|
||||
server_source: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Return a row from the ``tracks`` table whose any external ID
|
||||
column matches one of the provided IDs, or None if no match.
|
||||
|
||||
Returns a sqlite3.Row-like dict so callers can read whatever fields
|
||||
they want (id, title, file_path, etc.). When ``server_source`` is
|
||||
set, restrict matches to tracks scanned from that media server —
|
||||
avoids false positives when a user binds the same DB into multiple
|
||||
profiles/servers.
|
||||
|
||||
Performance: every external_id column is indexed in the schema, so
|
||||
each OR clause hits an index. Limit 1 because we only need to know
|
||||
whether a match exists.
|
||||
"""
|
||||
if not external_ids:
|
||||
return None
|
||||
|
||||
clauses: List[str] = []
|
||||
params: List[Any] = []
|
||||
for id_name, id_value in external_ids.items():
|
||||
column = EXTERNAL_ID_COLUMNS.get(id_name)
|
||||
if not column or not id_value:
|
||||
continue
|
||||
clauses.append(f"({column} = ? AND {column} IS NOT NULL AND {column} != '')")
|
||||
params.append(id_value)
|
||||
|
||||
if not clauses:
|
||||
return None
|
||||
|
||||
where_external = " OR ".join(clauses)
|
||||
|
||||
# Optional server_source filter
|
||||
if server_source:
|
||||
sql = (
|
||||
f"SELECT * FROM tracks WHERE ({where_external}) "
|
||||
f"AND (server_source = ? OR server_source IS NULL) LIMIT 1"
|
||||
)
|
||||
params.append(server_source)
|
||||
else:
|
||||
sql = f"SELECT * FROM tracks WHERE ({where_external}) LIMIT 1"
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(sql, params)
|
||||
row = cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
# sqlite3.Row supports keys() — return as dict for caller stability.
|
||||
try:
|
||||
return dict(row)
|
||||
except (TypeError, ValueError):
|
||||
# Fallback for cursors that don't return Row objects.
|
||||
cols = [c[0] for c in cursor.description]
|
||||
return dict(zip(cols, row, strict=False))
|
||||
except Exception as exc:
|
||||
logger.debug(f"find_library_track_by_external_id query failed: {exc}")
|
||||
return None
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down
|
||||
pass
|
||||
|
||||
|
||||
# Maps the conceptual ID name to the column on the ``track_downloads``
|
||||
# (provenance) table where SoulSync persists the IDs at download time.
|
||||
# Naming convention differs from ``tracks``: provenance uses the
|
||||
# explicit ``_track_id`` suffix to match the existing column shape.
|
||||
PROVENANCE_ID_COLUMNS: Dict[str, str] = {
|
||||
'spotify_id': 'spotify_track_id',
|
||||
'itunes_id': 'itunes_track_id',
|
||||
'deezer_id': 'deezer_track_id',
|
||||
'tidal_id': 'tidal_track_id',
|
||||
'qobuz_id': 'qobuz_track_id',
|
||||
'mbid': 'musicbrainz_recording_id',
|
||||
'audiodb_id': 'audiodb_id',
|
||||
'soul_id': 'soul_id',
|
||||
'isrc': 'isrc',
|
||||
}
|
||||
|
||||
|
||||
def find_provenance_by_external_id(
|
||||
db: Any,
|
||||
*,
|
||||
external_ids: Dict[str, str],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Return a row from the ``track_downloads`` table whose any external
|
||||
ID column matches one of the provided IDs, or None.
|
||||
|
||||
Used as a second-tier fallback by the watchlist scanner: when the
|
||||
primary library tracks-table lookup misses (e.g. the row exists but
|
||||
the enrichment worker hasn't backfilled its IDs yet, or the row
|
||||
doesn't exist yet because the media-server scan hasn't run since the
|
||||
download), this checks whether SoulSync downloaded the file recently
|
||||
enough that the IDs are sitting in the provenance table.
|
||||
|
||||
Caller should typically also confirm the ``file_path`` on the
|
||||
returned row still exists on disk before treating the track as
|
||||
"already in library" — otherwise a deleted file would prevent
|
||||
re-download.
|
||||
"""
|
||||
if not external_ids:
|
||||
return None
|
||||
|
||||
clauses: List[str] = []
|
||||
params: List[Any] = []
|
||||
for id_name, id_value in external_ids.items():
|
||||
column = PROVENANCE_ID_COLUMNS.get(id_name)
|
||||
if not column or not id_value:
|
||||
continue
|
||||
clauses.append(f"({column} = ? AND {column} IS NOT NULL AND {column} != '')")
|
||||
params.append(id_value)
|
||||
|
||||
if not clauses:
|
||||
return None
|
||||
|
||||
where_external = " OR ".join(clauses)
|
||||
sql = f"SELECT * FROM track_downloads WHERE ({where_external}) ORDER BY id DESC LIMIT 1"
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(sql, params)
|
||||
row = cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
try:
|
||||
return dict(row)
|
||||
except (TypeError, ValueError):
|
||||
cols = [c[0] for c in cursor.description]
|
||||
return dict(zip(cols, row, strict=False))
|
||||
except Exception as exc:
|
||||
logger.debug(f"find_provenance_by_external_id query failed: {exc}")
|
||||
return None
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down
|
||||
pass
|
||||
|
||||
|
||||
__all__ = [
|
||||
'EXTERNAL_ID_COLUMNS',
|
||||
'PROVENANCE_ID_COLUMNS',
|
||||
'extract_external_ids',
|
||||
'find_library_track_by_external_id',
|
||||
'find_provenance_by_external_id',
|
||||
]
|
||||
|
|
@ -506,7 +506,7 @@ def load_album_and_tracks(db, album_id):
|
|||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down
|
||||
pass
|
||||
|
||||
|
||||
|
|
@ -938,8 +938,8 @@ class _RunContext:
|
|||
return
|
||||
try:
|
||||
self.on_progress(updates)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("progress emit failed: %s", e)
|
||||
|
||||
def record_error(self, track_id, title, message, kind: str = 'skipped') -> None:
|
||||
with self.state_lock:
|
||||
|
|
@ -1185,8 +1185,8 @@ def reorganize_album(
|
|||
return
|
||||
try:
|
||||
on_progress(updates)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("reorganize progress callback failed: %s", e)
|
||||
|
||||
# Load album + tracks
|
||||
album_data, tracks = load_album_and_tracks(db, album_id)
|
||||
|
|
@ -1329,7 +1329,7 @@ def reorganize_album(
|
|||
try:
|
||||
if os.path.isdir(staging_album_dir):
|
||||
shutil.rmtree(staging_album_dir, ignore_errors=True)
|
||||
except Exception:
|
||||
except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down
|
||||
pass
|
||||
|
||||
# Best-effort cleanup of source directories. For each touched dir
|
||||
|
|
@ -1343,14 +1343,14 @@ def reorganize_album(
|
|||
if _has_remaining_audio(src_dir):
|
||||
continue
|
||||
_delete_album_sidecars(src_dir)
|
||||
except Exception:
|
||||
except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down
|
||||
pass
|
||||
|
||||
if cleanup_empty_dir_fn:
|
||||
for src_dir in src_dirs_touched:
|
||||
try:
|
||||
cleanup_empty_dir_fn(src_dir)
|
||||
except Exception:
|
||||
except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down
|
||||
pass
|
||||
|
||||
# Prune empty *destination* siblings — e.g. when a previous
|
||||
|
|
|
|||
|
|
@ -28,12 +28,15 @@ from utils.logging_config import get_logger
|
|||
from config.settings import config_manager
|
||||
|
||||
# Import Soulseek data structures for drop-in replacement compatibility
|
||||
from core.soulseek_client import TrackResult, AlbumResult, DownloadStatus
|
||||
from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus
|
||||
|
||||
logger = get_logger("lidarr_client")
|
||||
|
||||
|
||||
class LidarrDownloadClient:
|
||||
from core.download_plugins.base import DownloadSourcePlugin
|
||||
|
||||
|
||||
class LidarrDownloadClient(DownloadSourcePlugin):
|
||||
"""Lidarr download client — uses Lidarr as a download source for Usenet/torrent content.
|
||||
|
||||
Implements the same interface as SoulseekClient, QobuzClient, TidalDownloadClient
|
||||
|
|
@ -277,11 +280,12 @@ class LidarrDownloadClient:
|
|||
root_folder = self._get_root_folder()
|
||||
quality_profile_id = self._get_quality_profile_id()
|
||||
|
||||
metadata_profile_id = self._get_metadata_profile_id()
|
||||
add_artist = {
|
||||
'foreignArtistId': artist_data.get('foreignArtistId', ''),
|
||||
'artistName': artist_data.get('artistName', ''),
|
||||
'qualityProfileId': quality_profile_id,
|
||||
'metadataProfileId': 1,
|
||||
'metadataProfileId': metadata_profile_id,
|
||||
'rootFolderPath': root_folder,
|
||||
'monitored': False,
|
||||
'addOptions': {'monitor': 'none', 'searchForMissingAlbums': False},
|
||||
|
|
@ -331,8 +335,15 @@ class LidarrDownloadClient:
|
|||
self._set_error(download_id, f'Failed to trigger download: {e}')
|
||||
return
|
||||
|
||||
# Step 4: Poll queue for progress
|
||||
# Step 4: Poll until Lidarr reports the album has imported files.
|
||||
#
|
||||
# Old approach used `for/else` with `break` from the inner queue
|
||||
# loop, but inner-break only escaped the queue iteration — the
|
||||
# outer poll loop kept spinning even after we'd detected
|
||||
# completion. Replaced with an explicit `download_complete` flag
|
||||
# that breaks the OUTER loop once trackFileCount > 0.
|
||||
max_polls = 600 # 10 minutes max
|
||||
download_complete = False
|
||||
for poll in range(max_polls):
|
||||
if self.shutdown_check and self.shutdown_check():
|
||||
self._set_error(download_id, 'Server shutting down')
|
||||
|
|
@ -350,72 +361,125 @@ class LidarrDownloadClient:
|
|||
for item in queue['records']:
|
||||
item_album = item.get('album', {})
|
||||
if item_album.get('foreignAlbumId') == album.get('foreignAlbumId', ''):
|
||||
# Found our download in the queue
|
||||
# Surface progress while still downloading.
|
||||
status = item.get('status', '').lower()
|
||||
progress = 100.0 - (item.get('sizeleft', 0) / max(item.get('size', 1), 1) * 100)
|
||||
size_left = item.get('sizeleft', 0)
|
||||
size_total = max(item.get('size', 1), 1)
|
||||
progress = 100.0 - (size_left / size_total * 100)
|
||||
|
||||
with self._download_lock:
|
||||
self.active_downloads[download_id]['progress'] = min(progress, 95.0)
|
||||
|
||||
if status in ('completed', 'imported'):
|
||||
break
|
||||
elif status in ('failed', 'warning'):
|
||||
if status in ('failed', 'warning'):
|
||||
self._set_error(download_id, f'Lidarr download failed: {status}')
|
||||
return
|
||||
# 'completed' / 'imported' in the queue is
|
||||
# transient — Lidarr drops the item once
|
||||
# import finishes. Don't break here; let the
|
||||
# trackFileCount check below decide.
|
||||
|
||||
else:
|
||||
# Not in queue — might be completed already
|
||||
# Check if album has files
|
||||
if poll > 10: # Give it at least 10 seconds
|
||||
album_check = self._api_get(f'album/{lidarr_album_id}')
|
||||
if album_check and album_check.get('statistics', {}).get('trackFileCount', 0) > 0:
|
||||
break
|
||||
else:
|
||||
# No queue data — check if completed
|
||||
if poll > 10:
|
||||
album_check = self._api_get(f'album/{lidarr_album_id}')
|
||||
if album_check and album_check.get('statistics', {}).get('trackFileCount', 0) > 0:
|
||||
break
|
||||
# Authoritative completion signal: album has imported
|
||||
# files. Cheap to call (single GET on a known id) and
|
||||
# works even when the queue record disappeared between
|
||||
# polls.
|
||||
if poll > 5: # Give Lidarr a few seconds to start
|
||||
album_check = self._api_get(f'album/{lidarr_album_id}')
|
||||
if (album_check
|
||||
and album_check.get('statistics', {}).get('trackFileCount', 0) > 0):
|
||||
download_complete = True
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Queue poll error: {e}")
|
||||
|
||||
time.sleep(1)
|
||||
else:
|
||||
|
||||
if not download_complete:
|
||||
self._set_error(download_id, 'Download timed out')
|
||||
return
|
||||
|
||||
# Step 5: Find and import downloaded files
|
||||
# Step 5: Find and import the wanted track.
|
||||
#
|
||||
# Lidarr grabs whole albums; SoulSync's matched-context
|
||||
# post-processing wants the SPECIFIC track the user
|
||||
# requested. Old behavior copied every track in the album
|
||||
# and reported `imported_files[0]` as `file_path` — which
|
||||
# almost always pointed to track 1, not the user's actual
|
||||
# track. Post-processing then tagged track 1 with the
|
||||
# requested track's metadata. Misfiling guaranteed.
|
||||
#
|
||||
# New behavior: identify the wanted track by title (parsed
|
||||
# from display_name), look up its trackFile via Lidarr's
|
||||
# `track` API, copy ONLY that file. For album-level
|
||||
# dispatches (no specific track in display_name), fall back
|
||||
# to copying the first imported file so existing
|
||||
# album-grab UX still works.
|
||||
with self._download_lock:
|
||||
self.active_downloads[download_id]['progress'] = 96.0
|
||||
|
||||
try:
|
||||
# Get track files from Lidarr
|
||||
track_files = self._api_get('trackfile', params={'albumId': lidarr_album_id})
|
||||
if not track_files:
|
||||
self._set_error(download_id, 'No files found after download')
|
||||
return
|
||||
wanted_title = self._extract_wanted_track_title(display_name)
|
||||
wanted_src = self._pick_track_file_for_wanted(lidarr_album_id, wanted_title)
|
||||
|
||||
# Copy files to SoulSync's download path
|
||||
imported_files = []
|
||||
for tf in track_files:
|
||||
src_path = tf.get('path', '')
|
||||
if src_path and os.path.exists(src_path):
|
||||
dst_path = os.path.join(str(self.download_path), os.path.basename(src_path))
|
||||
try:
|
||||
shutil.copy2(src_path, dst_path)
|
||||
imported_files.append(dst_path)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to copy {src_path}: {e}")
|
||||
if wanted_src:
|
||||
# Copy ONLY the matched track. Other album files stay
|
||||
# in Lidarr's root folder and will be cleaned up by
|
||||
# the cleanup step (Step 6) when configured.
|
||||
dst_path = os.path.join(str(self.download_path),
|
||||
os.path.basename(wanted_src))
|
||||
try:
|
||||
shutil.copy2(wanted_src, dst_path)
|
||||
except Exception as e:
|
||||
self._set_error(download_id, f'Failed to copy wanted track: {e}')
|
||||
return
|
||||
|
||||
if imported_files:
|
||||
with self._download_lock:
|
||||
self.active_downloads[download_id]['state'] = 'Completed, Succeeded'
|
||||
self.active_downloads[download_id]['progress'] = 100.0
|
||||
self.active_downloads[download_id]['file_path'] = imported_files[0]
|
||||
logger.info(f"Lidarr download complete: {display_name} ({len(imported_files)} files)")
|
||||
self.active_downloads[download_id]['file_path'] = dst_path
|
||||
logger.info(
|
||||
f"Lidarr download complete: {display_name} "
|
||||
f"-> {os.path.basename(dst_path)}"
|
||||
)
|
||||
else:
|
||||
self._set_error(download_id, 'Failed to import files')
|
||||
# No specific track wanted (album dispatch) OR fuzzy
|
||||
# match failed. Fall back to copying the first imported
|
||||
# file so something always lands on disk; album-level
|
||||
# callers still get a usable file_path.
|
||||
track_files = self._api_get('trackfile', params={'albumId': lidarr_album_id})
|
||||
if not track_files:
|
||||
self._set_error(download_id, 'No files found after download')
|
||||
return
|
||||
|
||||
imported_files = []
|
||||
for tf in track_files:
|
||||
src_path = tf.get('path', '')
|
||||
if src_path and os.path.exists(src_path):
|
||||
dst_path = os.path.join(str(self.download_path),
|
||||
os.path.basename(src_path))
|
||||
try:
|
||||
shutil.copy2(src_path, dst_path)
|
||||
imported_files.append(dst_path)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to copy {src_path}: {e}")
|
||||
|
||||
if imported_files:
|
||||
with self._download_lock:
|
||||
self.active_downloads[download_id]['state'] = 'Completed, Succeeded'
|
||||
self.active_downloads[download_id]['progress'] = 100.0
|
||||
self.active_downloads[download_id]['file_path'] = imported_files[0]
|
||||
if wanted_title:
|
||||
logger.warning(
|
||||
f"Lidarr: wanted track '{wanted_title}' not matched in album "
|
||||
f"— falling back to first imported file ({len(imported_files)} total)"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Lidarr album-level download complete: {display_name} "
|
||||
f"({len(imported_files)} files)"
|
||||
)
|
||||
else:
|
||||
self._set_error(download_id, 'Failed to import files')
|
||||
|
||||
except Exception as e:
|
||||
self._set_error(download_id, f'Import failed: {e}')
|
||||
|
|
@ -426,8 +490,8 @@ class LidarrDownloadClient:
|
|||
try:
|
||||
self._api_delete(f'album/{lidarr_album_id}', params={'deleteFiles': 'false'})
|
||||
logger.debug(f"Cleaned up album {lidarr_album_id} from Lidarr")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("Lidarr album cleanup failed: %s", e)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Lidarr download thread failed: {e}")
|
||||
|
|
@ -440,6 +504,102 @@ class LidarrDownloadClient:
|
|||
self.active_downloads[download_id]['error'] = error
|
||||
logger.error(f"Lidarr download error: {error}")
|
||||
|
||||
@staticmethod
|
||||
def _extract_wanted_track_title(display_name: str) -> str:
|
||||
"""Pull the track title out of the dispatch display string.
|
||||
|
||||
``_search_sync`` builds two display shapes:
|
||||
- Track dispatch: ``f"{artist} - {album} - {track_title}"``
|
||||
- Album dispatch: ``f"{artist} - {album}"``
|
||||
|
||||
Need >=3 parts to confidently identify a track. 2-part strings
|
||||
are album-level dispatches — return empty so the caller falls
|
||||
back to copying the first file (correct behavior for "give me
|
||||
the whole album"). Track titles that themselves contain ``' - '``
|
||||
(e.g. live versions) get rejoined from parts[2:].
|
||||
"""
|
||||
if not display_name:
|
||||
return ''
|
||||
parts = display_name.split(' - ')
|
||||
if len(parts) < 3:
|
||||
return ''
|
||||
return ' - '.join(parts[2:]).strip()
|
||||
|
||||
def _pick_track_file_for_wanted(self, lidarr_album_id: int,
|
||||
wanted_title: str) -> Optional[str]:
|
||||
"""Find the on-disk path of the imported file matching the wanted track.
|
||||
|
||||
Walks Lidarr's `track` API to map track titles → trackFileIds,
|
||||
then resolves the trackFileId to a path via `trackfile`. Returns
|
||||
None when the album has no usable wanted-track match (caller
|
||||
falls back to the first imported file in that case so
|
||||
album-level dispatches still work).
|
||||
"""
|
||||
if not wanted_title:
|
||||
return None
|
||||
|
||||
tracks = self._api_get('track', params={'albumId': lidarr_album_id})
|
||||
if not tracks or not isinstance(tracks, list):
|
||||
return None
|
||||
|
||||
# Normalize for case-insensitive fuzzy match. Lidarr's track titles
|
||||
# come from MusicBrainz so they're usually canonical, but
|
||||
# punctuation / casing varies.
|
||||
wanted_norm = self._normalize_for_match(wanted_title)
|
||||
best_track_file_id: Optional[int] = None
|
||||
best_score = 0.0
|
||||
for t in tracks:
|
||||
track_title = t.get('title', '') or ''
|
||||
track_file_id = t.get('trackFileId')
|
||||
if not track_file_id:
|
||||
continue
|
||||
score = self._title_similarity(wanted_norm,
|
||||
self._normalize_for_match(track_title))
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_track_file_id = track_file_id
|
||||
|
||||
# 0.7 threshold avoids picking the wrong track when none match
|
||||
# well — caller falls back to first-imported behavior in that case.
|
||||
if best_score < 0.7 or best_track_file_id is None:
|
||||
return None
|
||||
|
||||
# Resolve trackFileId → path. /trackfile/{id} returns one record.
|
||||
tf = self._api_get(f'trackfile/{best_track_file_id}')
|
||||
if not tf:
|
||||
return None
|
||||
path = tf.get('path', '')
|
||||
if path and os.path.exists(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_for_match(s: str) -> str:
|
||||
"""Lower + strip punctuation + collapse whitespace for fuzzy compare."""
|
||||
if not s:
|
||||
return ''
|
||||
cleaned = re.sub(r'[^\w\s]', '', s.lower())
|
||||
return ' '.join(cleaned.split())
|
||||
|
||||
@staticmethod
|
||||
def _title_similarity(a: str, b: str) -> float:
|
||||
"""Cheap title similarity: equal → 1.0, substring → 0.85,
|
||||
token overlap ratio otherwise. Avoids pulling SequenceMatcher
|
||||
for every comparison since this runs in the hot download path."""
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
if a == b:
|
||||
return 1.0
|
||||
if a in b or b in a:
|
||||
return 0.85
|
||||
a_tokens = set(a.split())
|
||||
b_tokens = set(b.split())
|
||||
if not a_tokens or not b_tokens:
|
||||
return 0.0
|
||||
intersection = a_tokens & b_tokens
|
||||
union = a_tokens | b_tokens
|
||||
return len(intersection) / len(union) if union else 0.0
|
||||
|
||||
async def get_all_downloads(self) -> List[DownloadStatus]:
|
||||
with self._download_lock:
|
||||
return [self._to_status(dl) for dl in self.active_downloads.values()]
|
||||
|
|
@ -536,3 +696,22 @@ class LidarrDownloadClient:
|
|||
if p.get('name', '').lower() == self._quality_profile.lower():
|
||||
return p['id']
|
||||
return profiles[0].get('id', 1) if profiles else 1
|
||||
|
||||
def _get_metadata_profile_id(self) -> int:
|
||||
"""Resolve a usable metadataProfileId for adding artists.
|
||||
|
||||
Lidarr requires `metadataProfileId` when creating artist records.
|
||||
The default profile is usually id=1, but on installs where the
|
||||
user deleted/recreated profiles, that id may not exist — leading
|
||||
to the API rejecting the artist-add with a 400. Fetch live to
|
||||
pick whatever's actually configured. Falls back to 1 only when
|
||||
the API call fails entirely (preserves previous behavior so this
|
||||
change can't make things worse).
|
||||
"""
|
||||
profiles = self._api_get('metadataprofile')
|
||||
if profiles and isinstance(profiles, list):
|
||||
for p in profiles:
|
||||
pid = p.get('id')
|
||||
if isinstance(pid, int):
|
||||
return pid
|
||||
return 1
|
||||
|
|
|
|||
|
|
@ -302,8 +302,8 @@ class ListenBrainzManager:
|
|||
# Fallback to first image
|
||||
if images:
|
||||
return images[0].get('thumbnails', {}).get('small') or images[0].get('image')
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("cover-art fetch: %s", e)
|
||||
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -19,13 +19,17 @@ logger = get_logger("listening_stats_worker")
|
|||
class ListeningStatsWorker:
|
||||
"""Background worker that polls media servers for play data."""
|
||||
|
||||
def __init__(self, database, config_manager, plex_client=None,
|
||||
jellyfin_client=None, navidrome_client=None):
|
||||
def __init__(self, database, config_manager, media_server_engine=None):
|
||||
"""Initialize the worker.
|
||||
|
||||
``media_server_engine`` owns the per-server clients (Plex /
|
||||
Jellyfin / Navidrome). The worker resolves the active server's
|
||||
client through ``self._engine.client(name)`` instead of holding
|
||||
per-server kwargs.
|
||||
"""
|
||||
self.db = database
|
||||
self.config_manager = config_manager
|
||||
self.plex_client = plex_client
|
||||
self.jellyfin_client = jellyfin_client
|
||||
self.navidrome_client = navidrome_client
|
||||
self._engine = media_server_engine
|
||||
|
||||
# Worker state
|
||||
self.running = False
|
||||
|
|
@ -145,13 +149,11 @@ class ListeningStatsWorker:
|
|||
logger.info(f"Polling {active_server} for listening data...")
|
||||
self.current_item = f"Polling {active_server}..."
|
||||
|
||||
client = None
|
||||
if active_server == 'plex' and self.plex_client:
|
||||
client = self.plex_client
|
||||
elif active_server == 'jellyfin' and self.jellyfin_client:
|
||||
client = self.jellyfin_client
|
||||
elif active_server == 'navidrome' and self.navidrome_client:
|
||||
client = self.navidrome_client
|
||||
client = self._engine.client(active_server) if self._engine else None
|
||||
# SoulSync standalone has no listening data; only the three
|
||||
# streaming servers contribute. Mirror the legacy guard here.
|
||||
if active_server not in ('plex', 'jellyfin', 'navidrome'):
|
||||
client = None
|
||||
|
||||
if not client:
|
||||
logger.warning(f"No client available for active server: {active_server}")
|
||||
|
|
@ -505,7 +507,7 @@ class ListeningStatsWorker:
|
|||
if conn:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down
|
||||
pass
|
||||
|
||||
def _resolve_db_track_id(self, title, artist):
|
||||
|
|
|
|||
|
|
@ -7,8 +7,11 @@ from utils.logging_config import get_logger
|
|||
from config.settings import config_manager
|
||||
|
||||
from core.spotify_client import Track as SpotifyTrack
|
||||
from core.plex_client import PlexTrackInfo
|
||||
from core.soulseek_client import TrackResult, AlbumResult
|
||||
from core.media_server.types import TrackInfo
|
||||
# TrackResult / AlbumResult moved out of core.soulseek_client into the
|
||||
# neutral download_plugins package (download PR's Gap 1 lift). Import
|
||||
# from the new location.
|
||||
from core.download_plugins.types import TrackResult, AlbumResult
|
||||
|
||||
|
||||
logger = get_logger("matching_engine")
|
||||
|
|
@ -16,7 +19,7 @@ logger = get_logger("matching_engine")
|
|||
@dataclass
|
||||
class MatchResult:
|
||||
spotify_track: SpotifyTrack
|
||||
plex_track: Optional[PlexTrackInfo]
|
||||
plex_track: Optional[TrackInfo]
|
||||
confidence: float
|
||||
match_type: str
|
||||
|
||||
|
|
@ -302,7 +305,7 @@ class MusicMatchingEngine:
|
|||
confidence = (title_score * 0.60) + (artist_score * 0.30) + (duration_score * 0.10)
|
||||
return confidence, "standard_match"
|
||||
|
||||
def calculate_match_confidence(self, spotify_track: SpotifyTrack, plex_track: PlexTrackInfo) -> Tuple[float, str]:
|
||||
def calculate_match_confidence(self, spotify_track: SpotifyTrack, plex_track: TrackInfo) -> Tuple[float, str]:
|
||||
"""Calculates a confidence score using a prioritized model, starting with a strict 'core' title check."""
|
||||
return self.score_track_match(
|
||||
source_title=spotify_track.name,
|
||||
|
|
@ -313,7 +316,7 @@ class MusicMatchingEngine:
|
|||
candidate_duration_ms=plex_track.duration if plex_track.duration else 0
|
||||
)
|
||||
|
||||
def find_best_match(self, spotify_track: SpotifyTrack, plex_tracks: List[PlexTrackInfo]) -> MatchResult:
|
||||
def find_best_match(self, spotify_track: SpotifyTrack, plex_tracks: List[TrackInfo]) -> MatchResult:
|
||||
"""Finds the best Plex track match from a list of candidates."""
|
||||
best_match = None
|
||||
best_confidence = 0.0
|
||||
|
|
|
|||
39
core/media_server/__init__.py
Normal file
39
core/media_server/__init__.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""Media server engine — central registry-backed access to the
|
||||
per-server clients (Plex, Jellyfin, Navidrome, SoulSync standalone).
|
||||
|
||||
Companion to the download engine refactor — same architectural
|
||||
shape applied to the read-side of the library. Pre-refactor
|
||||
web_server.py held four separate per-server globals
|
||||
(``plex_client`` / ``jellyfin_client`` / ``navidrome_client`` /
|
||||
``soulsync_library_client``) that every dispatch site reached
|
||||
individually. This package replaces those globals with a single
|
||||
engine that owns the client instances + a generic
|
||||
``engine.client(name)`` accessor.
|
||||
|
||||
The 18-or-so ``if active_server == 'plex' / 'jellyfin' / ...``
|
||||
chains in web_server.py that do server-specific work (Plex raw
|
||||
playlist API vs Jellyfin / Navidrome client methods returning
|
||||
different shapes) stay explicit at the call site per the "lift
|
||||
what's truly shared" standard — but they reach the per-server
|
||||
client through ``engine.client(name)`` rather than the legacy
|
||||
globals. The four uniform-shape ``is_connected`` chains were the
|
||||
only ones genuinely shared and are now ``engine.is_connected()``.
|
||||
|
||||
See ``docs/media-server-engine-refactor-plan.md`` for the full
|
||||
phased plan.
|
||||
|
||||
Note: only ``MediaServerClient`` is re-exported here. The engine +
|
||||
registry are NOT — importing the registry triggers eager imports
|
||||
of every per-server client class, and those clients now inherit
|
||||
``MediaServerClient`` (Cin-1), so re-exporting them here would
|
||||
form a circular import the moment a client tried to resolve its
|
||||
base class. Import them directly from their submodules:
|
||||
from core.media_server.engine import MediaServerEngine
|
||||
from core.media_server.registry import build_default_registry
|
||||
"""
|
||||
|
||||
from core.media_server.contract import MediaServerClient
|
||||
|
||||
__all__ = [
|
||||
"MediaServerClient",
|
||||
]
|
||||
110
core/media_server/contract.py
Normal file
110
core/media_server/contract.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"""Canonical contract for media server clients.
|
||||
|
||||
Narrow on purpose. Protocol body declares ONLY the methods every
|
||||
registered client actually implements today — keeps the static
|
||||
contract honest. Server-specific extras (Plex's
|
||||
``set_music_library_by_name``, Jellyfin's user picker, Navidrome's
|
||||
music folder filter, SoulSync's filesystem rescan) and methods that
|
||||
most-but-not-all servers implement (``search_tracks`` on Plex /
|
||||
Navidrome but not Jellyfin; ``get_recently_added_albums`` on
|
||||
Jellyfin / Navidrome / SoulSync but not Plex) stay off the Protocol
|
||||
and are reached through ``engine.client(name)`` directly.
|
||||
|
||||
The contract is a Protocol (structural typing) rather than an ABC —
|
||||
existing PlexClient / JellyfinClient / NavidromeClient /
|
||||
SoulSyncClient grew the same shape independently because every
|
||||
caller needed the same four calls. This file just makes that
|
||||
implicit contract explicit + the conformance test pins it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class MediaServerClient(Protocol):
|
||||
"""Structural contract every media server client must satisfy.
|
||||
|
||||
``runtime_checkable`` lets ``isinstance(client, MediaServerClient)``
|
||||
work, but it ONLY checks method names — not signatures. The
|
||||
conformance test in ``tests/media_server/test_conformance.py``
|
||||
does the deeper class-level check via REQUIRED_METHODS.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection / lifecycle — required, every server implements
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Cheap probe — does the client have a live connection /
|
||||
token / session right now? Used by the dashboard status
|
||||
indicators + endpoint guards."""
|
||||
...
|
||||
|
||||
def ensure_connection(self) -> bool:
|
||||
"""Re-auth or reconnect if needed. May make a network call.
|
||||
Returns True if connection is usable after the call."""
|
||||
...
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Library reads — required, every server implements
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_all_artists(self) -> List[Any]:
|
||||
"""Return every artist the server knows about. Each item is
|
||||
a server-specific wrapper object (PlexArtist, JellyfinArtist,
|
||||
NavidromeArtist, SoulSyncArtist) — caller treats them
|
||||
opaquely."""
|
||||
...
|
||||
|
||||
def get_all_album_ids(self) -> set:
|
||||
"""Return the set of every album ID in the library. ID
|
||||
format is server-native — caller doesn't introspect."""
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Required method set — pinned by the conformance test. Mirrors the
|
||||
# Protocol body exactly so static + runtime contracts can't drift.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
REQUIRED_METHODS = {
|
||||
'is_connected',
|
||||
'ensure_connection',
|
||||
'get_all_artists',
|
||||
'get_all_album_ids',
|
||||
}
|
||||
|
||||
# Methods that exist on SOME servers but NOT all, listed here for
|
||||
# discoverability. The conformance test does NOT enforce these. Callers
|
||||
# that need one reach the per-server client directly via
|
||||
# ``engine.client(name).<method>`` rather than going through the engine,
|
||||
# since the engine has no uniform safe-default that fits every method.
|
||||
#
|
||||
# Coverage today (audited 2026-05):
|
||||
# search_tracks: Plex ✓, Navidrome ✓, Jellyfin ✗, SoulSync ✗
|
||||
# get_recently_added_albums: Jellyfin ✓, Navidrome ✓, SoulSync ✓, Plex ✗ (uses recentlyAdded() on music library)
|
||||
# trigger_library_scan / is_library_scanning: Plex ✓, Jellyfin ✓, Navidrome ✓, SoulSync ✗ (filesystem walks in-process)
|
||||
# get_library_stats: Plex ✓, Jellyfin ✓, Navidrome ✓, SoulSync ✗
|
||||
# create_playlist / update_playlist / get_all_playlists / etc: Plex ✓, Jellyfin ✓, Navidrome ✓, SoulSync ✗
|
||||
# update_artist_*, update_album_poster, update_track_metadata: Plex ✓, Jellyfin partial, Navidrome stubs, SoulSync ✗
|
||||
KNOWN_PER_SERVER_METHODS = (
|
||||
'search_tracks',
|
||||
'get_recently_added_albums',
|
||||
'trigger_library_scan',
|
||||
'is_library_scanning',
|
||||
'get_library_stats',
|
||||
'create_playlist',
|
||||
'update_playlist',
|
||||
'copy_playlist',
|
||||
'get_all_playlists',
|
||||
'get_playlist_by_name',
|
||||
'get_play_history',
|
||||
'get_track_play_counts',
|
||||
'update_artist_genres',
|
||||
'update_artist_poster',
|
||||
'update_album_poster',
|
||||
'update_artist_biography',
|
||||
'update_track_metadata',
|
||||
)
|
||||
209
core/media_server/engine.py
Normal file
209
core/media_server/engine.py
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
"""MediaServerEngine — central registry-backed access to media server clients.
|
||||
|
||||
Honest scope: the engine OWNS the per-server client instances and
|
||||
exposes a small set of generic accessors so callers don't need
|
||||
per-server attribute reaches. Most actual cross-server dispatch in
|
||||
web_server.py (playlist add / remove / replace, per-server metadata
|
||||
sync, deep scan with server-specific cache strategies) is genuinely
|
||||
different per server and stays explicit in the call site — the
|
||||
engine just provides the canonical client lookup so those sites
|
||||
reach via ``engine.client(name)`` instead of separate globals.
|
||||
|
||||
Surface:
|
||||
- ``client(name)`` / ``active_client()`` — name → client lookup
|
||||
- ``active_server`` — config-driven active server name
|
||||
- ``is_connected()`` — only cross-server dispatch with real callers
|
||||
today (dashboard status indicators); kept as the canonical example
|
||||
- ``configured_clients()`` — replaces the legacy per-server
|
||||
``if X and X.is_connected()`` chains in web_server.py
|
||||
- ``reload_config(name=None)`` — generic dispatch instead of
|
||||
per-client reload calls
|
||||
|
||||
Per-method engine wrappers for ``get_all_artists`` / ``search_tracks``
|
||||
/ ``trigger_library_scan`` / etc. were on an earlier draft but had no
|
||||
production callers — every consumer reaches the active client directly
|
||||
through ``sync_service._get_active_media_client()`` or
|
||||
``engine.client(name)`` and calls the per-server method itself. Cut
|
||||
per the "no premature abstraction" standard.
|
||||
|
||||
Engine is constructed once during web_server.py init and held as a
|
||||
process-wide singleton via ``set_media_server_engine`` /
|
||||
``get_media_server_engine``, mirroring the metadata + download
|
||||
engine factory shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
from core.media_server.contract import MediaServerClient
|
||||
from core.media_server.registry import MediaServerRegistry, build_default_registry
|
||||
|
||||
logger = get_logger("media_server.engine")
|
||||
|
||||
|
||||
class MediaServerEngine:
|
||||
"""Registry-backed access to the per-server media clients.
|
||||
|
||||
Owns the per-server client instances + a small set of generic
|
||||
accessors (``client(name)`` / ``active_client()`` /
|
||||
``configured_clients()`` / ``reload_config(name)``) so call sites
|
||||
don't reach for separate per-server globals. The one cross-server
|
||||
dispatch wrapper kept on the engine — ``is_connected()`` —
|
||||
backs the dashboard status indicators that have multiple call
|
||||
sites; everything else dispatches per-server in the call site
|
||||
itself, reaching the relevant client through ``engine.client(name)``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
registry: Optional[MediaServerRegistry] = None,
|
||||
active_server_resolver=None,
|
||||
clients: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Initialize the engine.
|
||||
|
||||
Args:
|
||||
registry: Plugin registry. Defaults to the four built-in
|
||||
servers (Plex, Jellyfin, Navidrome, SoulSync).
|
||||
active_server_resolver: Callable returning the current
|
||||
active server name (e.g. ``'plex'``). Defaults to
|
||||
``config_manager.get_active_media_server``. Tests
|
||||
inject a custom resolver to switch active server
|
||||
without touching real config.
|
||||
clients: Pre-built {name: client_instance} dict. When
|
||||
provided, the engine wraps these instances directly
|
||||
instead of asking the registry to construct fresh
|
||||
ones. web_server.py uses this so the engine
|
||||
shares the same client objects as the
|
||||
pre-existing global variables (no double-init).
|
||||
"""
|
||||
self.registry = registry if registry is not None else build_default_registry()
|
||||
|
||||
if clients is not None:
|
||||
# Wrap pre-built instances (production case from web_server.py
|
||||
# init). Skip registry.initialize() — we already have the
|
||||
# instances, hand them off via the registry's public
|
||||
# set_instance(name, client) method so internal storage stays
|
||||
# encapsulated.
|
||||
for name, client in clients.items():
|
||||
self.registry.set_instance(name, client)
|
||||
# Mark any registered-but-not-supplied as failed init so
|
||||
# active_client() returns None for them.
|
||||
for name in self.registry.names():
|
||||
if self.registry.get(name) is None and name not in clients:
|
||||
self.registry.set_instance(name, None)
|
||||
else:
|
||||
self.registry.initialize()
|
||||
|
||||
if active_server_resolver is None:
|
||||
from config.settings import config_manager
|
||||
active_server_resolver = config_manager.get_active_media_server
|
||||
self._resolve_active = active_server_resolver
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Client lookup — generic accessors that replace per-server
|
||||
# attribute reaches in callers.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def client(self, name: str) -> Optional[MediaServerClient]:
|
||||
"""Return the client instance for the given server name, or
|
||||
None if it's not registered / failed to initialize. Used by
|
||||
callers that need a server-specific method beyond the
|
||||
contract surface."""
|
||||
return self.registry.get(name)
|
||||
|
||||
@property
|
||||
def active_server(self) -> str:
|
||||
"""The currently-selected media server name."""
|
||||
return self._resolve_active()
|
||||
|
||||
def active_client(self) -> Optional[MediaServerClient]:
|
||||
"""The client for the currently-active server."""
|
||||
return self.registry.get(self.active_server)
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Active server's connection state. False if no active
|
||||
client (registered but failed to initialize). The dashboard
|
||||
status indicators + endpoint guards rely on this — the only
|
||||
cross-server dispatch wrapper kept on the engine because it
|
||||
actually has callers."""
|
||||
client = self.active_client()
|
||||
if client is None:
|
||||
return False
|
||||
try:
|
||||
return client.is_connected()
|
||||
except Exception as exc:
|
||||
logger.warning("%s is_connected raised: %s", self.active_server, exc)
|
||||
return False
|
||||
|
||||
def configured_clients(self) -> Dict[str, MediaServerClient]:
|
||||
"""Return ``{name: client}`` for every server that's both
|
||||
registered AND reports ``is_connected() == True``. Replaces
|
||||
the legacy per-server `if X and X.is_connected(): ...`
|
||||
chains in web_server.py.
|
||||
|
||||
``is_connected`` is in REQUIRED_METHODS, so every client the
|
||||
registry yields here implements it — no hasattr guard needed.
|
||||
"""
|
||||
result: Dict[str, MediaServerClient] = {}
|
||||
for name, client in self.registry.all_clients():
|
||||
try:
|
||||
if client.is_connected():
|
||||
result[name] = client
|
||||
except Exception as exc:
|
||||
logger.warning("%s is_connected raised in configured_clients: %s", name, exc)
|
||||
return result
|
||||
|
||||
def reload_config(self, name: Optional[str] = None) -> bool:
|
||||
"""Reload config on a single server (or every server when
|
||||
``name`` is None). Generic dispatch — caller passes the name
|
||||
instead of reaching for ``plex_client.reload_config()``
|
||||
/ ``jellyfin_client.reload_config()`` directly. Servers
|
||||
without a ``reload_config`` method are silently skipped.
|
||||
"""
|
||||
names = [name] if name else list(self.registry.names())
|
||||
ok = True
|
||||
for n in names:
|
||||
client = self.client(n)
|
||||
if client is None or not hasattr(client, 'reload_config'):
|
||||
continue
|
||||
try:
|
||||
client.reload_config()
|
||||
except Exception as exc:
|
||||
logger.warning("%s reload_config failed: %s", n, exc)
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Singleton accessor — mirrors the get_metadata_engine() /
|
||||
# get_download_orchestrator() pattern so callers that don't need a
|
||||
# custom registry use this instead of instantiating MediaServerEngine
|
||||
# directly. web_server.py constructs the singleton at startup and
|
||||
# installs it via ``set_media_server_engine`` so the factory + the
|
||||
# global handle share state.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_default_engine: Optional['MediaServerEngine'] = None
|
||||
|
||||
|
||||
def get_media_server_engine() -> 'MediaServerEngine':
|
||||
"""Return (lazily creating) the process-wide MediaServerEngine
|
||||
singleton. Mirrors the ``get_metadata_engine()`` /
|
||||
``get_download_orchestrator()`` shape."""
|
||||
global _default_engine
|
||||
if _default_engine is None:
|
||||
_default_engine = MediaServerEngine()
|
||||
return _default_engine
|
||||
|
||||
|
||||
def set_media_server_engine(engine: Optional['MediaServerEngine']) -> None:
|
||||
"""Set the process-wide singleton. Used by web_server.py at boot
|
||||
to install the engine it constructs (with the pre-built per-client
|
||||
instances) as the default for callers reaching via
|
||||
``get_media_server_engine()``."""
|
||||
global _default_engine
|
||||
_default_engine = engine
|
||||
142
core/media_server/registry.py
Normal file
142
core/media_server/registry.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"""Media server plugin registry.
|
||||
|
||||
Single source of truth for which servers exist, what their canonical
|
||||
names are, and which client class implements each. Replaces the
|
||||
historic web_server.py pattern of holding 4 separate client globals
|
||||
that every dispatch site reached individually.
|
||||
|
||||
Server-specific dispatch chains in web_server.py (playlist add /
|
||||
remove / replace, per-server metadata sync, etc.) still hand-branch
|
||||
on ``active_server == X`` because the work each server does at those
|
||||
sites is genuinely different — but they reach the per-server CLIENT
|
||||
through ``engine.client(name)`` (which goes through this registry)
|
||||
instead of separate globals.
|
||||
|
||||
Adding a new server (Subsonic / Emby) = one ``register`` call here +
|
||||
the new client class.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Dict, Iterator, List, Optional, Tuple
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
from core.media_server.contract import MediaServerClient
|
||||
|
||||
# Eager imports for the same import-order reason the download plugin
|
||||
# registry uses them (some integration tests inject mock modules into
|
||||
# sys.modules at collection time; lazy import would bind to the mock).
|
||||
from core.jellyfin_client import JellyfinClient
|
||||
from core.navidrome_client import NavidromeClient
|
||||
from core.plex_client import PlexClient
|
||||
from core.soulsync_client import SoulSyncClient
|
||||
|
||||
logger = get_logger("media_server.registry")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServerSpec:
|
||||
"""Static descriptor for a media server. ``factory`` is the
|
||||
zero-arg callable that builds the client (each server has its
|
||||
own setup chain — Plex pulls token from config, Jellyfin reads
|
||||
user_id, etc.)."""
|
||||
|
||||
name: str
|
||||
factory: Callable[[], MediaServerClient]
|
||||
display_name: str
|
||||
aliases: Tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
class MediaServerRegistry:
|
||||
"""Holds the live client instances + name → instance lookup.
|
||||
|
||||
Two-phase construction (mirrors the download plugin registry):
|
||||
1. Specs registered cheaply (just stores callable refs).
|
||||
2. ``initialize()`` calls each factory once. Failures captured
|
||||
in ``init_failures`` so one broken server doesn't take down
|
||||
the orchestrator.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._specs: Dict[str, ServerSpec] = {}
|
||||
self._instances: Dict[str, Optional[MediaServerClient]] = {}
|
||||
self._init_failures: List[str] = []
|
||||
|
||||
def register(self, spec: ServerSpec) -> None:
|
||||
if spec.name in self._specs:
|
||||
raise ValueError(f"Server already registered: {spec.name}")
|
||||
self._specs[spec.name] = spec
|
||||
|
||||
def initialize(self) -> None:
|
||||
for spec in self._specs.values():
|
||||
try:
|
||||
instance = spec.factory()
|
||||
self._instances[spec.name] = instance
|
||||
except Exception as exc:
|
||||
logger.error("%s media server client failed to initialize: %s", spec.display_name, exc)
|
||||
self._init_failures.append(spec.display_name)
|
||||
self._instances[spec.name] = None
|
||||
|
||||
def set_instance(self, name: str, instance: Optional[MediaServerClient]) -> None:
|
||||
"""Stash a pre-built client instance into the registry without
|
||||
running the spec's factory. Used by callers (web_server.py at
|
||||
boot) that already constructed the per-server clients and want
|
||||
the engine to wrap those exact instances rather than build new
|
||||
ones. Replaces the old pattern of reaching into ``_instances``
|
||||
directly from the engine."""
|
||||
self._instances[name] = instance
|
||||
|
||||
@property
|
||||
def init_failures(self) -> List[str]:
|
||||
return list(self._init_failures)
|
||||
|
||||
def get(self, name: str) -> Optional[MediaServerClient]:
|
||||
if not name:
|
||||
return None
|
||||
if name in self._instances:
|
||||
return self._instances[name]
|
||||
for spec in self._specs.values():
|
||||
if name in spec.aliases:
|
||||
return self._instances.get(spec.name)
|
||||
return None
|
||||
|
||||
def get_spec(self, name: str) -> Optional[ServerSpec]:
|
||||
if name in self._specs:
|
||||
return self._specs[name]
|
||||
for spec in self._specs.values():
|
||||
if name in spec.aliases:
|
||||
return spec
|
||||
return None
|
||||
|
||||
def display_name(self, name: str) -> str:
|
||||
spec = self.get_spec(name)
|
||||
return spec.display_name if spec else name
|
||||
|
||||
def names(self) -> List[str]:
|
||||
return list(self._specs.keys())
|
||||
|
||||
def all_clients(self) -> Iterator[Tuple[str, MediaServerClient]]:
|
||||
"""Yield (name, client) for every successfully-initialized
|
||||
server. Used by cross-server operations."""
|
||||
for name, instance in self._instances.items():
|
||||
if instance is not None:
|
||||
yield name, instance
|
||||
|
||||
|
||||
def build_default_registry() -> MediaServerRegistry:
|
||||
"""Construct the registry with SoulSync's four built-in media
|
||||
servers. Called once during MediaServerEngine construction.
|
||||
|
||||
Adding a server (e.g. Subsonic, Emby) = one ``register`` call
|
||||
here + the new client class. No dispatch-site changes required.
|
||||
"""
|
||||
registry = MediaServerRegistry()
|
||||
|
||||
registry.register(ServerSpec(name='plex', factory=PlexClient, display_name='Plex'))
|
||||
registry.register(ServerSpec(name='jellyfin', factory=JellyfinClient, display_name='Jellyfin'))
|
||||
registry.register(ServerSpec(name='navidrome', factory=NavidromeClient, display_name='Navidrome'))
|
||||
registry.register(ServerSpec(name='soulsync', factory=SoulSyncClient, display_name='SoulSync Library'))
|
||||
|
||||
return registry
|
||||
132
core/media_server/types.py
Normal file
132
core/media_server/types.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"""Shared dataclasses for the media-server contract.
|
||||
|
||||
Plex / Jellyfin / Navidrome clients all surfaced near-identical
|
||||
``XTrackInfo`` and ``XPlaylistInfo`` shapes (id, title, artist,
|
||||
album, duration, track_number, year, optional rating) because every
|
||||
consumer needed the same shape downstream. The per-server class
|
||||
names were a copy-paste artifact, not a real contract difference.
|
||||
|
||||
This module owns the canonical types. Plex's existing classmethod
|
||||
constructors (``TrackInfo.from_plex_track``,
|
||||
``PlaylistInfo.from_plex_playlist``) live here. Jellyfin and
|
||||
Navidrome currently construct ``TrackInfo`` inline at their call
|
||||
sites — lifting those into matching ``from_jellyfin_dict`` /
|
||||
``from_navidrome_dict`` classmethods is a clean followup but isn't
|
||||
needed for the dataclass unification this module ships.
|
||||
|
||||
Heavy server SDK types (``PlexTrack``) imported under TYPE_CHECKING
|
||||
so this module stays import-light.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# plexapi types — only loaded when type-checking; runtime
|
||||
# paths through from_plex_track accept whatever PlexClient
|
||||
# passes through.
|
||||
from plexapi.audio import Track as PlexTrack
|
||||
from plexapi.playlist import Playlist as PlexPlaylist
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackInfo:
|
||||
"""Canonical track-shape returned by media server clients.
|
||||
|
||||
Plex, Jellyfin, and Navidrome each defined their own near-identical
|
||||
``XTrackInfo`` dataclass (SoulSync standalone uses richer per-track
|
||||
wrappers and doesn't surface this exact shape). Lifted to one
|
||||
canonical type here so consumers (matching engine, sync service,
|
||||
library scanners) get a single import.
|
||||
"""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
artist: str
|
||||
album: str
|
||||
duration: int
|
||||
track_number: Optional[int] = None
|
||||
year: Optional[int] = None
|
||||
rating: Optional[float] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Per-server constructors — mirror Cin's metadata Album.from_X_dict
|
||||
# pattern. Each one knows ONE server's wire shape.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def from_plex_track(cls, track: 'PlexTrack') -> 'TrackInfo':
|
||||
"""Build a TrackInfo from a plexapi PlexTrack.
|
||||
|
||||
Defensive: tracks may be missing artist or album metadata in
|
||||
Plex (especially fan-uploaded content); fall back to
|
||||
"Unknown Artist" / "Unknown Album" instead of raising.
|
||||
"""
|
||||
# Imported lazily so this module stays import-light. plexapi
|
||||
# is heavy and pulls in network + ssl deps just to define
|
||||
# exception types.
|
||||
from plexapi.exceptions import NotFound
|
||||
|
||||
try:
|
||||
artist_title = track.artist().title if track.artist() else "Unknown Artist"
|
||||
except (NotFound, AttributeError):
|
||||
artist_title = "Unknown Artist"
|
||||
|
||||
try:
|
||||
album_title = track.album().title if track.album() else "Unknown Album"
|
||||
except (NotFound, AttributeError):
|
||||
album_title = "Unknown Album"
|
||||
|
||||
return cls(
|
||||
id=str(track.ratingKey),
|
||||
title=track.title,
|
||||
artist=artist_title,
|
||||
album=album_title,
|
||||
duration=track.duration,
|
||||
track_number=track.trackNumber,
|
||||
year=track.year,
|
||||
rating=track.userRating,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaylistInfo:
|
||||
"""Canonical playlist-shape returned by every media server client.
|
||||
|
||||
Same lift rationale as ``TrackInfo`` — every server defined the
|
||||
same five-field dataclass + a list of tracks.
|
||||
"""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
description: Optional[str]
|
||||
duration: int
|
||||
leaf_count: int
|
||||
tracks: List[TrackInfo] = field(default_factory=list)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Per-server constructors
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def from_plex_playlist(cls, playlist: 'PlexPlaylist') -> 'PlaylistInfo':
|
||||
"""Build a PlaylistInfo from a plexapi Playlist. Skips items
|
||||
that aren't audio tracks (Plex playlists can mix media types
|
||||
in theory, though music libraries shouldn't)."""
|
||||
from plexapi.audio import Track as PlexTrack
|
||||
|
||||
tracks: List[TrackInfo] = []
|
||||
for item in playlist.items():
|
||||
if isinstance(item, PlexTrack):
|
||||
tracks.append(TrackInfo.from_plex_track(item))
|
||||
|
||||
return cls(
|
||||
id=str(playlist.ratingKey),
|
||||
title=playlist.title,
|
||||
description=playlist.summary,
|
||||
duration=playlist.duration,
|
||||
leaf_count=playlist.leafCount,
|
||||
tracks=tracks,
|
||||
)
|
||||
|
|
@ -8,6 +8,7 @@ from core.metadata.album_tracks import (
|
|||
resolve_album_reference,
|
||||
)
|
||||
from core.metadata.artist_image import get_artist_image_url
|
||||
from core.metadata.artwork import is_internal_image_host, normalize_image_url
|
||||
from core.metadata.cache import MetadataCache, get_metadata_cache
|
||||
from core.metadata.completion import (
|
||||
check_album_completion,
|
||||
|
|
@ -40,6 +41,13 @@ from core.metadata.registry import (
|
|||
register_profile_spotify_credentials_provider,
|
||||
register_runtime_clients,
|
||||
)
|
||||
from core.metadata.status import (
|
||||
METADATA_SOURCE_STATUS_TTL,
|
||||
get_metadata_source_status,
|
||||
get_spotify_status,
|
||||
get_status_snapshot,
|
||||
invalidate_metadata_status_caches,
|
||||
)
|
||||
from core.metadata.service import MetadataProvider, MetadataService, get_metadata_service
|
||||
from core.metadata.similar_artists import (
|
||||
get_musicmap_similar_artists,
|
||||
|
|
@ -48,6 +56,7 @@ from core.metadata.similar_artists import (
|
|||
|
||||
__all__ = [
|
||||
"METADATA_SOURCE_PRIORITY",
|
||||
"METADATA_SOURCE_STATUS_TTL",
|
||||
"MetadataCache",
|
||||
"MetadataLookupOptions",
|
||||
"MetadataProvider",
|
||||
|
|
@ -71,6 +80,7 @@ __all__ = [
|
|||
"get_hydrabase_client",
|
||||
"get_itunes_client",
|
||||
"get_metadata_cache",
|
||||
"get_metadata_source_status",
|
||||
"get_metadata_service",
|
||||
"get_musicmap_similar_artists",
|
||||
"get_primary_client",
|
||||
|
|
@ -78,11 +88,16 @@ __all__ = [
|
|||
"get_spotify_client_for_profile",
|
||||
"get_registered_runtime_client",
|
||||
"get_spotify_client",
|
||||
"get_spotify_status",
|
||||
"get_source_priority",
|
||||
"get_status_snapshot",
|
||||
"iter_artist_discography_completion_events",
|
||||
"iter_musicmap_similar_artist_events",
|
||||
"is_hydrabase_enabled",
|
||||
"is_internal_image_host",
|
||||
"register_profile_spotify_credentials_provider",
|
||||
"register_runtime_clients",
|
||||
"normalize_image_url",
|
||||
"resolve_album_reference",
|
||||
"invalidate_metadata_status_caches",
|
||||
]
|
||||
|
|
|
|||
175
core/metadata/album_mbid_cache.py
Normal file
175
core/metadata/album_mbid_cache.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"""Persistent MusicBrainz release-MBID cache for albums.
|
||||
|
||||
The original in-memory `mb_release_cache` in `core/metadata/source.py`
|
||||
maps `(normalized_album_name, artist_name) -> release_mbid` so per-track
|
||||
enrichment of the same album hits the cache and writes the same
|
||||
``MUSICBRAINZ_ALBUMID`` to every track's tags. That cache is a bounded
|
||||
``OrderedDict`` (4096 entries) — bounded means it can evict entries
|
||||
between tracks of the same album when other albums are processed in
|
||||
between. Server restart drops it entirely. Either case can produce
|
||||
inconsistent album MBIDs across tracks of the same album, which causes
|
||||
Navidrome (and other media servers that group by album MBID) to split
|
||||
the album into multiple entries.
|
||||
|
||||
This module is the persistent layer behind that cache. Same key shape,
|
||||
backed by a tiny SQLite table so a successful lookup remembered ONCE
|
||||
applies to every future track of the same album for the lifetime of
|
||||
the install — not just the bounded in-memory window.
|
||||
|
||||
Strict additive design: every public function is wrapped in try/except
|
||||
and degrades to a None / no-op return on any database error. The
|
||||
existing in-memory cache + MusicBrainz lookup stays behind it as the
|
||||
authoritative fallback. If this module breaks, downloads continue
|
||||
exactly as they would today — just without the persistent benefit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
|
||||
logger = get_logger("metadata.album_mbid_cache")
|
||||
|
||||
|
||||
# Lazy DB accessor — the cache module shouldn't trigger MusicDatabase
|
||||
# import at module-load time (circular-import risk when source.py is
|
||||
# imported during database initialization).
|
||||
_db_factory_lock = threading.Lock()
|
||||
_db_factory = None
|
||||
|
||||
|
||||
def _get_database():
|
||||
"""Resolve the MusicDatabase singleton lazily.
|
||||
|
||||
Returns None if anything goes wrong — callers MUST handle a None
|
||||
return as "cache unavailable, fall through to MB lookup."
|
||||
"""
|
||||
global _db_factory
|
||||
with _db_factory_lock:
|
||||
if _db_factory is None:
|
||||
try:
|
||||
from database.music_database import get_database
|
||||
_db_factory = get_database
|
||||
except Exception as exc:
|
||||
logger.warning(f"Persistent MBID cache: could not load database module: {exc}")
|
||||
return None
|
||||
try:
|
||||
return _db_factory()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Persistent MBID cache: database accessor failed: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def lookup(normalized_album_key: str, artist_key: str) -> Optional[str]:
|
||||
"""Read a cached release MBID for the given (album, artist) pair.
|
||||
|
||||
Returns the stored MBID string if found, otherwise None. Never
|
||||
raises — DB errors degrade silently to "cache miss" so the caller
|
||||
falls through to MusicBrainz like it does today.
|
||||
|
||||
Args:
|
||||
normalized_album_key: Output of ``normalize_album_cache_key`` —
|
||||
already lowercased and stripped of edition parentheticals.
|
||||
artist_key: Lowercased artist name (caller's responsibility to
|
||||
pass a normalized key — keeps the schema uniform).
|
||||
"""
|
||||
if not normalized_album_key or not artist_key:
|
||||
return None
|
||||
|
||||
db = _get_database()
|
||||
if db is None:
|
||||
return None
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT release_mbid FROM mb_album_release_cache "
|
||||
"WHERE normalized_album_key = ? AND artist_key = ? LIMIT 1",
|
||||
(normalized_album_key, artist_key),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
mbid = row[0] if not hasattr(row, 'keys') else row['release_mbid']
|
||||
return mbid or None
|
||||
except Exception as exc:
|
||||
logger.debug(f"Persistent MBID cache lookup failed: {exc}")
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def record(normalized_album_key: str, artist_key: str, release_mbid: str) -> bool:
|
||||
"""Persist a (album, artist) -> release_mbid mapping.
|
||||
|
||||
Idempotent — uses INSERT OR REPLACE so re-recording the same key
|
||||
just refreshes the timestamp. Returns True on success, False on
|
||||
any failure. Failure is logged at debug level and never propagated
|
||||
so a flaky DB write can't break the enrichment path.
|
||||
"""
|
||||
if not normalized_album_key or not artist_key or not release_mbid:
|
||||
return False
|
||||
|
||||
db = _get_database()
|
||||
if db is None:
|
||||
return False
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT OR REPLACE INTO mb_album_release_cache "
|
||||
"(normalized_album_key, artist_key, release_mbid, updated_at) "
|
||||
"VALUES (?, ?, ?, CURRENT_TIMESTAMP)",
|
||||
(normalized_album_key, artist_key, release_mbid),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.debug(f"Persistent MBID cache record failed: {exc}")
|
||||
return False
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down
|
||||
pass
|
||||
|
||||
|
||||
def clear_all() -> bool:
|
||||
"""Wipe the persistent cache. Used by tests and by the maintenance
|
||||
endpoint when a user wants to force a fresh MusicBrainz re-lookup
|
||||
(e.g. after fixing widespread MBID inconsistencies)."""
|
||||
db = _get_database()
|
||||
if db is None:
|
||||
return False
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM mb_album_release_cache")
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(f"Persistent MBID cache clear failed: {exc}")
|
||||
return False
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["lookup", "record", "clear_all"]
|
||||
|
|
@ -2,14 +2,32 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from dataclasses import replace
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from core.metadata import registry as metadata_registry
|
||||
from core.metadata.lookup import MetadataLookupOptions
|
||||
from core.metadata.types import Album
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("metadata.album_tracks")
|
||||
|
||||
|
||||
# Per-source typed converter dispatch. Powers the typed path inside
|
||||
# ``_build_album_info`` — when the caller knows which provider the raw
|
||||
# response came from, route through the canonical Album converter
|
||||
# instead of duck-typing every field. Sources missing from this map
|
||||
# fall through to the legacy duck-typed path.
|
||||
_TYPED_ALBUM_CONVERTERS: Dict[str, Callable[[Dict[str, Any]], Album]] = {
|
||||
'spotify': Album.from_spotify_dict,
|
||||
'itunes': Album.from_itunes_dict,
|
||||
'deezer': Album.from_deezer_dict,
|
||||
'discogs': Album.from_discogs_dict,
|
||||
'musicbrainz': Album.from_musicbrainz_dict,
|
||||
'hydrabase': Album.from_hydrabase_dict,
|
||||
'qobuz': Album.from_qobuz_dict,
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"get_album_for_source",
|
||||
"get_album_tracks_for_source",
|
||||
|
|
@ -178,7 +196,83 @@ def _normalize_context_artists(artists: Any) -> List[Dict[str, Any]]:
|
|||
return normalized
|
||||
|
||||
|
||||
def _build_album_info(album_data: Any, album_id: str, album_name: str = '', artist_name: str = '') -> Dict[str, Any]:
|
||||
def _build_album_info(album_data: Any, album_id: str, album_name: str = '',
|
||||
artist_name: str = '', source: str = '') -> Dict[str, Any]:
|
||||
"""Build the canonical SoulSync internal album-info dict.
|
||||
|
||||
When ``source`` is provided AND maps to a known typed converter,
|
||||
routes through the canonical ``Album.from_<source>_dict()`` path —
|
||||
that single converter is the source of truth for that provider's
|
||||
wire shape. Falls back to the legacy duck-typed extraction when
|
||||
source is empty/unknown OR when the typed converter raises (so a
|
||||
converter bug can't break album resolution).
|
||||
|
||||
See ``docs/metadata-types-migration.md`` for the broader plan.
|
||||
"""
|
||||
typed_path_succeeded = None
|
||||
if source and isinstance(album_data, dict):
|
||||
converter = _TYPED_ALBUM_CONVERTERS.get(source.lower())
|
||||
if converter is not None:
|
||||
try:
|
||||
typed_path_succeeded = _build_album_info_typed(
|
||||
album_data, album_id, album_name, artist_name, converter,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Typed album_info converter failed for source %s, falling "
|
||||
"back to legacy path: %s", source, exc,
|
||||
)
|
||||
if typed_path_succeeded is not None:
|
||||
return typed_path_succeeded
|
||||
|
||||
return _build_album_info_legacy(album_data, album_id, album_name, artist_name)
|
||||
|
||||
|
||||
def _build_album_info_typed(album_data: Dict[str, Any], album_id: str,
|
||||
album_name: str, artist_name: str,
|
||||
converter: Callable[[Dict[str, Any]], Album]) -> Dict[str, Any]:
|
||||
"""Typed path: convert raw → Album, apply caller fallbacks for
|
||||
fields the converter couldn't fill, return canonical dict."""
|
||||
album = converter(album_data)
|
||||
|
||||
# Apply caller-provided fallbacks when the converter produced
|
||||
# empty values. The legacy path treated `album_id` / `album_name`
|
||||
# / `artist_name` as last-resort defaults.
|
||||
if not album.id:
|
||||
album = replace(album, id=album_id)
|
||||
if not album.name:
|
||||
album = replace(album, name=album_name or album_id)
|
||||
if (not album.artists or album.artists == ['Unknown Artist']) and artist_name:
|
||||
album = replace(album, artists=[artist_name])
|
||||
|
||||
ctx = album.to_context_dict()
|
||||
|
||||
# Preserve original `images` list shape from the raw input — the
|
||||
# legacy path passed the source's full multi-resolution images
|
||||
# array through verbatim. Some downstream consumers iterate the
|
||||
# full list to pick a different size.
|
||||
raw_images = album_data.get('images')
|
||||
if isinstance(raw_images, list) and raw_images:
|
||||
ctx['images'] = raw_images
|
||||
# Legacy path also derived image_url from the first images entry
|
||||
# when the source-specific cover field wasn't populated. Match
|
||||
# that fallback so callers with Spotify-shaped raw images keep
|
||||
# getting an image_url out of providers whose typed converter
|
||||
# only checks source-native cover fields.
|
||||
if not ctx.get('image_url'):
|
||||
first = raw_images[0]
|
||||
if isinstance(first, dict):
|
||||
ctx['image_url'] = first.get('url') or ctx.get('image_url')
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
def _build_album_info_legacy(album_data: Any, album_id: str,
|
||||
album_name: str, artist_name: str) -> Dict[str, Any]:
|
||||
"""Original duck-typed extraction. Kept as the fallback when the
|
||||
typed path can't apply (unknown source, non-dict input, converter
|
||||
error). Tracked for removal once every caller passes a recognized
|
||||
source — see migration plan."""
|
||||
images = _extract_lookup_value(album_data, 'images', default=[]) or []
|
||||
if not isinstance(images, list):
|
||||
images = list(images) if images else []
|
||||
|
|
@ -252,7 +346,10 @@ def _build_album_tracks_payload(
|
|||
album_name: str = '',
|
||||
artist_name: str = '',
|
||||
) -> Dict[str, Any]:
|
||||
album_info = _build_album_info(album_data, album_id, album_name=album_name, artist_name=artist_name)
|
||||
album_info = _build_album_info(
|
||||
album_data, album_id,
|
||||
album_name=album_name, artist_name=artist_name, source=source,
|
||||
)
|
||||
album_info['source'] = source
|
||||
album_info['_source'] = source
|
||||
album_info['provider'] = source
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ from __future__ import annotations
|
|||
import os
|
||||
import re
|
||||
import urllib.request
|
||||
from ipaddress import ip_address
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
from core.imports.context import get_import_context_album
|
||||
from core.metadata.common import (
|
||||
|
|
@ -17,12 +19,193 @@ from utils.logging_config import get_logger as _create_logger
|
|||
__all__ = [
|
||||
"embed_album_art_metadata",
|
||||
"download_cover_art",
|
||||
"is_internal_image_host",
|
||||
"is_image_proxy_url",
|
||||
"normalize_image_url",
|
||||
]
|
||||
|
||||
|
||||
logger = _create_logger("metadata.artwork")
|
||||
|
||||
|
||||
def normalize_image_url(thumb_url: str | None) -> str | None:
|
||||
"""Convert media-server image URLs into browser-safe URLs."""
|
||||
if not thumb_url:
|
||||
return None
|
||||
|
||||
try:
|
||||
if is_image_proxy_url(thumb_url):
|
||||
# Already normalized for browser use; avoid wrapping it in another proxy layer.
|
||||
return thumb_url
|
||||
|
||||
# Check if it's a localhost URL or relative path that needs fixing
|
||||
needs_fixing = (
|
||||
thumb_url.startswith('http://localhost:') or
|
||||
thumb_url.startswith('https://localhost:') or
|
||||
thumb_url.startswith('http://127.0.0.1:') or
|
||||
thumb_url.startswith('https://127.0.0.1:') or
|
||||
thumb_url.startswith('http://host.docker.internal:') or
|
||||
thumb_url.startswith('https://host.docker.internal:') or
|
||||
(thumb_url.startswith('http://') and is_internal_image_host(thumb_url)) or
|
||||
thumb_url.startswith('/library/') or # Plex relative paths
|
||||
thumb_url.startswith('/Items/') or # Jellyfin relative paths
|
||||
thumb_url.startswith('/api/') or # Old Navidrome API paths
|
||||
thumb_url.startswith('/rest/') # Navidrome Subsonic API paths
|
||||
)
|
||||
|
||||
if needs_fixing:
|
||||
cfg = get_config_manager()
|
||||
active_server = cfg.get_active_media_server()
|
||||
logger.debug("Fixing URL: %s, Active server: %s", thumb_url, active_server)
|
||||
|
||||
if active_server == 'plex':
|
||||
plex_config = cfg.get_plex_config()
|
||||
plex_base_url = plex_config.get('base_url', '')
|
||||
plex_token = plex_config.get('token', '')
|
||||
logger.info("Plex config - base_url: %s, token: %s...", plex_base_url, plex_token[:10])
|
||||
|
||||
if plex_base_url and plex_token:
|
||||
# Extract the path from URL
|
||||
if thumb_url.startswith('/library/'):
|
||||
# Already a path
|
||||
path = thumb_url
|
||||
else:
|
||||
# Full localhost URL, extract path
|
||||
parsed = urlparse(thumb_url)
|
||||
path = parsed.path
|
||||
|
||||
# Construct proper Plex URL with token
|
||||
fixed_url = f"{plex_base_url.rstrip('/')}{path}?X-Plex-Token={plex_token}"
|
||||
logger.info("Fixed URL: %s", fixed_url)
|
||||
return _browser_safe_image_url(fixed_url)
|
||||
|
||||
elif active_server == 'jellyfin':
|
||||
jellyfin_config = cfg.get_jellyfin_config()
|
||||
jellyfin_base_url = jellyfin_config.get('base_url', '')
|
||||
jellyfin_token = jellyfin_config.get('api_key', '')
|
||||
logger.info(
|
||||
"Jellyfin config - base_url: %s, token: %s...",
|
||||
jellyfin_base_url,
|
||||
jellyfin_token[:10] if jellyfin_token else 'None',
|
||||
)
|
||||
|
||||
if jellyfin_base_url:
|
||||
# Extract the path from URL
|
||||
if thumb_url.startswith('/Items/') or thumb_url.startswith('/api/'):
|
||||
# Already a path
|
||||
path = thumb_url
|
||||
else:
|
||||
# Full localhost URL, extract path
|
||||
parsed = urlparse(thumb_url)
|
||||
path = parsed.path
|
||||
|
||||
# Construct proper Jellyfin URL with token
|
||||
if jellyfin_token:
|
||||
separator = '&' if '?' in path else '?'
|
||||
fixed_url = f"{jellyfin_base_url.rstrip('/')}{path}{separator}X-Emby-Token={jellyfin_token}"
|
||||
else:
|
||||
fixed_url = f"{jellyfin_base_url.rstrip('/')}{path}"
|
||||
logger.info("Fixed URL: %s", fixed_url)
|
||||
return _browser_safe_image_url(fixed_url)
|
||||
|
||||
elif active_server == 'navidrome':
|
||||
navidrome_config = cfg.get_navidrome_config()
|
||||
navidrome_base_url = navidrome_config.get('base_url', '')
|
||||
navidrome_username = navidrome_config.get('username', '')
|
||||
navidrome_password = navidrome_config.get('password', '')
|
||||
logger.info("Navidrome config - base_url: %s, username: %s", navidrome_base_url, navidrome_username)
|
||||
|
||||
if navidrome_base_url and navidrome_username and navidrome_password:
|
||||
# Extract the path from URL
|
||||
if thumb_url.startswith('/rest/'):
|
||||
# Already a Subsonic API path
|
||||
path = thumb_url
|
||||
else:
|
||||
# Full localhost URL, extract path
|
||||
parsed = urlparse(thumb_url)
|
||||
path = parsed.path
|
||||
|
||||
# Generate Subsonic API authentication
|
||||
import hashlib
|
||||
import secrets
|
||||
salt = secrets.token_hex(6)
|
||||
token = hashlib.md5((navidrome_password + salt).encode()).hexdigest()
|
||||
|
||||
# Add authentication parameters to the URL
|
||||
separator = '&' if '?' in path else '?'
|
||||
auth_params = f"u={navidrome_username}&t={token}&s={salt}&v=1.16.1&c=SoulSync&f=json"
|
||||
|
||||
# Construct proper Navidrome Subsonic URL
|
||||
fixed_url = f"{navidrome_base_url.rstrip('/')}{path}{separator}{auth_params}"
|
||||
logger.info("Fixed URL: %s", fixed_url)
|
||||
return _browser_safe_image_url(fixed_url)
|
||||
|
||||
logger.warning("No configuration found for %s or unsupported server type", active_server)
|
||||
|
||||
# Return a browser-safe URL even if no server-specific rebuild was possible.
|
||||
return _browser_safe_image_url(thumb_url)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Error fixing image URL '%s': %s", thumb_url, exc)
|
||||
return _browser_safe_image_url(thumb_url)
|
||||
|
||||
|
||||
def is_image_proxy_url(url: str) -> bool:
|
||||
"""Return True for SoulSync image-proxy URLs, absolute or relative."""
|
||||
if not url:
|
||||
return False
|
||||
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
return parsed.path == '/api/image-proxy'
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def is_internal_image_host(url: str) -> bool:
|
||||
"""Return True when an image URL points at a host the browser likely cannot reach directly."""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
host = (parsed.hostname or '').strip('[]').lower()
|
||||
if not host:
|
||||
return False
|
||||
|
||||
if host in {'localhost', '127.0.0.1', '::1', 'host.docker.internal'}:
|
||||
return True
|
||||
|
||||
# Single-label hosts are usually Docker service names or local LAN aliases.
|
||||
if '.' not in host:
|
||||
return True
|
||||
|
||||
try:
|
||||
ip = ip_address(host)
|
||||
return ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_reserved
|
||||
except ValueError:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _browser_safe_image_url(url: str) -> str:
|
||||
"""Return a browser-safe image URL, proxying internal hosts through SoulSync."""
|
||||
if not url:
|
||||
return url
|
||||
|
||||
if is_image_proxy_url(url):
|
||||
return url
|
||||
|
||||
if url.startswith('/api/image-proxy?url='):
|
||||
return url
|
||||
|
||||
if url.startswith('http://') or url.startswith('https://'):
|
||||
if is_internal_image_host(url):
|
||||
return f"/api/image-proxy?url={quote(url, safe='')}"
|
||||
return url
|
||||
|
||||
# Relative media-server paths should already have been expanded before this point.
|
||||
return url
|
||||
|
||||
|
||||
def embed_album_art_metadata(audio_file, metadata: dict):
|
||||
cfg = get_config_manager()
|
||||
symbols = get_mutagen_symbols()
|
||||
|
|
@ -137,8 +320,8 @@ def download_cover_art(album_info: dict, target_dir: str, context: dict = None):
|
|||
from core.spotify_client import _upgrade_spotify_image_url
|
||||
|
||||
art_url = _upgrade_spotify_image_url(art_url)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("upgrade spotify image url failed: %s", e)
|
||||
elif art_url and "mzstatic.com" in art_url:
|
||||
art_url = re.sub(r"\d+x\d+bb", "3000x3000bb", art_url)
|
||||
if not art_url:
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ def get_metadata_cache():
|
|||
try:
|
||||
import threading
|
||||
threading.Thread(target=_cache_instance.backfill_deezer_album_genres, daemon=True).start()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("start deezer genres backfill failed: %s", e)
|
||||
return _cache_instance
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -171,8 +171,8 @@ def get_image_dimensions(data: bytes):
|
|||
return w, h
|
||||
length = struct.unpack(">H", data[i + 2 : i + 4])[0]
|
||||
i += 2 + length
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("parse JPEG dimensions failed: %s", e)
|
||||
return None, None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,16 +2,53 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from core.metadata import registry as metadata_registry
|
||||
from core.metadata.album_tracks import get_artist_albums_for_source
|
||||
from core.metadata.lookup import MetadataLookupOptions
|
||||
from core.metadata.types import Album
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("metadata.discography")
|
||||
|
||||
|
||||
# Per-source typed converter dispatch — same registry pattern as
|
||||
# ``core/metadata/album_tracks.py`` and ``core/imports/resolution.py``.
|
||||
# Discography release builders dispatch through the typed Album
|
||||
# converter when the active source is known. Falls back to legacy
|
||||
# duck-typed extraction below on unknown source / converter error.
|
||||
_TYPED_ALBUM_CONVERTERS: Dict[str, Callable[[Dict[str, Any]], Album]] = {
|
||||
'spotify': Album.from_spotify_dict,
|
||||
'itunes': Album.from_itunes_dict,
|
||||
'deezer': Album.from_deezer_dict,
|
||||
'discogs': Album.from_discogs_dict,
|
||||
'musicbrainz': Album.from_musicbrainz_dict,
|
||||
'hydrabase': Album.from_hydrabase_dict,
|
||||
'qobuz': Album.from_qobuz_dict,
|
||||
}
|
||||
|
||||
|
||||
def _typed_album_for_source(release: Any, source: Optional[str]) -> Optional[Album]:
|
||||
"""Return a typed Album when source maps to a registered converter
|
||||
and the converter succeeds. ``None`` means the caller should fall
|
||||
back to the legacy duck-typed extraction.
|
||||
"""
|
||||
if not source or not isinstance(release, dict):
|
||||
return None
|
||||
converter = _TYPED_ALBUM_CONVERTERS.get(source.strip().lower())
|
||||
if converter is None:
|
||||
return None
|
||||
try:
|
||||
return converter(release)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Typed album converter failed for source %s in discography "
|
||||
"build, falling back to legacy: %s", source, exc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_lookup_value(value: Any, *names: str, default: Any = None) -> Any:
|
||||
if value is None:
|
||||
return default
|
||||
|
|
@ -89,7 +126,32 @@ def _pick_best_artist_match(search_results: List[Any], artist_name: str) -> Opti
|
|||
return search_results[0]
|
||||
|
||||
|
||||
def _build_discography_release_dict(release: Any, artist_id: str) -> Optional[Dict[str, Any]]:
|
||||
def _build_discography_release_dict(release: Any, artist_id: str,
|
||||
source: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Build a normalized discography release dict.
|
||||
|
||||
When ``source`` is provided AND maps to a registered typed Album
|
||||
converter, routes through ``Album.from_<source>_dict()`` and pulls
|
||||
canonical fields off the typed Album. Falls back to the legacy
|
||||
duck-typed extraction on unknown source / non-dict input / typed
|
||||
converter error.
|
||||
"""
|
||||
typed_album = _typed_album_for_source(release, source)
|
||||
if typed_album is not None:
|
||||
if not typed_album.id:
|
||||
return None
|
||||
artist_name = typed_album.artists[0] if typed_album.artists else ''
|
||||
return {
|
||||
'id': typed_album.id,
|
||||
'name': typed_album.name or typed_album.id,
|
||||
'artist_name': artist_name,
|
||||
'release_date': typed_album.release_date or None,
|
||||
'album_type': typed_album.album_type or 'album',
|
||||
'image_url': typed_album.image_url,
|
||||
'total_tracks': typed_album.total_tracks or 0,
|
||||
'external_urls': typed_album.external_urls or {},
|
||||
}
|
||||
|
||||
release_id = _extract_lookup_value(release, 'id', 'album_id', 'release_id')
|
||||
if not release_id:
|
||||
return None
|
||||
|
|
@ -308,7 +370,7 @@ def get_artist_discography(
|
|||
seen_albums = set()
|
||||
|
||||
for release in albums or []:
|
||||
release_data = _build_discography_release_dict(release, artist_id)
|
||||
release_data = _build_discography_release_dict(release, artist_id, source=active_source)
|
||||
if not release_data:
|
||||
continue
|
||||
|
||||
|
|
@ -341,7 +403,46 @@ def get_artist_discography(
|
|||
}
|
||||
|
||||
|
||||
def _build_artist_detail_release_card(release: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
def _build_artist_detail_release_card(release: Dict[str, Any],
|
||||
source: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Build an artist-detail release card.
|
||||
|
||||
NOTE on inputs: this function may receive EITHER raw provider
|
||||
release dicts (when called directly during a fresh discography
|
||||
lookup) OR pre-built canonical release dicts produced by
|
||||
``_build_discography_release_dict`` (the more common case via
|
||||
``get_artist_detail_discography``). Pre-built dicts already carry
|
||||
canonical keys, so the typed dispatch is a no-op for them — and
|
||||
the legacy duck-typed path also handles them correctly. The typed
|
||||
dispatch only kicks in when the caller passes a known source AND
|
||||
the release dict matches a provider's wire shape.
|
||||
"""
|
||||
typed_album = _typed_album_for_source(release, source)
|
||||
if typed_album is not None and typed_album.id:
|
||||
release_year = None
|
||||
if typed_album.release_date:
|
||||
try:
|
||||
release_year = str(typed_album.release_date)[:4]
|
||||
except Exception:
|
||||
release_year = None
|
||||
|
||||
card = {
|
||||
'id': typed_album.id,
|
||||
'name': typed_album.name or typed_album.id,
|
||||
'title': typed_album.name or typed_album.id,
|
||||
'album_type': (typed_album.album_type or 'album').lower(),
|
||||
'image_url': typed_album.image_url,
|
||||
'year': release_year,
|
||||
'track_count': typed_album.total_tracks or 0,
|
||||
'owned': None,
|
||||
'track_completion': 'checking',
|
||||
}
|
||||
if typed_album.release_date:
|
||||
card['release_date'] = typed_album.release_date
|
||||
elif release_year:
|
||||
card['release_date'] = f"{release_year}-01-01"
|
||||
return card
|
||||
|
||||
release_id = _extract_lookup_value(release, 'id', 'album_id', 'release_id')
|
||||
if not release_id:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ def build_metadata_enrichment_runtime(
|
|||
deezer_worker: Any | None = None,
|
||||
audiodb_worker: Any | None = None,
|
||||
tidal_client: Any | None = None,
|
||||
hifi_client: Any | None = None,
|
||||
qobuz_enrichment_worker: Any | None = None,
|
||||
lastfm_worker: Any | None = None,
|
||||
genius_worker: Any | None = None,
|
||||
|
|
@ -49,6 +50,7 @@ def build_metadata_enrichment_runtime(
|
|||
deezer_worker=deezer_worker,
|
||||
audiodb_worker=audiodb_worker,
|
||||
tidal_client=tidal_client,
|
||||
hifi_client=hifi_client,
|
||||
qobuz_enrichment_worker=qobuz_enrichment_worker,
|
||||
lastfm_worker=lastfm_worker,
|
||||
genius_worker=genius_worker,
|
||||
|
|
|
|||
241
core/metadata/multi_source_search.py
Normal file
241
core/metadata/multi_source_search.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
"""Multi-source parallel metadata search.
|
||||
|
||||
Both the Track Redownload modal and the Artist Enhance Quality flow
|
||||
need to find the best metadata match for a known track (we have the
|
||||
title + artist + duration from the user's library; we want to find
|
||||
the matching entry in Spotify / iTunes / Deezer / Discogs / Hydrabase
|
||||
to drive the wishlist re-download).
|
||||
|
||||
Pre-extraction, redownload had a fully-fledged multi-source parallel
|
||||
search (parallel ThreadPoolExecutor, per-source query optimization,
|
||||
"current match" flagging via stored source IDs, per-result scoring)
|
||||
while enhance had a hardcoded Spotify-direct → Spotify-search →
|
||||
iTunes-fallback chain that only searched ONE source. That's why
|
||||
redownload "worked" for users without Spotify (it'd find matches via
|
||||
iTunes / Deezer in parallel) and enhance silently failed (single
|
||||
fallback returned junk).
|
||||
|
||||
This module owns the search logic. Both endpoints call
|
||||
``search_all_sources`` and get back the same shape — same scoring,
|
||||
same source-optimized queries, same "current match" semantics. UI
|
||||
behavior diverges per-endpoint (redownload renders a picker, enhance
|
||||
auto-picks the best across all sources) but the metadata-search
|
||||
contract is shared.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass, field
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger('metadata.multi_source_search')
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackQuery:
|
||||
"""Inputs needed to run a multi-source metadata search for one track."""
|
||||
title: str
|
||||
artist: str
|
||||
album: str = ''
|
||||
# Library-side duration in milliseconds. Used for the duration
|
||||
# similarity component of scoring; pass 0 when unknown (scoring
|
||||
# falls back to a neutral 0.5 weight).
|
||||
duration_ms: int = 0
|
||||
# Source-native track IDs already stored on the library track,
|
||||
# used for the "is_current_match" flag in the per-result rendering
|
||||
# so the UI can highlight the entry that produced the existing file.
|
||||
spotify_track_id: Optional[str] = None
|
||||
deezer_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MultiSourceResult:
|
||||
"""Aggregated output from ``search_all_sources``."""
|
||||
# source_name → list of result dicts (already per-source sorted:
|
||||
# is_current_match first, then descending match_score). Dict shape
|
||||
# is JSON-serializable for direct return to the frontend (the
|
||||
# redownload picker uses these as-is).
|
||||
metadata_results: Dict[str, List[dict]] = field(default_factory=dict)
|
||||
# source_name → list of source-native Track objects, parallel-indexed
|
||||
# to ``metadata_results[source_name]``. Used by callers (Enhance
|
||||
# Quality) that need to build a wishlist payload from the chosen
|
||||
# match — the dict shape lacks per-source fields like full album
|
||||
# data / external_urls / popularity that the wishlist needs.
|
||||
raw_tracks: Dict[str, List[Any]] = field(default_factory=dict)
|
||||
# Best match across all sources, or None if every source returned
|
||||
# nothing. Shape: ``{'source': str, 'index': int, 'score': float}``.
|
||||
best_match: Optional[dict] = None
|
||||
|
||||
def best_track(self) -> Optional[Any]:
|
||||
"""Convenience: return the source-native Track object for the
|
||||
cross-source best match, or None if no match was found."""
|
||||
if not self.best_match:
|
||||
return None
|
||||
source = self.best_match['source']
|
||||
index = self.best_match['index']
|
||||
tracks = self.raw_tracks.get(source) or []
|
||||
return tracks[index] if index < len(tracks) else None
|
||||
|
||||
|
||||
def _score_match(query: TrackQuery, result: dict) -> float:
|
||||
"""Score one result dict against the query.
|
||||
|
||||
Weights: title 0.5, artist 0.35, duration 0.15. Duration component
|
||||
is neutral (0.5) when the library track has no duration on file.
|
||||
|
||||
These weights match the redownload pre-extraction implementation
|
||||
so existing callers keep their scoring behavior identical.
|
||||
"""
|
||||
title_sim = SequenceMatcher(
|
||||
None, query.title.lower(), (result.get('name') or '').lower()
|
||||
).ratio()
|
||||
artist_sim = SequenceMatcher(
|
||||
None, query.artist.lower(), (result.get('artist') or '').lower()
|
||||
).ratio()
|
||||
if query.duration_ms:
|
||||
dur_diff = abs(query.duration_ms - (result.get('duration_ms') or 0))
|
||||
dur_score = max(0.0, 1.0 - dur_diff / 30000.0)
|
||||
else:
|
||||
dur_score = 0.5
|
||||
return round((title_sim * 0.5 + artist_sim * 0.35 + dur_score * 0.15), 3)
|
||||
|
||||
|
||||
def _build_source_query(source_name: str, query: TrackQuery, clean_title: str) -> str:
|
||||
"""Build the source-optimized search query string.
|
||||
|
||||
Deezer's API responds best to its native field-prefixed syntax
|
||||
(``artist:"X" track:"Y"``) — empirically returns better matches
|
||||
than a plain query for ambiguous track names. Other sources use
|
||||
the artist + clean-title concatenation.
|
||||
"""
|
||||
if source_name == 'deezer':
|
||||
return f'artist:"{query.artist}" track:"{clean_title}"'
|
||||
return f"{query.artist} {clean_title}"
|
||||
|
||||
|
||||
def _search_one_source(source_name: str, client: Any,
|
||||
query: TrackQuery, clean_title: str
|
||||
) -> Tuple[str, List[dict], List[Any]]:
|
||||
"""Run one source's search with three-tier query fallback.
|
||||
|
||||
Tier 1: source-optimized query (Deezer's structured form, others' plain).
|
||||
Tier 2: plain ``artist + title`` if tier 1 returned nothing.
|
||||
Tier 3: title-only as last resort.
|
||||
|
||||
Returns ``(source_name, results, raw_tracks)``:
|
||||
- ``results`` are the JSON-serializable dicts (id / name / artist /
|
||||
etc.), sorted by is_current_match first, then descending match_score
|
||||
- ``raw_tracks`` are the source-native Track objects, parallel-indexed
|
||||
to ``results``, for callers that need richer per-source fields
|
||||
than the dict surface (album_type, external_urls, etc).
|
||||
"""
|
||||
try:
|
||||
primary_q = _build_source_query(source_name, query, clean_title)
|
||||
plain_q = f"{query.artist} {clean_title}"
|
||||
title_q = clean_title
|
||||
|
||||
logger.info(f"[MultiSourceSearch] Searching {source_name} for: {primary_q}")
|
||||
track_objs = client.search_tracks(primary_q, limit=10)
|
||||
if not track_objs and primary_q != plain_q:
|
||||
track_objs = client.search_tracks(plain_q, limit=10)
|
||||
if not track_objs and clean_title != plain_q:
|
||||
track_objs = client.search_tracks(title_q, limit=10)
|
||||
logger.info(f"[MultiSourceSearch] {source_name} returned {len(track_objs)} results")
|
||||
|
||||
scored: List[Tuple[dict, Any]] = []
|
||||
for t in track_objs:
|
||||
r = {
|
||||
'id': str(getattr(t, 'id', '')),
|
||||
'name': getattr(t, 'name', '') or '',
|
||||
'artist': ', '.join(t.artists) if getattr(t, 'artists', None) else '',
|
||||
'album': getattr(t, 'album', '') or '',
|
||||
'duration_ms': getattr(t, 'duration_ms', 0) or 0,
|
||||
'image_url': getattr(t, 'image_url', '') or '',
|
||||
'is_current_match': False,
|
||||
}
|
||||
# Flag the result that backs the user's existing library
|
||||
# track so the UI can highlight it.
|
||||
if source_name == 'spotify' and query.spotify_track_id and r['id'] == str(query.spotify_track_id):
|
||||
r['is_current_match'] = True
|
||||
elif source_name == 'deezer' and query.deezer_id and r['id'] == str(query.deezer_id):
|
||||
r['is_current_match'] = True
|
||||
r['match_score'] = _score_match(query, r)
|
||||
scored.append((r, t))
|
||||
|
||||
# Sort dict + raw track in lockstep so raw_tracks[i] is the
|
||||
# source-native object behind metadata_results[source][i].
|
||||
scored.sort(key=lambda pair: (-int(pair[0]['is_current_match']), -pair[0]['match_score']))
|
||||
results = [pair[0] for pair in scored]
|
||||
raw_tracks = [pair[1] for pair in scored]
|
||||
return source_name, results, raw_tracks
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"[MultiSourceSearch] Search failed for {source_name}: {exc}",
|
||||
exc_info=True,
|
||||
)
|
||||
return source_name, [], []
|
||||
|
||||
|
||||
def search_all_sources(query: TrackQuery,
|
||||
sources: List[Tuple[str, Any]],
|
||||
clean_title: Optional[str] = None,
|
||||
max_workers: int = 3) -> MultiSourceResult:
|
||||
"""Run a parallel metadata search across every source in ``sources``.
|
||||
|
||||
Args:
|
||||
query: TrackQuery describing the library track we want to match.
|
||||
sources: List of ``(name, client)`` pairs. Each client must
|
||||
implement ``search_tracks(query: str, limit: int) -> List[Track]``
|
||||
where each Track has ``.id``, ``.name``, ``.artists`` (list),
|
||||
``.album``, ``.duration_ms``, ``.image_url`` attributes.
|
||||
All five primary metadata clients (Spotify / iTunes /
|
||||
Deezer / Discogs / Hydrabase) satisfy this contract.
|
||||
clean_title: Optional pre-cleaned track title (e.g. with
|
||||
"(Remastered)" / "(Single Version)" suffixes stripped).
|
||||
Defaults to ``query.title`` if not supplied.
|
||||
max_workers: ThreadPoolExecutor pool size. Default 3 matches
|
||||
the redownload endpoint's pre-extraction default — bumping
|
||||
higher rate-limits on slower sources without speeding up
|
||||
the slowest source's response.
|
||||
|
||||
Returns:
|
||||
MultiSourceResult with per-source results + cross-source best match.
|
||||
"""
|
||||
if clean_title is None:
|
||||
clean_title = query.title
|
||||
|
||||
if not sources:
|
||||
return MultiSourceResult()
|
||||
|
||||
metadata_results: Dict[str, List[dict]] = {}
|
||||
raw_tracks: Dict[str, List[Any]] = {}
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
futures = {
|
||||
pool.submit(_search_one_source, name, client, query, clean_title): name
|
||||
for name, client in sources
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
source_name, results, raws = future.result()
|
||||
metadata_results[source_name] = results
|
||||
raw_tracks[source_name] = raws
|
||||
|
||||
best_match: Optional[dict] = None
|
||||
for source, results in metadata_results.items():
|
||||
if results:
|
||||
top = results[0]
|
||||
if best_match is None or top['match_score'] > best_match['score']:
|
||||
best_match = {
|
||||
'source': source,
|
||||
'index': 0,
|
||||
'score': top['match_score'],
|
||||
}
|
||||
|
||||
return MultiSourceResult(
|
||||
metadata_results=metadata_results,
|
||||
raw_tracks=raw_tracks,
|
||||
best_match=best_match,
|
||||
)
|
||||
|
|
@ -9,6 +9,7 @@ from __future__ import annotations
|
|||
|
||||
import threading
|
||||
import hashlib
|
||||
import time
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
|
@ -277,8 +278,8 @@ def get_hydrabase_client(allow_fallback: bool = True, require_enabled: bool = Tr
|
|||
if client and client.is_connected():
|
||||
if not require_enabled or bool(_dev_mode_enabled_provider()):
|
||||
return client
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("hydrabase client lookup: %s", e)
|
||||
|
||||
if allow_fallback:
|
||||
return get_itunes_client()
|
||||
|
|
@ -341,6 +342,44 @@ def get_primary_client(
|
|||
)
|
||||
|
||||
|
||||
def get_primary_source_status(
|
||||
*,
|
||||
spotify_client_factory: Optional[MetadataClientFactory] = None,
|
||||
itunes_client_factory: Optional[MetadataClientFactory] = None,
|
||||
deezer_client_factory: Optional[MetadataClientFactory] = None,
|
||||
discogs_client_factory: Optional[MetadataClientFactory] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return a generic status snapshot for the active primary metadata source."""
|
||||
source = _get_config_value("metadata.fallback_source", "deezer") or "deezer"
|
||||
started = time.time()
|
||||
connected = False
|
||||
|
||||
try:
|
||||
client = get_client_for_source(
|
||||
source,
|
||||
spotify_client_factory=spotify_client_factory,
|
||||
itunes_client_factory=itunes_client_factory,
|
||||
deezer_client_factory=deezer_client_factory,
|
||||
discogs_client_factory=discogs_client_factory,
|
||||
)
|
||||
if source == "spotify":
|
||||
connected = bool(client and client.is_spotify_authenticated())
|
||||
elif source == "hydrabase":
|
||||
connected = bool(client and (client.is_connected() if hasattr(client, "is_connected") else client.is_authenticated()))
|
||||
elif client is not None and hasattr(client, "is_authenticated"):
|
||||
connected = bool(client.is_authenticated())
|
||||
else:
|
||||
connected = client is not None
|
||||
except Exception:
|
||||
connected = False
|
||||
|
||||
return {
|
||||
"source": source,
|
||||
"connected": connected,
|
||||
"response_time": round((time.time() - started) * 1000, 1),
|
||||
}
|
||||
|
||||
|
||||
def get_client_for_source(
|
||||
source: str,
|
||||
*,
|
||||
|
|
@ -355,8 +394,8 @@ def get_client_for_source(
|
|||
client = get_spotify_client(client_factory=spotify_client_factory)
|
||||
if client and client.is_spotify_authenticated():
|
||||
return client
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("spotify client get_for_source: %s", e)
|
||||
return None
|
||||
|
||||
if source == "deezer":
|
||||
|
|
|
|||
|
|
@ -124,12 +124,14 @@ SOURCE_TAG_CONFIG = {
|
|||
"AUDIODB_TRACK_ID": "audiodb.tags.track_id",
|
||||
"TIDAL_TRACK_ID": "tidal.tags.track_id",
|
||||
"TIDAL_ARTIST_ID": "tidal.tags.artist_id",
|
||||
"HIFI_TRACK_ID": "hifi.tags.track_id",
|
||||
"HIFI_ARTIST_ID": "hifi.tags.artist_id",
|
||||
"QOBUZ_TRACK_ID": "qobuz.tags.track_id",
|
||||
"QOBUZ_ARTIST_ID": "qobuz.tags.artist_id",
|
||||
"GENIUS_TRACK_ID": "genius.tags.track_id",
|
||||
}
|
||||
|
||||
DEFAULT_SOURCE_ORDER = ["musicbrainz", "deezer", "audiodb", "tidal", "qobuz", "lastfm", "genius"]
|
||||
DEFAULT_SOURCE_ORDER = ["musicbrainz", "deezer", "audiodb", "tidal", "hifi", "qobuz", "lastfm", "genius"]
|
||||
|
||||
ID3_TAG_MAP = {
|
||||
"MUSICBRAINZ_RECORDING_ID": ("UFID", "http://musicbrainz.org"),
|
||||
|
|
@ -253,7 +255,8 @@ def _process_musicbrainz_source(pp: dict, metadata: dict, cfg, runtime, track_ti
|
|||
album_name_for_mb = metadata.get("album", "")
|
||||
if album_name_for_mb:
|
||||
artist_key = (pp.get("batch_artist_name") or artist_name).lower().strip()
|
||||
rc_key_norm = (normalize_album_cache_key(album_name_for_mb), artist_key)
|
||||
normalized_album_key = normalize_album_cache_key(album_name_for_mb)
|
||||
rc_key_norm = (normalized_album_key, artist_key)
|
||||
rc_key_exact = (album_name_for_mb.lower().strip(), artist_key)
|
||||
release_mbid = None
|
||||
with mb_release_cache_lock:
|
||||
|
|
@ -263,12 +266,38 @@ def _process_musicbrainz_source(pp: dict, metadata: dict, cfg, runtime, track_ti
|
|||
if cached:
|
||||
release_mbid = cached
|
||||
else:
|
||||
rc_result = _call_source_lookup("MusicBrainz release", mb_service.match_release, album_name_for_mb, artist_name)
|
||||
if rc_result and rc_result.get("mbid"):
|
||||
release_mbid = rc_result["mbid"]
|
||||
# Persistent cache check BEFORE the live MB lookup. If a
|
||||
# previous SoulSync run already resolved this album's
|
||||
# release MBID, reuse it — guarantees every track of the
|
||||
# same album gets the SAME MUSICBRAINZ_ALBUMID tag, even
|
||||
# across server restarts and after the in-memory bounded
|
||||
# cache evicts the entry. Strictly additive: any failure
|
||||
# in the persistent lookup falls through to the live MB
|
||||
# query exactly as today.
|
||||
try:
|
||||
from core.metadata import album_mbid_cache as _persisted_cache
|
||||
persisted = _persisted_cache.lookup(normalized_album_key, artist_key)
|
||||
except Exception:
|
||||
persisted = None
|
||||
|
||||
if persisted:
|
||||
release_mbid = persisted
|
||||
else:
|
||||
rc_result = _call_source_lookup("MusicBrainz release", mb_service.match_release, album_name_for_mb, artist_name)
|
||||
if rc_result and rc_result.get("mbid"):
|
||||
release_mbid = rc_result["mbid"]
|
||||
|
||||
if release_mbid:
|
||||
_bounded_cache_set(mb_release_cache, rc_key_norm, release_mbid, _MB_RELEASE_CACHE_MAX_ENTRIES)
|
||||
_bounded_cache_set(mb_release_cache, rc_key_exact, release_mbid, _MB_RELEASE_CACHE_MAX_ENTRIES)
|
||||
# Also persist for future SoulSync runs. Defensive
|
||||
# try/except so a DB write failure can't block the
|
||||
# in-memory store + tag write that follow.
|
||||
try:
|
||||
from core.metadata import album_mbid_cache as _persisted_cache
|
||||
_persisted_cache.record(normalized_album_key, artist_key, release_mbid)
|
||||
except Exception as e:
|
||||
logger.debug("MBID cache persist failed: %s", e)
|
||||
pp["release_mbid"] = release_mbid or ""
|
||||
if pp["release_mbid"]:
|
||||
pp["id_tags"]["MUSICBRAINZ_RELEASE_ID"] = pp["release_mbid"]
|
||||
|
|
@ -452,6 +481,9 @@ def _process_tidal_source(pp: dict, metadata: dict, cfg, runtime, track_title: s
|
|||
td_details = _call_source_lookup("Tidal track details", tidal_client.get_track, str(td_track_id))
|
||||
if td_details:
|
||||
pp["tidal_isrc"] = td_details.get("isrc")
|
||||
td_bpm = td_details.get("bpm")
|
||||
if td_bpm and td_bpm > 0:
|
||||
pp["tidal_bpm"] = td_bpm
|
||||
td_copyright = td_details.get("copyright")
|
||||
if isinstance(td_copyright, dict):
|
||||
td_copyright = td_copyright.get("text", td_copyright.get("name", ""))
|
||||
|
|
@ -465,6 +497,47 @@ def _process_tidal_source(pp: dict, metadata: dict, cfg, runtime, track_title: s
|
|||
pp["release_year"] = td_release[:4]
|
||||
|
||||
|
||||
def _process_hifi_source(pp: dict, metadata: dict, cfg, runtime, track_title: str, artist_name: str) -> None:
|
||||
if cfg.get("hifi.embed_tags", True) is False:
|
||||
return
|
||||
if not track_title or not artist_name:
|
||||
return
|
||||
|
||||
hifi_client = getattr(runtime, "hifi_client", None)
|
||||
if not hifi_client:
|
||||
return
|
||||
hifi_results = _call_source_lookup("HiFi track", hifi_client.search_tracks, track_title, artist_name)
|
||||
if hifi_results and len(hifi_results) > 0:
|
||||
hifi_track = hifi_results[0]
|
||||
if _names_match(hifi_track.get("title", ""), track_title):
|
||||
hifi_track_id = hifi_track.get("id")
|
||||
if hifi_track_id:
|
||||
pp["id_tags"]["HIFI_TRACK_ID"] = str(hifi_track_id)
|
||||
hifi_artist_id = hifi_track.get("artist_id")
|
||||
if hifi_artist_id:
|
||||
pp["id_tags"]["HIFI_ARTIST_ID"] = str(hifi_artist_id)
|
||||
if hifi_track_id:
|
||||
hifi_details = _call_source_lookup("HiFi track details", hifi_client.get_track_info, hifi_track_id)
|
||||
if hifi_details:
|
||||
hifi_isrc = hifi_details.get("isrc")
|
||||
if hifi_isrc:
|
||||
pp["hifi_isrc"] = hifi_isrc
|
||||
hifi_bpm = hifi_details.get("bpm")
|
||||
if hifi_bpm and hifi_bpm > 0:
|
||||
pp["hifi_bpm"] = hifi_bpm
|
||||
hifi_copyright = hifi_details.get("copyright")
|
||||
if hifi_copyright:
|
||||
pp["hifi_copyright"] = hifi_copyright
|
||||
if not pp["release_year"]:
|
||||
hifi_album_id = hifi_track.get("album_id")
|
||||
if hifi_album_id:
|
||||
hifi_album = _call_source_lookup("HiFi album", hifi_client.get_album, hifi_album_id)
|
||||
if hifi_album:
|
||||
hifi_release = str(hifi_album.get("release_date", "") or "")
|
||||
if len(hifi_release) >= 4 and hifi_release[:4].isdigit():
|
||||
pp["release_year"] = hifi_release[:4]
|
||||
|
||||
|
||||
def _process_qobuz_source(pp: dict, metadata: dict, cfg, runtime, track_title: str, artist_name: str) -> None:
|
||||
if cfg.get("qobuz.embed_tags", True) is False:
|
||||
return
|
||||
|
|
@ -572,6 +645,8 @@ def _process_source_enrichment(source_name: str, pp: dict, metadata: dict, cfg,
|
|||
_process_audiodb_source(pp, metadata, cfg, runtime, track_title, artist_name)
|
||||
elif source_name == "tidal":
|
||||
_process_tidal_source(pp, metadata, cfg, runtime, track_title, artist_name)
|
||||
elif source_name == "hifi":
|
||||
_process_hifi_source(pp, metadata, cfg, runtime, track_title, artist_name)
|
||||
elif source_name == "qobuz":
|
||||
_process_qobuz_source(pp, metadata, cfg, runtime, track_title, artist_name)
|
||||
elif source_name == "lastfm":
|
||||
|
|
@ -634,15 +709,23 @@ def _write_embedded_metadata(audio_file, metadata: dict, pp: dict, cfg, symbols)
|
|||
audio_file["\xa9day"] = [release_year]
|
||||
logger.info("Date tag: %s", release_year)
|
||||
|
||||
if _tag_enabled(cfg, "deezer.tags.bpm") and pp["deezer_bpm"] and pp["deezer_bpm"] > 0:
|
||||
bpm_int = int(pp["deezer_bpm"])
|
||||
bpm_candidates = []
|
||||
if pp["deezer_bpm"] and pp["deezer_bpm"] > 0 and _tag_enabled(cfg, "deezer.tags.bpm"):
|
||||
bpm_candidates.append(("Deezer", pp["deezer_bpm"]))
|
||||
if pp["tidal_bpm"] and pp["tidal_bpm"] > 0 and _tag_enabled(cfg, "tidal.tags.bpm"):
|
||||
bpm_candidates.append(("Tidal", pp["tidal_bpm"]))
|
||||
if pp["hifi_bpm"] and pp["hifi_bpm"] > 0 and _tag_enabled(cfg, "hifi.tags.bpm"):
|
||||
bpm_candidates.append(("HiFi", pp["hifi_bpm"]))
|
||||
if bpm_candidates:
|
||||
bpm_source, bpm_val = bpm_candidates[0]
|
||||
bpm_int = int(bpm_val)
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
audio_file.tags.add(symbols.TBPM(encoding=3, text=[str(bpm_int)]))
|
||||
elif is_vorbis_like(audio_file, symbols):
|
||||
audio_file["BPM"] = [str(bpm_int)]
|
||||
elif isinstance(audio_file, symbols.MP4):
|
||||
audio_file["tmpo"] = [bpm_int]
|
||||
logger.info("BPM: %s", bpm_int)
|
||||
logger.info("BPM (%s): %s", bpm_source, bpm_int)
|
||||
|
||||
if _tag_enabled(cfg, "audiodb.tags.mood") and pp["audiodb_mood"]:
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
|
|
@ -699,6 +782,8 @@ def _write_embedded_metadata(audio_file, metadata: dict, pp: dict, cfg, symbols)
|
|||
isrc_candidates.append(("Deezer", pp["deezer_isrc"]))
|
||||
if pp["tidal_isrc"] and _tag_enabled(cfg, "tidal.tags.isrc"):
|
||||
isrc_candidates.append(("Tidal", pp["tidal_isrc"]))
|
||||
if pp["hifi_isrc"] and _tag_enabled(cfg, "hifi.tags.isrc"):
|
||||
isrc_candidates.append(("HiFi", pp["hifi_isrc"]))
|
||||
if pp["qobuz_isrc"] and _tag_enabled(cfg, "qobuz.tags.isrc"):
|
||||
isrc_candidates.append(("Qobuz", pp["qobuz_isrc"]))
|
||||
if isrc_candidates:
|
||||
|
|
@ -716,6 +801,8 @@ def _write_embedded_metadata(audio_file, metadata: dict, pp: dict, cfg, symbols)
|
|||
copyright_candidates.append(("Tidal", pp["tidal_copyright"]))
|
||||
if pp["qobuz_copyright"] and _tag_enabled(cfg, "qobuz.tags.copyright"):
|
||||
copyright_candidates.append(("Qobuz", pp["qobuz_copyright"]))
|
||||
if pp["hifi_copyright"] and _tag_enabled(cfg, "hifi.tags.copyright"):
|
||||
copyright_candidates.append(("HiFi", pp["hifi_copyright"]))
|
||||
if copyright_candidates:
|
||||
copyright_source, final_copyright = copyright_candidates[0]
|
||||
if isinstance(audio_file.tags, symbols.ID3):
|
||||
|
|
@ -872,8 +959,8 @@ def extract_source_metadata(context: dict, artist: dict, album_info: dict) -> di
|
|||
resolved = itunes_client.resolve_primary_artist(artist_id)
|
||||
if resolved and resolved != raw_album_artist:
|
||||
raw_album_artist = resolved
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("itunes primary artist resolve failed: %s", e)
|
||||
metadata["album_artist"] = raw_album_artist
|
||||
|
||||
if album_info.get("is_album"):
|
||||
|
|
@ -963,11 +1050,15 @@ def embed_source_ids(audio_file, metadata: dict, context: dict = None, runtime=N
|
|||
"isrc": None,
|
||||
"deezer_bpm": None,
|
||||
"deezer_isrc": None,
|
||||
"tidal_bpm": None,
|
||||
"hifi_bpm": None,
|
||||
"hifi_copyright": None,
|
||||
"audiodb_mood": None,
|
||||
"audiodb_style": None,
|
||||
"audiodb_genre": None,
|
||||
"tidal_isrc": None,
|
||||
"tidal_copyright": None,
|
||||
"hifi_isrc": None,
|
||||
"qobuz_isrc": None,
|
||||
"qobuz_copyright": None,
|
||||
"qobuz_label": None,
|
||||
|
|
@ -981,12 +1072,46 @@ def embed_source_ids(audio_file, metadata: dict, context: dict = None, runtime=N
|
|||
if not isinstance(source_order, list) or not source_order:
|
||||
source_order = DEFAULT_SOURCE_ORDER
|
||||
|
||||
# If this download came from HiFi, use cached metadata from the download
|
||||
# pipeline instead of re-searching the HiFi API.
|
||||
original_search = get_import_original_search(context)
|
||||
cached_meta = original_search.get("_source_metadata") or {}
|
||||
if cached_meta.get("source") == "hifi":
|
||||
if _tag_enabled(cfg, "hifi.embed_tags"):
|
||||
if cfg.get("hifi.tags.track_id", True) and cached_meta.get("track_id"):
|
||||
pp["id_tags"]["HIFI_TRACK_ID"] = str(cached_meta["track_id"])
|
||||
if cfg.get("hifi.tags.artist_id", True) and cached_meta.get("artist_id"):
|
||||
pp["id_tags"]["HIFI_ARTIST_ID"] = str(cached_meta["artist_id"])
|
||||
if cfg.get("hifi.tags.isrc", True) and cached_meta.get("isrc"):
|
||||
pp["hifi_isrc"] = cached_meta["isrc"]
|
||||
if cfg.get("hifi.tags.bpm", True) and cached_meta.get("bpm"):
|
||||
pp["hifi_bpm"] = cached_meta["bpm"]
|
||||
if cfg.get("hifi.tags.copyright", True) and cached_meta.get("copyright"):
|
||||
pp["hifi_copyright"] = cached_meta["copyright"]
|
||||
source_order = [s for s in source_order if s != "hifi"]
|
||||
|
||||
# If this download came from Tidal, use cached metadata from the download
|
||||
# pipeline instead of re-searching the Tidal API.
|
||||
if cached_meta.get("source") == "tidal":
|
||||
if _tag_enabled(cfg, "tidal.embed_tags"):
|
||||
if cfg.get("tidal.tags.track_id", True) and cached_meta.get("track_id"):
|
||||
pp["id_tags"]["TIDAL_TRACK_ID"] = str(cached_meta["track_id"])
|
||||
if cfg.get("tidal.tags.artist_id", True) and cached_meta.get("artist_id"):
|
||||
pp["id_tags"]["TIDAL_ARTIST_ID"] = str(cached_meta["artist_id"])
|
||||
if cfg.get("tidal.tags.isrc", True) and cached_meta.get("isrc"):
|
||||
pp["tidal_isrc"] = cached_meta["isrc"]
|
||||
if cfg.get("tidal.tags.bpm", True) and cached_meta.get("bpm"):
|
||||
pp["tidal_bpm"] = cached_meta["bpm"]
|
||||
if cfg.get("tidal.tags.copyright", True) and cached_meta.get("copyright"):
|
||||
pp["tidal_copyright"] = cached_meta["copyright"]
|
||||
source_order = [s for s in source_order if s != "tidal"]
|
||||
|
||||
db = get_database()
|
||||
|
||||
for source_name in source_order:
|
||||
_process_source_enrichment(source_name, pp, metadata, cfg, runtime, track_title, artist_name)
|
||||
|
||||
if not pp["id_tags"] and not pp["deezer_bpm"] and not pp["deezer_isrc"] and not pp["audiodb_mood"] and not pp["audiodb_style"]:
|
||||
if not pp["id_tags"] and not pp["deezer_bpm"] and not pp["deezer_isrc"] and not pp["tidal_bpm"] and not pp["hifi_bpm"] and not pp["hifi_copyright"] and not pp["audiodb_mood"] and not pp["audiodb_style"]:
|
||||
return
|
||||
|
||||
release_year = _write_embedded_metadata(audio_file, metadata, pp, cfg, symbols)
|
||||
|
|
@ -995,5 +1120,24 @@ def embed_source_ids(audio_file, metadata: dict, context: dict = None, runtime=N
|
|||
metadata["musicbrainz_release_id"] = release_id
|
||||
_update_album_year_in_database(db, metadata, release_year)
|
||||
|
||||
# Expose the final ID tag set + per-source values back to the
|
||||
# import context so downstream side-effects (notably
|
||||
# ``record_download_provenance``) can persist them to the
|
||||
# ``track_downloads`` table without re-collecting. Without this,
|
||||
# the watchlist scanner would have to wait for the async
|
||||
# enrichment workers to backfill ``tracks.spotify_track_id`` etc.
|
||||
# before recognizing freshly downloaded files.
|
||||
if isinstance(context, dict):
|
||||
try:
|
||||
context["_embedded_id_tags"] = dict(pp.get("id_tags") or {})
|
||||
isrc_value = (
|
||||
pp.get("isrc") or pp.get("deezer_isrc") or pp.get("tidal_isrc")
|
||||
or pp.get("hifi_isrc") or pp.get("qobuz_isrc")
|
||||
)
|
||||
if isrc_value:
|
||||
context["_isrc"] = str(isrc_value)
|
||||
except Exception as e:
|
||||
logger.debug("context isrc copy failed: %s", e)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Error embedding source IDs (non-fatal): %s", exc)
|
||||
|
|
|
|||
179
core/metadata/status.py
Normal file
179
core/metadata/status.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
"""Cached metadata-provider and Spotify status snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
from core.metadata.registry import get_primary_source_status
|
||||
|
||||
logger = get_logger("metadata.status")
|
||||
|
||||
METADATA_SOURCE_STATUS_TTL = 120
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
_status_lock = threading.RLock()
|
||||
_metadata_source_status_cache: Dict[str, Any] = {
|
||||
"source": "deezer",
|
||||
"connected": False,
|
||||
"response_time": 0,
|
||||
}
|
||||
_metadata_source_status_timestamp = 0.0
|
||||
_spotify_status_cache: Dict[str, Any] = {
|
||||
"connected": False,
|
||||
"authenticated": False,
|
||||
"rate_limited": False,
|
||||
"rate_limit": None,
|
||||
"post_ban_cooldown": None,
|
||||
}
|
||||
_spotify_status_timestamp = 0.0
|
||||
_spotify_status_initialized = False
|
||||
_spotify_rate_limit_expires_at = 0.0
|
||||
_spotify_post_ban_cooldown_expires_at = 0.0
|
||||
|
||||
|
||||
def invalidate_metadata_status_caches() -> None:
|
||||
"""Mark the cached metadata-source snapshot stale."""
|
||||
global _metadata_source_status_timestamp
|
||||
with _status_lock:
|
||||
_metadata_source_status_timestamp = 0.0
|
||||
|
||||
|
||||
def publish_spotify_status(
|
||||
*,
|
||||
connected: Any = _UNSET,
|
||||
authenticated: Any = _UNSET,
|
||||
rate_limited: Any = _UNSET,
|
||||
rate_limit: Any = _UNSET,
|
||||
post_ban_cooldown: Any = _UNSET,
|
||||
) -> Dict[str, Any]:
|
||||
"""Update the cached Spotify status snapshot from an event."""
|
||||
global _spotify_status_timestamp, _spotify_status_initialized
|
||||
global _spotify_rate_limit_expires_at, _spotify_post_ban_cooldown_expires_at
|
||||
|
||||
with _status_lock:
|
||||
if connected is not _UNSET:
|
||||
_spotify_status_cache["connected"] = connected
|
||||
if authenticated is not _UNSET:
|
||||
_spotify_status_cache["authenticated"] = authenticated
|
||||
if rate_limited is not _UNSET:
|
||||
_spotify_status_cache["rate_limited"] = rate_limited
|
||||
if rate_limit is not _UNSET:
|
||||
_spotify_status_cache["rate_limit"] = rate_limit
|
||||
if rate_limit and isinstance(rate_limit, dict):
|
||||
_spotify_rate_limit_expires_at = float(rate_limit.get("expires_at") or 0.0)
|
||||
else:
|
||||
_spotify_rate_limit_expires_at = 0.0
|
||||
if post_ban_cooldown is not _UNSET:
|
||||
_spotify_status_cache["post_ban_cooldown"] = post_ban_cooldown
|
||||
if post_ban_cooldown is not None:
|
||||
_spotify_post_ban_cooldown_expires_at = time.time() + max(0, float(post_ban_cooldown))
|
||||
else:
|
||||
_spotify_post_ban_cooldown_expires_at = 0.0
|
||||
_spotify_status_timestamp = time.time()
|
||||
_spotify_status_initialized = True
|
||||
_normalize_spotify_status_locked(_spotify_status_timestamp)
|
||||
return dict(_spotify_status_cache)
|
||||
|
||||
|
||||
def refresh_spotify_status_from_client(spotify_client: Optional[Any]) -> Dict[str, Any]:
|
||||
"""Probe Spotify once to seed the cache when no event has populated it yet."""
|
||||
if spotify_client is None:
|
||||
with _status_lock:
|
||||
return dict(_spotify_status_cache)
|
||||
|
||||
try:
|
||||
is_rate_limited = spotify_client.is_rate_limited() if spotify_client else False
|
||||
rate_limit_info = spotify_client.get_rate_limit_info() if (spotify_client and is_rate_limited) else None
|
||||
cooldown_remaining = spotify_client.get_post_ban_cooldown_remaining() if spotify_client else 0
|
||||
authenticated = spotify_client.is_spotify_authenticated() if spotify_client else False
|
||||
except Exception as exc:
|
||||
logger.debug("Spotify status probe failed: %s", exc)
|
||||
authenticated = False
|
||||
is_rate_limited = False
|
||||
rate_limit_info = None
|
||||
cooldown_remaining = 0
|
||||
|
||||
return publish_spotify_status(
|
||||
connected=authenticated,
|
||||
authenticated=authenticated,
|
||||
rate_limited=is_rate_limited,
|
||||
rate_limit=rate_limit_info,
|
||||
post_ban_cooldown=cooldown_remaining if cooldown_remaining > 0 else None,
|
||||
)
|
||||
|
||||
|
||||
def get_metadata_source_status() -> Dict[str, Any]:
|
||||
"""Return a cached snapshot for the active primary metadata source."""
|
||||
global _metadata_source_status_timestamp
|
||||
|
||||
current_time = time.time()
|
||||
with _status_lock:
|
||||
if _metadata_source_status_timestamp and current_time - _metadata_source_status_timestamp <= METADATA_SOURCE_STATUS_TTL:
|
||||
return dict(_metadata_source_status_cache)
|
||||
|
||||
try:
|
||||
status_data = get_primary_source_status()
|
||||
except Exception as exc:
|
||||
logger.debug("Metadata source status refresh failed: %s", exc)
|
||||
status_data = None
|
||||
|
||||
if status_data:
|
||||
with _status_lock:
|
||||
_metadata_source_status_cache.update(status_data)
|
||||
_metadata_source_status_timestamp = current_time
|
||||
|
||||
with _status_lock:
|
||||
return dict(_metadata_source_status_cache)
|
||||
|
||||
|
||||
def get_spotify_status(spotify_client: Optional[Any] = None) -> Dict[str, Any]:
|
||||
"""Return a cached Spotify-specific status snapshot."""
|
||||
with _status_lock:
|
||||
if _spotify_status_initialized:
|
||||
_normalize_spotify_status_locked(time.time())
|
||||
return dict(_spotify_status_cache)
|
||||
|
||||
return refresh_spotify_status_from_client(spotify_client)
|
||||
|
||||
|
||||
def _normalize_spotify_status_locked(current_time: float) -> None:
|
||||
"""Update derived Spotify status fields and clear expired ban state."""
|
||||
global _spotify_rate_limit_expires_at, _spotify_post_ban_cooldown_expires_at
|
||||
|
||||
rate_limit = _spotify_status_cache.get("rate_limit")
|
||||
if _spotify_status_cache.get("rate_limited") and rate_limit and isinstance(rate_limit, dict):
|
||||
expires_at = float(rate_limit.get("expires_at") or 0.0)
|
||||
if expires_at > 0:
|
||||
remaining = int(max(0, expires_at - current_time))
|
||||
if remaining > 0:
|
||||
_spotify_status_cache["rate_limit"] = {**rate_limit, "remaining_seconds": remaining}
|
||||
_spotify_rate_limit_expires_at = expires_at
|
||||
else:
|
||||
_spotify_status_cache["rate_limited"] = False
|
||||
_spotify_status_cache["rate_limit"] = None
|
||||
_spotify_rate_limit_expires_at = 0.0
|
||||
elif _spotify_rate_limit_expires_at and current_time >= _spotify_rate_limit_expires_at:
|
||||
_spotify_status_cache["rate_limited"] = False
|
||||
_spotify_status_cache["rate_limit"] = None
|
||||
_spotify_rate_limit_expires_at = 0.0
|
||||
|
||||
if _spotify_post_ban_cooldown_expires_at > 0:
|
||||
remaining = int(max(0, _spotify_post_ban_cooldown_expires_at - current_time))
|
||||
if remaining > 0:
|
||||
_spotify_status_cache["post_ban_cooldown"] = remaining
|
||||
else:
|
||||
_spotify_status_cache["post_ban_cooldown"] = None
|
||||
_spotify_post_ban_cooldown_expires_at = 0.0
|
||||
|
||||
|
||||
def get_status_snapshot(spotify_client: Optional[Any] = None) -> Dict[str, Any]:
|
||||
"""Return the combined metadata-provider status snapshot."""
|
||||
return {
|
||||
"metadata_source": get_metadata_source_status(),
|
||||
"spotify": get_spotify_status(spotify_client=spotify_client),
|
||||
}
|
||||
618
core/metadata/types.py
Normal file
618
core/metadata/types.py
Normal file
|
|
@ -0,0 +1,618 @@
|
|||
"""Canonical typed dataclasses for metadata across all providers.
|
||||
|
||||
The metadata pipeline historically grew organically: each new provider
|
||||
(Spotify → iTunes → Deezer → Tidal → Qobuz → MusicBrainz → AudioDB →
|
||||
Discogs → Hydrabase) returns its own response shape, and consumer code
|
||||
defensively extracts every field via fallback chains:
|
||||
|
||||
_extract_lookup_value(album_data, 'id', 'album_id', 'collectionId',
|
||||
'release_id', default=album_id)
|
||||
|
||||
That pattern works but is brittle: each new provider adds more keys to
|
||||
chase, each consumer re-runs the same defensive logic, and there's no
|
||||
contract about what shape any given consumer can trust.
|
||||
|
||||
This module is the canonical contract. Every provider produces these
|
||||
types via a single ``from_<provider>_dict()`` classmethod. Every
|
||||
consumer accepts these types and trusts the fields. Field names are
|
||||
provider-neutral (``release_date`` not ``releaseDate``,
|
||||
``image_url`` not ``artworkUrl100``).
|
||||
|
||||
This is the foundation PR. It only DEFINES the contract and provides
|
||||
the converters; no consumer is migrated in this PR. Future PRs each
|
||||
migrate one consumer to accept ``Album`` / ``Track`` / ``Artist``
|
||||
instead of raw dicts.
|
||||
|
||||
The ``Album`` / ``Track`` / ``Artist`` symbols also re-export from
|
||||
``core.itunes_client`` for backward compatibility — existing callers
|
||||
don't need to change anything.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers shared by converters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _str(value: Any, default: str = '') -> str:
|
||||
"""Coerce to non-None str, never None."""
|
||||
if value is None:
|
||||
return default
|
||||
return str(value)
|
||||
|
||||
|
||||
def _int(value: Any, default: int = 0) -> int:
|
||||
"""Coerce to int, default on parse failure."""
|
||||
if value is None or value == '':
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _strip_discogs_disambiguation(name: str) -> str:
|
||||
"""Discogs appends ``(N)`` to artist names when there are multiple
|
||||
artists with the same name. Strip so cross-provider matches work."""
|
||||
return re.sub(r'\s*\(\d+\)$', '', name or '').strip()
|
||||
|
||||
|
||||
def _itunes_artwork(url: Optional[str]) -> Optional[str]:
|
||||
"""iTunes serves cover art at any size by template substitution.
|
||||
Always upgrade ``100x100bb`` → ``3000x3000bb`` for highest quality."""
|
||||
if not url:
|
||||
return None
|
||||
return url.replace('100x100bb', '3000x3000bb')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Album
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Album:
|
||||
"""Provider-neutral album.
|
||||
|
||||
Required fields are guaranteed to be set by every converter. Optional
|
||||
fields are explicit ``Optional[...]`` so consumers know they may be
|
||||
None / empty. Source-specific raw IDs that don't fit the typed schema
|
||||
can be stashed in ``external_ids`` (provider name → id string).
|
||||
"""
|
||||
|
||||
id: str # Source-native id, always set
|
||||
name: str # Album title, always set
|
||||
artists: List[str] # Display names, may be ['Unknown Artist']
|
||||
release_date: str # ISO 'YYYY' or 'YYYY-MM-DD' or '' when unknown
|
||||
total_tracks: int # 0 when unknown
|
||||
album_type: str # 'album' / 'single' / 'ep' / 'compilation'
|
||||
|
||||
# Optional but commonly populated
|
||||
image_url: Optional[str] = None # Highest-quality cover URL
|
||||
artist_id: Optional[str] = None # Primary artist's source-native id
|
||||
genres: List[str] = field(default_factory=list)
|
||||
label: Optional[str] = None # Record label / publisher
|
||||
barcode: Optional[str] = None # UPC/EAN — Discogs/MusicBrainz only
|
||||
|
||||
# Source provenance
|
||||
source: str = '' # 'spotify' / 'itunes' / etc — set by converter
|
||||
external_ids: Dict[str, str] = field(default_factory=dict)
|
||||
external_urls: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Per-source converters. Each one is the SINGLE source of truth for
|
||||
# how that provider's response maps to the canonical Album. Adding
|
||||
# a new provider = adding one more converter here. Consumer code
|
||||
# never needs to know any provider's wire shape.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def from_spotify_dict(cls, raw: Dict[str, Any]) -> 'Album':
|
||||
"""Spotify Web API ``/albums/{id}`` response shape."""
|
||||
artists_raw = raw.get('artists') or []
|
||||
artist_names = [_str(a.get('name')) for a in artists_raw
|
||||
if isinstance(a, dict) and a.get('name')]
|
||||
primary_artist_id = ''
|
||||
if artists_raw and isinstance(artists_raw[0], dict):
|
||||
primary_artist_id = _str(artists_raw[0].get('id'))
|
||||
|
||||
images = raw.get('images') or []
|
||||
image_url = None
|
||||
if images and isinstance(images[0], dict):
|
||||
image_url = _str(images[0].get('url')) or None
|
||||
|
||||
external_ids = {}
|
||||
if raw.get('id'):
|
||||
external_ids['spotify'] = _str(raw['id'])
|
||||
upc = (raw.get('external_ids') or {}).get('upc')
|
||||
if upc:
|
||||
external_ids['upc'] = _str(upc)
|
||||
|
||||
external_urls = {}
|
||||
sp_url = (raw.get('external_urls') or {}).get('spotify')
|
||||
if sp_url:
|
||||
external_urls['spotify'] = _str(sp_url)
|
||||
|
||||
return cls(
|
||||
id=_str(raw.get('id')),
|
||||
name=_str(raw.get('name')),
|
||||
artists=artist_names or ['Unknown Artist'],
|
||||
release_date=_str(raw.get('release_date')),
|
||||
total_tracks=_int(raw.get('total_tracks')),
|
||||
album_type=_str(raw.get('album_type'), default='album'),
|
||||
image_url=image_url,
|
||||
artist_id=primary_artist_id or None,
|
||||
genres=list(raw.get('genres') or []),
|
||||
label=_str(raw.get('label')) or None,
|
||||
barcode=external_ids.get('upc'),
|
||||
source='spotify',
|
||||
external_ids=external_ids,
|
||||
external_urls=external_urls,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_itunes_dict(cls, raw: Dict[str, Any]) -> 'Album':
|
||||
"""iTunes Search API album response shape (`collectionType=Album`)."""
|
||||
track_count = _int(raw.get('trackCount'))
|
||||
|
||||
# iTunes doesn't tag album type; infer from track count + collectionType.
|
||||
collection_type = _str(raw.get('collectionType'), default='Album')
|
||||
if 'compilation' in collection_type.lower():
|
||||
album_type = 'compilation'
|
||||
elif track_count <= 3:
|
||||
album_type = 'single'
|
||||
elif track_count <= 6:
|
||||
album_type = 'ep'
|
||||
else:
|
||||
album_type = 'album'
|
||||
|
||||
artist_id = _str(raw.get('artistId')) or None
|
||||
external_ids = {}
|
||||
if raw.get('collectionId'):
|
||||
external_ids['itunes'] = _str(raw['collectionId'])
|
||||
if artist_id:
|
||||
external_ids['itunes_artist'] = artist_id
|
||||
|
||||
external_urls = {}
|
||||
if raw.get('collectionViewUrl'):
|
||||
external_urls['itunes'] = _str(raw['collectionViewUrl'])
|
||||
|
||||
# Strip iTunes "(Single)" / "(EP)" / "(Deluxe)" suffixes from name
|
||||
# the same way the existing _clean_itunes_album_name helper does.
|
||||
name = _str(raw.get('collectionName'))
|
||||
name = re.sub(r'\s*[-(]\s*(Single|EP)\s*[)]?$', '', name, flags=re.IGNORECASE).strip()
|
||||
|
||||
release_date = _str(raw.get('releaseDate'))
|
||||
if release_date and 'T' in release_date:
|
||||
release_date = release_date.split('T', 1)[0]
|
||||
|
||||
primary_genre = _str(raw.get('primaryGenreName'))
|
||||
return cls(
|
||||
id=_str(raw.get('collectionId')),
|
||||
name=name,
|
||||
artists=[_str(raw.get('artistName'), default='Unknown Artist')],
|
||||
release_date=release_date,
|
||||
total_tracks=track_count,
|
||||
album_type=album_type,
|
||||
image_url=_itunes_artwork(raw.get('artworkUrl100')),
|
||||
artist_id=artist_id,
|
||||
genres=[primary_genre] if primary_genre else [],
|
||||
source='itunes',
|
||||
external_ids=external_ids,
|
||||
external_urls=external_urls,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_deezer_dict(cls, raw: Dict[str, Any]) -> 'Album':
|
||||
"""Deezer API ``/album/{id}`` response shape."""
|
||||
artist = raw.get('artist') or {}
|
||||
artist_name = _str(artist.get('name'), default='Unknown Artist') if isinstance(artist, dict) else _str(artist) or 'Unknown Artist'
|
||||
artist_id = _str(artist.get('id')) if isinstance(artist, dict) else ''
|
||||
|
||||
# Deezer cover URLs come in size suffixes (cover_xl, cover_big,
|
||||
# cover_medium, cover_small). Prefer xl.
|
||||
image_url = (
|
||||
_str(raw.get('cover_xl'))
|
||||
or _str(raw.get('cover_big'))
|
||||
or _str(raw.get('cover_medium'))
|
||||
or _str(raw.get('cover'))
|
||||
or None
|
||||
)
|
||||
|
||||
record_type = _str(raw.get('record_type'), default='album').lower()
|
||||
album_type = {'single': 'single', 'ep': 'ep'}.get(record_type, 'album')
|
||||
|
||||
external_ids = {}
|
||||
if raw.get('id'):
|
||||
external_ids['deezer'] = _str(raw['id'])
|
||||
if raw.get('upc'):
|
||||
external_ids['upc'] = _str(raw['upc'])
|
||||
|
||||
external_urls = {}
|
||||
if raw.get('link'):
|
||||
external_urls['deezer'] = _str(raw['link'])
|
||||
|
||||
return cls(
|
||||
id=_str(raw.get('id')),
|
||||
name=_str(raw.get('title')),
|
||||
artists=[artist_name],
|
||||
release_date=_str(raw.get('release_date')),
|
||||
total_tracks=_int(raw.get('nb_tracks')),
|
||||
album_type=album_type,
|
||||
image_url=image_url,
|
||||
artist_id=artist_id or None,
|
||||
genres=[g.get('name', '') for g in (raw.get('genres', {}) or {}).get('data', [])
|
||||
if isinstance(g, dict) and g.get('name')],
|
||||
label=_str(raw.get('label')) or None,
|
||||
barcode=external_ids.get('upc'),
|
||||
source='deezer',
|
||||
external_ids=external_ids,
|
||||
external_urls=external_urls,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_discogs_dict(cls, raw: Dict[str, Any]) -> 'Album':
|
||||
"""Discogs API ``/releases/{id}`` response shape."""
|
||||
artists_raw = raw.get('artists') or []
|
||||
artist_names = []
|
||||
primary_artist_id = ''
|
||||
for a in artists_raw:
|
||||
if not isinstance(a, dict):
|
||||
continue
|
||||
name = _strip_discogs_disambiguation(_str(a.get('name')))
|
||||
if name:
|
||||
artist_names.append(name)
|
||||
if not primary_artist_id and a.get('id'):
|
||||
primary_artist_id = _str(a['id'])
|
||||
|
||||
images = raw.get('images') or []
|
||||
image_url = None
|
||||
if images and isinstance(images[0], dict):
|
||||
image_url = _str(images[0].get('uri') or images[0].get('uri150')) or None
|
||||
|
||||
# Discogs `tracklist` is the source of total_tracks.
|
||||
tracklist = raw.get('tracklist') or []
|
||||
total_tracks = sum(1 for t in tracklist if isinstance(t, dict)
|
||||
and t.get('type_') == 'track')
|
||||
if not total_tracks:
|
||||
total_tracks = len(tracklist)
|
||||
|
||||
labels = raw.get('labels') or []
|
||||
label_name = ''
|
||||
if labels and isinstance(labels[0], dict):
|
||||
label_name = _str(labels[0].get('name'))
|
||||
|
||||
external_ids = {}
|
||||
if raw.get('id'):
|
||||
external_ids['discogs'] = _str(raw['id'])
|
||||
# Discogs `identifiers` array can include barcode entries
|
||||
for ident in raw.get('identifiers', []) or []:
|
||||
if isinstance(ident, dict) and ident.get('type', '').lower() == 'barcode':
|
||||
bc = _str(ident.get('value')).strip()
|
||||
if bc:
|
||||
external_ids['barcode'] = bc
|
||||
break
|
||||
|
||||
external_urls = {}
|
||||
if raw.get('uri'):
|
||||
external_urls['discogs'] = _str(raw['uri'])
|
||||
|
||||
year = raw.get('year')
|
||||
release_date = str(year) if year and _int(year) > 0 else ''
|
||||
|
||||
return cls(
|
||||
id=_str(raw.get('id')),
|
||||
name=_str(raw.get('title')),
|
||||
artists=artist_names or ['Unknown Artist'],
|
||||
release_date=release_date,
|
||||
total_tracks=total_tracks,
|
||||
album_type='album', # Discogs doesn't tag this; default to album
|
||||
image_url=image_url,
|
||||
artist_id=primary_artist_id or None,
|
||||
genres=list(raw.get('genres') or []) + list(raw.get('styles') or []),
|
||||
label=label_name or None,
|
||||
barcode=external_ids.get('barcode'),
|
||||
source='discogs',
|
||||
external_ids=external_ids,
|
||||
external_urls=external_urls,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_musicbrainz_dict(cls, raw: Dict[str, Any]) -> 'Album':
|
||||
"""MusicBrainz ``/release/{mbid}`` response shape (release, not release-group)."""
|
||||
artist_credit = raw.get('artist-credit') or []
|
||||
artist_names = []
|
||||
primary_artist_id = ''
|
||||
for credit in artist_credit:
|
||||
if isinstance(credit, dict) and 'artist' in credit:
|
||||
name = _str(credit['artist'].get('name'))
|
||||
if name:
|
||||
artist_names.append(name)
|
||||
if not primary_artist_id and credit['artist'].get('id'):
|
||||
primary_artist_id = _str(credit['artist']['id'])
|
||||
|
||||
# Total tracks: sum across media (MB stores per-disc).
|
||||
media = raw.get('media') or []
|
||||
total_tracks = sum(_int(m.get('track-count')) for m in media if isinstance(m, dict))
|
||||
|
||||
external_ids = {}
|
||||
if raw.get('id'):
|
||||
external_ids['musicbrainz'] = _str(raw['id'])
|
||||
if raw.get('barcode'):
|
||||
external_ids['barcode'] = _str(raw['barcode'])
|
||||
|
||||
# MB `release-group` carries the album-level type (album/single/ep)
|
||||
rg = raw.get('release-group') or {}
|
||||
primary_type = _str(rg.get('primary-type'), default='Album').lower()
|
||||
album_type = {'single': 'single', 'ep': 'ep'}.get(primary_type, 'album')
|
||||
if rg.get('id'):
|
||||
external_ids['musicbrainz_release_group'] = _str(rg['id'])
|
||||
|
||||
labels = raw.get('label-info') or []
|
||||
label_name = ''
|
||||
if labels and isinstance(labels[0], dict):
|
||||
lbl = labels[0].get('label') or {}
|
||||
label_name = _str(lbl.get('name'))
|
||||
|
||||
return cls(
|
||||
id=_str(raw.get('id')),
|
||||
name=_str(raw.get('title')),
|
||||
artists=artist_names or ['Unknown Artist'],
|
||||
release_date=_str(raw.get('date')),
|
||||
total_tracks=total_tracks,
|
||||
album_type=album_type,
|
||||
image_url=None, # MB doesn't serve cover art directly; CAA is separate
|
||||
artist_id=primary_artist_id or None,
|
||||
genres=[], # MB has tags but they're noisy; consumer can fetch separately
|
||||
label=label_name or None,
|
||||
barcode=external_ids.get('barcode'),
|
||||
source='musicbrainz',
|
||||
external_ids=external_ids,
|
||||
external_urls={},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_qobuz_dict(cls, raw: Dict[str, Any]) -> 'Album':
|
||||
"""Qobuz API ``album/get`` response shape."""
|
||||
artist = raw.get('artist') or {}
|
||||
artist_name = _str(artist.get('name'), default='Unknown Artist') if isinstance(artist, dict) else _str(artist) or 'Unknown Artist'
|
||||
artist_id = _str(artist.get('id')) if isinstance(artist, dict) else ''
|
||||
|
||||
# Qobuz `image` is a dict with small/large/thumbnail variants.
|
||||
image = raw.get('image') or {}
|
||||
image_url = None
|
||||
if isinstance(image, dict):
|
||||
image_url = (
|
||||
_str(image.get('large'))
|
||||
or _str(image.get('small'))
|
||||
or _str(image.get('thumbnail'))
|
||||
or None
|
||||
)
|
||||
|
||||
external_ids = {}
|
||||
if raw.get('id'):
|
||||
external_ids['qobuz'] = _str(raw['id'])
|
||||
if raw.get('upc'):
|
||||
external_ids['upc'] = _str(raw['upc'])
|
||||
|
||||
external_urls = {}
|
||||
if raw.get('url'):
|
||||
external_urls['qobuz'] = _str(raw['url'])
|
||||
|
||||
# Qobuz exposes both `release_date_original` (vinyl/original
|
||||
# press date) and `released_at` (digital release timestamp).
|
||||
# Prefer the original date for cross-provider matching.
|
||||
release_date = _str(raw.get('release_date_original') or raw.get('released_at'))
|
||||
if release_date and 'T' in release_date:
|
||||
release_date = release_date.split('T', 1)[0]
|
||||
|
||||
genre = raw.get('genre') or {}
|
||||
genre_name = _str(genre.get('name')) if isinstance(genre, dict) else _str(genre)
|
||||
|
||||
label = raw.get('label') or {}
|
||||
label_name = _str(label.get('name')) if isinstance(label, dict) else _str(label)
|
||||
|
||||
return cls(
|
||||
id=_str(raw.get('id')),
|
||||
name=_str(raw.get('title')),
|
||||
artists=[artist_name],
|
||||
release_date=release_date,
|
||||
total_tracks=_int(raw.get('tracks_count')),
|
||||
album_type='album', # Qobuz doesn't tag this consistently
|
||||
image_url=image_url,
|
||||
artist_id=artist_id or None,
|
||||
genres=[genre_name] if genre_name else [],
|
||||
label=label_name or None,
|
||||
barcode=external_ids.get('upc'),
|
||||
source='qobuz',
|
||||
external_ids=external_ids,
|
||||
external_urls=external_urls,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_tidal_object(cls, obj: Any) -> 'Album':
|
||||
"""tidalapi ``Album`` object shape.
|
||||
|
||||
Tidal goes through the ``tidalapi`` library which returns
|
||||
Python objects, not raw dicts — so this converter is named
|
||||
``from_tidal_object`` to make the input contract explicit.
|
||||
Duck-types attribute access so unit tests can pass simple
|
||||
SimpleNamespace stand-ins."""
|
||||
artist = getattr(obj, 'artist', None)
|
||||
artist_name = _str(getattr(artist, 'name', None), default='Unknown Artist')
|
||||
artist_id = _str(getattr(artist, 'id', '')) if artist else ''
|
||||
|
||||
# tidalapi exposes `image()` as a method that returns a URL at
|
||||
# a given size. Try a sensible default size; fall back to the
|
||||
# `picture` field (the raw image id) if the method's missing.
|
||||
image_url = None
|
||||
try:
|
||||
if hasattr(obj, 'image') and callable(obj.image):
|
||||
image_url = obj.image(640) or None
|
||||
except Exception:
|
||||
image_url = None
|
||||
if not image_url:
|
||||
picture = _str(getattr(obj, 'picture', ''))
|
||||
if picture:
|
||||
# Tidal CDN URL format
|
||||
pic_path = picture.replace('-', '/')
|
||||
image_url = f"https://resources.tidal.com/images/{pic_path}/640x640.jpg"
|
||||
|
||||
release_date = ''
|
||||
rd = getattr(obj, 'release_date', None)
|
||||
if rd is not None:
|
||||
release_date = _str(rd).split('T')[0] if 'T' in _str(rd) else _str(rd)
|
||||
|
||||
external_ids = {}
|
||||
if getattr(obj, 'id', None):
|
||||
external_ids['tidal'] = _str(obj.id)
|
||||
if getattr(obj, 'universal_product_number', None):
|
||||
external_ids['upc'] = _str(obj.universal_product_number)
|
||||
|
||||
return cls(
|
||||
id=_str(getattr(obj, 'id', '')),
|
||||
name=_str(getattr(obj, 'name', '')),
|
||||
artists=[artist_name],
|
||||
release_date=release_date,
|
||||
total_tracks=_int(getattr(obj, 'num_tracks', 0)),
|
||||
album_type=_str(getattr(obj, 'type', None), default='album').lower() or 'album',
|
||||
image_url=image_url,
|
||||
artist_id=artist_id or None,
|
||||
genres=[], # tidalapi doesn't expose genres on Album
|
||||
barcode=external_ids.get('upc'),
|
||||
source='tidal',
|
||||
external_ids=external_ids,
|
||||
external_urls={},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_hydrabase_dict(cls, raw: Dict[str, Any]) -> 'Album':
|
||||
"""Hydrabase metadata service response shape."""
|
||||
artists_raw = raw.get('artists') or []
|
||||
if isinstance(artists_raw, str):
|
||||
artist_names = [artists_raw]
|
||||
else:
|
||||
artist_names = []
|
||||
for a in artists_raw:
|
||||
if isinstance(a, dict):
|
||||
name = _str(a.get('name'))
|
||||
else:
|
||||
name = _str(a)
|
||||
if name:
|
||||
artist_names.append(name)
|
||||
|
||||
external_ids = {}
|
||||
if raw.get('id'):
|
||||
external_ids['hydrabase'] = _str(raw['id'])
|
||||
if raw.get('soul_id'):
|
||||
external_ids['soul'] = _str(raw['soul_id'])
|
||||
|
||||
return cls(
|
||||
id=_str(raw.get('id')),
|
||||
name=_str(raw.get('name') or raw.get('title')),
|
||||
artists=artist_names or ['Unknown Artist'],
|
||||
release_date=_str(raw.get('release_date')),
|
||||
total_tracks=_int(raw.get('total_tracks')),
|
||||
album_type=_str(raw.get('album_type'), default='album'),
|
||||
image_url=_str(raw.get('image_url') or raw.get('thumb_url')) or None,
|
||||
artist_id=_str(raw.get('artist_id')) or None,
|
||||
source='hydrabase',
|
||||
external_ids=external_ids,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Consumer-side helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def to_context_dict(self) -> Dict[str, Any]:
|
||||
"""Return the canonical dict shape SoulSync's import / download
|
||||
pipelines expect. This is the bridge between typed metadata and
|
||||
the existing dict-passing internal API. Future PRs migrate
|
||||
consumers off this dict shape and onto the typed Album directly,
|
||||
at which point this helper becomes unnecessary."""
|
||||
primary_artist = self.artists[0] if self.artists else 'Unknown Artist'
|
||||
artists_dicts = [{'name': name, 'id': self.artist_id if i == 0 else ''}
|
||||
for i, name in enumerate(self.artists)]
|
||||
images = [{'url': self.image_url}] if self.image_url else []
|
||||
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'artist': primary_artist,
|
||||
'artist_name': primary_artist,
|
||||
'artist_id': self.artist_id or '',
|
||||
'artists': artists_dicts,
|
||||
'image_url': self.image_url,
|
||||
'images': images,
|
||||
'release_date': self.release_date,
|
||||
'album_type': self.album_type,
|
||||
'total_tracks': self.total_tracks,
|
||||
'source': self.source,
|
||||
'genres': list(self.genres),
|
||||
'label': self.label or '',
|
||||
'barcode': self.barcode or '',
|
||||
'external_ids': dict(self.external_ids),
|
||||
'external_urls': dict(self.external_urls),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Track and Artist — kept lighter for now. Future PRs flesh these out
|
||||
# in the same per-source-converter pattern as Album.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Track:
|
||||
"""Provider-neutral track. Required fields are always populated by
|
||||
every provider's converter; optional fields may be None."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
artists: List[str]
|
||||
album: str
|
||||
duration_ms: int
|
||||
|
||||
# Optional
|
||||
track_number: Optional[int] = None
|
||||
disc_number: Optional[int] = None
|
||||
image_url: Optional[str] = None
|
||||
release_date: Optional[str] = None
|
||||
album_type: Optional[str] = None
|
||||
total_tracks: Optional[int] = None
|
||||
preview_url: Optional[str] = None
|
||||
isrc: Optional[str] = None
|
||||
popularity: int = 0 # Spotify-only; 0 elsewhere
|
||||
|
||||
# Source provenance
|
||||
source: str = ''
|
||||
external_ids: Dict[str, str] = field(default_factory=dict)
|
||||
external_urls: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Artist:
|
||||
"""Provider-neutral artist."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
|
||||
# Optional
|
||||
image_url: Optional[str] = None
|
||||
genres: List[str] = field(default_factory=list)
|
||||
popularity: int = 0 # Spotify-only; 0 elsewhere
|
||||
followers: int = 0 # Spotify-only; 0 elsewhere
|
||||
|
||||
# Source provenance
|
||||
source: str = ''
|
||||
external_ids: Dict[str, str] = field(default_factory=dict)
|
||||
external_urls: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
__all__ = ['Album', 'Track', 'Artist']
|
||||
|
|
@ -141,8 +141,8 @@ class MusicBrainzWorker:
|
|||
itype = item.get('type', '')
|
||||
table = 'artists' if 'artist' in itype else ('albums' if 'album' in itype else 'tracks')
|
||||
# Can't mark status without an ID — just skip
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("null id table resolve failed: %s", e)
|
||||
continue
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,33 +2,19 @@ import requests
|
|||
import hashlib
|
||||
import secrets
|
||||
from typing import List, Optional, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import json
|
||||
from utils.logging_config import get_logger
|
||||
from config.settings import config_manager
|
||||
|
||||
# Shared dataclasses live in the neutral media_server package — every
|
||||
# server client used to define a near-identical XTrackInfo /
|
||||
# XPlaylistInfo. Lifted to one canonical type so consumers (matching
|
||||
# engine, sync service) get a single import.
|
||||
from core.media_server.types import TrackInfo, PlaylistInfo
|
||||
|
||||
logger = get_logger("navidrome_client")
|
||||
|
||||
@dataclass
|
||||
class NavidromeTrackInfo:
|
||||
id: str
|
||||
title: str
|
||||
artist: str
|
||||
album: str
|
||||
duration: int
|
||||
track_number: Optional[int] = None
|
||||
year: Optional[int] = None
|
||||
rating: Optional[float] = None
|
||||
|
||||
@dataclass
|
||||
class NavidromePlaylistInfo:
|
||||
id: str
|
||||
title: str
|
||||
description: Optional[str]
|
||||
duration: int
|
||||
leaf_count: int
|
||||
tracks: List[NavidromeTrackInfo]
|
||||
|
||||
class NavidromeArtist:
|
||||
"""Wrapper class to mimic Plex artist object interface"""
|
||||
|
|
@ -117,6 +103,14 @@ class NavidromeTrack:
|
|||
self.suffix = navidrome_data.get('suffix') # e.g. "flac", "mp3"
|
||||
self.bitRate = navidrome_data.get('bitRate') # e.g. 320
|
||||
self.path = navidrome_data.get('path') # e.g. "/music/Artist/Album/track.flac"
|
||||
# File size in bytes (Subsonic <song size="..."/>) — powers the
|
||||
# Library Disk Usage card on Stats. None when the server didn't
|
||||
# report a size (rare but possible for streaming-only nodes).
|
||||
_nv_size = navidrome_data.get('size')
|
||||
try:
|
||||
self.file_size = int(_nv_size) if _nv_size else None
|
||||
except (TypeError, ValueError):
|
||||
self.file_size = None
|
||||
|
||||
self._album_id = navidrome_data.get('albumId', '')
|
||||
self._artist_id = navidrome_data.get('artistId', '')
|
||||
|
|
@ -142,7 +136,10 @@ class NavidromeTrack:
|
|||
return self._client.get_album_by_id(self._album_id)
|
||||
return None
|
||||
|
||||
class NavidromeClient:
|
||||
from core.media_server.contract import MediaServerClient
|
||||
|
||||
|
||||
class NavidromeClient(MediaServerClient):
|
||||
def __init__(self):
|
||||
self.base_url: Optional[str] = None
|
||||
self.username: Optional[str] = None
|
||||
|
|
@ -816,7 +813,7 @@ class NavidromeClient:
|
|||
return {}
|
||||
return {}
|
||||
|
||||
def get_all_playlists(self) -> List[NavidromePlaylistInfo]:
|
||||
def get_all_playlists(self) -> List[PlaylistInfo]:
|
||||
"""Get all playlists from Navidrome server"""
|
||||
if not self.ensure_connection():
|
||||
return []
|
||||
|
|
@ -830,7 +827,7 @@ class NavidromeClient:
|
|||
playlists_data = response.get('playlists', {}).get('playlist', [])
|
||||
|
||||
for playlist_data in playlists_data:
|
||||
playlist_info = NavidromePlaylistInfo(
|
||||
playlist_info = PlaylistInfo(
|
||||
id=playlist_data.get('id', ''),
|
||||
title=playlist_data.get('name', 'Unknown Playlist'),
|
||||
description=playlist_data.get('comment'),
|
||||
|
|
@ -847,7 +844,7 @@ class NavidromeClient:
|
|||
logger.error(f"Error getting playlists from Navidrome: {e}")
|
||||
return []
|
||||
|
||||
def get_playlist_by_name(self, name: str) -> Optional[NavidromePlaylistInfo]:
|
||||
def get_playlist_by_name(self, name: str) -> Optional[PlaylistInfo]:
|
||||
"""Get a specific playlist by name"""
|
||||
playlists = self.get_all_playlists()
|
||||
for playlist in playlists:
|
||||
|
|
@ -925,8 +922,8 @@ class NavidromeClient:
|
|||
if target_playlist:
|
||||
self._make_request('deletePlaylist', {'id': target_playlist.id})
|
||||
logger.info(f"Deleted existing backup playlist '{target_name}'")
|
||||
except Exception:
|
||||
pass # Target doesn't exist, which is fine
|
||||
except Exception as e:
|
||||
logger.debug("backup playlist precheck: %s", e)
|
||||
|
||||
# Create new playlist with copied tracks
|
||||
try:
|
||||
|
|
@ -968,7 +965,7 @@ class NavidromeClient:
|
|||
logger.error(f"Error getting tracks for playlist {playlist_id}: {e}")
|
||||
return []
|
||||
|
||||
def get_playlists_by_name(self, name: str) -> List[NavidromePlaylistInfo]:
|
||||
def get_playlists_by_name(self, name: str) -> List[PlaylistInfo]:
|
||||
"""Get all playlists matching a specific name (case-insensitive)"""
|
||||
matches = []
|
||||
playlists = self.get_all_playlists()
|
||||
|
|
@ -1132,7 +1129,7 @@ class NavidromeClient:
|
|||
self._track_cache.clear()
|
||||
logger.info("Navidrome client cache cleared")
|
||||
|
||||
def search_tracks(self, title: str, artist: str, limit: int = 15) -> List[NavidromeTrackInfo]:
|
||||
def search_tracks(self, title: str, artist: str, limit: int = 15) -> List[TrackInfo]:
|
||||
"""Search for tracks using Navidrome search API"""
|
||||
if not self.ensure_connection():
|
||||
logger.warning("Navidrome not connected. Cannot perform search.")
|
||||
|
|
@ -1158,7 +1155,7 @@ class NavidromeClient:
|
|||
search_result = response.get('searchResult3', {})
|
||||
|
||||
for track_data in search_result.get('song', []):
|
||||
track_info = NavidromeTrackInfo(
|
||||
track_info = TrackInfo(
|
||||
id=track_data.get('id', ''),
|
||||
title=track_data.get('title', ''),
|
||||
artist=track_data.get('artist', ''),
|
||||
|
|
|
|||
|
|
@ -176,8 +176,8 @@ def playlist_explorer_build_tree(deps: PlaylistExplorerDeps):
|
|||
artist_image = images[0].get('url') if images else None
|
||||
elif hasattr(artist_info, 'image_url'):
|
||||
artist_image = artist_info.image_url
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("artist image resolve: %s", e)
|
||||
else:
|
||||
# No pre-resolved ID — search by name
|
||||
try:
|
||||
|
|
@ -224,8 +224,8 @@ def playlist_explorer_build_tree(deps: PlaylistExplorerDeps):
|
|||
cursor.execute("SELECT title FROM albums WHERE artist_id = ?", (ar['id'],))
|
||||
for alb_row in cursor.fetchall():
|
||||
owned_titles.add((alb_row['title'] or '').strip().lower())
|
||||
except Exception:
|
||||
pass # Non-critical — owned badges just won't show
|
||||
except Exception as e:
|
||||
logger.debug("owned-titles lookup: %s", e)
|
||||
|
||||
# Build release list
|
||||
releases = []
|
||||
|
|
@ -256,8 +256,8 @@ def playlist_explorer_build_tree(deps: PlaylistExplorerDeps):
|
|||
if cache and releases:
|
||||
try:
|
||||
cache.store_entity(source_name, 'artist_discography', cache_key, result)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("cache discography write: %s", e)
|
||||
|
||||
return result
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from plexapi.audio import Track as PlexTrack, Album as PlexAlbum, Artist as Plex
|
|||
from plexapi.playlist import Playlist as PlexPlaylist
|
||||
from plexapi.exceptions import PlexApiException, NotFound
|
||||
from typing import List, Optional, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
import re
|
||||
|
|
@ -12,72 +11,36 @@ from utils.logging_config import get_logger
|
|||
from config.settings import config_manager
|
||||
import threading
|
||||
|
||||
# Shared dataclasses live in the neutral media_server package now —
|
||||
# every server client used to define a near-identical XTrackInfo /
|
||||
# XPlaylistInfo. Lifted to one canonical type so consumers (matching
|
||||
# engine, sync service, etc.) get a single import.
|
||||
from core.media_server.types import TrackInfo, PlaylistInfo
|
||||
from core.media_server.contract import MediaServerClient
|
||||
|
||||
logger = get_logger("plex_client")
|
||||
|
||||
@dataclass
|
||||
class PlexTrackInfo:
|
||||
id: str
|
||||
title: str
|
||||
artist: str
|
||||
album: str
|
||||
duration: int
|
||||
track_number: Optional[int] = None
|
||||
year: Optional[int] = None
|
||||
rating: Optional[float] = None
|
||||
|
||||
@classmethod
|
||||
def from_plex_track(cls, track: PlexTrack) -> 'PlexTrackInfo':
|
||||
# Gracefully handle tracks that might be missing artist or album metadata in Plex
|
||||
try:
|
||||
artist_title = track.artist().title if track.artist() else "Unknown Artist"
|
||||
except (NotFound, AttributeError):
|
||||
artist_title = "Unknown Artist"
|
||||
|
||||
try:
|
||||
album_title = track.album().title if track.album() else "Unknown Album"
|
||||
except (NotFound, AttributeError):
|
||||
album_title = "Unknown Album"
|
||||
|
||||
return cls(
|
||||
id=str(track.ratingKey),
|
||||
title=track.title,
|
||||
artist=artist_title,
|
||||
album=album_title,
|
||||
duration=track.duration,
|
||||
track_number=track.trackNumber,
|
||||
year=track.year,
|
||||
rating=track.userRating
|
||||
)
|
||||
# Sentinel value stored in the ``plex_music_library`` DB preference when
|
||||
# the user picks "All Libraries (combined)". Detection is one string
|
||||
# compare in ``_find_music_library`` / ``set_music_library_by_name``.
|
||||
# Existing prefs (real library names) are unaffected — only this exact
|
||||
# string flips the client into multi-section read mode.
|
||||
ALL_LIBRARIES_SENTINEL = '__all_libraries__'
|
||||
|
||||
@dataclass
|
||||
class PlexPlaylistInfo:
|
||||
id: str
|
||||
title: str
|
||||
description: Optional[str]
|
||||
duration: int
|
||||
leaf_count: int
|
||||
tracks: List[PlexTrackInfo]
|
||||
|
||||
@classmethod
|
||||
def from_plex_playlist(cls, playlist: PlexPlaylist) -> 'PlexPlaylistInfo':
|
||||
tracks = []
|
||||
for item in playlist.items():
|
||||
if isinstance(item, PlexTrack):
|
||||
tracks.append(PlexTrackInfo.from_plex_track(item))
|
||||
|
||||
return cls(
|
||||
id=str(playlist.ratingKey),
|
||||
title=playlist.title,
|
||||
description=playlist.summary,
|
||||
duration=playlist.duration,
|
||||
leaf_count=playlist.leafCount,
|
||||
tracks=tracks
|
||||
)
|
||||
|
||||
class PlexClient:
|
||||
class PlexClient(MediaServerClient):
|
||||
def __init__(self):
|
||||
self.server: Optional[PlexServer] = None
|
||||
self.music_library: Optional[MusicSection] = None
|
||||
# When True, read methods union across every music section under
|
||||
# the connected token via ``server.library.search(libtype=...)``
|
||||
# instead of querying ``self.music_library`` only. Set by
|
||||
# ``_find_music_library`` / ``set_music_library_by_name`` when the
|
||||
# user's saved preference matches ``ALL_LIBRARIES_SENTINEL``.
|
||||
# Write methods (genre / poster / metadata updates) are unaffected
|
||||
# — they operate on Plex objects via ratingKey, section-agnostic.
|
||||
self._all_libraries_mode = False
|
||||
self._connection_attempted = False
|
||||
self._is_connecting = False
|
||||
self._last_connection_check = 0 # Cache connection checks
|
||||
|
|
@ -116,6 +79,7 @@ class PlexClient:
|
|||
logger.info("Resetting Plex connection state")
|
||||
self.server = None
|
||||
self.music_library = None
|
||||
self._all_libraries_mode = False
|
||||
self._connection_attempted = False
|
||||
self._last_connection_check = 0
|
||||
|
||||
|
|
@ -142,7 +106,14 @@ class PlexClient:
|
|||
self.server = None
|
||||
|
||||
def get_available_music_libraries(self) -> List[Dict[str, str]]:
|
||||
"""Get list of all available music libraries on the Plex server"""
|
||||
"""Get list of all available music libraries on the Plex server.
|
||||
|
||||
Prepends an "All Libraries (combined)" synthetic entry whose
|
||||
``key`` is ``ALL_LIBRARIES_SENTINEL``. The settings UI renders
|
||||
this as a normal dropdown option; selecting it stores the
|
||||
sentinel as the saved preference, which flips the client into
|
||||
all-libraries read mode (see ``_all_libraries_mode``).
|
||||
"""
|
||||
if not self.ensure_connection() or not self.server:
|
||||
return []
|
||||
|
||||
|
|
@ -152,24 +123,56 @@ class PlexClient:
|
|||
if section.type == 'artist':
|
||||
music_libraries.append({
|
||||
'title': section.title,
|
||||
'key': str(section.key)
|
||||
'key': str(section.key),
|
||||
# ``value`` is what the frontend submits to
|
||||
# ``/api/plex/select-music-library``. Real
|
||||
# libraries use their title (preserves prior
|
||||
# behavior). The synthetic sentinel entry below
|
||||
# uses the sentinel string so the backend's
|
||||
# ``set_music_library_by_name`` can recognize it.
|
||||
'value': section.title,
|
||||
})
|
||||
|
||||
logger.debug(f"Found {len(music_libraries)} music libraries")
|
||||
# Only offer the "All Libraries" option when the user actually
|
||||
# has more than one music library on their server — single-
|
||||
# library users don't need the extra menu item.
|
||||
if len(music_libraries) > 1:
|
||||
music_libraries.insert(0, {
|
||||
'title': 'All Libraries (combined)',
|
||||
'key': ALL_LIBRARIES_SENTINEL,
|
||||
'value': ALL_LIBRARIES_SENTINEL,
|
||||
})
|
||||
|
||||
logger.debug(f"Found {len(music_libraries)} music libraries (incl. sentinel)")
|
||||
return music_libraries
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting music libraries: {e}")
|
||||
return []
|
||||
|
||||
def set_music_library_by_name(self, library_name: str) -> bool:
|
||||
"""Set the active music library by name"""
|
||||
"""Set the active music library by name.
|
||||
|
||||
Special-case ``ALL_LIBRARIES_SENTINEL`` (``'__all_libraries__'``):
|
||||
flips the client into all-libraries mode where read methods union
|
||||
across every music section instead of querying a single one.
|
||||
"""
|
||||
if not self.server:
|
||||
return False
|
||||
|
||||
try:
|
||||
if library_name == ALL_LIBRARIES_SENTINEL:
|
||||
self.music_library = None
|
||||
self._all_libraries_mode = True
|
||||
logger.info("Set music library to: All Libraries (combined)")
|
||||
from database.music_database import MusicDatabase
|
||||
db = MusicDatabase()
|
||||
db.set_preference('plex_music_library', ALL_LIBRARIES_SENTINEL)
|
||||
return True
|
||||
|
||||
for section in self.server.library.sections():
|
||||
if section.type == 'artist' and section.title == library_name:
|
||||
self.music_library = section
|
||||
self._all_libraries_mode = False
|
||||
logger.info(f"Set music library to: {library_name}")
|
||||
|
||||
# Store preference in database
|
||||
|
|
@ -207,11 +210,23 @@ class PlexClient:
|
|||
db = MusicDatabase()
|
||||
preferred_library = db.get_preference('plex_music_library')
|
||||
|
||||
if preferred_library == ALL_LIBRARIES_SENTINEL:
|
||||
# User opted into all-libraries mode — read methods
|
||||
# union across every music section.
|
||||
self.music_library = None
|
||||
self._all_libraries_mode = True
|
||||
logger.debug(
|
||||
f"Using all-libraries mode across "
|
||||
f"{len(music_sections)} music sections"
|
||||
)
|
||||
return
|
||||
|
||||
if preferred_library:
|
||||
# Try to find the preferred library
|
||||
for section in music_sections:
|
||||
if section.title == preferred_library:
|
||||
self.music_library = section
|
||||
self._all_libraries_mode = False
|
||||
logger.debug(f"Using user-selected music library: {section.title}")
|
||||
return
|
||||
except Exception as e:
|
||||
|
|
@ -265,10 +280,167 @@ class PlexClient:
|
|||
return self.server is not None
|
||||
|
||||
def is_fully_configured(self) -> bool:
|
||||
"""Check if both server is connected AND music library is selected."""
|
||||
return self.server is not None and self.music_library is not None
|
||||
"""Check if both server is connected AND music library is selected
|
||||
(or user has opted into all-libraries mode)."""
|
||||
return self.server is not None and (
|
||||
self.music_library is not None or self._all_libraries_mode
|
||||
)
|
||||
|
||||
def is_all_libraries_mode(self) -> bool:
|
||||
"""True when the user has selected the synthetic "All Libraries
|
||||
(combined)" option — read methods union across every music
|
||||
section instead of querying a single one. Public accessor so
|
||||
external callers don't reach into the underscore-prefixed
|
||||
flag directly."""
|
||||
return self._all_libraries_mode
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Library scope helpers — single dispatch point for "single section
|
||||
# vs all music sections" so every read method funnels through the
|
||||
# same branch.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _can_query(self) -> bool:
|
||||
"""True when there's something to query — a selected section or
|
||||
all-libraries mode + a connected server."""
|
||||
if not self.server:
|
||||
return False
|
||||
if self._all_libraries_mode:
|
||||
return True
|
||||
return self.music_library is not None
|
||||
|
||||
def _get_music_sections(self) -> List:
|
||||
"""Music sections currently in scope (one for single-library
|
||||
mode, every music section for all-libraries mode). Used by
|
||||
``trigger_library_scan`` / ``is_library_scanning`` which take
|
||||
per-section action."""
|
||||
if not self.server:
|
||||
return []
|
||||
if self._all_libraries_mode:
|
||||
try:
|
||||
return [s for s in self.server.library.sections() if s.type == 'artist']
|
||||
except Exception as exc:
|
||||
logger.error(f"Error enumerating music sections: {exc}")
|
||||
return []
|
||||
if self.music_library:
|
||||
return [self.music_library]
|
||||
return []
|
||||
|
||||
def _all_artists(self) -> List[PlexArtist]:
|
||||
"""Every artist in the configured scope."""
|
||||
if self._all_libraries_mode:
|
||||
return self.server.library.search(libtype='artist')
|
||||
if self.music_library:
|
||||
return self.music_library.searchArtists()
|
||||
return []
|
||||
|
||||
def _all_albums(self) -> List[PlexAlbum]:
|
||||
"""Every album in the configured scope."""
|
||||
if self._all_libraries_mode:
|
||||
return self.server.library.search(libtype='album')
|
||||
if self.music_library:
|
||||
return self.music_library.albums()
|
||||
return []
|
||||
|
||||
def _all_tracks(self) -> List[PlexTrack]:
|
||||
"""Every track in the configured scope."""
|
||||
if self._all_libraries_mode:
|
||||
return self.server.library.search(libtype='track')
|
||||
if self.music_library:
|
||||
return self.music_library.searchTracks()
|
||||
return []
|
||||
|
||||
def _search_general(self, **kwargs):
|
||||
"""Generic search dispatch — single section or server-wide.
|
||||
|
||||
Used by ``_find_track`` / ``search_tracks`` for fielded
|
||||
searches (title=, artist=, libtype=, limit=, etc).
|
||||
"""
|
||||
if self._all_libraries_mode:
|
||||
return self.server.library.search(**kwargs)
|
||||
if self.music_library:
|
||||
return self.music_library.search(**kwargs)
|
||||
return []
|
||||
|
||||
def _search_artists_by_name(self, title: str, limit: int = 1) -> List[PlexArtist]:
|
||||
"""Find artist objects matching a name. Used by ``search_tracks``
|
||||
Stage 1 + ``search_albums`` artist-then-filter flow."""
|
||||
if self._all_libraries_mode:
|
||||
results = self.server.library.search(title=title, libtype='artist', limit=limit)
|
||||
return [r for r in results if isinstance(r, PlexArtist)]
|
||||
if self.music_library:
|
||||
return self.music_library.searchArtists(title=title, limit=limit)
|
||||
return []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cross-section dedup. Applied ONLY in all-libraries mode and ONLY
|
||||
# at the listing/stats layer. Never apply to ratingKey enumeration
|
||||
# (``get_all_artist_ids`` / ``get_all_album_ids``) — removal
|
||||
# detection compares those sets against DB-linked ratingKeys and
|
||||
# deduping would falsely prune library tracks pointing at the
|
||||
# non-canonical ratingKey. Single-library mode is a no-op fast
|
||||
# path so the dedup code path never touches it.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _dedupe_artists(self, artists: List[PlexArtist]) -> List[PlexArtist]:
|
||||
"""Collapse same-name artists across sections to a single
|
||||
canonical entry (the one with the higher track count). Returns
|
||||
the input unchanged in single-library mode and when there's
|
||||
nothing to dedup."""
|
||||
if not self._all_libraries_mode or not artists:
|
||||
return artists
|
||||
by_name: Dict[str, PlexArtist] = {}
|
||||
for a in artists:
|
||||
name_key = (getattr(a, 'title', '') or '').strip().lower()
|
||||
if not name_key:
|
||||
# Untitled artist — keep as-is, can't dedup blindly.
|
||||
by_name[f'__nokey_{getattr(a, "ratingKey", id(a))}'] = a
|
||||
continue
|
||||
existing = by_name.get(name_key)
|
||||
if existing is None:
|
||||
by_name[name_key] = a
|
||||
continue
|
||||
try:
|
||||
existing_count = getattr(existing, 'leafCount', 0) or 0
|
||||
new_count = getattr(a, 'leafCount', 0) or 0
|
||||
if new_count > existing_count:
|
||||
by_name[name_key] = a
|
||||
except Exception as e:
|
||||
logger.debug("artist leafCount compare failed: %s", e)
|
||||
return list(by_name.values())
|
||||
|
||||
def _dedupe_albums(self, albums: List[PlexAlbum]) -> List[PlexAlbum]:
|
||||
"""Collapse same-album-by-same-artist across sections to a
|
||||
single canonical entry. Group key is (artist_lower, title_lower);
|
||||
canonical = higher track count. No-op in single-library mode."""
|
||||
if not self._all_libraries_mode or not albums:
|
||||
return albums
|
||||
by_key: Dict[tuple, PlexAlbum] = {}
|
||||
for alb in albums:
|
||||
title = (getattr(alb, 'title', '') or '').strip().lower()
|
||||
if not title:
|
||||
by_key[('__nokey__', getattr(alb, 'ratingKey', id(alb)))] = alb
|
||||
continue
|
||||
artist = ''
|
||||
try:
|
||||
artist = (getattr(alb, 'parentTitle', '') or '').strip().lower()
|
||||
except Exception as e:
|
||||
logger.debug("album parentTitle read failed: %s", e)
|
||||
key = (artist, title)
|
||||
existing = by_key.get(key)
|
||||
if existing is None:
|
||||
by_key[key] = alb
|
||||
continue
|
||||
try:
|
||||
existing_count = getattr(existing, 'leafCount', 0) or 0
|
||||
new_count = getattr(alb, 'leafCount', 0) or 0
|
||||
if new_count > existing_count:
|
||||
by_key[key] = alb
|
||||
except Exception as e:
|
||||
logger.debug("album leafCount compare failed: %s", e)
|
||||
return list(by_key.values())
|
||||
|
||||
def get_all_playlists(self) -> List[PlexPlaylistInfo]:
|
||||
def get_all_playlists(self) -> List[PlaylistInfo]:
|
||||
if not self.ensure_connection():
|
||||
logger.error("Not connected to Plex server")
|
||||
return []
|
||||
|
|
@ -278,7 +450,7 @@ class PlexClient:
|
|||
try:
|
||||
for playlist in self.server.playlists():
|
||||
if getattr(playlist, 'playlistType', None) == 'audio':
|
||||
playlist_info = PlexPlaylistInfo.from_plex_playlist(playlist)
|
||||
playlist_info = PlaylistInfo.from_plex_playlist(playlist)
|
||||
playlists.append(playlist_info)
|
||||
|
||||
logger.info(f"Retrieved {len(playlists)} audio playlists")
|
||||
|
|
@ -288,14 +460,14 @@ class PlexClient:
|
|||
logger.error(f"Error fetching playlists: {e}")
|
||||
return []
|
||||
|
||||
def get_playlist_by_name(self, name: str) -> Optional[PlexPlaylistInfo]:
|
||||
def get_playlist_by_name(self, name: str) -> Optional[PlaylistInfo]:
|
||||
if not self.ensure_connection():
|
||||
return None
|
||||
|
||||
try:
|
||||
playlist = self.server.playlist(name)
|
||||
if getattr(playlist, 'playlistType', None) == 'audio':
|
||||
return PlexPlaylistInfo.from_plex_playlist(playlist)
|
||||
return PlaylistInfo.from_plex_playlist(playlist)
|
||||
return None
|
||||
|
||||
except NotFound:
|
||||
|
|
@ -311,14 +483,14 @@ class PlexClient:
|
|||
return False
|
||||
|
||||
try:
|
||||
# Handle both PlexTrackInfo objects and actual Plex track objects
|
||||
# Handle both TrackInfo objects and actual Plex track objects
|
||||
plex_tracks = []
|
||||
for track in tracks:
|
||||
if hasattr(track, 'ratingKey'):
|
||||
# This is already a Plex track object
|
||||
plex_tracks.append(track)
|
||||
elif hasattr(track, '_original_plex_track'):
|
||||
# This is a PlexTrackInfo object with stored original track reference
|
||||
# This is a TrackInfo object with stored original track reference
|
||||
original_track = track._original_plex_track
|
||||
if original_track is not None:
|
||||
plex_tracks.append(original_track)
|
||||
|
|
@ -326,7 +498,7 @@ class PlexClient:
|
|||
else:
|
||||
logger.warning(f"Stored track reference is None for: {track.title} by {track.artist}")
|
||||
elif hasattr(track, 'title'):
|
||||
# Fallback: This is a PlexTrackInfo object, need to find the actual track
|
||||
# Fallback: This is a TrackInfo object, need to find the actual track
|
||||
plex_track = self._find_track(track.title, track.artist, track.album)
|
||||
if plex_track:
|
||||
plex_tracks.append(plex_track)
|
||||
|
|
@ -448,7 +620,7 @@ class PlexClient:
|
|||
logger.error(f"Error copying playlist '{source_name}' to '{target_name}': {e}")
|
||||
return False
|
||||
|
||||
def update_playlist(self, playlist_name: str, tracks: List[PlexTrackInfo]) -> bool:
|
||||
def update_playlist(self, playlist_name: str, tracks: List[TrackInfo]) -> bool:
|
||||
if not self.ensure_connection():
|
||||
return False
|
||||
|
||||
|
|
@ -493,38 +665,38 @@ class PlexClient:
|
|||
return False
|
||||
|
||||
def _find_track(self, title: str, artist: str, album: str) -> Optional[PlexTrack]:
|
||||
if not self.music_library:
|
||||
if not self._can_query():
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
search_results = self.music_library.search(title=title, artist=artist, album=album)
|
||||
|
||||
search_results = self._search_general(title=title, artist=artist, album=album)
|
||||
|
||||
for result in search_results:
|
||||
if isinstance(result, PlexTrack):
|
||||
if (result.title.lower() == title.lower() and
|
||||
if (result.title.lower() == title.lower() and
|
||||
result.artist().title.lower() == artist.lower() and
|
||||
result.album().title.lower() == album.lower()):
|
||||
return result
|
||||
|
||||
broader_search = self.music_library.search(title=title, artist=artist)
|
||||
|
||||
broader_search = self._search_general(title=title, artist=artist)
|
||||
for result in broader_search:
|
||||
if isinstance(result, PlexTrack):
|
||||
if (result.title.lower() == title.lower() and
|
||||
if (result.title.lower() == title.lower() and
|
||||
result.artist().title.lower() == artist.lower()):
|
||||
return result
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching for track '{title}' by '{artist}': {e}")
|
||||
return None
|
||||
|
||||
def search_tracks(self, title: str, artist: str, limit: int = 15) -> List[PlexTrackInfo]:
|
||||
def search_tracks(self, title: str, artist: str, limit: int = 15) -> List[TrackInfo]:
|
||||
"""
|
||||
Searches for tracks using an efficient, multi-stage "early exit" strategy.
|
||||
It stops and returns results as soon as candidates are found.
|
||||
"""
|
||||
if not self.music_library:
|
||||
if not self._can_query():
|
||||
logger.warning("Plex music library not found. Cannot perform search.")
|
||||
return []
|
||||
|
||||
|
|
@ -542,7 +714,7 @@ class PlexClient:
|
|||
# --- Stage 1: High-Precision Search (Artist -> then filter by Title) ---
|
||||
if artist:
|
||||
logger.debug(f"Stage 1: Searching for artist '{artist}'")
|
||||
artist_results = self.music_library.searchArtists(title=artist, limit=1)
|
||||
artist_results = self._search_artists_by_name(title=artist, limit=1)
|
||||
if artist_results:
|
||||
plex_artist = artist_results[0]
|
||||
all_artist_tracks = plex_artist.tracks()
|
||||
|
|
@ -554,7 +726,7 @@ class PlexClient:
|
|||
# --- Early Exit: If Stage 1 found results, stop here ---
|
||||
if candidate_tracks:
|
||||
logger.info(f"Found {len(candidate_tracks)} candidates in Stage 1. Exiting early.")
|
||||
tracks = [PlexTrackInfo.from_plex_track(track) for track in candidate_tracks[:limit]]
|
||||
tracks = [TrackInfo.from_plex_track(track) for track in candidate_tracks[:limit]]
|
||||
# Store references to original tracks for playlist creation
|
||||
for i, track_info in enumerate(tracks):
|
||||
if i < len(candidate_tracks):
|
||||
|
|
@ -567,13 +739,13 @@ class PlexClient:
|
|||
# --- Stage 2: Flexible Keyword Search (Artist + Title combined) ---
|
||||
search_query = f"{artist} {title}".strip()
|
||||
logger.debug(f"Stage 2: Performing keyword search for '{search_query}'")
|
||||
stage2_results = self.music_library.search(title=search_query, libtype='track', limit=limit)
|
||||
stage2_results = self._search_general(title=search_query, libtype='track', limit=limit)
|
||||
add_candidates(stage2_results)
|
||||
|
||||
# --- Early Exit: If Stage 2 found results, stop here ---
|
||||
if candidate_tracks:
|
||||
logger.info(f"Found {len(candidate_tracks)} candidates in Stage 2. Exiting early.")
|
||||
tracks = [PlexTrackInfo.from_plex_track(track) for track in candidate_tracks[:limit]]
|
||||
tracks = [TrackInfo.from_plex_track(track) for track in candidate_tracks[:limit]]
|
||||
# Store references to original tracks for playlist creation
|
||||
for i, track_info in enumerate(tracks):
|
||||
if i < len(candidate_tracks):
|
||||
|
|
@ -587,7 +759,7 @@ class PlexClient:
|
|||
# Removed to prevent false positives where tracks with same title
|
||||
# but different artists are incorrectly matched
|
||||
|
||||
tracks = [PlexTrackInfo.from_plex_track(track) for track in candidate_tracks[:limit]]
|
||||
tracks = [TrackInfo.from_plex_track(track) for track in candidate_tracks[:limit]]
|
||||
|
||||
# Store references to original tracks for playlist creation
|
||||
for i, track_info in enumerate(tracks):
|
||||
|
|
@ -612,18 +784,106 @@ class PlexClient:
|
|||
|
||||
|
||||
def get_library_stats(self) -> Dict[str, int]:
|
||||
if not self.music_library:
|
||||
if not self._can_query():
|
||||
return {}
|
||||
|
||||
|
||||
try:
|
||||
# Apply dedup to artist + album counts so stats match what
|
||||
# the user actually sees in the library list (deduped). Tracks
|
||||
# stay raw — same track in two sections means two distinct
|
||||
# files / Plex entries, not a logical duplicate.
|
||||
return {
|
||||
'artists': len(self.music_library.searchArtists()),
|
||||
'albums': len(self.music_library.searchAlbums()),
|
||||
'tracks': len(self.music_library.searchTracks())
|
||||
'artists': len(self._dedupe_artists(self._all_artists())),
|
||||
'albums': len(self._dedupe_albums(self._all_albums())),
|
||||
'tracks': len(self._all_tracks())
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting library stats: {e}")
|
||||
return {}
|
||||
|
||||
def get_recently_added_albums(self, maxresults: int = 400,
|
||||
libtype: Optional[str] = 'album') -> List:
|
||||
"""Recently added items across the configured scope.
|
||||
|
||||
In all-libraries mode, iterates every music section and concats
|
||||
each section's ``recentlyAdded`` list. Honors ``maxresults``
|
||||
per-section to bound the response. Caller is responsible for
|
||||
further sorting / deduping if needed.
|
||||
|
||||
Pass ``libtype=None`` to skip the type filter (returns mixed
|
||||
artists / albums / tracks). Used by the database update worker's
|
||||
deep-scan recent-content sweep — pre-fix it reached
|
||||
``self.music_library.recentlyAdded()`` directly which crashed
|
||||
in all-libraries mode (music_library is None there).
|
||||
"""
|
||||
if not self.ensure_connection() or not self._can_query():
|
||||
return []
|
||||
|
||||
def _call(target):
|
||||
try:
|
||||
if libtype is not None:
|
||||
return target.recentlyAdded(libtype=libtype, maxresults=maxresults)
|
||||
return target.recentlyAdded(maxresults=maxresults)
|
||||
except TypeError:
|
||||
# Older PlexAPI versions don't accept libtype/maxresults
|
||||
# as kwargs — fall back to bare call.
|
||||
return target.recentlyAdded()
|
||||
|
||||
try:
|
||||
if self._all_libraries_mode:
|
||||
aggregate = []
|
||||
for section in self._get_music_sections():
|
||||
try:
|
||||
aggregate.extend(_call(section))
|
||||
except Exception as inner_exc:
|
||||
logger.debug(f"recentlyAdded failed for section '{section.title}': {inner_exc}")
|
||||
return aggregate
|
||||
if self.music_library:
|
||||
return _call(self.music_library)
|
||||
return []
|
||||
except Exception as exc:
|
||||
logger.error(f"Error getting recently added albums: {exc}")
|
||||
return []
|
||||
|
||||
def get_recently_updated_albums(self, limit: int = 400) -> List:
|
||||
"""Albums sorted by ``updatedAt`` descending across the scope.
|
||||
|
||||
Used by the deep-scan to catch metadata corrections (renames,
|
||||
re-tagging) that recently-added doesn't surface. In all-
|
||||
libraries mode, unions across every music section.
|
||||
"""
|
||||
if not self.ensure_connection() or not self._can_query():
|
||||
return []
|
||||
try:
|
||||
return self._search_general(sort='updatedAt:desc', libtype='album', limit=limit)
|
||||
except Exception as exc:
|
||||
logger.error(f"Error getting recently updated albums: {exc}")
|
||||
return []
|
||||
|
||||
def get_music_library_locations(self) -> List[str]:
|
||||
"""Folder roots configured for the music library scope.
|
||||
|
||||
Single-library mode returns the selected section's locations.
|
||||
All-libraries mode unions locations across every music section
|
||||
— needed by ``web_server.py``'s file-path resolver to recognize
|
||||
files under any music root, not just one.
|
||||
"""
|
||||
if not self.ensure_connection() or not self._can_query():
|
||||
return []
|
||||
try:
|
||||
sections = self._get_music_sections()
|
||||
locations = []
|
||||
for section in sections:
|
||||
try:
|
||||
for loc in section.locations:
|
||||
if loc and loc not in locations:
|
||||
locations.append(loc)
|
||||
except Exception as inner_exc:
|
||||
logger.debug(f"Could not read locations for section '{getattr(section, 'title', '?')}': {inner_exc}")
|
||||
return locations
|
||||
except Exception as exc:
|
||||
logger.error(f"Error getting music library locations: {exc}")
|
||||
return []
|
||||
|
||||
def get_play_history(self, limit=500):
|
||||
"""Fetch recently played tracks from Plex.
|
||||
|
|
@ -663,11 +923,11 @@ class PlexClient:
|
|||
|
||||
Returns dict of {ratingKey: play_count}.
|
||||
"""
|
||||
if not self.ensure_connection() or not self.music_library:
|
||||
if not self.ensure_connection() or not self._can_query():
|
||||
return {}
|
||||
|
||||
try:
|
||||
tracks = self.music_library.searchTracks()
|
||||
tracks = self._all_tracks()
|
||||
counts = {}
|
||||
for track in tracks:
|
||||
view_count = getattr(track, 'viewCount', 0) or 0
|
||||
|
|
@ -680,14 +940,27 @@ class PlexClient:
|
|||
return {}
|
||||
|
||||
def get_all_artists(self) -> List[PlexArtist]:
|
||||
"""Get all artists from the music library"""
|
||||
if not self.ensure_connection() or not self.music_library:
|
||||
"""Get all artists from the music library.
|
||||
|
||||
In all-libraries mode, dedupes same-name artists across
|
||||
sections (canonical = higher track count) so the library list
|
||||
doesn't show "Drake" twice when Drake is in two sections.
|
||||
Single-library mode is unaffected — dedup helper is a no-op.
|
||||
"""
|
||||
if not self.ensure_connection() or not self._can_query():
|
||||
logger.error("Not connected to Plex server or no music library")
|
||||
return []
|
||||
|
||||
try:
|
||||
artists = self.music_library.searchArtists()
|
||||
logger.info(f"Found {len(artists)} artists in Plex library")
|
||||
raw = self._all_artists()
|
||||
artists = self._dedupe_artists(raw)
|
||||
if len(raw) != len(artists):
|
||||
logger.info(
|
||||
f"Found {len(raw)} artists across all music sections "
|
||||
f"({len(artists)} unique after cross-section dedup)"
|
||||
)
|
||||
else:
|
||||
logger.info(f"Found {len(artists)} artists in Plex library")
|
||||
return artists
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting all artists: {e}")
|
||||
|
|
@ -695,10 +968,10 @@ class PlexClient:
|
|||
|
||||
def get_all_artist_ids(self) -> set:
|
||||
"""Get all artist IDs from Plex library (lightweight, for removal detection)."""
|
||||
if not self.ensure_connection() or not self.music_library:
|
||||
if not self.ensure_connection() or not self._can_query():
|
||||
return set()
|
||||
try:
|
||||
artists = self.music_library.searchArtists()
|
||||
artists = self._all_artists()
|
||||
ids = {str(a.ratingKey) for a in artists}
|
||||
logger.info(f"Retrieved {len(ids)} artist IDs from Plex")
|
||||
return ids
|
||||
|
|
@ -708,10 +981,10 @@ class PlexClient:
|
|||
|
||||
def get_all_album_ids(self) -> set:
|
||||
"""Get all album IDs from Plex library (lightweight, for removal detection)."""
|
||||
if not self.ensure_connection() or not self.music_library:
|
||||
if not self.ensure_connection() or not self._can_query():
|
||||
return set()
|
||||
try:
|
||||
albums = self.music_library.albums()
|
||||
albums = self._all_albums()
|
||||
ids = {str(a.ratingKey) for a in albums}
|
||||
logger.info(f"Retrieved {len(ids)} album IDs from Plex")
|
||||
return ids
|
||||
|
|
@ -921,11 +1194,31 @@ class PlexClient:
|
|||
return False
|
||||
|
||||
def trigger_library_scan(self, library_name: str = "Music") -> bool:
|
||||
"""Trigger Plex library scan for the specified library"""
|
||||
"""Trigger Plex library scan.
|
||||
|
||||
In all-libraries mode, fans the scan trigger across every music
|
||||
section. Otherwise targets the named section (default ``Music``).
|
||||
Returns True if at least one section was successfully triggered.
|
||||
"""
|
||||
if not self.ensure_connection():
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
if self._all_libraries_mode:
|
||||
sections = self._get_music_sections()
|
||||
if not sections:
|
||||
logger.warning("All-libraries mode active but no music sections found")
|
||||
return False
|
||||
triggered = 0
|
||||
for section in sections:
|
||||
try:
|
||||
section.update()
|
||||
triggered += 1
|
||||
except Exception as inner_exc:
|
||||
logger.error(f"Failed to trigger scan for '{section.title}': {inner_exc}")
|
||||
logger.info(f"Triggered Plex library scan across {triggered}/{len(sections)} music sections")
|
||||
return triggered > 0
|
||||
|
||||
library = self.server.library.section(library_name)
|
||||
library.update() # Non-blocking scan request
|
||||
logger.info(f"Triggered Plex library scan for '{library_name}'")
|
||||
|
|
@ -933,66 +1226,93 @@ class PlexClient:
|
|||
except Exception as e:
|
||||
logger.error(f"Failed to trigger library scan for '{library_name}': {e}")
|
||||
return False
|
||||
|
||||
|
||||
def is_library_scanning(self, library_name: str = "Music") -> bool:
|
||||
"""Check if Plex library is currently scanning"""
|
||||
"""Check if Plex library is currently scanning.
|
||||
|
||||
In all-libraries mode, returns True if ANY music section is
|
||||
scanning. Otherwise checks the named section.
|
||||
"""
|
||||
if not self.ensure_connection():
|
||||
logger.debug("Not connected to Plex, cannot check scan status")
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
if self._all_libraries_mode:
|
||||
sections = self._get_music_sections()
|
||||
# Per-section refreshing flag check.
|
||||
for section in sections:
|
||||
if hasattr(section, 'refreshing') and section.refreshing:
|
||||
logger.debug(f"Section '{section.title}' is refreshing")
|
||||
return True
|
||||
# Activity feed check — match any music-section title.
|
||||
try:
|
||||
activities = self.server.activities()
|
||||
section_titles = {s.title.lower() for s in sections}
|
||||
for activity in activities:
|
||||
activity_type = getattr(activity, 'type', 'unknown')
|
||||
activity_title = getattr(activity, 'title', '') or ''
|
||||
if activity_type not in ['library.scan', 'library.refresh']:
|
||||
continue
|
||||
if any(title in activity_title.lower() for title in section_titles):
|
||||
logger.debug(f"Matching scan activity: {activity_title}")
|
||||
return True
|
||||
except Exception as activities_error:
|
||||
logger.debug(f"Could not check server activities: {activities_error}")
|
||||
return False
|
||||
|
||||
library = self.server.library.section(library_name)
|
||||
|
||||
|
||||
# Check if library has a scanning attribute or is refreshing
|
||||
# The Plex API exposes this through the library's refreshing property
|
||||
refreshing = hasattr(library, 'refreshing') and library.refreshing
|
||||
logger.debug("Library.refreshing = %s", refreshing)
|
||||
|
||||
|
||||
if refreshing:
|
||||
logger.debug("Library is refreshing")
|
||||
return True
|
||||
|
||||
|
||||
# Alternative method: Check server activities for scanning
|
||||
try:
|
||||
activities = self.server.activities()
|
||||
logger.debug("Found %s server activities", len(activities))
|
||||
|
||||
|
||||
for activity in activities:
|
||||
# Look for library scan activities
|
||||
activity_type = getattr(activity, 'type', 'unknown')
|
||||
activity_title = getattr(activity, 'title', 'unknown')
|
||||
logger.debug("Activity - type=%s title=%s", activity_type, activity_title)
|
||||
|
||||
|
||||
if (activity_type in ['library.scan', 'library.refresh'] and
|
||||
library_name.lower() in activity_title.lower()):
|
||||
logger.debug("Found matching scan activity: %s", activity_title)
|
||||
return True
|
||||
except Exception as activities_error:
|
||||
logger.debug(f"Could not check server activities: {activities_error}")
|
||||
|
||||
|
||||
logger.debug("No scan activity detected")
|
||||
return False
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error checking if library is scanning: {e}")
|
||||
return False
|
||||
|
||||
def search_albums(self, album_name: str = "", artist_name: str = "", limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""Search for albums in Plex library"""
|
||||
if not self.ensure_connection() or not self.music_library:
|
||||
if not self.ensure_connection() or not self._can_query():
|
||||
return []
|
||||
|
||||
|
||||
try:
|
||||
albums = []
|
||||
|
||||
|
||||
# Perform search - different approaches based on what we're searching for
|
||||
search_results = []
|
||||
|
||||
|
||||
if album_name and artist_name:
|
||||
# Search for albums by specific artist and title
|
||||
try:
|
||||
# First try searching for the artist, then filter their albums
|
||||
artist_results = self.music_library.searchArtists(title=artist_name, limit=3)
|
||||
artist_results = self._search_artists_by_name(title=artist_name, limit=3)
|
||||
for artist in artist_results:
|
||||
try:
|
||||
artist_albums = artist.albums()
|
||||
|
|
@ -1005,25 +1325,25 @@ class PlexClient:
|
|||
logger.debug(f"Artist search failed, trying general search: {e}")
|
||||
# Fallback to general album search
|
||||
try:
|
||||
search_results = self.music_library.search(title=album_name)
|
||||
search_results = self._search_general(title=album_name)
|
||||
# Filter to only albums
|
||||
search_results = [r for r in search_results if isinstance(r, PlexAlbum)]
|
||||
except Exception as e2:
|
||||
logger.debug(f"General search also failed: {e2}")
|
||||
|
||||
|
||||
elif album_name:
|
||||
# Search for albums by title only
|
||||
try:
|
||||
search_results = self.music_library.search(title=album_name)
|
||||
# Filter to only albums
|
||||
search_results = self._search_general(title=album_name)
|
||||
# Filter to only albums
|
||||
search_results = [r for r in search_results if isinstance(r, PlexAlbum)]
|
||||
except Exception as e:
|
||||
logger.debug(f"Album title search failed: {e}")
|
||||
|
||||
|
||||
elif artist_name:
|
||||
# Search for all albums by artist
|
||||
try:
|
||||
artist_results = self.music_library.searchArtists(title=artist_name, limit=1)
|
||||
artist_results = self._search_artists_by_name(title=artist_name, limit=1)
|
||||
if artist_results:
|
||||
search_results = artist_results[0].albums()
|
||||
except Exception as e:
|
||||
|
|
@ -1031,7 +1351,7 @@ class PlexClient:
|
|||
else:
|
||||
# Get all albums if no search terms
|
||||
try:
|
||||
search_results = self.music_library.albums()
|
||||
search_results = self._all_albums()
|
||||
except Exception as e:
|
||||
logger.debug(f"Get all albums failed: {e}")
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ from utils.logging_config import get_logger
|
|||
from config.settings import config_manager
|
||||
|
||||
# Import Soulseek data structures for drop-in replacement compatibility
|
||||
from core.soulseek_client import TrackResult, AlbumResult, DownloadStatus
|
||||
from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus
|
||||
|
||||
logger = get_logger("qobuz_client")
|
||||
|
||||
|
|
@ -104,7 +104,10 @@ QOBUZ_QUALITY_MAP = {
|
|||
}
|
||||
|
||||
|
||||
class QobuzClient:
|
||||
from core.download_plugins.base import DownloadSourcePlugin
|
||||
|
||||
|
||||
class QobuzClient(DownloadSourcePlugin):
|
||||
"""
|
||||
Qobuz download client using Qobuz REST API.
|
||||
Provides search, matching, and download capabilities as a drop-in alternative to Soulseek/YouTube/Tidal.
|
||||
|
|
@ -136,13 +139,19 @@ class QobuzClient:
|
|||
self.user_info: Optional[Dict] = None
|
||||
self._auth_error: Optional[str] = None
|
||||
|
||||
# Download queue management
|
||||
self.active_downloads: Dict[str, Dict[str, Any]] = {}
|
||||
self._download_lock = threading.Lock()
|
||||
# Engine reference is populated by set_engine() at registration
|
||||
# time. None until orchestrator wires the registry.
|
||||
self._engine = None
|
||||
|
||||
# Try to restore saved session
|
||||
self._restore_session()
|
||||
|
||||
def set_engine(self, engine):
|
||||
"""Engine callback — gives the client access to the central
|
||||
thread worker + state store. Engine calls this during
|
||||
``register_plugin`` if the plugin defines it."""
|
||||
self._engine = engine
|
||||
|
||||
def set_shutdown_check(self, check_callable):
|
||||
"""Set a callback function to check for system shutdown"""
|
||||
self.shutdown_check = check_callable
|
||||
|
|
@ -516,6 +525,45 @@ class QobuzClient:
|
|||
config_manager.set('qobuz.session', {})
|
||||
logger.info("Qobuz session cleared")
|
||||
|
||||
def reload_credentials(self) -> None:
|
||||
"""Pull session state from config without making a network probe.
|
||||
|
||||
SoulSync runs two ``QobuzClient`` instances side by side — one wired
|
||||
through ``download_orchestrator.client('qobuz')`` for the auth-flow endpoints, and a
|
||||
second owned by the enrichment worker for thread safety. When the user
|
||||
logs in via ``/api/qobuz/auth/login`` or ``/api/qobuz/auth/token`` only
|
||||
the auth-flow instance's in-memory state is updated; the worker's
|
||||
instance still believes itself unauthenticated, which is what made the
|
||||
dashboard "yellow" indicator and the connection-test step report
|
||||
``Qobuz not authenticated`` even after a successful Connect.
|
||||
|
||||
Call this on the worker's client immediately after a successful login
|
||||
(and on logout, to clear) to keep the two instances in lockstep.
|
||||
Unlike ``_restore_session`` this does not validate the token over the
|
||||
network — the caller has just authenticated, so the token is known
|
||||
good.
|
||||
"""
|
||||
saved = config_manager.get('qobuz.session', {}) or {}
|
||||
new_app_id = saved.get('app_id', '') or None
|
||||
new_app_secret = saved.get('app_secret', '') or None
|
||||
new_token = saved.get('user_auth_token', '') or None
|
||||
|
||||
self.app_id = new_app_id
|
||||
self.app_secret = new_app_secret
|
||||
self.user_auth_token = new_token
|
||||
|
||||
if new_app_id:
|
||||
self.session.headers['X-App-Id'] = new_app_id
|
||||
else:
|
||||
self.session.headers.pop('X-App-Id', None)
|
||||
|
||||
if new_token:
|
||||
self.session.headers['X-User-Auth-Token'] = new_token
|
||||
else:
|
||||
self.session.headers.pop('X-User-Auth-Token', None)
|
||||
self.user_info = None
|
||||
self._auth_error = None
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
"""Check if we have a valid Qobuz session."""
|
||||
return bool(self.user_auth_token and self.app_id and self.app_secret)
|
||||
|
|
@ -845,97 +893,38 @@ class QobuzClient:
|
|||
return None
|
||||
|
||||
async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]:
|
||||
"""
|
||||
Download a Qobuz track (async, Soulseek-compatible interface).
|
||||
"""Download a Qobuz track. Delegates to engine.worker which
|
||||
spawns the background thread + manages state."""
|
||||
if '||' not in filename:
|
||||
logger.error(f"Invalid filename format: {filename}")
|
||||
return None
|
||||
if self._engine is None:
|
||||
# Raise rather than return None so the orchestrator's
|
||||
# download_with_fallback surfaces a real warning + tries
|
||||
# the next source. Returning None silently dropped the
|
||||
# download with no user feedback (per JohnBaumb).
|
||||
raise RuntimeError("Qobuz client has no engine reference — cannot dispatch download")
|
||||
|
||||
Returns download_id immediately and runs download in background thread.
|
||||
|
||||
Args:
|
||||
username: Ignored for Qobuz (always "qobuz")
|
||||
filename: Encoded as "track_id||display_name"
|
||||
file_size: Ignored
|
||||
"""
|
||||
track_id_str, display_name = filename.split('||', 1)
|
||||
try:
|
||||
if '||' not in filename:
|
||||
logger.error(f"Invalid filename format: {filename}")
|
||||
return None
|
||||
|
||||
track_id_str, display_name = filename.split('||', 1)
|
||||
try:
|
||||
track_id = int(track_id_str)
|
||||
except ValueError:
|
||||
logger.error(f"Invalid Qobuz track ID: {track_id_str}")
|
||||
return None
|
||||
|
||||
logger.info(f"Starting Qobuz download: {display_name}")
|
||||
|
||||
download_id = str(uuid.uuid4())
|
||||
|
||||
with self._download_lock:
|
||||
self.active_downloads[download_id] = {
|
||||
'id': download_id,
|
||||
'filename': filename,
|
||||
'username': 'qobuz',
|
||||
'state': 'Initializing',
|
||||
'progress': 0.0,
|
||||
'size': 0,
|
||||
'transferred': 0,
|
||||
'speed': 0,
|
||||
'time_remaining': None,
|
||||
'track_id': track_id,
|
||||
'display_name': display_name,
|
||||
'file_path': None,
|
||||
}
|
||||
|
||||
# Start download in background thread
|
||||
download_thread = threading.Thread(
|
||||
target=self._download_thread_worker,
|
||||
args=(download_id, track_id, display_name, filename),
|
||||
daemon=True,
|
||||
)
|
||||
download_thread.start()
|
||||
|
||||
logger.info(f"Qobuz download {download_id} started in background")
|
||||
return download_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start Qobuz download: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
track_id = int(track_id_str)
|
||||
except ValueError:
|
||||
logger.error(f"Invalid Qobuz track ID: {track_id_str}")
|
||||
return None
|
||||
|
||||
def _download_thread_worker(self, download_id: str, track_id: int, display_name: str, original_filename: str):
|
||||
"""Background thread worker for downloading Qobuz tracks."""
|
||||
try:
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['state'] = 'InProgress, Downloading'
|
||||
logger.info(f"Starting Qobuz download: {display_name}")
|
||||
|
||||
file_path = self._download_sync(download_id, track_id, display_name)
|
||||
|
||||
if file_path:
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['state'] = 'Completed, Succeeded'
|
||||
self.active_downloads[download_id]['progress'] = 100.0
|
||||
self.active_downloads[download_id]['file_path'] = file_path
|
||||
|
||||
logger.info(f"Qobuz download {download_id} completed: {file_path}")
|
||||
else:
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['state'] = 'Errored'
|
||||
|
||||
logger.error(f"Qobuz download {download_id} failed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Qobuz download thread failed for {download_id}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['state'] = 'Errored'
|
||||
return self._engine.worker.dispatch(
|
||||
source_name='qobuz',
|
||||
target_id=track_id,
|
||||
display_name=display_name,
|
||||
original_filename=filename,
|
||||
impl_callable=self._download_sync,
|
||||
extra_record_fields={
|
||||
'track_id': track_id,
|
||||
'display_name': display_name,
|
||||
},
|
||||
)
|
||||
|
||||
def _download_sync(self, download_id: str, track_id: int, display_name: str) -> Optional[str]:
|
||||
"""
|
||||
|
|
@ -1011,9 +1000,8 @@ class QobuzClient:
|
|||
chunk_size = 64 * 1024 # 64KB chunks
|
||||
start_time = time.time()
|
||||
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['size'] = total_size
|
||||
if self._engine is not None:
|
||||
self._engine.update_record('qobuz', download_id, {'size': total_size})
|
||||
|
||||
with open(out_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=chunk_size):
|
||||
|
|
@ -1022,9 +1010,10 @@ class QobuzClient:
|
|||
|
||||
# Check for shutdown or cancellation
|
||||
cancelled = False
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
cancelled = self.active_downloads[download_id].get('state') == 'Cancelled'
|
||||
if self._engine is not None:
|
||||
rec = self._engine.get_record('qobuz', download_id)
|
||||
if rec is not None:
|
||||
cancelled = rec.get('state') == 'Cancelled'
|
||||
if cancelled or (self.shutdown_check and self.shutdown_check()):
|
||||
reason = "cancelled" if cancelled else "server shutting down"
|
||||
logger.info(f"Aborting Qobuz download mid-stream: {reason}")
|
||||
|
|
@ -1045,18 +1034,20 @@ class QobuzClient:
|
|||
progress = 0
|
||||
time_remaining = None
|
||||
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['transferred'] = downloaded
|
||||
self.active_downloads[download_id]['progress'] = round(progress, 1)
|
||||
self.active_downloads[download_id]['speed'] = int(speed)
|
||||
self.active_downloads[download_id]['time_remaining'] = time_remaining
|
||||
if self._engine is not None:
|
||||
self._engine.update_record('qobuz', download_id, {
|
||||
'transferred': downloaded,
|
||||
'progress': round(progress, 1),
|
||||
'speed': int(speed),
|
||||
'time_remaining': time_remaining,
|
||||
})
|
||||
|
||||
# If download was aborted (shutdown/cancel), clean up partial file
|
||||
abort_check = False
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
abort_check = self.active_downloads[download_id].get('state') == 'Cancelled'
|
||||
if self._engine is not None:
|
||||
rec = self._engine.get_record('qobuz', download_id)
|
||||
if rec is not None:
|
||||
abort_check = rec.get('state') == 'Cancelled'
|
||||
if abort_check or (self.shutdown_check and self.shutdown_check()):
|
||||
out_path.unlink(missing_ok=True)
|
||||
return None
|
||||
|
|
@ -1100,79 +1091,55 @@ class QobuzClient:
|
|||
|
||||
# ===================== Status / Cancel / Clear =====================
|
||||
|
||||
def _record_to_status(self, record):
|
||||
return DownloadStatus(
|
||||
id=record['id'],
|
||||
filename=record['filename'],
|
||||
username=record['username'],
|
||||
state=record['state'],
|
||||
progress=record['progress'],
|
||||
size=record.get('size', 0),
|
||||
transferred=record.get('transferred', 0),
|
||||
speed=record.get('speed', 0),
|
||||
time_remaining=record.get('time_remaining'),
|
||||
file_path=record.get('file_path'),
|
||||
)
|
||||
|
||||
async def get_all_downloads(self) -> List[DownloadStatus]:
|
||||
"""Get all active downloads (matches Soulseek interface)."""
|
||||
download_statuses = []
|
||||
|
||||
with self._download_lock:
|
||||
for _download_id, info in self.active_downloads.items():
|
||||
status = DownloadStatus(
|
||||
id=info['id'],
|
||||
filename=info['filename'],
|
||||
username=info['username'],
|
||||
state=info['state'],
|
||||
progress=info['progress'],
|
||||
size=info['size'],
|
||||
transferred=info['transferred'],
|
||||
speed=info['speed'],
|
||||
time_remaining=info.get('time_remaining'),
|
||||
file_path=info.get('file_path'),
|
||||
)
|
||||
download_statuses.append(status)
|
||||
|
||||
return download_statuses
|
||||
if self._engine is None:
|
||||
return []
|
||||
return [
|
||||
self._record_to_status(record)
|
||||
for record in self._engine.iter_records_for_source('qobuz')
|
||||
]
|
||||
|
||||
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
||||
"""Get status of a specific download (matches Soulseek interface)."""
|
||||
with self._download_lock:
|
||||
if download_id not in self.active_downloads:
|
||||
return None
|
||||
|
||||
info = self.active_downloads[download_id]
|
||||
return DownloadStatus(
|
||||
id=info['id'],
|
||||
filename=info['filename'],
|
||||
username=info['username'],
|
||||
state=info['state'],
|
||||
progress=info['progress'],
|
||||
size=info['size'],
|
||||
transferred=info['transferred'],
|
||||
speed=info['speed'],
|
||||
time_remaining=info.get('time_remaining'),
|
||||
file_path=info.get('file_path'),
|
||||
)
|
||||
if self._engine is None:
|
||||
return None
|
||||
record = self._engine.get_record('qobuz', download_id)
|
||||
return self._record_to_status(record) if record is not None else None
|
||||
|
||||
async def cancel_download(self, download_id: str, username: str = None, remove: bool = False) -> bool:
|
||||
"""Cancel an active download (matches Soulseek interface)."""
|
||||
try:
|
||||
with self._download_lock:
|
||||
if download_id not in self.active_downloads:
|
||||
logger.warning(f"Download {download_id} not found")
|
||||
return False
|
||||
|
||||
self.active_downloads[download_id]['state'] = 'Cancelled'
|
||||
logger.info(f"Marked Qobuz download {download_id} as cancelled")
|
||||
|
||||
if remove:
|
||||
del self.active_downloads[download_id]
|
||||
logger.info(f"Removed Qobuz download {download_id} from queue")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cancel download {download_id}: {e}")
|
||||
if self._engine is None:
|
||||
return False
|
||||
if self._engine.get_record('qobuz', download_id) is None:
|
||||
logger.warning(f"Qobuz download {download_id} not found")
|
||||
return False
|
||||
self._engine.update_record('qobuz', download_id, {'state': 'Cancelled'})
|
||||
logger.info(f"Marked Qobuz download {download_id} as cancelled")
|
||||
if remove:
|
||||
self._engine.remove_record('qobuz', download_id)
|
||||
logger.info(f"Removed Qobuz download {download_id} from queue")
|
||||
return True
|
||||
|
||||
async def clear_all_completed_downloads(self) -> bool:
|
||||
"""Clear all terminal downloads from the list (matches Soulseek interface)."""
|
||||
if self._engine is None:
|
||||
return True
|
||||
try:
|
||||
with self._download_lock:
|
||||
ids_to_remove = [
|
||||
did for did, info in self.active_downloads.items()
|
||||
if info.get('state', '') in ('Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted')
|
||||
]
|
||||
for did in ids_to_remove:
|
||||
del self.active_downloads[did]
|
||||
|
||||
terminal = {'Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted'}
|
||||
for record in list(self._engine.iter_records_for_source('qobuz')):
|
||||
if record.get('state') in terminal:
|
||||
self._engine.remove_record('qobuz', record['id'])
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing downloads: {e}")
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue