diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ae96209f..89f37e58 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -9,9 +9,9 @@ on: workflow_dispatch: inputs: version_tag: - description: 'Version tag (e.g. 2.5.9)' + description: 'Version tag (e.g. 2.6.0)' required: true - default: '2.5.9' + default: '2.6.0' jobs: build-and-push: diff --git a/core/discovery/qobuz.py b/core/discovery/qobuz.py new file mode 100644 index 00000000..3f08f6ae --- /dev/null +++ b/core/discovery/qobuz.py @@ -0,0 +1,299 @@ +"""Background worker for Qobuz playlist discovery. + +`run_qobuz_discovery_worker(playlist_id, deps)` is the function the +Qobuz discovery start-endpoint submits to its executor to match each +Qobuz playlist track against Spotify (preferred) or the configured +fallback metadata source (iTunes / Deezer / Discogs / MusicBrainz). + +Mirrors `core/discovery/deezer.py` exactly — Qobuz playlists arrive as +dicts (not dataclasses) from `core/qobuz_client.py:get_playlist`, so +this worker uses dict-style access on track data and wraps each entry +in a SimpleNamespace before handing it to the shared +`_search_spotify_for_tidal_track` helper. +""" + +from __future__ import annotations + +import logging +import time +import types +from dataclasses import dataclass +from typing import Any, Callable + +logger = logging.getLogger(__name__) + + +@dataclass +class QobuzDiscoveryDeps: + """Bundle of cross-cutting deps the Qobuz discovery worker needs.""" + qobuz_discovery_states: dict + spotify_client: Any + pause_enrichment_workers: Callable[[str], dict] + resume_enrichment_workers: Callable[[dict, str], None] + get_active_discovery_source: Callable[[], str] + get_metadata_fallback_client: Callable[[], Any] + get_discovery_cache_key: Callable + get_database: Callable[[], Any] + validate_discovery_cache_artist: Callable + search_spotify_for_tidal_track: Callable + build_discovery_wing_it_stub: Callable + add_activity_item: Callable + sync_discovery_results_to_mirrored: Callable + + +def run_qobuz_discovery_worker(playlist_id, deps: QobuzDiscoveryDeps): + """Background worker for Qobuz discovery process (Spotify preferred, fallback metadata source).""" + _ew_state = {} + try: + _ew_state = deps.pause_enrichment_workers('Qobuz discovery') + state = deps.qobuz_discovery_states[playlist_id] + playlist = state['playlist'] + + # Determine which provider to use + discovery_source = deps.get_active_discovery_source() + use_spotify = (discovery_source == 'spotify') and deps.spotify_client and deps.spotify_client.is_spotify_authenticated() + + # Initialize fallback client if needed + itunes_client_instance = None + if not use_spotify: + itunes_client_instance = deps.get_metadata_fallback_client() + + logger.info(f"Starting Qobuz discovery for: {playlist['name']} (using {discovery_source.upper()})") + + # Store discovery source in state for frontend + state['discovery_source'] = discovery_source + + successful_discoveries = 0 + tracks = playlist['tracks'] + + for i, qobuz_track in enumerate(tracks): + if state.get('cancelled', False): + break + + try: + track_name = qobuz_track['name'] + track_artists = qobuz_track['artists'] + track_id = qobuz_track['id'] + track_album = qobuz_track.get('album', '') + track_duration_ms = qobuz_track.get('duration_ms', 0) + + logger.info(f"[{i+1}/{len(tracks)}] Searching {discovery_source.upper()}: {track_name} by {', '.join(track_artists)}") + + # Check discovery cache first + cache_key = deps.get_discovery_cache_key(track_name, track_artists[0] if track_artists else '') + try: + cache_db = deps.get_database() + cached_match = cache_db.get_discovery_cache_match(cache_key[0], cache_key[1], discovery_source) + if cached_match and deps.validate_discovery_cache_artist(track_artists[0] if track_artists else '', cached_match): + logger.debug(f"CACHE HIT [{i+1}/{len(tracks)}]: {track_name} by {', '.join(track_artists)}") + cached_artists = cached_match.get('artists', []) + if cached_artists: + cached_artist_str = ', '.join( + a if isinstance(a, str) else a.get('name', '') for a in cached_artists + ) + else: + cached_artist_str = '' + cached_album = cached_match.get('album', '') + if isinstance(cached_album, dict): + cached_album = cached_album.get('name', '') + + result = { + 'qobuz_track': { + 'id': track_id, + 'name': track_name, + 'artists': track_artists or [], + 'album': track_album, + 'duration_ms': track_duration_ms, + }, + 'spotify_data': cached_match, + 'match_data': cached_match, + 'status': 'Found', + 'status_class': 'found', + 'spotify_track': cached_match.get('name', ''), + 'spotify_artist': cached_artist_str, + 'spotify_album': cached_album, + 'spotify_id': cached_match.get('id', ''), + 'discovery_source': discovery_source, + 'index': i + } + successful_discoveries += 1 + state['spotify_matches'] = successful_discoveries + state['discovery_results'].append(result) + state['discovery_progress'] = int(((i + 1) / len(tracks)) * 100) + continue + except Exception as cache_err: + logger.error(f"Cache lookup error: {cache_err}") + + # SimpleNamespace duck-type for _search_spotify_for_tidal_track + track_ns = types.SimpleNamespace( + id=track_id, + name=track_name, + artists=track_artists, + album=track_album, + duration_ms=track_duration_ms + ) + + track_result = deps.search_spotify_for_tidal_track( + track_ns, + use_spotify=use_spotify, + itunes_client=itunes_client_instance + ) + + result = { + 'qobuz_track': { + 'id': track_id, + 'name': track_name, + 'artists': track_artists or [], + 'album': track_album, + 'duration_ms': track_duration_ms, + }, + 'spotify_data': None, + 'match_data': None, + 'status': 'Not Found', + 'status_class': 'not-found', + 'spotify_track': '', + 'spotify_artist': '', + 'spotify_album': '', + 'discovery_source': discovery_source + } + + match_confidence = 0.0 + + if use_spotify and isinstance(track_result, tuple): + track_obj, raw_track_data, match_confidence = track_result + album_obj = raw_track_data.get('album', {}) if raw_track_data else {} + if isinstance(album_obj, dict) and not album_obj.get('name') and track_obj.album: + album_obj['name'] = track_obj.album + elif not album_obj and track_obj.album: + album_obj = {'name': track_obj.album} + if isinstance(album_obj, dict) and not album_obj.get('release_date'): + album_obj['release_date'] = getattr(track_obj, 'release_date', '') or '' + _album_images = album_obj.get('images', []) if isinstance(album_obj, dict) else [] + _image_url = _album_images[0].get('url', '') if _album_images else (getattr(track_obj, 'image_url', '') or '') + + match_data = { + 'id': track_obj.id, + 'name': track_obj.name, + 'artists': track_obj.artists, + 'album': album_obj, + 'duration_ms': track_obj.duration_ms, + 'external_urls': track_obj.external_urls, + 'image_url': _image_url, + 'source': 'spotify' + } + if raw_track_data and raw_track_data.get('track_number'): + match_data['track_number'] = raw_track_data['track_number'] + if raw_track_data and raw_track_data.get('disc_number'): + match_data['disc_number'] = raw_track_data['disc_number'] + result['spotify_data'] = match_data + result['match_data'] = match_data + result['status'] = 'Found' + result['status_class'] = 'found' + result['spotify_track'] = track_obj.name + result['spotify_artist'] = ', '.join(track_obj.artists) if isinstance(track_obj.artists, list) else str(track_obj.artists) + result['spotify_album'] = album_obj.get('name', '') if isinstance(album_obj, dict) else str(album_obj) + result['spotify_id'] = track_obj.id + result['confidence'] = match_confidence + successful_discoveries += 1 + state['spotify_matches'] = successful_discoveries + + elif not use_spotify and track_result and isinstance(track_result, dict): + match_confidence = track_result.pop('confidence', 0.80) + match_data = track_result + match_data['source'] = discovery_source + _fb_album = match_data.get('album', {}) + _fb_images = _fb_album.get('images', []) if isinstance(_fb_album, dict) else [] + if _fb_images and 'image_url' not in match_data: + match_data['image_url'] = _fb_images[0].get('url', '') + result['spotify_data'] = match_data + result['match_data'] = match_data + result['status'] = 'Found' + result['status_class'] = 'found' + result['spotify_track'] = match_data.get('name', '') + itunes_artists = match_data.get('artists', []) + result['spotify_artist'] = ', '.join(a if isinstance(a, str) else a.get('name', '') for a in itunes_artists) if itunes_artists else '' + result['spotify_album'] = match_data.get('album', {}).get('name', '') if isinstance(match_data.get('album'), dict) else match_data.get('album', '') + result['spotify_id'] = match_data.get('id', '') + result['confidence'] = match_confidence + successful_discoveries += 1 + state['spotify_matches'] = successful_discoveries + + # Save to discovery cache if match found + if result['status_class'] == 'found' and result.get('match_data'): + try: + cache_db = deps.get_database() + cache_db.save_discovery_cache_match( + cache_key[0], cache_key[1], discovery_source, match_confidence, + result['match_data'], track_name, + track_artists[0] if track_artists else '' + ) + logger.info(f"CACHE SAVED: {track_name} (confidence: {match_confidence:.3f})") + except Exception as cache_err: + logger.error(f"Cache save error: {cache_err}") + + # Auto Wing It fallback for unmatched tracks + if result['status_class'] == 'not-found': + qobuz_t = result.get('qobuz_track', {}) + stub = deps.build_discovery_wing_it_stub( + qobuz_t.get('name', ''), + ', '.join(qobuz_t.get('artists', [])), + qobuz_t.get('duration_ms', 0) + ) + result['status'] = 'Wing It' + result['status_class'] = 'wing-it' + result['spotify_data'] = stub + result['match_data'] = stub + result['spotify_track'] = qobuz_t.get('name', '') + result['spotify_artist'] = ', '.join(qobuz_t.get('artists', [])) + result['wing_it_fallback'] = True + result['confidence'] = 0 + successful_discoveries += 1 + state['spotify_matches'] = successful_discoveries + state['wing_it_count'] = state.get('wing_it_count', 0) + 1 + + result['index'] = i + state['discovery_results'].append(result) + state['discovery_progress'] = int(((i + 1) / len(tracks)) * 100) + + time.sleep(0.1) + + except Exception as e: + logger.error(f"Error processing track {i+1}: {e}") + result = { + 'qobuz_track': { + 'name': qobuz_track.get('name', 'Unknown'), + 'artists': qobuz_track.get('artists', []), + }, + 'spotify_data': None, + 'match_data': None, + 'status': 'Error', + 'status_class': 'error', + 'spotify_track': '', + 'spotify_artist': '', + 'spotify_album': '', + 'error': str(e), + 'discovery_source': discovery_source, + 'index': i + } + state['discovery_results'].append(result) + state['discovery_progress'] = int(((i + 1) / len(tracks)) * 100) + + # Mark as complete + state['phase'] = 'discovered' + state['status'] = 'discovered' + state['discovery_progress'] = 100 + + source_label = discovery_source.upper() + deps.add_activity_item("", f"Qobuz Discovery Complete ({source_label})", f"'{playlist['name']}' - {successful_discoveries}/{len(tracks)} tracks found", "Now") + + logger.info(f"Qobuz discovery complete ({source_label}): {successful_discoveries}/{len(tracks)} tracks found") + + deps.sync_discovery_results_to_mirrored('qobuz', playlist_id, state.get('discovery_results', []), discovery_source, profile_id=state.get('_profile_id', 1)) + + except Exception as e: + logger.error(f"Error in Qobuz discovery worker: {e}") + if playlist_id in deps.qobuz_discovery_states: + deps.qobuz_discovery_states[playlist_id]['phase'] = 'error' + deps.qobuz_discovery_states[playlist_id]['status'] = f'error: {str(e)}' + finally: + deps.resume_enrichment_workers(_ew_state, 'Qobuz discovery') diff --git a/core/download_orchestrator.py b/core/download_orchestrator.py index 879d0f52..1984c0a6 100644 --- a/core/download_orchestrator.py +++ b/core/download_orchestrator.py @@ -113,6 +113,22 @@ class DownloadOrchestrator: if hasattr(amazon, '_client') and amazon._client: amazon._client.preferred_codec = quality + # Let registry-backed plugins refresh any config they cache at + # construction time. This covers Prowlarr-backed torrent / usenet + # clients without rebuilding the registry and losing active downloads. + for name, client in self.registry.all_plugins(): + if not hasattr(client, 'reload_settings'): + continue + try: + client.reload_settings() + logger.info("%s client settings reloaded", self.registry.display_name(name)) + except Exception as exc: + logger.warning( + "%s client settings reload failed: %s", + self.registry.display_name(name), + exc, + ) + # 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 diff --git a/core/downloads/album_bundle_dispatch.py b/core/downloads/album_bundle_dispatch.py index 6a74914f..09ec1a46 100644 --- a/core/downloads/album_bundle_dispatch.py +++ b/core/downloads/album_bundle_dispatch.py @@ -7,8 +7,9 @@ can be unit-tested in isolation. The gate fires only when ALL conditions hold: - Batch is an album-context download (``is_album_download`` flag). -- Active download source is ``torrent`` or ``usenet`` (single-source - mode — hybrid stays per-track to preserve fallback). +- Active download source is ``torrent``, ``usenet``, or ``soulseek``. + In hybrid mode the caller may pass the first configured source as a + source override; later hybrid sources stay per-track to preserve fallback. - Both album-name and artist-name are populated in batch context. - The resolved plugin exposes ``download_album_to_staging``. @@ -57,7 +58,7 @@ class BatchStateAccess(Protocol): # so the Downloads page can render it without coupling to the # specific payload shape. _MIRRORED_KEYS = ('progress', 'release', 'speed', 'downloaded', - 'size', 'seeders', 'grabs', 'count') + 'size', 'seeders', 'grabs', 'count', 'failed') def is_eligible( @@ -72,7 +73,7 @@ def is_eligible( the gate logic without standing up a plugin.""" if not is_album: return False - if (mode or '').lower() not in ('torrent', 'usenet'): + if (mode or '').lower() not in ('torrent', 'usenet', 'soulseek'): return False if not (album_name or '').strip(): return False @@ -90,6 +91,8 @@ def try_dispatch( config_get: Callable[..., Any], plugin_resolver: Callable[[str], Optional[Any]], state: BatchStateAccess, + source_override: Optional[str] = None, + plugin_kwargs: Optional[dict] = None, ) -> bool: """Attempt the album-bundle flow. Returns ``True`` iff the master worker should return early (gate engaged and completed @@ -102,7 +105,7 @@ def try_dispatch( BatchStateAccess shim. Injecting these keeps the module dependency-light + unit-testable. """ - mode = (config_get('download_source.mode', 'soulseek') or 'soulseek').lower() + mode = (source_override or config_get('download_source.mode', 'soulseek') or 'soulseek').lower() album_name = (album_context or {}).get('name') or '' artist_name = (artist_context or {}).get('name') or '' @@ -159,6 +162,7 @@ def try_dispatch( try: outcome = plugin.download_album_to_staging( album_name, artist_name, staging_dir, _emit, + **(plugin_kwargs or {}), ) except Exception as exc: logger.exception("[Album Bundle] %s plugin raised: %s", mode, exc) @@ -166,6 +170,17 @@ def try_dispatch( if not outcome.get('success'): err = outcome.get('error', 'Album bundle download failed') + if outcome.get('fallback'): + logger.warning( + "[Album Bundle] %s flow could not commit for '%s': %s — falling back to per-track flow", + mode, album_name, err, + ) + state.update_fields(batch_id, { + 'phase': 'analysis', + 'album_bundle_state': 'fallback', + 'album_bundle_error': err, + }) + return False logger.error("[Album Bundle] %s flow failed for '%s': %s", mode, album_name, err) state.mark_failed(batch_id, err) @@ -178,6 +193,9 @@ def try_dispatch( state.update_fields(batch_id, { 'phase': 'analysis', 'album_bundle_state': 'staged', + 'album_bundle_partial': bool(outcome.get('partial')), + 'album_bundle_expected_count': outcome.get('expected_count'), + 'album_bundle_completed_count': outcome.get('completed_count', len(outcome.get('files', []))), }) # Engaged-and-succeeded: we DON'T early-return because the # per-track flow needs to run to create + complete the per-track diff --git a/core/downloads/master.py b/core/downloads/master.py index 90adbb4e..83842f17 100644 --- a/core/downloads/master.py +++ b/core/downloads/master.py @@ -50,6 +50,7 @@ _VARIANT_WORDS = { 'remix', 'rmx', 'acapella', 'a cappella', 'instrumental', 'karaoke', 'live', 'demo', 'extended', } +_ALBUM_BUNDLE_SOURCES = frozenset(('torrent', 'usenet', 'soulseek')) def _norm_text(value: Any) -> str: @@ -245,6 +246,29 @@ def _soulseek_album_preflight_enabled(config_manager: Any) -> bool: return primary == 'soulseek' +def _resolve_album_bundle_source(config_manager: Any) -> str: + """Return the album-bundle source for this batch. + + In single-source mode, the active source may own the whole album if + it supports album bundles. In hybrid mode, only the first source in + the configured order may claim the whole album; later sources remain + per-track fallback. + """ + mode = (config_manager.get('download_source.mode', 'soulseek') or 'soulseek').lower() + if mode in _ALBUM_BUNDLE_SOURCES: + return mode + if mode != 'hybrid': + return '' + + order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek']) + first = '' + if order: + first = str(order[0] or '').lower() + else: + first = str(config_manager.get('download_source.hybrid_primary', '') or '').lower() + return first if first in _ALBUM_BUNDLE_SOURCES else '' + + @dataclass class MasterDeps: """Bundle of cross-cutting deps the master worker needs.""" @@ -338,16 +362,19 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma # should stop (gate fired and failed); False = engaged-and- # succeeded OR didn't engage, both fall through to per-track. _bundle_state = _BatchStateAccessImpl() - if _album_bundle_dispatch.try_dispatch( - batch_id=batch_id, - is_album=batch_is_album, - album_context=batch_album_context, - artist_context=batch_artist_context, - config_get=deps.config_manager.get, - plugin_resolver=deps.download_orchestrator.client, - state=_bundle_state, - ): - return + _album_bundle_source = _resolve_album_bundle_source(deps.config_manager) + if _album_bundle_source and _album_bundle_source != 'soulseek': + if _album_bundle_dispatch.try_dispatch( + batch_id=batch_id, + is_album=batch_is_album, + album_context=batch_album_context, + artist_context=batch_artist_context, + config_get=deps.config_manager.get, + plugin_resolver=deps.download_orchestrator.client, + state=_bundle_state, + source_override=_album_bundle_source, + ): + return # Allow duplicate tracks across albums — when enabled, only skip tracks already # owned in THIS album, not tracks owned in other albums @@ -640,6 +667,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma batch_album_context = batch.get('album_context') batch_artist_context = batch.get('artist_context') batch_is_album = batch.get('is_album_download', False) + batch_private_album_bundle = bool(batch.get('album_bundle_private_staging')) batch_playlist_folder_mode = batch.get('playlist_folder_mode', False) batch_playlist_name = batch.get('playlist_name', 'Unknown Playlist') @@ -648,7 +676,8 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma preflight_source = None preflight_tracks = None soulseek_is_source = _soulseek_album_preflight_enabled(deps.config_manager) - if batch_is_album and batch_album_context and batch_artist_context and soulseek_is_source: + if (batch_is_album and batch_album_context and batch_artist_context + and soulseek_is_source and not batch_private_album_bundle): artist_name = batch_artist_context.get('name', '') album_name = batch_album_context.get('name', '') if artist_name and album_name: @@ -761,13 +790,44 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma logger.error(f"[Album Pre-flight] Search failed (non-fatal, falling back to track-by-track): {preflight_err}") deps.source_reuse_logger.info(f"[Album Pre-flight] Exception: {preflight_err}") + # Soulseek album bundles run after analysis so an already-owned + # album does not get downloaded just because the source supports a + # whole-folder flow. When preflight selected a folder, pass that + # exact source into the bundle downloader so we keep the richer + # tracklist-aware scoring instead of doing a weaker second pick. + _bundle_state = _BatchStateAccessImpl() + _album_bundle_source = _resolve_album_bundle_source(deps.config_manager) + if _album_bundle_source == 'soulseek': + if _album_bundle_dispatch.try_dispatch( + batch_id=batch_id, + is_album=batch_is_album, + album_context=batch_album_context, + artist_context=batch_artist_context, + config_get=deps.config_manager.get, + plugin_resolver=deps.download_orchestrator.client, + state=_bundle_state, + source_override=_album_bundle_source, + plugin_kwargs={ + 'preferred_source': preflight_source, + 'preferred_tracks': preflight_tracks, + } if preflight_source and preflight_tracks else None, + ): + return + with tasks_lock: if batch_id not in download_batches: return download_batches[batch_id]['phase'] = 'downloading' # Store album pre-flight results on batch for source reuse - if preflight_source and preflight_tracks: + # unless the Soulseek album-bundle path already staged a private + # release. Task workers check source reuse before staging match, so + # preloading here would make the staged happy path re-download. + if ( + preflight_source + and preflight_tracks + and not download_batches[batch_id].get('album_bundle_private_staging') + ): download_batches[batch_id]['last_good_source'] = preflight_source download_batches[batch_id]['source_folder_tracks'] = preflight_tracks download_batches[batch_id]['failed_sources'] = set() diff --git a/core/downloads/task_worker.py b/core/downloads/task_worker.py index 67b2a3fe..77cb803f 100644 --- a/core/downloads/task_worker.py +++ b/core/downloads/task_worker.py @@ -34,7 +34,7 @@ logger = logging.getLogger(__name__) def _private_album_bundle_staging_miss_reason(batch_id: Optional[str], deps: Any) -> Optional[str]: """Return a user-facing miss reason when per-track search should stop. - Torrent / usenet album batches first download one private staged release, + Torrent / usenet / Soulseek album batches first download one private staged release, then each track claims the matching staged file. If that claim fails after the release is already staged, falling through to the normal per-track search only retries release-level sources N times and can keep re-adding @@ -49,11 +49,19 @@ def _private_album_bundle_staging_miss_reason(batch_id: Optional[str], deps: Any source = (batch.get('album_bundle_source') or '').lower() mode = (getattr(deps.download_orchestrator, 'mode', '') or '').lower() + hybrid_first = '' + if mode == 'hybrid': + order = getattr(deps.download_orchestrator, 'hybrid_order', None) or [] + if order: + hybrid_first = str(order[0] or '').lower() + else: + hybrid_first = str(getattr(deps.download_orchestrator, 'hybrid_primary', '') or '').lower() if ( batch.get('album_bundle_private_staging') and batch.get('album_bundle_state') == 'staged' - and source in ('torrent', 'usenet') - and mode == source + and not batch.get('album_bundle_partial') + and source in ('torrent', 'usenet', 'soulseek') + and (mode == source or (mode == 'hybrid' and hybrid_first == source)) ): return f'Track was not found in the staged {source} album release' diff --git a/core/imports/routes.py b/core/imports/routes.py index 57df3c7b..edd38f08 100644 --- a/core/imports/routes.py +++ b/core/imports/routes.py @@ -217,7 +217,12 @@ def staging_hints(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]: def staging_suggestions() -> tuple[Dict[str, Any], int]: """Return cached import suggestions and readiness state.""" cache = get_import_suggestions_cache() - return {"success": True, "suggestions": cache["suggestions"], "ready": cache["built"]}, 200 + return { + "success": True, + "suggestions": cache["suggestions"], + "ready": cache["built"], + "primary_source": _get_primary_source(), + }, 200 def search_albums(runtime: ImportRouteRuntime, query: str, limit: int = 12) -> tuple[Dict[str, Any], int]: @@ -228,11 +233,12 @@ def search_albums(runtime: ImportRouteRuntime, query: str, limit: int = 12) -> t return {"success": False, "error": "Missing query parameter"}, 400 limit = min(int(limit), 50) - if runtime.get_primary_source() == "hydrabase" and runtime.hydrabase_worker and runtime.dev_mode_enabled: + primary_source = runtime.get_primary_source() + if primary_source == "hydrabase" and runtime.hydrabase_worker and runtime.dev_mode_enabled: runtime.hydrabase_worker.enqueue(query, "albums") albums = runtime.search_import_albums(query, limit=limit) - return {"success": True, "albums": albums}, 200 + return {"success": True, "albums": albums, "primary_source": primary_source}, 200 except Exception as exc: runtime.logger.error("Error searching albums for import: %s", exc) return {"success": False, "error": str(exc)}, 500 @@ -362,11 +368,12 @@ def search_tracks(runtime: ImportRouteRuntime, query: str, limit: int = 10) -> t return {"success": False, "error": "Missing query parameter"}, 400 limit = min(int(limit), 30) - if runtime.get_primary_source() == "hydrabase" and runtime.hydrabase_worker and runtime.dev_mode_enabled: + primary_source = runtime.get_primary_source() + if primary_source == "hydrabase" and runtime.hydrabase_worker and runtime.dev_mode_enabled: runtime.hydrabase_worker.enqueue(query, "tracks") tracks = runtime.search_import_tracks(query, limit=limit) - return {"success": True, "tracks": tracks}, 200 + return {"success": True, "tracks": tracks, "primary_source": primary_source}, 200 except Exception as exc: runtime.logger.error("Error searching tracks for import: %s", exc) return {"success": False, "error": str(exc)}, 500 diff --git a/core/qobuz_client.py b/core/qobuz_client.py index 1cb58a66..cf4fbf9b 100644 --- a/core/qobuz_client.py +++ b/core/qobuz_client.py @@ -711,6 +711,249 @@ class QobuzClient(DownloadSourcePlugin): logger.error(f"Error getting Qobuz track {track_id}: {e}") return None + # ===================== Playlists & Favorites ===================== + # + # Qobuz playlist sync surface — mirrors the Tidal client contract + # (see core/tidal_client.py:629 + :1227) so the Sync page's + # per-service handlers can render Qobuz playlists in the same + # discovery / mirror flow. Returns normalized dicts rather than + # dataclasses to match the rest of this client's idiom. + # + # Favorite Tracks ride on the same `get_playlist()` entry point via + # the virtual ID below — same pattern as Tidal's COLLECTION_PLAYLIST_ID + # so sync-services.js can treat favorites as just another playlist + # card without per-service special-casing. + QOBUZ_FAVORITES_ID = "qobuz-favorites" + QOBUZ_FAVORITES_NAME = "Favorite Tracks" + QOBUZ_FAVORITES_DESCRIPTION = "Your favorited tracks on Qobuz" + + # Page size for paginated playlist + favorite listings. Qobuz caps at + # 500 per page; 100 is a safe middle ground for responsiveness. + _PLAYLIST_PAGE_SIZE = 100 + + def _normalize_qobuz_playlist(self, p: Dict) -> Dict: + """Project a Qobuz playlist dict into the shape the Sync page expects.""" + image = p.get('images', []) or [] + image_url = image[0] if image else (p.get('image_rectangle', [None])[0] if p.get('image_rectangle') else None) + if not image_url: + image_url = p.get('image', '') or '' + return { + 'id': str(p.get('id', '')), + 'name': p.get('name', 'Unknown Playlist'), + 'description': p.get('description', '') or '', + 'public': bool(p.get('is_public', False)), + 'track_count': int(p.get('tracks_count', 0) or 0), + 'image_url': image_url, + 'external_urls': {'qobuz': f"https://play.qobuz.com/playlist/{p.get('id', '')}"} if p.get('id') else {}, + } + + def _normalize_qobuz_track(self, t: Dict) -> Dict: + """Project a Qobuz track dict into the shape the Sync page expects.""" + performer = t.get('performer') or {} + album = t.get('album') or {} + album_artist = album.get('artist') or {} + + # Artist names — Qobuz can stash the artist on performer, album.artist, + # or composer depending on the track. Prefer performer, fall back to + # album artist, then composer, then "Unknown Artist". + artist_name = ( + performer.get('name') + or album_artist.get('name') + or (t.get('composer') or {}).get('name') + or 'Unknown Artist' + ) + + album_image = album.get('image') or {} + image_url = album_image.get('large') or album_image.get('small') or album_image.get('thumbnail') or '' + + return { + 'id': str(t.get('id', '')), + 'name': t.get('title', '') or '', + 'artists': [artist_name], + 'album': album.get('title', '') or '', + 'duration_ms': int(t.get('duration', 0) or 0) * 1000, + 'image_url': image_url, + 'external_urls': {'qobuz': f"https://play.qobuz.com/track/{t.get('id', '')}"} if t.get('id') else {}, + 'explicit': bool(t.get('parental_warning', False)), + } + + def get_user_playlists(self) -> List[Dict[str, Any]]: + """Fetch the authenticated user's Qobuz playlists. + + Returns metadata only (no tracks) — track lists are fetched + on-demand via `get_playlist()` when the user selects one. + Matches the Tidal `get_user_playlists_metadata_only` contract + so the Sync page renderer can treat both services uniformly. + """ + if not self.is_authenticated(): + logger.warning("Qobuz not authenticated — cannot list playlists") + return [] + + playlists: List[Dict[str, Any]] = [] + offset = 0 + while True: + data = self._api_request('playlist/getUserPlaylists', { + 'limit': self._PLAYLIST_PAGE_SIZE, + 'offset': offset, + }) + if not data: + break + + container = data.get('playlists') or {} + items = container.get('items', []) or [] + if not items: + break + + for raw in items: + try: + playlists.append(self._normalize_qobuz_playlist(raw)) + except Exception as exc: + logger.debug(f"Skipping malformed Qobuz playlist entry: {exc}") + + total = int(container.get('total', len(playlists)) or len(playlists)) + offset += len(items) + if offset >= total or len(items) < self._PLAYLIST_PAGE_SIZE: + break + + logger.info(f"Retrieved {len(playlists)} Qobuz user playlists") + return playlists + + def get_playlist(self, playlist_id: str) -> Optional[Dict[str, Any]]: + """Fetch a Qobuz playlist with its full tracklist. + + Recognizes the virtual ``qobuz-favorites`` ID and dispatches to + ``get_user_favorite_tracks`` so the Sync page can treat + favorites as just another playlist card (same pattern as + Tidal's ``tidal-favorites``). + """ + if not self.is_authenticated(): + logger.warning("Qobuz not authenticated — cannot fetch playlist") + return None + + if str(playlist_id) == self.QOBUZ_FAVORITES_ID: + tracks = self.get_user_favorite_tracks() + return { + 'id': self.QOBUZ_FAVORITES_ID, + 'name': self.QOBUZ_FAVORITES_NAME, + 'description': self.QOBUZ_FAVORITES_DESCRIPTION, + 'public': False, + 'track_count': len(tracks), + 'image_url': '', + 'external_urls': {}, + 'tracks': tracks, + } + + # Paginate tracks ourselves — Qobuz's playlist/get only returns + # the first ~50 tracks even with limit=500 on some accounts. + tracks: List[Dict[str, Any]] = [] + offset = 0 + playlist_meta: Optional[Dict] = None + while True: + data = self._api_request('playlist/get', { + 'playlist_id': playlist_id, + 'extra': 'tracks', + 'limit': self._PLAYLIST_PAGE_SIZE, + 'offset': offset, + }) + if not data: + break + + if playlist_meta is None: + playlist_meta = data + + track_container = data.get('tracks') or {} + items = track_container.get('items', []) or [] + if not items: + break + + for raw in items: + try: + tracks.append(self._normalize_qobuz_track(raw)) + except Exception as exc: + logger.debug(f"Skipping malformed Qobuz playlist track: {exc}") + + total = int(track_container.get('total', len(tracks)) or len(tracks)) + offset += len(items) + if offset >= total or len(items) < self._PLAYLIST_PAGE_SIZE: + break + + if playlist_meta is None: + logger.warning(f"Qobuz playlist {playlist_id} not found") + return None + + normalized = self._normalize_qobuz_playlist(playlist_meta) + normalized['tracks'] = tracks + normalized['track_count'] = len(tracks) + logger.info(f"Retrieved Qobuz playlist '{normalized['name']}' with {len(tracks)} tracks") + return normalized + + def get_user_favorite_tracks(self, limit: Optional[int] = None) -> List[Dict[str, Any]]: + """Fetch the authenticated user's favorited tracks. + + Mirrors ``TidalClient.get_collection_tracks`` — the Sync page's + Favorite Tracks card pulls from here on click. By default this + fetches the full favorites collection so the card count and the + discovered track list cannot silently diverge. Pass ``limit`` for + explicit capped callers. + """ + if not self.is_authenticated(): + logger.warning("Qobuz not authenticated — cannot list favorite tracks") + return [] + + tracks: List[Dict[str, Any]] = [] + offset = 0 + while True: + page_size = self._PLAYLIST_PAGE_SIZE if limit is None else min(self._PLAYLIST_PAGE_SIZE, limit - len(tracks)) + if page_size <= 0: + break + + data = self._api_request('favorite/getUserFavorites', { + 'type': 'tracks', + 'limit': page_size, + 'offset': offset, + }) + if not data: + break + + container = data.get('tracks') or {} + items = container.get('items', []) or [] + if not items: + break + + for raw in items: + try: + tracks.append(self._normalize_qobuz_track(raw)) + except Exception as exc: + logger.debug(f"Skipping malformed Qobuz favorite track: {exc}") + + total = int(container.get('total', len(tracks)) or len(tracks)) + offset += len(items) + if offset >= total or len(items) < page_size: + break + if limit is not None and len(tracks) >= limit: + break + + logger.info(f"Retrieved {len(tracks)} Qobuz favorite tracks") + return tracks + + def get_user_favorite_tracks_count(self) -> int: + """Cheap track-count lookup for the Favorite Tracks card metadata. + + Mirrors ``TidalClient.get_collection_tracks_count`` — avoids + fetching the full list just to populate the card's track-count + chip on the Sync page. + """ + if not self.is_authenticated(): + return 0 + data = self._api_request('favorite/getUserFavorites', { + 'type': 'tracks', + 'limit': 1, + 'offset': 0, + }) + if not data: + return 0 + return int((data.get('tracks') or {}).get('total', 0) or 0) + async def search(self, query: str, timeout: int = None, progress_callback=None) -> Tuple[List[TrackResult], List[AlbumResult]]: """ Search Qobuz for tracks (async, Soulseek-compatible interface). diff --git a/core/repair_jobs/orphan_file_detector.py b/core/repair_jobs/orphan_file_detector.py index 80c5a237..5c6e69cd 100644 --- a/core/repair_jobs/orphan_file_detector.py +++ b/core/repair_jobs/orphan_file_detector.py @@ -67,22 +67,28 @@ class OrphanFileDetectorJob(RepairJob): suffix = '/'.join(parts[-depth:]).lower() known_suffixes.add(suffix) - # Build title+artist sets for tag-based fallback matching + # Build title+artist sets for fallback matching. Include both + # track artist and album artist so Picard-style albumartist paths + # don't look orphaned when tracks have featured/guest artists. cursor.execute(""" - SELECT t.title, ar.name FROM tracks t - LEFT JOIN artists ar ON ar.id = t.artist_id + SELECT t.title, track_ar.name, album_ar.name FROM tracks t + LEFT JOIN artists track_ar ON track_ar.id = t.artist_id + LEFT JOIN albums al ON al.id = t.album_id + LEFT JOIN artists album_ar ON album_ar.id = al.artist_id WHERE t.title IS NOT NULL AND t.title != '' """) for row in cursor.fetchall(): title = (row[0] or '').lower().strip() - artist = (row[1] or '').lower().strip() if title: - known_titles.add((title, artist)) - # Also store normalized version (stripped of feat., parentheticals, etc.) - clean_t = _strip_extras(title) - clean_a = _strip_extras(artist) - if clean_t: - known_titles_clean.add((clean_t, clean_a)) + for artist_value in (row[1], row[2]): + artist = (artist_value or '').lower().strip() + known_titles.add((title, artist)) + # Also store normalized version (stripped of feat., + # parentheticals, etc.) + clean_t = _strip_extras(title) + clean_a = _strip_extras(artist) + if clean_t: + known_titles_clean.add((clean_t, clean_a)) except Exception as e: logger.error("Error reading known file paths from DB: %s", e, exc_info=True) result.errors += 1 @@ -144,14 +150,21 @@ class OrphanFileDetectorJob(RepairJob): if audio: file_title = ((audio.get('title') or [None])[0] or '').lower().strip() file_artist = ((audio.get('artist') or [None])[0] or '').lower().strip() + file_albumartist = ( + (audio.get('albumartist') or audio.get('album_artist') or [None])[0] + or '' + ).lower().strip() if file_title: + file_artists = [a for a in (file_artist, file_albumartist) if a] + if not file_artists: + file_artists = [''] # Exact match first (fast path) - if (file_title, file_artist) in known_titles: + if any((file_title, artist) in known_titles for artist in file_artists): is_known = True else: # Normalized match: strip (feat. X), [FLAC 16bit], etc. clean_title = _strip_extras(file_title) - clean_artist = _strip_extras(file_artist) + clean_artist = _strip_extras(file_artists[0]) # Also try first artist only (handles "Gorillaz, Dennis Hopper" → "Gorillaz") first_artist = clean_artist.split(',')[0].strip() if clean_artist else '' if clean_title and ( @@ -159,6 +172,16 @@ class OrphanFileDetectorJob(RepairJob): (first_artist and (clean_title, first_artist) in known_titles_clean) ): is_known = True + if clean_title and not is_known: + for artist in file_artists[1:]: + clean_artist = _strip_extras(artist) + first_artist = clean_artist.split(',')[0].strip() if clean_artist else '' + if ( + (clean_title, clean_artist) in known_titles_clean or + (first_artist and (clean_title, first_artist) in known_titles_clean) + ): + is_known = True + break except Exception as e: logger.debug("tag-based orphan check: %s", e) diff --git a/core/soulseek_client.py b/core/soulseek_client.py index 71b20345..8a7f82dd 100644 --- a/core/soulseek_client.py +++ b/core/soulseek_client.py @@ -18,7 +18,13 @@ from core.download_plugins.types import ( SearchResult, TrackResult, ) +from core.download_plugins.album_bundle import ( + copy_audio_files_atomically, + get_poll_interval, + get_poll_timeout, +) from core.download_plugins.base import DownloadSourcePlugin +from utils.async_helpers import run_async logger = get_logger("soulseek_client") @@ -1456,6 +1462,326 @@ class SoulseekClient(DownloadSourcePlugin): logger.info(f"Downloading: {best_result.filename} ({quality_info}) from {best_result.username}") return await self.download(best_result.username, best_result.filename, best_result.size) + + def download_album_to_staging( + self, + album_name: str, + artist_name: str, + staging_dir: str, + progress_callback=None, + *, + preferred_source: Optional[Dict[str, Any]] = None, + preferred_tracks: Optional[List[TrackResult]] = None, + ) -> Dict[str, Any]: + """One-shot Soulseek album download. + + Search for one album folder, enqueue files from that single + ``username + folder_path``, wait for slskd to report completion, + then copy completed files into the private album-bundle staging + directory. If the folder cannot be selected or enqueued cleanly, + callers may fall back to the existing per-track Soulseek flow. + Once files are staged, the per-track staging matcher owns final + import, same as torrent / usenet album bundles. + """ + result: Dict[str, Any] = { + 'success': False, + 'files': [], + 'error': None, + 'fallback': True, + 'partial': False, + } + if not self.is_configured(): + result['error'] = 'Soulseek source not configured' + return result + + def _emit(state: str, **extra) -> None: + if progress_callback: + try: + progress_callback({'state': state, **extra}) + except Exception as cb_exc: + logger.debug("[Soulseek album] progress callback failed: %s", cb_exc) + + picked = None + folder_tracks = list(preferred_tracks or []) + username = (preferred_source or {}).get('username', '') if preferred_source else '' + folder_path = (preferred_source or {}).get('folder_path', '') if preferred_source else '' + if username and folder_path: + logger.info( + "[Soulseek album] Using preflight-selected folder %s:%s", + username, + folder_path, + ) + _emit('searching', query=f"{artist_name} {album_name}".strip(), release=folder_path) + else: + query = f"{artist_name} {album_name}".strip() + _emit('searching', query=query) + try: + _, albums = run_async(self.search(query, timeout=30)) + except Exception as exc: + result['error'] = f'Soulseek album search failed: {exc}' + return result + + if not albums: + result['error'] = 'No complete Soulseek album folders found' + return result + + picked = self._pick_album_bundle_folder(albums, album_name, artist_name) + if picked is None: + result['error'] = 'No suitable Soulseek album folder after filtering' + return result + + folder_path = getattr(picked, 'album_path', '') or '' + username = getattr(picked, 'username', '') or '' + if not username or not folder_path: + result['error'] = 'No suitable Soulseek album folder after filtering' + return result + + logger.info( + "[Soulseek album] Picked %s:%s (%s tracks, quality=%s)", + username, + folder_path, + getattr(picked, 'track_count', 0), + getattr(picked, 'dominant_quality', ''), + ) + _emit( + 'queued', + release=getattr(picked, 'album_title', folder_path) if picked else folder_path, + count=getattr(picked, 'track_count', 0) if picked else len(folder_tracks), + ) + + if not folder_tracks: + try: + browse_files = run_async(self.browse_user_directory(username, folder_path)) + except Exception as exc: + result['error'] = f'Soulseek folder browse failed: {exc}' + return result + + if not browse_files: + result['error'] = 'Could not browse selected Soulseek album folder' + return result + + folder_tracks = self.parse_browse_results_to_tracks( + username, + browse_files, + directory=folder_path, + ) + folder_tracks = self.filter_results_by_quality_preference(folder_tracks) + if not folder_tracks: + result['error'] = 'Selected Soulseek album folder contained no audio files' + return result + + transfer_keys: Dict[tuple, TrackResult] = {} + _emit( + 'downloading', + release=getattr(picked, 'album_title', folder_path) if picked else folder_path, + count=len(folder_tracks), + ) + for track in folder_tracks: + try: + download_id = run_async(self.download(track.username, track.filename, track.size)) + except Exception as exc: + logger.warning("[Soulseek album] Failed to enqueue %s: %s", track.filename, exc) + continue + if download_id: + transfer_keys[(track.username, track.filename)] = track + + if not transfer_keys: + result['error'] = 'No Soulseek album files could be enqueued' + return result + + result['fallback'] = False + completed = self._poll_album_bundle_downloads(transfer_keys, _emit) + if not completed: + result['error'] = 'Soulseek album download failed or timed out' + return result + + _emit('staging', release=getattr(picked, 'album_title', folder_path) if picked else folder_path) + copied = copy_audio_files_atomically(completed, Path(staging_dir)) + if not copied: + result['error'] = 'No Soulseek album files copied to staging' + return result + + partial = len(copied) < len(transfer_keys) + if partial: + logger.warning( + "[Soulseek album] Staged partial album for '%s': %d/%d files", + album_name, + len(copied), + len(transfer_keys), + ) + else: + logger.info("[Soulseek album] Staged %d files for '%s'", len(copied), album_name) + _emit('staged', count=len(copied)) + result['success'] = True + result['files'] = copied + result['partial'] = partial + result['expected_count'] = len(transfer_keys) + result['completed_count'] = len(copied) + return result + + def _pick_album_bundle_folder( + self, + albums: List[AlbumResult], + album_name: str, + artist_name: str, + ) -> Optional[AlbumResult]: + scored = [] + for album in albums: + tracks = self.filter_results_by_quality_preference(list(getattr(album, 'tracks', []) or [])) + if not tracks: + continue + album_text = f"{getattr(album, 'album_title', '')} {getattr(album, 'album_path', '')}" + artist_text = f"{getattr(album, 'artist', '')} {getattr(album, 'album_path', '')}" + album_score = self._bundle_similarity(album_name, album_text) + artist_score = self._bundle_similarity(artist_name, artist_text) + track_count = int(getattr(album, 'track_count', 0) or len(tracks)) + count_score = 1.0 if track_count >= 3 else 0.35 + score = ( + album_score * 0.42 + + artist_score * 0.22 + + count_score * 0.12 + + min(1.0, len(tracks) / max(1, track_count)) * 0.12 + + float(getattr(album, 'quality_score', 0.0) or 0.0) * 0.12 + ) + scored.append((score, len(tracks), album)) + if not scored: + return None + scored.sort(key=lambda row: (row[0], row[1], getattr(row[2], 'quality_score', 0.0)), reverse=True) + best_score, _, best = scored[0] + if best_score < 0.58: + logger.warning("[Soulseek album] Best folder score %.3f below threshold", best_score) + return None + return best + + @staticmethod + def _bundle_similarity(expected: Any, actual: Any) -> float: + import re + from difflib import SequenceMatcher + left = re.sub(r'[^a-z0-9]+', ' ', str(expected or '').lower()).strip() + right = re.sub(r'[^a-z0-9]+', ' ', str(actual or '').lower()).strip() + if not left or not right: + return 0.0 + if left == right: + return 1.0 + left_words = set(left.split()) + right_words = set(right.split()) + if left_words and left_words.issubset(right_words): + return 0.92 + if right_words and right_words.issubset(left_words): + return 0.86 + if left in right or right in left: + return min(len(left), len(right)) / max(len(left), len(right)) + return SequenceMatcher(None, left, right).ratio() + + def _poll_album_bundle_downloads(self, transfer_keys: Dict[tuple, TrackResult], emit) -> List[Path]: + deadline = time.monotonic() + get_poll_timeout() + interval = get_poll_interval() + completed_paths: Dict[tuple, Path] = {} + failed_states: Dict[tuple, str] = {} + while time.monotonic() < deadline: + try: + downloads = run_async(self.get_all_downloads()) + except Exception as exc: + logger.warning("[Soulseek album] Poll error: %s", exc) + downloads = [] + + by_key = {} + for dl in downloads: + exact_key = (dl.username, dl.filename) + by_key[exact_key] = dl + basename_key = ( + dl.username, + os.path.basename((dl.filename or '').replace('\\', '/')), + ) + by_key.setdefault(basename_key, dl) + for key, track in transfer_keys.items(): + if key in completed_paths or key in failed_states: + continue + dl = by_key.get(key) or by_key.get(( + key[0], + os.path.basename((key[1] or '').replace('\\', '/')), + )) + state = (getattr(dl, 'state', '') or '') if dl else '' + if any(token in state for token in ('Errored', 'Failed', 'Rejected', 'TimedOut')): + failed_states[key] = state or 'Failed' + logger.warning( + "[Soulseek album] Transfer failed from selected folder: %s (%s)", + os.path.basename((track.filename or '').replace('\\', '/')), + failed_states[key], + ) + continue + if dl and ('Completed' in state or 'Succeeded' in state): + if dl.size and dl.transferred and dl.transferred < dl.size: + continue + path = self._resolve_downloaded_album_file(track.filename) + if path: + completed_paths[key] = path + else: + logger.debug( + "[Soulseek album] Transfer completed but local file not found yet: %s", + track.filename, + ) + emit( + 'downloading', + progress=round(len(completed_paths) / max(1, len(transfer_keys)) * 100, 1), + count=len(completed_paths), + failed=len(failed_states), + ) + if completed_paths and len(completed_paths) + len(failed_states) == len(transfer_keys): + logger.warning( + "[Soulseek album] Selected folder finished with %d completed and %d failed transfer(s)", + len(completed_paths), + len(failed_states), + ) + return list(completed_paths.values()) + if not completed_paths and len(failed_states) == len(transfer_keys): + logger.warning("[Soulseek album] All %d transfer(s) failed from selected folder", len(failed_states)) + return [] + if len(completed_paths) == len(transfer_keys): + return list(completed_paths.values()) + time.sleep(interval) + pending = len(transfer_keys) - len(completed_paths) - len(failed_states) + if completed_paths: + logger.warning( + "[Soulseek album] Timed out with partial album: %d completed, %d failed, %d pending", + len(completed_paths), + len(failed_states), + pending, + ) + return list(completed_paths.values()) + logger.error( + "[Soulseek album] Timed out waiting for %d album files (%d failed, %d pending)", + len(transfer_keys), + len(failed_states), + pending, + ) + return [] + + def _resolve_downloaded_album_file(self, remote_filename: str) -> Optional[Path]: + basename = os.path.basename((remote_filename or '').replace('\\', '/')) + if not basename: + return None + candidates = [ + self.download_path / remote_filename, + self.download_path / basename, + ] + normalized_parts = [p for p in remote_filename.replace('\\', '/').split('/') if p] + if normalized_parts: + candidates.append(self.download_path.joinpath(*normalized_parts)) + for candidate in candidates: + try: + if candidate.exists() and candidate.is_file(): + return candidate + except OSError: + continue + try: + matches = list(self.download_path.rglob(basename)) + except OSError: + matches = [] + for match in matches: + if match.is_file(): + return match + return None async def check_connection(self) -> bool: """Check if slskd is running and connected to the Soulseek network""" diff --git a/tests/downloads/test_download_orchestrator.py b/tests/downloads/test_download_orchestrator.py index e157047e..b551750e 100644 --- a/tests/downloads/test_download_orchestrator.py +++ b/tests/downloads/test_download_orchestrator.py @@ -198,6 +198,37 @@ def test_reload_instances_with_no_args_reloads_every_source(): assert b.reload_called is True +def test_reload_settings_refreshes_registry_plugins(monkeypatch): + """Settings saves should refresh plugins that cache config at init. + + Prowlarr-backed torrent / usenet clients keep a ProwlarrClient + instance, so without this hook newly-saved indexer settings only + took effect after process restart. + """ + + class _ReloadSettingsClient(_FakeClient): + def __init__(self): + super().__init__() + self.reload_calls = 0 + + def reload_settings(self): + self.reload_calls += 1 + + torrent = _ReloadSettingsClient() + usenet = _ReloadSettingsClient() + orch = _build_orchestrator(torrent=torrent, usenet=usenet) + + monkeypatch.setattr( + 'core.download_orchestrator.config_manager.get', + lambda _key, default=None: default, + ) + + orch.reload_settings() + + assert torrent.reload_calls == 1 + assert usenet.reload_calls == 1 + + # --------------------------------------------------------------------------- # Singleton factory (matches Cin's get_metadata_engine pattern) # --------------------------------------------------------------------------- diff --git a/tests/downloads/test_downloads_master.py b/tests/downloads/test_downloads_master.py index 80daa759..a1586028 100644 --- a/tests/downloads/test_downloads_master.py +++ b/tests/downloads/test_downloads_master.py @@ -132,6 +132,37 @@ class _FakeSoulseekWrapper: return self.soulseek if name == 'soulseek' else None +class _FakePluginWrapper: + def __init__(self, plugins): + self._plugins = dict(plugins) + + def client(self, name): + return self._plugins.get(name) + + +class _FakeAlbumBundleSoulseek: + def __init__(self, outcome=None): + self.calls = [] + self.outcome = outcome or {'success': True, 'files': ['/tmp/a.flac']} + + def download_album_to_staging(self, album, artist, staging, emit, **kwargs): + self.calls.append((album, artist, staging, kwargs)) + emit({'state': 'staged', 'count': len(self.outcome.get('files', []))}) + return self.outcome + + +class _FakePreflightAlbumBundleSoulseek(_FakeSoulseek): + def __init__(self, *args, outcome=None, **kwargs): + super().__init__(*args, **kwargs) + self.calls = [] + self.outcome = outcome or {'success': True, 'files': ['/tmp/a.flac']} + + def download_album_to_staging(self, album, artist, staging, emit, **kwargs): + self.calls.append((album, artist, staging, kwargs)) + emit({'state': 'staged', 'count': len(self.outcome.get('files', []))}) + return self.outcome + + class _FakeMonitor: def __init__(self): self.started = [] @@ -704,6 +735,133 @@ def test_soulseek_album_preflight_does_not_jump_ahead_of_hybrid_primary(monkeypa assert 'last_good_source' not in download_batches['B24'] +def test_soulseek_album_bundle_runs_after_missing_analysis(monkeypatch): + """Soulseek whole-folder bundles should engage only after analysis + has confirmed there is something missing.""" + db = _FakeDB() + monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db) + + plugin = _FakeAlbumBundleSoulseek() + deps = _build_deps( + config=_FakeConfig({'download_source.mode': 'soulseek'}), + soulseek=_FakeSoulseekWrapper(plugin), + ) + _seed_batch( + 'B25', + is_album_download=True, + album_context={'name': 'Test Album', 'total_tracks': 1}, + artist_context={'name': 'Artist'}, + ) + tracks = [{'name': 'T1', 'artists': ['Artist'], 'track_number': 1}] + + mw.run_full_missing_tracks_process('B25', 'album:1', tracks, deps) + + assert len(plugin.calls) == 1 + album, artist, staging, kwargs = plugin.calls[0] + assert (album, artist) == ('Test Album', 'Artist') + assert staging.replace('\\', '/').endswith('storage/album_bundle_staging/B25') + assert kwargs == {} + assert download_batches['B25']['album_bundle_source'] == 'soulseek' + assert download_batches['B25']['album_bundle_private_staging'] is True + assert download_batches['B25']['album_bundle_state'] == 'staged' + assert 'last_good_source' not in download_batches['B25'] + + +def test_hybrid_first_soulseek_uses_album_bundle(monkeypatch): + """Hybrid keeps fallback semantics, but the first source can own + album-bundle downloads when it supports them.""" + db = _FakeDB() + monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db) + + plugin = _FakeAlbumBundleSoulseek() + deps = _build_deps( + config=_FakeConfig({ + 'download_source.mode': 'hybrid', + 'download_source.hybrid_order': ['soulseek', 'hifi'], + }), + soulseek=_FakeSoulseekWrapper(plugin), + ) + _seed_batch( + 'B26', + is_album_download=True, + album_context={'name': 'Test Album', 'total_tracks': 1}, + artist_context={'name': 'Artist'}, + ) + tracks = [{'name': 'T1', 'artists': ['Artist'], 'track_number': 1}] + + mw.run_full_missing_tracks_process('B26', 'album:1', tracks, deps) + + assert len(plugin.calls) == 1 + assert download_batches['B26']['album_bundle_source'] == 'soulseek' + assert download_batches['B26']['album_bundle_private_staging'] is True + + +def test_soulseek_album_bundle_uses_preflight_source_without_preloading_reuse(monkeypatch): + """When the bundle path stages files, workers must claim staging + before any Soulseek source-reuse attempt can fire.""" + db = _FakeDB() + monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db) + + tracks = [{'name': 'T1', 'artists': ['Artist'], 'track_number': 1}] + folder_tracks = [_slsk_track('T1', 1, folder='Artist/Test Album')] + album = _album_result('peer', 'Artist/Test Album', 'Test Album', folder_tracks) + slsk = _FakePreflightAlbumBundleSoulseek( + album_results=[album], + browse_files=None, + parsed_tracks=folder_tracks, + ) + deps = _build_deps( + config=_FakeConfig({'download_source.mode': 'soulseek'}), + soulseek=_FakeSoulseekWrapper(slsk), + ) + _seed_batch( + 'B28', + is_album_download=True, + album_context={'name': 'Test Album', 'total_tracks': 1}, + artist_context={'name': 'Artist'}, + ) + + mw.run_full_missing_tracks_process('B28', 'album:1', tracks, deps) + + assert len(slsk.calls) == 1 + assert slsk.calls[0][3] == { + 'preferred_source': { + 'username': 'peer', + 'folder_path': 'Artist/Test Album', + }, + 'preferred_tracks': folder_tracks, + } + assert download_batches['B28']['album_bundle_private_staging'] is True + assert 'last_good_source' not in download_batches['B28'] + assert 'source_folder_tracks' not in download_batches['B28'] + + +def test_hybrid_first_torrent_uses_album_bundle_before_per_track(monkeypatch): + db = _FakeDB() + monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db) + + plugin = _FakeAlbumBundleSoulseek() + deps = _build_deps( + config=_FakeConfig({ + 'download_source.mode': 'hybrid', + 'download_source.hybrid_order': ['torrent', 'soulseek'], + }), + soulseek=_FakePluginWrapper({'torrent': plugin}), + ) + _seed_batch( + 'B27', + is_album_download=True, + album_context={'name': 'Test Album', 'total_tracks': 1}, + artist_context={'name': 'Artist'}, + ) + tracks = [{'name': 'T1', 'artists': ['Artist'], 'track_number': 1}] + + mw.run_full_missing_tracks_process('B27', 'album:1', tracks, deps) + + assert len(plugin.calls) == 1 + assert download_batches['B27']['album_bundle_source'] == 'torrent' + + # --------------------------------------------------------------------------- # Task creation # --------------------------------------------------------------------------- diff --git a/tests/downloads/test_downloads_task_worker.py b/tests/downloads/test_downloads_task_worker.py index e5156b4a..a222b311 100644 --- a/tests/downloads/test_downloads_task_worker.py +++ b/tests/downloads/test_downloads_task_worker.py @@ -223,6 +223,92 @@ def test_private_torrent_album_staging_miss_skips_per_track_search(): assert ('done', ('b1', 't1', False), {}) in rec.calls +def test_private_soulseek_album_staging_miss_skips_per_track_search(): + _seed_task(track_info={ + 'id': 'sp-1', 'name': 'Song', 'artists': ['Artist'], + 'album': 'Album', 'duration_ms': 180000, + }) + download_batches['b1'] = { + 'album_bundle_private_staging': True, + 'album_bundle_state': 'staged', + 'album_bundle_source': 'soulseek', + } + client = _FakeClient(results=['should-not-search'], mode='soulseek') + rec = _Recorder() + deps, _ = _build_deps( + soulseek=client, + matching=_FakeMatchEngine(queries=['Artist Song']), + try_staging_match=lambda *a, **kw: False, + on_download_completed=rec('done'), + ) + + tw.download_track_worker('t1', 'b1', deps) + + assert client.search_calls == [] + assert download_tasks['t1']['status'] == 'not_found' + assert 'staged soulseek album release' in download_tasks['t1']['error_message'] + assert ('done', ('b1', 't1', False), {}) in rec.calls + + +def test_private_hybrid_first_soulseek_album_staging_miss_skips_per_track_search(): + _seed_task(track_info={ + 'id': 'sp-1', 'name': 'Song', 'artists': ['Artist'], + 'album': 'Album', 'duration_ms': 180000, + }) + download_batches['b1'] = { + 'album_bundle_private_staging': True, + 'album_bundle_state': 'staged', + 'album_bundle_source': 'soulseek', + } + client = _FakeClient( + results=['should-not-search'], + mode='hybrid', + subclients={'hybrid_order': ['soulseek', 'hifi']}, + ) + rec = _Recorder() + deps, _ = _build_deps( + soulseek=client, + matching=_FakeMatchEngine(queries=['Artist Song']), + try_staging_match=lambda *a, **kw: False, + on_download_completed=rec('done'), + ) + + tw.download_track_worker('t1', 'b1', deps) + + assert client.search_calls == [] + assert download_tasks['t1']['status'] == 'not_found' + assert 'staged soulseek album release' in download_tasks['t1']['error_message'] + + +def test_partial_private_hybrid_first_soulseek_album_staging_miss_allows_per_track_search(): + _seed_task(track_info={ + 'id': 'sp-1', 'name': 'Song', 'artists': ['Artist'], + 'album': 'Album', 'duration_ms': 180000, + }) + download_batches['b1'] = { + 'album_bundle_private_staging': True, + 'album_bundle_state': 'staged', + 'album_bundle_source': 'soulseek', + 'album_bundle_partial': True, + } + client = _FakeClient( + results=[], + mode='hybrid', + subclients={'hybrid_order': ['soulseek', 'hifi']}, + ) + deps, _ = _build_deps( + soulseek=client, + matching=_FakeMatchEngine(queries=['Artist Song']), + try_staging_match=lambda *a, **kw: False, + ) + + tw.download_track_worker('t1', 'b1', deps) + + assert client.search_calls + assert download_tasks['t1']['status'] == 'not_found' + assert 'staged soulseek album release' not in download_tasks['t1']['error_message'] + + # --------------------------------------------------------------------------- # Search loop happy path # --------------------------------------------------------------------------- diff --git a/tests/downloads/test_soulseek_pinning.py b/tests/downloads/test_soulseek_pinning.py index 0c7a8202..3ba3508b 100644 --- a/tests/downloads/test_soulseek_pinning.py +++ b/tests/downloads/test_soulseek_pinning.py @@ -28,6 +28,7 @@ from unittest.mock import AsyncMock, patch import pytest from core.soulseek_client import SoulseekClient +from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult def _run_async(coro): @@ -121,6 +122,153 @@ def test_download_extracts_id_from_dict_response(configured_client): assert result == 'abc123' +# --------------------------------------------------------------------------- +# album bundle +# --------------------------------------------------------------------------- + + +def _track(username='peer', filename='Artist/Album/01 - Song.flac', title='Song', number=1, size=10): + return TrackResult( + username=username, + filename=filename, + size=size, + bitrate=None, + duration=180000, + quality='flac', + free_upload_slots=1, + upload_speed=1_000_000, + queue_length=0, + artist='Artist', + title=title, + album='Album', + track_number=number, + ) + + +def test_album_bundle_stages_one_selected_soulseek_folder(configured_client, tmp_path): + configured_client.download_path = tmp_path + local_file = tmp_path / '01 - Song.flac' + local_file.write_bytes(b'audio') + track = _track(filename='Artist/Album/01 - Song.flac') + album = AlbumResult( + username='peer', + album_path='Artist/Album', + album_title='Album', + artist='Artist', + track_count=1, + total_size=10, + tracks=[track], + dominant_quality='flac', + free_upload_slots=1, + upload_speed=1_000_000, + queue_length=0, + ) + events = [] + + with patch.object(configured_client, 'search', AsyncMock(return_value=([], [album]))), \ + patch.object(configured_client, 'browse_user_directory', AsyncMock(return_value=[ + {'filename': '01 - Song.flac', 'size': 10} + ])), \ + patch.object(configured_client, 'filter_results_by_quality_preference', side_effect=lambda tracks: tracks), \ + patch.object(configured_client, 'download', AsyncMock(return_value='dl-1')) as download_mock, \ + patch.object(configured_client, 'get_all_downloads', AsyncMock(return_value=[ + DownloadStatus( + id='dl-1', + username='peer', + filename='Artist/Album/01 - Song.flac', + state='Completed, Succeeded', + progress=100, + size=10, + transferred=10, + speed=0, + ) + ])), \ + patch('core.soulseek_client.get_poll_timeout', return_value=1), \ + patch('core.soulseek_client.get_poll_interval', return_value=0.01): + outcome = configured_client.download_album_to_staging( + 'Album', + 'Artist', + str(tmp_path / 'staging'), + events.append, + ) + + assert outcome['success'] is True + assert outcome['fallback'] is False + assert len(outcome['files']) == 1 + assert Path(outcome['files'][0]).read_bytes() == b'audio' + download_mock.assert_awaited_once_with( + 'peer', + 'Artist/Album/01 - Song.flac', + 10, + ) + assert events[-1]['state'] == 'staged' + + +def test_album_bundle_stages_completed_files_when_same_source_partially_times_out(configured_client, tmp_path): + configured_client.download_path = tmp_path + (tmp_path / '01 - Ready.flac').write_bytes(b'audio') + ready = _track(filename='Artist/Album/01 - Ready.flac', title='Ready', number=1) + timed_out = _track(filename='Artist/Album/02 - Waiting.flac', title='Waiting', number=2) + events = [] + + with patch.object(configured_client, 'download', AsyncMock(side_effect=['dl-1', 'dl-2'])), \ + patch.object(configured_client, 'filter_results_by_quality_preference', side_effect=lambda tracks: tracks), \ + patch.object(configured_client, 'get_all_downloads', AsyncMock(return_value=[ + DownloadStatus( + id='dl-1', + username='peer', + filename='Artist/Album/01 - Ready.flac', + state='Completed, Succeeded', + progress=100, + size=10, + transferred=10, + speed=0, + ), + DownloadStatus( + id='dl-2', + username='peer', + filename='Artist/Album/02 - Waiting.flac', + state='TimedOut', + progress=0, + size=10, + transferred=0, + speed=0, + ), + ])), \ + patch('core.soulseek_client.get_poll_timeout', return_value=1), \ + patch('core.soulseek_client.get_poll_interval', return_value=0.01): + outcome = configured_client.download_album_to_staging( + 'Album', + 'Artist', + str(tmp_path / 'staging'), + events.append, + preferred_source={'username': 'peer', 'folder_path': 'Artist/Album'}, + preferred_tracks=[ready, timed_out], + ) + + assert outcome['success'] is True + assert outcome['fallback'] is False + assert len(outcome['files']) == 1 + assert Path(outcome['files'][0]).name == '01 - Ready.flac' + assert any(event.get('failed') == 1 for event in events) + + +def test_album_bundle_falls_back_when_no_album_folder(configured_client, tmp_path): + configured_client.download_path = tmp_path + with patch.object(configured_client, 'search', AsyncMock(return_value=([], []))), \ + patch.object(configured_client, 'download', AsyncMock(return_value='dl-1')) as dl: + outcome = configured_client.download_album_to_staging( + 'Missing Album', + 'Artist', + str(tmp_path / 'staging'), + ) + + assert outcome['success'] is False + assert outcome['fallback'] is True + assert 'No complete Soulseek album folders' in outcome['error'] + dl.assert_not_awaited() + + def test_download_extracts_id_from_list_response(configured_client): """Pinning: slskd sometimes returns a list of file objects. The first item's id is the download_id.""" diff --git a/tests/imports/test_import_routes.py b/tests/imports/test_import_routes.py index 83c57350..cfc7d8ce 100644 --- a/tests/imports/test_import_routes.py +++ b/tests/imports/test_import_routes.py @@ -207,6 +207,7 @@ def test_staging_suggestions_returns_cache_payload(monkeypatch): "get_import_suggestions_cache", lambda: {"suggestions": [{"album": "Album"}], "built": True}, ) + monkeypatch.setattr(import_routes, "_get_primary_source", lambda: "deezer") payload, status = staging_suggestions() @@ -215,6 +216,7 @@ def test_staging_suggestions_returns_cache_payload(monkeypatch): "success": True, "suggestions": [{"album": "Album"}], "ready": True, + "primary_source": "deezer", } @@ -303,7 +305,11 @@ def test_search_albums_enqueues_hydrabase_and_caps_limit(): payload, status = search_albums(runtime, " Album ", 99) assert status == 200 - assert payload == {"success": True, "albums": [{"id": "album-1"}]} + assert payload == { + "success": True, + "albums": [{"id": "album-1"}], + "primary_source": "hydrabase", + } assert worker.enqueued == [("Album", "albums")] assert calls == [("Album", 50)] @@ -315,6 +321,28 @@ def test_search_albums_requires_query(): assert payload == {"success": False, "error": "Missing query parameter"} +def test_search_albums_exposes_primary_source_when_chain_falls_back(): + # Pins github issue #681: when the primary source returns nothing and the + # silent fallback chain (intentional, see core/auto_import_worker.py:1316) + # serves results from a different source, the response must carry both + # `primary_source` (what the user configured) and per-album `source` + # (what actually served the result) so the UI can warn the user. + runtime = ImportRouteRuntime( + get_primary_source=lambda: "musicbrainz", + search_import_albums=lambda query, limit: [ + {"id": "deezer-1", "name": "Album", "source": "deezer"}, + ], + logger=_FakeLogger(), + ) + + payload, status = search_albums(runtime, "Weapons of Mass Destruction", 12) + + assert status == 200 + assert payload["success"] is True + assert payload["primary_source"] == "musicbrainz" + assert payload["albums"][0]["source"] == "deezer" + + def test_search_tracks_enqueues_hydrabase_and_caps_limit(): worker = _FakeHydrabaseWorker() calls = [] @@ -329,7 +357,11 @@ def test_search_tracks_enqueues_hydrabase_and_caps_limit(): payload, status = search_tracks(runtime, " Track ", 99) assert status == 200 - assert payload == {"success": True, "tracks": [{"id": "track-1"}]} + assert payload == { + "success": True, + "tracks": [{"id": "track-1"}], + "primary_source": "hydrabase", + } assert worker.enqueued == [("Track", "tracks")] assert calls == [("Track", 30)] diff --git a/tests/test_album_bundle_dispatch.py b/tests/test_album_bundle_dispatch.py index 68579f6d..5cafa065 100644 --- a/tests/test_album_bundle_dispatch.py +++ b/tests/test_album_bundle_dispatch.py @@ -56,18 +56,20 @@ def test_is_eligible_requires_album_flag() -> None: album_name='X', artist_name='Y') is False -def test_is_eligible_requires_torrent_or_usenet_mode() -> None: - for mode in ('soulseek', 'youtube', 'tidal', 'qobuz', 'hifi', +def test_is_eligible_requires_album_bundle_mode() -> None: + for mode in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'amazon', 'lidarr', 'soundcloud', 'hybrid'): assert is_eligible(mode=mode, is_album=True, album_name='X', artist_name='Y') is False -def test_is_eligible_accepts_torrent_and_usenet() -> None: +def test_is_eligible_accepts_torrent_usenet_and_soulseek() -> None: assert is_eligible(mode='torrent', is_album=True, album_name='X', artist_name='Y') is True assert is_eligible(mode='usenet', is_album=True, album_name='X', artist_name='Y') is True + assert is_eligible(mode='soulseek', is_album=True, + album_name='X', artist_name='Y') is True def test_is_eligible_requires_non_empty_names() -> None: @@ -103,13 +105,13 @@ def test_dispatch_returns_false_when_not_album() -> None: plugin.download_album_to_staging.assert_not_called() -def test_dispatch_returns_false_for_non_torrent_modes() -> None: +def test_dispatch_returns_false_for_non_album_bundle_modes() -> None: state = _FakeState() plugin = MagicMock() result = try_dispatch( batch_id='b1', is_album=True, album_context={'name': 'X'}, artist_context={'name': 'Y'}, - config_get=_config({'download_source.mode': 'soulseek'}), + config_get=_config({'download_source.mode': 'youtube'}), plugin_resolver=lambda _name: plugin, state=state, ) assert result is False @@ -234,6 +236,28 @@ def test_dispatch_failure_returns_true_so_master_stops() -> None: assert state.fields['phase'] == 'failed' +def test_dispatch_fallback_failure_returns_false_for_per_track_flow() -> None: + state = _FakeState() + plugin = MagicMock() + plugin.download_album_to_staging.return_value = { + 'success': False, + 'files': [], + 'error': 'No complete Soulseek album folders found', + 'fallback': True, + } + result = try_dispatch( + batch_id='b1', is_album=True, + album_context={'name': 'Album'}, artist_context={'name': 'Artist'}, + config_get=_config({'download_source.mode': 'soulseek'}), + plugin_resolver=lambda _name: plugin, state=state, + ) + assert result is False + assert state.failed_with == '' + assert state.fields['phase'] == 'analysis' + assert state.fields['album_bundle_state'] == 'fallback' + assert state.fields['album_bundle_error'] == 'No complete Soulseek album folders found' + + def test_dispatch_plugin_exception_treated_as_failure() -> None: """A bug / network error in the plugin must not propagate into the master worker — caught + treated as a normal failure so @@ -269,6 +293,25 @@ def test_dispatch_strips_whitespace_from_names() -> None: assert args.args[1] == 'Kendrick' +def test_dispatch_source_override_uses_first_hybrid_source() -> None: + state = _FakeState() + plugin = MagicMock() + plugin.download_album_to_staging.return_value = {'success': True, 'files': ['/x']} + seen = [] + + try_dispatch( + batch_id='b1', is_album=True, + album_context={'name': 'GNX'}, artist_context={'name': 'Kendrick Lamar'}, + config_get=_config({'download_source.mode': 'hybrid'}), + plugin_resolver=lambda name: seen.append(name) or plugin, + state=state, + source_override='soulseek', + ) + + assert seen == ['soulseek'] + assert state.fields['album_bundle_source'] == 'soulseek' + + def test_dispatch_progress_callback_mirrors_payload_to_state() -> None: """The progress callback the plugin gets must mirror its payload onto the batch state under ``album_bundle_*`` keys so diff --git a/tests/test_import_page_album_lookup_pattern.py b/tests/test_import_page_album_lookup_pattern.py index 54bf142d..5bf4ab9d 100644 --- a/tests/test_import_page_album_lookup_pattern.py +++ b/tests/test_import_page_album_lookup_pattern.py @@ -82,19 +82,39 @@ def test_select_album_handler_reads_cache(js_source: str): ) -def test_card_renderers_populate_cache_before_onclick(js_source: str): - """Both renderers (suggestion card + search-result card) must write - to ``_albumLookup`` before emitting the onclick — otherwise the - click handler reads an empty cache for newly-displayed albums.""" - cache_writes = re.findall( - r"_albumLookup\[a\.id\]\s*=\s*\{", - js_source, +def test_card_renderer_populates_cache_before_onclick(js_source: str): + """The shared card-renderer ``_renderSuggestionCard`` must write to + ``_albumLookup`` before emitting the onclick — otherwise the click + handler reads an empty cache for newly-displayed albums. + + Originally this test required >=2 cache writes (one per inline + renderer), but the search-results inline render was consolidated + into a single ``_renderSuggestionCard`` call as part of the #681 + fix. The invariant now is: the shared renderer populates the cache, + and every render call site goes through it (no inline duplicates).""" + # 1. The shared renderer must contain the cache write. + match = re.search( + r"function _renderSuggestionCard\([^)]*\) \{(.*?)^\}", + js_source, re.DOTALL | re.MULTILINE, ) - assert len(cache_writes) >= 2, ( - f"Expected >=2 _albumLookup writes (one per card renderer - " - f"suggestions + search results), found {len(cache_writes)}. " - "Adding a new card-rendering site without populating the cache " - "regresses issue #524 for that path." + assert match, "_renderSuggestionCard function not found" + body = match.group(1) + assert re.search(r"_albumLookup\[a\.id\]\s*=\s*\{", body), ( + "_renderSuggestionCard no longer writes to _albumLookup before " + "emitting the onclick — every card rendered through this helper " + "would have an empty cache on click, regressing issue #524." + ) + + # 2. No inline card render allowed outside the shared helper. + # A second `_albumLookup[a.id] = {` write means a caller is + # re-implementing the renderer instead of calling the helper — + # that's exactly the duplication the #524 fix consolidated away. + cache_writes = re.findall(r"_albumLookup\[a\.id\]\s*=\s*\{", js_source) + assert len(cache_writes) == 1, ( + f"Expected exactly 1 _albumLookup write (inside _renderSuggestionCard), " + f"found {len(cache_writes)}. A new inline card-render site has " + "duplicated the cache-write logic — route the new caller through " + "_renderSuggestionCard(a, primarySource) instead." ) diff --git a/tests/test_orphan_file_detector.py b/tests/test_orphan_file_detector.py new file mode 100644 index 00000000..02ba7150 --- /dev/null +++ b/tests/test_orphan_file_detector.py @@ -0,0 +1,81 @@ +"""Regression tests for the orphan file detector.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +from core.repair_jobs.base import JobContext +from core.repair_jobs.orphan_file_detector import OrphanFileDetectorJob + + +class _DB: + def __init__(self, path: Path) -> None: + self.path = path + + def _get_connection(self): + return sqlite3.connect(self.path) + + +def _seed_library(db_path: Path) -> None: + conn = sqlite3.connect(db_path) + try: + conn.executescript( + """ + CREATE TABLE artists ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + ); + CREATE TABLE albums ( + id INTEGER PRIMARY KEY, + artist_id INTEGER NOT NULL, + title TEXT NOT NULL + ); + CREATE TABLE tracks ( + id INTEGER PRIMARY KEY, + album_id INTEGER NOT NULL, + artist_id INTEGER NOT NULL, + title TEXT NOT NULL, + file_path TEXT + ); + INSERT INTO artists (id, name) VALUES + (1, 'Clouddead89'), + (2, 'Featured Artist'); + INSERT INTO albums (id, artist_id, title) VALUES + (10, 1, 'Perfect Match Error'); + INSERT INTO tracks (id, album_id, artist_id, title, file_path) VALUES + (100, 10, 2, 'Perfect Match', '/old/prefix/elsewhere.mp3'); + """ + ) + conn.commit() + finally: + conn.close() + + +def test_orphan_detector_accepts_picard_albumartist_folder_match(tmp_path: Path) -> None: + """Picard paths use albumartist/album (year)/track - title. + + Even when the DB track artist is a featured artist, the album artist + folder should be enough to recognize the file as tracked. + """ + db_path = tmp_path / "library.sqlite" + _seed_library(db_path) + + transfer = tmp_path / "Clouddead89" / "Perfect Match Error (2026)" + transfer.mkdir(parents=True) + audio_path = transfer / "01 - Perfect Match.mp3" + audio_path.write_bytes(b"not a real mp3; filename fallback handles this") + + findings = [] + context = JobContext( + db=_DB(db_path), + transfer_folder=str(tmp_path), + config_manager=None, + create_finding=lambda **kwargs: findings.append(kwargs) or True, + ) + + result = OrphanFileDetectorJob().scan(context) + + assert result.scanned == 1 + assert result.findings_created == 0 + assert findings == [] diff --git a/tests/test_qobuz_playlists.py b/tests/test_qobuz_playlists.py new file mode 100644 index 00000000..828a9f97 --- /dev/null +++ b/tests/test_qobuz_playlists.py @@ -0,0 +1,456 @@ +"""Unit tests for QobuzClient playlist + favorites methods. + +Covers the Sync-page parity added for github issue #677: +- `get_user_playlists` paginates + normalizes the playlist list +- `get_playlist` paginates the tracklist + normalizes track shape +- `get_playlist` recognizes the virtual `qobuz-favorites` ID and + dispatches to `get_user_favorite_tracks` (same pattern as Tidal's + COLLECTION_PLAYLIST_ID) +- `get_user_favorite_tracks_count` reads the cheap count-only path +""" + +from __future__ import annotations + +import sys +import types +from typing import Any, Dict, List + +import pytest + + +@pytest.fixture +def qobuz_client_module(): + """Import core.qobuz_client with config_manager stubbed to a mutable + in-memory dict. Snapshots and restores sys.modules entries on + teardown so downstream tests still see the real config. + """ + config_state: Dict[str, Any] = {} + + class _StubConfigManager: + def get(self, key, default=None): + cur: Any = config_state + for part in key.split('.'): + if isinstance(cur, dict) and part in cur: + cur = cur[part] + else: + return default + return cur + + def set(self, key, value): + cur: Any = config_state + parts = key.split('.') + for part in parts[:-1]: + cur = cur.setdefault(part, {}) + cur[parts[-1]] = value + + original_modules = { + name: sys.modules.get(name) + for name in ('config', 'config.settings', 'core.qobuz_client') + } + + if 'config' not in sys.modules: + sys.modules['config'] = types.ModuleType('config') + settings_mod = types.ModuleType('config.settings') + settings_mod.config_manager = _StubConfigManager() + sys.modules['config.settings'] = settings_mod + + sys.modules.pop('core.qobuz_client', None) + try: + import core.qobuz_client as qobuz_client_module + yield qobuz_client_module, config_state + finally: + for name, original in original_modules.items(): + if original is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = original + + +@pytest.fixture +def authed_client(qobuz_client_module): + """A QobuzClient with stub credentials so is_authenticated() returns True.""" + module, config = qobuz_client_module + config['qobuz'] = { + 'session': { + 'app_id': 'APP-1', + 'app_secret': 'SECRET-1', + 'user_auth_token': 'TOKEN-1', + } + } + client = module.QobuzClient() + client.reload_credentials() + assert client.is_authenticated() is True + return client + + +def _install_api_responder(client, responder): + """Replace `_api_request` with a deterministic responder for the test.""" + client._api_request = responder # type: ignore[method-assign] + + +# --------------------------------------------------------------------------- +# get_user_playlists — pagination + normalization +# --------------------------------------------------------------------------- + + +def test_get_user_playlists_returns_normalized_metadata(authed_client): + calls: List[Dict[str, Any]] = [] + + def responder(endpoint, params=None): + calls.append({'endpoint': endpoint, 'params': params}) + return { + 'playlists': { + 'items': [ + { + 'id': 1001, + 'name': 'My Mix', + 'description': 'on repeat', + 'is_public': True, + 'tracks_count': 12, + 'images': ['https://qobuz.example/cover.jpg'], + }, + ], + 'total': 1, + } + } + + _install_api_responder(authed_client, responder) + playlists = authed_client.get_user_playlists() + + assert calls == [{ + 'endpoint': 'playlist/getUserPlaylists', + 'params': {'limit': 100, 'offset': 0}, + }] + assert playlists == [{ + 'id': '1001', + 'name': 'My Mix', + 'description': 'on repeat', + 'public': True, + 'track_count': 12, + 'image_url': 'https://qobuz.example/cover.jpg', + 'external_urls': {'qobuz': 'https://play.qobuz.com/playlist/1001'}, + }] + + +def test_get_user_playlists_paginates_until_total_reached(authed_client): + # Two pages of 100 each, third page returns empty to verify the loop + # terminates on `total` rather than waiting for an empty page. + page_one = [{'id': i, 'name': f'P{i}', 'tracks_count': 0} for i in range(100)] + page_two = [{'id': 100 + i, 'name': f'P{100 + i}', 'tracks_count': 0} for i in range(50)] + calls: List[int] = [] + + def responder(endpoint, params=None): + calls.append(params['offset']) + if params['offset'] == 0: + return {'playlists': {'items': page_one, 'total': 150}} + if params['offset'] == 100: + return {'playlists': {'items': page_two, 'total': 150}} + return {'playlists': {'items': [], 'total': 150}} + + _install_api_responder(authed_client, responder) + playlists = authed_client.get_user_playlists() + + assert len(playlists) == 150 + assert calls == [0, 100] # No third request needed + + +def test_get_user_playlists_returns_empty_when_unauthenticated(qobuz_client_module): + module, _ = qobuz_client_module + client = module.QobuzClient() # no credentials configured + assert client.is_authenticated() is False + + def responder(endpoint, params=None): + raise AssertionError('should not hit the API when unauthenticated') + + _install_api_responder(client, responder) + assert client.get_user_playlists() == [] + + +# --------------------------------------------------------------------------- +# get_playlist — track pagination + normalization +# --------------------------------------------------------------------------- + + +def test_get_playlist_normalizes_tracks(authed_client): + def responder(endpoint, params=None): + assert endpoint == 'playlist/get' + return { + 'id': 2002, + 'name': 'Deep Cuts', + 'description': '', + 'is_public': False, + 'tracks_count': 1, + 'images': ['https://qobuz.example/dc.jpg'], + 'tracks': { + 'items': [ + { + 'id': 555, + 'title': 'Forgotten Track', + 'duration': 240, + 'parental_warning': True, + 'performer': {'name': 'Some Artist'}, + 'album': { + 'title': 'Some Album', + 'image': {'large': 'https://qobuz.example/art.jpg'}, + }, + }, + ], + 'total': 1, + }, + } + + _install_api_responder(authed_client, responder) + playlist = authed_client.get_playlist('2002') + + assert playlist is not None + assert playlist['id'] == '2002' + assert playlist['name'] == 'Deep Cuts' + assert playlist['track_count'] == 1 + assert playlist['tracks'] == [{ + 'id': '555', + 'name': 'Forgotten Track', + 'artists': ['Some Artist'], + 'album': 'Some Album', + 'duration_ms': 240_000, + 'image_url': 'https://qobuz.example/art.jpg', + 'external_urls': {'qobuz': 'https://play.qobuz.com/track/555'}, + 'explicit': True, + }] + + +def test_get_playlist_routes_favorites_virtual_id(authed_client): + """The virtual `qobuz-favorites` ID must dispatch to the favorites + endpoint rather than the playlist/get endpoint — mirrors Tidal's + COLLECTION_PLAYLIST_ID pattern.""" + seen_endpoints: List[str] = [] + + def responder(endpoint, params=None): + seen_endpoints.append(endpoint) + # favorite/getUserFavorites is the only endpoint that should fire + return { + 'tracks': { + 'items': [ + { + 'id': 777, + 'title': 'Liked Song', + 'duration': 180, + 'performer': {'name': 'Loved Artist'}, + 'album': {'title': 'Heart Album', 'image': {'large': 'https://q.example/h.jpg'}}, + }, + ], + 'total': 1, + } + } + + _install_api_responder(authed_client, responder) + playlist = authed_client.get_playlist(authed_client.QOBUZ_FAVORITES_ID) + + assert playlist is not None + assert playlist['id'] == authed_client.QOBUZ_FAVORITES_ID + assert playlist['name'] == authed_client.QOBUZ_FAVORITES_NAME + assert playlist['track_count'] == 1 + assert playlist['tracks'][0]['name'] == 'Liked Song' + # Only the favorites endpoint should have been hit — no playlist/get. + assert seen_endpoints == ['favorite/getUserFavorites'] + + +def test_get_playlist_paginates_track_list(authed_client): + page_one_tracks = [ + {'id': i, 'title': f'T{i}', 'duration': 100, 'performer': {'name': 'A'}, 'album': {'title': 'Alb', 'image': {}}} + for i in range(100) + ] + page_two_tracks = [ + {'id': 100 + i, 'title': f'T{100 + i}', 'duration': 100, 'performer': {'name': 'A'}, 'album': {'title': 'Alb', 'image': {}}} + for i in range(25) + ] + offsets: List[int] = [] + + def responder(endpoint, params=None): + offsets.append(params['offset']) + if params['offset'] == 0: + return { + 'id': 'X', 'name': 'Long', 'description': '', 'is_public': False, + 'tracks_count': 125, 'images': [], + 'tracks': {'items': page_one_tracks, 'total': 125}, + } + if params['offset'] == 100: + return { + 'id': 'X', 'name': 'Long', 'description': '', 'is_public': False, + 'tracks_count': 125, 'images': [], + 'tracks': {'items': page_two_tracks, 'total': 125}, + } + return {'tracks': {'items': [], 'total': 125}} + + _install_api_responder(authed_client, responder) + playlist = authed_client.get_playlist('X') + + assert playlist is not None + assert len(playlist['tracks']) == 125 + assert playlist['track_count'] == 125 + assert offsets == [0, 100] + + +def test_get_playlist_returns_none_when_unauthenticated(qobuz_client_module): + module, _ = qobuz_client_module + client = module.QobuzClient() + assert client.get_playlist('whatever') is None + + +# --------------------------------------------------------------------------- +# get_user_favorite_tracks + get_user_favorite_tracks_count +# --------------------------------------------------------------------------- + + +def test_get_user_favorite_tracks_paginates(authed_client): + def make_items(start, count): + return [ + {'id': start + i, 'title': f'F{start + i}', 'duration': 200, + 'performer': {'name': 'Fav Artist'}, + 'album': {'title': 'Fav Album', 'image': {}}} + for i in range(count) + ] + + offsets: List[int] = [] + + def responder(endpoint, params=None): + assert endpoint == 'favorite/getUserFavorites' + assert params['type'] == 'tracks' + offsets.append(params['offset']) + if params['offset'] == 0: + return {'tracks': {'items': make_items(0, 100), 'total': 130}} + if params['offset'] == 100: + return {'tracks': {'items': make_items(100, 30), 'total': 130}} + return {'tracks': {'items': [], 'total': 130}} + + _install_api_responder(authed_client, responder) + tracks = authed_client.get_user_favorite_tracks() + + assert len(tracks) == 130 + assert offsets == [0, 100] + assert tracks[0]['name'] == 'F0' + assert tracks[-1]['name'] == 'F129' + + +def test_get_user_favorite_tracks_fetches_all_by_default(authed_client): + def make_items(start, count): + return [ + {'id': start + i, 'title': f'F{start + i}', 'duration': 200, + 'performer': {'name': 'Fav Artist'}, + 'album': {'title': 'Fav Album', 'image': {}}} + for i in range(count) + ] + + offsets: List[int] = [] + + def responder(endpoint, params=None): + assert endpoint == 'favorite/getUserFavorites' + offsets.append(params['offset']) + start = params['offset'] + remaining = max(0, 625 - start) + return {'tracks': {'items': make_items(start, min(params['limit'], remaining)), 'total': 625}} + + _install_api_responder(authed_client, responder) + tracks = authed_client.get_user_favorite_tracks() + + assert len(tracks) == 625 + assert offsets == [0, 100, 200, 300, 400, 500, 600] + assert tracks[-1]['name'] == 'F624' + + +def test_get_user_favorite_tracks_honors_explicit_limit(authed_client): + def make_items(start, count): + return [ + {'id': start + i, 'title': f'F{start + i}', 'duration': 200, + 'performer': {'name': 'Fav Artist'}, + 'album': {'title': 'Fav Album', 'image': {}}} + for i in range(count) + ] + + requests: List[Dict[str, Any]] = [] + + def responder(endpoint, params=None): + assert endpoint == 'favorite/getUserFavorites' + requests.append(dict(params)) + start = params['offset'] + return {'tracks': {'items': make_items(start, params['limit']), 'total': 625}} + + _install_api_responder(authed_client, responder) + tracks = authed_client.get_user_favorite_tracks(limit=150) + + assert len(tracks) == 150 + assert requests == [ + {'type': 'tracks', 'limit': 100, 'offset': 0}, + {'type': 'tracks', 'limit': 50, 'offset': 100}, + ] + assert tracks[-1]['name'] == 'F149' + + +def test_get_user_favorite_tracks_count_uses_cheap_call(authed_client): + captured: Dict[str, Any] = {} + + def responder(endpoint, params=None): + captured['endpoint'] = endpoint + captured['params'] = params + return {'tracks': {'items': [], 'total': 4242}} + + _install_api_responder(authed_client, responder) + count = authed_client.get_user_favorite_tracks_count() + + assert count == 4242 + # Single request with limit=1 — must not iterate the full list. + assert captured == { + 'endpoint': 'favorite/getUserFavorites', + 'params': {'type': 'tracks', 'limit': 1, 'offset': 0}, + } + + +def test_get_user_favorite_tracks_count_returns_zero_when_unauthenticated(qobuz_client_module): + module, _ = qobuz_client_module + client = module.QobuzClient() + assert client.get_user_favorite_tracks_count() == 0 + + +# --------------------------------------------------------------------------- +# Track normalization fallbacks — artist resolution chain +# --------------------------------------------------------------------------- + + +def test_track_normalization_falls_back_to_album_artist(authed_client): + """When `performer.name` is missing, album.artist.name should win + over the bare 'Unknown Artist' default.""" + def responder(endpoint, params=None): + return { + 'id': 'P', 'name': 'p', 'description': '', 'is_public': False, + 'tracks_count': 1, 'images': [], + 'tracks': { + 'items': [{ + 'id': 1, 'title': 'X', 'duration': 10, + 'album': { + 'title': 'A', + 'artist': {'name': 'Album Artist'}, + 'image': {'large': ''}, + }, + }], + 'total': 1, + } + } + + _install_api_responder(authed_client, responder) + playlist = authed_client.get_playlist('P') + assert playlist['tracks'][0]['artists'] == ['Album Artist'] + + +def test_track_normalization_uses_unknown_artist_when_all_sources_empty(authed_client): + def responder(endpoint, params=None): + return { + 'id': 'P', 'name': 'p', 'description': '', 'is_public': False, + 'tracks_count': 1, 'images': [], + 'tracks': { + 'items': [{'id': 1, 'title': 'X', 'duration': 10}], + 'total': 1, + } + } + + _install_api_responder(authed_client, responder) + playlist = authed_client.get_playlist('P') + assert playlist['tracks'][0]['artists'] == ['Unknown Artist'] diff --git a/tests/test_torrent_usenet_plugins.py b/tests/test_torrent_usenet_plugins.py index 57f11079..1dd9f75b 100644 --- a/tests/test_torrent_usenet_plugins.py +++ b/tests/test_torrent_usenet_plugins.py @@ -381,6 +381,34 @@ def test_usenet_is_configured_requires_both_sides() -> None: # --------------------------------------------------------------------------- +def test_usenet_reload_settings_refreshes_cached_prowlarr_config(monkeypatch) -> None: + """Settings saves must update the plugin's held ProwlarrClient. + + The active usenet adapter is rebuilt from config on each call, but + ProwlarrClient is cached inside the plugin. This is the path that + used to require a process restart after entering Prowlarr settings. + """ + settings = { + 'prowlarr.url': '', + 'prowlarr.api_key': '', + } + monkeypatch.setattr( + 'core.prowlarr_client.config_manager.get', + lambda key, default=None: settings.get(key, default), + ) + + plugin = UsenetDownloadPlugin() + assert plugin._prowlarr.is_configured() is False + + settings.update({ + 'prowlarr.url': 'http://prowlarr:9696', + 'prowlarr.api_key': 'secret', + }) + plugin.reload_settings() + + assert plugin._prowlarr.is_configured() is True + + def test_plugins_conform_to_protocol() -> None: from core.download_plugins.base import DownloadSourcePlugin assert isinstance(TorrentDownloadPlugin(), DownloadSourcePlugin) diff --git a/tests/webui/test_assets.py b/tests/webui/test_assets.py index 21660899..8c6a921d 100644 --- a/tests/webui/test_assets.py +++ b/tests/webui/test_assets.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import os +from pathlib import Path import time import pytest @@ -94,3 +95,18 @@ def test_load_webui_vite_manifest_reloads_when_file_changes(tmp_path): second = load_webui_vite_manifest(manifest_path) assert second["src/app/main.tsx"]["file"] == "assets/two.js" + + +def test_static_ui_uses_existing_album_placeholder_asset(): + repo_root = Path(__file__).resolve().parents[2] + static_dir = repo_root / "webui" / "static" + + assert (static_dir / "placeholder-album.png").exists() + assert not (static_dir / "placeholder.png").exists() + + stale_refs = [] + for path in static_dir.glob("*.js"): + if "/static/placeholder.png" in path.read_text(encoding="utf-8"): + stale_refs.append(path.name) + + assert stale_refs == [] diff --git a/web_server.py b/web_server.py index 3966f93c..f3cad76f 100644 --- a/web_server.py +++ b/web_server.py @@ -40,7 +40,7 @@ logger = setup_logging(_log_level, _log_path) # App version — single source of truth for backup metadata, system-info, update check, etc. # Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release. -_SOULSYNC_BASE_VERSION = "2.5.9" +_SOULSYNC_BASE_VERSION = "2.6.0" def _build_version_string(): """Append short commit hash to version when available (e.g. 2.35+abc1234).""" @@ -1586,6 +1586,7 @@ def _shutdown_runtime_components(): (import_singles_executor, "import singles executor"), (tidal_discovery_executor, "tidal discovery executor"), (deezer_discovery_executor, "deezer discovery executor"), + (qobuz_discovery_executor, "qobuz discovery executor"), (spotify_public_discovery_executor, "spotify public discovery executor"), (youtube_discovery_executor, "youtube discovery executor"), (beatport_discovery_executor, "beatport discovery executor"), @@ -8738,6 +8739,9 @@ def library_check_tracks(): file_ext = os.path.splitext(matched_db_track.file_path or '')[1].lstrip('.').upper() or None owned_map[track_name] = { "owned": True, + "track_id": getattr(matched_db_track, 'id', None), + "title": getattr(matched_db_track, 'title', track_name), + "file_path": getattr(matched_db_track, 'file_path', None), "format": file_ext, "bitrate": matched_db_track.bitrate, "album": getattr(matched_db_track, 'album_title', None) @@ -21844,6 +21848,655 @@ def cancel_deezer_sync(playlist_id): return jsonify({"error": str(e)}), 500 +# =================================================================== +# QOBUZ PLAYLIST DISCOVERY API ENDPOINTS +# =================================================================== +# +# Mirrors the Tidal + Deezer endpoint set for parity on the Sync page. +# Qobuz playlists arrive from `core/qobuz_client.py` as dicts (matching +# the Deezer client's shape), so the state + endpoint code follows the +# Deezer template rather than Tidal's dataclass-based one. Github issue +# #677. + +# Global state for Qobuz playlist discovery management +qobuz_discovery_states = {} # Key: playlist_id, Value: discovery state +qobuz_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="qobuz_discovery") + + +def _get_qobuz_client_for_sync(): + """Resolve the Qobuz client via the download orchestrator. + + The orchestrator owns the canonical instance (same one Settings → + Connections authenticates against), so the Sync page tab always sees + fresh auth state without a second login flow. + """ + if not download_orchestrator or not hasattr(download_orchestrator, 'client'): + return None + try: + return download_orchestrator.client("qobuz") + except Exception as exc: + logger.debug(f"Qobuz client lookup failed: {exc}") + return None + + +@app.route('/api/qobuz/playlists', methods=['GET']) +def get_qobuz_playlists(): + """Fetches the authenticated user's Qobuz playlists (metadata only). + + Tracks are fetched on demand by the per-playlist detail endpoint — + matches the Tidal + Deezer behaviour so the Sync page renderer can + treat all three services uniformly. + """ + qobuz = _get_qobuz_client_for_sync() + if not qobuz or not qobuz.is_authenticated(): + return jsonify({"error": "Qobuz not authenticated."}), 401 + + try: + playlists = qobuz.get_user_playlists() + + playlist_data = [] + for p in playlists: + playlist_data.append({ + "id": p['id'], + "name": p['name'], + "owner": "You", + "track_count": p.get('track_count', 0), + "image_url": p.get('image_url') or None, + "description": p.get('description', ''), + "tracks": [] + }) + + # Append virtual "Favorite Tracks" entry at the END (mirrors + # Tidal's COLLECTION_PLAYLIST_ID pattern — count only here, full + # fetch deferred to the per-playlist detail endpoint). + try: + from core.qobuz_client import QobuzClient as _QobuzClientTypeRef + favorites_count = qobuz.get_user_favorite_tracks_count() + if favorites_count > 0: + playlist_data.append({ + "id": qobuz.QOBUZ_FAVORITES_ID, + "name": qobuz.QOBUZ_FAVORITES_NAME, + "owner": "You", + "track_count": favorites_count, + "image_url": None, + "description": qobuz.QOBUZ_FAVORITES_DESCRIPTION, + "tracks": [], + }) + logger.info( + f"Added virtual '{qobuz.QOBUZ_FAVORITES_NAME}' playlist with {favorites_count} tracks (count only)" + ) + except Exception as favorites_error: + logger.error(f"Failed to add Qobuz Favorite Tracks playlist: {favorites_error}") + + logger.info(f"Loaded {len(playlist_data)} Qobuz playlists") + return jsonify(playlist_data) + except Exception as e: + logger.error(f"Error loading Qobuz playlists: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/qobuz/playlist/', methods=['GET']) +def get_qobuz_playlist_tracks(playlist_id): + """Fetches full track details for a specific Qobuz playlist.""" + qobuz = _get_qobuz_client_for_sync() + if not qobuz or not qobuz.is_authenticated(): + return jsonify({"error": "Qobuz not authenticated."}), 401 + + try: + logger.info(f"Getting full Qobuz playlist with tracks for: {playlist_id}") + full_playlist = qobuz.get_playlist(playlist_id) + if not full_playlist: + return jsonify({"error": "Playlist not found or unable to access."}), 404 + + tracks = full_playlist.get('tracks') or [] + if not tracks: + return jsonify({"error": "This playlist appears to have no tracks or they cannot be accessed"}), 403 + + logger.info(f"Loaded {len(tracks)} tracks from Qobuz playlist: {full_playlist['name']}") + + playlist_dict = { + 'id': full_playlist['id'], + 'name': full_playlist['name'], + 'description': full_playlist.get('description', ''), + 'owner': 'You', + 'track_count': len(tracks), + 'image_url': full_playlist.get('image_url') or None, + 'tracks': tracks, + } + return jsonify(playlist_dict) + except Exception as e: + logger.error(f"Error getting Qobuz playlist tracks: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/qobuz/discovery/start/', methods=['POST']) +def start_qobuz_discovery(playlist_id): + """Start Spotify discovery process for a Qobuz playlist.""" + try: + qobuz = _get_qobuz_client_for_sync() + if not qobuz or not qobuz.is_authenticated(): + return jsonify({"error": "Qobuz not authenticated."}), 401 + + if playlist_id in qobuz_discovery_states: + existing_state = qobuz_discovery_states[playlist_id] + if existing_state['phase'] == 'discovering': + return jsonify({"error": "Discovery already in progress"}), 400 + + if not existing_state.get('playlist'): + playlist_data = qobuz.get_playlist(playlist_id) + if not playlist_data: + return jsonify({"error": "Qobuz playlist not found"}), 404 + existing_state['playlist'] = playlist_data + + existing_state['phase'] = 'discovering' + existing_state['status'] = 'discovering' + existing_state['last_accessed'] = time.time() + state = existing_state + else: + playlist_data = qobuz.get_playlist(playlist_id) + + if not playlist_data: + return jsonify({"error": "Qobuz playlist not found"}), 404 + + if not playlist_data.get('tracks'): + return jsonify({"error": "Playlist has no tracks"}), 400 + + state = { + 'playlist': playlist_data, + 'phase': 'discovering', + 'status': 'discovering', + 'discovery_progress': 0, + 'spotify_matches': 0, + 'spotify_total': len(playlist_data['tracks']), + 'discovery_results': [], + 'sync_playlist_id': None, + 'converted_spotify_playlist_id': None, + 'download_process_id': None, + 'created_at': time.time(), + 'last_accessed': time.time(), + 'discovery_future': None, + 'sync_progress': {} + } + qobuz_discovery_states[playlist_id] = state + + playlist_name = state['playlist']['name'] + track_count = len(state['playlist']['tracks']) + add_activity_item("", "Qobuz Discovery Started", f"'{playlist_name}' - {track_count} tracks", "Now") + + qobuz_discovery_states[playlist_id]['_profile_id'] = get_current_profile_id() + future = qobuz_discovery_executor.submit(_run_qobuz_discovery_worker, playlist_id) + state['discovery_future'] = future + + logger.info(f"Started Spotify discovery for Qobuz playlist: {playlist_name}") + return jsonify({"success": True, "message": "Discovery started"}) + + except Exception as e: + logger.error(f"Error starting Qobuz discovery: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/qobuz/discovery/status/', methods=['GET']) +def get_qobuz_discovery_status(playlist_id): + """Get real-time discovery status for a Qobuz playlist.""" + try: + if playlist_id not in qobuz_discovery_states: + return jsonify({"error": "Qobuz discovery not found"}), 404 + + state = qobuz_discovery_states[playlist_id] + state['last_accessed'] = time.time() + + response = { + 'phase': state['phase'], + 'status': state['status'], + 'progress': state['discovery_progress'], + 'spotify_matches': state['spotify_matches'], + 'spotify_total': state['spotify_total'], + 'results': state['discovery_results'], + 'complete': state['phase'] == 'discovered' + } + + return jsonify(response) + + except Exception as e: + logger.error(f"Error getting Qobuz discovery status: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/qobuz/discovery/update_match', methods=['POST']) +def update_qobuz_discovery_match(): + """Update a Qobuz discovery result with a manually selected Spotify track.""" + try: + data = request.get_json() + identifier = data.get('identifier') # playlist_id + track_index = data.get('track_index') + spotify_track = data.get('spotify_track') + + if not identifier or track_index is None or not spotify_track: + return jsonify({'error': 'Missing required fields'}), 400 + + state = qobuz_discovery_states.get(identifier) + if not state: + return jsonify({'error': 'Discovery state not found'}), 404 + + if track_index >= len(state['discovery_results']): + return jsonify({'error': 'Invalid track index'}), 400 + + result = state['discovery_results'][track_index] + old_status = result.get('status') + + result['status'] = 'Found' + result['status_class'] = 'found' + result['spotify_track'] = spotify_track['name'] + result['spotify_artist'] = _join_artist_names(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else _extract_artist_name(spotify_track['artists']) + result['spotify_album'] = spotify_track['album'] + result['spotify_id'] = spotify_track['id'] + + duration_ms = spotify_track.get('duration_ms', 0) + if duration_ms: + minutes = duration_ms // 60000 + seconds = (duration_ms % 60000) // 1000 + result['duration'] = f"{minutes}:{seconds:02d}" + else: + result['duration'] = '0:00' + + # Manual match from the fix modal — build rich spotify_data matching + # the normal discovery shape, clear wing-it flag since the user + # picked a real metadata match. + result['spotify_data'] = _build_fix_modal_spotify_data(spotify_track) + result['wing_it_fallback'] = False + result['manual_match'] = True + + if old_status != 'found' and old_status != 'Found': + state['spotify_matches'] = state.get('spotify_matches', 0) + 1 + + logger.info(f"Manual match updated: qobuz - {identifier} - track {track_index}") + logger.info(f" → {result['spotify_artist']} - {result['spotify_track']}") + + try: + original_track = result.get('qobuz_track', {}) + original_name = original_track.get('name', spotify_track['name']) + original_artists = original_track.get('artists', []) + original_artist = original_artists[0] if original_artists else '' + + cache_key = _get_discovery_cache_key(original_name, original_artist) + artists_list = spotify_track['artists'] + if isinstance(artists_list, list): + artists_list = [a if isinstance(a, str) else a.get('name', '') for a in artists_list] + image_url = spotify_track.get('image_url') or '' + album_raw = spotify_track.get('album', '') + if isinstance(album_raw, dict): + album_obj = dict(album_raw) + if image_url and not album_obj.get('image_url'): + album_obj['image_url'] = image_url + if image_url and not album_obj.get('images'): + album_obj['images'] = [{'url': image_url}] + else: + album_obj = {'name': album_raw or ''} + if image_url: + album_obj['image_url'] = image_url + album_obj['images'] = [{'url': image_url}] + + matched_data = { + 'id': spotify_track['id'], + 'name': spotify_track['name'], + 'artists': artists_list, + 'album': album_obj, + 'duration_ms': spotify_track.get('duration_ms', 0), + 'image_url': image_url, + 'source': 'spotify', + } + cache_db = get_database() + cache_db.save_discovery_cache_match( + cache_key[0], cache_key[1], _get_active_discovery_source(), 1.0, matched_data, + original_name, original_artist + ) + logger.info(f"Manual fix saved to discovery cache: {original_name} by {original_artist}") + except Exception as cache_err: + logger.error(f"Error saving manual fix to discovery cache: {cache_err}") + + return jsonify({'success': True, 'result': result}) + + except Exception as e: + logger.error(f"Error updating Qobuz discovery match: {e}") + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/qobuz/playlists/states', methods=['GET']) +def get_qobuz_playlist_states(): + """Get all stored Qobuz playlist discovery states for frontend hydration.""" + try: + states = [] + current_time = time.time() + + for playlist_id, state in qobuz_discovery_states.items(): + state['last_accessed'] = current_time + + state_info = { + 'playlist_id': playlist_id, + 'phase': state['phase'], + 'status': state['status'], + 'discovery_progress': state['discovery_progress'], + 'spotify_matches': state['spotify_matches'], + 'spotify_total': state['spotify_total'], + 'discovery_results': state['discovery_results'], + 'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'), + 'download_process_id': state.get('download_process_id'), + 'last_accessed': state['last_accessed'] + } + states.append(state_info) + + logger.info(f"Returning {len(states)} stored Qobuz playlist states for hydration") + return jsonify({"states": states}) + + except Exception as e: + logger.error(f"Error getting Qobuz playlist states: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/qobuz/state/', methods=['GET']) +def get_qobuz_playlist_state(playlist_id): + """Get specific Qobuz playlist state (detailed version).""" + try: + if playlist_id not in qobuz_discovery_states: + return jsonify({"error": "Qobuz playlist not found"}), 404 + + state = qobuz_discovery_states[playlist_id] + state['last_accessed'] = time.time() + + response = { + 'playlist_id': playlist_id, + 'playlist': state['playlist'], + 'phase': state['phase'], + 'status': state['status'], + 'discovery_progress': state['discovery_progress'], + 'spotify_matches': state['spotify_matches'], + 'spotify_total': state['spotify_total'], + 'discovery_results': state['discovery_results'], + 'sync_playlist_id': state.get('sync_playlist_id'), + 'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'), + 'download_process_id': state.get('download_process_id'), + 'sync_progress': state.get('sync_progress', {}), + 'last_accessed': state['last_accessed'] + } + + return jsonify(response) + + except Exception as e: + logger.error(f"Error getting Qobuz playlist state: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/qobuz/reset/', methods=['POST']) +def reset_qobuz_playlist(playlist_id): + """Reset Qobuz playlist to fresh phase (clear discovery/sync data).""" + try: + if playlist_id not in qobuz_discovery_states: + return jsonify({"error": "Qobuz playlist not found"}), 404 + + state = qobuz_discovery_states[playlist_id] + + if 'discovery_future' in state and state['discovery_future']: + state['discovery_future'].cancel() + + state['phase'] = 'fresh' + state['status'] = 'fresh' + state['discovery_results'] = [] + state['discovery_progress'] = 0 + state['spotify_matches'] = 0 + state['sync_playlist_id'] = None + state['converted_spotify_playlist_id'] = None + state['download_process_id'] = None + state['sync_progress'] = {} + state['discovery_future'] = None + state['last_accessed'] = time.time() + + logger.info(f"Reset Qobuz playlist to fresh: {playlist_id}") + return jsonify({"success": True, "message": "Playlist reset to fresh phase"}) + + except Exception as e: + logger.error(f"Error resetting Qobuz playlist: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/qobuz/delete/', methods=['POST']) +def delete_qobuz_playlist(playlist_id): + """Delete Qobuz playlist state completely.""" + try: + if playlist_id not in qobuz_discovery_states: + return jsonify({"error": "Qobuz playlist not found"}), 404 + + state = qobuz_discovery_states[playlist_id] + + if 'discovery_future' in state and state['discovery_future']: + state['discovery_future'].cancel() + + del qobuz_discovery_states[playlist_id] + + logger.info(f"Deleted Qobuz playlist state: {playlist_id}") + return jsonify({"success": True, "message": "Playlist deleted"}) + + except Exception as e: + logger.error(f"Error deleting Qobuz playlist: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/qobuz/update_phase/', methods=['POST']) +def update_qobuz_playlist_phase(playlist_id): + """Update Qobuz playlist phase (used when modal closes to reset from download_complete to discovered).""" + try: + if playlist_id not in qobuz_discovery_states: + return jsonify({"error": "Qobuz playlist not found"}), 404 + + data = request.get_json() + if not data or 'phase' not in data: + return jsonify({"error": "Phase not provided"}), 400 + + new_phase = data['phase'] + valid_phases = ['fresh', 'discovering', 'discovered', 'syncing', 'sync_complete', 'downloading', 'download_complete'] + + if new_phase not in valid_phases: + return jsonify({"error": f"Invalid phase. Must be one of: {', '.join(valid_phases)}"}), 400 + + state = qobuz_discovery_states[playlist_id] + old_phase = state.get('phase', 'unknown') + state['phase'] = new_phase + state['last_accessed'] = time.time() + + if 'download_process_id' in data: + state['download_process_id'] = data['download_process_id'] + if 'converted_spotify_playlist_id' in data: + state['converted_spotify_playlist_id'] = data['converted_spotify_playlist_id'] + + logger.info(f"Updated Qobuz playlist {playlist_id} phase: {old_phase} → {new_phase}") + return jsonify({"success": True, "message": f"Phase updated to {new_phase}", "old_phase": old_phase, "new_phase": new_phase}) + + except Exception as e: + logger.error(f"Error updating Qobuz playlist phase: {e}") + return jsonify({"error": str(e)}), 500 + + +# Qobuz discovery worker logic lives in core/discovery/qobuz.py. +from core.discovery import qobuz as _discovery_qobuz + + +def _build_qobuz_discovery_deps(): + """Build the QobuzDiscoveryDeps bundle from web_server.py globals on each call.""" + return _discovery_qobuz.QobuzDiscoveryDeps( + qobuz_discovery_states=qobuz_discovery_states, + spotify_client=spotify_client, + pause_enrichment_workers=_pause_enrichment_workers, + resume_enrichment_workers=_resume_enrichment_workers, + get_active_discovery_source=_get_active_discovery_source, + get_metadata_fallback_client=_get_metadata_fallback_client, + get_discovery_cache_key=_get_discovery_cache_key, + get_database=get_database, + validate_discovery_cache_artist=_validate_discovery_cache_artist, + search_spotify_for_tidal_track=_search_spotify_for_tidal_track, + build_discovery_wing_it_stub=_build_discovery_wing_it_stub, + add_activity_item=add_activity_item, + sync_discovery_results_to_mirrored=_sync_discovery_results_to_mirrored, + ) + + +def _run_qobuz_discovery_worker(playlist_id): + return _discovery_qobuz.run_qobuz_discovery_worker(playlist_id, _build_qobuz_discovery_deps()) + + +def convert_qobuz_results_to_spotify_tracks(discovery_results): + """Convert Qobuz discovery results to Spotify tracks format for sync.""" + spotify_tracks = [] + + for result in discovery_results: + if result.get('spotify_data'): + spotify_data = result['spotify_data'] + + track = { + 'id': spotify_data['id'], + 'name': spotify_data['name'], + 'artists': spotify_data['artists'], + 'album': spotify_data['album'], + 'duration_ms': spotify_data.get('duration_ms', 0) + } + if spotify_data.get('track_number'): + track['track_number'] = spotify_data['track_number'] + if spotify_data.get('disc_number'): + track['disc_number'] = spotify_data['disc_number'] + spotify_tracks.append(track) + elif result.get('spotify_track') and result.get('status_class') == 'found': + track = { + 'id': result.get('spotify_id', 'unknown'), + 'name': result.get('spotify_track', 'Unknown Track'), + 'artists': [result.get('spotify_artist', 'Unknown Artist')] if result.get('spotify_artist') else ['Unknown Artist'], + 'album': result.get('spotify_album', 'Unknown Album'), + 'duration_ms': 0 + } + spotify_tracks.append(track) + + logger.info(f"Converted {len(spotify_tracks)} Qobuz matches to Spotify tracks for sync") + return spotify_tracks + + +# =================================================================== +# QOBUZ SYNC API ENDPOINTS +# =================================================================== + +@app.route('/api/qobuz/sync/start/', methods=['POST']) +def start_qobuz_sync(playlist_id): + """Start sync process for a Qobuz playlist using discovered Spotify tracks.""" + try: + if playlist_id not in qobuz_discovery_states: + return jsonify({"error": "Qobuz playlist not found"}), 404 + + state = qobuz_discovery_states[playlist_id] + state['last_accessed'] = time.time() + + if state['phase'] not in ['discovered', 'sync_complete', 'download_complete']: + return jsonify({"error": "Qobuz playlist not ready for sync"}), 400 + + spotify_tracks = convert_qobuz_results_to_spotify_tracks(state['discovery_results']) + if not spotify_tracks: + return jsonify({"error": "No Spotify matches found for sync"}), 400 + + sync_playlist_id = f"qobuz_{playlist_id}" + playlist_name = state['playlist']['name'] + + add_activity_item("", "Qobuz Sync Started", f"'{playlist_name}' - {len(spotify_tracks)} tracks", "Now") + + state['phase'] = 'syncing' + state['sync_playlist_id'] = sync_playlist_id + state['sync_progress'] = {} + + sync_data = { + 'playlist_id': sync_playlist_id, + 'playlist_name': playlist_name, + 'tracks': spotify_tracks + } + + with sync_lock: + sync_states[sync_playlist_id] = {"status": "starting", "progress": {}} + + playlist_image_url = state['playlist'].get('image_url', '') + future = sync_executor.submit(_run_sync_task, sync_playlist_id, sync_data['playlist_name'], spotify_tracks, None, get_current_profile_id(), playlist_image_url) + active_sync_workers[sync_playlist_id] = future + + logger.info(f"Started Qobuz sync for: {playlist_name} ({len(spotify_tracks)} tracks)") + return jsonify({"success": True, "sync_playlist_id": sync_playlist_id}) + + except Exception as e: + logger.error(f"Error starting Qobuz sync: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/qobuz/sync/status/', methods=['GET']) +def get_qobuz_sync_status(playlist_id): + """Get sync status for a Qobuz playlist.""" + try: + if playlist_id not in qobuz_discovery_states: + return jsonify({"error": "Qobuz playlist not found"}), 404 + + state = qobuz_discovery_states[playlist_id] + state['last_accessed'] = time.time() + sync_playlist_id = state.get('sync_playlist_id') + + if not sync_playlist_id: + return jsonify({"error": "No sync in progress"}), 404 + + with sync_lock: + sync_state = sync_states.get(sync_playlist_id, {}) + + response = { + 'phase': state['phase'], + 'sync_status': sync_state.get('status', 'unknown'), + 'progress': sync_state.get('progress', {}), + 'complete': sync_state.get('status') == 'finished', + 'error': sync_state.get('error') + } + + if sync_state.get('status') == 'finished': + state['phase'] = 'sync_complete' + state['sync_progress'] = sync_state.get('progress', {}) + playlist_name = state['playlist']['name'] + add_activity_item("", "Sync Complete", f"Qobuz playlist '{playlist_name}' synced successfully", "Now") + elif sync_state.get('status') == 'error': + state['phase'] = 'discovered' + playlist_name = state['playlist']['name'] + add_activity_item("", "Sync Failed", f"Qobuz playlist '{playlist_name}' sync failed", "Now") + + return jsonify(response) + + except Exception as e: + logger.error(f"Error getting Qobuz sync status: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/qobuz/sync/cancel/', methods=['POST']) +def cancel_qobuz_sync(playlist_id): + """Cancel sync for a Qobuz playlist.""" + try: + if playlist_id not in qobuz_discovery_states: + return jsonify({"error": "Qobuz playlist not found"}), 404 + + state = qobuz_discovery_states[playlist_id] + state['last_accessed'] = time.time() + sync_playlist_id = state.get('sync_playlist_id') + + if sync_playlist_id: + with sync_lock: + sync_states[sync_playlist_id] = {"status": "cancelled"} + if sync_playlist_id in active_sync_workers: + del active_sync_workers[sync_playlist_id] + + state['phase'] = 'discovered' + state['sync_playlist_id'] = None + state['sync_progress'] = {} + + return jsonify({"success": True, "message": "Qobuz sync cancelled"}) + + except Exception as e: + logger.error(f"Error cancelling Qobuz sync: {e}") + return jsonify({"error": str(e)}), 500 + + # =================================================================== # SPOTIFY PUBLIC PLAYLIST DISCOVERY API ENDPOINTS # =================================================================== @@ -24315,9 +24968,10 @@ def select_profile(): return jsonify({'success': False, 'error': 'Invalid PIN'}), 401 session['profile_id'] = profile_id - # If PIN was just validated, also mark launch PIN as verified - # so the subsequent page reload doesn't ask again - if pin: + # If the admin PIN was just validated, also mark launch PIN as + # verified so the subsequent page reload doesn't ask again. A + # non-admin profile PIN must not unlock the admin launch lock. + if pin and profile_id == 1: session['launch_pin_verified'] = True return jsonify({'success': True, 'profile': profile}) except Exception as e: @@ -24402,12 +25056,16 @@ def reset_pin_via_credential(): # Credential verified — clear PIN for the requested profile (default: admin) database = get_database() - target_profile = data.get('profile_id', 1) + try: + target_profile = int(data.get('profile_id', 1)) + except (TypeError, ValueError): + target_profile = 1 database.update_profile(target_profile, pin_hash=None) # If clearing admin PIN, also disable launch lock if target_profile == 1: config_manager.set('security.require_pin_on_launch', False) - session['launch_pin_verified'] = True + if target_profile == 1: + session['launch_pin_verified'] = True return jsonify({'success': True, 'message': 'PIN cleared. You can set a new PIN in Settings.'}) except Exception as e: @@ -33159,6 +33817,101 @@ def stats_recent(): except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 +@app.route('/api/lyrics/fetch', methods=['POST']) +def fetch_lyrics_endpoint(): + """Fetch lyrics for the now-playing media player. + + Body: ``{title, artist, album?, duration?}``. Returns + ``{success, synced, plain, source}`` where ``synced`` is an LRC + string with ``[mm:ss.xx] line`` timestamps (or None) and ``plain`` + is the untimestamped text (or None). ``source`` is the lookup + strategy that hit (``exact`` / ``search`` / ``sidecar``). + + Tries the local ``.lrc`` / ``.txt`` sidecar first when a + ``file_path`` is supplied — already-downloaded tracks should not + bounce LRClib on every play. Falls through to LRClib's exact- + match endpoint when title+artist+album+duration are all available, + then to its generic search endpoint. + """ + try: + data = request.get_json() or {} + title = (data.get('title') or '').strip() + artist = (data.get('artist') or '').strip() + album = (data.get('album') or '').strip() or None + try: + duration = int(data.get('duration') or 0) or None + except (TypeError, ValueError): + duration = None + file_path = data.get('file_path') or None + + if not title or not artist: + return jsonify({'success': False, 'error': 'title and artist required', + 'synced': None, 'plain': None, 'source': None}), 400 + + # 1. Sidecar — fastest, no network. The post-processing flow + # drops .lrc / .txt next to the audio for every successful + # enrichment, so existing downloads almost always have one. + if file_path: + try: + import os as _os + stem, _ = _os.path.splitext(file_path) + lrc_path = stem + '.lrc' + txt_path = stem + '.txt' + if _os.path.exists(lrc_path): + with open(lrc_path, 'r', encoding='utf-8') as fh: + body = fh.read().strip() + if body: + return jsonify({'success': True, 'synced': body, + 'plain': None, 'source': 'sidecar'}) + if _os.path.exists(txt_path): + with open(txt_path, 'r', encoding='utf-8') as fh: + body = fh.read().strip() + if body: + return jsonify({'success': True, 'synced': None, + 'plain': body, 'source': 'sidecar'}) + except Exception as sidecar_err: + logger.debug("lyrics sidecar read skipped: %s", sidecar_err) + + # 2. LRClib network lookup via the shared client instance. + from core.lyrics_client import lyrics_client as _lyrics_client + api = getattr(_lyrics_client, 'api', None) + if api is None: + return jsonify({'success': False, 'error': 'lrclib unavailable', + 'synced': None, 'plain': None, 'source': None}), 200 + + result = None + # Exact-match endpoint requires all four fields. LRClib's API + # will 404 on any miss; treat as soft failure and fall through + # to the search endpoint. + if album and duration: + try: + result = api.get_lyrics(track_name=title, artist_name=artist, + album_name=album, duration=duration) + except Exception as exact_err: + logger.debug("lrclib exact lookup failed: %s", exact_err) + + if result is None: + try: + hits = api.search_lyrics(track_name=title, artist_name=artist) + if hits: + result = hits[0] + except Exception as search_err: + logger.debug("lrclib search lookup failed: %s", search_err) + + if result is None: + return jsonify({'success': False, 'error': 'no lyrics found', + 'synced': None, 'plain': None, 'source': None}) + + synced = getattr(result, 'synced_lyrics', None) or None + plain = getattr(result, 'plain_lyrics', None) or None + return jsonify({'success': bool(synced or plain), 'synced': synced, + 'plain': plain, 'source': 'lrclib'}) + except Exception as e: + logger.error("lyrics fetch failed: %s", e) + return jsonify({'success': False, 'error': str(e), + 'synced': None, 'plain': None, 'source': None}), 500 + + @app.route('/api/stats/resolve-track', methods=['POST']) def stats_resolve_track(): """Resolve a track by title+artist to get its file_path for playback.""" @@ -34519,6 +35272,7 @@ def _emit_discovery_progress_loop(): """Push discovery progress to subscribed rooms every 1 second.""" platform_states = { 'tidal': lambda: tidal_discovery_states, + 'qobuz': lambda: qobuz_discovery_states, 'deezer': lambda: deezer_discovery_states, 'youtube': lambda: youtube_playlist_states, 'beatport': lambda: beatport_chart_states, diff --git a/webui/README.md b/webui/README.md index d32587c2..0074b850 100644 --- a/webui/README.md +++ b/webui/README.md @@ -79,6 +79,12 @@ webui/src/ test/ Shared test utilities and setup helpers ``` +Migration planning docs live under `webui/docs/migration/`. + +- keep the high-level route backlog there +- add one route-specific sketch per migration task +- keep migration notes close to the WebUI code rather than the repo root + ### Route Slices - Keep route-specific code inside `webui/src/routes//`. diff --git a/webui/docs/migration/README.md b/webui/docs/migration/README.md new file mode 100644 index 00000000..a7a9d5e3 --- /dev/null +++ b/webui/docs/migration/README.md @@ -0,0 +1,46 @@ +# WebUI Migration Docs + +This folder is the home for React migration planning work inside `webui`. + +## Purpose + +- Keep migration planning close to the code it describes. +- Separate WebUI migration docs from repo-level product or backend docs. +- Give each route migration a predictable place to live. + +## Current Docs + +- [page-migration-overview.md](./page-migration-overview.md) + - high-level route inventory + - migration waves + - cross-route risk assessment +- [stats-migration-plan.md](./stats-migration-plan.md) + - route-specific migration plan for `stats` + +## Naming Guidance + +- Keep one high-level backlog / sequencing doc: + - `page-migration-overview.md` +- Use one route-specific plan per migration task: + - `-migration-plan.md` + +Examples: + +- `search-migration-plan.md` +- `watchlist-migration-plan.md` +- `library-migration-plan.md` + +## Scope + +Use this folder for: + +- migration sequencing +- route-specific implementation sketches +- React ownership cutover notes +- shell handoff notes tied to WebUI page migrations + +Do not use this folder for: + +- generic product docs +- backend architecture notes unrelated to WebUI migration +- permanent user-facing documentation diff --git a/docs/webui-page-migration-analysis.md b/webui/docs/migration/page-migration-overview.md similarity index 87% rename from docs/webui-page-migration-analysis.md rename to webui/docs/migration/page-migration-overview.md index 0d123551..01c96a57 100644 --- a/docs/webui-page-migration-analysis.md +++ b/webui/docs/migration/page-migration-overview.md @@ -1,16 +1,17 @@ -# WebUI Page Migration Analysis +# WebUI Page Migration Overview -Snapshot date: 2026-05-02 +Snapshot date: 2026-05-14 ## Summary - The shell route manifest now has 18 page ids. -- `issues` is still the only React-owned route. +- `issues` and `stats` are now React-owned routes. - Since the last snapshot, the biggest changes are: - `downloads` was renamed into `search`. - The live queue became `active-downloads`. - `watchlist` and `wishlist` became full sidebar pages. - `tools` was split off from `dashboard`. - `artists` is no longer a route id. +- Since the last migration review, `stats` has been fully moved to React, the legacy stats HTML/JS/CSS path has been removed, and the global `Chart.js` import has been dropped in favor of route-local `Recharts`. - The shell is also more modular now. The old monolithic `script.js` has been split across `core.js`, `init.js`, `shared-helpers.js`, and feature modules such as `search.js`, `api-monitor.js`, `pages-extra.js`, `stats-automations.js`, and `wishlist-tools.js`. - Current profile compatibility still normalizes old `downloads` and `artists` references to `search`, so the docs and the route ids are not always using the same historical language. @@ -28,7 +29,7 @@ Snapshot date: 2026-05-02 - `webui/static/core.js` now holds a lot of the shared global state that used to live in the old monolith. - `webui/static/init.js` still owns page activation, permission gating, nav highlighting, legacy routing, and the `window.SoulSyncWebRouter` bridge. - `webui/static/shell-bridge.js` and the TanStack Router adapter still decide whether a route is handled by the React host or handed back to the legacy shell. -- `issues` remains the reference pattern for React-owned pages: route manifest ownership, shell bridge integration, route-local data loading, and detail-modal behavior all live in the React subtree. +- `issues` remains the reference pattern for interactive React-owned pages, while `stats` now complements it as the reference for data-heavy read-only routes with route-local charts and explicit shell handoffs. - The legacy shell is now spread across feature modules rather than one giant coordinator file, which makes the migration seams a little clearer than they were a month ago. ### Route and Compatibility Notes @@ -48,6 +49,7 @@ Snapshot date: 2026-05-02 - Socket/WebSocket and polling behavior remain the biggest migration risks for live pages. - The help system, tours, and helper annotations still reference some historical route names, so route-migration work should use the manifest as the source of truth. - Visual effects such as `particles.js` and `worker-orbs.js` remain shell-global. +- Route migrations should actively scan for emerging shared UI or shell patterns. Do not force abstractions on the first occurrence, but do document overlap and prefer extracting a shared primitive once a second route clearly needs the same behavior. ## Scoring Rubric Each page is scored from 1 to 5 on five axes: @@ -74,10 +76,10 @@ Rollups: | Page | Owner | Scores (R/S/A/C/T) | Effort | Risk | Recommended Wave | | --- | --- | --- | --- | --- | --- | -| `issues` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 0 | +| `issues` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Completed | +| `stats` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Completed | | `help` | Legacy | 3 / 2 / 1 / 1 / 2 | Low | Low | Wave 1 | | `hydrabase` | Legacy | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 1 | -| `stats` | Legacy | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 1 | | `import` | Legacy | 3 / 3 / 3 / 2 / 3 | Medium | Medium | Wave 1 | | `search` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 2 | | `watchlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 | @@ -104,6 +106,13 @@ Rollups: - Key coupling: shell page gating, shell nav badge refresh, bridge-controlled chrome, React Query cache. - Why it stays first: it is already the canonical React route pattern and the migration baseline. +#### `stats` +- Current owner: React. +- Primary files: `webui/src/routes/stats/*`, `webui/src/platform/shell/*`. +- Main surface: listening stats, charts, ranked lists, disk usage, database storage visualization. +- Key coupling: query invalidation, shell handoffs for playback and artist navigation, route-local chart composition. +- Why it matters now: it is the first completed data-heavy read-only migration and the current reference for route-local charting, explicit shell bridge usage, and legacy cleanup after cutover. + ### Wave 1: Safest wins #### `help` @@ -120,19 +129,12 @@ Rollups: - Key coupling: profile gating and a small amount of shell state. - Recommendation: low-risk route with a narrow surface. -#### `stats` -- Current owner: Legacy. -- Primary files: `webui/index.html`, `webui/static/stats-automations.js`. -- Main surface: listening stats, charts, ranked lists, database storage visualization. -- Key coupling: chart rendering, some deep links back into library routes. -- Recommendation: early migration candidate with good parity-test potential. - #### `import` - Current owner: Legacy. - Primary files: `webui/index.html`, `webui/static/stats-automations.js`, `webui/static/helper.js`. - Main surface: staging files, album and singles matching, suggestion cards, processing queue. - Key coupling: settings-derived staging path assumptions and downstream library state. -- Recommendation: still bounded enough for an early wave, though more workflow-heavy than `help` or `stats`. +- Recommendation: still bounded enough for an early wave, though more workflow-heavy than `help` or `hydrabase`. ### Wave 2: Search split @@ -244,6 +246,7 @@ Rollups: - Recommendation: save this for the final wave with the other complex authoring surfaces. ## Platform Unlocks +- `stats` now provides the baseline for route-local charting, explicit shell-bridge interop from React, and the pattern of cleaning out legacy assets once parity is confirmed. - `search` likely unlocks reusable search-controller and download-launch primitives for the global search widget and other search entry points. - `watchlist` likely unlocks artist-card, per-artist config, and scan-status primitives for `discover` and `wishlist`. - `wishlist` likely unlocks queue/cycle visualization, live polling, and retry-state handling for `active-downloads` and sync-driven download flows. @@ -252,6 +255,7 @@ Rollups: - `library` + `artist-detail` still unlock entity-detail patterns, bulk actions, and file-management workflows. ## Why Earlier Waves Are Safer +- `stats` validated that bounded data pages can move early without needing broad shell rewrites, while also surfacing a healthy reminder to watch for emerging shared primitives during migration work. - Wave 1 routes are either mostly static or bounded data UIs with limited cross-route side effects. - Wave 2 adds the renamed search surface without dragging in the full queue history. - Wave 3 introduces the new watchlist/wishlist split, which is important but still narrower than discovery or library management. @@ -260,8 +264,9 @@ Rollups: - Waves 6-10 defer the broadest, most coupled, or most orchestration-heavy surfaces until the team has the most leverage. ## Final Recommendation -- Keep `issues` as the reference implementation and preserve the existing bridge contract. +- Keep `issues` and `stats` as the current React reference implementations, and preserve the explicit bridge contract between React routes and legacy shell behavior. - Treat `search`, `watchlist`, `wishlist`, `active-downloads`, and `tools` as the current route ids, and keep `downloads` and `artists` only as compatibility history. -- Migrate the safe routes first: `help`, `hydrabase`, `stats`, and `import`. +- Migrate the remaining safe legacy routes first: `help`, `hydrabase`, and `import`. +- During each migration, actively look for small reuse opportunities across route slices and shared UI primitives, but only extract once the overlap is clearly real. - Use `search` as the next meaningful proving ground now that the download queue has been split out. - Avoid pulling `settings`, `sync`, `library`, `artist-detail`, or `automations` forward unless there is a separate product priority strong enough to justify the added regression risk. diff --git a/webui/docs/migration/stats-migration-plan.md b/webui/docs/migration/stats-migration-plan.md new file mode 100644 index 00000000..bd582c2e --- /dev/null +++ b/webui/docs/migration/stats-migration-plan.md @@ -0,0 +1,408 @@ +# WebUI Stats Migration Plan + +Snapshot date: 2026-05-14 + +## Status + +- Completed on 2026-05-14. +- `stats` is now React-owned in the shell route manifest. +- The legacy stats HTML, JS, and CSS path has been removed. +- The global `Chart.js` import was removed and replaced with route-local `Recharts`. +- Legacy playback and artist-detail handoffs now go through the explicit shell bridge. +- A local seed script exists for realistic UI testing without production listening history: `tools/seed_stats_ui_scenarios.py`. + +## Goal + +- Migrate `stats` from the legacy shell to the React route host. +- Replace the global `Chart.js` CDN script with route-local React chart components. +- Use the `issues` route slice as the structural reference, but add a few stronger conventions for data-heavy read-only pages. + +## Why `stats` Is The Right Next Route + +- The route is shell-local today. +- The activation path is narrow. +- The page has real async data loading and interaction. +- The page is complex enough to validate query conventions, search-param state, and route-local chart components. +- The page does not currently drive broad shell-global workflows. + +This route has now validated those assumptions successfully. + +## Current Legacy Shape + +Page surface in `webui/index.html`: + +- Header + - time range buttons + - last synced label + - manual sync action +- Overview cards +- Left column + - listening activity chart + - genre breakdown chart + - recently played list +- Right column + - top artists + - top albums + - top tracks +- Full-width sections + - library health + - library disk usage + - database storage +- Empty state + +Legacy JS responsibilities in `webui/static/stats-automations.js`: + +- page initialization +- range switch handling +- data fetch orchestration +- formatting helpers +- chart instantiation and teardown +- ranked list rendering +- cross-page deep links into library / artist detail +- playback handoff for recent and top tracks + +Backend endpoints already split cleanly: + +- `GET /api/stats/cached` +- `GET /api/stats/db-storage` +- `GET /api/stats/library-disk-usage` +- `POST /api/listening-stats/sync` +- `GET /api/listening-stats/status` + +There are also narrower stats endpoints in the backend, but the current page already gets most of its main payload from the cached route. + +## Library Choice + +Recommended charting library: + +- `recharts` + +Reasoning: + +- React-native component model +- good fit for bar + doughnut-style dashboards +- easy to split into small route-local components +- easier to theme from CSS variables than raw imperative chart setup +- easier to test than a canvas-first imperative path + +Not recommended for this migration: + +- `react-chartjs-2` + - better for parity-only migration + - still keeps the mental model close to Chart.js +- `visx` + - stronger for bespoke visualization systems + - more work than this page needs + +## Proposed Route Slice + +```text +webui/src/routes/stats/ + route.tsx + -stats.types.ts + -stats.api.ts + -stats.helpers.ts + -stats.api.test.ts + -stats.helpers.test.ts + -ui/ + stats-page.tsx + stats-page.module.css + stats-header.tsx + stats-overview-cards.tsx + stats-empty-state.tsx + stats-ranked-list.tsx + stats-recent-plays.tsx + stats-library-health.tsx + stats-disk-usage.tsx + stats-activity-chart.tsx + stats-genre-chart.tsx + stats-db-storage-chart.tsx +``` + +## Proposed Route Responsibilities + +`route.tsx` + +- declare `/stats` +- validate search params +- gate route through `bridge.isPageAllowed('stats')` +- preload the shell context +- load the main cached stats payload plus listening-status payload +- optionally preload disk usage and db storage if we want zero-layout-shift first render + +`-stats.types.ts` + +- search param schema +- response payload types +- normalized display shapes +- chart row types + +`-stats.api.ts` + +- query keys +- fetchers for: + - cached stats + - listening status + - db storage + - library disk usage +- mutation helper for manual sync +- invalidation helpers + +`-stats.helpers.ts` + +- range labels +- numeric and duration formatters +- disk size formatters +- chart data shaping +- legend shaping +- safe fallbacks for empty server responses + +`-ui/stats-page.tsx` + +- page composition +- search-param driven range selection +- section layout +- empty-state branching + +## Search Params + +Use search params for state that should survive reloads and linking: + +- `range` + +Recommended values: + +- `7d` +- `30d` +- `12m` +- `all` + +This is the one clear page-state value worth encoding in the URL. Everything else can remain derived from server data. + +## Query Model + +Recommended split: + +- primary query: + - `statsCachedQueryOptions(profileId, range)` +- secondary queries: + - `statsListeningStatusQueryOptions(profileId)` + - `statsDbStorageQueryOptions(profileId)` + - `statsLibraryDiskUsageQueryOptions(profileId)` + +Why this split: + +- `cached` is the real page backbone +- `db-storage` and `library-disk-usage` are already separate in the backend +- they can render as progressively enhanced cards without blocking the whole route +- `listening-stats/status` updates the sync label and complements the sync mutation + +Recommended route-loader behavior: + +- always ensure: + - cached stats + - listening status +- optional: + - db storage + - disk usage + +If we want a snappier first migration, we should keep the last two as client-side `useQuery` calls rather than route-loader requirements. + +## Component Sketch + +`StatsPage` + +- calls `useReactPageShell('stats')` +- reads `range` from route search +- renders: + - `StatsHeader` + - `StatsOverviewCards` + - `StatsEmptyState` or main sections + +`StatsHeader` + +- range segmented control +- last synced text +- sync button mutation + +`StatsOverviewCards` + +- five summary cards + +`StatsActivityChart` + +- Recharts `BarChart` +- responsive container +- route-local tooltip +- accepts already-shaped rows + +`StatsGenreChart` + +- Recharts `PieChart` +- legend rendered in React markup beside the chart +- top-10 clipping stays in helpers + +`StatsDbStorageChart` + +- Recharts `PieChart` +- custom center label rendered in React +- legend list rendered beside chart + +`StatsRankedList` + +- shared component for artists / albums / tracks +- variant props for: + - artwork + - subtitle/meta + - count label + - optional play action + - optional artist-detail deep link + +`StatsRecentPlays` + +- simple list component +- play action + +`StatsLibraryHealth` + +- overview metrics +- format breakdown bar +- enrichment coverage rows + +`StatsDiskUsage` + +- total bytes row +- pending/deep-scan message +- per-format horizontal bars + +## Recharts Mapping + +Legacy Chart.js to React mapping: + +- listening activity + - from imperative `new Chart(... type: 'bar')` + - to `ResponsiveContainer` + `BarChart` + `Bar` + `XAxis` + `YAxis` + `Tooltip` +- genre breakdown + - from doughnut chart + - to `PieChart` + `Pie` + custom legend +- database storage + - from doughnut chart with center total overlay + - to `PieChart` + `Pie` + React-rendered center label + +Suggested chart convention: + +- keep all chart data shaping outside the chart components +- chart components should receive already-normalized rows and colors +- never read directly from raw server payloads inside Recharts markup + +## CSS Strategy + +Recommended first pass: + +- create `stats-page.module.css` +- port stats-specific selectors from `webui/static/style.css` +- keep class names semantically similar to reduce migration risk + +Suggested approach: + +- move only the selectors needed by the React route +- leave legacy stats selectors in place until the route flip is complete +- after the React route owns `stats`, remove unused legacy selectors in a cleanup pass + +Do not try to redesign the page during the migration. + +## Shell And Routing Changes + +When the route is ready: + +1. Add `webui/src/routes/stats/route.tsx` +2. Regenerate the TanStack route tree if needed +3. Change `stats` from `legacy` to `react` in `webui/src/platform/shell/route-manifest.ts` +4. Keep the legacy `stats-page` DOM in `webui/index.html` during the initial cutover if that reduces risk +5. Remove legacy activation from `webui/static/init.js` once React ownership is confirmed +6. Remove the global Chart.js script from `webui/index.html` + +## Incremental Migration Order + +Recommended order: + +1. Add types, API layer, and helpers +2. Build the React route with plain markup and no charts yet +3. Port overview, ranked lists, recent plays, and empty state +4. Port library health and disk usage +5. Port Recharts activity, genre, and db storage charts +6. Flip route ownership from legacy to React +7. Remove global Chart.js import +8. Delete or shrink legacy `stats` logic from `stats-automations.js` + +This order gives us a working React page before charting becomes the critical path. + +## Testing Sketch + +Unit tests: + +- `-stats.helpers.test.ts` + - range formatting + - duration formatting + - db storage grouping into `Other` + - genre top-10 shaping + - disk usage empty-state shaping + +API tests: + +- `-stats.api.test.ts` + - cached stats success / error + - listening status success / error + - db storage success / error + - disk usage success / error + - sync mutation success / error + +Route / component tests: + +- initial render for default `range=7d` +- changing range updates the URL and query key +- empty state renders when `overview.total_plays === 0` +- ranked artist click deep-links to library / artist detail +- track play action triggers the expected handoff +- sync action shows pending state and invalidates relevant queries + +Playwright is optional for the first pass. + +## Decisions To Keep Simple + +- Keep the existing page structure. +- Keep the current backend endpoint split. +- Keep the current time-range set. +- Reuse the existing shell deep-link behavior for library and playback. +- Use Recharts only inside `stats` first. + +## Follow-Up Opportunities + +- Extract shared chart colors into route-local constants or a small shared viz helper. +- Consider a tiny `components/charts/` layer only after a second React page needs charts. +- Revisit whether `stats/cached` should remain the primary page payload or whether the route should fan out to narrower endpoints later. +- Keep watching for overlap between route-local controls and shared UI primitives. The stats range selector is a good example of a pattern that should stay local for now, but should be reconsidered if another migrated route needs the same segmented-control behavior. + +## Recommendation + +The first implementation should optimize for: + +- parity +- clear route-local boundaries +- removal of global `Chart.js` +- reusable data/query conventions + +It should not optimize for: + +- visual redesign +- a cross-app chart abstraction +- backend reshaping + +## Outcome + +- The route now serves as the reference for data-heavy read-only React pages. +- The migration proved out route-local charts, route-search state, explicit shell-bridge interop, and post-cutover legacy cleanup. +- The work also reinforced a migration guideline for future routes: + - prefer local implementation on first use + - actively note overlap with shared primitives + - extract only once the second clear consumer appears diff --git a/webui/index.html b/webui/index.html index 41393284..d590d158 100644 --- a/webui/index.html +++ b/webui/index.html @@ -194,78 +194,78 @@ @@ -943,6 +943,9 @@ + @@ -993,6 +996,17 @@ + +
+
+

Your Qobuz Playlists

+ +
+
+
Click 'Refresh' to load your Qobuz playlists.
+
+
+