mirror of https://github.com/Nezreka/SoulSync.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
918 lines
30 KiB
918 lines
30 KiB
"""Adapter contract tests for ``core/playlists/sources/``.
|
|
|
|
These pin the projection from each backing client's native shape into
|
|
the unified ``PlaylistMeta`` / ``NormalizedTrack`` shape. Adapters are
|
|
fed minimal fakes (not real clients) so the test is independent of the
|
|
live API surface — the goal is to lock in the field mapping so later
|
|
phases that consume the unified interface can rely on it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from typing import Any, Callable, List, Optional
|
|
|
|
import pytest
|
|
|
|
from core.playlists.sources import (
|
|
NormalizedTrack,
|
|
PlaylistDetail,
|
|
PlaylistMeta,
|
|
PlaylistSource,
|
|
PlaylistSourceRegistry,
|
|
get_registry,
|
|
to_mirror_track_dict,
|
|
)
|
|
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.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
|
|
|
|
|
|
# ─── Spotify ────────────────────────────────────────────────────────────
|
|
|
|
|
|
class _FakeSpotifyClient:
|
|
"""Stand-in for ``core.spotify_client.SpotifyClient``."""
|
|
|
|
def __init__(self, authed: bool = True):
|
|
self._authed = authed
|
|
|
|
def is_authenticated(self) -> bool:
|
|
return self._authed
|
|
|
|
def get_user_playlists_metadata_only(self):
|
|
return [
|
|
SimpleNamespace(
|
|
id="pl1",
|
|
name="Drive",
|
|
description="long drives",
|
|
owner="me",
|
|
public=True,
|
|
collaborative=False,
|
|
tracks=[],
|
|
total_tracks=2,
|
|
)
|
|
]
|
|
|
|
def get_playlist_by_id(self, playlist_id: str):
|
|
track = SimpleNamespace(
|
|
id="t1",
|
|
name="Run",
|
|
artists=["A", "B"],
|
|
album="Album",
|
|
duration_ms=180_000,
|
|
popularity=50,
|
|
preview_url=None,
|
|
external_urls={"spotify": "url"},
|
|
image_url="img",
|
|
)
|
|
return SimpleNamespace(
|
|
id=playlist_id,
|
|
name="Drive",
|
|
description="long drives",
|
|
owner="me",
|
|
public=True,
|
|
collaborative=False,
|
|
tracks=[track],
|
|
total_tracks=1,
|
|
)
|
|
|
|
|
|
def test_spotify_adapter_lists_and_fetches():
|
|
client = _FakeSpotifyClient()
|
|
src = SpotifyPlaylistSource(lambda: client)
|
|
|
|
assert isinstance(src, PlaylistSource)
|
|
assert src.is_authenticated() is True
|
|
|
|
metas = src.list_playlists()
|
|
assert len(metas) == 1
|
|
m = metas[0]
|
|
assert m.source == "spotify"
|
|
assert m.source_playlist_id == "pl1"
|
|
assert m.name == "Drive"
|
|
assert m.track_count == 2
|
|
|
|
detail = src.get_playlist("pl1")
|
|
assert detail is not None
|
|
assert detail.meta.track_count == 1
|
|
t = detail.tracks[0]
|
|
assert t.source_track_id == "t1"
|
|
assert t.track_name == "Run"
|
|
# First artist only — matches the mirrored_playlist shape that the
|
|
# legacy refresh_mirrored handler wrote (``t.artists[0]``).
|
|
assert t.artist_name == "A"
|
|
assert t.album_name == "Album"
|
|
assert t.duration_ms == 180_000
|
|
assert t.needs_discovery is False
|
|
# Spotify authenticated API path emits matched_data so the discovery
|
|
# worker can skip its search step and go straight to enrichment.
|
|
assert t.extra["discovered"] is True
|
|
assert t.extra["provider"] == "spotify"
|
|
assert t.extra["matched_data"]["id"] == "t1"
|
|
assert t.extra["matched_data"]["artists"] == [{"name": "A"}, {"name": "B"}]
|
|
assert t.extra["matched_data"]["album"]["name"] == "Album"
|
|
assert t.extra["matched_data"]["album"]["images"][0]["url"] == "img"
|
|
|
|
|
|
def test_spotify_adapter_handles_unauthed():
|
|
src = SpotifyPlaylistSource(lambda: _FakeSpotifyClient(authed=False))
|
|
assert src.is_authenticated() is False
|
|
assert src.list_playlists() == []
|
|
assert src.get_playlist("pl1") is None
|
|
|
|
|
|
def test_spotify_adapter_handles_missing_client():
|
|
src = SpotifyPlaylistSource(lambda: None)
|
|
assert src.is_authenticated() is False
|
|
assert src.list_playlists() == []
|
|
|
|
|
|
# ─── Tidal ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
class _FakeTidalClient:
|
|
def __init__(self, authed: bool = True):
|
|
self._authed = authed
|
|
|
|
def is_authenticated(self) -> bool:
|
|
return self._authed
|
|
|
|
def get_user_playlists_metadata_only(self):
|
|
return [
|
|
SimpleNamespace(
|
|
id="tpl",
|
|
name="Tidal Mix",
|
|
description="",
|
|
tracks=[],
|
|
external_urls={"tidal": "url"},
|
|
owner={"name": "broque"},
|
|
public=True,
|
|
)
|
|
]
|
|
|
|
def get_playlist(self, playlist_id: str):
|
|
track = SimpleNamespace(
|
|
id="ttrk",
|
|
name="Wave",
|
|
artists=["X"],
|
|
album="Ocean",
|
|
duration_ms=200_000,
|
|
external_urls={},
|
|
popularity=0,
|
|
explicit=False,
|
|
)
|
|
return SimpleNamespace(
|
|
id=playlist_id,
|
|
name="Tidal Mix",
|
|
description="",
|
|
tracks=[track],
|
|
external_urls={},
|
|
owner={"name": "broque"},
|
|
public=True,
|
|
)
|
|
|
|
|
|
def test_tidal_adapter_projection():
|
|
src = TidalPlaylistSource(lambda: _FakeTidalClient())
|
|
|
|
metas = src.list_playlists()
|
|
assert metas[0].owner == "broque"
|
|
assert metas[0].source == "tidal"
|
|
|
|
detail = src.get_playlist("tpl")
|
|
assert detail is not None
|
|
assert detail.tracks[0].source_track_id == "ttrk"
|
|
assert detail.tracks[0].album_name == "Ocean"
|
|
assert detail.tracks[0].needs_discovery is False
|
|
|
|
|
|
# ─── Qobuz ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
class _FakeQobuzClient:
|
|
def is_authenticated(self) -> bool:
|
|
return True
|
|
|
|
def get_user_playlists(self):
|
|
return [{
|
|
"id": "q1",
|
|
"name": "Q Mix",
|
|
"description": "qobuz",
|
|
"public": False,
|
|
"track_count": 2,
|
|
"image_url": "img",
|
|
"external_urls": {"qobuz": "url"},
|
|
}]
|
|
|
|
def get_playlist(self, playlist_id: str):
|
|
return {
|
|
"id": playlist_id,
|
|
"name": "Q Mix",
|
|
"description": "qobuz",
|
|
"public": False,
|
|
"track_count": 1,
|
|
"image_url": "img",
|
|
"external_urls": {"qobuz": "url"},
|
|
"tracks": [{
|
|
"id": "qt1",
|
|
"name": "Track",
|
|
"artists": ["Q-Artist"],
|
|
"album": "Q-Album",
|
|
"duration_ms": 300_000,
|
|
"image_url": "ti",
|
|
}],
|
|
}
|
|
|
|
|
|
def test_qobuz_adapter_projection():
|
|
src = QobuzPlaylistSource(lambda: _FakeQobuzClient())
|
|
metas = src.list_playlists()
|
|
assert metas[0].source_playlist_id == "q1"
|
|
|
|
detail = src.get_playlist("q1")
|
|
assert detail.tracks[0].source_track_id == "qt1"
|
|
assert detail.tracks[0].duration_ms == 300_000
|
|
assert detail.tracks[0].image_url == "ti"
|
|
|
|
|
|
# ─── Spotify Public ─────────────────────────────────────────────────────
|
|
|
|
|
|
def test_spotify_public_adapter_invalid_url():
|
|
src = SpotifyPublicPlaylistSource()
|
|
# invalid URL → parser returns None → adapter returns None
|
|
assert src.get_playlist("not-a-spotify-url") is None
|
|
assert src.supports_listing is False
|
|
assert src.list_playlists() == []
|
|
|
|
|
|
def test_spotify_public_adapter_projects_scrape(monkeypatch):
|
|
src = SpotifyPublicPlaylistSource()
|
|
|
|
def fake_parse(url: str):
|
|
return {"type": "playlist", "id": "xyz"}
|
|
|
|
def fake_scrape(spotify_type: str, spotify_id: str):
|
|
return {
|
|
"id": spotify_id,
|
|
"type": "playlist",
|
|
"name": "Embed",
|
|
"subtitle": "owner",
|
|
"tracks": [
|
|
{
|
|
"id": "sptrk",
|
|
"name": "Song",
|
|
"artists": [{"name": "Artist"}],
|
|
"duration_ms": 100_000,
|
|
"is_explicit": False,
|
|
"track_number": 1,
|
|
},
|
|
],
|
|
"url": "https://open.spotify.com/playlist/xyz",
|
|
"url_hash": "abc123",
|
|
}
|
|
|
|
monkeypatch.setattr("core.spotify_public_scraper.parse_spotify_url", fake_parse)
|
|
monkeypatch.setattr("core.spotify_public_scraper.scrape_spotify_embed", fake_scrape)
|
|
|
|
detail = src.get_playlist("https://open.spotify.com/playlist/xyz")
|
|
assert detail is not None
|
|
assert detail.meta.source_playlist_id == "abc123"
|
|
assert detail.meta.source_url == "https://open.spotify.com/playlist/xyz"
|
|
assert detail.tracks[0].artist_name == "Artist"
|
|
assert detail.tracks[0].source_track_id == "sptrk"
|
|
|
|
|
|
# ─── YouTube ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_youtube_adapter_projection():
|
|
def parser(url: str):
|
|
return {
|
|
"id": "yt_pl",
|
|
"name": "YT Mix",
|
|
"track_count": 1,
|
|
"url": url,
|
|
"image_url": "thumb",
|
|
"tracks": [{
|
|
"id": "vid1",
|
|
"name": "Track",
|
|
"artists": ["Channel"],
|
|
"duration_ms": 240_000,
|
|
"url": "https://youtu.be/vid1",
|
|
}],
|
|
}
|
|
|
|
src = YouTubePlaylistSource(parser)
|
|
detail = src.get_playlist("https://youtube.com/playlist?list=yt_pl")
|
|
assert detail is not None
|
|
assert detail.meta.source == "youtube"
|
|
assert detail.meta.source_url == "https://youtube.com/playlist?list=yt_pl"
|
|
assert len(detail.meta.source_playlist_id) == 12 # md5[:12]
|
|
assert detail.tracks[0].source_track_id == "vid1"
|
|
|
|
|
|
def test_youtube_adapter_failed_parse():
|
|
src = YouTubePlaylistSource(lambda url: None)
|
|
assert src.get_playlist("https://bad") is None
|
|
|
|
|
|
# ─── iTunes Link ────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_itunes_link_adapter_projection():
|
|
def parser(url: str):
|
|
return {
|
|
"id": "1234",
|
|
"type": "album",
|
|
"name": "Album X",
|
|
"subtitle": "Artist",
|
|
"url": url,
|
|
"url_hash": "abcd1234",
|
|
"track_count": 1,
|
|
"image_url": "art",
|
|
"tracks": [{
|
|
"id": "555",
|
|
"name": "Song",
|
|
"artists": ["Artist"],
|
|
"album": {"name": "Album X"},
|
|
"duration_ms": 220_000,
|
|
"image_url": "art",
|
|
}],
|
|
}
|
|
|
|
src = ITunesLinkPlaylistSource(parser)
|
|
detail = src.get_playlist("https://music.apple.com/us/album/1234")
|
|
assert detail is not None
|
|
assert detail.meta.source == "itunes_link"
|
|
assert detail.meta.source_playlist_id == "abcd1234"
|
|
assert detail.tracks[0].album_name == "Album X"
|
|
assert detail.tracks[0].source_track_id == "555"
|
|
|
|
|
|
# ─── ListenBrainz ───────────────────────────────────────────────────────
|
|
|
|
|
|
class _FakeLBManager:
|
|
def __init__(self, authed: bool = True):
|
|
self.client = SimpleNamespace(is_authenticated=lambda: authed)
|
|
self._rows = {
|
|
"created_for_user": [{
|
|
"playlist_mbid": "lb-1",
|
|
"title": "Weekly Discovery",
|
|
"creator": "ListenBrainz",
|
|
"track_count": 1,
|
|
"annotation": {"note": "weekly"},
|
|
"last_updated": "2026-05-26",
|
|
}],
|
|
"user_created": [],
|
|
"collaborative": [],
|
|
}
|
|
self._tracks = {
|
|
"lb-1": [{
|
|
"track_name": "Discovery Track",
|
|
"artist_name": "MB Artist",
|
|
"album_name": "MB Album",
|
|
"duration_ms": 250_000,
|
|
"recording_mbid": "rec-1",
|
|
"release_mbid": "rel-1",
|
|
"album_cover_url": "cover",
|
|
"additional_metadata": {},
|
|
}],
|
|
}
|
|
self.refresh_called = False
|
|
self.refresh_playlist_calls: list[str] = []
|
|
# Toggle to raise from refresh_playlist for the silent-swallow test.
|
|
self.refresh_raises: Optional[Exception] = None
|
|
|
|
def get_cached_playlists(self, playlist_type: str):
|
|
return self._rows.get(playlist_type, [])
|
|
|
|
def get_playlist_type(self, mbid: str) -> str:
|
|
for ptype, rows in self._rows.items():
|
|
if any(r["playlist_mbid"] == mbid for r in rows):
|
|
return ptype
|
|
return ""
|
|
|
|
def get_cached_tracks(self, mbid: str):
|
|
return self._tracks.get(mbid, [])
|
|
|
|
def update_all_playlists(self):
|
|
# Pre-fix fallback — kept so adapters that haven't been
|
|
# migrated still work, and so an accidental return to the
|
|
# legacy entry-point is detectable in tests.
|
|
self.refresh_called = True
|
|
|
|
def refresh_playlist(self, mbid: str):
|
|
self.refresh_playlist_calls.append(mbid)
|
|
if self.refresh_raises is not None:
|
|
raise self.refresh_raises
|
|
return {"success": True, "result": "skipped", "playlist_mbid": mbid}
|
|
|
|
|
|
def test_listenbrainz_adapter_marks_needs_discovery():
|
|
manager = _FakeLBManager()
|
|
src = ListenBrainzPlaylistSource(lambda: manager)
|
|
|
|
metas = src.list_playlists()
|
|
assert len(metas) == 1
|
|
assert metas[0].source == "listenbrainz"
|
|
assert metas[0].extra["playlist_type"] == "created_for_user"
|
|
|
|
detail = src.get_playlist("lb-1")
|
|
assert detail is not None
|
|
assert detail.meta.track_count == 1
|
|
t = detail.tracks[0]
|
|
assert t.needs_discovery is True
|
|
assert t.source_track_id == "rec-1"
|
|
assert t.extra["recording_mbid"] == "rec-1"
|
|
|
|
|
|
def test_listenbrainz_adapter_refresh_uses_targeted_manager_call():
|
|
"""Adapter must call ``manager.refresh_playlist(mbid)`` — the
|
|
targeted single-playlist refresh — not the legacy
|
|
``update_all_playlists`` which re-pulls every cached LB row.
|
|
"""
|
|
manager = _FakeLBManager()
|
|
src = ListenBrainzPlaylistSource(lambda: manager)
|
|
detail = src.refresh_playlist("lb-1")
|
|
|
|
assert manager.refresh_playlist_calls == ["lb-1"]
|
|
# Legacy entry-point must NOT be touched.
|
|
assert manager.refresh_called is False
|
|
# Refresh returned a detail (read-back via get_playlist).
|
|
assert detail is not None
|
|
assert detail.meta.source_playlist_id == "lb-1"
|
|
|
|
|
|
def test_listenbrainz_adapter_refresh_logs_and_returns_none_on_manager_error():
|
|
"""When the LB manager raises, the adapter MUST surface the
|
|
failure as ``None`` (so the outer handler logs + counts it),
|
|
not silently swallow and return a stale cache read.
|
|
|
|
Pre-fix: ``except Exception: pass`` then ``return get_playlist()``
|
|
— masking every LB API failure as a successful no-op refresh.
|
|
"""
|
|
manager = _FakeLBManager()
|
|
manager.refresh_raises = RuntimeError("LB API timed out")
|
|
src = ListenBrainzPlaylistSource(lambda: manager)
|
|
|
|
result = src.refresh_playlist("lb-1")
|
|
|
|
assert result is None
|
|
assert manager.refresh_playlist_calls == ["lb-1"]
|
|
|
|
|
|
def test_listenbrainz_adapter_refresh_resolves_synthetic_series_id():
|
|
"""Rolling-series synthetic ids (``lb_weekly_jams_<user>``) must
|
|
resolve to the latest cached member MBID before calling the
|
|
targeted manager refresh. Without resolution the manager would
|
|
try to fetch the synthetic id as a real MBID and 404."""
|
|
manager = _FakeLBManager()
|
|
# Re-shape the fake's rows so the title matches a series LIKE pattern.
|
|
manager._rows["created_for_user"] = [{
|
|
"playlist_mbid": "weekly-mbid",
|
|
"title": "Weekly Jams for nezreka, week of 2026-05-25 Mon",
|
|
"creator": "ListenBrainz",
|
|
"track_count": 1,
|
|
"annotation": {},
|
|
"last_updated": "2026-05-26",
|
|
}]
|
|
manager._tracks = {"weekly-mbid": []}
|
|
# Stub the manager DB connection used by the resolution helper.
|
|
import sqlite3
|
|
conn = sqlite3.connect(":memory:")
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"CREATE TABLE listenbrainz_playlists "
|
|
"(playlist_mbid TEXT, title TEXT, profile_id INTEGER, last_updated TEXT)"
|
|
)
|
|
cur.execute(
|
|
"INSERT INTO listenbrainz_playlists VALUES "
|
|
"('weekly-mbid', 'Weekly Jams for nezreka, week of 2026-05-25 Mon', 1, '2026-05-26')"
|
|
)
|
|
conn.commit()
|
|
manager.profile_id = 1
|
|
manager._get_db_connection = lambda: conn
|
|
|
|
src = ListenBrainzPlaylistSource(lambda: manager)
|
|
src.refresh_playlist("lb_weekly_jams_nezreka")
|
|
|
|
# Manager refresh got called with the RESOLVED real MBID, not the
|
|
# synthetic one.
|
|
assert manager.refresh_playlist_calls == ["weekly-mbid"]
|
|
|
|
|
|
# ─── Last.fm ────────────────────────────────────────────────────────────
|
|
|
|
|
|
class _FakeLastFMManager:
|
|
def __init__(self):
|
|
self._rows = [{
|
|
"playlist_mbid": "lfm-1",
|
|
"title": "Last.fm Radio: Seed",
|
|
"creator": "Last.fm",
|
|
"track_count": 1,
|
|
"annotation": {"seed": "track"},
|
|
"last_updated": "2026-05-26",
|
|
}]
|
|
self._tracks = [{
|
|
"track_name": "Similar",
|
|
"artist_name": "Artist",
|
|
"album_name": None,
|
|
"duration_ms": 200_000,
|
|
"recording_mbid": "lfm-rec-1",
|
|
"release_mbid": None,
|
|
"album_cover_url": None,
|
|
"additional_metadata": {},
|
|
}]
|
|
|
|
def get_cached_playlists(self, playlist_type: str):
|
|
if playlist_type == "lastfm_radio":
|
|
return self._rows
|
|
return []
|
|
|
|
def get_cached_tracks(self, mbid: str):
|
|
if mbid == "lfm-1":
|
|
return self._tracks
|
|
return []
|
|
|
|
|
|
def test_lastfm_adapter_projects_radio_rows():
|
|
src = LastFMPlaylistSource(lambda: _FakeLastFMManager())
|
|
|
|
metas = src.list_playlists()
|
|
assert len(metas) == 1
|
|
assert metas[0].source == "lastfm"
|
|
assert metas[0].owner == "Last.fm"
|
|
|
|
detail = src.get_playlist("lfm-1")
|
|
assert detail is not None
|
|
assert detail.tracks[0].needs_discovery is True
|
|
assert detail.tracks[0].source_track_id == "lfm-rec-1"
|
|
|
|
|
|
# ─── SoulSync Discovery ─────────────────────────────────────────────────
|
|
|
|
|
|
class _FakeDiscoveryManager:
|
|
def __init__(self):
|
|
self.refresh_calls = []
|
|
self._records = [SimpleNamespace(
|
|
id=42,
|
|
profile_id=1,
|
|
kind="hidden_gems",
|
|
variant="",
|
|
name="Hidden Gems",
|
|
config=None,
|
|
track_count=1,
|
|
last_generated_at="2026-05-26T00:00:00Z",
|
|
last_synced_at=None,
|
|
last_generation_source="discovery_pool",
|
|
last_generation_error=None,
|
|
is_stale=False,
|
|
)]
|
|
self._tracks = [SimpleNamespace(
|
|
track_name="Gem",
|
|
artist_name="Indie",
|
|
album_name="EP",
|
|
spotify_track_id="sp-gem",
|
|
itunes_track_id=None,
|
|
deezer_track_id=None,
|
|
album_cover_url="art",
|
|
duration_ms=180_000,
|
|
popularity=20,
|
|
track_data_json=None,
|
|
source="discovery",
|
|
primary_id=lambda: "sp-gem",
|
|
)]
|
|
|
|
def list_playlists(self, profile_id: int = 1):
|
|
return self._records
|
|
|
|
def get_playlist_tracks(self, playlist_id: int):
|
|
return self._tracks if playlist_id == 42 else []
|
|
|
|
def refresh_playlist(self, kind: str, variant: str = "", profile_id: int = 1, config_overrides=None):
|
|
self.refresh_calls.append((kind, variant, profile_id))
|
|
return self._records[0]
|
|
|
|
|
|
def test_soulsync_discovery_adapter_tracks_dont_need_discovery():
|
|
manager = _FakeDiscoveryManager()
|
|
src = SoulSyncDiscoveryPlaylistSource(lambda: manager, profile_id_getter=lambda: 1)
|
|
|
|
metas = src.list_playlists()
|
|
assert metas[0].source == "soulsync_discovery"
|
|
assert metas[0].source_playlist_id == "42"
|
|
assert metas[0].extra["kind"] == "hidden_gems"
|
|
|
|
detail = src.get_playlist("42")
|
|
assert detail is not None
|
|
t = detail.tracks[0]
|
|
assert t.needs_discovery is False
|
|
assert t.source_track_id == "sp-gem"
|
|
assert t.album_name == "EP"
|
|
assert t.extra["spotify_track_id"] == "sp-gem"
|
|
|
|
|
|
def test_soulsync_discovery_adapter_refresh_invokes_manager():
|
|
manager = _FakeDiscoveryManager()
|
|
src = SoulSyncDiscoveryPlaylistSource(lambda: manager, profile_id_getter=lambda: 1)
|
|
src.refresh_playlist("42")
|
|
assert manager.refresh_calls == [("hidden_gems", "", 1)]
|
|
|
|
|
|
# ─── Registry ───────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_registry_lazy_construct_and_cache():
|
|
reg = PlaylistSourceRegistry()
|
|
constructed = []
|
|
|
|
def factory():
|
|
constructed.append(True)
|
|
return SpotifyPlaylistSource(lambda: None)
|
|
|
|
reg.register("spotify", factory)
|
|
assert constructed == [] # not built yet
|
|
|
|
first = reg.get_source("spotify")
|
|
second = reg.get_source("spotify")
|
|
assert first is second # cached
|
|
assert len(constructed) == 1
|
|
|
|
|
|
def test_registry_re_register_invalidates_instance():
|
|
reg = PlaylistSourceRegistry()
|
|
reg.register("spotify", lambda: SpotifyPlaylistSource(lambda: None))
|
|
first = reg.get_source("spotify")
|
|
reg.register("spotify", lambda: SpotifyPlaylistSource(lambda: None))
|
|
second = reg.get_source("spotify")
|
|
assert first is not second
|
|
|
|
|
|
def test_registry_unknown_source_returns_none():
|
|
reg = PlaylistSourceRegistry()
|
|
assert reg.get_source("nope") is None
|
|
|
|
|
|
# ─── Deezer ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
class _FakeDeezerClient:
|
|
def is_authenticated(self) -> bool:
|
|
return True # Deezer public API always available
|
|
|
|
def get_user_playlists(self):
|
|
return [] # stub-interface variant returns []
|
|
|
|
def get_playlist(self, playlist_id: str):
|
|
return {
|
|
"id": playlist_id,
|
|
"name": "Deez Mix",
|
|
"description": "deezer",
|
|
"track_count": 1,
|
|
"image_url": "img",
|
|
"owner": "user",
|
|
"tracks": [{
|
|
"id": "dt1",
|
|
"name": "Song",
|
|
"artists": ["Deez Artist"],
|
|
"album": "Deez Album",
|
|
"duration_ms": 240_000,
|
|
"track_number": 1,
|
|
}],
|
|
}
|
|
|
|
|
|
def test_deezer_adapter_projection():
|
|
src = DeezerPlaylistSource(lambda: _FakeDeezerClient())
|
|
assert src.is_authenticated() is True
|
|
assert src.list_playlists() == [] # user playlists need OAuth
|
|
|
|
detail = src.get_playlist("d1")
|
|
assert detail is not None
|
|
assert detail.meta.source == "deezer"
|
|
assert detail.meta.image_url == "img"
|
|
t = detail.tracks[0]
|
|
assert t.source_track_id == "dt1"
|
|
assert t.artist_name == "Deez Artist"
|
|
assert t.album_name == "Deez Album"
|
|
assert t.needs_discovery is False
|
|
|
|
|
|
# ─── to_mirror_track_dict projection helper ─────────────────────────────
|
|
|
|
|
|
def test_mirror_dict_minimal_track_has_no_extra_data():
|
|
track = NormalizedTrack(
|
|
position=0,
|
|
track_name="Song",
|
|
artist_name="Artist",
|
|
album_name="Album",
|
|
duration_ms=200_000,
|
|
source_track_id="abc",
|
|
)
|
|
d = to_mirror_track_dict(track)
|
|
assert d == {
|
|
"track_name": "Song",
|
|
"artist_name": "Artist",
|
|
"album_name": "Album",
|
|
"duration_ms": 200_000,
|
|
"source_track_id": "abc",
|
|
}
|
|
assert "extra_data" not in d
|
|
|
|
|
|
def test_mirror_dict_spotify_authed_emits_matched_data():
|
|
"""The Spotify adapter's authenticated-API path planted
|
|
``discovered`` + ``matched_data`` in ``extra``; projection must
|
|
serialize them into ``extra_data`` matching the legacy refresh
|
|
handler's shape (pre-extraction)."""
|
|
track = NormalizedTrack(
|
|
position=0,
|
|
track_name="Run",
|
|
artist_name="Adele",
|
|
album_name="25",
|
|
duration_ms=295_000,
|
|
source_track_id="track123",
|
|
extra={
|
|
"discovered": True,
|
|
"provider": "spotify",
|
|
"confidence": 1.0,
|
|
"matched_data": {
|
|
"id": "track123",
|
|
"name": "Run",
|
|
"artists": [{"name": "Adele"}],
|
|
"album": {"name": "25"},
|
|
"duration_ms": 295_000,
|
|
"image_url": None,
|
|
},
|
|
},
|
|
)
|
|
d = to_mirror_track_dict(track)
|
|
assert "extra_data" in d
|
|
import json as _json
|
|
extra = _json.loads(d["extra_data"])
|
|
assert extra["discovered"] is True
|
|
assert extra["provider"] == "spotify"
|
|
assert extra["confidence"] == 1.0
|
|
assert extra["matched_data"]["id"] == "track123"
|
|
assert extra["matched_data"]["artists"] == [{"name": "Adele"}]
|
|
|
|
|
|
def test_default_discover_tracks_is_no_op():
|
|
"""Adapters whose tracks already carry provider IDs (Spotify,
|
|
Tidal, Qobuz, YouTube, Deezer, Spotify-public, iTunes-link,
|
|
SoulSync-Discovery) inherit the ABC default — return tracks
|
|
unchanged."""
|
|
track = NormalizedTrack(
|
|
position=0,
|
|
track_name="Song",
|
|
artist_name="Artist",
|
|
source_track_id="abc",
|
|
needs_discovery=False,
|
|
)
|
|
src = SpotifyPlaylistSource(lambda: None)
|
|
out = src.discover_tracks([track])
|
|
assert out == [track]
|
|
|
|
|
|
def test_listenbrainz_discover_tracks_uses_callable():
|
|
"""When the LB adapter is wired with a discover_callable, MB
|
|
tracks get matched_data populated; ``needs_discovery`` flips to
|
|
False on matches; non-matches stay as-is."""
|
|
|
|
def fake_discover(track_dicts):
|
|
# Match the first, leave second unmatched.
|
|
return [
|
|
{
|
|
"id": "matched-1",
|
|
"name": "Matched",
|
|
"artists": ["Artist 1"],
|
|
"album": {"name": "Album"},
|
|
"duration_ms": 200_000,
|
|
"image_url": "art",
|
|
"source": "spotify",
|
|
"_provider": "spotify",
|
|
"_confidence": 0.95,
|
|
},
|
|
None,
|
|
]
|
|
|
|
src = ListenBrainzPlaylistSource(
|
|
lambda: None,
|
|
discover_callable=fake_discover,
|
|
)
|
|
tracks = [
|
|
NormalizedTrack(
|
|
position=0,
|
|
track_name="Song A",
|
|
artist_name="Artist 1",
|
|
source_track_id="mbid-1",
|
|
needs_discovery=True,
|
|
),
|
|
NormalizedTrack(
|
|
position=1,
|
|
track_name="Song B",
|
|
artist_name="Artist 2",
|
|
source_track_id="mbid-2",
|
|
needs_discovery=True,
|
|
),
|
|
]
|
|
out = src.discover_tracks(tracks)
|
|
assert len(out) == 2
|
|
|
|
assert out[0].needs_discovery is False
|
|
assert out[0].source_track_id == "matched-1"
|
|
assert out[0].extra["discovered"] is True
|
|
assert out[0].extra["provider"] == "spotify"
|
|
assert out[0].extra["confidence"] == 0.95
|
|
assert out[0].extra["matched_data"]["id"] == "matched-1"
|
|
|
|
# Unmatched stays as-is.
|
|
assert out[1].needs_discovery is True
|
|
assert out[1].source_track_id == "mbid-2"
|
|
assert "matched_data" not in (out[1].extra or {})
|
|
|
|
|
|
def test_listenbrainz_discover_tracks_no_callable_is_no_op():
|
|
"""If no ``discover_callable`` is wired, the adapter returns the
|
|
list unchanged — refresh paths that haven't enabled discovery
|
|
still work."""
|
|
src = ListenBrainzPlaylistSource(lambda: None, discover_callable=None)
|
|
tracks = [
|
|
NormalizedTrack(
|
|
position=0,
|
|
track_name="T",
|
|
artist_name="A",
|
|
needs_discovery=True,
|
|
)
|
|
]
|
|
assert src.discover_tracks(tracks) == tracks
|
|
|
|
|
|
def test_lastfm_discover_tracks_shares_listenbrainz_implementation():
|
|
"""Last.fm radio tracks have the same MB-metadata shape as LB
|
|
tracks, so the adapter reuses LB's ``discover_tracks``."""
|
|
|
|
def fake_discover(track_dicts):
|
|
return [{
|
|
"id": "lfm-matched",
|
|
"name": "Match",
|
|
"artists": ["Artist"],
|
|
"album": {"name": ""},
|
|
"duration_ms": 200_000,
|
|
"image_url": "",
|
|
"source": "spotify",
|
|
"_provider": "spotify",
|
|
}]
|
|
|
|
src = LastFMPlaylistSource(lambda: None, discover_callable=fake_discover)
|
|
tracks = [
|
|
NormalizedTrack(
|
|
position=0,
|
|
track_name="T",
|
|
artist_name="A",
|
|
needs_discovery=True,
|
|
)
|
|
]
|
|
out = src.discover_tracks(tracks)
|
|
assert out[0].needs_discovery is False
|
|
assert out[0].source_track_id == "lfm-matched"
|
|
assert out[0].extra["matched_data"]["id"] == "lfm-matched"
|
|
|
|
|
|
def test_mirror_dict_spotify_public_emits_spotify_hint():
|
|
"""Public-embed path: track ID known but album art / canonical
|
|
metadata missing, so we emit a ``spotify_hint`` for the discovery
|
|
worker instead of marking discovered."""
|
|
track = NormalizedTrack(
|
|
position=0,
|
|
track_name="Song",
|
|
artist_name="Artist",
|
|
duration_ms=200_000,
|
|
source_track_id="sptrk",
|
|
extra={
|
|
"spotify_hint": {
|
|
"id": "sptrk",
|
|
"name": "Song",
|
|
"artists": [{"name": "Artist"}],
|
|
},
|
|
},
|
|
)
|
|
d = to_mirror_track_dict(track)
|
|
import json as _json
|
|
extra = _json.loads(d["extra_data"])
|
|
assert extra["discovered"] is False
|
|
assert extra["spotify_hint"]["id"] == "sptrk"
|