diff --git a/core/spotify_client.py b/core/spotify_client.py index efd83021..0dafdcde 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -471,6 +471,14 @@ class Album: artist_ids=[artist['id'] for artist in album_data['artists']] ) +# The web UI's virtual "Liked Songs" playlist id. There is NO real playlist +# behind a user's liked songs — Spotify serves them via /me/tracks (saved +# tracks), and passing this id to the playlist endpoint 400s with +# "Unsupported URL / URI". Anything resolving playlists by id must special-case +# it (get_playlist_by_id does). +LIKED_SONGS_PLAYLIST_ID = "spotify:liked-songs" + + @dataclass class Playlist: id: str @@ -1287,15 +1295,56 @@ class SpotifyClient: def get_playlist_by_id(self, playlist_id: str) -> Optional[Playlist]: if not self.is_spotify_authenticated(): return None - + + # "Liked Songs" is virtual — no playlist URI exists for it, so the + # playlist endpoint 400s ("Unsupported URL / URI"). Serve it from the + # saved-tracks endpoint instead, so mirrored playlists / anything that + # re-resolves by stored id can refresh it like a normal playlist. + if playlist_id in (LIKED_SONGS_PLAYLIST_ID, 'liked-songs'): + return self._liked_songs_as_playlist() + try: playlist_data = self.sp.playlist(playlist_id) tracks = self._get_playlist_tracks(playlist_id) return Playlist.from_spotify_playlist(playlist_data, tracks) - + except Exception as e: logger.error(f"Error fetching playlist {playlist_id}: {e}") return None + + def _liked_songs_as_playlist(self) -> Optional[Playlist]: + """Build a ``Playlist`` for the virtual Liked Songs collection from the + saved-tracks endpoint (the only API that serves it).""" + try: + tracks = self.get_saved_tracks() + if not tracks: + # get_saved_tracks swallows fetch errors into [] — indistinguishable + # from "no likes". The virtual playlist is only offered when likes + # exist, so treat empty as a FAILED refresh (None) rather than hand + # the sync a valid-looking empty playlist it might mirror by + # clearing the server-side copy. + logger.error("Liked Songs resolve: saved-tracks fetch returned nothing — treating as failed refresh") + return None + owner = 'You' + try: + info = self.get_user_info() + if info and info.get('display_name'): + owner = info['display_name'] + except Exception: # noqa: S110 — owner label is cosmetic; default stands + pass + return Playlist( + id=LIKED_SONGS_PLAYLIST_ID, + name='Liked Songs', + description='Your liked songs on Spotify', + owner=owner, + public=False, + collaborative=False, + tracks=tracks, + total_tracks=len(tracks), + ) + except Exception as e: + logger.error(f"Error building Liked Songs playlist: {e}") + return None @rate_limited def get_followed_artists(self) -> list: diff --git a/tests/test_liked_songs_playlist.py b/tests/test_liked_songs_playlist.py new file mode 100644 index 00000000..92bd38ed --- /dev/null +++ b/tests/test_liked_songs_playlist.py @@ -0,0 +1,91 @@ +"""Liked Songs virtual-playlist resolution. + +wolf39us: the mirrored "Liked" playlist silently failed every refresh with +``Error fetching playlist spotify:liked-songs: http status: 400 ... Unsupported +URL / URI``. There is no real playlist URI behind a user's liked songs — the +web UI invents the virtual id ``spotify:liked-songs`` and Spotify serves the +collection via the saved-tracks endpoint. ``get_playlist_by_id`` (what the +mirrored refresh path resolves stored ids through) must special-case it. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from core.spotify_client import LIKED_SONGS_PLAYLIST_ID, SpotifyClient + + +def _track(i): + return SimpleNamespace( + id=f'trk{i}', name=f'Song {i}', artists=['Artist'], + album='Album', duration_ms=200_000, image_url=None, + popularity=10, external_urls=None, preview_url=None, + ) + + +def _client(monkeypatch, saved): + client = SpotifyClient.__new__(SpotifyClient) + client.sp = MagicMock() + monkeypatch.setattr(client, 'is_spotify_authenticated', lambda: True) + monkeypatch.setattr(client, 'get_saved_tracks', lambda: list(saved)) + monkeypatch.setattr(client, 'get_user_info', lambda: {'display_name': 'Wolf'}) + return client + + +def test_virtual_liked_songs_id_resolves_from_saved_tracks(monkeypatch): + client = _client(monkeypatch, [_track(1), _track(2)]) + client.sp.playlist.side_effect = AssertionError( + 'sp.playlist() must not be called for the virtual Liked Songs id') + + pl = client.get_playlist_by_id(LIKED_SONGS_PLAYLIST_ID) + + assert pl is not None + assert pl.id == LIKED_SONGS_PLAYLIST_ID + assert pl.name == 'Liked Songs' and pl.owner == 'Wolf' + assert pl.total_tracks == 2 and len(pl.tracks) == 2 + client.sp.playlist.assert_not_called() + + +def test_real_playlist_id_still_uses_playlist_endpoint(monkeypatch): + """Regression guard: normal playlists keep going through sp.playlist().""" + client = _client(monkeypatch, []) + client.sp.playlist.return_value = { + 'id': 'pl1', 'name': 'Mix', 'description': '', 'public': True, + 'collaborative': False, 'owner': {'display_name': 'Wolf'}, + 'tracks': {'total': 0}, + } + monkeypatch.setattr(client, '_get_playlist_tracks', lambda pid: []) + + pl = client.get_playlist_by_id('pl1') + + assert pl is not None and pl.id == 'pl1' and pl.name == 'Mix' + client.sp.playlist.assert_called_once_with('pl1') + + +def test_mirrored_adapter_resolves_liked_songs(monkeypatch): + """The seam that actually failed: SpotifyPlaylistSource.get_playlist — the + mirrored-playlist refresh path — with the stored virtual id.""" + from core.playlists.sources.spotify import SpotifyPlaylistSource + + client = _client(monkeypatch, [_track(1)]) + client.sp.playlist.side_effect = AssertionError('must not hit the playlist endpoint') + src = SpotifyPlaylistSource(lambda: client) + + detail = src.get_playlist(LIKED_SONGS_PLAYLIST_ID) + + assert detail is not None + assert detail.meta.name == 'Liked Songs' + assert detail.meta.source_playlist_id == LIKED_SONGS_PLAYLIST_ID + assert len(detail.tracks) == 1 + assert detail.tracks[0].track_name == 'Song 1' + assert detail.tracks[0].artist_name == 'Artist' + + +def test_empty_saved_tracks_is_a_failed_refresh_not_an_empty_playlist(monkeypatch): + """get_saved_tracks swallows fetch errors into [] — indistinguishable from + 'no likes'. A valid-looking EMPTY playlist could make a mirror sync clear + the server-side copy, so empty must resolve as a failed refresh (None).""" + client = _client(monkeypatch, []) + + assert client.get_playlist_by_id(LIKED_SONGS_PLAYLIST_ID) is None