Merge pull request #521 from Nezreka/dev

Release 2.4.2
This commit is contained in:
BoulderBadgeDad 2026-05-07 16:23:21 -07:00 committed by GitHub
commit fd4b051bcc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
246 changed files with 27821 additions and 5745 deletions

View file

@ -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:

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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": "",

View file

@ -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}' "

View file

@ -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}'")

View file

@ -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."""

View file

@ -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()

View file

@ -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']:

View file

@ -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

View file

@ -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

View file

@ -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("""

View file

@ -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':

View file

@ -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)

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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]

View file

@ -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'] = {}

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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."""

View file

@ -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,

View file

@ -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}")

View file

@ -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

View 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"]

View 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

View 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

View 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 C2C7 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',),
)

View file

@ -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

View 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",
]

View 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."""
...

View 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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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}"]

View file

@ -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}")

View file

@ -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):

View file

@ -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.

View file

@ -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),

View file

@ -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}")

View file

156
core/enrichment/api.py Normal file
View 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

View 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
View 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()

View file

@ -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}")

View file

@ -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

View file

@ -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]:

View file

@ -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 {}

View 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)

View file

@ -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)}")

View file

@ -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)

View file

@ -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.

View file

@ -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")

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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)

View file

@ -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}")

View 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"]

View file

@ -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'],

View file

@ -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'))

View 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',
]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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):

View file

@ -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

View 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",
]

View 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
View 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

View 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
View 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,
)

View file

@ -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",
]

View 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"]

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View 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,
)

View file

@ -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":

View file

@ -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
View 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
View 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']

View file

@ -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

View file

@ -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', ''),

View file

@ -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

View file

@ -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}")

View file

@ -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