fix: make playlist detail page publicly viewable (#519)

- add get_record_public() to _internal/atproto/records.py for unauthenticated ATProto record fetches
- remove auth requirement from GET /playlists/{id} endpoint
- remove auth redirect from frontend playlist page loader
- edit/delete buttons still only show for playlist owner (via client-side auth check)

ATProto records are public by design - the previous auth requirement was unnecessary for read access.

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

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

authored by zzstoatzz.io Claude and committed by GitHub 525ba641 56e29fb8

Changed files
+58 -50
backend
src
backend
_internal
atproto
api
frontend
src
routes
playlist
[id]
+40
backend/src/backend/_internal/atproto/records.py
··· 335 335 return parts[0], parts[1], parts[2] 336 336 337 337 338 + async def get_record_public( 339 + record_uri: str, 340 + pds_url: str | None = None, 341 + ) -> dict[str, Any]: 342 + """fetch an ATProto record without authentication. 343 + 344 + ATProto records are public by design - any client can read them. 345 + uses the owner's PDS URL if provided, otherwise falls back to 346 + bsky.network relay which indexes all public records. 347 + 348 + args: 349 + record_uri: AT URI of the record (at://did/collection/rkey) 350 + pds_url: optional PDS URL to use (falls back to bsky.network) 351 + 352 + returns: 353 + the record value dict 354 + 355 + raises: 356 + ValueError: if URI is malformed 357 + Exception: if fetch fails 358 + """ 359 + import httpx 360 + 361 + repo, collection, rkey = _parse_at_uri(record_uri) 362 + 363 + base_url = pds_url or "https://bsky.network" 364 + url = f"{base_url}/xrpc/com.atproto.repo.getRecord" 365 + params = {"repo": repo, "collection": collection, "rkey": rkey} 366 + 367 + async with httpx.AsyncClient() as client: 368 + response = await client.get(url, params=params, timeout=10.0) 369 + 370 + if response.status_code != 200: 371 + raise Exception( 372 + f"failed to fetch record: {response.status_code} {response.text}" 373 + ) 374 + 375 + return response.json() 376 + 377 + 338 378 async def update_record( 339 379 auth_session: AuthSession, 340 380 record_uri: str,
+15 -42
backend/src/backend/api/lists.py
··· 19 19 create_list_record, 20 20 update_list_record, 21 21 ) 22 - from backend.models import Artist, Playlist, Track, TrackLike, UserPreferences, get_db 22 + from backend.models import Artist, Playlist, Track, UserPreferences, get_db 23 23 from backend.schemas import TrackResponse 24 24 from backend.storage import storage 25 25 from backend.utilities.aggregations import get_comment_counts, get_like_counts ··· 347 347 @router.get("/playlists/{playlist_id}", response_model=PlaylistWithTracksResponse) 348 348 async def get_playlist( 349 349 playlist_id: str, 350 - session: AuthSession = Depends(require_auth), 351 350 db: AsyncSession = Depends(get_db), 352 351 ) -> PlaylistWithTracksResponse: 353 - """get a playlist with full track details. 352 + """get a playlist with full track details (public, no auth required). 354 353 355 354 fetches the ATProto list record to get track ordering, then hydrates 356 355 track metadata from the database. 357 356 """ 357 + from backend._internal.atproto.records import get_record_public 358 + 358 359 # get playlist from database 359 360 result = await db.execute( 360 361 select(Playlist, Artist) ··· 368 369 369 370 playlist, artist = row 370 371 371 - # fetch ATProto list record to get track ordering 372 - oauth_data = session.oauth_session 373 - if not oauth_data or "access_token" not in oauth_data: 374 - raise HTTPException(status_code=401, detail="invalid session") 375 - 376 - oauth_session = _reconstruct_oauth_session(oauth_data) 377 - from backend._internal import get_oauth_client 378 - 379 - # parse the AT URI to get repo and rkey 380 - parts = playlist.atproto_record_uri.replace("at://", "").split("/") 381 - if len(parts) != 3: 382 - raise HTTPException(status_code=500, detail="invalid playlist URI") 372 + # fetch ATProto record (public - no auth needed) 373 + try: 374 + record_data = await get_record_public( 375 + record_uri=playlist.atproto_record_uri, 376 + pds_url=artist.pds_url, 377 + ) 378 + except Exception as e: 379 + raise HTTPException( 380 + status_code=500, detail=f"failed to fetch playlist record: {e}" 381 + ) from e 383 382 384 - repo, collection, rkey = parts 385 - 386 - # get the list record from PDS 387 - url = f"{oauth_data['pds_url']}/xrpc/com.atproto.repo.getRecord" 388 - params = {"repo": repo, "collection": collection, "rkey": rkey} 389 - 390 - response = await get_oauth_client().make_authenticated_request( 391 - session=oauth_session, 392 - method="GET", 393 - url=url, 394 - params=params, 395 - ) 396 - 397 - if response.status_code != 200: 398 - raise HTTPException(status_code=500, detail="failed to fetch playlist record") 399 - 400 - record_data = response.json() 401 383 items = record_data.get("value", {}).get("items", []) 402 384 403 385 # extract track URIs in order ··· 422 404 like_counts = await get_like_counts(db, track_ids) if track_ids else {} 423 405 comment_counts = await get_comment_counts(db, track_ids) if track_ids else {} 424 406 425 - # get authenticated user's liked tracks 407 + # no authenticated user for public endpoint - liked status not available 426 408 liked_track_ids: set[int] = set() 427 - if track_ids: 428 - liked_result = await db.execute( 429 - select(TrackLike.track_id).where( 430 - TrackLike.user_did == session.did, 431 - TrackLike.track_id.in_(track_ids), 432 - ) 433 - ) 434 - liked_track_ids = set(liked_result.scalars().all()) 435 409 436 410 # maintain ATProto ordering, skip unavailable tracks 437 411 for uri in track_uris: ··· 439 413 track = track_by_uri[uri] 440 414 track_response = await TrackResponse.from_track( 441 415 track, 442 - pds_url=oauth_data.get("pds_url"), 443 416 liked_track_ids=liked_track_ids, 444 417 like_counts=like_counts, 445 418 comment_counts=comment_counts,
+3 -8
frontend/src/routes/playlist/[id]/+page.ts
··· 1 1 import { browser } from '$app/environment'; 2 - import { redirect, error } from '@sveltejs/kit'; 2 + import { error } from '@sveltejs/kit'; 3 3 import { API_URL } from '$lib/config'; 4 4 import type { LoadEvent } from '@sveltejs/kit'; 5 5 import type { PlaylistWithTracks, Playlist } from '$lib/types'; ··· 9 9 playlistMeta: Playlist | null; 10 10 } 11 11 12 - export async function load({ params, parent, data }: LoadEvent): Promise<PageData> { 12 + export async function load({ params, data }: LoadEvent): Promise<PageData> { 13 13 // server data for OG tags 14 14 const serverData = data as { playlistMeta: Playlist | null } | undefined; 15 15 ··· 33 33 }; 34 34 } 35 35 36 - // check auth from parent layout data 37 - const { isAuthenticated } = await parent(); 38 - if (!isAuthenticated) { 39 - throw redirect(302, '/'); 40 - } 41 - 36 + // playlist endpoint is public - no auth required 42 37 const response = await fetch(`${API_URL}/lists/playlists/${params.id}`, { 43 38 credentials: 'include' 44 39 });