soulsync/core/playlists/sources/lastfm.py
Broque Thomas c5898c3b9b Add unified PlaylistSource adapter layer (Phase 0)
Groundwork for unifying Discover-page playlists (ListenBrainz, Last.fm
radio, SoulSync Discovery) with Sync-page playlists (Spotify, Tidal,
Qobuz, YouTube, Spotify public, iTunes link). All nine sources now
expose the same `PlaylistSource` Protocol so callers stop having to
branch per-source.

This commit only adds the abstraction — no dispatch sites collapse to
the registry yet, no DB or UI changes. Adapters wrap existing clients
via injected getter callables to avoid eager imports of web_server.py
globals.

- core/playlists/sources/base.py — PlaylistMeta, NormalizedTrack,
  PlaylistDetail dataclasses + PlaylistSource Protocol with
  supports_listing / supports_refresh / requires_auth capability
  flags. needs_discovery flag on NormalizedTrack marks tracks that
  carry raw MB metadata (LB, Last.fm) vs tracks already matched to a
  provider ID (everything else).
- core/playlists/sources/registry.py — thread-safe lazy-factory
  registry with instance caching + re-register invalidation.
- nine adapters in core/playlists/sources/ wrapping SpotifyClient,
  TidalClient, QobuzClient, spotify_public_scraper, the YouTube +
  iTunes-link parsers (via injected callables), ListenBrainzManager,
  Last.fm radio rows in the ListenBrainz cache, and
  PersonalizedPlaylistManager.
- tests/test_playlist_sources_adapters.py — 18 tests covering each
  adapter's field projection with fake backing clients, plus
  registry lazy-construct + cache + re-register invalidation.

Phase 1 will collapse refresh_mirrored.py's per-source if/elif chain
to a registry lookup and surface ListenBrainz as a Sync-page tab.
2026-05-26 12:22:09 -07:00

117 lines
4.2 KiB
Python

"""Last.fm radio playlist source adapter.
Last.fm radio playlists are persisted by
``ListenBrainzManager.save_lastfm_radio_playlist`` under
``playlist_type='lastfm_radio'`` in the ``listenbrainz_playlists``
table — they share the same storage as ListenBrainz playlists but
originate from Last.fm's similar-tracks API.
Like ListenBrainz, tracks are MB metadata only, so ``needs_discovery``
is True on every track.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional
from core.playlists.sources.base import (
NormalizedTrack,
PlaylistDetail,
PlaylistMeta,
PlaylistSource,
SOURCE_LASTFM,
)
LASTFM_PLAYLIST_TYPE = "lastfm_radio"
class LastFMPlaylistSource(PlaylistSource):
name = SOURCE_LASTFM
supports_listing = True
supports_refresh = False # Refresh requires re-running the radio generator
requires_auth = True
def __init__(self, manager_getter: Callable[[], Any]):
"""``manager_getter`` returns the profile's ``ListenBrainzManager``
(Last.fm radio playlists share that storage layer)."""
self._manager_getter = manager_getter
def _manager(self):
return self._manager_getter()
def is_authenticated(self) -> bool:
# Last.fm radio rows exist independently of any auth state —
# they're persisted snapshots. Treat "manager exists" as enough.
return self._manager() is not None
def list_playlists(self) -> List[PlaylistMeta]:
manager = self._manager()
if manager is None:
return []
try:
rows = manager.get_cached_playlists(LASTFM_PLAYLIST_TYPE) or []
except Exception:
rows = []
return [self._meta_from_cache_row(r) for r in rows]
def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
manager = self._manager()
if manager is None:
return None
try:
rows = manager.get_cached_playlists(LASTFM_PLAYLIST_TYPE) or []
except Exception:
rows = []
meta_row = next(
(r for r in rows if str(r.get("playlist_mbid")) == str(playlist_id)),
None,
)
if meta_row is None:
return None
try:
tracks_raw = manager.get_cached_tracks(playlist_id) or []
except Exception:
tracks_raw = []
meta = self._meta_from_cache_row(meta_row)
meta.track_count = len(tracks_raw)
tracks = [self._track_from_cache_row(t, idx) for idx, t in enumerate(tracks_raw)]
return PlaylistDetail(meta=meta, tracks=tracks)
def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
# Regenerating a Last.fm radio playlist needs the seed track +
# the Last.fm client — that lives outside this adapter. Phase 1
# wires up the regeneration; Phase 0 just returns the current
# snapshot.
return self.get_playlist(playlist_id)
# ---- projection helpers ------------------------------------------------
def _meta_from_cache_row(self, row: Dict[str, Any]) -> PlaylistMeta:
return PlaylistMeta(
source=self.name,
source_playlist_id=str(row.get("playlist_mbid", "")),
name=row.get("title") or "Last.fm Radio",
owner=row.get("creator") or "Last.fm",
track_count=int(row.get("track_count", 0) or 0),
extra={
"annotation": row.get("annotation") or {},
"last_updated": row.get("last_updated"),
},
)
def _track_from_cache_row(self, row: Dict[str, Any], position: int) -> NormalizedTrack:
return NormalizedTrack(
position=position,
track_name=row.get("track_name", "Unknown Track"),
artist_name=row.get("artist_name", "Unknown Artist"),
album_name=row.get("album_name") or None,
duration_ms=int(row.get("duration_ms", 0) or 0),
source_track_id=row.get("recording_mbid") or None,
image_url=row.get("album_cover_url") or None,
needs_discovery=True,
extra={
"recording_mbid": row.get("recording_mbid"),
"release_mbid": row.get("release_mbid"),
},
)