soulsync/tests/test_playlist_sources_adapters.py
BoulderBadgeDad a15fe15842 Fix: auto-sync capped public Spotify playlists at 100 tracks (#838)
Regression in 2.6.9. The spotify_public source adapter (used by auto-sync /
refresh_mirrored) called scrape_spotify_embed() directly — the embed widget only
exposes ~100 tracks — instead of fetch_spotify_public(), the wrapper the rest of
the app uses, which pulls the full list via the paginated public API and only
falls back to the embed on failure. So initial discovery got the whole playlist
but every auto-sync re-fetch truncated it to 100.

Switched the adapter to fetch_spotify_public (same return shape — drop-in). Albums
still resolve via the embed (already whole); on any failure it falls back to the
embed exactly as before.

Test: the adapter returns all 150 tracks when the full fetch yields 150 (was
capped at 100). 29 adapter tests pass.
2026-06-10 18:31:43 -07:00

948 lines
32 KiB
Python

"""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"
def test_spotify_public_adapter_paginates_past_100(monkeypatch):
"""#838: auto-sync truncated >100-track playlists to 100 because the adapter
called the embed scraper (≤100) directly instead of the full-fetch wrapper.
With the wrapper, a playlist whose full fetch returns 150 tracks keeps all 150."""
src = SpotifyPublicPlaylistSource()
monkeypatch.setattr(
"core.spotify_public_scraper.parse_spotify_url",
lambda url: {"type": "playlist", "id": "big"},
)
full = {
"id": "big", "type": "playlist", "name": "Big PL", "subtitle": "owner",
"url": "https://open.spotify.com/playlist/big", "url_hash": "bighash",
"tracks": [
{"id": f"t{i}", "name": f"Song {i}", "artists": [{"name": "A"}],
"duration_ms": 1000, "is_explicit": False, "track_number": i + 1}
for i in range(150)
],
}
# The full paginated path succeeds → wrapper returns all 150 (no embed cap).
monkeypatch.setattr(
"core.spotify_public_api.fetch_public_playlist_full",
lambda spotify_id: full,
)
detail = src.get_playlist("https://open.spotify.com/playlist/big")
assert detail is not None
assert len(detail.tracks) == 150, "pre-#838 the adapter capped this at 100"
assert detail.meta.track_count == 150