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.
This commit is contained in:
parent
eba7f61e04
commit
a34eae1445
10 changed files with 2625 additions and 30 deletions
299
core/discovery/qobuz.py
Normal file
299
core/discovery/qobuz.py
Normal file
|
|
@ -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).
|
||||
|
|
|
|||
402
tests/test_qobuz_playlists.py
Normal file
402
tests/test_qobuz_playlists.py
Normal file
|
|
@ -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']
|
||||
650
web_server.py
650
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/<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…
Reference in a new issue