soulsync/core/playlists/sources/bootstrap.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

97 lines
3.7 KiB
Python

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