Refactor refresh_mirrored to use unified PlaylistSource registry

Phase 1a of the Discover-to-Sync unification. The mirrored-playlist
refresh handler used to branch per-source through a ~190-line
if/elif chain (Spotify, Spotify public, Deezer, Tidal, YouTube).
Each branch hand-built its own ``extra_data`` JSON for the matched-
data block. With every new source we considered for Sync-page mirror
support (ListenBrainz, Last.fm radio, SoulSync Discovery, iTunes
link), that chain would have grown a new elif.

This commit lifts the per-source logic into the existing adapter
layer and collapses the dispatch to a registry lookup:

- ``core/playlists/sources/deezer.py`` — new adapter so the registry
  covers every source the refresh handler previously branched on.
- ``core/playlists/sources/bootstrap.py`` — single helper that builds
  a populated registry from injected getter callables. Both
  ``web_server.py`` boot and the automation test fixtures call it,
  so the two construction paths can't drift.
- ``core/playlists/sources/base.py`` — ``to_mirror_track_dict``
  projection helper centralises the NormalizedTrack → DB-row
  conversion (including the discovered/matched_data and
  spotify_hint extra_data shapes the downstream sync + wishlist
  consumers already expect).
- Spotify adapter now populates ``extra['discovered']`` + an
  ``extra['matched_data']`` block when fetching via the authed API,
  so Spotify mirrors keep landing pre-discovered (matches the
  pre-refactor contract pinned by
  ``test_spotify_refresh_writes_to_db``).
- Spotify-public adapter populates ``extra['spotify_hint']`` so the
  discovery worker can skip its search step and jump straight to
  enrichment for the known track ID.
- All artist-name fields now project to first-artist-only across
  every adapter — matches the pre-refactor mirror_playlist DB shape
  (``t.artists[0]``).

``refresh_mirrored.py`` shrinks ~190 → ~80 lines and keeps:

- the file/beatport unrefreshable-source filter,
- URL extraction from ``description`` via ``require_refresh_url``
  for spotify_public + youtube,
- the Spotify-public → authed-Spotify fallback when the user is
  signed in (handler-level branch, not in any adapter),
- the Tidal-not-authenticated soft-skip log (skip, not error),
- existing-extra_data preservation across refreshes,
- the ``playlist_changed`` automation event emit on track-set delta.

Test scaffolding:

- ``_build_deps`` in ``tests/automation/test_handlers_playlist.py``
  now builds a default registry from the passed clients via
  ``build_playlist_source_registry``, so existing refresh tests
  exercise the same path without per-test changes. New tests cover
  Tidal-not-authed soft-skip, Deezer refresh writes plain tracks,
  YouTube refresh reads URL from description, and Spotify-public
  uses authed Spotify when signed in.
- 4 new adapter tests for Deezer projection +
  ``to_mirror_track_dict`` (minimal track, Spotify matched_data,
  Spotify-public spotify_hint).
- ``playlist_source_registry`` field on ``AutomationDeps`` defaults
  to ``None`` so the other 5 automation test files (which don't
  exercise refresh_mirrored) keep working unchanged.

220 tests across automation + adapter suites green.
This commit is contained in:
Broque Thomas 2026-05-26 12:52:39 -07:00
parent c5898c3b9b
commit 8c41b05fe8
16 changed files with 953 additions and 295 deletions

View file

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

View file

@ -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`` URLID 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-publicauthed
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

View file

@ -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",
]

View file

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

View file

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

View file

@ -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")},
)

View file

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

View file

@ -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"),

View file

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

View file

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

View file

@ -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,

View file

@ -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"),

View file

@ -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 ───────────────────────────────────────────────────

View file

@ -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"

View file

@ -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,

View file

@ -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' },