soulsync/core/playlists/sources/bootstrap.py
Broque Thomas 246503066b Fold provider-matching into PlaylistSource contract (Phase 1b)
Adds ``discover_tracks(tracks) -> List[NormalizedTrack]`` to the
PlaylistSource interface. Sources whose tracks already carry
provider IDs (Spotify, Tidal, Qobuz, YouTube, Deezer, Spotify
public, iTunes link, SoulSync Discovery) inherit a no-op default;
ListenBrainz + Last.fm override to run the matching engine.

This closes the last gap before LB / Last.fm / SoulSync Discovery
can land as Sync-page mirror sources: the refresh handler now
calls ``source.discover_tracks(...)`` whenever a source returns
tracks with ``needs_discovery=True``, so mirrored LB rows arrive
already discovered + ready for the sync pipeline. Previously, LB
playlists ran through a separate state-machine worker tied to the
Discover-page UI, with results stored in ``discovery_cache``
instead of ``mirrored_playlist_tracks.extra_data``.

Changes:

- ``core/playlists/sources/base.py`` — PlaylistSource switches from
  Protocol to ABC so a concrete default for ``discover_tracks``
  can live on the base class. The four real-work methods stay
  ``@abstractmethod``; instantiating an adapter that forgets one
  fails loudly at construction.
- ``core/discovery/matching.py`` (new) — pure ``match_mb_tracks``
  helper that runs Strategy-1-only matching-engine queries against
  Spotify (primary) or iTunes (fallback). No state machine, no
  discovery-cache writes, no wing-it stub — that richer flow stays
  in ``core/discovery/listenbrainz.py`` for the Discover-page UI.
- ``ListenBrainzPlaylistSource`` + ``LastFMPlaylistSource`` take
  an optional ``discover_callable`` constructor arg. Last.fm reuses
  the LB implementation since the track shape is identical.
- ``bootstrap.build_playlist_source_registry`` accepts a
  ``discover_callable`` kwarg and wires it into LB + Last.fm
  adapters.
- ``web_server.py`` boot constructs the discovery callable from the
  existing matching engine + ``_discovery_score_candidates`` +
  Spotify / iTunes clients, passes through to the registry.
- ``refresh_mirrored.py`` adds a small ``_maybe_discover`` helper
  that calls ``source.discover_tracks(...)`` between fetch and
  ``to_mirror_track_dict`` projection — only fires when at least
  one track has ``needs_discovery=True``, so the normal Spotify /
  Tidal / etc. refresh path stays a zero-cost pass-through.

Tests:

- 5 new adapter tests: default no-op pass-through, LB discovery
  with mixed matches/misses, LB no-callable fallback, Last.fm
  shares the LB implementation, mirror-dict spotify_hint emit.
- 1 new automation test: end-to-end LB refresh with a stub
  discover_callable proves the matched_data lands in
  ``mirror_playlist_tracks.extra_data`` after the registry
  refresh + discover hop.

225 tests across adapter + automation suites green.
2026-05-26 13:07:01 -07:00

104 lines
3.9 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,
discover_callable: Optional[Callable[..., Any]] = 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,
discover_callable=discover_callable,
),
)
reg.register(
SOURCE_LASTFM,
lambda: LastFMPlaylistSource(
lastfm_manager_getter or _no_manager,
discover_callable=discover_callable,
),
)
reg.register(
SOURCE_SOULSYNC_DISCOVERY,
lambda: SoulSyncDiscoveryPlaylistSource(
personalized_manager_getter or _no_manager,
profile_id_getter=profile_id_getter,
),
)
return reg