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.
104 lines
3.9 KiB
Python
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
|