audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: resolve album AT-URIs through generic list resolver (#1206) (#1223)

AT-URI resolution for `*.list` URIs only checked the Playlist table,
so album AT-URIs returned 404. Add `GET /lists/by-uri` that checks
both Album and Playlist tables and returns type-specific routing info.

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

authored by

nate nowack
Claude Opus 4.6
and committed by
GitHub
bf2e494b ff9f405d

+191 -6
+41 -1
backend/src/backend/api/lists.py
··· 3 3 import contextlib 4 4 import json 5 5 import logging 6 - from typing import Annotated 6 + from typing import Annotated, Literal 7 7 8 8 from fastapi import ( 9 9 APIRouter, ··· 195 195 raise HTTPException( 196 196 status_code=500, detail=f"failed to reorder list: {e}" 197 197 ) from e 198 + 199 + 200 + # --- generic list resolver --- 201 + 202 + 203 + class ListByUriResponse(BaseModel): 204 + """resolved list type and routing info for an AT-URI.""" 205 + 206 + type: Literal["album", "playlist"] 207 + id: str 208 + handle: str | None = None 209 + slug: str | None = None 210 + 211 + 212 + @router.get("/by-uri", response_model=ListByUriResponse) 213 + async def resolve_list_by_uri( 214 + uri: Annotated[str, Query(description="AT-URI of a list record")], 215 + db: AsyncSession = Depends(get_db), 216 + ) -> ListByUriResponse: 217 + """resolve a list AT-URI to its type (album or playlist) with routing info.""" 218 + # check albums first 219 + result = await db.execute( 220 + select(Album, Artist) 221 + .join(Artist, Album.artist_did == Artist.did) 222 + .where(Album.atproto_record_uri == uri) 223 + ) 224 + if row := result.first(): 225 + album, artist = row 226 + return ListByUriResponse( 227 + type="album", id=album.id, handle=artist.handle, slug=album.slug 228 + ) 229 + 230 + # check playlists 231 + result = await db.execute( 232 + select(Playlist).where(Playlist.atproto_record_uri == uri) 233 + ) 234 + if playlist := result.scalar_one_or_none(): 235 + return ListByUriResponse(type="playlist", id=playlist.id) 236 + 237 + raise HTTPException(status_code=404, detail="list not found") 198 238 199 239 200 240 # --- playlist CRUD endpoints ---
+141
backend/tests/api/test_list_by_uri.py
··· 1 + """tests for generic list resolver endpoint (GET /lists/by-uri).""" 2 + 3 + from collections.abc import Generator 4 + 5 + import pytest 6 + from fastapi import FastAPI 7 + from httpx import ASGITransport, AsyncClient 8 + from sqlalchemy.ext.asyncio import AsyncSession 9 + 10 + from backend._internal import Session, require_auth 11 + from backend.main import app 12 + from backend.models import Album, Artist, Playlist 13 + 14 + ALBUM_URI = "at://did:plc:artist123/fm.plyr.list/album456" 15 + PLAYLIST_URI = "at://did:plc:artist123/fm.plyr.list/playlist789" 16 + 17 + 18 + class MockSession(Session): 19 + """mock session for auth bypass in tests.""" 20 + 21 + def __init__(self, did: str = "did:test:user123"): 22 + self.did = did 23 + self.handle = "testuser.bsky.social" 24 + self.session_id = "test_session_id" 25 + self.access_token = "test_token" 26 + self.refresh_token = "test_refresh" 27 + self.oauth_session = { 28 + "did": did, 29 + "handle": "testuser.bsky.social", 30 + "pds_url": "https://test.pds", 31 + "authserver_iss": "https://auth.test", 32 + "scope": "atproto transition:generic", 33 + "access_token": "test_token", 34 + "refresh_token": "test_refresh", 35 + "dpop_private_key_pem": "fake_key", 36 + "dpop_authserver_nonce": "", 37 + "dpop_pds_nonce": "", 38 + } 39 + 40 + 41 + @pytest.fixture 42 + async def test_artist(db_session: AsyncSession) -> Artist: 43 + """create a test artist.""" 44 + artist = Artist( 45 + did="did:plc:artist123", 46 + handle="artist.bsky.social", 47 + display_name="Test Artist", 48 + ) 49 + db_session.add(artist) 50 + await db_session.commit() 51 + return artist 52 + 53 + 54 + @pytest.fixture 55 + async def test_album(db_session: AsyncSession, test_artist: Artist) -> Album: 56 + """create a test album.""" 57 + album = Album( 58 + artist_did=test_artist.did, 59 + title="Test Album", 60 + slug="test-album", 61 + atproto_record_uri=ALBUM_URI, 62 + atproto_record_cid="bafyalbum456", 63 + ) 64 + db_session.add(album) 65 + await db_session.commit() 66 + await db_session.refresh(album) 67 + return album 68 + 69 + 70 + @pytest.fixture 71 + async def test_playlist(db_session: AsyncSession, test_artist: Artist) -> Playlist: 72 + """create a test playlist.""" 73 + playlist = Playlist( 74 + owner_did=test_artist.did, 75 + name="Test Playlist", 76 + atproto_record_uri=PLAYLIST_URI, 77 + atproto_record_cid="bafyplaylist789", 78 + track_count=0, 79 + ) 80 + db_session.add(playlist) 81 + await db_session.commit() 82 + await db_session.refresh(playlist) 83 + return playlist 84 + 85 + 86 + @pytest.fixture 87 + def test_app(db_session: AsyncSession) -> Generator[FastAPI, None, None]: 88 + """create test app with mocked auth.""" 89 + 90 + async def mock_require_auth() -> Session: 91 + return MockSession() 92 + 93 + app.dependency_overrides[require_auth] = mock_require_auth 94 + yield app 95 + app.dependency_overrides.clear() 96 + 97 + 98 + async def test_resolve_album_uri( 99 + test_app: FastAPI, db_session: AsyncSession, test_album: Album 100 + ) -> None: 101 + """album AT-URI returns type=album with handle and slug.""" 102 + async with AsyncClient( 103 + transport=ASGITransport(app=test_app), base_url="http://test" 104 + ) as client: 105 + response = await client.get("/lists/by-uri", params={"uri": ALBUM_URI}) 106 + 107 + assert response.status_code == 200 108 + data = response.json() 109 + assert data["type"] == "album" 110 + assert data["id"] == test_album.id 111 + assert data["handle"] == "artist.bsky.social" 112 + assert data["slug"] == "test-album" 113 + 114 + 115 + async def test_resolve_playlist_uri( 116 + test_app: FastAPI, db_session: AsyncSession, test_playlist: Playlist 117 + ) -> None: 118 + """playlist AT-URI returns type=playlist with id.""" 119 + async with AsyncClient( 120 + transport=ASGITransport(app=test_app), base_url="http://test" 121 + ) as client: 122 + response = await client.get("/lists/by-uri", params={"uri": PLAYLIST_URI}) 123 + 124 + assert response.status_code == 200 125 + data = response.json() 126 + assert data["type"] == "playlist" 127 + assert data["id"] == test_playlist.id 128 + 129 + 130 + async def test_resolve_unknown_uri(test_app: FastAPI, db_session: AsyncSession) -> None: 131 + """unknown AT-URI returns 404.""" 132 + async with AsyncClient( 133 + transport=ASGITransport(app=test_app), base_url="http://test" 134 + ) as client: 135 + response = await client.get( 136 + "/lists/by-uri", 137 + params={"uri": "at://did:plc:nobody/fm.plyr.list/nope"}, 138 + ) 139 + 140 + assert response.status_code == 404 141 + assert response.json()["detail"] == "list not found"
+8 -4
frontend/src/routes/at/[...uri]/+page.server.ts
··· 48 48 49 49 if (uri.collection.endsWith('.list')) { 50 50 const response = await fetch( 51 - `${API_URL}/lists/playlists/by-uri?uri=${encodeURIComponent(uri.toString())}` 51 + `${API_URL}/lists/by-uri?uri=${encodeURIComponent(uri.toString())}` 52 52 ); 53 53 if (!response.ok) { 54 - throw error(404, 'playlist not found'); 54 + throw error(404, 'list not found'); 55 55 } 56 - const playlist: { id: number } = await response.json(); 57 - throw redirect(301, `/playlist/${playlist.id}`); 56 + const list: { type: 'album' | 'playlist'; id: string; handle?: string; slug?: string } = 57 + await response.json(); 58 + if (list.type === 'album' && list.handle && list.slug) { 59 + throw redirect(301, `/u/${list.handle}/album/${list.slug}`); 60 + } 61 + throw redirect(301, `/playlist/${list.id}`); 58 62 } 59 63 60 64 throw error(404, `unsupported collection: ${uri.collection}`);
+1 -1
loq.toml
··· 35 35 36 36 [[rules]] 37 37 path = "backend/src/backend/api/lists.py" 38 - max_lines = 1099 38 + max_lines = 1137 39 39 40 40 [[rules]] 41 41 path = "backend/src/backend/api/tracks/listing.py"