feat: audio revisions with confirm-before-replace and restore (#1318)
* feat: audio revisions with confirm-before-replace and restore
closes the UX loop on the audio-replace feature shipped in #1311-1313.
two changes shipped together:
1. **confirmation gate** before audio replace fires. picking a file no
longer kicks off the irreversible upload — clicking "replace audio"
now opens a confirm dialog. addresses Alex's report that hitting
"cancel" after picking a file did not roll back the replace (because
nothing actually fired until "replace audio" was clicked, but the
coupling between picker and that button was confusing).
2. **track_revisions table** + restore endpoint + version-history sheet.
every audio replace snapshots the displaced audio into a TrackRevision
row in the same DB transaction as the swap. column names are
provider-neutral (audio_url, not r2_url) so swapping blob providers
later doesn't leave cruft behind. retention cap is 10 per track —
pruning deletes the backing blob if no other row still references
it. PDS-only audio is never deleted (user owns those blobs).
restore is an instant pointer-swap: the chosen revision becomes the
live audio, the displaced current is snapshotted into a new revision
row, and the chosen revision row is deleted (its content is now
current). PDS record is republished as part of the same flow — non-
negotiable so the user's PDS stays in sync with plyr.fm state.
restore is rejected with 409 if it would cross the public ↔ gated
boundary — moving blobs between buckets isn't built yet, and serving
gated audio from the public bucket would defeat the gate.
the version-history surface is a bottom-sheet on mobile / centered
modal on desktop, modeled on LikersSheet. trigger lives in the audio
file section of the track edit form. each row shows format,
relative time, duration, storage location, and a restore button.
new endpoints:
- GET /tracks/{id}/revisions
- POST /tracks/{id}/revisions/{revision_id}/restore
new components:
- ConfirmDialog.svelte — generic alertdialog (used for replace + restore)
- AudioRevisionsSheet.svelte — mobile-first version-history surface
related: #1314 (orphan R2 files) — revisions give R2 files an owner,
which removes the orphan path. #1315 (in-flight tasks writing stale
results) is orthogonal and not addressed here.
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
* test: integration coverage for audio revisions + restore
three end-to-end tests against staging (skip when PLYR_TEST_TOKEN_* unset):
- replace_audio_creates_revision — upload, replace, verify history holds
exactly one row capturing the displaced original
- restore_swaps_audio_and_rotates_revision — upload, replace, restore;
live audio is back to the original, chosen revision row is gone, the
displaced post-replace audio is now in history
- non_owner_cannot_list_or_restore — user2 gets 403 on both list and
restore against user1's track
each test cleans up via the SDK's delete(). new endpoints aren't in the
SDK yet, so raw httpx is used for replace + revisions/restore.
these will run automatically after the PR merges and staging deploys
(the integration-tests workflow fires on deploy staging completion).
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>
authored by