Add Qobuz playlist sync to Sync page (#677)

Qobuz joins Tidal and Deezer as a first-class playlist sync source.
New Qobuz tab on the Sync page lists user playlists + a virtual
Favorite Tracks entry, and clicks route through the same discovery →
sync → download pipeline the other services already use.

Backend:
* core/qobuz_client.py — new get_user_playlists, get_playlist,
  get_user_favorite_tracks, get_user_favorite_tracks_count. Returns
  normalized dicts (matches Deezer client shape, not Tidal's
  dataclasses) so the discovery worker can iterate directly without
  duck-typing. Virtual `qobuz-favorites` ID dispatches to favorites
  fetcher inside get_playlist — same trick Tidal uses with
  COLLECTION_PLAYLIST_ID. Both list endpoints paginate against
  Qobuz's 500-cap limit.
* core/discovery/qobuz.py — new worker module. Mirrors
  core/discovery/deezer.py: pause enrichment, iterate tracks,
  hit discovery cache, fall back to _search_spotify_for_tidal_track,
  build wing-it stub on miss, sync results to mirrored playlist.
* web_server.py — adds /api/qobuz/playlists, /playlist/<id>,
  /discovery/start/<id>, /discovery/status/<id>, /discovery/update_match,
  /playlists/states, /state/<id>, /reset/<id>, /delete/<id>,
  /update_phase/<id>, /sync/start/<id>, /sync/status/<id>,
  /sync/cancel/<id>. One-for-one with the Tidal + Deezer endpoint
  sets. Qobuz discovery executor registered for clean shutdown.

Frontend:
* webui/static/sync-services.js — full handler set (loadQobuzPlaylists,
  createQobuzCard, openQobuzDiscoveryModal, startQobuzDiscoveryPolling,
  startQobuzPlaylistSync, startQobuzSyncPolling, cancelQobuzSync,
  startQobuzDownloadMissing, rehydrateQobuzDownloadModal, etc.).
  Reuses the shared YouTube discovery modal via fake `qobuz_<id>`
  urlHash and is_qobuz_playlist flag. Shared switch statements in
  getModalActionButtons / generateTableRowsFromState / Wing It helpers
  in downloads.js gain new isQobuz branches alongside the existing
  per-service ones.
* webui/index.html — new Qobuz tab button + content div, slotted
  between Deezer and Deezer Link.
* webui/static/style.css — new .qobuz-icon for the tab icon.
* webui/static/core.js — qobuzPlaylists / qobuzPlaylistStates /
  qobuzPlaylistsLoaded globals.

Followed the existing per-service pattern verbatim rather than
refactoring the duplicated transformers across Tidal / Deezer /
Spotify-public / YouTube / Mirrored — that refactor is its own follow-up
PR per the "don't break Tidal/Deezer" scope discipline. Adding the 6th
copy of a proven pattern is lower risk than collapsing 5 working
services behind a new abstraction.

Tests:
* tests/test_qobuz_playlists.py — 12 tests covering pagination,
  normalization, favorites virtual-ID routing, artist-name fallback
  chain (performer → album.artist → 'Unknown Artist'), and
  unauthenticated short-circuits.
pull/685/head
Broque Thomas 1 day ago
parent eba7f61e04
commit a34eae1445

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

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

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

@ -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/<playlist_id>', 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/<playlist_id>', 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/<playlist_id>', 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/<playlist_id>', 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/<playlist_id>', 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/<playlist_id>', 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/<playlist_id>', 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/<playlist_id>', 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/<playlist_id>', 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/<playlist_id>', 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
# ===================================================================

@ -943,6 +943,9 @@
<button class="sync-tab-button" data-tab="deezer">
<span class="tab-icon deezer-icon"></span> Deezer
</button>
<button class="sync-tab-button" data-tab="qobuz">
<span class="tab-icon qobuz-icon"></span> Qobuz
</button>
<button class="sync-tab-button" data-tab="deezer-link">
<span class="tab-icon deezer-icon"></span> Deezer Link
</button>
@ -993,6 +996,17 @@
</div>
</div>
<!-- Qobuz Tab Content -->
<div class="sync-tab-content" id="qobuz-tab-content">
<div class="playlist-header">
<h3>Your Qobuz Playlists</h3>
<button class="refresh-button qobuz" id="qobuz-refresh-btn">🔄 Refresh</button>
</div>
<div class="playlist-scroll-container" id="qobuz-playlist-container">
<div class="playlist-placeholder">Click 'Refresh' to load your Qobuz playlists.</div>
</div>
</div>
<!-- Deezer Link Tab Content (URL Import) -->
<div class="sync-tab-content" id="deezer-link-tab-content">
<div class="youtube-input-section">

@ -67,6 +67,11 @@ let deezerPlaylistStates = {};
let deezerArlPlaylists = [];
let deezerArlPlaylistsLoaded = false;
// --- Qobuz Playlist State Management (mirrors Tidal — github issue #677) ---
let qobuzPlaylists = [];
let qobuzPlaylistStates = {}; // Key: playlist_id, Value: playlist state with phases
let qobuzPlaylistsLoaded = false;
// --- Beatport Chart State Management (Similar to YouTube/Tidal) ---
let beatportChartStates = {}; // Key: chart_hash, Value: chart state with phases
let beatportContentState = {

@ -64,10 +64,11 @@ function _wingItAction(urlHash, action) {
const tracks = state.tracks || state.rawTracks || state.playlist?.tracks || [];
const name = state.playlistName || state.name || state.playlist?.name || 'Playlist';
const isTidal = state.is_tidal_playlist;
const isQobuz = state.is_qobuz_playlist;
const isLB = state.is_listenbrainz_playlist;
const isBeatport = state.is_beatport_playlist;
const isDeezer = state.is_deezer_playlist;
const source = isLB ? 'ListenBrainz' : isTidal ? 'Tidal' : isDeezer ? 'Deezer' : isBeatport ? 'Beatport' : 'YouTube';
const source = isLB ? 'ListenBrainz' : isTidal ? 'Tidal' : isQobuz ? 'Qobuz' : isDeezer ? 'Deezer' : isBeatport ? 'Beatport' : 'YouTube';
if (!tracks.length) {
showToast('No tracks available for Wing It', 'error');
@ -347,10 +348,11 @@ async function _wingItFromModal(urlHash) {
const tracks = state.tracks || state.rawTracks || state.playlist?.tracks || [];
const name = state.playlistName || state.name || state.playlist?.name || 'Playlist';
const isTidal = state.is_tidal_playlist;
const isQobuz = state.is_qobuz_playlist;
const isLB = state.is_listenbrainz_playlist;
const isBeatport = state.is_beatport_playlist;
const isDeezer = state.is_deezer_playlist;
const source = isLB ? 'ListenBrainz' : isTidal ? 'Tidal' : isDeezer ? 'Deezer' : isBeatport ? 'Beatport' : 'YouTube';
const source = isLB ? 'ListenBrainz' : isTidal ? 'Tidal' : isQobuz ? 'Qobuz' : isDeezer ? 'Deezer' : isBeatport ? 'Beatport' : 'YouTube';
if (!tracks.length) {
showToast('No tracks available for Wing It', 'error');

@ -3415,6 +3415,7 @@ function closeHelperSearch() {
const WHATS_NEW = {
'2.5.9': [
{ date: 'Unreleased — dev cycle' },
{ title: 'Qobuz playlist sync', desc: 'new Qobuz tab on the Sync page. Connect Qobuz in Settings → Connections, hit Refresh on the tab, and your Qobuz playlists + Favorite Tracks show up alongside Tidal and Deezer. clicks run the same discovery → sync → download flow as the other sources.', page: 'sync' },
{ title: 'Import search: show when results came from the fallback source', desc: 'if you picked MusicBrainz (or Discogs / iTunes / etc.) as your primary metadata source but the Import album search ended up serving Deezer cards, you had no idea — the chain silently fell through when the primary returned nothing. now each card shows a small "via Deezer" label when the source differs from your primary, and a banner above the grid spells it out when all results came from the fallback. backend behavior unchanged.' },
{ date: 'May 21, 2026 — 2.5.9 release' },
{ title: 'Now-playing modal: lyrics panel', desc: 'new lyrics panel below the player controls in the expanded now-playing modal. fetches from LRClib via /api/lyrics/fetch, but prefers the local .lrc / .txt sidecar files SoulSync drops next to your audio during post-processing so downloaded tracks show lyrics instantly with zero network. synced LRC (timestamped) highlights the active line and auto-scrolls it into the middle of the viewport on every audio timeupdate; plain text renders without highlighting. status chip shows whether the result came back Synced or Plain. panel is collapsed by default — click the Lyrics header to expand. cached per track so revisiting a track doesn\'t refetch.' },
@ -3495,6 +3496,17 @@ const WHATS_NEW = {
// Section shape: { title, description, features: [bullet strings],
// usage_note?: 'optional hint shown at the bottom' }
const VERSION_MODAL_SECTIONS = [
{
title: "Qobuz Playlist Sync",
description: "Qobuz joins Tidal and Deezer as a first-class playlist sync source on the Sync page. Browse your Qobuz playlists and Favorite Tracks, run them through the same discovery flow as Tidal, sync the resulting Spotify-matched tracks, and queue downloads — same multi-step pipeline you already know.",
features: [
"new Qobuz tab on the Sync page, listed between Deezer and Deezer Link",
"lists your Qobuz user playlists plus a Favorite Tracks entry (same virtual-playlist treatment Tidal gets)",
"click any card to fire discovery (Spotify-preferred, your primary metadata fallback otherwise), then sync or download just like Tidal / Deezer playlists",
"uses the Qobuz auth token you already configured for downloads — no extra connection step",
],
usage_note: "Sync → Qobuz → 🔄 Refresh",
},
{
title: "2.5.9 Release Stability Pass",
description: "this release ties together the new release-based download sources and a set of fixes from real user reports: HiFi instance detection, Jellyfin full refreshes, transient SQLite disk I/O failures, and wrong-artist Album Completeness fills.",

@ -11453,6 +11453,10 @@ body.helper-mode-active #dashboard-activity-feed:hover {
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23ff0000"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>');
}
.qobuz-icon {
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23ffffff"><circle cx="12" cy="12" r="9.5" fill="none" stroke="%23ffffff" stroke-width="1.8"/><path d="M16.5 16.5l3 3" stroke="%23ffffff" stroke-width="1.8" stroke-linecap="round"/></svg>');
}
.beatport-icon {
background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%2301FF95"><path d="M2 6h20v2H2zm0 5h20v2H2zm0 5h20v2H2z"/></svg>');
}

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save