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

137 lines
5.2 KiB
Python

"""Spotify playlist source adapter.
Wraps ``core.spotify_client.SpotifyClient`` — the authenticated Spotify
client used everywhere else. Adapter projects Spotify's ``Playlist`` /
``Track`` dataclasses into ``PlaylistMeta`` / ``NormalizedTrack``.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional
from core.playlists.sources.base import (
NormalizedTrack,
PlaylistDetail,
PlaylistMeta,
PlaylistSource,
SOURCE_SPOTIFY,
)
class SpotifyPlaylistSource(PlaylistSource):
name = SOURCE_SPOTIFY
supports_listing = True
supports_refresh = True
requires_auth = True
def __init__(self, client_getter: Callable[[], Any]):
"""``client_getter`` returns the live ``SpotifyClient`` singleton.
We accept a getter (not the client itself) so the adapter can be
constructed at import time, before ``web_server.py`` has wired
up the singleton."""
self._client_getter = client_getter
def _client(self):
return self._client_getter()
def is_authenticated(self) -> bool:
client = self._client()
if client is None:
return False
# ``is_spotify_authenticated`` is the Spotify-specific check;
# ``is_authenticated`` on SpotifyClient is a metadata-aware
# superset that returns True even when only the iTunes
# fallback is available. The adapter needs the strict check
# because it calls Spotify-only endpoints (get_user_playlists,
# get_playlist_by_id).
check = getattr(client, "is_spotify_authenticated", None) or client.is_authenticated
return bool(check())
def list_playlists(self) -> List[PlaylistMeta]:
client = self._client()
if client is None or not self.is_authenticated():
return []
playlists = client.get_user_playlists_metadata_only()
return [self._meta_from_playlist(p) for p in playlists]
def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
client = self._client()
if client is None or not self.is_authenticated():
return None
playlist = client.get_playlist_by_id(playlist_id)
if playlist is None:
return None
meta = self._meta_from_playlist(playlist)
tracks = [self._track_from_spotify(t, idx) for idx, t in enumerate(playlist.tracks)]
meta.track_count = len(tracks)
return PlaylistDetail(meta=meta, tracks=tracks)
def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
return self.get_playlist(playlist_id)
# ---- projection helpers ------------------------------------------------
def _meta_from_playlist(self, playlist: Any) -> PlaylistMeta:
return PlaylistMeta(
source=self.name,
source_playlist_id=str(playlist.id),
name=playlist.name,
owner=playlist.owner,
description=playlist.description,
track_count=int(getattr(playlist, "total_tracks", 0) or 0),
extra={
"public": bool(getattr(playlist, "public", False)),
"collaborative": bool(getattr(playlist, "collaborative", False)),
},
)
def _track_from_spotify(self, track: Any, position: int) -> NormalizedTrack:
artists = getattr(track, "artists", None) or []
artist_name = artists[0] if artists else "Unknown Artist"
track_id = str(track.id) if getattr(track, "id", None) else ""
track_name = track.name or ""
album_name = getattr(track, "album", "") or ""
duration_ms = int(getattr(track, "duration_ms", 0) or 0)
image_url = getattr(track, "image_url", None)
# Spotify's authenticated API IS canonical metadata — populate
# the discovered/matched_data block so to_mirror_track_dict emits
# the same extra_data shape downstream consumers (sync, wishlist)
# already expect from this path.
extra: Dict[str, Any] = {
"popularity": getattr(track, "popularity", 0),
"external_urls": getattr(track, "external_urls", None),
"preview_url": getattr(track, "preview_url", None),
}
if track_id:
album_obj: Dict[str, Any] = {"name": album_name}
if image_url:
album_obj["images"] = [{
"url": image_url,
"height": 600,
"width": 600,
}]
extra["discovered"] = True
extra["provider"] = "spotify"
extra["confidence"] = 1.0
extra["matched_data"] = {
"id": track_id,
"name": track_name,
"artists": [{"name": str(a)} for a in artists],
"album": album_obj,
"duration_ms": duration_ms,
"image_url": image_url,
}
return NormalizedTrack(
position=position,
track_name=track_name,
artist_name=str(artist_name),
album_name=album_name or None,
duration_ms=duration_ms,
source_track_id=track_id,
image_url=image_url,
needs_discovery=False,
extra=extra,
)