fix: show liked state on playlist detail page (#589)

the playlist endpoint wasn't checking for authenticated users, so
tracks always showed is_liked: false even when the user had liked them.

backend:
- add session cookie check to GET /lists/playlists/{id}
- query user's liked tracks when authenticated
- pass liked_track_ids to TrackResponse.from_track()

frontend:
- add client-side liked state hydration (matching artist page pattern)
- prime from localStorage cache, then fetch fresh data

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub b1d59a66 83a527d5

Changed files
+361 -5
backend
src
backend
api
tests
frontend
src
routes
playlist
+29 -5
backend/src/backend/api/lists.py
··· 6 from pathlib import Path 7 from typing import Annotated 8 9 - from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile 10 from pydantic import BaseModel 11 from sqlalchemy import select 12 from sqlalchemy.ext.asyncio import AsyncSession ··· 19 create_list_record, 20 update_list_record, 21 ) 22 - from backend.models import Artist, Playlist, Track, UserPreferences, get_db 23 from backend.schemas import TrackResponse 24 from backend.storage import storage 25 from backend.utilities.aggregations import get_comment_counts, get_like_counts ··· 347 @router.get("/playlists/{playlist_id}", response_model=PlaylistWithTracksResponse) 348 async def get_playlist( 349 playlist_id: str, 350 db: AsyncSession = Depends(get_db), 351 ) -> PlaylistWithTracksResponse: 352 - """get a playlist with full track details (public, no auth required). 353 354 fetches the ATProto list record to get track ordering, then hydrates 355 - track metadata from the database. 356 """ 357 from backend._internal.atproto.records import get_record_public 358 ··· 404 like_counts = await get_like_counts(db, track_ids) if track_ids else {} 405 comment_counts = await get_comment_counts(db, track_ids) if track_ids else {} 406 407 - # no authenticated user for public endpoint - liked status not available 408 liked_track_ids: set[int] = set() 409 410 # maintain ATProto ordering, skip unavailable tracks 411 for uri in track_uris:
··· 6 from pathlib import Path 7 from typing import Annotated 8 9 + from fastapi import ( 10 + APIRouter, 11 + Cookie, 12 + Depends, 13 + File, 14 + Form, 15 + HTTPException, 16 + Request, 17 + UploadFile, 18 + ) 19 from pydantic import BaseModel 20 from sqlalchemy import select 21 from sqlalchemy.ext.asyncio import AsyncSession ··· 28 create_list_record, 29 update_list_record, 30 ) 31 + from backend._internal.auth import get_session 32 + from backend.models import Artist, Playlist, Track, TrackLike, UserPreferences, get_db 33 from backend.schemas import TrackResponse 34 from backend.storage import storage 35 from backend.utilities.aggregations import get_comment_counts, get_like_counts ··· 357 @router.get("/playlists/{playlist_id}", response_model=PlaylistWithTracksResponse) 358 async def get_playlist( 359 playlist_id: str, 360 + request: Request, 361 db: AsyncSession = Depends(get_db), 362 + session_id_cookie: str | None = Cookie(default=None, alias="session_id"), 363 ) -> PlaylistWithTracksResponse: 364 + """get a playlist with full track details (public, auth optional for liked state). 365 366 fetches the ATProto list record to get track ordering, then hydrates 367 + track metadata from the database. if authenticated, includes liked state. 368 """ 369 from backend._internal.atproto.records import get_record_public 370 ··· 416 like_counts = await get_like_counts(db, track_ids) if track_ids else {} 417 comment_counts = await get_comment_counts(db, track_ids) if track_ids else {} 418 419 + # get authenticated user's likes if session available 420 liked_track_ids: set[int] = set() 421 + session_id = session_id_cookie or request.headers.get( 422 + "authorization", "" 423 + ).replace("Bearer ", "") 424 + if session_id and (auth_session := await get_session(session_id)): 425 + if track_ids: 426 + liked_result = await db.execute( 427 + select(TrackLike.track_id).where( 428 + TrackLike.user_did == auth_session.did, 429 + TrackLike.track_id.in_(track_ids), 430 + ) 431 + ) 432 + liked_track_ids = set(liked_result.scalars().all()) 433 434 # maintain ATProto ordering, skip unavailable tracks 435 for uri in track_uris:
+249
backend/tests/api/test_playlist_liked_state.py
···
··· 1 + """tests for playlist track liked state (regression test for liked state bug).""" 2 + 3 + from collections.abc import Generator 4 + from unittest.mock import AsyncMock, patch 5 + 6 + import pytest 7 + from fastapi import FastAPI 8 + from httpx import ASGITransport, AsyncClient 9 + from sqlalchemy.ext.asyncio import AsyncSession 10 + 11 + from backend._internal import Session 12 + from backend.main import app 13 + from backend.models import Artist, Playlist, Track, TrackLike 14 + 15 + 16 + class MockSession(Session): 17 + """mock session for auth bypass in tests.""" 18 + 19 + def __init__(self, did: str = "did:test:user123"): 20 + self.did = did 21 + self.handle = "testuser.bsky.social" 22 + self.session_id = "test_session_id" 23 + self.access_token = "test_token" 24 + self.refresh_token = "test_refresh" 25 + self.oauth_session = { 26 + "did": did, 27 + "handle": "testuser.bsky.social", 28 + "pds_url": "https://test.pds", 29 + "authserver_iss": "https://auth.test", 30 + "scope": "atproto transition:generic", 31 + "access_token": "test_token", 32 + "refresh_token": "test_refresh", 33 + "dpop_private_key_pem": "fake_key", 34 + "dpop_authserver_nonce": "", 35 + "dpop_pds_nonce": "", 36 + } 37 + 38 + 39 + @pytest.fixture 40 + async def test_artist(db_session: AsyncSession) -> Artist: 41 + """create a test artist (owns the playlist).""" 42 + artist = Artist( 43 + did="did:plc:playlistowner", 44 + handle="owner.bsky.social", 45 + display_name="Playlist Owner", 46 + pds_url="https://test.pds", 47 + ) 48 + db_session.add(artist) 49 + await db_session.commit() 50 + await db_session.refresh(artist) 51 + return artist 52 + 53 + 54 + @pytest.fixture 55 + async def test_track_artist(db_session: AsyncSession) -> Artist: 56 + """create a test artist (owns the tracks).""" 57 + artist = Artist( 58 + did="did:plc:trackartist", 59 + handle="trackartist.bsky.social", 60 + display_name="Track Artist", 61 + pds_url="https://test.pds", 62 + ) 63 + db_session.add(artist) 64 + await db_session.commit() 65 + await db_session.refresh(artist) 66 + return artist 67 + 68 + 69 + @pytest.fixture 70 + async def test_tracks( 71 + db_session: AsyncSession, test_track_artist: Artist 72 + ) -> list[Track]: 73 + """create test tracks with ATProto records.""" 74 + tracks = [] 75 + for i in range(3): 76 + track = Track( 77 + title=f"Track {i + 1}", 78 + file_id=f"playlisttrack{i}", 79 + file_type="audio/mpeg", 80 + artist_did=test_track_artist.did, 81 + atproto_record_uri=f"at://did:plc:trackartist/fm.plyr.track/track{i}", 82 + atproto_record_cid=f"bafytrack{i}", 83 + ) 84 + db_session.add(track) 85 + tracks.append(track) 86 + 87 + await db_session.commit() 88 + for track in tracks: 89 + await db_session.refresh(track) 90 + 91 + return tracks 92 + 93 + 94 + @pytest.fixture 95 + async def test_playlist( 96 + db_session: AsyncSession, test_artist: Artist, test_tracks: list[Track] 97 + ) -> Playlist: 98 + """create a test playlist with ATProto record.""" 99 + playlist = Playlist( 100 + id="test-playlist-id", 101 + name="Test Playlist", 102 + owner_did=test_artist.did, 103 + atproto_record_uri=f"at://{test_artist.did}/fm.plyr.playlist/testplaylist", 104 + atproto_record_cid="bafyplaylistcid123", 105 + track_count=len(test_tracks), 106 + ) 107 + db_session.add(playlist) 108 + await db_session.commit() 109 + await db_session.refresh(playlist) 110 + return playlist 111 + 112 + 113 + @pytest.fixture 114 + async def liked_track(db_session: AsyncSession, test_tracks: list[Track]) -> TrackLike: 115 + """create a like for the first track by the test user.""" 116 + like = TrackLike( 117 + user_did="did:test:user123", 118 + track_id=test_tracks[0].id, 119 + atproto_like_uri=f"at://did:test:user123/app.bsky.feed.like/{test_tracks[0].id}", 120 + ) 121 + db_session.add(like) 122 + await db_session.commit() 123 + await db_session.refresh(like) 124 + return like 125 + 126 + 127 + @pytest.fixture 128 + def test_app(db_session: AsyncSession) -> Generator[FastAPI, None, None]: 129 + """create test app (no auth override - uses session cookie).""" 130 + yield app 131 + 132 + 133 + async def test_playlist_returns_liked_state_for_authenticated_user( 134 + db_session: AsyncSession, 135 + test_app: FastAPI, 136 + test_artist: Artist, 137 + test_tracks: list[Track], 138 + test_playlist: Playlist, 139 + liked_track: TrackLike, 140 + ): 141 + """test that playlist endpoint returns is_liked=True for authenticated user's liked tracks. 142 + 143 + this is a regression test for the bug where playlist tracks never showed 144 + the liked state even when the user had liked them. 145 + """ 146 + # mock the ATProto record fetch to return our test tracks 147 + mock_record_data = { 148 + "value": { 149 + "items": [ 150 + { 151 + "subject": { 152 + "uri": track.atproto_record_uri, 153 + "cid": track.atproto_record_cid, 154 + } 155 + } 156 + for track in test_tracks 157 + ] 158 + } 159 + } 160 + 161 + # mock get_session to return our test user session 162 + mock_session = MockSession() 163 + 164 + with ( 165 + patch( 166 + "backend._internal.atproto.records.get_record_public", 167 + new_callable=AsyncMock, 168 + return_value=mock_record_data, 169 + ), 170 + patch( 171 + "backend.api.lists.get_session", 172 + new_callable=AsyncMock, 173 + return_value=mock_session, 174 + ), 175 + ): 176 + async with AsyncClient( 177 + transport=ASGITransport(app=test_app), 178 + base_url="http://test", 179 + cookies={"session_id": "test_session_id"}, 180 + ) as client: 181 + response = await client.get(f"/lists/playlists/{test_playlist.id}") 182 + 183 + assert response.status_code == 200 184 + data = response.json() 185 + 186 + # verify playlist metadata 187 + assert data["name"] == "Test Playlist" 188 + assert len(data["tracks"]) == 3 189 + 190 + # the first track should be marked as liked 191 + assert data["tracks"][0]["is_liked"] is True 192 + assert data["tracks"][0]["title"] == "Track 1" 193 + 194 + # other tracks should not be liked 195 + assert data["tracks"][1]["is_liked"] is False 196 + assert data["tracks"][2]["is_liked"] is False 197 + 198 + 199 + async def test_playlist_returns_no_liked_state_for_unauthenticated_user( 200 + db_session: AsyncSession, 201 + test_app: FastAPI, 202 + test_artist: Artist, 203 + test_tracks: list[Track], 204 + test_playlist: Playlist, 205 + liked_track: TrackLike, 206 + ): 207 + """test that playlist endpoint returns is_liked=False for all tracks when not authenticated. 208 + 209 + even if tracks have likes, unauthenticated users should not see their own liked state. 210 + """ 211 + # mock the ATProto record fetch to return our test tracks 212 + mock_record_data = { 213 + "value": { 214 + "items": [ 215 + { 216 + "subject": { 217 + "uri": track.atproto_record_uri, 218 + "cid": track.atproto_record_cid, 219 + } 220 + } 221 + for track in test_tracks 222 + ] 223 + } 224 + } 225 + 226 + with ( 227 + patch( 228 + "backend._internal.atproto.records.get_record_public", 229 + new_callable=AsyncMock, 230 + return_value=mock_record_data, 231 + ), 232 + patch( 233 + "backend.api.lists.get_session", 234 + new_callable=AsyncMock, 235 + return_value=None, 236 + ), 237 + ): 238 + async with AsyncClient( 239 + transport=ASGITransport(app=test_app), 240 + base_url="http://test", 241 + ) as client: 242 + response = await client.get(f"/lists/playlists/{test_playlist.id}") 243 + 244 + assert response.status_code == 200 245 + data = response.json() 246 + 247 + # all tracks should have is_liked=False when not authenticated 248 + for track in data["tracks"]: 249 + assert track["is_liked"] is False
+83
frontend/src/routes/playlist/[id]/+page.svelte
··· 6 import { auth } from "$lib/auth.svelte"; 7 import { goto } from "$app/navigation"; 8 import { page } from "$app/stores"; 9 import { API_URL } from "$lib/config"; 10 import { APP_NAME, APP_CANONICAL_URL } from "$lib/branding"; 11 import { toast } from "$lib/toast.svelte"; 12 import { player } from "$lib/player.svelte"; 13 import { queue } from "$lib/queue.svelte"; 14 import type { PageData } from "./$types"; 15 import type { PlaylistWithTracks, Track } from "$lib/types"; 16 17 let { data }: { data: PageData } = $props(); 18 let playlist = $state<PlaylistWithTracks>(data.playlist); 19 let tracks = $state<Track[]>(data.playlist.tracks); 20 21 // search state 22 let showSearch = $state(false);
··· 6 import { auth } from "$lib/auth.svelte"; 7 import { goto } from "$app/navigation"; 8 import { page } from "$app/stores"; 9 + import { browser } from "$app/environment"; 10 import { API_URL } from "$lib/config"; 11 import { APP_NAME, APP_CANONICAL_URL } from "$lib/branding"; 12 import { toast } from "$lib/toast.svelte"; 13 import { player } from "$lib/player.svelte"; 14 import { queue } from "$lib/queue.svelte"; 15 + import { fetchLikedTracks } from "$lib/tracks.svelte"; 16 import type { PageData } from "./$types"; 17 import type { PlaylistWithTracks, Track } from "$lib/types"; 18 19 let { data }: { data: PageData } = $props(); 20 let playlist = $state<PlaylistWithTracks>(data.playlist); 21 let tracks = $state<Track[]>(data.playlist.tracks); 22 + 23 + // liked state hydration 24 + let tracksHydrated = $state(false); 25 + let loadedForPlaylistId = $state<string | null>(null); 26 + 27 + // sync tracks when navigating between playlists 28 + $effect(() => { 29 + const currentId = data.playlist.id; 30 + if (!currentId || !browser) return; 31 + 32 + if (loadedForPlaylistId !== currentId) { 33 + // reset state for new playlist 34 + tracksHydrated = false; 35 + playlist = data.playlist; 36 + tracks = data.playlist.tracks; 37 + loadedForPlaylistId = currentId; 38 + 39 + // hydrate liked state 40 + primeLikesFromCache(); 41 + void hydrateTracksWithLikes(); 42 + } 43 + }); 44 + 45 + async function hydrateTracksWithLikes() { 46 + if (!browser || tracksHydrated) return; 47 + 48 + // skip if not authenticated - no need to fetch liked tracks 49 + if (!auth.isAuthenticated) { 50 + tracksHydrated = true; 51 + return; 52 + } 53 + 54 + try { 55 + const likedTracks = await fetchLikedTracks(); 56 + const likedIds = new Set(likedTracks.map(track => track.id)); 57 + applyLikedFlags(likedIds); 58 + } catch (_e) { 59 + console.error('failed to hydrate playlist likes:', _e); 60 + } finally { 61 + tracksHydrated = true; 62 + } 63 + } 64 + 65 + function applyLikedFlags(likedIds: Set<number>) { 66 + let changed = false; 67 + 68 + const nextTracks = tracks.map(track => { 69 + const nextLiked = likedIds.has(track.id); 70 + const currentLiked = Boolean(track.is_liked); 71 + if (currentLiked !== nextLiked) { 72 + changed = true; 73 + return { ...track, is_liked: nextLiked }; 74 + } 75 + return track; 76 + }); 77 + 78 + if (changed) { 79 + tracks = nextTracks; 80 + } 81 + } 82 + 83 + function primeLikesFromCache() { 84 + if (!browser) return; 85 + try { 86 + const cachedRaw = localStorage.getItem('tracks_cache'); 87 + if (!cachedRaw) return; 88 + const cached = JSON.parse(cachedRaw) as { tracks?: Track[] }; 89 + const cachedTracks = cached.tracks ?? []; 90 + if (cachedTracks.length === 0) return; 91 + 92 + const likedIds = new Set( 93 + cachedTracks.filter(track => Boolean(track.is_liked)).map(track => track.id) 94 + ); 95 + 96 + if (likedIds.size > 0) { 97 + applyLikedFlags(likedIds); 98 + } 99 + } catch (e) { 100 + console.warn('failed to hydrate likes from cache', e); 101 + } 102 + } 103 104 // search state 105 let showSearch = $state(false);