feat(backend): replace audio on existing track (#1311)
* feat(backend): replace audio on existing track via PUT /tracks/{id}/audio
artists currently delete + re-upload to fix bad audio (logfire shows
darkhart.bsky.social did this 3× in ~65min). that loses likes, comments,
plays, and the track URI. add an endpoint that swaps the audio bytes while
keeping the track's stable identity intact.
orchestration is atomic with rollback:
1. validate + store new audio (R2; transcode if lossless)
2. upload to PDS (best-effort, falls back to r2-only on size limit)
3. PUT updated ATProto record (URI stable, new CID)
4. DB row swap in single tx — file_id, r2_url, atproto_record_cid, duration,
pds_blob_*, audio_storage; clears stale genre_predictions provenance
5. delete old R2 object only on success
6. fire post-replace hooks: invalidate old CopyrightScan rows, re-fire
copyright/embedding/genre tasks; never re-notify followers
7. resync album list record so its strongRef carries the new track CID
if step 3 fails, rollback deletes the just-written R2 file and leaves the
track row untouched.
reuses upload phase helpers (_validate_audio, _store_audio, _upload_to_pds)
so the transcode/PDS-blob/gating logic stays in one place.
intentional non-changes:
- labeler labels on the (URI-stable) track are NOT auto-dismissed — that's
a moderation call left for manual review
- likes/playlists/comments retain stale strongRef CIDs; this is the same
CID-churn behavior that PATCH /tracks/{id} produces today
also fixes two pre-existing test failures uncovered while building this:
- conftest pg_trgm extension only created in xdist template path
- moderation report tests leaked rate-limit budget across the session
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
* fix(audio-replace): tighten rollback scope + handle gated bucket
addresses two review findings on #1311:
1. **post-commit failures triggered rollback (P1)**. the previous orchestrator
wrapped both pre- and post-commit work in one try block. if any side
effect after `_commit_db_swap` raised (post-replace hooks, album resync,
cache invalidation), the except path would delete the new R2 file even
though the track row + ATProto record were already pointing at it —
leaving production with a 404 for the freshly-replaced audio.
split into two phases: pre-commit may rollback; post-commit failures are
logged and swallowed (the swap stands). each post-commit side effect
gets its own try/log so one failure doesn't skip the others.
2. **gated tracks leaked private-bucket objects (P2)**. `R2Storage.delete()`
only probes the public audio + image buckets, so cleanup and rollback
silently no-op'd on supporter-gated tracks (which live in
`private_audio_bucket_name`).
added `delete_gated()` to `R2Storage` + `StorageProtocol` (mirrors
`delete()`'s refcount guard and key probing, against the private bucket).
`_cleanup_old_files` and `_rollback_new_files` now route based on the
track's `support_gate`. also fixes the same pre-existing leak for
gated tracks deleted via the API today (separate latent bug, but the
primitive is now there).
3. **defensive metadata refresh before publish**. a concurrent PATCH that
landed between `_load_and_authorize` and `_publish_record_update` would
have its title / album / features clobbered by the stale snapshot. now
re-loads the row right before building the new ATProto record.
4. **hoist deferred imports** in audio_replace.py + storage/r2.py per the
project's "no unnecessary deferred imports" rule (CLAUDE.md). the
`backend.api.albums` import doesn't have a real circular dep — i'd
copied the pattern from mutations.py without checking.
new tests:
- post-replace hook failure does NOT roll back the new file
- album list sync failure does NOT roll back the new file
- gated track success path uses `delete_gated` for old file
- gated track rollback uses `delete_gated` for new file
- concurrent PATCH title is reflected in the published ATProto record
full xdist suite: 815 passed (was 810 + 5 new tests).
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>
authored by
This is a binary file and will not be displayed.