audio streaming app plyr.fm
38
fork

Configure Feed

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

fix: support PDS-backed audio playback for gated and public tracks (#1071)

* fix: support PDS-backed audio playback for gated and public tracks

audio streaming endpoint now resolves PDS blob URLs for tracks stored
on PDS (audio_storage='pds' or 'both'):

- public PDS-only tracks redirect to com.atproto.sync.getBlob
- gated PDS-backed tracks redirect to PDS blob after auth validation
(previously fell through to presigned R2 URL, which 404'd)
- adds _resolve_pds_url() to look up cached PDS endpoint per artist

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: create artist inline in PDS audio redirect tests

the test_audio.py conftest doesn't provide an artist fixture — create
artist records inline like other tests in the file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

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

authored by

nate nowack
Claude Opus 4.6
and committed by
GitHub
c67b5f13 bc6a31aa

+159 -6
+60 -4
backend/src/backend/api/audio.py
··· 7 7 from sqlalchemy import or_, select 8 8 9 9 from backend._internal import Session, get_optional_session, validate_supporter 10 - from backend.models import Track 10 + from backend._internal.atproto.client import pds_blob_url 11 + from backend.models import Artist, Track 11 12 from backend.storage import storage 12 13 from backend.utilities.database import db_session 13 14 14 15 router = APIRouter(prefix="/audio", tags=["audio"]) 16 + 17 + 18 + async def _resolve_pds_url(artist_did: str) -> str | None: 19 + """look up the cached PDS URL for an artist.""" 20 + async with db_session() as db: 21 + result = await db.execute( 22 + select(Artist.pds_url).where(Artist.did == artist_did).limit(1) 23 + ) 24 + return result.scalar_one_or_none() 15 25 16 26 17 27 class AudioUrlResponse(BaseModel): ··· 52 62 Track.original_file_type, 53 63 Track.support_gate, 54 64 Track.artist_did, 65 + Track.audio_storage, 66 + Track.pds_blob_cid, 55 67 ) 56 68 .where(or_(Track.file_id == file_id, Track.original_file_id == file_id)) 57 69 .order_by(Track.r2_url.is_not(None).desc(), Track.created_at.desc()) ··· 70 82 original_file_type, 71 83 support_gate, 72 84 artist_did, 85 + audio_storage, 86 + pds_blob_cid, 73 87 ) = track_data 74 88 75 89 # determine if we're serving the original lossless file ··· 85 99 artist_did=artist_did, 86 100 session=session, 87 101 is_head_request=is_head_request, 102 + audio_storage=audio_storage, 103 + pds_blob_cid=pds_blob_cid, 88 104 ) 89 105 90 106 # public track - use cached r2_url only for transcoded version 91 107 if not serving_original and r2_url and r2_url.startswith("http"): 92 108 return RedirectResponse(url=r2_url) 93 109 110 + # PDS-only tracks: redirect to PDS getBlob endpoint 111 + if audio_storage == "pds" and pds_blob_cid and not r2_url: 112 + if artist_pds_url := await _resolve_pds_url(artist_did): 113 + return RedirectResponse( 114 + url=pds_blob_url(artist_pds_url, artist_did, pds_blob_cid) 115 + ) 116 + 94 117 # get URL for the requested file (original or transcoded) 95 118 url = await storage.get_url( 96 119 serve_file_id, file_type="audio", extension=serve_file_type ··· 106 129 artist_did: str, 107 130 session: Session | None, 108 131 is_head_request: bool = False, 132 + audio_storage: str = "r2", 133 + pds_blob_cid: str | None = None, 109 134 ) -> RedirectResponse | Response: 110 135 """handle streaming for supporter-gated content. 111 136 112 137 validates that the user is authenticated and either: 113 138 - is the artist who uploaded the track, OR 114 139 - supports the artist via atprotofans 115 - before returning a presigned URL for the private bucket. 140 + before returning the appropriate URL (presigned R2 or PDS blob). 116 141 117 142 for HEAD requests (used for pre-flight auth checks), returns 200 status 118 143 without redirecting to avoid CORS issues with cross-origin redirects. ··· 150 175 if is_head_request: 151 176 return Response(status_code=200) 152 177 153 - # authorized - generate presigned URL for private bucket 178 + # authorized — resolve URL based on storage type 154 179 if session.did != artist_did: 155 180 logfire.info( 156 181 "serving gated content to supporter", ··· 159 184 artist_did=artist_did, 160 185 ) 161 186 187 + # PDS-backed gated tracks: redirect to PDS blob (unauthenticated endpoint, 188 + # gating is enforced by plyr.fm, not the PDS) 189 + if audio_storage == "pds" and pds_blob_cid: 190 + if artist_pds_url := await _resolve_pds_url(artist_did): 191 + return RedirectResponse( 192 + url=pds_blob_url(artist_pds_url, artist_did, pds_blob_cid) 193 + ) 194 + 195 + # R2-backed gated tracks: presigned URL for private bucket 162 196 url = await storage.generate_presigned_url(file_id=file_id, extension=file_type) 163 197 return RedirectResponse(url=url) 164 198 ··· 185 219 Track.original_file_type, 186 220 Track.support_gate, 187 221 Track.artist_did, 222 + Track.audio_storage, 223 + Track.pds_blob_cid, 188 224 ) 189 225 .where(or_(Track.file_id == file_id, Track.original_file_id == file_id)) 190 226 .order_by(Track.r2_url.is_not(None).desc(), Track.created_at.desc()) ··· 203 239 original_file_type, 204 240 support_gate, 205 241 artist_did, 242 + audio_storage, 243 + pds_blob_cid, 206 244 ) = track_data 207 245 208 246 # determine if we're serving the original lossless file ··· 234 272 headers={"X-Support-Required": "true"}, 235 273 ) 236 274 237 - # return presigned URL 275 + # PDS-backed gated tracks: return PDS blob URL 276 + if audio_storage == "pds" and pds_blob_cid: 277 + if artist_pds_url := await _resolve_pds_url(artist_did): 278 + return AudioUrlResponse( 279 + url=pds_blob_url(artist_pds_url, artist_did, pds_blob_cid), 280 + file_id=serve_file_id, 281 + file_type=serve_file_type, 282 + ) 283 + 284 + # R2-backed gated tracks: presigned URL for private bucket 238 285 url = await storage.generate_presigned_url( 239 286 file_id=serve_file_id, extension=serve_file_type 240 287 ) ··· 247 294 return AudioUrlResponse( 248 295 url=r2_url, file_id=serve_file_id, file_type=serve_file_type 249 296 ) 297 + 298 + # PDS-only tracks: return PDS getBlob URL 299 + if audio_storage == "pds" and pds_blob_cid and not r2_url: 300 + if artist_pds_url := await _resolve_pds_url(artist_did): 301 + return AudioUrlResponse( 302 + url=pds_blob_url(artist_pds_url, artist_did, pds_blob_cid), 303 + file_id=serve_file_id, 304 + file_type=serve_file_type, 305 + ) 250 306 251 307 # otherwise, resolve it 252 308 url = await storage.get_url(
+98 -1
backend/tests/api/test_audio.py
··· 7 7 from httpx import ASGITransport, AsyncClient 8 8 from sqlalchemy.ext.asyncio import AsyncSession 9 9 10 - from backend._internal import Session, require_auth 10 + from backend._internal import Session, get_optional_session, require_auth 11 11 from backend.main import app 12 12 from backend.models import Artist, Track 13 13 ··· 607 607 assert data["file_type"] == test_track_with_original.original_file_type 608 608 finally: 609 609 test_app.dependency_overrides.pop(require_auth, None) 610 + 611 + 612 + class TestAudioPdsRedirect: 613 + """tests for PDS-backed audio streaming.""" 614 + 615 + PDS_ARTIST_DID = "did:plc:pdsartist" 616 + PDS_URL = "https://pds.example.com" 617 + 618 + async def test_pds_redirect(self, db_session: AsyncSession, client: object) -> None: 619 + """PDS-only track redirects to getBlob endpoint.""" 620 + from fastapi.testclient import TestClient 621 + 622 + assert isinstance(client, TestClient) 623 + 624 + artist = Artist( 625 + did=self.PDS_ARTIST_DID, 626 + handle="pdsartist.bsky.social", 627 + display_name="PDS Artist", 628 + pds_url=self.PDS_URL, 629 + ) 630 + db_session.add(artist) 631 + await db_session.flush() 632 + 633 + pds_track = Track( 634 + title="PDS Only", 635 + file_id="pds_file_001", 636 + file_type="mp3", 637 + artist_did=artist.did, 638 + r2_url=None, 639 + audio_storage="pds", 640 + pds_blob_cid="bafyaudiocid123", 641 + ) 642 + db_session.add(pds_track) 643 + await db_session.commit() 644 + 645 + response = client.get(f"/audio/{pds_track.file_id}", follow_redirects=False) 646 + 647 + assert response.status_code == 307 648 + location = response.headers["location"] 649 + assert "com.atproto.sync.getBlob" in location 650 + assert f"did={artist.did}" in location 651 + assert f"cid={pds_track.pds_blob_cid}" in location 652 + 653 + async def test_gated_pds_redirect( 654 + self, 655 + db_session: AsyncSession, 656 + client: object, 657 + fastapi_app: object, 658 + ) -> None: 659 + """gated PDS-only track redirects to getBlob (not private R2 bucket).""" 660 + from fastapi.testclient import TestClient 661 + 662 + assert isinstance(client, TestClient) 663 + assert isinstance(fastapi_app, FastAPI) 664 + 665 + artist = Artist( 666 + did="did:plc:gatedpdsowner", 667 + handle="gatedpds.bsky.social", 668 + display_name="Gated PDS Owner", 669 + pds_url=self.PDS_URL, 670 + ) 671 + db_session.add(artist) 672 + await db_session.flush() 673 + 674 + gated_pds_track = Track( 675 + title="Gated PDS", 676 + file_id="gated_pds_001", 677 + file_type="mp3", 678 + artist_did=artist.did, 679 + r2_url=None, 680 + audio_storage="pds", 681 + pds_blob_cid="bafygatedblob", 682 + support_gate={"type": "any"}, 683 + ) 684 + db_session.add(gated_pds_track) 685 + await db_session.commit() 686 + 687 + # override auth dependency to return the track owner's session 688 + mock_owner = Session( 689 + session_id="test", 690 + did=artist.did, 691 + handle="gatedpds.bsky.social", 692 + oauth_session={}, 693 + ) 694 + fastapi_app.dependency_overrides[get_optional_session] = lambda: mock_owner 695 + try: 696 + response = client.get( 697 + f"/audio/{gated_pds_track.file_id}", follow_redirects=False 698 + ) 699 + finally: 700 + fastapi_app.dependency_overrides.pop(get_optional_session, None) 701 + 702 + assert response.status_code == 307 703 + location = response.headers["location"] 704 + assert "com.atproto.sync.getBlob" in location 705 + assert f"did={artist.did}" in location 706 + assert f"cid={gated_pds_track.pds_blob_cid}" in location
+1 -1
loq.toml
··· 59 59 60 60 [[rules]] 61 61 path = "backend/tests/api/test_audio.py" 62 - max_lines = 650 62 + max_lines = 710 63 63 64 64 [[rules]] 65 65 path = "backend/tests/api/test_albums.py"