soulsync/core/playlists/sources/base.py
Broque Thomas 8c41b05fe8 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.
2026-05-26 12:52:39 -07:00

217 lines
7.8 KiB
Python

"""PlaylistSource Protocol + normalized data containers.
These dataclasses define the *single* shape every adapter must return.
The legacy backing clients each return slightly different dicts /
dataclasses; the adapter's job is to project those into ``PlaylistMeta``
and ``NormalizedTrack`` so callers don't have to know which source they
got the data from.
Two distinct shapes:
- ``PlaylistMeta``: cheap, lightweight — used for "list playlists for a
tab" responses. No tracks.
- ``PlaylistDetail``: meta + full normalized track list. Used after the
user selects a playlist to mirror.
Discovery flag:
- ``NormalizedTrack.needs_discovery`` is True for sources that return
raw metadata only (ListenBrainz, Last.fm radio) — the caller must run
the match step before the track is usable in the download pipeline.
Sources that already carry a provider ID (Spotify, Tidal, etc.) set
this to False.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
# Canonical source identifiers used as the key in mirrored_playlists.source
# and in the registry. Centralized so a typo in one place doesn't silently
# create a new "source".
SOURCE_SPOTIFY = "spotify"
SOURCE_SPOTIFY_PUBLIC = "spotify_public"
SOURCE_DEEZER = "deezer"
SOURCE_TIDAL = "tidal"
SOURCE_QOBUZ = "qobuz"
SOURCE_YOUTUBE = "youtube"
SOURCE_ITUNES_LINK = "itunes_link"
SOURCE_LISTENBRAINZ = "listenbrainz"
SOURCE_LASTFM = "lastfm"
SOURCE_SOULSYNC_DISCOVERY = "soulsync_discovery"
ALL_SOURCES = (
SOURCE_SPOTIFY,
SOURCE_SPOTIFY_PUBLIC,
SOURCE_DEEZER,
SOURCE_TIDAL,
SOURCE_QOBUZ,
SOURCE_YOUTUBE,
SOURCE_ITUNES_LINK,
SOURCE_LISTENBRAINZ,
SOURCE_LASTFM,
SOURCE_SOULSYNC_DISCOVERY,
)
@dataclass
class PlaylistMeta:
"""Lightweight playlist descriptor — no tracks."""
source: str
source_playlist_id: str
name: str
track_count: int = 0
owner: Optional[str] = None
description: Optional[str] = None
image_url: Optional[str] = None
# Original URL for URL-backed sources (youtube, spotify_public,
# itunes_link). Used by the refresh path to re-fetch.
source_url: Optional[str] = None
# Free-form per-source passthrough — adapter can stash whatever the
# native API returned for downstream consumers that need richer data
# (e.g. ListenBrainz creator/MBID, Spotify snapshot_id).
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class NormalizedTrack:
"""A single track in normalized shape.
``source_track_id`` is the native ID at the source — Spotify track
ID, Tidal ID, YouTube video ID, ListenBrainz recording MBID, etc.
Empty string is allowed for sources that don't have a stable per-
track ID (rare).
"""
position: int
track_name: str
artist_name: str
album_name: Optional[str] = None
duration_ms: int = 0
source_track_id: Optional[str] = None
image_url: Optional[str] = None
# True when the track needs a discovery / match step before it can be
# downloaded (e.g. ListenBrainz returns MB recording metadata only —
# no Spotify/iTunes ID, so the matching engine has to run first).
needs_discovery: bool = False
# Passthrough for source-specific extras (explicit flag, popularity,
# external_urls, recording_mbid, etc.). Adapters decide what to stash.
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class PlaylistDetail:
"""Full playlist payload — meta + tracks."""
meta: PlaylistMeta
tracks: List[NormalizedTrack] = field(default_factory=list)
@runtime_checkable
class PlaylistSource(Protocol):
"""Contract every playlist source adapter implements.
Capability flags let callers query the adapter's shape before
invoking it (e.g. ``supports_listing=False`` for URL-only sources
means the Sync page should render a paste-URL input instead of a
playlist picker).
"""
name: str
supports_listing: bool
supports_refresh: bool
requires_auth: bool
def is_authenticated(self) -> bool:
"""Return True if the adapter can currently call its backend.
For sources without auth (YouTube, Spotify public, iTunes link),
this is always True. For sources where auth check is expensive,
the adapter may cache (existing clients already do this)."""
def list_playlists(self) -> List[PlaylistMeta]:
"""Return all playlists the user has access to.
For ``supports_listing=False`` sources, return ``[]`` and let
the caller use ``get_playlist`` with a URL/ID directly."""
def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
"""Fetch full playlist (meta + tracks) by source-native ID.
For URL-backed sources, ``playlist_id`` is the full URL. For ID-
backed sources it's the native ID string. Returns ``None`` if
the playlist isn't reachable (404, auth failure, etc.)."""
def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
"""Re-fetch a playlist for the auto-refresh pipeline.
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