audio streaming app plyr.fm
38
fork

Configure Feed

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

fix(restore): preserve PDS blob ref on restored track record (#1319)

caught by manual staging smoke: when restoring a revision that had
audio_storage="both" with a PDS blob ref, the restored PDS record was
being published WITHOUT an audioBlob field, silently dropping the user's
PDS-hosted copy of the audio.

root cause: the restore code explicitly passed `audio_blob=None` to
build_track_record with a misleading comment claiming "PDS blob not
re-uploaded on restore". the comment was right about the blob bytes
(they're already on PDS), but the BLOB REF must still be included in
the new record — PDS records can reference pre-uploaded blobs.

fix: if the revision carries a pds_blob_cid, construct a BlobRef
(using the stored size + the file_type's mime type) and pass it
through to build_track_record. PDS records now keep their audioBlob
field through the full replace → restore round trip.

also adds a regression test that:
- sets up the same scenario the smoke hit (both → replace → restore)
- asserts the published record contains audioBlob pointing at the
original ref
- asserts the live track row keeps audio_storage="both" and the
correct pds_blob_cid / pds_blob_size after restore

note: if the user's PDS has already GC'd the old blob, the record is
still valid — playback falls back to audio_url (R2). we don't re-upload
the blob as part of restore; that would require hauling bytes through
the backend and is out of scope for v1.

Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>

authored by

nate nowack
Claude Opus 4 (1M context)
and committed by
GitHub
f282e3ad 703341e2

+93 -2
+19 -1
backend/src/backend/api/tracks/revisions.py
··· 29 29 from backend._internal import Session as AuthSession 30 30 from backend._internal import require_auth 31 31 from backend._internal.atproto.records import build_track_record, update_record 32 + from backend._internal.audio import AudioFormat 32 33 from backend._internal.track_revisions import prune_revisions 33 34 from backend.api.albums import invalidate_album_cache_by_id 34 35 from backend.config import settings ··· 196 197 197 198 # build + publish the updated PDS record FIRST, mirroring the replace flow. 198 199 # if this fails, we abort before touching the DB. 200 + # 201 + # if the revision carried a PDS blob ref, include it in the new record so 202 + # the user's PDS keeps its canonical copy of the audio. the blob itself is 203 + # NOT re-uploaded — we trust that PDS still has it (blobs are only GC'd 204 + # after a grace period post-dereference). if the blob has already been 205 + # GC'd by the user's PDS, this record is still valid; playback falls back 206 + # to audio_url (R2). 207 + audio_blob: dict | None = None 208 + if revision.pds_blob_cid: 209 + audio_format = AudioFormat.from_extension(f".{revision.file_type}") 210 + audio_blob = { 211 + "$type": "blob", 212 + "ref": {"$link": revision.pds_blob_cid}, 213 + "mimeType": audio_format.media_type if audio_format else "audio/mpeg", 214 + "size": revision.pds_blob_size or 0, 215 + } 216 + 199 217 new_record = build_track_record( 200 218 title=track.title, 201 219 artist=track.artist.display_name, ··· 206 224 features=list(track.features) if track.features else None, 207 225 image_url=await track.get_image_url(), 208 226 support_gate=dict(track.support_gate) if track.support_gate else None, 209 - audio_blob=None, # PDS blob not re-uploaded on restore (see note below) 227 + audio_blob=audio_blob, 210 228 description=track.description, 211 229 ) 212 230 try:
+74 -1
backend/tests/api/track_audio_replace/test_revisions.py
··· 16 16 from sqlalchemy.ext.asyncio import AsyncSession 17 17 18 18 from backend._internal.track_revisions import prune_revisions 19 - from backend.models import MAX_REVISIONS_PER_TRACK, Artist, TrackRevision 19 + from backend.models import MAX_REVISIONS_PER_TRACK, Artist, Track, TrackRevision 20 20 21 21 from ._helpers import OWNER_DID, TRACK_URI, make_track 22 22 ··· 324 324 assert len(revisions_after) == 1 325 325 assert revisions_after[0].file_id == "CURRENT" 326 326 assert revisions_after[0].duration == 200 327 + 328 + async def test_restore_preserves_pds_blob_on_live_track( 329 + self, 330 + test_app_owner: FastAPI, 331 + db_session: AsyncSession, 332 + owner: Artist, 333 + ) -> None: 334 + """regression for staging smoke: if the revision was audio_storage='both' 335 + with a PDS blob cid, the restored live track must KEEP audio_storage='both' 336 + and the pds_blob_cid, not silently drop back to 'r2' with null blob.""" 337 + track = make_track(file_id="CURRENT-NEW", duration=200) 338 + # simulate the post-replace state: track has 'both' storage with a new blob 339 + track.audio_storage = "both" 340 + track.pds_blob_cid = "bafkreiNEWBLOB" 341 + track.pds_blob_size = 9999 342 + db_session.add(track) 343 + await db_session.commit() 344 + await db_session.refresh(track) 345 + 346 + # revision row captures the pre-replace 'both' state with the ORIGINAL blob 347 + revision = TrackRevision( 348 + track_id=track.id, 349 + file_id="ORIGINAL", 350 + file_type="wav", 351 + original_file_id=None, 352 + original_file_type=None, 353 + audio_storage="both", 354 + audio_url="https://audio.example/ORIGINAL.wav", 355 + pds_blob_cid="bafkreiORIGINALBLOB", 356 + pds_blob_size=4096, 357 + duration=120, 358 + was_gated=False, 359 + ) 360 + db_session.add(revision) 361 + await db_session.commit() 362 + await db_session.refresh(revision) 363 + original_revision_id = revision.id 364 + track_id = track.id 365 + 366 + with patch( 367 + "backend.api.tracks.revisions.update_record", 368 + AsyncMock(return_value=(TRACK_URI, "bafyRESTORED")), 369 + ) as mock_update: 370 + async with AsyncClient( 371 + transport=ASGITransport(app=test_app_owner), base_url="http://test" 372 + ) as client: 373 + resp = await client.post( 374 + f"/tracks/{track_id}/revisions/{original_revision_id}/restore" 375 + ) 376 + 377 + assert resp.status_code == 200 378 + 379 + # the PDS record MUST have been rebuilt with the original blob ref — 380 + # dropping it silently would desync PDS from DB and lose the user's 381 + # PDS-hosted copy of the audio. 382 + assert mock_update.call_count == 1 383 + published_record = mock_update.call_args.kwargs["record"] 384 + assert published_record.get("audioBlob") is not None, ( 385 + "restore built a record without audioBlob, losing the user's PDS blob ref" 386 + ) 387 + assert published_record["audioBlob"]["ref"]["$link"] == "bafkreiORIGINALBLOB" 388 + 389 + # the DB track row must reflect the revision's storage state 390 + db_session.expire_all() 391 + refreshed = await db_session.get(Track, track_id) 392 + assert refreshed is not None 393 + assert refreshed.file_id == "ORIGINAL" 394 + assert refreshed.audio_storage == "both", ( 395 + f"expected audio_storage='both' after restoring a 'both' revision, " 396 + f"got {refreshed.audio_storage!r}" 397 + ) 398 + assert refreshed.pds_blob_cid == "bafkreiORIGINALBLOB" 399 + assert refreshed.pds_blob_size == 4096 327 400 328 401 async def test_409_when_gating_mismatches( 329 402 self,