audio streaming app plyr.fm
38
fork

Configure Feed

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

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

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

+2194 -81
+68
backend/alembic/versions/2026_04_19_123626_8bd123b1513d_add_track_revisions_table.py
··· 1 + """add track_revisions table 2 + 3 + Revision ID: 8bd123b1513d 4 + Revises: 9eda586624d0 5 + Create Date: 2026-04-19 12:36:26.443159 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + 13 + from alembic import op 14 + 15 + # revision identifiers, used by Alembic. 16 + revision: str = "8bd123b1513d" 17 + down_revision: str | Sequence[str] | None = "9eda586624d0" 18 + branch_labels: str | Sequence[str] | None = None 19 + depends_on: str | Sequence[str] | None = None 20 + 21 + 22 + def upgrade() -> None: 23 + """Create track_revisions table for storing previous audio versions of a track. 24 + 25 + column names are intentionally provider-neutral (audio_url, not r2_url) so 26 + swapping blob providers later doesn't leave cruft behind. 27 + """ 28 + op.create_table( 29 + "track_revisions", 30 + sa.Column("id", sa.Integer(), nullable=False), 31 + sa.Column("track_id", sa.Integer(), nullable=False), 32 + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), 33 + sa.Column("file_id", sa.String(), nullable=False), 34 + sa.Column("file_type", sa.String(), nullable=False), 35 + sa.Column("original_file_id", sa.String(), nullable=True), 36 + sa.Column("original_file_type", sa.String(), nullable=True), 37 + sa.Column("audio_storage", sa.String(), nullable=False), 38 + sa.Column("audio_url", sa.String(), nullable=True), 39 + sa.Column("pds_blob_cid", sa.String(), nullable=True), 40 + sa.Column("pds_blob_size", sa.Integer(), nullable=True), 41 + sa.Column("duration", sa.Integer(), nullable=True), 42 + sa.Column("was_gated", sa.Boolean(), server_default="false", nullable=False), 43 + sa.ForeignKeyConstraint(["track_id"], ["tracks.id"], ondelete="CASCADE"), 44 + sa.PrimaryKeyConstraint("id"), 45 + ) 46 + op.create_index( 47 + op.f("ix_track_revisions_id"), "track_revisions", ["id"], unique=False 48 + ) 49 + op.create_index( 50 + op.f("ix_track_revisions_track_id"), 51 + "track_revisions", 52 + ["track_id"], 53 + unique=False, 54 + ) 55 + op.create_index( 56 + "ix_track_revisions_track_created", 57 + "track_revisions", 58 + ["track_id", sa.literal_column("created_at DESC")], 59 + unique=False, 60 + ) 61 + 62 + 63 + def downgrade() -> None: 64 + """Drop the track_revisions table.""" 65 + op.drop_index("ix_track_revisions_track_created", table_name="track_revisions") 66 + op.drop_index(op.f("ix_track_revisions_track_id"), table_name="track_revisions") 67 + op.drop_index(op.f("ix_track_revisions_id"), table_name="track_revisions") 68 + op.drop_table("track_revisions")
+116
backend/src/backend/_internal/track_revisions.py
··· 1 + """helpers for managing per-track audio revision history. 2 + 3 + revisions are written by the audio replace and audio restore endpoints — each 4 + captures the audio state that's about to be displaced. this module owns the 5 + retention policy: keep at most `MAX_REVISIONS_PER_TRACK` per track, and when 6 + pruning, delete the backing blob if no other revision (or the track itself) 7 + still references it. 8 + 9 + prune is best-effort and runs OUTSIDE the swap transaction. a failure to 10 + prune leaves the row in place — worst case a track has more revisions than 11 + the cap, never silently corrupts the live audio pointer. 12 + """ 13 + 14 + import logging 15 + 16 + from sqlalchemy import select 17 + 18 + from backend.models import MAX_REVISIONS_PER_TRACK, Track, TrackRevision 19 + from backend.storage import storage 20 + from backend.utilities.database import db_session 21 + 22 + logger = logging.getLogger(__name__) 23 + 24 + 25 + async def prune_revisions(track_id: int) -> None: 26 + """drop oldest revisions beyond `MAX_REVISIONS_PER_TRACK`, freeing blobs. 27 + 28 + runs in its own short transaction. exceptions are logged but never 29 + re-raised — pruning failure must not corrupt the swap that just succeeded. 30 + """ 31 + try: 32 + async with db_session() as db: 33 + result = await db.execute( 34 + select(TrackRevision) 35 + .where(TrackRevision.track_id == track_id) 36 + .order_by(TrackRevision.created_at.asc(), TrackRevision.id.asc()) 37 + ) 38 + revisions = list(result.scalars()) 39 + 40 + excess = len(revisions) - MAX_REVISIONS_PER_TRACK 41 + if excess <= 0: 42 + return 43 + 44 + to_prune = revisions[:excess] 45 + keepers = revisions[excess:] 46 + 47 + # collect file_ids still in use by the live track + the revisions 48 + # we're keeping, so we don't delete a blob another row depends on 49 + # (e.g. user restored to an old version, so track and an older 50 + # revision share a file_id) 51 + track = await db.get(Track, track_id) 52 + in_use_file_ids: set[str] = set() 53 + in_use_original_file_ids: set[str] = set() 54 + if track: 55 + in_use_file_ids.add(track.file_id) 56 + if track.original_file_id: 57 + in_use_original_file_ids.add(track.original_file_id) 58 + for keeper in keepers: 59 + in_use_file_ids.add(keeper.file_id) 60 + if keeper.original_file_id: 61 + in_use_original_file_ids.add(keeper.original_file_id) 62 + 63 + for revision in to_prune: 64 + await _maybe_delete_blob( 65 + revision, in_use_file_ids, in_use_original_file_ids 66 + ) 67 + await db.delete(revision) 68 + 69 + await db.commit() 70 + except Exception: 71 + logger.exception( 72 + "prune_revisions failed for track_id=%s (revisions may exceed the " 73 + "retention cap until the next prune)", 74 + track_id, 75 + ) 76 + 77 + 78 + async def _maybe_delete_blob( 79 + revision: TrackRevision, 80 + in_use_file_ids: set[str], 81 + in_use_original_file_ids: set[str], 82 + ) -> None: 83 + """delete blobs owned by us if no live row still references them. 84 + 85 + PDS-only audio (audio_storage="pds") lives on the user's PDS — we never 86 + delete those. for "r2" or "both" audio, the playable file is in our 87 + storage and we own it. transcode originals always live in the public 88 + bucket regardless of gating (gated tracks can't be lossless yet). 89 + """ 90 + if revision.audio_storage == "pds": 91 + return # not ours to delete 92 + 93 + # primary playable file: routed to gated bucket if it was gated at the time 94 + if revision.file_id and revision.file_id not in in_use_file_ids: 95 + delete_fn = storage.delete_gated if revision.was_gated else storage.delete 96 + try: 97 + await delete_fn(revision.file_id, revision.file_type) 98 + except Exception: 99 + logger.exception( 100 + "failed to delete pruned revision blob (file_id=%s, gated=%s)", 101 + revision.file_id, 102 + revision.was_gated, 103 + ) 104 + 105 + # transcode original: always public bucket 106 + if ( 107 + revision.original_file_id 108 + and revision.original_file_id not in in_use_original_file_ids 109 + ): 110 + try: 111 + await storage.delete(revision.original_file_id, revision.original_file_type) 112 + except Exception: 113 + logger.exception( 114 + "failed to delete pruned revision original (file_id=%s)", 115 + revision.original_file_id, 116 + )
+1
backend/src/backend/api/tracks/__init__.py
··· 15 15 from . import comments as _comments # /{track_id}/comments, /comments/{comment_id} 16 16 from . import mutations as _mutations # /{track_id}, /{track_id}/restore-record 17 17 from . import audio_replace as _audio_replace # /{track_id}/audio 18 + from . import revisions as _revisions # /{track_id}/revisions(/{revision_id}/restore) 18 19 from . import playback as _playback # /{track_id}, /{track_id}/play 19 20 20 21 __all__ = ["router"]
+41 -42
backend/src/backend/api/tracks/audio_replace.py
··· 13 13 (URI-stable) track. operators must dismiss it manually if the new audio is 14 14 clean. this is intentional — automatically dismissing copyright labels would 15 15 be a moderation hole. 16 - - the old R2 object is deleted only after the PDS write succeeds; the old PDS 17 - blob is left to PDS garbage collection. 16 + - the old audio is NOT deleted on replace. it's snapshotted into a 17 + TrackRevision row inside the same transaction as the swap, so users can 18 + roll back. blobs are only deleted when revisions are pruned past the 19 + per-track retention cap (see `_internal/track_revisions.py`). PDS blobs 20 + are left to PDS garbage collection regardless. 18 21 """ 19 22 20 23 import contextlib ··· 50 53 invalidate_tracks_discovery_cache, 51 54 run_post_track_audio_replace_hooks, 52 55 ) 56 + from backend._internal.track_revisions import prune_revisions 53 57 from backend.api.albums import invalidate_album_cache_by_id 54 58 from backend.api.tracks.uploads import ( 55 59 AudioInfo, ··· 63 67 _validate_audio, 64 68 ) 65 69 from backend.config import settings 66 - from backend.models import Track 70 + from backend.models import Track, TrackRevision 67 71 from backend.models.job import JobStatus, JobType 68 72 from backend.storage import storage 69 73 from backend.utilities.database import db_session ··· 79 83 class TrackAudioState: 80 84 """snapshot of a track's audio fields, captured before replacement. 81 85 82 - used for both rebuilding the track ATProto record (preserving non-audio 83 - metadata like title/album/features/image) and for cleanup of the old R2 84 - object after the new record publishes successfully. 86 + used for rebuilding the track ATProto record (preserving non-audio 87 + metadata like title/album/features/image) on top of the new audio. 88 + the old audio is preserved separately as a TrackRevision row written 89 + inside the swap transaction. 85 90 """ 86 91 87 92 track_id: int ··· 251 256 pds_result: PdsBlobResult | None, 252 257 new_record_cid: str, 253 258 ) -> Track: 254 - """phase 6: atomically swap track audio fields in a single transaction. 259 + """phase 6: atomically swap track audio fields AND snapshot the displaced 260 + audio into a new TrackRevision row, in a single transaction. 261 + 262 + the snapshot has to share the transaction with the swap — otherwise a crash 263 + between the two would either lose history (snapshot first, swap fails) or 264 + corrupt the row pointed-at-but-no-longer-current (swap first, snapshot 265 + fails, blob still in R2 but no row owns it). 255 266 256 267 clears the auto_tag flag and stale genre prediction provenance so the 257 268 post-replace hooks make a clean re-classification decision. ··· 269 280 # extremely unlikely race: track deleted between authorize and commit 270 281 raise UploadPhaseError("track was deleted during replace") 271 282 283 + # snapshot the about-to-be-displaced audio into a revision row BEFORE 284 + # overwriting. this row now owns the old blob — the post-commit cleanup 285 + # path no longer deletes it. pruning (post-commit, best-effort) handles 286 + # long-term retention and blob deletion when revisions exceed the cap. 287 + snapshot = TrackRevision( 288 + track_id=track.id, 289 + file_id=track.file_id, 290 + file_type=track.file_type, 291 + original_file_id=track.original_file_id, 292 + original_file_type=track.original_file_type, 293 + audio_storage=track.audio_storage, 294 + audio_url=track.r2_url, 295 + pds_blob_cid=track.pds_blob_cid, 296 + pds_blob_size=track.pds_blob_size, 297 + duration=track.duration, 298 + was_gated=track.support_gate is not None, 299 + ) 300 + db.add(snapshot) 301 + 272 302 track.file_id = sr.file_id 273 303 track.file_type = playable_file_type 274 304 track.original_file_id = sr.original_file_id ··· 291 321 await db.commit() 292 322 await db.refresh(track) 293 323 return track 294 - 295 - 296 - async def _cleanup_old_files(state: TrackAudioState, sr: StorageResult) -> None: 297 - """delete the old R2 audio object(s) after a successful swap. 298 - 299 - routes to `delete_gated` when the track was supporter-gated (private bucket); 300 - otherwise uses the public-bucket `delete`. skips deletion when the new 301 - file_id matches the old one (identical bytes — nothing to clean up), and 302 - silently swallows already-gone errors. 303 - 304 - note: the gated/non-gated decision uses the OLD `support_gate` because the 305 - file we're cleaning up is the OLD audio. if a future endpoint flips gating 306 - *and* replaces audio in the same call, this needs to take the OLD vs NEW 307 - bucket destination separately. 308 - """ 309 - delete_fn = ( 310 - storage.delete_gated if state.support_gate is not None else storage.delete 311 - ) 312 - if state.old_file_id != sr.file_id: 313 - with contextlib.suppress(Exception): 314 - await delete_fn(state.old_file_id, state.old_file_type) 315 - # transcode originals always go to the public bucket regardless of gating 316 - # (gated tracks can't be lossless yet — see _store_audio in uploads.py) 317 - if state.old_original_file_id and state.old_original_file_id != sr.original_file_id: 318 - with contextlib.suppress(Exception): 319 - await storage.delete( 320 - state.old_original_file_id, state.old_original_file_type 321 - ) 322 324 323 325 324 326 async def _maybe_resync_album_list(track: Track, auth_session: AuthSession) -> None: ··· 435 437 # ---- post-commit (NO rollback — the swap is committed) ---- 436 438 # each side effect is best-effort. if any fails we log and keep going; 437 439 # the track is already pointing at the new audio. 438 - try: 439 - await _cleanup_old_files(state, sr) 440 - except Exception: 441 - logger.exception( 442 - "audio replace: old-file cleanup failed (track is replaced; " 443 - "old R2 object may be orphaned)", 444 - ) 440 + # the OLD audio is NOT deleted here — it now belongs to the revision 441 + # row inserted alongside the swap. pruning (below) handles long-term 442 + # retention and blob cleanup when the per-track cap is exceeded. 443 + await prune_revisions(state.track_id) 445 444 446 445 try: 447 446 await run_post_track_audio_replace_hooks(track.id, audio_url=sr.r2_url)
+299
backend/src/backend/api/tracks/revisions.py
··· 1 + """list and restore previous audio versions of a track. 2 + 3 + GET /tracks/{track_id}/revisions — owner-only history 4 + POST /tracks/{track_id}/revisions/{revision_id}/restore — owner-only revert 5 + 6 + restore is an instant pointer-swap: the chosen revision's audio (already in 7 + storage) becomes the live audio. the track.file_id update + the displaced 8 + audio's snapshot + the chosen revision's deletion all share one DB 9 + transaction. the PDS record is republished beforehand so its CID is what 10 + lands in the row on commit. 11 + 12 + a restore that would cross the public ↔ gated boundary is rejected with 13 + 409 — moving an existing blob between buckets isn't built yet, and serving 14 + gated audio from the public bucket would defeat the gate. the user can 15 + ungate, restore, then re-gate manually if needed. 16 + """ 17 + 18 + import logging 19 + from datetime import datetime 20 + from typing import Annotated 21 + from urllib.parse import urljoin 22 + 23 + import logfire 24 + from fastapi import Depends, HTTPException 25 + from pydantic import BaseModel 26 + from sqlalchemy import select 27 + from sqlalchemy.orm import selectinload 28 + 29 + from backend._internal import Session as AuthSession 30 + from backend._internal import require_auth 31 + from backend._internal.atproto.records import build_track_record, update_record 32 + from backend._internal.track_revisions import prune_revisions 33 + from backend.api.albums import invalidate_album_cache_by_id 34 + from backend.config import settings 35 + from backend.models import Track, TrackRevision 36 + from backend.utilities.database import db_session 37 + 38 + from .router import router 39 + 40 + logger = logging.getLogger(__name__) 41 + 42 + 43 + # -- response models ------------------------------------------------------------- 44 + 45 + 46 + class RevisionResponse(BaseModel): 47 + """one historical audio version of a track.""" 48 + 49 + id: int 50 + track_id: int 51 + created_at: datetime 52 + file_type: str 53 + original_file_type: str | None 54 + audio_storage: str # "r2" | "pds" | "both" 55 + duration: int | None 56 + was_gated: bool 57 + 58 + @classmethod 59 + def from_revision(cls, revision: TrackRevision) -> "RevisionResponse": 60 + return cls( 61 + id=revision.id, 62 + track_id=revision.track_id, 63 + created_at=revision.created_at, 64 + file_type=revision.file_type, 65 + original_file_type=revision.original_file_type, 66 + audio_storage=revision.audio_storage, 67 + duration=revision.duration, 68 + was_gated=revision.was_gated, 69 + ) 70 + 71 + 72 + class RevisionListResponse(BaseModel): 73 + """history payload returned to the owner of a track.""" 74 + 75 + track_id: int 76 + revisions: list[RevisionResponse] 77 + 78 + 79 + # -- helpers --------------------------------------------------------------------- 80 + 81 + 82 + async def _load_owned_track(track_id: int, did: str) -> Track: 83 + """load a track and verify the caller owns it. raises 404/403.""" 84 + async with db_session() as db: 85 + result = await db.execute( 86 + select(Track) 87 + .options(selectinload(Track.artist)) 88 + .where(Track.id == track_id) 89 + ) 90 + track = result.scalar_one_or_none() 91 + 92 + if not track: 93 + raise HTTPException(status_code=404, detail="track not found") 94 + if track.artist_did != did: 95 + raise HTTPException( 96 + status_code=403, 97 + detail="you can only view or restore revisions on your own tracks", 98 + ) 99 + return track 100 + 101 + 102 + def _audio_url_for_record(revision: TrackRevision) -> str: 103 + """compute the audioUrl field for a republished record on restore. 104 + 105 + gated tracks (`was_gated=True`) point at the auth-protected backend 106 + streaming endpoint; public tracks point at the stored audio_url 107 + directly. 108 + """ 109 + if revision.was_gated: 110 + backend_url = settings.atproto.redirect_uri.rsplit("/", 2)[0] 111 + return urljoin(backend_url + "/", f"audio/{revision.file_id}") 112 + if not revision.audio_url: 113 + # public revision missing a CDN url — this shouldn't happen for any 114 + # revision created post-CDN-cutover, but guard the assertion anyway 115 + raise HTTPException( 116 + status_code=500, 117 + detail="revision missing audio_url; cannot republish record", 118 + ) 119 + return revision.audio_url 120 + 121 + 122 + # -- HTTP surface ---------------------------------------------------------------- 123 + 124 + 125 + @router.get("/{track_id}/revisions", response_model=RevisionListResponse) 126 + async def list_track_revisions( 127 + track_id: int, 128 + auth_session: Annotated[AuthSession, Depends(require_auth)], 129 + ) -> RevisionListResponse: 130 + """List previous audio versions of a track, newest first (owner only). 131 + 132 + Returns history only — the current audio lives on the track row itself. 133 + """ 134 + await _load_owned_track(track_id, auth_session.did) 135 + 136 + async with db_session() as db: 137 + result = await db.execute( 138 + select(TrackRevision) 139 + .where(TrackRevision.track_id == track_id) 140 + .order_by(TrackRevision.created_at.desc(), TrackRevision.id.desc()) 141 + ) 142 + revisions = list(result.scalars()) 143 + 144 + return RevisionListResponse( 145 + track_id=track_id, 146 + revisions=[RevisionResponse.from_revision(r) for r in revisions], 147 + ) 148 + 149 + 150 + @router.post( 151 + "/{track_id}/revisions/{revision_id}/restore", 152 + response_model=RevisionResponse, 153 + ) 154 + async def restore_track_revision( 155 + track_id: int, 156 + revision_id: int, 157 + auth_session: Annotated[AuthSession, Depends(require_auth)], 158 + ) -> RevisionResponse: 159 + """Restore a previous audio version (owner only). 160 + 161 + The chosen revision's audio becomes live in a single DB transaction: 162 + the displaced current audio is snapshotted into a new revision row, 163 + the track row is updated, and the chosen revision row is deleted (its 164 + content is now current). The PDS record is republished beforehand so 165 + its CID is what lands on the row when the transaction commits. 166 + 167 + Restore is rejected with 409 if it would cross the public ↔ gated 168 + boundary — moving blobs between buckets isn't built yet. 169 + """ 170 + track = await _load_owned_track(track_id, auth_session.did) 171 + if not track.atproto_record_uri: 172 + raise HTTPException( 173 + status_code=400, 174 + detail=( 175 + "this track has no ATProto record — restore the record before " 176 + "restoring an audio revision" 177 + ), 178 + ) 179 + 180 + # load the chosen revision and verify it belongs to this track 181 + async with db_session() as db: 182 + revision = await db.get(TrackRevision, revision_id) 183 + if not revision or revision.track_id != track_id: 184 + raise HTTPException(status_code=404, detail="revision not found") 185 + 186 + # gating compat check — see module docstring 187 + track_is_gated = track.support_gate is not None 188 + if revision.was_gated != track_is_gated: 189 + raise HTTPException( 190 + status_code=409, 191 + detail=( 192 + "restore would cross the public/gated boundary — " 193 + "ungate or re-gate the track first" 194 + ), 195 + ) 196 + 197 + # build + publish the updated PDS record FIRST, mirroring the replace flow. 198 + # if this fails, we abort before touching the DB. 199 + new_record = build_track_record( 200 + title=track.title, 201 + artist=track.artist.display_name, 202 + audio_url=_audio_url_for_record(revision), 203 + file_type=revision.file_type, 204 + album=track.album, 205 + duration=revision.duration, 206 + features=list(track.features) if track.features else None, 207 + image_url=await track.get_image_url(), 208 + 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) 210 + description=track.description, 211 + ) 212 + try: 213 + _, new_cid = await update_record( 214 + auth_session=auth_session, 215 + record_uri=track.atproto_record_uri, 216 + record=new_record, 217 + ) 218 + except Exception as exc: 219 + logfire.exception( 220 + "restore: failed to update ATProto record", 221 + track_id=track_id, 222 + revision_id=revision_id, 223 + ) 224 + raise HTTPException( 225 + status_code=502, 226 + detail=f"failed to publish restored record: {exc}", 227 + ) from exc 228 + 229 + # commit: snapshot current → update track → delete chosen revision. 230 + # all in one transaction so we never end up with a track pointing at a 231 + # blob no row owns. 232 + async with db_session() as db: 233 + live_track = await db.get(Track, track_id) 234 + if not live_track: 235 + raise HTTPException(status_code=404, detail="track not found") 236 + 237 + snapshot = TrackRevision( 238 + track_id=live_track.id, 239 + file_id=live_track.file_id, 240 + file_type=live_track.file_type, 241 + original_file_id=live_track.original_file_id, 242 + original_file_type=live_track.original_file_type, 243 + audio_storage=live_track.audio_storage, 244 + audio_url=live_track.r2_url, 245 + pds_blob_cid=live_track.pds_blob_cid, 246 + pds_blob_size=live_track.pds_blob_size, 247 + duration=live_track.duration, 248 + was_gated=live_track.support_gate is not None, 249 + ) 250 + db.add(snapshot) 251 + 252 + live_track.file_id = revision.file_id 253 + live_track.file_type = revision.file_type 254 + live_track.original_file_id = revision.original_file_id 255 + live_track.original_file_type = revision.original_file_type 256 + live_track.audio_storage = revision.audio_storage 257 + live_track.r2_url = revision.audio_url 258 + live_track.pds_blob_cid = revision.pds_blob_cid 259 + live_track.pds_blob_size = revision.pds_blob_size 260 + live_track.atproto_record_cid = new_cid 261 + 262 + # update duration in extra; clear stale genre-prediction provenance so 263 + # a future re-classification doesn't get short-circuited. 264 + extra = dict(live_track.extra) if live_track.extra else {} 265 + if revision.duration is not None: 266 + extra["duration"] = revision.duration 267 + else: 268 + extra.pop("duration", None) 269 + extra.pop("genre_predictions", None) 270 + extra.pop("genre_predictions_file_id", None) 271 + live_track.extra = extra 272 + 273 + # delete the chosen revision row — its content is now the live audio, 274 + # and keeping it would duplicate the track row's pointer. 275 + chosen = await db.get(TrackRevision, revision_id) 276 + if chosen: 277 + await db.delete(chosen) 278 + 279 + await db.commit() 280 + 281 + # post-commit best-effort: 282 + # - prune revisions if the snapshot pushed us over the cap 283 + # - resync album list record so its strongRef carries the new CID 284 + await prune_revisions(track_id) 285 + 286 + if track.album_id: 287 + from backend._internal.tasks import schedule_album_list_sync 288 + 289 + try: 290 + await schedule_album_list_sync(auth_session.session_id, track.album_id) 291 + async with db_session() as db: 292 + await invalidate_album_cache_by_id(db, track.album_id) 293 + except Exception: 294 + logger.exception( 295 + "restore: album list resync failed (track restored; album " 296 + "record's strongRef may carry a stale CID until next edit)", 297 + ) 298 + 299 + return RevisionResponse.from_revision(snapshot)
+3
backend/src/backend/models/__init__.py
··· 23 23 from backend.models.track import Track 24 24 from backend.models.track_comment import TrackComment 25 25 from backend.models.track_like import TrackLike 26 + from backend.models.track_revision import MAX_REVISIONS_PER_TRACK, TrackRevision 26 27 from backend.utilities.database import db_session, get_db 27 28 28 29 __all__ = [ 30 + "MAX_REVISIONS_PER_TRACK", 29 31 "Album", 30 32 "Artist", 31 33 "Base", ··· 49 51 "Track", 50 52 "TrackComment", 51 53 "TrackLike", 54 + "TrackRevision", 52 55 "TrackTag", 53 56 "UserPreferences", 54 57 "UserSession",
+95
backend/src/backend/models/track_revision.py
··· 1 + """historical audio revisions for a track. 2 + 3 + a TrackRevision row represents a PRIOR audio version for a track. the Track 4 + itself always holds the CURRENT audio in its own columns; revisions are 5 + strictly historical. 6 + 7 + written on every audio replace (snapshot of the about-to-be-displaced state) 8 + and on every restore (snapshot of the about-to-be-replaced current). pruned 9 + to a per-track cap (`MAX_REVISIONS_PER_TRACK`) — oldest revisions are deleted 10 + along with their backing blobs. 11 + 12 + column names are intentionally provider-neutral: `audio_url` instead of 13 + `r2_url`, so that swapping blob providers later doesn't leave cruft behind. 14 + the `audio_storage` column mirrors `Track.audio_storage` values for parity 15 + ("r2" | "pds" | "both"). 16 + """ 17 + 18 + from datetime import UTC, datetime 19 + from typing import TYPE_CHECKING 20 + 21 + from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String 22 + from sqlalchemy.orm import Mapped, mapped_column, relationship 23 + 24 + from backend.models.database import Base 25 + 26 + if TYPE_CHECKING: 27 + from backend.models.track import Track 28 + 29 + 30 + # per-track retention cap. when a new revision pushes count above this, the 31 + # oldest revision is pruned (and its backing blob is deleted if no other 32 + # row references it). 33 + MAX_REVISIONS_PER_TRACK = 10 34 + 35 + 36 + class TrackRevision(Base): 37 + """a previous audio version of a track.""" 38 + 39 + __tablename__ = "track_revisions" 40 + 41 + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) 42 + 43 + track_id: Mapped[int] = mapped_column( 44 + Integer, 45 + ForeignKey("tracks.id", ondelete="CASCADE"), 46 + nullable=False, 47 + index=True, 48 + ) 49 + track: Mapped["Track"] = relationship("Track", lazy="raise") 50 + 51 + created_at: Mapped[datetime] = mapped_column( 52 + DateTime(timezone=True), 53 + default=lambda: datetime.now(UTC), 54 + nullable=False, 55 + ) 56 + 57 + # --- audio snapshot (provider-neutral column names) --- 58 + 59 + # primary playable file pointer (mirrors Track.file_id) 60 + file_id: Mapped[str] = mapped_column(String, nullable=False) 61 + 62 + # playable file format (mp3, m4a, etc.) 63 + file_type: Mapped[str] = mapped_column(String, nullable=False) 64 + 65 + # lossless original (when current was transcoded at upload time) 66 + original_file_id: Mapped[str | None] = mapped_column(String, nullable=True) 67 + original_file_type: Mapped[str | None] = mapped_column(String, nullable=True) 68 + 69 + # storage location: "r2" | "pds" | "both" — mirrors Track.audio_storage 70 + audio_storage: Mapped[str] = mapped_column(String, nullable=False) 71 + 72 + # public CDN URL for the playable file (or gated backend URL if was_gated). 73 + # named generically so we can swap blob providers without renaming. 74 + audio_url: Mapped[str | None] = mapped_column(String, nullable=True) 75 + 76 + # PDS blob ref (when audio_storage in {"pds", "both"}) 77 + pds_blob_cid: Mapped[str | None] = mapped_column(String, nullable=True) 78 + pds_blob_size: Mapped[int | None] = mapped_column(Integer, nullable=True) 79 + 80 + # --- display + restore-safety helpers --- 81 + 82 + # duration in seconds at time of snapshot (denormalized from Track.extra) 83 + duration: Mapped[int | None] = mapped_column(Integer, nullable=True) 84 + 85 + # was the track support-gated when this revision was current? used to 86 + # detect cross-bucket restore attempts (public ↔ gated) which require 87 + # blob migration work we haven't built yet. 88 + was_gated: Mapped[bool] = mapped_column( 89 + Boolean, nullable=False, default=False, server_default="false" 90 + ) 91 + 92 + __table_args__ = ( 93 + # newest-first listing for a track's history 94 + Index("ix_track_revisions_track_created", "track_id", created_at.desc()), 95 + )
+52 -10
backend/tests/api/track_audio_replace/test_pipeline.py
··· 38 38 self, db_session: AsyncSession, owner: Artist 39 39 ) -> None: 40 40 """on success: file_id/r2_url/atproto_record_cid/duration update, 41 - old R2 file is deleted, post-replace hooks fire, no notification fires.""" 41 + old audio is preserved as a TrackRevision row (NOT deleted), post- 42 + replace hooks fire, no notification fires.""" 43 + from sqlalchemy import select 44 + 45 + from backend.models import TrackRevision 46 + 42 47 track = make_track(file_id="OLD", duration=120) 43 48 db_session.add(track) 44 49 await db_session.commit() ··· 68 73 assert track.pds_blob_cid == "bafyNEWBLOB" 69 74 assert track.notification_sent is True # never re-fires 70 75 71 - # old R2 file was deleted 72 - assert "OLD" in deleted_keys 76 + # the OLD audio is preserved as a revision row (not deleted from R2); 77 + # the user can roll back to it. 78 + revisions = ( 79 + ( 80 + await db_session.execute( 81 + select(TrackRevision).where(TrackRevision.track_id == track_id) 82 + ) 83 + ) 84 + .scalars() 85 + .all() 86 + ) 87 + assert len(revisions) == 1 88 + assert revisions[0].file_id == "OLD" 89 + assert revisions[0].audio_storage == "r2" 90 + assert revisions[0].duration == 120 91 + assert revisions[0].was_gated is False 92 + 93 + # the OLD R2 file was NOT immediately deleted — it belongs to the 94 + # revision row now. pruning handles long-term cleanup. 95 + assert "OLD" not in deleted_keys 73 96 74 97 # post-replace hooks were scheduled with the new audio URL 75 98 mocks["post_hooks"].assert_called_once() ··· 313 336 314 337 # the NEW R2 file must NOT have been deleted 315 338 assert "NEW" not in deleted_keys 316 - # the OLD R2 file should have been cleaned up before the hooks ran 317 - assert "OLD" in deleted_keys 339 + # the OLD R2 file is preserved as a revision (not deleted on replace) 340 + assert "OLD" not in deleted_keys 318 341 319 342 async def test_album_resync_failure_does_not_rollback( 320 343 self, db_session: AsyncSession, owner: Artist ··· 344 367 """regression for review feedback: gated tracks live in the private R2 345 368 bucket. cleanup and rollback must use `delete_gated`, not `delete`.""" 346 369 347 - async def test_old_gated_audio_uses_delete_gated_on_success( 370 + async def test_old_gated_audio_preserved_as_gated_revision( 348 371 self, db_session: AsyncSession, owner: Artist 349 372 ) -> None: 373 + """gated tracks: the old audio is snapshotted with was_gated=True so 374 + future pruning routes the blob delete to the private bucket. it is 375 + NOT deleted on replace itself.""" 376 + from sqlalchemy import select 377 + 378 + from backend.models import TrackRevision 379 + 350 380 track = make_track(file_id="OLD", support_gate={"type": "any"}) 351 381 db_session.add(track) 352 382 await db_session.commit() ··· 360 390 ) as mocks: 361 391 await _process_replace_background(replace_ctx(track_id=track_id)) 362 392 363 - # the OLD gated file was deleted via delete_gated, NOT delete 364 - mocks["storage_delete_gated"].assert_called_once() 365 - assert mocks["storage_delete_gated"].call_args.args[0] == "OLD" 366 - # delete (public bucket) must not have been used for the gated file_id 393 + # the OLD gated file was preserved as a revision with was_gated=True 394 + revision = ( 395 + ( 396 + await db_session.execute( 397 + select(TrackRevision).where(TrackRevision.track_id == track_id) 398 + ) 399 + ) 400 + .scalars() 401 + .one() 402 + ) 403 + assert revision.file_id == "OLD" 404 + assert revision.was_gated is True 405 + 406 + # neither delete nor delete_gated was called on replace — the old 407 + # gated blob lives on as a revision until pruning kicks in 408 + assert mocks["storage_delete_gated"].call_count == 0 367 409 for call in mocks["storage_delete"].call_args_list: 368 410 assert call.args[0] != "OLD" 369 411
+413
backend/tests/api/track_audio_replace/test_revisions.py
··· 1 + """tests for the audio revision history surface. 2 + 3 + three things to cover: 4 + 1. prune_revisions enforces MAX_REVISIONS_PER_TRACK and deletes orphan blobs 5 + 2. GET /tracks/{id}/revisions: owner-only history listing 6 + 3. POST /tracks/{id}/revisions/{revision_id}/restore: pointer-swap + republish 7 + """ 8 + 9 + from __future__ import annotations 10 + 11 + from unittest.mock import AsyncMock, patch 12 + 13 + from fastapi import FastAPI 14 + from httpx import ASGITransport, AsyncClient 15 + from sqlalchemy import select 16 + from sqlalchemy.ext.asyncio import AsyncSession 17 + 18 + from backend._internal.track_revisions import prune_revisions 19 + from backend.models import MAX_REVISIONS_PER_TRACK, Artist, TrackRevision 20 + 21 + from ._helpers import OWNER_DID, TRACK_URI, make_track 22 + 23 + 24 + def _add_revision( 25 + track_id: int, 26 + *, 27 + file_id: str, 28 + file_type: str = "mp3", 29 + audio_storage: str = "r2", 30 + audio_url: str | None = None, 31 + was_gated: bool = False, 32 + duration: int | None = 120, 33 + original_file_id: str | None = None, 34 + ) -> TrackRevision: 35 + """build (but don't add) a TrackRevision row.""" 36 + return TrackRevision( 37 + track_id=track_id, 38 + file_id=file_id, 39 + file_type=file_type, 40 + original_file_id=original_file_id, 41 + original_file_type=None, 42 + audio_storage=audio_storage, 43 + audio_url=audio_url or f"https://audio.example/{file_id}.{file_type}", 44 + pds_blob_cid=None, 45 + pds_blob_size=None, 46 + duration=duration, 47 + was_gated=was_gated, 48 + ) 49 + 50 + 51 + class TestPruneRevisions: 52 + """verify retention cap + blob deletion behavior.""" 53 + 54 + async def test_under_cap_is_a_noop( 55 + self, db_session: AsyncSession, owner: Artist 56 + ) -> None: 57 + track = make_track() 58 + db_session.add(track) 59 + await db_session.commit() 60 + await db_session.refresh(track) 61 + 62 + for i in range(3): 63 + db_session.add(_add_revision(track.id, file_id=f"REV-{i}")) 64 + await db_session.commit() 65 + 66 + with patch( 67 + "backend._internal.track_revisions.storage.delete", 68 + AsyncMock(return_value=True), 69 + ) as mock_delete: 70 + await prune_revisions(track.id) 71 + 72 + # nothing was pruned; nothing was deleted 73 + revisions = ( 74 + ( 75 + await db_session.execute( 76 + select(TrackRevision).where(TrackRevision.track_id == track.id) 77 + ) 78 + ) 79 + .scalars() 80 + .all() 81 + ) 82 + assert len(revisions) == 3 83 + mock_delete.assert_not_called() 84 + 85 + async def test_over_cap_drops_oldest_and_deletes_blob( 86 + self, db_session: AsyncSession, owner: Artist 87 + ) -> None: 88 + from datetime import UTC, datetime, timedelta 89 + 90 + track = make_track() 91 + db_session.add(track) 92 + await db_session.commit() 93 + await db_session.refresh(track) 94 + 95 + # write MAX+2 revisions with deterministic created_at ordering 96 + base = datetime(2026, 1, 1, tzinfo=UTC) 97 + for i in range(MAX_REVISIONS_PER_TRACK + 2): 98 + r = _add_revision(track.id, file_id=f"REV-{i:02d}") 99 + r.created_at = base + timedelta(hours=i) 100 + db_session.add(r) 101 + await db_session.commit() 102 + 103 + with patch( 104 + "backend._internal.track_revisions.storage.delete", 105 + AsyncMock(return_value=True), 106 + ) as mock_delete: 107 + await prune_revisions(track.id) 108 + 109 + # exactly MAX remain; the two oldest are gone 110 + remaining = ( 111 + ( 112 + await db_session.execute( 113 + select(TrackRevision.file_id) 114 + .where(TrackRevision.track_id == track.id) 115 + .order_by(TrackRevision.created_at.asc()) 116 + ) 117 + ) 118 + .scalars() 119 + .all() 120 + ) 121 + assert len(remaining) == MAX_REVISIONS_PER_TRACK 122 + assert "REV-00" not in remaining 123 + assert "REV-01" not in remaining 124 + assert "REV-02" in remaining 125 + 126 + # blobs for the two pruned revisions were deleted 127 + deleted_keys = {call.args[0] for call in mock_delete.call_args_list} 128 + assert "REV-00" in deleted_keys 129 + assert "REV-01" in deleted_keys 130 + 131 + async def test_does_not_delete_blob_still_referenced_by_track( 132 + self, db_session: AsyncSession, owner: Artist 133 + ) -> None: 134 + """if track.file_id matches an oldest revision's file_id (e.g. a 135 + restore made them share content), pruning must NOT delete the blob.""" 136 + from datetime import UTC, datetime, timedelta 137 + 138 + track = make_track(file_id="SHARED") 139 + db_session.add(track) 140 + await db_session.commit() 141 + await db_session.refresh(track) 142 + 143 + base = datetime(2026, 1, 1, tzinfo=UTC) 144 + # oldest revision shares file_id with track.file_id 145 + r0 = _add_revision(track.id, file_id="SHARED") 146 + r0.created_at = base 147 + db_session.add(r0) 148 + for i in range(1, MAX_REVISIONS_PER_TRACK + 1): 149 + r = _add_revision(track.id, file_id=f"REV-{i:02d}") 150 + r.created_at = base + timedelta(hours=i) 151 + db_session.add(r) 152 + await db_session.commit() 153 + 154 + with patch( 155 + "backend._internal.track_revisions.storage.delete", 156 + AsyncMock(return_value=True), 157 + ) as mock_delete: 158 + await prune_revisions(track.id) 159 + 160 + # SHARED revision row was pruned, but the blob is still in use 161 + assert mock_delete.call_count == 0 162 + 163 + async def test_pds_only_revision_blob_is_not_deleted( 164 + self, db_session: AsyncSession, owner: Artist 165 + ) -> None: 166 + """audio_storage='pds' means the blob lives on the user's PDS — we 167 + never delete those, only the row.""" 168 + from datetime import UTC, datetime, timedelta 169 + 170 + track = make_track() 171 + db_session.add(track) 172 + await db_session.commit() 173 + await db_session.refresh(track) 174 + 175 + base = datetime(2026, 1, 1, tzinfo=UTC) 176 + r0 = _add_revision(track.id, file_id="PDS-ONLY", audio_storage="pds") 177 + r0.created_at = base 178 + db_session.add(r0) 179 + for i in range(1, MAX_REVISIONS_PER_TRACK + 1): 180 + r = _add_revision(track.id, file_id=f"REV-{i:02d}") 181 + r.created_at = base + timedelta(hours=i) 182 + db_session.add(r) 183 + await db_session.commit() 184 + 185 + with patch( 186 + "backend._internal.track_revisions.storage.delete", 187 + AsyncMock(return_value=True), 188 + ) as mock_delete: 189 + await prune_revisions(track.id) 190 + 191 + # PDS-only revision was pruned (row gone) but no blob deletion attempted 192 + for call in mock_delete.call_args_list: 193 + assert call.args[0] != "PDS-ONLY" 194 + 195 + 196 + class TestListRevisionsEndpoint: 197 + """GET /tracks/{id}/revisions""" 198 + 199 + async def test_returns_history_newest_first( 200 + self, 201 + test_app_owner: FastAPI, 202 + db_session: AsyncSession, 203 + owner: Artist, 204 + ) -> None: 205 + from datetime import UTC, datetime, timedelta 206 + 207 + track = make_track() 208 + db_session.add(track) 209 + await db_session.commit() 210 + await db_session.refresh(track) 211 + 212 + base = datetime(2026, 1, 1, tzinfo=UTC) 213 + for i in range(3): 214 + r = _add_revision(track.id, file_id=f"REV-{i}", duration=100 + i) 215 + r.created_at = base + timedelta(hours=i) 216 + db_session.add(r) 217 + await db_session.commit() 218 + 219 + async with AsyncClient( 220 + transport=ASGITransport(app=test_app_owner), base_url="http://test" 221 + ) as client: 222 + resp = await client.get(f"/tracks/{track.id}/revisions") 223 + 224 + assert resp.status_code == 200 225 + body = resp.json() 226 + assert body["track_id"] == track.id 227 + assert len(body["revisions"]) == 3 228 + # newest-first 229 + durations = [r["duration"] for r in body["revisions"]] 230 + assert durations == [102, 101, 100] 231 + # response shape 232 + assert "audio_url" not in body["revisions"][0] # not exposed 233 + assert "file_id" not in body["revisions"][0] # internal 234 + assert body["revisions"][0]["file_type"] == "mp3" 235 + assert body["revisions"][0]["was_gated"] is False 236 + 237 + async def test_403_when_not_owner( 238 + self, 239 + test_app_other: FastAPI, 240 + db_session: AsyncSession, 241 + owner: Artist, 242 + other_artist: Artist, 243 + ) -> None: 244 + track = make_track() 245 + db_session.add(track) 246 + await db_session.commit() 247 + await db_session.refresh(track) 248 + 249 + async with AsyncClient( 250 + transport=ASGITransport(app=test_app_other), base_url="http://test" 251 + ) as client: 252 + resp = await client.get(f"/tracks/{track.id}/revisions") 253 + assert resp.status_code == 403 254 + 255 + async def test_404_when_track_missing( 256 + self, test_app_owner: FastAPI, owner: Artist 257 + ) -> None: 258 + async with AsyncClient( 259 + transport=ASGITransport(app=test_app_owner), base_url="http://test" 260 + ) as client: 261 + resp = await client.get("/tracks/999999/revisions") 262 + assert resp.status_code == 404 263 + 264 + 265 + class TestRestoreEndpoint: 266 + """POST /tracks/{id}/revisions/{revision_id}/restore""" 267 + 268 + async def test_restore_swaps_audio_and_publishes_record( 269 + self, 270 + test_app_owner: FastAPI, 271 + db_session: AsyncSession, 272 + owner: Artist, 273 + ) -> None: 274 + track = make_track(file_id="CURRENT", duration=200) 275 + db_session.add(track) 276 + await db_session.commit() 277 + await db_session.refresh(track) 278 + 279 + revision = _add_revision(track.id, file_id="OLD", duration=120) 280 + db_session.add(revision) 281 + await db_session.commit() 282 + await db_session.refresh(revision) 283 + original_revision_id = revision.id # snapshot before session expires 284 + track_id = track.id 285 + 286 + with patch( 287 + "backend.api.tracks.revisions.update_record", 288 + AsyncMock(return_value=(TRACK_URI, "bafyRESTORED")), 289 + ) as mock_update: 290 + async with AsyncClient( 291 + transport=ASGITransport(app=test_app_owner), base_url="http://test" 292 + ) as client: 293 + resp = await client.post( 294 + f"/tracks/{track_id}/revisions/{original_revision_id}/restore" 295 + ) 296 + 297 + assert resp.status_code == 200 298 + mock_update.assert_called_once() 299 + 300 + # track now points at the restored audio 301 + await db_session.refresh(track) 302 + assert track.file_id == "OLD" 303 + assert track.atproto_record_cid == "bafyRESTORED" 304 + assert track.extra["duration"] == 120 305 + 306 + # the chosen revision row was deleted (its content is now current). 307 + # expire the session so the SELECT goes to the DB rather than the 308 + # identity map (the endpoint commits via its own session). 309 + db_session.expire_all() 310 + revisions_after = ( 311 + ( 312 + await db_session.execute( 313 + select(TrackRevision).where(TrackRevision.track_id == track_id) 314 + ) 315 + ) 316 + .scalars() 317 + .all() 318 + ) 319 + 320 + revision_ids = [r.id for r in revisions_after] 321 + assert original_revision_id not in revision_ids 322 + 323 + # exactly one revision remains: the snapshot of the displaced current 324 + assert len(revisions_after) == 1 325 + assert revisions_after[0].file_id == "CURRENT" 326 + assert revisions_after[0].duration == 200 327 + 328 + async def test_409_when_gating_mismatches( 329 + self, 330 + test_app_owner: FastAPI, 331 + db_session: AsyncSession, 332 + owner: Artist, 333 + ) -> None: 334 + """restore is rejected when it would cross the public ↔ gated 335 + boundary — moving blobs between buckets isn't built yet.""" 336 + # current state: gated. revision: was public. 337 + track = make_track(file_id="GATED-NEW", support_gate={"type": "any"}) 338 + db_session.add(track) 339 + await db_session.commit() 340 + await db_session.refresh(track) 341 + 342 + revision = _add_revision(track.id, file_id="OLD-PUBLIC", was_gated=False) 343 + db_session.add(revision) 344 + await db_session.commit() 345 + await db_session.refresh(revision) 346 + 347 + async with AsyncClient( 348 + transport=ASGITransport(app=test_app_owner), base_url="http://test" 349 + ) as client: 350 + resp = await client.post( 351 + f"/tracks/{track.id}/revisions/{revision.id}/restore" 352 + ) 353 + assert resp.status_code == 409 354 + 355 + # track is unchanged 356 + await db_session.refresh(track) 357 + assert track.file_id == "GATED-NEW" 358 + 359 + async def test_404_when_revision_belongs_to_different_track( 360 + self, 361 + test_app_owner: FastAPI, 362 + db_session: AsyncSession, 363 + owner: Artist, 364 + ) -> None: 365 + track_a = make_track(file_id="A") 366 + track_b = make_track(file_id="B") 367 + db_session.add_all([track_a, track_b]) 368 + await db_session.commit() 369 + await db_session.refresh(track_a) 370 + await db_session.refresh(track_b) 371 + 372 + revision = _add_revision(track_b.id, file_id="REV-B") 373 + db_session.add(revision) 374 + await db_session.commit() 375 + await db_session.refresh(revision) 376 + 377 + async with AsyncClient( 378 + transport=ASGITransport(app=test_app_owner), base_url="http://test" 379 + ) as client: 380 + resp = await client.post( 381 + f"/tracks/{track_a.id}/revisions/{revision.id}/restore" 382 + ) 383 + assert resp.status_code == 404 384 + 385 + async def test_403_when_not_owner( 386 + self, 387 + test_app_other: FastAPI, 388 + db_session: AsyncSession, 389 + owner: Artist, 390 + other_artist: Artist, 391 + ) -> None: 392 + track = make_track() 393 + db_session.add(track) 394 + await db_session.commit() 395 + await db_session.refresh(track) 396 + 397 + revision = _add_revision(track.id, file_id="OLD") 398 + db_session.add(revision) 399 + await db_session.commit() 400 + await db_session.refresh(revision) 401 + 402 + async with AsyncClient( 403 + transport=ASGITransport(app=test_app_other), base_url="http://test" 404 + ) as client: 405 + resp = await client.post( 406 + f"/tracks/{track.id}/revisions/{revision.id}/restore" 407 + ) 408 + assert resp.status_code == 403 409 + 410 + 411 + # OWNER_DID is imported but unused in this file's body; the test_app_* 412 + # fixtures use it implicitly via MockSession. silence ruff with a no-op ref. 413 + _ = OWNER_DID
+271
backend/tests/integration/test_audio_revisions.py
··· 1 + """integration tests for audio replace + revisions + restore. 2 + 3 + these run against a real backend (default: staging) using dev tokens. they 4 + seed via the SDK, then exercise the new /tracks/{id}/audio (replace) and 5 + /tracks/{id}/revisions(/restore) endpoints with raw httpx — the SDK doesn't 6 + have wrappers for those yet. 7 + 8 + each test cleans up after itself by deleting the seeded track. 9 + 10 + prereqs: 11 + - staging deployed with the track_revisions migration applied 12 + - PLYR_TEST_TOKEN_1 (and TOKEN_2 for the owner-only test) set 13 + """ 14 + 15 + from __future__ import annotations 16 + 17 + import asyncio 18 + from pathlib import Path 19 + from typing import TYPE_CHECKING 20 + 21 + import httpx 22 + import pytest 23 + 24 + from .conftest import IntegrationSettings 25 + 26 + if TYPE_CHECKING: 27 + from plyrfm import AsyncPlyrClient 28 + 29 + 30 + pytestmark = [pytest.mark.integration, pytest.mark.timeout(180)] 31 + 32 + 33 + # replace + restore each kick off background work. polling cap chosen for 34 + # typical staging round-trip times (transcode + R2 + PDS write usually <30s). 35 + POLL_INTERVAL_SEC = 1.0 36 + POLL_MAX_ATTEMPTS = 60 37 + 38 + 39 + def _auth_headers(token: str) -> dict[str, str]: 40 + return {"Authorization": f"Bearer {token}"} 41 + 42 + 43 + async def _poll_until_file_id_changes( 44 + http: httpx.AsyncClient, 45 + api_url: str, 46 + token: str, 47 + track_id: int, 48 + original_file_id: str, 49 + ) -> str: 50 + """poll GET /tracks/{id} until file_id changes (or time out). 51 + 52 + audio replace runs in a background task; this is the simplest way for the 53 + test to wait for it without subscribing to SSE. 54 + """ 55 + for _ in range(POLL_MAX_ATTEMPTS): 56 + resp = await http.get( 57 + f"{api_url}/tracks/{track_id}", headers=_auth_headers(token) 58 + ) 59 + resp.raise_for_status() 60 + current_file_id = resp.json()["file_id"] 61 + if current_file_id != original_file_id: 62 + return current_file_id 63 + await asyncio.sleep(POLL_INTERVAL_SEC) 64 + raise AssertionError( 65 + f"file_id did not change within {POLL_MAX_ATTEMPTS * POLL_INTERVAL_SEC}s " 66 + f"(track {track_id}, still at {original_file_id})" 67 + ) 68 + 69 + 70 + async def _list_revisions( 71 + http: httpx.AsyncClient, api_url: str, token: str, track_id: int 72 + ) -> list[dict]: 73 + resp = await http.get( 74 + f"{api_url}/tracks/{track_id}/revisions", headers=_auth_headers(token) 75 + ) 76 + resp.raise_for_status() 77 + body = resp.json() 78 + return body["revisions"] 79 + 80 + 81 + async def test_replace_audio_creates_revision( 82 + user1_client: AsyncPlyrClient, 83 + integration_settings: IntegrationSettings, 84 + drone_a4: Path, 85 + drone_e4: Path, 86 + ): 87 + """upload → replace audio with a different file → revision list contains 88 + exactly one row capturing the original audio.""" 89 + client = user1_client 90 + api_url = integration_settings.api_url 91 + assert integration_settings.token_1 92 + token = integration_settings.token_1 93 + 94 + upload = await client.upload( 95 + drone_a4, 96 + "integration: replace creates revision", 97 + tags={"integration-test", "audio-revisions"}, 98 + ) 99 + track_id = upload.track_id 100 + try: 101 + original = await client.get_track(track_id) 102 + original_file_id = original.file_id 103 + 104 + # before any replace: history is empty 105 + async with httpx.AsyncClient(timeout=60.0) as http: 106 + revisions_before = await _list_revisions(http, api_url, token, track_id) 107 + assert revisions_before == [] 108 + 109 + # replace via raw httpx (SDK has no wrapper yet) 110 + with drone_e4.open("rb") as f: 111 + files = {"file": ("drone_e4.wav", f, "audio/wav")} 112 + replace_resp = await http.put( 113 + f"{api_url}/tracks/{track_id}/audio", 114 + files=files, 115 + headers=_auth_headers(token), 116 + ) 117 + replace_resp.raise_for_status() 118 + 119 + # wait for the background task to land 120 + new_file_id = await _poll_until_file_id_changes( 121 + http, api_url, token, track_id, original_file_id 122 + ) 123 + assert new_file_id != original_file_id 124 + 125 + # exactly one revision now exists, capturing the displaced original 126 + revisions_after = await _list_revisions(http, api_url, token, track_id) 127 + assert len(revisions_after) == 1 128 + rev = revisions_after[0] 129 + assert rev["track_id"] == track_id 130 + assert rev["file_type"] == "wav" 131 + assert rev["was_gated"] is False 132 + # response shape is intentionally narrow — file_id stays internal 133 + assert "file_id" not in rev 134 + assert "audio_url" not in rev 135 + finally: 136 + await client.delete(track_id) 137 + 138 + 139 + async def test_restore_swaps_audio_and_rotates_revision( 140 + user1_client: AsyncPlyrClient, 141 + integration_settings: IntegrationSettings, 142 + drone_a4: Path, 143 + drone_e4: Path, 144 + ): 145 + """upload → replace → restore the displaced version. the original audio is 146 + live again, the chosen revision row is gone, and the displaced post-replace 147 + audio is now in history.""" 148 + client = user1_client 149 + api_url = integration_settings.api_url 150 + assert integration_settings.token_1 151 + token = integration_settings.token_1 152 + 153 + upload = await client.upload( 154 + drone_a4, 155 + "integration: restore rotates revision", 156 + tags={"integration-test", "audio-revisions"}, 157 + ) 158 + track_id = upload.track_id 159 + try: 160 + original = await client.get_track(track_id) 161 + original_file_id = original.file_id 162 + 163 + async with httpx.AsyncClient(timeout=60.0) as http: 164 + with drone_e4.open("rb") as f: 165 + files = {"file": ("drone_e4.wav", f, "audio/wav")} 166 + replace_resp = await http.put( 167 + f"{api_url}/tracks/{track_id}/audio", 168 + files=files, 169 + headers=_auth_headers(token), 170 + ) 171 + replace_resp.raise_for_status() 172 + 173 + replaced_file_id = await _poll_until_file_id_changes( 174 + http, api_url, token, track_id, original_file_id 175 + ) 176 + 177 + revisions = await _list_revisions(http, api_url, token, track_id) 178 + assert len(revisions) == 1 179 + chosen_revision_id = revisions[0]["id"] 180 + 181 + # restore the displaced original 182 + restore_resp = await http.post( 183 + f"{api_url}/tracks/{track_id}/revisions/{chosen_revision_id}/restore", 184 + headers=_auth_headers(token), 185 + ) 186 + restore_resp.raise_for_status() 187 + snapshot_payload = restore_resp.json() 188 + # the response is the snapshot of the audio that was just displaced 189 + assert snapshot_payload["track_id"] == track_id 190 + assert snapshot_payload["id"] != chosen_revision_id 191 + 192 + # the live track should now point back at the original file_id 193 + # (restore is sync — no polling needed) 194 + after_restore = ( 195 + await http.get( 196 + f"{api_url}/tracks/{track_id}", headers=_auth_headers(token) 197 + ) 198 + ).json() 199 + assert after_restore["file_id"] == original_file_id 200 + 201 + # history now contains exactly one row: the displaced post-replace 202 + # audio. the original (chosen) row was deleted on restore. 203 + revisions_after = await _list_revisions(http, api_url, token, track_id) 204 + assert len(revisions_after) == 1 205 + assert revisions_after[0]["id"] != chosen_revision_id 206 + assert revisions_after[0]["id"] == snapshot_payload["id"] 207 + del replaced_file_id # asserted indirectly via the snapshot id mapping 208 + finally: 209 + await client.delete(track_id) 210 + 211 + 212 + async def test_non_owner_cannot_list_or_restore( 213 + user1_client: AsyncPlyrClient, 214 + user2_client: AsyncPlyrClient, 215 + integration_settings: IntegrationSettings, 216 + drone_a4: Path, 217 + drone_e4: Path, 218 + ): 219 + """user2 must not be able to list user1's revisions OR restore one. this 220 + also doubles as a smoke that the dev token surface enforces ownership.""" 221 + api_url = integration_settings.api_url 222 + assert integration_settings.token_1 223 + assert integration_settings.token_2 224 + token1 = integration_settings.token_1 225 + token2 = integration_settings.token_2 226 + del user2_client # only used to surface the multi-user skip via the fixture 227 + 228 + upload = await user1_client.upload( 229 + drone_a4, 230 + "integration: ownership check on revisions", 231 + tags={"integration-test", "audio-revisions"}, 232 + ) 233 + track_id = upload.track_id 234 + try: 235 + async with httpx.AsyncClient(timeout=60.0) as http: 236 + # produce one revision so there's something for user2 to try to 237 + # restore 238 + original_file_id = ( 239 + await http.get( 240 + f"{api_url}/tracks/{track_id}", headers=_auth_headers(token1) 241 + ) 242 + ).json()["file_id"] 243 + with drone_e4.open("rb") as f: 244 + files = {"file": ("drone_e4.wav", f, "audio/wav")} 245 + await http.put( 246 + f"{api_url}/tracks/{track_id}/audio", 247 + files=files, 248 + headers=_auth_headers(token1), 249 + ) 250 + await _poll_until_file_id_changes( 251 + http, api_url, token1, track_id, original_file_id 252 + ) 253 + revs = await _list_revisions(http, api_url, token1, track_id) 254 + assert len(revs) == 1 255 + revision_id = revs[0]["id"] 256 + 257 + # user2: cannot list 258 + list_resp = await http.get( 259 + f"{api_url}/tracks/{track_id}/revisions", 260 + headers=_auth_headers(token2), 261 + ) 262 + assert list_resp.status_code == 403 263 + 264 + # user2: cannot restore 265 + restore_resp = await http.post( 266 + f"{api_url}/tracks/{track_id}/revisions/{revision_id}/restore", 267 + headers=_auth_headers(token2), 268 + ) 269 + assert restore_resp.status_code == 403 270 + finally: 271 + await user1_client.delete(track_id)
+451
frontend/src/lib/components/AudioRevisionsSheet.svelte
··· 1 + <script lang="ts"> 2 + interface RevisionRow { 3 + id: number; 4 + track_id: number; 5 + created_at: string; 6 + file_type: string; 7 + original_file_type: string | null; 8 + audio_storage: string; 9 + duration: number | null; 10 + was_gated: boolean; 11 + } 12 + 13 + interface Props { 14 + open: boolean; 15 + trackTitle: string; 16 + revisions: RevisionRow[]; 17 + loading: boolean; 18 + error: string | null; 19 + onClose: () => void; 20 + onRestore: (revision: RevisionRow) => void; 21 + } 22 + 23 + let { 24 + open, 25 + trackTitle, 26 + revisions, 27 + loading, 28 + error, 29 + onClose, 30 + onRestore 31 + }: Props = $props(); 32 + 33 + function formatTime(isoString: string): string { 34 + const seconds = Math.floor((Date.now() - new Date(isoString).getTime()) / 1000); 35 + if (seconds < 60) return 'just now'; 36 + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; 37 + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; 38 + if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`; 39 + return `${Math.floor(seconds / 604800)}w ago`; 40 + } 41 + 42 + function formatDuration(seconds: number | null): string { 43 + if (seconds === null) return '—'; 44 + const total = Math.floor(seconds); 45 + const m = Math.floor(total / 60); 46 + const s = total % 60; 47 + return `${m}:${s.toString().padStart(2, '0')}`; 48 + } 49 + 50 + function formatFormat(rev: RevisionRow): string { 51 + const ft = rev.file_type.toUpperCase(); 52 + if (rev.original_file_type && rev.original_file_type !== rev.file_type) { 53 + return `format: ${ft} (transcoded from ${rev.original_file_type.toUpperCase()})`; 54 + } 55 + return `format: ${ft}`; 56 + } 57 + 58 + function formatStorage(storage: string): string { 59 + return storage === 'pds' || storage === 'both' ? 'on your PDS' : 'on plyr.fm'; 60 + } 61 + 62 + function buildSubtitle(rev: RevisionRow): string { 63 + const parts = [ 64 + formatTime(rev.created_at), 65 + formatDuration(rev.duration), 66 + formatStorage(rev.audio_storage) 67 + ]; 68 + if (rev.was_gated) parts.push('was gated'); 69 + return parts.join(' · '); 70 + } 71 + 72 + function handleBackdropClick(event: MouseEvent) { 73 + if (event.target === event.currentTarget) onClose(); 74 + } 75 + 76 + function stopPropagation(event: MouseEvent) { 77 + event.stopPropagation(); 78 + } 79 + 80 + function handleSheetKeydown(event: KeyboardEvent) { 81 + if (event.key === 'Escape') { 82 + event.preventDefault(); 83 + onClose(); 84 + } 85 + } 86 + </script> 87 + 88 + <div 89 + class="sheet-backdrop" 90 + class:open 91 + role="presentation" 92 + onclick={handleBackdropClick} 93 + > 94 + <div 95 + class="sheet" 96 + role="dialog" 97 + aria-modal="true" 98 + aria-label="version history" 99 + tabindex="-1" 100 + onclick={stopPropagation} 101 + onkeydown={handleSheetKeydown} 102 + > 103 + <div class="sheet-handle"></div> 104 + <div class="sheet-header"> 105 + <div class="sheet-titles"> 106 + <span class="sheet-title">version history</span> 107 + <span class="sheet-subtitle">{trackTitle}</span> 108 + </div> 109 + <button class="sheet-close" onclick={onClose} aria-label="close"> 110 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> 111 + <line x1="18" y1="6" x2="6" y2="18"></line> 112 + <line x1="6" y1="6" x2="18" y2="18"></line> 113 + </svg> 114 + </button> 115 + </div> 116 + <div class="sheet-content"> 117 + {#if loading} 118 + <div class="sheet-loading"> 119 + {#each [1, 2, 3] as _, i (i)} 120 + <div class="revision-skeleton"> 121 + <div class="icon-skeleton"></div> 122 + <div class="text-skeleton-stack"> 123 + <div class="text-skeleton wide"></div> 124 + <div class="text-skeleton narrow"></div> 125 + </div> 126 + <div class="btn-skeleton"></div> 127 + </div> 128 + {/each} 129 + </div> 130 + {:else if error} 131 + <div class="sheet-empty error">{error}</div> 132 + {:else if revisions.length > 0} 133 + <div class="revisions-list"> 134 + {#each revisions as rev (rev.id)} 135 + <div class="revision-row"> 136 + <div class="revision-icon" aria-hidden="true"> 137 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 138 + <path d="M9 18V5l12-2v13"></path> 139 + <circle cx="6" cy="18" r="3"></circle> 140 + <circle cx="18" cy="16" r="3"></circle> 141 + </svg> 142 + </div> 143 + <div class="revision-info"> 144 + <span class="revision-format">{formatFormat(rev)}</span> 145 + <span class="revision-meta">{buildSubtitle(rev)}</span> 146 + </div> 147 + <button 148 + class="restore-btn" 149 + onclick={() => onRestore(rev)} 150 + > 151 + restore 152 + </button> 153 + </div> 154 + {/each} 155 + </div> 156 + {:else} 157 + <div class="sheet-empty"> 158 + no previous versions yet — replace the audio to start building history 159 + </div> 160 + {/if} 161 + </div> 162 + </div> 163 + </div> 164 + 165 + <style> 166 + .sheet-backdrop { 167 + position: fixed; 168 + inset: 0; 169 + background: color-mix(in srgb, var(--bg-primary) 60%, transparent); 170 + backdrop-filter: blur(4px); 171 + -webkit-backdrop-filter: blur(4px); 172 + z-index: 9999; 173 + opacity: 0; 174 + pointer-events: none; 175 + transition: opacity 0.15s; 176 + display: flex; 177 + align-items: flex-end; 178 + justify-content: center; 179 + } 180 + 181 + .sheet-backdrop.open { 182 + opacity: 1; 183 + pointer-events: auto; 184 + } 185 + 186 + .sheet { 187 + width: 100%; 188 + max-width: 480px; 189 + max-height: 70vh; 190 + background: var(--bg-secondary); 191 + border: 1px solid var(--border-subtle); 192 + border-bottom: none; 193 + border-radius: var(--radius-xl) var(--radius-xl) 0 0; 194 + display: flex; 195 + flex-direction: column; 196 + transform: translateY(100%); 197 + transition: transform 0.2s ease-out; 198 + padding-bottom: env(safe-area-inset-bottom, 0px); 199 + } 200 + 201 + .sheet-backdrop.open .sheet { 202 + transform: translateY(0); 203 + } 204 + 205 + .sheet-handle { 206 + width: 32px; 207 + height: 4px; 208 + background: var(--border-default); 209 + border-radius: 2px; 210 + margin: 0.75rem auto 0; 211 + flex-shrink: 0; 212 + } 213 + 214 + .sheet-header { 215 + display: flex; 216 + align-items: flex-start; 217 + justify-content: space-between; 218 + gap: 0.75rem; 219 + padding: 0.75rem 1rem; 220 + flex-shrink: 0; 221 + } 222 + 223 + .sheet-titles { 224 + display: flex; 225 + flex-direction: column; 226 + gap: 0.125rem; 227 + min-width: 0; 228 + flex: 1; 229 + } 230 + 231 + .sheet-title { 232 + font-size: var(--text-base); 233 + font-weight: 600; 234 + color: var(--text-primary); 235 + } 236 + 237 + .sheet-subtitle { 238 + font-size: var(--text-xs); 239 + color: var(--text-tertiary); 240 + white-space: nowrap; 241 + overflow: hidden; 242 + text-overflow: ellipsis; 243 + } 244 + 245 + .sheet-close { 246 + background: none; 247 + border: none; 248 + color: var(--text-muted); 249 + cursor: pointer; 250 + padding: 0.25rem; 251 + border-radius: var(--radius-sm); 252 + transition: color 0.15s; 253 + display: flex; 254 + align-items: center; 255 + justify-content: center; 256 + flex-shrink: 0; 257 + } 258 + 259 + .sheet-close:hover { 260 + color: var(--text-primary); 261 + } 262 + 263 + .sheet-content { 264 + overflow-y: auto; 265 + padding: 0 1rem 1rem; 266 + flex: 1; 267 + min-height: 0; 268 + } 269 + 270 + .revisions-list { 271 + display: flex; 272 + flex-direction: column; 273 + } 274 + 275 + .revision-row { 276 + display: flex; 277 + align-items: center; 278 + gap: 0.75rem; 279 + padding: 0.75rem 0.25rem; 280 + border-bottom: 1px solid var(--border-subtle); 281 + } 282 + 283 + .revision-row:last-child { 284 + border-bottom: none; 285 + } 286 + 287 + .revision-icon { 288 + width: 32px; 289 + height: 32px; 290 + border-radius: var(--radius-sm); 291 + background: var(--bg-tertiary); 292 + color: var(--text-muted); 293 + display: flex; 294 + align-items: center; 295 + justify-content: center; 296 + flex-shrink: 0; 297 + } 298 + 299 + .revision-info { 300 + display: flex; 301 + flex-direction: column; 302 + gap: 0.125rem; 303 + min-width: 0; 304 + flex: 1; 305 + } 306 + 307 + .revision-format { 308 + font-size: var(--text-sm); 309 + font-weight: 500; 310 + color: var(--text-primary); 311 + white-space: nowrap; 312 + overflow: hidden; 313 + text-overflow: ellipsis; 314 + } 315 + 316 + .revision-meta { 317 + font-size: var(--text-xs); 318 + color: var(--text-tertiary); 319 + white-space: nowrap; 320 + overflow: hidden; 321 + text-overflow: ellipsis; 322 + } 323 + 324 + .restore-btn { 325 + flex-shrink: 0; 326 + padding: 0.375rem 0.75rem; 327 + font-family: inherit; 328 + font-size: var(--text-sm); 329 + font-weight: 500; 330 + color: var(--accent); 331 + background: transparent; 332 + border: 1px solid var(--accent); 333 + border-radius: var(--radius-md); 334 + cursor: pointer; 335 + transition: all 0.15s; 336 + } 337 + 338 + .restore-btn:hover { 339 + background: color-mix(in srgb, var(--accent) 12%, transparent); 340 + } 341 + 342 + .sheet-empty { 343 + color: var(--text-tertiary); 344 + font-size: var(--text-sm); 345 + text-align: center; 346 + padding: 2rem 1rem; 347 + } 348 + 349 + .sheet-empty.error { 350 + color: #ef4444; 351 + } 352 + 353 + .sheet-loading { 354 + display: flex; 355 + flex-direction: column; 356 + } 357 + 358 + .revision-skeleton { 359 + display: flex; 360 + align-items: center; 361 + gap: 0.75rem; 362 + padding: 0.75rem 0.25rem; 363 + } 364 + 365 + .icon-skeleton { 366 + width: 32px; 367 + height: 32px; 368 + border-radius: var(--radius-sm); 369 + background: linear-gradient(90deg, var(--bg-tertiary) 0%, var(--bg-hover) 50%, var(--bg-tertiary) 100%); 370 + background-size: 200% 100%; 371 + animation: shimmer 1.5s ease-in-out infinite; 372 + flex-shrink: 0; 373 + } 374 + 375 + .text-skeleton-stack { 376 + display: flex; 377 + flex-direction: column; 378 + gap: 0.375rem; 379 + flex: 1; 380 + } 381 + 382 + .text-skeleton { 383 + height: 12px; 384 + border-radius: var(--radius-sm); 385 + background: linear-gradient(90deg, var(--bg-tertiary) 0%, var(--bg-hover) 50%, var(--bg-tertiary) 100%); 386 + background-size: 200% 100%; 387 + animation: shimmer 1.5s ease-in-out infinite; 388 + } 389 + 390 + .text-skeleton.wide { 391 + width: 70%; 392 + } 393 + 394 + .text-skeleton.narrow { 395 + width: 45%; 396 + } 397 + 398 + .btn-skeleton { 399 + width: 64px; 400 + height: 28px; 401 + border-radius: var(--radius-md); 402 + background: linear-gradient(90deg, var(--bg-tertiary) 0%, var(--bg-hover) 50%, var(--bg-tertiary) 100%); 403 + background-size: 200% 100%; 404 + animation: shimmer 1.5s ease-in-out infinite; 405 + flex-shrink: 0; 406 + } 407 + 408 + @keyframes shimmer { 409 + 0% { background-position: 200% 0; } 410 + 100% { background-position: -200% 0; } 411 + } 412 + 413 + @media (min-width: 600px) { 414 + .sheet-backdrop { 415 + align-items: center; 416 + } 417 + 418 + .sheet { 419 + border-radius: var(--radius-xl); 420 + border-bottom: 1px solid var(--border-subtle); 421 + max-width: 480px; 422 + max-height: 70vh; 423 + transform: scale(0.95); 424 + opacity: 0; 425 + transition: transform 0.2s ease-out, opacity 0.15s; 426 + } 427 + 428 + .sheet-backdrop.open .sheet { 429 + transform: scale(1); 430 + opacity: 1; 431 + } 432 + 433 + .sheet-handle { 434 + display: none; 435 + } 436 + } 437 + 438 + @media (prefers-reduced-motion: reduce) { 439 + .icon-skeleton, 440 + .text-skeleton, 441 + .btn-skeleton { 442 + animation: none; 443 + } 444 + .sheet { 445 + transition: none; 446 + } 447 + .sheet-backdrop { 448 + transition: none; 449 + } 450 + } 451 + </style>
+191
frontend/src/lib/components/ConfirmDialog.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + open: boolean; 4 + title: string; 5 + body: string; 6 + confirmText?: string; 7 + cancelText?: string; 8 + variant?: 'primary' | 'danger'; 9 + pending?: boolean; 10 + pendingText?: string; 11 + onConfirm: () => void | Promise<void>; 12 + onCancel?: () => void; 13 + } 14 + 15 + let { 16 + open = $bindable(), 17 + title, 18 + body, 19 + confirmText = 'confirm', 20 + cancelText = 'cancel', 21 + variant = 'primary', 22 + pending = false, 23 + pendingText, 24 + onConfirm, 25 + onCancel 26 + }: Props = $props(); 27 + 28 + const titleId = `confirm-dialog-title-${Math.random().toString(36).slice(2, 10)}`; 29 + 30 + function close() { 31 + if (pending) return; 32 + if (onCancel) { 33 + onCancel(); 34 + } else { 35 + open = false; 36 + } 37 + } 38 + 39 + function handleBackdropClick(event: MouseEvent) { 40 + if (event.target === event.currentTarget) close(); 41 + } 42 + 43 + function handleKeydown(event: KeyboardEvent) { 44 + if (event.key === 'Escape') { 45 + event.preventDefault(); 46 + close(); 47 + } 48 + } 49 + 50 + async function handleConfirm() { 51 + await onConfirm(); 52 + } 53 + </script> 54 + 55 + {#if open} 56 + <div 57 + class="modal-overlay" 58 + role="presentation" 59 + onclick={handleBackdropClick} 60 + onkeydown={handleKeydown} 61 + > 62 + <div 63 + class="modal" 64 + role="alertdialog" 65 + aria-modal="true" 66 + aria-labelledby={titleId} 67 + tabindex="-1" 68 + > 69 + <div class="modal-header"> 70 + <h3 id={titleId}>{title}</h3> 71 + </div> 72 + <div class="modal-body"> 73 + <p>{body}</p> 74 + </div> 75 + <div class="modal-footer"> 76 + <button class="cancel-btn" onclick={close} disabled={pending}> 77 + {cancelText} 78 + </button> 79 + <button 80 + class="confirm-btn" 81 + class:danger={variant === 'danger'} 82 + onclick={handleConfirm} 83 + disabled={pending} 84 + > 85 + {pending && pendingText ? pendingText : confirmText} 86 + </button> 87 + </div> 88 + </div> 89 + </div> 90 + {/if} 91 + 92 + <style> 93 + .modal-overlay { 94 + position: fixed; 95 + top: 0; 96 + left: 0; 97 + right: 0; 98 + bottom: 0; 99 + background: rgba(0, 0, 0, 0.5); 100 + display: flex; 101 + align-items: center; 102 + justify-content: center; 103 + z-index: 1000; 104 + padding: 1rem; 105 + } 106 + 107 + .modal { 108 + background: var(--bg-primary); 109 + border: 1px solid var(--border-default); 110 + border-radius: var(--radius-xl); 111 + width: 100%; 112 + max-width: 400px; 113 + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); 114 + } 115 + 116 + .modal-header { 117 + display: flex; 118 + align-items: center; 119 + justify-content: space-between; 120 + padding: 1.25rem 1.5rem; 121 + border-bottom: 1px solid var(--border-default); 122 + } 123 + 124 + .modal-header h3 { 125 + font-size: var(--text-xl); 126 + font-weight: 600; 127 + color: var(--text-primary); 128 + margin: 0; 129 + } 130 + 131 + .modal-body { 132 + padding: 1.5rem; 133 + } 134 + 135 + .modal-body p { 136 + margin: 0; 137 + color: var(--text-secondary); 138 + font-size: var(--text-base); 139 + line-height: 1.5; 140 + } 141 + 142 + .modal-footer { 143 + display: flex; 144 + justify-content: flex-end; 145 + gap: 0.75rem; 146 + padding: 1rem 1.5rem 1.25rem; 147 + } 148 + 149 + .cancel-btn, 150 + .confirm-btn { 151 + padding: 0.625rem 1.25rem; 152 + border-radius: var(--radius-md); 153 + font-family: inherit; 154 + font-size: var(--text-base); 155 + font-weight: 500; 156 + cursor: pointer; 157 + transition: all 0.15s; 158 + } 159 + 160 + .cancel-btn { 161 + background: var(--bg-secondary); 162 + border: 1px solid var(--border-default); 163 + color: var(--text-secondary); 164 + } 165 + 166 + .cancel-btn:hover:not(:disabled) { 167 + background: var(--bg-hover); 168 + color: var(--text-primary); 169 + } 170 + 171 + .confirm-btn { 172 + background: var(--accent); 173 + border: 1px solid var(--accent); 174 + color: white; 175 + } 176 + 177 + .confirm-btn.danger { 178 + background: #ef4444; 179 + border-color: #ef4444; 180 + } 181 + 182 + .confirm-btn:hover:not(:disabled) { 183 + opacity: 0.9; 184 + } 185 + 186 + .confirm-btn:disabled, 187 + .cancel-btn:disabled { 188 + opacity: 0.5; 189 + cursor: not-allowed; 190 + } 191 + </style>
+188 -28
frontend/src/routes/portal/+page.svelte
··· 13 13 import PdsBackfillControl from '$lib/components/PdsBackfillControl.svelte'; 14 14 import type { Track, FeaturedArtist, AlbumSummary, Playlist } from '$lib/types'; 15 15 import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 16 + import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; 17 + import AudioRevisionsSheet from '$lib/components/AudioRevisionsSheet.svelte'; 16 18 import { API_URL, getServerConfig } from '$lib/config'; 17 19 import { toast } from '$lib/toast.svelte'; 18 20 import { auth } from '$lib/auth.svelte'; ··· 41 43 let editRemoveImage = $state(false); 42 44 // audio replace state — separate flow from metadata edit because the 43 45 // upload + transcode + PDS write can take 30s+ and has its own SSE progress 44 - // (surfaced via toast, not inline). 46 + // (surfaced via toast, not inline). a confirm dialog gates the irreversible 47 + // replace; previous audio is preserved in track_revisions for rollback. 45 48 let editAudioFile = $state<File | null>(null); 49 + let replaceConfirm = $state<{ track: Track; file: File } | null>(null); 50 + 51 + // version history sheet state 52 + interface AudioRevision { 53 + id: number; 54 + track_id: number; 55 + created_at: string; 56 + file_type: string; 57 + original_file_type: string | null; 58 + audio_storage: string; 59 + duration: number | null; 60 + was_gated: boolean; 61 + } 62 + let revisionsSheetTrack = $state<Track | null>(null); 63 + let revisionsList = $state<AudioRevision[]>([]); 64 + let revisionsLoading = $state(false); 65 + let revisionsError = $state<string | null>(null); 66 + let restoreConfirm = $state<{ track: Track; revision: AudioRevision } | null>(null); 67 + let restorePending = $state(false); 46 68 let editSupportGate = $state(false); 47 69 let editUnlisted = $state(false); 48 70 let hasUnresolvedEditFeaturesInput = $state(false); ··· 477 499 editAudioFile = file; 478 500 } 479 501 480 - function replaceAudio(track: typeof tracks[0]) { 502 + function requestReplaceAudio(track: typeof tracks[0]) { 503 + // stage the (track, file) pair; the confirm dialog will fire the 504 + // actual replace. we don't run anything irreversible until confirmed. 481 505 if (!editAudioFile) return; 482 - const file = editAudioFile; 506 + replaceConfirm = { track, file: editAudioFile }; 507 + } 508 + 509 + async function reloadCurrentPlayingTrack(trackId: number) { 510 + if (player.currentTrack?.id !== trackId) return; 511 + try { 512 + const resp = await fetch(`${API_URL}/tracks/${trackId}`, { 513 + credentials: 'include' 514 + }); 515 + if (resp.ok) { 516 + const fresh = await resp.json(); 517 + player.currentTrack = { ...player.currentTrack, ...fresh }; 518 + } 519 + } catch { 520 + // best effort — next track navigation will pick up the new src 521 + } 522 + } 523 + 524 + function executeReplaceAudio() { 525 + if (!replaceConfirm) return; 526 + const { track, file } = replaceConfirm; 483 527 const trackId = track.id; 484 - const trackTitle = track.title; 485 528 486 529 // kick off the background upload+SSE flow. progress and outcome are 487 530 // surfaced via toast — same pattern as the initial upload form. 488 - uploader.replaceAudio(trackId, file, trackTitle, async () => { 531 + uploader.replaceAudio(trackId, file, track.title, async () => { 489 532 // refresh local tracks so the row reflects the new file_id and r2_url 490 533 await loadMyTracks(); 491 - 492 - // if the user is currently playing this track, fetch the fresh row 493 - // and assign it to player.currentTrack — Player.svelte's $effect now 494 - // watches file_id, so a reassign with the new file_id reloads the 495 - // <audio> element src in place. 496 - if (player.currentTrack?.id === trackId) { 497 - try { 498 - const resp = await fetch(`${API_URL}/tracks/${trackId}`, { 499 - credentials: 'include' 500 - }); 501 - if (resp.ok) { 502 - const fresh = await resp.json(); 503 - player.currentTrack = { ...player.currentTrack, ...fresh }; 504 - } 505 - } catch { 506 - // best effort — next track navigation will pick up the new src 507 - } 508 - } 534 + await reloadCurrentPlayingTrack(trackId); 509 535 }); 510 536 511 - // clear the picker immediately; the SSE flow continues in the toast. 512 - // keep the rest of the edit form open in case the user has other 513 - // unsaved metadata changes. 537 + // clear the staged file + dialog. SSE flow continues in the toast; 538 + // the rest of the edit form stays open for other unsaved metadata. 514 539 editAudioFile = null; 540 + replaceConfirm = null; 541 + } 542 + 543 + async function openVersionHistory(track: Track) { 544 + revisionsSheetTrack = track; 545 + revisionsList = []; 546 + revisionsError = null; 547 + revisionsLoading = true; 548 + try { 549 + const resp = await fetch(`${API_URL}/tracks/${track.id}/revisions`, { 550 + credentials: 'include' 551 + }); 552 + if (!resp.ok) { 553 + revisionsError = 'failed to load version history'; 554 + return; 555 + } 556 + const body = await resp.json(); 557 + revisionsList = body.revisions ?? []; 558 + } catch { 559 + revisionsError = 'failed to load version history'; 560 + } finally { 561 + revisionsLoading = false; 562 + } 563 + } 564 + 565 + function requestRestoreRevision(revision: AudioRevision) { 566 + if (!revisionsSheetTrack) return; 567 + restoreConfirm = { track: revisionsSheetTrack, revision }; 568 + } 569 + 570 + async function executeRestoreRevision() { 571 + if (!restoreConfirm) return; 572 + const { track, revision } = restoreConfirm; 573 + restorePending = true; 574 + try { 575 + const resp = await fetch( 576 + `${API_URL}/tracks/${track.id}/revisions/${revision.id}/restore`, 577 + { method: 'POST', credentials: 'include' } 578 + ); 579 + if (!resp.ok) { 580 + const detail = await resp.json().catch(() => ({})); 581 + toast.error(detail.detail ?? 'failed to restore audio'); 582 + return; 583 + } 584 + toast.success('audio restored'); 585 + await loadMyTracks(); 586 + await reloadCurrentPlayingTrack(track.id); 587 + // refresh the sheet's revision list — the chosen one is now gone, 588 + // the displaced current is now in the list 589 + if (revisionsSheetTrack?.id === track.id) { 590 + await openVersionHistory(track); 591 + } 592 + restoreConfirm = null; 593 + } catch { 594 + toast.error('failed to restore audio'); 595 + } finally { 596 + restorePending = false; 597 + } 515 598 } 516 599 517 600 ··· 1093 1176 <button 1094 1177 type="button" 1095 1178 class="audio-replace-btn" 1096 - onclick={() => replaceAudio(track)} 1179 + onclick={() => requestReplaceAudio(track)} 1097 1180 > 1098 1181 replace audio 1099 1182 </button> ··· 1123 1206 </svg> 1124 1207 choose new file 1125 1208 </label> 1209 + <button 1210 + type="button" 1211 + class="audio-history-btn" 1212 + onclick={() => openVersionHistory(track)} 1213 + title="view previous audio versions" 1214 + > 1215 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> 1216 + <path d="M3 12a9 9 0 1 0 3-6.7L3 8"></path> 1217 + <polyline points="3 3 3 8 8 8"></polyline> 1218 + <polyline points="12 7 12 12 15 14"></polyline> 1219 + </svg> 1220 + version history 1221 + </button> 1126 1222 {/if} 1127 1223 <p class="audio-replace-hint"> 1128 - likes, comments, plays, and the track URL all stay the same. updates the audio file only. 1224 + choose a new file, then confirm. uploading runs in the background and the previous audio is kept in version history so you can roll back. likes, comments, plays, and the track URL stay the same. 1129 1225 </p> 1130 1226 </div> 1131 1227 </div> ··· 1654 1750 </main> 1655 1751 {/if} 1656 1752 1753 + <!-- audio replace confirmation: gates the irreversible upload --> 1754 + <ConfirmDialog 1755 + open={replaceConfirm !== null} 1756 + title="replace audio?" 1757 + body={replaceConfirm 1758 + ? `this will swap the audio file for "${replaceConfirm.track.title}". the previous audio will be saved in version history so you can roll back. likes, comments, plays, and the track URL won't change.` 1759 + : ''} 1760 + confirmText="replace" 1761 + cancelText="cancel" 1762 + onConfirm={executeReplaceAudio} 1763 + onCancel={() => { replaceConfirm = null; }} 1764 + /> 1765 + 1766 + <!-- version history sheet: lists previous audio versions with restore --> 1767 + <AudioRevisionsSheet 1768 + open={revisionsSheetTrack !== null} 1769 + trackTitle={revisionsSheetTrack?.title ?? ''} 1770 + revisions={revisionsList} 1771 + loading={revisionsLoading} 1772 + error={revisionsError} 1773 + onClose={() => { 1774 + revisionsSheetTrack = null; 1775 + revisionsList = []; 1776 + revisionsError = null; 1777 + }} 1778 + onRestore={requestRestoreRevision} 1779 + /> 1780 + 1781 + <!-- restore confirmation: gates the swap-back-to-old-audio --> 1782 + <ConfirmDialog 1783 + open={restoreConfirm !== null} 1784 + title="restore this version?" 1785 + body={restoreConfirm 1786 + ? `this will make the selected version the live audio for "${restoreConfirm.track.title}". the current audio will move into version history.` 1787 + : ''} 1788 + confirmText="restore" 1789 + cancelText="cancel" 1790 + pending={restorePending} 1791 + pendingText="restoring..." 1792 + onConfirm={executeRestoreRevision} 1793 + onCancel={() => { restoreConfirm = null; }} 1794 + /> 1795 + 1657 1796 <style> 1658 1797 .loading, 1659 1798 .error-container { ··· 2738 2877 2739 2878 .audio-replace-btn:hover { 2740 2879 filter: brightness(1.1); 2880 + } 2881 + 2882 + .audio-history-btn { 2883 + display: inline-flex; 2884 + align-items: center; 2885 + gap: 0.35rem; 2886 + padding: 0.4rem 0.7rem; 2887 + background: transparent; 2888 + border: 1px solid var(--border-default); 2889 + border-radius: var(--radius-full); 2890 + color: var(--text-secondary); 2891 + font-size: var(--text-xs); 2892 + font-weight: 500; 2893 + cursor: pointer; 2894 + transition: all 0.15s; 2895 + } 2896 + 2897 + .audio-history-btn:hover { 2898 + background: var(--bg-hover); 2899 + color: var(--text-primary); 2900 + border-color: var(--text-secondary); 2741 2901 } 2742 2902 2743 2903 .audio-replace-hint {
+5 -1
loq.toml
··· 156 156 157 157 [[rules]] 158 158 path = "frontend/src/routes/portal/+page.svelte" 159 - max_lines = 3718 159 + max_lines = 3878 160 160 161 161 [[rules]] 162 162 path = "frontend/src/routes/settings/+page.svelte" ··· 261 261 [[rules]] 262 262 path = "backend/tests/conftest.py" 263 263 max_lines = 515 264 + 265 + [[rules]] 266 + path = "backend/tests/api/track_audio_replace/test_pipeline.py" 267 + max_lines = 512