diff --git a/core/automation/deps.py b/core/automation/deps.py index 5811766e..08d73d47 100644 --- a/core/automation/deps.py +++ b/core/automation/deps.py @@ -152,3 +152,10 @@ class AutomationDeps: # PersonalizedPlaylistManager per run (cheap accessors inside, # no caching needed yet). build_personalized_manager: Callable[[], Any] + + # --- Unified PlaylistSource registry --- + # Optional so test fixtures that don't exercise refresh_mirrored + # can keep their existing scaffolding. Production wiring in + # ``web_server.py`` always populates it via + # ``core.playlists.sources.bootstrap.build_playlist_source_registry``. + playlist_source_registry: Optional[Any] = None diff --git a/core/automation/handlers/refresh_mirrored.py b/core/automation/handlers/refresh_mirrored.py index 02e546a8..98418c8a 100644 --- a/core/automation/handlers/refresh_mirrored.py +++ b/core/automation/handlers/refresh_mirrored.py @@ -1,24 +1,43 @@ """Automation handler: ``refresh_mirrored`` action. -Lifted from ``web_server._register_automation_handlers`` (the -``_auto_refresh_mirrored`` closure). Re-pulls track lists from each -mirrored playlist's source (Spotify / Tidal / Deezer / YouTube), -updates the local mirror DB, and emits a ``playlist_changed`` -automation event when the track set actually shifts. +Re-pulls track lists from each mirrored playlist's source via the +unified ``PlaylistSourceRegistry`` (Phase 1 of the Discover-to-Sync +unification). The pre-extraction handler had ~190 lines of per-source +if/elif branches; this version delegates to the adapter for each +source, leaving the handler responsible only for: -Source-specific branches (Spotify auth + public-embed fallback, -``spotify_public`` URL→ID resolution, Deezer / Tidal / YouTube) -remain identical to the pre-extraction closure — this is a -mechanical lift, not a redesign. +- filtering sources that can't be refreshed (``file``, ``beatport``), +- extracting upstream URLs from the stored ``description`` for URL- + backed sources (``spotify_public``, ``youtube``), +- the Spotify-public → authenticated-Spotify fallback (uses the + ``spotify`` adapter when the user is signed in so the mirror keeps + album art), +- the Tidal-not-authenticated skip log type (vs error), +- preserving existing per-track ``extra_data`` on tracks that survive + the refresh, and +- emitting the ``playlist_changed`` automation event when the track + set actually shifts. """ from __future__ import annotations -import json -from typing import Any, Dict +from typing import Any, Dict, List, Optional from core.automation.deps import AutomationDeps from core.playlists.source_refs import require_refresh_url +from core.playlists.sources import PlaylistDetail, to_mirror_track_dict +from core.playlists.sources.base import ( + SOURCE_SPOTIFY, + SOURCE_SPOTIFY_PUBLIC, + SOURCE_TIDAL, + SOURCE_YOUTUBE, +) + + +# Sources that store the upstream URL in ``description`` (because their +# ``source_playlist_id`` is a deterministic hash, not the native ID). +# The refresh path has to recover the URL before calling the adapter. +_URL_BACKED_SOURCES = {SOURCE_SPOTIFY_PUBLIC, SOURCE_YOUTUBE} def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]: @@ -45,7 +64,7 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[ playlists = [pl for pl in playlists if pl.get('source', '') not in ('file', 'beatport')] refreshed = 0 - errors = [] + errors: List[str] = [] for idx, pl in enumerate(playlists): try: source = pl.get('source', '') @@ -56,201 +75,12 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[ phase=f'Refreshing: "{pl.get("name", "")}"', current_item=pl.get('name', ''), ) - tracks = None - if source == 'spotify': - # Try authenticated API first, fall back to public embed scraper. - if deps.spotify_client and deps.spotify_client.is_spotify_authenticated(): - playlist_obj = deps.spotify_client.get_playlist_by_id(source_id) - if playlist_obj and playlist_obj.tracks: - tracks = [] - for t in playlist_obj.tracks: - artist_name = t.artists[0] if t.artists else '' - track_dict = { - 'track_name': t.name or '', - 'artist_name': str(artist_name), - 'album_name': t.album or '', - 'duration_ms': t.duration_ms or 0, - 'source_track_id': t.id or '', - } - # Spotify data IS official — auto-mark as discovered. - if t.id: - _album_obj = {'name': t.album or ''} - if getattr(t, 'image_url', None): - _album_obj['images'] = [{'url': t.image_url, 'height': 600, 'width': 600}] - track_dict['extra_data'] = json.dumps({ - 'discovered': True, - 'provider': 'spotify', - 'confidence': 1.0, - 'matched_data': { - 'id': t.id, - 'name': t.name or '', - 'artists': [{'name': str(a)} for a in (t.artists or [])], - 'album': _album_obj, - 'duration_ms': t.duration_ms or 0, - 'image_url': getattr(t, 'image_url', None), - } - }) - tracks.append(track_dict) - - # Fallback: public embed scraper (no auth needed). - if tracks is None: - try: - from core.spotify_public_scraper import scrape_spotify_embed - embed_data = scrape_spotify_embed('playlist', source_id) - if embed_data and not embed_data.get('error') and embed_data.get('tracks'): - embed_album = embed_data.get('name', '') if embed_data.get('type') == 'album' else '' - tracks = [] - for t in embed_data['tracks']: - artist_names = [a['name'] for a in t.get('artists', [])] - artist_name = artist_names[0] if artist_names else '' - track_dict = { - 'track_name': t.get('name', ''), - 'artist_name': artist_name, - 'album_name': embed_album, - 'duration_ms': t.get('duration_ms', 0), - 'source_track_id': t.get('id', ''), - } - # Store Spotify track ID hint but don't mark discovered — - # Discover step needs to run for proper album art. - if t.get('id'): - track_dict['extra_data'] = json.dumps({ - 'discovered': False, - 'spotify_hint': { - 'id': t['id'], - 'name': t.get('name', ''), - 'artists': t.get('artists', []), - } - }) - tracks.append(track_dict) - except Exception as e: - deps.logger.warning(f"Spotify public scraper fallback failed for {source_id}: {e}") - - elif source == 'spotify_public': - # source_playlist_id is an MD5 hash; extract actual Spotify ID from stored description (URL). - try: - from core.spotify_public_scraper import parse_spotify_url, scrape_spotify_embed - spotify_url = require_refresh_url(source, pl.get('description', ''), pl.get('name', '')) - parsed = parse_spotify_url(spotify_url) if spotify_url else None - - # If Spotify is authenticated, use the full API (auto-discovers with album art). - if (parsed and parsed.get('type') == 'playlist' - and deps.spotify_client and deps.spotify_client.is_spotify_authenticated()): - playlist_obj = deps.spotify_client.get_playlist_by_id(parsed['id']) - if playlist_obj and playlist_obj.tracks: - tracks = [] - for t in playlist_obj.tracks: - artist_name = t.artists[0] if t.artists else '' - track_dict = { - 'track_name': t.name or '', - 'artist_name': str(artist_name), - 'album_name': t.album or '', - 'duration_ms': t.duration_ms or 0, - 'source_track_id': t.id or '', - } - if t.id: - _album_obj = {'name': t.album or ''} - if getattr(t, 'image_url', None): - _album_obj['images'] = [{'url': t.image_url, 'height': 600, 'width': 600}] - track_dict['extra_data'] = json.dumps({ - 'discovered': True, - 'provider': 'spotify', - 'confidence': 1.0, - 'matched_data': { - 'id': t.id, - 'name': t.name or '', - 'artists': [{'name': str(a)} for a in (t.artists or [])], - 'album': _album_obj, - 'duration_ms': t.duration_ms or 0, - 'image_url': getattr(t, 'image_url', None), - } - }) - tracks.append(track_dict) - - # Fallback: public embed scraper (no auth or album-type URL). - if tracks is None and parsed: - embed_data = scrape_spotify_embed(parsed['type'], parsed['id']) - if embed_data and not embed_data.get('error') and embed_data.get('tracks'): - embed_album = embed_data.get('name', '') if embed_data.get('type') == 'album' else '' - tracks = [] - for t in embed_data['tracks']: - artist_names = [a['name'] for a in t.get('artists', [])] - artist_name = artist_names[0] if artist_names else '' - tracks.append({ - 'track_name': t.get('name', ''), - 'artist_name': artist_name, - 'album_name': embed_album, - 'duration_ms': t.get('duration_ms', 0), - 'source_track_id': t.get('id', ''), - }) - # No extra_data — let preservation code keep existing discovery data. - except Exception as e: - deps.logger.warning(f"Spotify public playlist refresh failed for {source_id}: {e}") - errors.append(f"{pl.get('name', '?')}: {str(e)}") - deps.update_progress( - auto_id, - log_line=f'Refresh failed: "{pl.get("name", "")}" - {str(e)}', - log_type='error', - ) - continue - - elif source == 'deezer': - try: - deezer = deps.get_deezer_client() - playlist_data = deezer.get_playlist(source_id) - if playlist_data and playlist_data.get('tracks'): - tracks = [] - for t in playlist_data['tracks']: - artist_name = t['artists'][0] if t.get('artists') else '' - tracks.append({ - 'track_name': t.get('name', ''), - 'artist_name': str(artist_name), - 'album_name': t.get('album', ''), - 'duration_ms': t.get('duration_ms', 0), - 'source_track_id': str(t.get('id', '')), - }) - except Exception as e: - deps.logger.warning(f"Deezer playlist refresh failed for {source_id}: {e}") - - elif source == 'tidal': - if not deps.tidal_client or not deps.tidal_client.is_authenticated(): - deps.logger.warning(f"Tidal not authenticated — skipping refresh for '{pl.get('name', '')}'") - deps.update_progress( - auto_id, - log_line=f'Skipped "{pl.get("name", "")}" — Tidal not authenticated', - log_type='skip', - ) - continue - full_playlist = deps.tidal_client.get_playlist(source_id) - if full_playlist and full_playlist.tracks: - tracks = [] - for t in full_playlist.tracks: - artist_name = t.artists[0] if t.artists else '' - tracks.append({ - 'track_name': t.name or '', - 'artist_name': str(artist_name), - 'album_name': t.album or '', - 'duration_ms': t.duration_ms or 0, - 'source_track_id': t.id or '', - }) - - elif source == 'youtube': - # source_playlist_id is now a deterministic hash; use stored description (original URL) for refresh. - yt_url = require_refresh_url(source, pl.get('description', ''), pl.get('name', '')) - playlist_data = deps.parse_youtube_playlist(yt_url) - if playlist_data and playlist_data.get('tracks'): - tracks = [] - for t in playlist_data['tracks']: - artist_name = t['artists'][0] if t.get('artists') else '' - tracks.append({ - 'track_name': t.get('name', ''), - 'artist_name': str(artist_name), - 'album_name': '', - 'duration_ms': t.get('duration_ms', 0), - 'source_track_id': t.get('id', ''), - }) - - if tracks is None: + detail = _fetch_detail(source, source_id, pl, deps, auto_id) + if detail is None: + # _fetch_detail already logged the specific failure; + # mark the playlist as a generic refresh error so the + # automation result tally matches the legacy handler. errors.append(f"{pl.get('name', '?')}: no tracks returned from source") deps.update_progress( auto_id, @@ -259,63 +89,12 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[ ) continue - if tracks is not None: - # Compare old vs new track IDs to detect changes. - old_tracks = db.get_mirrored_playlist_tracks(pl['id']) if pl.get('id') else [] - old_ids = {t.get('source_track_id') for t in old_tracks if t.get('source_track_id')} - new_ids = {t.get('source_track_id') for t in tracks if t.get('source_track_id')} - - # Preserve existing discovery extra_data for tracks that still exist. - old_extra_map = db.get_mirrored_tracks_extra_data_map(pl['id']) if pl.get('id') else {} - for t in tracks: - sid = t.get('source_track_id', '') - if sid and sid in old_extra_map and 'extra_data' not in t: - t['extra_data'] = old_extra_map[sid] - - db.mirror_playlist( - source=source, - source_playlist_id=source_id, - name=pl['name'], - tracks=tracks, - profile_id=pl.get('profile_id', 1), - description=pl.get('description'), - owner=pl.get('owner'), - image_url=pl.get('image_url'), - ) - refreshed += 1 - - # Emit playlist_changed if tracks actually changed. - if old_ids != new_ids: - added_count = len(new_ids - old_ids) - removed_count = len(old_ids - new_ids) - deps.logger.info( - f"[AUTOMATION] Playlist changed: '{pl.get('name', '')}' — " - f"{added_count} added, {removed_count} removed (old={len(old_ids)}, new={len(new_ids)})" - ) - deps.update_progress( - auto_id, - log_line=f'"{pl.get("name", "")}" — {added_count} added, {removed_count} removed', - log_type='success', - ) - try: - if deps.engine: - deps.engine.emit('playlist_changed', { - 'playlist_name': pl.get('name', ''), - 'playlist_id': str(pl.get('id', '')), - 'old_count': str(len(old_ids)), - 'new_count': str(len(new_ids)), - 'added': str(added_count), - 'removed': str(removed_count), - }) - except Exception as e: - deps.logger.debug("playlist_synced automation emit failed: %s", e) - else: - deps.logger.warning(f"[AUTOMATION] No changes: '{pl.get('name', '')}' (tracks={len(old_ids)})") - deps.update_progress( - auto_id, - log_line=f'No changes: "{pl.get("name", "")}"', - log_type='skip', - ) + tracks = [to_mirror_track_dict(t) for t in detail.tracks] + refreshed += _commit_refresh(pl, source, source_id, tracks, db, deps, auto_id) + except _SkipPlaylist: + # Source-specific soft-skip (e.g. Tidal not authenticated). + # Logging was already emitted; do not count as error. + continue except Exception as e: errors.append(f"{pl.get('name', '?')}: {str(e)}") deps.update_progress( @@ -324,3 +103,178 @@ def auto_refresh_mirrored(config: Dict[str, Any], deps: AutomationDeps) -> Dict[ log_type='error', ) return {'status': 'completed', 'refreshed': str(refreshed), 'errors': str(len(errors))} + + +class _SkipPlaylist(Exception): + """Internal sentinel: source-specific soft-skip (e.g. not authed). + + The per-playlist loop catches it specifically so the skip isn't + counted in the error tally — matches the pre-extraction behavior + where ``continue`` was used inline.""" + + +def _fetch_detail( + source: str, + source_id: str, + pl: Dict[str, Any], + deps: AutomationDeps, + auto_id: Optional[str], +) -> Optional[PlaylistDetail]: + """Resolve the playlist's tracks through the registry. + + Handler-level branches (URL extraction, Spotify-public→authed + fallback, Tidal not-authed skip) live here; everything else + delegates to the adapter.""" + registry = deps.playlist_source_registry + if registry is None: + return None + + # URL-backed sources: pull the upstream URL out of `description`. + playlist_input = source_id + if source in _URL_BACKED_SOURCES: + # ``require_refresh_url`` raises ValueError on missing URL. + # The outer try/except in the loop catches it and reports as + # an error — matching the pre-extraction behavior. + playlist_input = require_refresh_url( + source, pl.get('description', ''), pl.get('name', '') + ) + + # Spotify-public refresh: prefer the authenticated Spotify API + # when the user is signed in. Better album art, matches the + # pre-extraction handler. Falls through to the public scraper on + # auth failure or non-playlist URL types (e.g. album URLs). + if source == SOURCE_SPOTIFY_PUBLIC: + detail = _try_spotify_authed_for_public(playlist_input, deps) + if detail is not None: + return detail + + # Tidal not-authed: soft-skip with a 'skip' log line, not an error. + if source == SOURCE_TIDAL: + tidal_source = registry.get_source(SOURCE_TIDAL) + if tidal_source is None or not tidal_source.is_authenticated(): + deps.logger.warning( + f"Tidal not authenticated — skipping refresh for '{pl.get('name', '')}'" + ) + deps.update_progress( + auto_id, + log_line=f'Skipped "{pl.get("name", "")}" — Tidal not authenticated', + log_type='skip', + ) + raise _SkipPlaylist + + adapter = registry.get_source(source) + if adapter is None: + return None + try: + return adapter.refresh_playlist(playlist_input) + except Exception as exc: + deps.logger.warning( + f"{source} playlist refresh failed for {playlist_input}: {exc}" + ) + return None + + +def _try_spotify_authed_for_public( + spotify_url: str, deps: AutomationDeps +) -> Optional[PlaylistDetail]: + """Best-effort: use the authenticated Spotify adapter on a public URL. + + Returns ``None`` to signal "fall through to the public-scraper + adapter" — never raises. Only applies to ``playlist``-type URLs; + album URLs fall through unconditionally.""" + if not spotify_url: + return None + spotify_client = deps.spotify_client + if spotify_client is None or not spotify_client.is_spotify_authenticated(): + return None + try: + from core.spotify_public_scraper import parse_spotify_url + parsed = parse_spotify_url(spotify_url) + except Exception: + return None + if not parsed or parsed.get('type') != 'playlist': + return None + adapter = deps.playlist_source_registry.get_source(SOURCE_SPOTIFY) + if adapter is None: + return None + try: + return adapter.refresh_playlist(parsed['id']) + except Exception as exc: + deps.logger.debug(f"Spotify authed fallback for public mirror failed: {exc}") + return None + + +def _commit_refresh( + pl: Dict[str, Any], + source: str, + source_id: str, + tracks: List[Dict[str, Any]], + db: Any, + deps: AutomationDeps, + auto_id: Optional[str], +) -> int: + """Persist the refreshed track list + emit playlist_changed when delta. + + Returns 1 when a refresh successfully landed, 0 otherwise. The + caller is responsible for incrementing the running tally.""" + old_tracks = db.get_mirrored_playlist_tracks(pl['id']) if pl.get('id') else [] + old_ids = {t.get('source_track_id') for t in old_tracks if t.get('source_track_id')} + new_ids = {t.get('source_track_id') for t in tracks if t.get('source_track_id')} + + # Preserve existing extra_data (matched_data + discovery state) + # for tracks that still exist in the refreshed snapshot, unless + # the adapter already provided fresh extra_data for that track. + old_extra_map = ( + db.get_mirrored_tracks_extra_data_map(pl['id']) if pl.get('id') else {} + ) + for t in tracks: + sid = t.get('source_track_id', '') + if sid and sid in old_extra_map and 'extra_data' not in t: + t['extra_data'] = old_extra_map[sid] + + db.mirror_playlist( + source=source, + source_playlist_id=source_id, + name=pl['name'], + tracks=tracks, + profile_id=pl.get('profile_id', 1), + description=pl.get('description'), + owner=pl.get('owner'), + image_url=pl.get('image_url'), + ) + + if old_ids != new_ids: + added = len(new_ids - old_ids) + removed = len(old_ids - new_ids) + deps.logger.info( + f"[AUTOMATION] Playlist changed: '{pl.get('name', '')}' — " + f"{added} added, {removed} removed (old={len(old_ids)}, new={len(new_ids)})" + ) + deps.update_progress( + auto_id, + log_line=f'"{pl.get("name", "")}" — {added} added, {removed} removed', + log_type='success', + ) + try: + if deps.engine: + deps.engine.emit('playlist_changed', { + 'playlist_name': pl.get('name', ''), + 'playlist_id': str(pl.get('id', '')), + 'old_count': str(len(old_ids)), + 'new_count': str(len(new_ids)), + 'added': str(added), + 'removed': str(removed), + }) + except Exception as e: + deps.logger.debug("playlist_synced automation emit failed: %s", e) + else: + deps.logger.warning( + f"[AUTOMATION] No changes: '{pl.get('name', '')}' (tracks={len(old_ids)})" + ) + deps.update_progress( + auto_id, + log_line=f'No changes: "{pl.get("name", "")}"', + log_type='skip', + ) + + return 1 diff --git a/core/playlists/sources/__init__.py b/core/playlists/sources/__init__.py index a087f7d3..d1be7ecc 100644 --- a/core/playlists/sources/__init__.py +++ b/core/playlists/sources/__init__.py @@ -16,6 +16,7 @@ from core.playlists.sources.base import ( PlaylistMeta, PlaylistSource, NormalizedTrack, + to_mirror_track_dict, ) from core.playlists.sources.registry import ( PlaylistSourceRegistry, @@ -29,4 +30,5 @@ __all__ = [ "NormalizedTrack", "PlaylistSourceRegistry", "get_registry", + "to_mirror_track_dict", ] diff --git a/core/playlists/sources/base.py b/core/playlists/sources/base.py index fb39aa24..1de22e4c 100644 --- a/core/playlists/sources/base.py +++ b/core/playlists/sources/base.py @@ -33,6 +33,7 @@ from typing import Any, Dict, List, Optional, Protocol, runtime_checkable # create a new "source". SOURCE_SPOTIFY = "spotify" SOURCE_SPOTIFY_PUBLIC = "spotify_public" +SOURCE_DEEZER = "deezer" SOURCE_TIDAL = "tidal" SOURCE_QOBUZ = "qobuz" SOURCE_YOUTUBE = "youtube" @@ -44,6 +45,7 @@ SOURCE_SOULSYNC_DISCOVERY = "soulsync_discovery" ALL_SOURCES = ( SOURCE_SPOTIFY, SOURCE_SPOTIFY_PUBLIC, + SOURCE_DEEZER, SOURCE_TIDAL, SOURCE_QOBUZ, SOURCE_YOUTUBE, @@ -149,3 +151,67 @@ class PlaylistSource(Protocol): Default behavior is identical to ``get_playlist``. Sources whose refresh has side effects (e.g. ListenBrainz cache update, SoulSync Discovery regeneration) override this.""" + + +# ─── projection helpers ──────────────────────────────────────────────── +# +# Adapters return NormalizedTrack objects; the mirrored-playlist DB +# writer (``MusicDatabase.mirror_playlist``) accepts a list of dicts +# with a specific shape. ``to_mirror_track_dict`` is the single, +# tested projection between the two — kept here (not in the handler) +# so every caller that writes mirrored tracks uses the same mapping. + + +import json as _json + + +def to_mirror_track_dict(track: NormalizedTrack) -> Dict[str, Any]: + """Project a NormalizedTrack into the shape ``mirror_playlist`` expects. + + Adapter conventions consumed: + + - ``track.extra['discovered']`` (bool) — when True, the adapter has + enough metadata to skip the discovery worker and write a fully- + populated ``matched_data`` block straight into ``extra_data``. + Spotify's authenticated API path sets this. + - ``track.extra['provider']`` (str) — provider name to record on + the matched_data block (e.g. 'spotify'). + - ``track.extra['confidence']`` (float) — 0..1 match confidence; + defaults to 1.0 when ``discovered`` is True. + - ``track.extra['matched_data']`` (dict) — pre-built matched_data + payload. Overrides the auto-derived payload below. + - ``track.extra['spotify_hint']`` (dict) — public-embed scraper + path: the Spotify track ID + artists hint that lets the + discovery worker skip its search and go straight to enrichment. + + When none of the above are present, the result has only the core + fields and no ``extra_data`` — the discovery worker handles the + track from scratch. + """ + result: Dict[str, Any] = { + "track_name": track.track_name or "", + "artist_name": track.artist_name or "", + "album_name": track.album_name or "", + "duration_ms": int(track.duration_ms or 0), + "source_track_id": track.source_track_id or "", + } + + extra = track.extra or {} + matched_data = extra.get("matched_data") + is_discovered = bool(extra.get("discovered")) + spotify_hint = extra.get("spotify_hint") + + if is_discovered and matched_data: + result["extra_data"] = _json.dumps({ + "discovered": True, + "provider": extra.get("provider") or "unknown", + "confidence": float(extra.get("confidence", 1.0)), + "matched_data": matched_data, + }) + elif spotify_hint: + result["extra_data"] = _json.dumps({ + "discovered": False, + "spotify_hint": spotify_hint, + }) + + return result diff --git a/core/playlists/sources/bootstrap.py b/core/playlists/sources/bootstrap.py new file mode 100644 index 00000000..0b3b0855 --- /dev/null +++ b/core/playlists/sources/bootstrap.py @@ -0,0 +1,97 @@ +"""Helper for constructing + populating a PlaylistSourceRegistry. + +Both ``web_server.py`` (at app boot) and the automation test fixtures +build a registry the same way: take the client / parser / manager +getters that already exist as module globals, wire them into the +adapter constructors, register each adapter under its canonical name. + +This module owns that wiring so the two call sites can't drift. +""" + +from __future__ import annotations + +from typing import Any, Callable, Optional + +from core.playlists.sources.base import ( + SOURCE_DEEZER, + SOURCE_ITUNES_LINK, + SOURCE_LASTFM, + SOURCE_LISTENBRAINZ, + SOURCE_QOBUZ, + SOURCE_SOULSYNC_DISCOVERY, + SOURCE_SPOTIFY, + SOURCE_SPOTIFY_PUBLIC, + SOURCE_TIDAL, + SOURCE_YOUTUBE, +) +from core.playlists.sources.deezer import DeezerPlaylistSource +from core.playlists.sources.itunes_link import ITunesLinkPlaylistSource +from core.playlists.sources.lastfm import LastFMPlaylistSource +from core.playlists.sources.listenbrainz import ListenBrainzPlaylistSource +from core.playlists.sources.qobuz import QobuzPlaylistSource +from core.playlists.sources.registry import PlaylistSourceRegistry +from core.playlists.sources.soulsync_discovery import ( + SoulSyncDiscoveryPlaylistSource, +) +from core.playlists.sources.spotify import SpotifyPlaylistSource +from core.playlists.sources.spotify_public import SpotifyPublicPlaylistSource +from core.playlists.sources.tidal import TidalPlaylistSource +from core.playlists.sources.youtube import YouTubePlaylistSource + + +def build_playlist_source_registry( + *, + spotify_client_getter: Callable[[], Any], + tidal_client_getter: Callable[[], Any], + qobuz_client_getter: Callable[[], Any], + deezer_client_getter: Callable[[], Any], + itunes_link_parser: Optional[Callable[[str], Optional[dict]]] = None, + youtube_parser: Optional[Callable[[str], Optional[dict]]] = None, + listenbrainz_manager_getter: Optional[Callable[[], Any]] = None, + lastfm_manager_getter: Optional[Callable[[], Any]] = None, + personalized_manager_getter: Optional[Callable[[], Any]] = None, + profile_id_getter: Optional[Callable[[], int]] = None, +) -> PlaylistSourceRegistry: + """Build a fresh registry with every default adapter registered. + + Each parameter is the getter the corresponding adapter needs. Pass + ``lambda: None`` (or omit) for sources you don't want to expose — + the adapter will simply degrade to empty results when its backing + client is None / its parser is unset. + """ + reg = PlaylistSourceRegistry() + + reg.register(SOURCE_SPOTIFY, lambda: SpotifyPlaylistSource(spotify_client_getter)) + reg.register(SOURCE_SPOTIFY_PUBLIC, lambda: SpotifyPublicPlaylistSource()) + reg.register(SOURCE_DEEZER, lambda: DeezerPlaylistSource(deezer_client_getter)) + reg.register(SOURCE_TIDAL, lambda: TidalPlaylistSource(tidal_client_getter)) + reg.register(SOURCE_QOBUZ, lambda: QobuzPlaylistSource(qobuz_client_getter)) + + _no_url_parser = lambda url: None + reg.register( + SOURCE_YOUTUBE, + lambda: YouTubePlaylistSource(youtube_parser or _no_url_parser), + ) + reg.register( + SOURCE_ITUNES_LINK, + lambda: ITunesLinkPlaylistSource(itunes_link_parser or _no_url_parser), + ) + + _no_manager = lambda: None + reg.register( + SOURCE_LISTENBRAINZ, + lambda: ListenBrainzPlaylistSource(listenbrainz_manager_getter or _no_manager), + ) + reg.register( + SOURCE_LASTFM, + lambda: LastFMPlaylistSource(lastfm_manager_getter or _no_manager), + ) + reg.register( + SOURCE_SOULSYNC_DISCOVERY, + lambda: SoulSyncDiscoveryPlaylistSource( + personalized_manager_getter or _no_manager, + profile_id_getter=profile_id_getter, + ), + ) + + return reg diff --git a/core/playlists/sources/deezer.py b/core/playlists/sources/deezer.py new file mode 100644 index 00000000..5d8fae1b --- /dev/null +++ b/core/playlists/sources/deezer.py @@ -0,0 +1,108 @@ +"""Deezer playlist source adapter. + +Wraps ``core.deezer_client.DeezerClient.get_playlist``. Deezer's public +API needs no auth, so ``is_authenticated`` always returns True. Listing +the *user's* playlists requires OAuth — surfaced via the underlying +``is_user_authenticated`` flag — but the get-by-id flow works on any +public playlist regardless. +""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, List, Optional + +from core.playlists.sources.base import ( + NormalizedTrack, + PlaylistDetail, + PlaylistMeta, + PlaylistSource, + SOURCE_DEEZER, +) + + +class DeezerPlaylistSource(PlaylistSource): + name = SOURCE_DEEZER + supports_listing = True # user playlists need OAuth; falls back to [] + supports_refresh = True + requires_auth = False + + def __init__(self, client_getter: Callable[[], Any]): + self._client_getter = client_getter + + def _client(self): + return self._client_getter() + + def is_authenticated(self) -> bool: + client = self._client() + if client is None: + return False + # Deezer's `is_authenticated` is True even with no OAuth token — + # the public API works without one. Use that as our liveness signal. + return bool(client.is_authenticated()) + + def list_playlists(self) -> List[PlaylistMeta]: + client = self._client() + if client is None: + return [] + # User playlists need OAuth; `get_user_playlists` returns [] when + # the stub-interface variant is in use. Honor whatever the client + # actually returns. + try: + playlists = client.get_user_playlists() or [] + except Exception: + return [] + return [self._meta_from_playlist(p) for p in playlists] + + def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + client = self._client() + if client is None: + return None + data = client.get_playlist(playlist_id) + if not data: + return None + meta = self._meta_from_dict(data) + tracks_raw = data.get("tracks") or [] + tracks = [self._track_from_dict(t, idx) for idx, t in enumerate(tracks_raw)] + meta.track_count = len(tracks) + return PlaylistDetail(meta=meta, tracks=tracks) + + def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: + return self.get_playlist(playlist_id) + + # ---- projection helpers ------------------------------------------------ + + def _meta_from_playlist(self, playlist: Any) -> PlaylistMeta: + """Project a ``DeezerClient.Playlist`` dataclass into PlaylistMeta.""" + return PlaylistMeta( + source=self.name, + source_playlist_id=str(getattr(playlist, "id", "")), + name=getattr(playlist, "name", "Deezer Playlist"), + description=getattr(playlist, "description", None), + track_count=int(getattr(playlist, "total_tracks", 0) or 0), + owner=getattr(playlist, "owner", None), + ) + + def _meta_from_dict(self, p: Dict[str, Any]) -> PlaylistMeta: + return PlaylistMeta( + source=self.name, + source_playlist_id=str(p.get("id", "")), + name=p.get("name", "Deezer Playlist"), + description=p.get("description") or None, + owner=p.get("owner") or None, + image_url=p.get("image_url") or None, + track_count=int(p.get("track_count", 0) or 0), + ) + + def _track_from_dict(self, t: Dict[str, Any], position: int) -> NormalizedTrack: + artists = t.get("artists") or [] + artist_name = artists[0] if artists else "Unknown Artist" + return NormalizedTrack( + position=position, + track_name=t.get("name", "Unknown Track"), + artist_name=artist_name, + album_name=t.get("album") or None, + duration_ms=int(t.get("duration_ms", 0) or 0), + source_track_id=str(t.get("id", "")), + needs_discovery=False, + extra={"track_number": t.get("track_number")}, + ) diff --git a/core/playlists/sources/itunes_link.py b/core/playlists/sources/itunes_link.py index 0f663165..89271d9d 100644 --- a/core/playlists/sources/itunes_link.py +++ b/core/playlists/sources/itunes_link.py @@ -80,9 +80,11 @@ class ITunesLinkPlaylistSource(PlaylistSource): def _track_from_itunes(self, track: dict, position: int) -> NormalizedTrack: artists = track.get("artists") or [] if artists and isinstance(artists[0], dict): - artist_name = ", ".join(a.get("name", "") for a in artists if a.get("name")) + artist_name = artists[0].get("name", "") or "" + elif artists: + artist_name = str(artists[0]) else: - artist_name = ", ".join(str(a) for a in artists if a) + artist_name = "" if not artist_name: artist_name = "Unknown Artist" album = track.get("album") diff --git a/core/playlists/sources/qobuz.py b/core/playlists/sources/qobuz.py index 999d1a3b..acc79623 100644 --- a/core/playlists/sources/qobuz.py +++ b/core/playlists/sources/qobuz.py @@ -78,7 +78,7 @@ class QobuzPlaylistSource(PlaylistSource): def _track_from_dict(self, t: Dict[str, Any], position: int) -> NormalizedTrack: artists = t.get("artists") or [] - artist_name = ", ".join(artists) if artists else "Unknown Artist" + artist_name = artists[0] if artists else "Unknown Artist" return NormalizedTrack( position=position, track_name=t.get("name", "Unknown Track"), diff --git a/core/playlists/sources/spotify.py b/core/playlists/sources/spotify.py index ccbf4bfd..78bf8e67 100644 --- a/core/playlists/sources/spotify.py +++ b/core/playlists/sources/spotify.py @@ -7,7 +7,7 @@ client used everywhere else. Adapter projects Spotify's ``Playlist`` / from __future__ import annotations -from typing import Any, Callable, List, Optional +from typing import Any, Callable, Dict, List, Optional from core.playlists.sources.base import ( NormalizedTrack, @@ -39,18 +39,25 @@ class SpotifyPlaylistSource(PlaylistSource): client = self._client() if client is None: return False - return bool(client.is_authenticated()) + # ``is_spotify_authenticated`` is the Spotify-specific check; + # ``is_authenticated`` on SpotifyClient is a metadata-aware + # superset that returns True even when only the iTunes + # fallback is available. The adapter needs the strict check + # because it calls Spotify-only endpoints (get_user_playlists, + # get_playlist_by_id). + check = getattr(client, "is_spotify_authenticated", None) or client.is_authenticated + return bool(check()) def list_playlists(self) -> List[PlaylistMeta]: client = self._client() - if client is None or not client.is_authenticated(): + if client is None or not self.is_authenticated(): return [] playlists = client.get_user_playlists_metadata_only() return [self._meta_from_playlist(p) for p in playlists] def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]: client = self._client() - if client is None or not client.is_authenticated(): + if client is None or not self.is_authenticated(): return None playlist = client.get_playlist_by_id(playlist_id) if playlist is None: @@ -81,19 +88,50 @@ class SpotifyPlaylistSource(PlaylistSource): def _track_from_spotify(self, track: Any, position: int) -> NormalizedTrack: artists = getattr(track, "artists", None) or [] - artist_name = ", ".join(artists) if artists else "Unknown Artist" + artist_name = artists[0] if artists else "Unknown Artist" + track_id = str(track.id) if getattr(track, "id", None) else "" + track_name = track.name or "" + album_name = getattr(track, "album", "") or "" + duration_ms = int(getattr(track, "duration_ms", 0) or 0) + image_url = getattr(track, "image_url", None) + + # Spotify's authenticated API IS canonical metadata — populate + # the discovered/matched_data block so to_mirror_track_dict emits + # the same extra_data shape downstream consumers (sync, wishlist) + # already expect from this path. + extra: Dict[str, Any] = { + "popularity": getattr(track, "popularity", 0), + "external_urls": getattr(track, "external_urls", None), + "preview_url": getattr(track, "preview_url", None), + } + if track_id: + album_obj: Dict[str, Any] = {"name": album_name} + if image_url: + album_obj["images"] = [{ + "url": image_url, + "height": 600, + "width": 600, + }] + extra["discovered"] = True + extra["provider"] = "spotify" + extra["confidence"] = 1.0 + extra["matched_data"] = { + "id": track_id, + "name": track_name, + "artists": [{"name": str(a)} for a in artists], + "album": album_obj, + "duration_ms": duration_ms, + "image_url": image_url, + } + return NormalizedTrack( position=position, - track_name=track.name, - artist_name=artist_name, - album_name=getattr(track, "album", None), - duration_ms=int(getattr(track, "duration_ms", 0) or 0), - source_track_id=str(track.id), - image_url=getattr(track, "image_url", None), + track_name=track_name, + artist_name=str(artist_name), + album_name=album_name or None, + duration_ms=duration_ms, + source_track_id=track_id, + image_url=image_url, needs_discovery=False, - extra={ - "popularity": getattr(track, "popularity", 0), - "external_urls": getattr(track, "external_urls", None), - "preview_url": getattr(track, "preview_url", None), - }, + extra=extra, ) diff --git a/core/playlists/sources/spotify_public.py b/core/playlists/sources/spotify_public.py index 0b9cec98..51189687 100644 --- a/core/playlists/sources/spotify_public.py +++ b/core/playlists/sources/spotify_public.py @@ -8,7 +8,7 @@ library" to enumerate; the user pastes a URL and the adapter fetches. from __future__ import annotations import hashlib -from typing import List, Optional +from typing import Any, Dict, List, Optional from core.playlists.sources.base import ( NormalizedTrack, @@ -77,19 +77,38 @@ class SpotifyPublicPlaylistSource(PlaylistSource): def _track_from_embed(self, track: dict, position: int) -> NormalizedTrack: artists = track.get("artists") or [] - artist_name = ", ".join( - a.get("name", "") for a in artists if isinstance(a, dict) - ) or "Unknown Artist" + if artists and isinstance(artists[0], dict): + artist_name = artists[0].get("name", "") or "Unknown Artist" + elif artists: + artist_name = str(artists[0]) + else: + artist_name = "Unknown Artist" + track_id = str(track.get("id", "")) + track_name = track.get("name", "") + + # Public-embed data isn't canonical (no album art in the embed + # response), so we DON'T set ``discovered=True``. Instead, plant + # a ``spotify_hint`` so the downstream discovery worker can skip + # its search step and go straight to Spotify enrichment for the + # known track ID. Matches the pre-extraction handler behavior. + extra: Dict[str, Any] = { + "explicit": bool(track.get("is_explicit", False)), + "track_number": track.get("track_number"), + } + if track_id: + extra["spotify_hint"] = { + "id": track_id, + "name": track_name, + "artists": artists, + } + return NormalizedTrack( position=position, - track_name=track.get("name", "Unknown Track"), + track_name=track_name, artist_name=artist_name, album_name=None, duration_ms=int(track.get("duration_ms", 0) or 0), - source_track_id=str(track.get("id", "")), + source_track_id=track_id, needs_discovery=False, - extra={ - "explicit": bool(track.get("is_explicit", False)), - "track_number": track.get("track_number"), - }, + extra=extra, ) diff --git a/core/playlists/sources/tidal.py b/core/playlists/sources/tidal.py index 7a74d423..b7dfff32 100644 --- a/core/playlists/sources/tidal.py +++ b/core/playlists/sources/tidal.py @@ -84,7 +84,9 @@ class TidalPlaylistSource(PlaylistSource): def _track_from_tidal(self, track: Any, position: int) -> NormalizedTrack: artists = getattr(track, "artists", None) or [] - artist_name = ", ".join(artists) if artists else "Unknown Artist" + # First artist only — matches the mirrored_playlist shape the + # legacy refresh_mirrored handler wrote. + artist_name = artists[0] if artists else "Unknown Artist" return NormalizedTrack( position=position, track_name=track.name, diff --git a/core/playlists/sources/youtube.py b/core/playlists/sources/youtube.py index fdf21c5d..e8b976f9 100644 --- a/core/playlists/sources/youtube.py +++ b/core/playlists/sources/youtube.py @@ -70,7 +70,7 @@ class YouTubePlaylistSource(PlaylistSource): def _track_from_yt(self, track: dict, position: int) -> NormalizedTrack: artists = track.get("artists") or [] - artist_name = ", ".join(artists) if artists else "Unknown Artist" + artist_name = artists[0] if artists else "Unknown Artist" return NormalizedTrack( position=position, track_name=track.get("name", "Unknown Track"), diff --git a/tests/automation/test_handlers_playlist.py b/tests/automation/test_handlers_playlist.py index 60442bea..02808d4b 100644 --- a/tests/automation/test_handlers_playlist.py +++ b/tests/automation/test_handlers_playlist.py @@ -30,6 +30,7 @@ from core.automation.handlers._pipeline_shared import run_sync_and_wishlist from core.automation.handlers.playlist_pipeline import auto_playlist_pipeline from core.automation.handlers.refresh_mirrored import auto_refresh_mirrored from core.automation.handlers.sync_playlist import auto_sync_playlist +from core.playlists.sources.bootstrap import build_playlist_source_registry # ─── shared scaffolding ────────────────────────────────────────────── @@ -74,6 +75,22 @@ class _StubDB: def _build_deps(**overrides) -> AutomationDeps: + # Build a default registry from whatever clients the test passed. + # The refresh_mirrored handler reads from deps.playlist_source_registry + # exclusively, so the registry must mirror the passed clients to + # preserve the pre-refactor test behavior. + _spotify = overrides.get('spotify_client') + _tidal = overrides.get('tidal_client') + _get_deezer = overrides.get('get_deezer_client', lambda: None) + _parse_youtube = overrides.get('parse_youtube_playlist', lambda url: None) + _registry = build_playlist_source_registry( + spotify_client_getter=lambda: _spotify, + tidal_client_getter=lambda: _tidal, + qobuz_client_getter=lambda: None, + deezer_client_getter=_get_deezer, + youtube_parser=_parse_youtube, + ) + defaults = dict( engine=object(), state=AutomationState(), @@ -94,6 +111,7 @@ def _build_deps(**overrides) -> AutomationDeps: load_sync_status_file=lambda: {}, get_deezer_client=lambda: None, parse_youtube_playlist=lambda url: None, + playlist_source_registry=_registry, get_sync_states=lambda: {}, set_db_update_automation_id=lambda v: None, get_db_update_state=lambda: {}, @@ -190,6 +208,17 @@ class _StubSpotifyTrack: @dataclass class _StubSpotifyPlaylist: tracks: list + # Adapter-side projection reads metadata fields off the playlist + # object (the real ``core.spotify_client.Playlist`` dataclass). + # Provide minimal defaults so the stub stays a one-liner at call + # sites that only care about tracks. + id: str = 'spot-id' + name: str = 'My Spot' + description: Optional[str] = '' + owner: str = 'me' + public: bool = True + collaborative: bool = False + total_tracks: int = 0 class _StubSpotifyClient: @@ -285,6 +314,163 @@ class TestRefreshMirrored: assert db.mirror_calls == [] assert any(p.get('log_type') == 'error' and 'missing its original source URL' in p.get('log_line', '') for p in progress) + def test_tidal_not_authenticated_emits_skip_not_error(self): + """Soft-skip preserves the legacy log_type='skip' contract — the + run still counts as completed with 0 errors so the automation + doesn't surface a Tidal-down condition as a refresh failure.""" + class _UnauthedTidal: + def is_authenticated(self): + return False + + db = _StubDB(playlists=[ + {'id': 7, 'name': 'My Tidal', 'source': 'tidal', + 'source_playlist_id': 'tid-id', 'profile_id': 1}, + ]) + progress = [] + deps = _build_deps( + get_database=lambda: db, + tidal_client=_UnauthedTidal(), + update_progress=lambda *a, **k: progress.append(k), + ) + + result = auto_refresh_mirrored({'playlist_id': '7'}, deps) + + assert result['status'] == 'completed' + assert result['refreshed'] == '0' + assert result['errors'] == '0' # skip, not error + assert db.mirror_calls == [] + assert any( + p.get('log_type') == 'skip' and 'Tidal not authenticated' in p.get('log_line', '') + for p in progress + ) + + def test_deezer_refresh_writes_plain_tracks_no_matched_data(self): + class _StubDeezer: + def is_authenticated(self): + return True + + def get_user_playlists(self): + return [] + + def get_playlist(self, playlist_id): + return { + 'id': playlist_id, + 'name': 'Deez', + 'description': '', + 'track_count': 1, + 'image_url': '', + 'owner': '', + 'tracks': [{ + 'id': 'dz1', + 'name': 'Track', + 'artists': ['Deez Artist'], + 'album': 'Deez Album', + 'duration_ms': 200_000, + }], + } + + db = _StubDB(playlists=[ + {'id': 9, 'name': 'Deez Mix', 'source': 'deezer', + 'source_playlist_id': 'dz-id', 'profile_id': 1}, + ]) + deps = _build_deps( + get_database=lambda: db, + get_deezer_client=lambda: _StubDeezer(), + ) + + result = auto_refresh_mirrored({'playlist_id': '9'}, deps) + + assert result['status'] == 'completed' + assert result['refreshed'] == '1' + assert len(db.mirror_calls) == 1 + call = db.mirror_calls[0] + assert call['source'] == 'deezer' + assert len(call['tracks']) == 1 + # Deezer tracks don't carry discovery state — no extra_data. + assert 'extra_data' not in call['tracks'][0] + assert call['tracks'][0]['source_track_id'] == 'dz1' + + def test_youtube_refresh_reads_url_from_description(self): + """URL-backed sources store the hash in source_playlist_id and + the canonical URL in description. The handler has to pull the + URL out before passing to the adapter.""" + parsed_calls = [] + + def _fake_parser(url): + parsed_calls.append(url) + return { + 'id': 'yt_pl', + 'name': 'YT Mix', + 'url': url, + 'track_count': 1, + 'tracks': [{ + 'id': 'vid1', + 'name': 'Track', + 'artists': ['Channel'], + 'duration_ms': 240_000, + }], + } + + db = _StubDB(playlists=[ + { + 'id': 11, + 'name': 'YT Mix', + 'source': 'youtube', + 'source_playlist_id': 'hashhash', + 'description': 'https://youtube.com/playlist?list=yt_pl', + 'profile_id': 1, + }, + ]) + deps = _build_deps( + get_database=lambda: db, + parse_youtube_playlist=_fake_parser, + ) + + result = auto_refresh_mirrored({'playlist_id': '11'}, deps) + + assert result['refreshed'] == '1' + # Parser was called with the URL from description, not the hash. + assert parsed_calls == ['https://youtube.com/playlist?list=yt_pl'] + assert db.mirror_calls[0]['tracks'][0]['source_track_id'] == 'vid1' + + def test_spotify_public_uses_authed_spotify_when_signed_in(self): + """The handler-level fallback chain: when Spotify is authed + AND the public URL is a playlist URL, prefer the authed API so + the mirror gets album-art-bearing matched_data instead of the + bare scraper output.""" + track = _StubSpotifyTrack( + id='auth-trk', name='From Auth', artists=['Artist'], + album='Album', duration_ms=200_000, image_url='img', + ) + spotify = _StubSpotifyClient(_StubSpotifyPlaylist( + tracks=[track], id='auth-pid', name='Auth', + )) + + db = _StubDB(playlists=[ + { + 'id': 22, + 'name': 'Pub', + 'source': 'spotify_public', + 'source_playlist_id': 'hash', + 'description': 'https://open.spotify.com/playlist/abc123def456', + 'profile_id': 1, + }, + ]) + deps = _build_deps( + get_database=lambda: db, + spotify_client=spotify, + ) + + result = auto_refresh_mirrored({'playlist_id': '22'}, deps) + + assert result['refreshed'] == '1' + call = db.mirror_calls[0] + # Track came from the authed Spotify path → carries matched_data. + extra = json.loads(call['tracks'][0]['extra_data']) + assert extra['discovered'] is True + assert extra['provider'] == 'spotify' + assert extra['matched_data']['id'] == 'auth-trk' + # ─── sync_playlist ─────────────────────────────────────────────────── diff --git a/tests/test_playlist_sources_adapters.py b/tests/test_playlist_sources_adapters.py index b83afe81..20355ffa 100644 --- a/tests/test_playlist_sources_adapters.py +++ b/tests/test_playlist_sources_adapters.py @@ -21,7 +21,9 @@ from core.playlists.sources import ( PlaylistSource, PlaylistSourceRegistry, get_registry, + to_mirror_track_dict, ) +from core.playlists.sources.deezer import DeezerPlaylistSource from core.playlists.sources.itunes_link import ITunesLinkPlaylistSource from core.playlists.sources.lastfm import LastFMPlaylistSource from core.playlists.sources.listenbrainz import ListenBrainzPlaylistSource @@ -104,10 +106,20 @@ def test_spotify_adapter_lists_and_fetches(): t = detail.tracks[0] assert t.source_track_id == "t1" assert t.track_name == "Run" - assert t.artist_name == "A, B" + # First artist only — matches the mirrored_playlist shape that the + # legacy refresh_mirrored handler wrote (``t.artists[0]``). + assert t.artist_name == "A" assert t.album_name == "Album" assert t.duration_ms == 180_000 assert t.needs_discovery is False + # Spotify authenticated API path emits matched_data so the discovery + # worker can skip its search step and go straight to enrichment. + assert t.extra["discovered"] is True + assert t.extra["provider"] == "spotify" + assert t.extra["matched_data"]["id"] == "t1" + assert t.extra["matched_data"]["artists"] == [{"name": "A"}, {"name": "B"}] + assert t.extra["matched_data"]["album"]["name"] == "Album" + assert t.extra["matched_data"]["album"]["images"][0]["url"] == "img" def test_spotify_adapter_handles_unauthed(): @@ -571,3 +583,133 @@ def test_registry_re_register_invalidates_instance(): def test_registry_unknown_source_returns_none(): reg = PlaylistSourceRegistry() assert reg.get_source("nope") is None + + +# ─── Deezer ───────────────────────────────────────────────────────────── + + +class _FakeDeezerClient: + def is_authenticated(self) -> bool: + return True # Deezer public API always available + + def get_user_playlists(self): + return [] # stub-interface variant returns [] + + def get_playlist(self, playlist_id: str): + return { + "id": playlist_id, + "name": "Deez Mix", + "description": "deezer", + "track_count": 1, + "image_url": "img", + "owner": "user", + "tracks": [{ + "id": "dt1", + "name": "Song", + "artists": ["Deez Artist"], + "album": "Deez Album", + "duration_ms": 240_000, + "track_number": 1, + }], + } + + +def test_deezer_adapter_projection(): + src = DeezerPlaylistSource(lambda: _FakeDeezerClient()) + assert src.is_authenticated() is True + assert src.list_playlists() == [] # user playlists need OAuth + + detail = src.get_playlist("d1") + assert detail is not None + assert detail.meta.source == "deezer" + assert detail.meta.image_url == "img" + t = detail.tracks[0] + assert t.source_track_id == "dt1" + assert t.artist_name == "Deez Artist" + assert t.album_name == "Deez Album" + assert t.needs_discovery is False + + +# ─── to_mirror_track_dict projection helper ───────────────────────────── + + +def test_mirror_dict_minimal_track_has_no_extra_data(): + track = NormalizedTrack( + position=0, + track_name="Song", + artist_name="Artist", + album_name="Album", + duration_ms=200_000, + source_track_id="abc", + ) + d = to_mirror_track_dict(track) + assert d == { + "track_name": "Song", + "artist_name": "Artist", + "album_name": "Album", + "duration_ms": 200_000, + "source_track_id": "abc", + } + assert "extra_data" not in d + + +def test_mirror_dict_spotify_authed_emits_matched_data(): + """The Spotify adapter's authenticated-API path planted + ``discovered`` + ``matched_data`` in ``extra``; projection must + serialize them into ``extra_data`` matching the legacy refresh + handler's shape (pre-extraction).""" + track = NormalizedTrack( + position=0, + track_name="Run", + artist_name="Adele", + album_name="25", + duration_ms=295_000, + source_track_id="track123", + extra={ + "discovered": True, + "provider": "spotify", + "confidence": 1.0, + "matched_data": { + "id": "track123", + "name": "Run", + "artists": [{"name": "Adele"}], + "album": {"name": "25"}, + "duration_ms": 295_000, + "image_url": None, + }, + }, + ) + d = to_mirror_track_dict(track) + assert "extra_data" in d + import json as _json + extra = _json.loads(d["extra_data"]) + assert extra["discovered"] is True + assert extra["provider"] == "spotify" + assert extra["confidence"] == 1.0 + assert extra["matched_data"]["id"] == "track123" + assert extra["matched_data"]["artists"] == [{"name": "Adele"}] + + +def test_mirror_dict_spotify_public_emits_spotify_hint(): + """Public-embed path: track ID known but album art / canonical + metadata missing, so we emit a ``spotify_hint`` for the discovery + worker instead of marking discovered.""" + track = NormalizedTrack( + position=0, + track_name="Song", + artist_name="Artist", + duration_ms=200_000, + source_track_id="sptrk", + extra={ + "spotify_hint": { + "id": "sptrk", + "name": "Song", + "artists": [{"name": "Artist"}], + }, + }, + ) + d = to_mirror_track_dict(track) + import json as _json + extra = _json.loads(d["extra_data"]) + assert extra["discovered"] is False + assert extra["spotify_hint"]["id"] == "sptrk" diff --git a/web_server.py b/web_server.py index 59fd23ef..beca48b3 100644 --- a/web_server.py +++ b/web_server.py @@ -961,6 +961,7 @@ def _register_automation_handlers(): from core.automation.deps import AutomationDeps, AutomationState from core.automation.handlers import register_all as _register_extracted_handlers + from core.playlists.sources.bootstrap import build_playlist_source_registry # Mutable shared state previously lived as module-level globals # (`_scan_library_automation_id`, `_pipeline_running`, etc). @@ -971,6 +972,38 @@ def _register_automation_handlers(): from core.watchlist_scanner import get_watchlist_scanner as _get_watchlist_scanner_fn + # ListenBrainz / Last.fm are profile-scoped, so the manager getter + # resolves the current profile's manager on each call. iTunes-link + # parsing lives as a module helper rather than a class — wrap it in + # a callable that matches the adapter contract. + def _lb_manager_for_registry(): + try: + manager, _username, _source = _get_profile_lb_manager() + return manager + except Exception: + return None + + def _itunes_link_parser_for_registry(url): + # ``parse_itunes_link_endpoint`` is a Flask route handler; the + # actual parsing work needs the underlying helpers. For now + # we punt — Phase 2 will lift the parsing into a pure helper + # the adapter can call directly. Refresh of itunes_link + # mirrors is not yet wired (current handler skips them). + return None + + _playlist_source_registry = build_playlist_source_registry( + spotify_client_getter=lambda: spotify_client, + tidal_client_getter=lambda: tidal_client, + qobuz_client_getter=_get_qobuz_client_for_sync, + deezer_client_getter=_get_deezer_client, + youtube_parser=parse_youtube_playlist, + itunes_link_parser=_itunes_link_parser_for_registry, + listenbrainz_manager_getter=_lb_manager_for_registry, + lastfm_manager_getter=_lb_manager_for_registry, + personalized_manager_getter=_build_personalized_manager, + profile_id_getter=get_current_profile_id, + ) + _automation_deps = AutomationDeps( engine=automation_engine, state=_automation_state, @@ -992,6 +1025,7 @@ def _register_automation_handlers(): get_deezer_client=_get_deezer_client, parse_youtube_playlist=parse_youtube_playlist, get_sync_states=lambda: sync_states, + playlist_source_registry=_playlist_source_registry, set_db_update_automation_id=_set_db_update_automation_id, get_db_update_state=lambda: db_update_state, db_update_lock=db_update_lock, diff --git a/webui/static/helper.js b/webui/static/helper.js index 5eb26aca..784c846b 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3416,6 +3416,7 @@ const WHATS_NEW = { '2.6.3': [ { unreleased: true }, { title: 'Groundwork: unified playlist source layer', desc: 'first slice of a refactor that\'ll let ListenBrainz, Last.fm radio, and SoulSync Discovery playlists live as Sync-page tabs alongside Spotify / Tidal / Qobuz / YouTube — so they can be mirrored + scheduled like the rest. this commit adds the shared adapter layer all those sources will plug into; no UI changes yet. nothing to do on your end.' }, + { title: 'Auto-Sync refresh now routes through the unified source layer', desc: 'follow-up to the groundwork above. the mirrored-playlist auto-refresh handler used to have a ~190-line if/elif chain branching per source (one branch each for Spotify, Spotify public, Deezer, Tidal, YouTube). now it asks the source registry for the right adapter and calls one refresh method. behavior identical — same matched_data, same Tidal-skip-on-no-auth log, same Spotify-public-prefers-authed-API fallback. unlocks ListenBrainz / Last.fm / SoulSync Discovery as future Sync-page mirror sources without a fresh elif branch each time.' }, ], '2.6.2': [ { date: 'May 24, 2026 — 2.6.2 release' },