fix: preserve user-chosen order on album upload (#1260)
* fix: preserve user-chosen order on album upload
The album upload form (introduced to concurrent uploads in #1238) lost the
user's chosen track order because the ATProto list record was built by
per-track sync tasks that sorted by Track.created_at. Under concurrent
inserts created_at reflects the DB commit race, not user intent.
This keeps the atproto-native model (the list record remains the source
of truth for order) and fixes the bug by writing the list record exactly
once, with the frontend passing the user-intended order at finalize time.
Backend:
- POST /albums/ — create an empty album shell (title, description).
Uses the existing AlbumCreatePayload model, emits album_release
CollectionEvent, idempotent on (artist_did, slug).
- POST /tracks/ — new optional album_id form field. When provided:
skips get_or_create_album, resolves the album up front to populate
the ATProto track record's album title, links Track.album_id at row
creation, and skips the per-track schedule_album_list_sync docket
task. Mutually exclusive with the legacy album: str field.
- POST /albums/{id}/finalize — accepts ordered track_ids, validates
every id belongs to the album and has a completed PDS write, builds
strongRefs in the requested order, calls upsert_album_list_record
once. Idempotent.
- The legacy album: str path (single-track upload, /upload page) is
untouched.
- Upload SSE completion now surfaces atproto_uri and atproto_cid so the
frontend can track them.
Frontend:
- uploader.svelte.ts: onSuccess callback receives an optional
UploadResult { trackId, atprotoUri, atprotoCid }. New albumId
parameter on uploader.upload() appends album_id to the form data.
- AlbumUploadForm.svelte: new flow is create album → upload cover →
concurrent track uploads with album_id → finalize with ordered
track_ids. Preserves the per-track toast UX and concurrent throughput
from #1238. Partial failure: successful tracks land in relative
original order, failures surface as error toasts.
Regression tests in test_albums.py cover: create endpoint, idempotent
on duplicate slug, finalize writes strongRefs in requested order (NOT
created_at order), finalize rejects foreign tracks, finalize rejects
tracks missing PDS records.
Plan doc at docs/internal/plans/2026-04-09-album-upload-ordering.md.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: cover album upload flow + regenerate API reference
- new internal doc docs/internal/backend/album-uploads.md explaining
the three-step flow (POST /albums/ → POST /tracks/ with album_id →
POST /albums/{id}/finalize), the atproto-native rationale (the list
record's items[] array is the source of truth for track order, not
the DB), the partial-failure story, and the legacy single-track path
- link the new doc from docs/internal/README.md
- update streaming-uploads.md API-contract section to mention the
album_id form field and the atproto_uri/atproto_cid SSE additions
- regenerate docs/public/developers/api-reference so create_album,
finalize_album, AlbumFinalizePayload, and the upload_track album_id
param all appear. the regen also brings api-reference in sync with
already-merged changes that had drifted: adds for_you.md (#1249),
removes feeds.md (#1139)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address review feedback on album upload flow
Two P1 issues caught in external review of #1260:
1. create_album was emitting the album_release CollectionEvent before
any track actually landed, so a total upload failure (or abandoned
upload) surfaced a zero-track album on the artist profile and a fake
"released" event in the activity feed.
Fix:
- Defer CollectionEvent emission to finalize_album, and only emit on
the first successful finalize (deduped by existing-event lookup).
- Filter GET /albums/, GET /albums/{handle}, album search, and the
sitemap to albums with at least one track so empty draft rows are
invisible to public listings. get_album (detail endpoint) is
deliberately left open to preserve legacy semantics where albums
without a synced list record still render.
2. finalize_album was writing the ATProto list record with only the
current session's track_ids. When typing an existing album name in
the upload form (the idempotent-create path), this truncated the
prior tracks out of the list record — existing tracks stayed in SQL
but disappeared from the authoritative PDS order.
Fix:
- finalize_album now fetches ALL PDS-ref'd tracks already on the
album and partitions them into "preserved" (not in track_ids) and
"new" (in track_ids).
- For preserved tracks, fetch the existing list record from the PDS
and honor its items[] order (so manual reorderings via the album
edit page are respected). Fall back to created_at if the PDS fetch
fails or a track isn't in the existing list.
- Final order: preserved (existing order) + new (in requested order).
Appending to an existing album now correctly extends the list
record instead of overwriting it.
Regression tests in test_albums.py:
- test_create_album_does_not_emit_release_event
- test_finalize_album_emits_release_event_first_time_only (idempotent
on repeat finalize)
- test_list_albums_hides_empty_albums
- test_list_artist_albums_hides_empty_albums
- test_finalize_album_preserves_existing_tracks_on_append (the core
claim-2 regression test: album with 2 existing tracks + 1 new,
finalize passed only the new id, assert the written list contains
all 3 with existing first)
Internal doc updated to reflect deferred event emission, the preserved/
new partition logic, and the "abandoned shells are invisible" invariant.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: regenerate API reference after review fixes
Review feedback on #1260 noted the public API reference still described
the pre-fix create_album/finalize_album semantics. I'd updated the
backend docstrings when addressing the P1 claims but forgot to re-run
just api-ref. This regeneration picks up:
- create_album: "deferred album_release emission" language
- finalize_album: append-preserve semantics + first-successful-call
dedup for the release event
- list_albums: "zero-track albums hidden" note
- meta.py sitemap: "albums with at least one track" filter
- search.py: empty-album filter on the album search helper
Pure doc regeneration — no code changes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: integration coverage for album upload flow
Adds tests/integration/test_album_upload.py with two end-to-end tests
against a real backend (staging) exercising the new multi-track album
upload path:
1. test_album_upload_preserves_finalize_order — create album shell,
upload three tracks sequentially (so created_at is monotonic),
finalize in REVERSE order, GET album detail, assert the returned
track order matches the finalize order. This would catch any
regression where finalize_album stops being authoritative and the
list record reverts to created_at ordering.
2. test_album_finalize_preserves_existing_tracks_on_append — upload
one track, finalize, then "re-create" the album by title (idempotent
create path), upload a second track, finalize with only the new id.
Asserts both tracks are present in the album detail and that the
preserved track appears before the appended one. Catches any
regression of the claim-2 list-record-truncation fix.
The SDK's upload() helper doesn't know about album_id or the new
atproto_uri/atproto_cid SSE fields, so the tests drive raw HTTP via
the client's internal httpx machinery. Each test also asserts that
the SSE completion event includes atproto_uri and atproto_cid —
guarding the frontend strongRef plumbing that finalize depends on.
Skip cleanly without PLYR_TEST_TOKEN_1, matching the rest of the
integration suite.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
authored by