"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.
91 lines
3.6 KiB
Python
91 lines
3.6 KiB
Python
"""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
|