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:
BoulderBadgeDad 2026-06-06 13:50:49 -07:00
parent df19317dac
commit fe1366d9e0
2 changed files with 142 additions and 2 deletions

View file

@ -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:

View 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