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/qobuz_client.py b/core/qobuz_client.py index 1cb58a66..c2cf4e13 100644 --- a/core/qobuz_client.py +++ b/core/qobuz_client.py @@ -711,6 +711,241 @@ 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: int = 500) -> 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. + """ + if not self.is_authenticated(): + logger.warning("Qobuz not authenticated — cannot list favorite tracks") + return [] + + tracks: List[Dict[str, Any]] = [] + offset = 0 + while len(tracks) < limit: + page_size = min(self._PLAYLIST_PAGE_SIZE, limit - len(tracks)) + 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 + + 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/tests/test_qobuz_playlists.py b/tests/test_qobuz_playlists.py new file mode 100644 index 00000000..3a3a9bea --- /dev/null +++ b/tests/test_qobuz_playlists.py @@ -0,0 +1,402 @@ +"""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_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/web_server.py b/web_server.py index f5b817fb..b6f3ea6a 100644 --- a/web_server.py +++ b/web_server.py @@ -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"), @@ -21847,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 # =================================================================== diff --git a/webui/index.html b/webui/index.html index b22dd056..d590d158 100644 --- a/webui/index.html +++ b/webui/index.html @@ -943,6 +943,9 @@ + @@ -993,6 +996,17 @@ + +
+
+

Your Qobuz Playlists

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