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

87 lines
3 KiB
Python

"""YouTube playlist source adapter.
Wraps ``parse_youtube_playlist`` (currently a free function in
``web_server.py`` — Phase 0 doesn't move it, just calls it via an
injected callable to avoid the circular import). ``supports_listing``
is False — YouTube playlists are URL-input only, no user library.
"""
from __future__ import annotations
import hashlib
from typing import Any, Callable, List, Optional
from core.playlists.sources.base import (
NormalizedTrack,
PlaylistDetail,
PlaylistMeta,
PlaylistSource,
SOURCE_YOUTUBE,
)
class YouTubePlaylistSource(PlaylistSource):
name = SOURCE_YOUTUBE
supports_listing = False
supports_refresh = True
requires_auth = False
def __init__(self, parser: Callable[[str], Optional[dict]]):
"""``parser`` matches the signature of ``parse_youtube_playlist``
in web_server.py — takes a URL, returns the playlist dict or
``None``. Injected so adapter can be constructed at import time."""
self._parser = parser
def is_authenticated(self) -> bool:
return True
def list_playlists(self) -> List[PlaylistMeta]:
return []
def get_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
"""``playlist_id`` is the full YouTube playlist URL."""
data = self._parser(playlist_id)
if not data:
return None
source_url = data.get("url") or playlist_id
url_hash = hashlib.md5(source_url.encode()).hexdigest()[:12]
tracks_raw = data.get("tracks") or []
meta = PlaylistMeta(
source=self.name,
source_playlist_id=url_hash,
name=data.get("name", "YouTube Playlist"),
track_count=int(data.get("track_count", len(tracks_raw))),
image_url=data.get("image_url") or None,
source_url=source_url,
extra={
"youtube_playlist_id": data.get("id"),
},
)
tracks = [self._track_from_yt(t, idx) for idx, t in enumerate(tracks_raw) if t]
return PlaylistDetail(meta=meta, tracks=tracks)
def refresh_playlist(self, playlist_id: str) -> Optional[PlaylistDetail]:
return self.get_playlist(playlist_id)
# ---- projection helpers ------------------------------------------------
def _track_from_yt(self, track: dict, position: int) -> NormalizedTrack:
artists = track.get("artists") or []
artist_name = ", ".join(artists) if artists else "Unknown Artist"
return NormalizedTrack(
position=position,
track_name=track.get("name", "Unknown Track"),
artist_name=artist_name,
album_name=None,
duration_ms=int(track.get("duration_ms", 0) or 0),
source_track_id=str(track.get("id", "")),
needs_discovery=False,
extra={
"url": track.get("url"),
"raw_title": track.get("raw_title"),
"raw_artist": track.get("raw_artist"),
},
)