Mirrored "Liked Songs" stops 400ing on every auto-refresh (wolf39us)
"Liked Songs" isn't a real Spotify playlist — no playlist URI exists for it; the web UI invents the virtual id 'spotify:liked-songs' and Spotify serves the collection via the saved-tracks endpoint. The playlist DETAIL endpoint special- cases that id, but the mirrored refresh path resolves stored ids through get_playlist_by_id, which fed the virtual id straight into sp.playlist() -> "http status: 400 ... Unsupported URL / URI" on every sync cycle, silently. get_playlist_by_id now special-cases the virtual id at the client seam (every by-id resolver benefits, not just the mirror adapter): it builds the Playlist from the existing get_saved_tracks() pagination, with the real owner name and track count. New LIKED_SONGS_PLAYLIST_ID constant owns the magic string. Safety: get_saved_tracks swallows fetch errors into [] — indistinguishable from "no likes" — and the virtual playlist is only ever offered when likes exist. An empty result therefore resolves as a FAILED refresh (None) instead of a valid-looking empty playlist a mirror sync might propagate by clearing the server-side copy. Tests: virtual id resolves from saved tracks and never touches the playlist endpoint, real ids still do (regression), the mirrored adapter seam returns a full PlaylistDetail, and empty saved-tracks -> None. 473 passed across the playlist/mirror/spotify families.
This commit is contained in:
parent
df19317dac
commit
fe1366d9e0
2 changed files with 142 additions and 2 deletions
|
|
@ -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:
|
||||
|
|
|
|||
91
tests/test_liked_songs_playlist.py
Normal file
91
tests/test_liked_songs_playlist.py
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue