audio streaming app plyr.fm
38
fork

Configure Feed

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

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

nate nowack
Claude Opus 4.6 (1M context)
and committed by
GitHub
cfe3d03d 46235398

+2063 -216
+289 -1
backend/src/backend/api/albums.py
··· 3 3 import asyncio 4 4 import contextlib 5 5 import logging 6 + from datetime import datetime 6 7 from typing import Annotated 7 8 8 9 from fastapi import ( ··· 140 141 description: str | None = None 141 142 142 143 144 + class AlbumFinalizePayload(BaseModel): 145 + """request body for POST /albums/{id}/finalize. 146 + 147 + track_ids is the authoritative user-intended order for the album's 148 + ATProto list record. every id must belong to this album and have a 149 + completed PDS write (atproto_record_uri + cid set). 150 + """ 151 + 152 + track_ids: list[int] 153 + 154 + 143 155 # Helper functions 144 156 async def _album_stats(db: AsyncSession, album_id: str) -> tuple[int, int]: 145 157 result = await db.execute( ··· 224 236 async def list_albums( 225 237 db: Annotated[AsyncSession, Depends(get_db)], 226 238 ) -> dict[str, list[AlbumListItem]]: 227 - """list all albums with basic metadata.""" 239 + """list all albums with basic metadata. 240 + 241 + albums with zero tracks are hidden — they're either unfinalized drafts 242 + from the multi-track upload flow or legacy albums awaiting sync. only 243 + albums that have at least one track appear in public listings. 244 + """ 228 245 stmt = ( 229 246 select( 230 247 Album, ··· 235 252 .join(Artist, Album.artist_did == Artist.did) 236 253 .outerjoin(Track, Track.album_id == Album.id) 237 254 .group_by(Album.id, Artist.did) 255 + .having(func.count(Track.id) > 0) 238 256 .order_by(func.lower(Album.title)) 239 257 ) 240 258 ··· 272 290 .outerjoin(Track, Track.album_id == Album.id) 273 291 .where(Album.artist_did == artist.did) 274 292 .group_by(Album.id) 293 + .having(func.count(Track.id) > 0) 275 294 .order_by(func.lower(Album.title)) 276 295 ) 277 296 result = await db.execute(stmt) ··· 436 455 return response 437 456 438 457 458 + @router.post("/") 459 + async def create_album( 460 + body: AlbumCreatePayload, 461 + db: Annotated[AsyncSession, Depends(get_db)], 462 + auth_session: Annotated[AuthSession, Depends(require_artist_profile)], 463 + ) -> AlbumMetadata: 464 + """create an empty album shell for the multi-track upload flow. 465 + 466 + the ATProto list record is NOT written here — it is deferred to 467 + `POST /albums/{id}/finalize`, which runs after tracks have actually 468 + been published so a total upload failure doesn't leave a fake release 469 + behind. for the same reason, the `album_release` CollectionEvent is 470 + also deferred to finalize (first successful call only, deduped). 471 + 472 + idempotent on (artist_did, slug): if an album with the same slug 473 + already exists, the existing row is returned instead of failing. 474 + this preserves the "type an existing album name to add tracks to it" 475 + UX — see finalize_album for the append semantics. 476 + """ 477 + from sqlalchemy.exc import IntegrityError 478 + 479 + title = body.title.strip() 480 + if not title: 481 + raise HTTPException(status_code=400, detail="title is required") 482 + 483 + slug = body.slug.strip() if body.slug else slugify(title) 484 + if not slug: 485 + raise HTTPException(status_code=400, detail="invalid slug") 486 + 487 + description = body.description.strip() if body.description else None 488 + 489 + # lookup artist for the response payload 490 + artist_result = await db.execute( 491 + select(Artist).where(Artist.did == auth_session.did) 492 + ) 493 + artist = artist_result.scalar_one() 494 + 495 + # idempotent on (artist_did, slug) — matches get_or_create_album semantics 496 + existing_result = await db.execute( 497 + select(Album).where(Album.artist_did == artist.did, Album.slug == slug) 498 + ) 499 + if existing := existing_result.scalar_one_or_none(): 500 + track_count, total_plays = await _album_stats(db, existing.id) 501 + return await _album_metadata(existing, artist, track_count, total_plays) 502 + 503 + album = Album( 504 + artist_did=artist.did, 505 + slug=slug, 506 + title=title, 507 + description=description, 508 + ) 509 + db.add(album) 510 + try: 511 + await db.flush() 512 + except IntegrityError: 513 + # concurrent create raced us — return the winning row 514 + await db.rollback() 515 + retry_result = await db.execute( 516 + select(Album).where(Album.artist_did == artist.did, Album.slug == slug) 517 + ) 518 + album = retry_result.scalar_one() 519 + track_count, total_plays = await _album_stats(db, album.id) 520 + return await _album_metadata(album, artist, track_count, total_plays) 521 + 522 + await db.commit() 523 + await db.refresh(album) 524 + 525 + return await _album_metadata(album, artist, track_count=0, total_plays=0) 526 + 527 + 439 528 @router.post("/{album_id}/cover") 440 529 async def upload_album_cover( 441 530 album_id: str, ··· 484 573 raise HTTPException( 485 574 status_code=500, detail=f"failed to upload image: {e!s}" 486 575 ) from e 576 + 577 + 578 + @router.post("/{album_id}/finalize") 579 + async def finalize_album( 580 + album_id: str, 581 + body: AlbumFinalizePayload, 582 + db: Annotated[AsyncSession, Depends(get_db)], 583 + auth_session: Annotated[AuthSession, Depends(require_artist_profile)], 584 + ) -> AlbumMetadata: 585 + """write the album's ATProto list record using an explicit track order. 586 + 587 + called by the frontend after per-track uploads have settled. this is 588 + the single place the list record is created/updated for albums built 589 + via `POST /albums/` + `POST /tracks/?album_id=...`. 590 + 591 + append semantics: `track_ids` carries only the tracks from the current 592 + upload session. any tracks already on the album that are NOT in 593 + `track_ids` are preserved in the list record at their current positions 594 + (fetched from the existing list record if present, falling back to 595 + created_at order). new tracks are appended at the end in the order 596 + requested. this matches the "type an existing album name to add tracks 597 + to it" UX without truncating prior track history. 598 + 599 + also emits an `album_release` CollectionEvent on the first successful 600 + finalize for the album — so total upload failures don't leave a fake 601 + release event in the activity feed. 602 + """ 603 + from backend._internal.atproto.records import get_record_public_resilient 604 + from backend._internal.atproto.records.fm_plyr.list import ( 605 + upsert_album_list_record, 606 + ) 607 + from backend.models import CollectionEvent 608 + 609 + if not body.track_ids: 610 + raise HTTPException(status_code=400, detail="track_ids must not be empty") 611 + 612 + # verify album ownership 613 + album_result = await db.execute(select(Album).where(Album.id == album_id)) 614 + album = album_result.scalar_one_or_none() 615 + if not album: 616 + raise HTTPException(status_code=404, detail="album not found") 617 + if album.artist_did != auth_session.did: 618 + raise HTTPException( 619 + status_code=403, detail="you can only finalize your own albums" 620 + ) 621 + 622 + # fetch the requested tracks for validation 623 + requested_result = await db.execute( 624 + select(Track).where(Track.id.in_(body.track_ids)) 625 + ) 626 + requested_by_id = {t.id: t for t in requested_result.scalars().all()} 627 + 628 + # validate: every requested id exists, belongs to this album, and has a 629 + # completed PDS write. surface specific errors so the frontend can retry 630 + # or message the user precisely. 631 + missing = [tid for tid in body.track_ids if tid not in requested_by_id] 632 + if missing: 633 + raise HTTPException(status_code=400, detail=f"track(s) not found: {missing}") 634 + 635 + wrong_album = [ 636 + tid for tid in body.track_ids if requested_by_id[tid].album_id != album_id 637 + ] 638 + if wrong_album: 639 + raise HTTPException( 640 + status_code=400, 641 + detail=f"track(s) do not belong to this album: {wrong_album}", 642 + ) 643 + 644 + missing_pds = [ 645 + tid 646 + for tid in body.track_ids 647 + if not requested_by_id[tid].atproto_record_uri 648 + or not requested_by_id[tid].atproto_record_cid 649 + ] 650 + if missing_pds: 651 + raise HTTPException( 652 + status_code=400, 653 + detail=( 654 + f"track(s) missing PDS record (upload may still be in flight): " 655 + f"{missing_pds}" 656 + ), 657 + ) 658 + 659 + # fetch ALL PDS-ref'd tracks already on this album — these may include 660 + # tracks from prior upload sessions that the current request doesn't 661 + # mention and must be preserved in the list record. 662 + existing_result = await db.execute( 663 + select(Track).where( 664 + Track.album_id == album_id, 665 + Track.atproto_record_uri.isnot(None), 666 + Track.atproto_record_cid.isnot(None), 667 + ) 668 + ) 669 + all_album_tracks = {t.id: t for t in existing_result.scalars().all()} 670 + 671 + # partition: preserved (existing, not in this request) vs new (in this request). 672 + # a track id that appears in both sets is treated as "new" so a repeat finalize 673 + # with the same ids rewrites the order deterministically. 674 + requested_set = set(body.track_ids) 675 + preserved_tracks = [ 676 + t for tid, t in all_album_tracks.items() if tid not in requested_set 677 + ] 678 + 679 + # determine preserved order: if the album already has a list record, honor 680 + # its current item order (which captures any manual reorderings the owner 681 + # made from the album edit page). fall back to created_at for tracks not 682 + # in the existing list, or if the PDS fetch fails entirely. 683 + preserved_position_by_uri: dict[str, int] = {} 684 + if album.atproto_record_uri and preserved_tracks: 685 + try: 686 + artist_lookup = await db.execute( 687 + select(Artist).where(Artist.did == album.artist_did) 688 + ) 689 + artist_for_pds = artist_lookup.scalar_one() 690 + record_data, _ = await get_record_public_resilient( 691 + record_uri=album.atproto_record_uri, 692 + pds_url=artist_for_pds.pds_url, 693 + ) 694 + items = record_data.get("value", {}).get("items", []) 695 + for i, item in enumerate(items): 696 + uri = item.get("subject", {}).get("uri") 697 + if uri: 698 + preserved_position_by_uri[uri] = i 699 + except Exception as e: 700 + logger.debug( 701 + f"finalize_album: failed to fetch existing list for preserved " 702 + f"track order on {album_id}: {e}" 703 + ) 704 + 705 + def _preserved_sort_key(t: Track) -> tuple[int, datetime]: 706 + # tracks already in the existing list: keep their position 707 + # tracks not in the existing list (or if fetch failed): sort by created_at 708 + # after all positioned items 709 + pos = preserved_position_by_uri.get(t.atproto_record_uri or "", 10_000_000) 710 + return (pos, t.created_at) 711 + 712 + preserved_tracks.sort(key=_preserved_sort_key) 713 + 714 + # build the final list: preserved (existing, at front) + new (in requested order) 715 + final_order: list[Track] = list(preserved_tracks) + [ 716 + requested_by_id[tid] for tid in body.track_ids 717 + ] 718 + 719 + # strongRefs in final order (the validation above guarantees these are 720 + # non-None for the requested tracks; preserved tracks were filtered at 721 + # fetch time, but narrow for the type checker) 722 + track_refs: list[dict[str, str]] = [] 723 + for t in final_order: 724 + assert t.atproto_record_uri is not None 725 + assert t.atproto_record_cid is not None 726 + track_refs.append({"uri": t.atproto_record_uri, "cid": t.atproto_record_cid}) 727 + 728 + try: 729 + result = await upsert_album_list_record( 730 + auth_session, 731 + album_id=album_id, 732 + album_title=album.title, 733 + track_refs=track_refs, 734 + existing_uri=album.atproto_record_uri, 735 + existing_created_at=album.created_at, 736 + ) 737 + except Exception as e: 738 + logger.warning(f"failed to write album list record for {album_id}: {e}") 739 + raise HTTPException( 740 + status_code=500, detail=f"failed to write album list record: {e}" 741 + ) from e 742 + 743 + if result: 744 + album.atproto_record_uri = result[0] 745 + album.atproto_record_cid = result[1] 746 + 747 + # emit album_release CollectionEvent on the first successful finalize only. 748 + # deferred from create_album so a total upload failure doesn't publish a 749 + # fake release event. deduped by checking for any existing event. 750 + existing_event = await db.execute( 751 + select(CollectionEvent).where( 752 + CollectionEvent.album_id == album_id, 753 + CollectionEvent.event_type == "album_release", 754 + ) 755 + ) 756 + if not existing_event.scalar_one_or_none(): 757 + db.add( 758 + CollectionEvent( 759 + event_type="album_release", 760 + actor_did=auth_session.did, 761 + album_id=album_id, 762 + ) 763 + ) 764 + 765 + await db.commit() 766 + 767 + await invalidate_album_cache(auth_session.handle, album.slug) 768 + 769 + artist_result = await db.execute( 770 + select(Artist).where(Artist.did == album.artist_did) 771 + ) 772 + artist = artist_result.scalar_one() 773 + track_count, total_plays = await _album_stats(db, album_id) 774 + return await _album_metadata(album, artist, track_count, total_plays) 487 775 488 776 489 777 @router.patch("/{album_id}")
+5 -1
backend/src/backend/api/meta.py
··· 132 132 for row in artists_result.all() 133 133 ] 134 134 135 - # fetch all albums (artist handle, slug, updated_at) 135 + # fetch all albums that have at least one track — empty albums are either 136 + # unfinalized drafts or legacy rows awaiting sync and don't belong in the 137 + # sitemap 136 138 albums_result = await db.execute( 137 139 select(Album.slug, Artist.handle, Album.updated_at) 138 140 .join(Artist, Album.artist_did == Artist.did) 141 + .join(Track, Track.album_id == Album.id) 142 + .distinct() 139 143 .order_by(Album.updated_at.desc()) 140 144 ) 141 145 albums = [
+3 -1
backend/src/backend/api/search.py
··· 236 236 similarity = func.similarity(Album.title, query) 237 237 substring_match = Album.title.ilike(f"%{query}%") 238 238 239 + # filter out empty albums (unfinalized drafts or legacy rows awaiting sync) 240 + has_tracks = select(Track.id).where(Track.album_id == Album.id).limit(1).exists() 239 241 stmt = ( 240 242 select(Album, Artist, similarity.label("relevance")) 241 243 .join(Artist, Album.artist_did == Artist.did) 242 - .where(or_(similarity > 0.1, substring_match)) 244 + .where(or_(similarity > 0.1, substring_match), has_tracks) 243 245 .order_by(similarity.desc()) 244 246 .limit(limit) 245 247 )
+56 -8
backend/src/backend/api/tracks/uploads.py
··· 44 44 from backend._internal.tasks.hooks import run_post_track_create_hooks 45 45 from backend._internal.thumbnails import generate_and_save 46 46 from backend.config import settings 47 - from backend.models import Artist, Track, UserPreferences 47 + from backend.models import Album, Artist, Track, UserPreferences 48 48 from backend.models.job import JobStatus, JobType 49 49 from backend.storage import storage 50 50 from backend.utilities.audio import extract_duration ··· 84 84 # track metadata 85 85 title: str 86 86 artist_did: str 87 - album: str | None 87 + album: str | None # legacy: album display name for get_or_create_album 88 + album_id: str | None # new: explicit reference to an existing Album row 88 89 features_json: str | None 89 90 tags: list[str] 90 91 ··· 667 668 ctx.features_json, artist.handle 668 669 ) 669 670 671 + # if an explicit album_id was passed, resolve the album up front so the 672 + # ATProto track record includes the correct album title and the row is 673 + # linked at creation (instead of the legacy defer-until-after-PDS flow). 674 + album_row: Album | None = None 675 + if ctx.album_id: 676 + album_lookup = await db.execute( 677 + select(Album).where(Album.id == ctx.album_id) 678 + ) 679 + album_row = album_lookup.scalar_one_or_none() 680 + if not album_row: 681 + raise UploadPhaseError(f"album {ctx.album_id} not found") 682 + if album_row.artist_did != ctx.artist_did: 683 + raise UploadPhaseError("album does not belong to this artist") 684 + # sync the display name so build_track_record embeds the right value 685 + ctx.album = album_row.title 686 + 670 687 extra: dict = {} 671 688 if audio_info.duration: 672 689 extra["duration"] = audio_info.duration ··· 681 698 artist_display_name = artist.display_name 682 699 683 700 # album creation deferred to after PDS success to avoid orphan albums 701 + # (legacy path only — new path uses album_row set above) 684 702 track = Track( 685 703 title=ctx.title, 686 704 file_id=sr.file_id, ··· 690 708 artist_did=ctx.artist_did, 691 709 description=ctx.description, 692 710 extra=extra, 693 - album_id=None, 711 + album_id=album_row.id if album_row else None, 694 712 features=featured_artists, 695 713 r2_url=sr.r2_url, 696 714 atproto_record_uri=uri, ··· 779 797 780 798 # step 3: atomic CAS update pending → published + deferred album linkage 781 799 async with db_session() as db: 782 - # create album now that PDS write succeeded (avoids orphan albums on failure) 800 + # legacy path: create album now that PDS write succeeded (avoids orphan 801 + # albums on failure). the new explicit-album_id path has already linked 802 + # the track to its album at row-creation time, so this block is skipped. 783 803 album_record = None 784 - if ctx.album: 804 + if ctx.album and not ctx.album_id: 785 805 artist_row = await db.execute( 786 806 select(Artist).where(Artist.did == ctx.artist_did) 787 807 ) ··· 838 858 async with db_session() as db: 839 859 await add_tags_to_track(db, track.id, ctx.tags, ctx.artist_did) 840 860 841 - # upload-specific: album list sync 842 - if track.album_id: 861 + # upload-specific: album list sync (legacy path only — the explicit 862 + # album_id path defers list creation to POST /albums/{id}/finalize so the 863 + # record is written once with the user-intended order instead of racing 864 + # per-track sync tasks on `created_at`) 865 + if track.album_id and not ctx.album_id: 843 866 await schedule_album_list_sync(ctx.auth_session.session_id, track.album_id) 867 + from backend.api.albums import invalidate_album_cache_by_id 868 + 869 + async with db_session() as db: 870 + await invalidate_album_cache_by_id(db, track.album_id) 871 + elif track.album_id and ctx.album_id: 872 + # still invalidate cache so the album page reflects the new track once 873 + # finalize runs 844 874 from backend.api.albums import invalidate_album_cache_by_id 845 875 846 876 async with db_session() as db: ··· 902 932 # phase 7: post-upload tasks (tags, album sync, shared hooks) 903 933 await _schedule_post_upload(ctx, sr, track, run_hooks=published_by_us) 904 934 905 - result: dict[str, Any] = {"track_id": track.id} 935 + result: dict[str, Any] = { 936 + "track_id": track.id, 937 + "atproto_uri": track.atproto_record_uri, 938 + "atproto_cid": track.atproto_record_cid, 939 + } 906 940 if pds_result and pds_result.warning: 907 941 result["warnings"] = [pds_result.warning] 908 942 ··· 942 976 background_tasks: BackgroundTasks, 943 977 auth_session: AuthSession = Depends(require_artist_profile), 944 978 album: Annotated[str | None, Form()] = None, 979 + album_id: Annotated[ 980 + str | None, 981 + Form( 982 + description="explicit album id to attach to (mutually exclusive with album)" 983 + ), 984 + ] = None, 945 985 features: Annotated[str | None, Form()] = None, 946 986 tags: Annotated[str | None, Form(description="JSON array of tag names")] = None, 947 987 support_gate: Annotated[ ··· 977 1017 Returns: 978 1018 dict: A payload containing `upload_id` for monitoring progress via SSE. 979 1019 """ 1020 + # album and album_id are mutually exclusive 1021 + if album and album_id: 1022 + raise HTTPException( 1023 + status_code=400, 1024 + detail="album and album_id are mutually exclusive — provide one or the other", 1025 + ) 1026 + 980 1027 # validate tags upfront before any processing 981 1028 try: 982 1029 validated_tags = parse_tags_json(tags) ··· 1084 1131 title=title, 1085 1132 artist_did=auth_session.did, 1086 1133 album=album, 1134 + album_id=album_id, 1087 1135 features_json=features, 1088 1136 tags=validated_tags, 1089 1137 description=description,
+586
backend/tests/api/test_albums.py
··· 915 915 916 916 assert response.status_code == 400 917 917 assert "not in this album" in response.json()["detail"] 918 + 919 + 920 + # ----------------------------------------------------------------------------- 921 + # POST /albums/ and POST /albums/{id}/finalize 922 + # ----------------------------------------------------------------------------- 923 + 924 + 925 + async def test_create_album_endpoint(test_app: FastAPI, db_session: AsyncSession): 926 + """POST /albums/ creates an empty album shell without tracks or list record.""" 927 + artist = Artist( 928 + did="did:test:user123", 929 + handle="test.artist", 930 + display_name="Test Artist", 931 + pds_url="https://test.pds", 932 + ) 933 + db_session.add(artist) 934 + await db_session.commit() 935 + 936 + async with AsyncClient( 937 + transport=ASGITransport(app=test_app), base_url="http://test" 938 + ) as client: 939 + response = await client.post( 940 + "/albums/", 941 + json={"title": "My New Album", "description": "some notes"}, 942 + ) 943 + 944 + assert response.status_code == 200 945 + data = response.json() 946 + assert data["title"] == "My New Album" 947 + assert data["slug"] == "my-new-album" 948 + assert data["description"] == "some notes" 949 + assert data["track_count"] == 0 950 + assert data["list_uri"] is None # no PDS list record yet 951 + 952 + # verify it landed in the DB 953 + result = await db_session.execute(select(Album).where(Album.slug == "my-new-album")) 954 + album = result.scalar_one() 955 + assert album.artist_did == artist.did 956 + assert album.atproto_record_uri is None 957 + 958 + 959 + async def test_create_album_idempotent_on_duplicate_slug( 960 + test_app: FastAPI, db_session: AsyncSession 961 + ): 962 + """POST /albums/ with a duplicate title returns the existing row.""" 963 + artist = Artist( 964 + did="did:test:user123", 965 + handle="test.artist", 966 + display_name="Test Artist", 967 + pds_url="https://test.pds", 968 + ) 969 + db_session.add(artist) 970 + await db_session.flush() 971 + 972 + existing = Album( 973 + artist_did=artist.did, 974 + slug="my-album", 975 + title="My Album", 976 + ) 977 + db_session.add(existing) 978 + await db_session.commit() 979 + existing_id = existing.id 980 + 981 + async with AsyncClient( 982 + transport=ASGITransport(app=test_app), base_url="http://test" 983 + ) as client: 984 + response = await client.post("/albums/", json={"title": "My Album"}) 985 + 986 + assert response.status_code == 200 987 + assert response.json()["id"] == existing_id 988 + 989 + 990 + async def test_finalize_album_writes_list_in_requested_order( 991 + test_app: FastAPI, db_session: AsyncSession 992 + ): 993 + """finalize uses the track_ids array order, ignoring created_at. 994 + 995 + regression test for concurrent-album-upload ordering: the frontend posts 996 + track_ids in user-intended order; the backend must build the ATProto 997 + list record using that exact ordering, not Track.created_at (which is 998 + racy under concurrent inserts). 999 + """ 1000 + from datetime import UTC, datetime, timedelta 1001 + 1002 + artist = Artist( 1003 + did="did:test:user123", 1004 + handle="test.artist", 1005 + display_name="Test Artist", 1006 + pds_url="https://test.pds", 1007 + ) 1008 + db_session.add(artist) 1009 + await db_session.flush() 1010 + 1011 + album = Album( 1012 + artist_did=artist.did, 1013 + slug="finalize-test", 1014 + title="Finalize Test", 1015 + ) 1016 + db_session.add(album) 1017 + await db_session.flush() 1018 + 1019 + # intentionally stagger created_at to differ from user-intended order. 1020 + # user-intended order (as passed to finalize): [t_a, t_b, t_c] 1021 + # created_at order (if we were dumb): [t_c, t_b, t_a] 1022 + base = datetime.now(UTC) 1023 + t_a = Track( 1024 + title="First by user intent", 1025 + file_id="fin-a", 1026 + file_type="audio/mpeg", 1027 + artist_did=artist.did, 1028 + album_id=album.id, 1029 + atproto_record_uri="at://did:test:user123/fm.plyr.track/aaa", 1030 + atproto_record_cid="cidA", 1031 + created_at=base + timedelta(hours=2), 1032 + ) 1033 + t_b = Track( 1034 + title="Second", 1035 + file_id="fin-b", 1036 + file_type="audio/mpeg", 1037 + artist_did=artist.did, 1038 + album_id=album.id, 1039 + atproto_record_uri="at://did:test:user123/fm.plyr.track/bbb", 1040 + atproto_record_cid="cidB", 1041 + created_at=base + timedelta(hours=1), 1042 + ) 1043 + t_c = Track( 1044 + title="Third", 1045 + file_id="fin-c", 1046 + file_type="audio/mpeg", 1047 + artist_did=artist.did, 1048 + album_id=album.id, 1049 + atproto_record_uri="at://did:test:user123/fm.plyr.track/ccc", 1050 + atproto_record_cid="cidC", 1051 + created_at=base, 1052 + ) 1053 + db_session.add_all([t_a, t_b, t_c]) 1054 + await db_session.commit() 1055 + 1056 + album_id = album.id 1057 + ordered_ids = [t_a.id, t_b.id, t_c.id] 1058 + 1059 + captured: dict[str, object] = {} 1060 + 1061 + async def fake_upsert( 1062 + auth_session: object, 1063 + *, 1064 + album_id: str, 1065 + album_title: str, 1066 + track_refs: list[dict[str, str]], 1067 + existing_uri: str | None = None, 1068 + existing_created_at: object = None, 1069 + ) -> tuple[str, str]: 1070 + captured["track_refs"] = track_refs 1071 + return ( 1072 + f"at://did:test:user123/fm.plyr.list/{album_id}", 1073 + "new-list-cid", 1074 + ) 1075 + 1076 + with patch( 1077 + "backend._internal.atproto.records.fm_plyr.list.upsert_album_list_record", 1078 + side_effect=fake_upsert, 1079 + ): 1080 + async with AsyncClient( 1081 + transport=ASGITransport(app=test_app), base_url="http://test" 1082 + ) as client: 1083 + response = await client.post( 1084 + f"/albums/{album_id}/finalize", 1085 + json={"track_ids": ordered_ids}, 1086 + ) 1087 + 1088 + assert response.status_code == 200, response.json() 1089 + data = response.json() 1090 + assert data["list_uri"] == f"at://did:test:user123/fm.plyr.list/{album_id}" 1091 + 1092 + # the strongRefs passed to upsert_album_list_record must be in the exact 1093 + # order requested (t_a → t_b → t_c), NOT the created_at order 1094 + track_refs = captured["track_refs"] 1095 + assert isinstance(track_refs, list) 1096 + uris: list[str] = [ref["uri"] for ref in track_refs] # type: ignore[index] 1097 + assert uris == [ 1098 + "at://did:test:user123/fm.plyr.track/aaa", 1099 + "at://did:test:user123/fm.plyr.track/bbb", 1100 + "at://did:test:user123/fm.plyr.track/ccc", 1101 + ] 1102 + 1103 + 1104 + async def test_finalize_album_rejects_foreign_tracks( 1105 + test_app: FastAPI, db_session: AsyncSession 1106 + ): 1107 + """finalize 400s if a track_id doesn't belong to the album.""" 1108 + artist = Artist( 1109 + did="did:test:user123", 1110 + handle="test.artist", 1111 + display_name="Test Artist", 1112 + pds_url="https://test.pds", 1113 + ) 1114 + db_session.add(artist) 1115 + await db_session.flush() 1116 + 1117 + album = Album(artist_did=artist.did, slug="album-a", title="Album A") 1118 + other_album = Album(artist_did=artist.did, slug="album-b", title="Album B") 1119 + db_session.add_all([album, other_album]) 1120 + await db_session.flush() 1121 + 1122 + foreign_track = Track( 1123 + title="Foreign", 1124 + file_id="foreign-1", 1125 + file_type="audio/mpeg", 1126 + artist_did=artist.did, 1127 + album_id=other_album.id, 1128 + atproto_record_uri="at://did:test:user123/fm.plyr.track/foreign", 1129 + atproto_record_cid="cidF", 1130 + ) 1131 + db_session.add(foreign_track) 1132 + await db_session.commit() 1133 + 1134 + async with AsyncClient( 1135 + transport=ASGITransport(app=test_app), base_url="http://test" 1136 + ) as client: 1137 + response = await client.post( 1138 + f"/albums/{album.id}/finalize", 1139 + json={"track_ids": [foreign_track.id]}, 1140 + ) 1141 + 1142 + assert response.status_code == 400 1143 + assert "do not belong" in response.json()["detail"] 1144 + 1145 + 1146 + async def test_finalize_album_rejects_tracks_missing_pds_record( 1147 + test_app: FastAPI, db_session: AsyncSession 1148 + ): 1149 + """finalize 400s if a track hasn't completed its PDS write yet.""" 1150 + artist = Artist( 1151 + did="did:test:user123", 1152 + handle="test.artist", 1153 + display_name="Test Artist", 1154 + pds_url="https://test.pds", 1155 + ) 1156 + db_session.add(artist) 1157 + await db_session.flush() 1158 + 1159 + album = Album(artist_did=artist.did, slug="pending-album", title="Pending") 1160 + db_session.add(album) 1161 + await db_session.flush() 1162 + 1163 + pending_track = Track( 1164 + title="Still pending", 1165 + file_id="pending-1", 1166 + file_type="audio/mpeg", 1167 + artist_did=artist.did, 1168 + album_id=album.id, 1169 + atproto_record_uri=None, # not yet published 1170 + atproto_record_cid=None, 1171 + ) 1172 + db_session.add(pending_track) 1173 + await db_session.commit() 1174 + 1175 + async with AsyncClient( 1176 + transport=ASGITransport(app=test_app), base_url="http://test" 1177 + ) as client: 1178 + response = await client.post( 1179 + f"/albums/{album.id}/finalize", 1180 + json={"track_ids": [pending_track.id]}, 1181 + ) 1182 + 1183 + assert response.status_code == 400 1184 + assert "PDS record" in response.json()["detail"] 1185 + 1186 + 1187 + # ----------------------------------------------------------------------------- 1188 + # regression tests for review feedback on #1260: 1189 + # P1: create_album used to emit album_release immediately, so a total upload 1190 + # failure left a visible fake release in the activity feed. 1191 + # P1: finalize_album used to send only the current-session tracks to the list 1192 + # record, truncating prior tracks when appending to an existing album. 1193 + # ----------------------------------------------------------------------------- 1194 + 1195 + 1196 + async def test_create_album_does_not_emit_release_event( 1197 + test_app: FastAPI, db_session: AsyncSession 1198 + ): 1199 + """create_album must NOT emit album_release — that's deferred to finalize 1200 + so total upload failures don't publish a fake release.""" 1201 + from backend.models import CollectionEvent 1202 + 1203 + artist = Artist( 1204 + did="did:test:user123", 1205 + handle="test.artist", 1206 + display_name="Test Artist", 1207 + pds_url="https://test.pds", 1208 + ) 1209 + db_session.add(artist) 1210 + await db_session.commit() 1211 + 1212 + async with AsyncClient( 1213 + transport=ASGITransport(app=test_app), base_url="http://test" 1214 + ) as client: 1215 + response = await client.post("/albums/", json={"title": "Unreleased Album"}) 1216 + assert response.status_code == 200 1217 + 1218 + # verify no album_release event was emitted 1219 + events_result = await db_session.execute( 1220 + select(CollectionEvent).where(CollectionEvent.event_type == "album_release") 1221 + ) 1222 + events = events_result.scalars().all() 1223 + assert len(events) == 0, "create_album should not emit album_release" 1224 + 1225 + 1226 + async def test_finalize_album_emits_release_event_first_time_only( 1227 + test_app: FastAPI, db_session: AsyncSession 1228 + ): 1229 + """finalize_album emits album_release on the first successful call, and 1230 + never re-emits on subsequent finalize calls for the same album.""" 1231 + from backend.models import CollectionEvent 1232 + 1233 + artist = Artist( 1234 + did="did:test:user123", 1235 + handle="test.artist", 1236 + display_name="Test Artist", 1237 + pds_url="https://test.pds", 1238 + ) 1239 + db_session.add(artist) 1240 + await db_session.flush() 1241 + 1242 + album = Album( 1243 + artist_did=artist.did, 1244 + slug="first-time", 1245 + title="First Time", 1246 + ) 1247 + db_session.add(album) 1248 + await db_session.flush() 1249 + 1250 + track = Track( 1251 + title="Only Track", 1252 + file_id="only-file", 1253 + file_type="audio/mpeg", 1254 + artist_did=artist.did, 1255 + album_id=album.id, 1256 + atproto_record_uri="at://did:test:user123/fm.plyr.track/only", 1257 + atproto_record_cid="cidOnly", 1258 + ) 1259 + db_session.add(track) 1260 + await db_session.commit() 1261 + album_id = album.id 1262 + track_id = track.id 1263 + 1264 + async def fake_upsert( 1265 + auth_session: object, 1266 + *, 1267 + album_id: str, 1268 + album_title: str, 1269 + track_refs: list[dict[str, str]], 1270 + existing_uri: str | None = None, 1271 + existing_created_at: object = None, 1272 + ) -> tuple[str, str]: 1273 + return (f"at://did:test:user123/fm.plyr.list/{album_id}", "cid-finalize") 1274 + 1275 + with patch( 1276 + "backend._internal.atproto.records.fm_plyr.list.upsert_album_list_record", 1277 + side_effect=fake_upsert, 1278 + ): 1279 + async with AsyncClient( 1280 + transport=ASGITransport(app=test_app), base_url="http://test" 1281 + ) as client: 1282 + # first finalize → emit event 1283 + r1 = await client.post( 1284 + f"/albums/{album_id}/finalize", json={"track_ids": [track_id]} 1285 + ) 1286 + assert r1.status_code == 200 1287 + 1288 + # second finalize → must NOT emit again 1289 + r2 = await client.post( 1290 + f"/albums/{album_id}/finalize", json={"track_ids": [track_id]} 1291 + ) 1292 + assert r2.status_code == 200 1293 + 1294 + await db_session.commit() 1295 + 1296 + events_result = await db_session.execute( 1297 + select(CollectionEvent).where( 1298 + CollectionEvent.album_id == album_id, 1299 + CollectionEvent.event_type == "album_release", 1300 + ) 1301 + ) 1302 + events = events_result.scalars().all() 1303 + assert len(events) == 1, f"expected exactly one album_release, got {len(events)}" 1304 + 1305 + 1306 + async def test_list_albums_hides_empty_albums( 1307 + test_app: FastAPI, db_session: AsyncSession 1308 + ): 1309 + """GET /albums/ must not include albums with zero tracks (drafts or 1310 + abandoned uploads).""" 1311 + artist = Artist( 1312 + did="did:test:user123", 1313 + handle="test.artist", 1314 + display_name="Test Artist", 1315 + pds_url="https://test.pds", 1316 + ) 1317 + db_session.add(artist) 1318 + await db_session.flush() 1319 + 1320 + populated = Album(artist_did=artist.did, slug="populated", title="Populated Album") 1321 + empty = Album(artist_did=artist.did, slug="empty", title="Empty Draft") 1322 + db_session.add_all([populated, empty]) 1323 + await db_session.flush() 1324 + 1325 + track = Track( 1326 + title="Only Track", 1327 + file_id="pop-file", 1328 + file_type="audio/mpeg", 1329 + artist_did=artist.did, 1330 + album_id=populated.id, 1331 + ) 1332 + db_session.add(track) 1333 + await db_session.commit() 1334 + 1335 + async with AsyncClient( 1336 + transport=ASGITransport(app=test_app), base_url="http://test" 1337 + ) as client: 1338 + response = await client.get("/albums/") 1339 + 1340 + assert response.status_code == 200 1341 + titles = [a["title"] for a in response.json()["albums"]] 1342 + assert "Populated Album" in titles 1343 + assert "Empty Draft" not in titles 1344 + 1345 + 1346 + async def test_list_artist_albums_hides_empty_albums( 1347 + test_app: FastAPI, db_session: AsyncSession 1348 + ): 1349 + """GET /albums/{handle} must not include empty albums either — artist 1350 + profile pages must not render fake releases.""" 1351 + artist = Artist( 1352 + did="did:test:user123", 1353 + handle="test.artist", 1354 + display_name="Test Artist", 1355 + pds_url="https://test.pds", 1356 + ) 1357 + db_session.add(artist) 1358 + await db_session.flush() 1359 + 1360 + populated = Album(artist_did=artist.did, slug="real-album", title="Real Album") 1361 + empty = Album(artist_did=artist.did, slug="ghost", title="Ghost") 1362 + db_session.add_all([populated, empty]) 1363 + await db_session.flush() 1364 + 1365 + track = Track( 1366 + title="Only Track", 1367 + file_id="real-file", 1368 + file_type="audio/mpeg", 1369 + artist_did=artist.did, 1370 + album_id=populated.id, 1371 + ) 1372 + db_session.add(track) 1373 + await db_session.commit() 1374 + 1375 + async with AsyncClient( 1376 + transport=ASGITransport(app=test_app), base_url="http://test" 1377 + ) as client: 1378 + response = await client.get(f"/albums/{artist.handle}") 1379 + 1380 + assert response.status_code == 200 1381 + titles = [a["title"] for a in response.json()["albums"]] 1382 + assert "Real Album" in titles 1383 + assert "Ghost" not in titles 1384 + 1385 + 1386 + async def test_finalize_album_preserves_existing_tracks_on_append( 1387 + test_app: FastAPI, db_session: AsyncSession 1388 + ): 1389 + """when finalize is called with only a subset of the album's tracks (e.g. 1390 + appending new tracks to an existing album), tracks already on the album 1391 + that are NOT in track_ids must be preserved in the written list record. 1392 + 1393 + this is the P1 fix for the "list record truncation on append" review 1394 + finding: without this, uploading additional tracks to an existing album 1395 + would drop the older tracks from the PDS list record. 1396 + """ 1397 + artist = Artist( 1398 + did="did:test:user123", 1399 + handle="test.artist", 1400 + display_name="Test Artist", 1401 + pds_url="https://test.pds", 1402 + ) 1403 + db_session.add(artist) 1404 + await db_session.flush() 1405 + 1406 + # pre-existing album with a list record and 2 existing tracks 1407 + album = Album( 1408 + artist_did=artist.did, 1409 + slug="established", 1410 + title="Established", 1411 + atproto_record_uri="at://did:test:user123/fm.plyr.list/established", 1412 + atproto_record_cid="cid-prev", 1413 + ) 1414 + db_session.add(album) 1415 + await db_session.flush() 1416 + 1417 + old1 = Track( 1418 + title="Old Track 1", 1419 + file_id="old-1", 1420 + file_type="audio/mpeg", 1421 + artist_did=artist.did, 1422 + album_id=album.id, 1423 + atproto_record_uri="at://did:test:user123/fm.plyr.track/old1", 1424 + atproto_record_cid="cidOld1", 1425 + ) 1426 + old2 = Track( 1427 + title="Old Track 2", 1428 + file_id="old-2", 1429 + file_type="audio/mpeg", 1430 + artist_did=artist.did, 1431 + album_id=album.id, 1432 + atproto_record_uri="at://did:test:user123/fm.plyr.track/old2", 1433 + atproto_record_cid="cidOld2", 1434 + ) 1435 + new1 = Track( 1436 + title="New Track 1", 1437 + file_id="new-1", 1438 + file_type="audio/mpeg", 1439 + artist_did=artist.did, 1440 + album_id=album.id, 1441 + atproto_record_uri="at://did:test:user123/fm.plyr.track/new1", 1442 + atproto_record_cid="cidNew1", 1443 + ) 1444 + db_session.add_all([old1, old2, new1]) 1445 + await db_session.commit() 1446 + album_id = album.id 1447 + new1_id = new1.id 1448 + 1449 + # simulate the current list record having old1, old2 in that order 1450 + existing_list_record = { 1451 + "value": { 1452 + "items": [ 1453 + {"subject": {"uri": old1.atproto_record_uri, "cid": "cidOld1"}}, 1454 + {"subject": {"uri": old2.atproto_record_uri, "cid": "cidOld2"}}, 1455 + ] 1456 + } 1457 + } 1458 + 1459 + captured: dict[str, object] = {} 1460 + 1461 + async def fake_upsert( 1462 + auth_session: object, 1463 + *, 1464 + album_id: str, 1465 + album_title: str, 1466 + track_refs: list[dict[str, str]], 1467 + existing_uri: str | None = None, 1468 + existing_created_at: object = None, 1469 + ) -> tuple[str, str]: 1470 + captured["track_refs"] = track_refs 1471 + return (existing_uri or "at://test/list/1", "cid-new") 1472 + 1473 + with ( 1474 + patch( 1475 + "backend._internal.atproto.records.get_record_public_resilient", 1476 + new_callable=AsyncMock, 1477 + return_value=(existing_list_record, None), 1478 + ), 1479 + patch( 1480 + "backend._internal.atproto.records.fm_plyr.list.upsert_album_list_record", 1481 + side_effect=fake_upsert, 1482 + ), 1483 + ): 1484 + async with AsyncClient( 1485 + transport=ASGITransport(app=test_app), base_url="http://test" 1486 + ) as client: 1487 + # finalize with ONLY the new track — old tracks must be preserved 1488 + response = await client.post( 1489 + f"/albums/{album_id}/finalize", 1490 + json={"track_ids": [new1_id]}, 1491 + ) 1492 + 1493 + assert response.status_code == 200, response.json() 1494 + track_refs = captured["track_refs"] 1495 + assert isinstance(track_refs, list) 1496 + uris: list[str] = [ref["uri"] for ref in track_refs] # type: ignore[index] 1497 + # the final list MUST contain all three tracks in order: 1498 + # preserved (old1, old2 from existing list record) → new (new1) 1499 + assert uris == [ 1500 + "at://did:test:user123/fm.plyr.track/old1", 1501 + "at://did:test:user123/fm.plyr.track/old2", 1502 + "at://did:test:user123/fm.plyr.track/new1", 1503 + ], f"append-to-existing-album must preserve prior tracks, got {uris}"
+2
backend/tests/api/test_upload_session_reload.py
··· 49 49 title="test track", 50 50 artist_did="did:plc:test", 51 51 album=None, 52 + album_id=None, 52 53 features_json=None, 53 54 tags=[], 54 55 ) ··· 135 136 title="test track", 136 137 artist_did="did:plc:test", 137 138 album=None, 139 + album_id=None, 138 140 features_json=None, 139 141 tags=[], 140 142 )
+308
backend/tests/integration/test_album_upload.py
··· 1 + """integration tests for the multi-track album upload flow. 2 + 3 + exercises the full create → upload → finalize → verify loop against a 4 + real (staging) backend. regression coverage for #1260 — the PR that 5 + switched album uploads to the first-class album_id + finalize flow. 6 + 7 + these tests use raw HTTP via the SDK's internal httpx client because 8 + the SDK's upload() method doesn't expose the new album_id form field 9 + or the atproto_uri/atproto_cid SSE completion fields. 10 + """ 11 + 12 + from __future__ import annotations 13 + 14 + import json 15 + from pathlib import Path 16 + from typing import TYPE_CHECKING, Any 17 + 18 + import pytest 19 + 20 + if TYPE_CHECKING: 21 + from plyrfm import AsyncPlyrClient 22 + 23 + pytestmark = [pytest.mark.integration, pytest.mark.timeout(180)] 24 + 25 + 26 + async def _create_album( 27 + client: AsyncPlyrClient, 28 + *, 29 + title: str, 30 + description: str | None = None, 31 + ) -> dict[str, Any]: 32 + """POST /albums/ via raw http. returns the album metadata payload.""" 33 + body: dict[str, Any] = {"title": title} 34 + if description: 35 + body["description"] = description 36 + response = await client._client.post( 37 + client._url("/albums/"), 38 + headers=client._auth_headers, 39 + json=body, 40 + ) 41 + response.raise_for_status() 42 + return response.json() 43 + 44 + 45 + async def _upload_track_with_album_id( 46 + client: AsyncPlyrClient, 47 + *, 48 + file: Path, 49 + title: str, 50 + album_id: str, 51 + tags: set[str], 52 + timeout: float = 120.0, 53 + ) -> int: 54 + """POST /tracks/ with an explicit album_id form field, then poll the 55 + SSE progress stream for completion. returns the created track id. 56 + 57 + the SDK's upload() helper doesn't know about album_id, so we drive 58 + the raw multipart request and SSE polling here. 59 + """ 60 + with open(file, "rb") as f: 61 + files = {"file": (file.name, f)} 62 + data: dict[str, str] = { 63 + "title": title, 64 + "album_id": album_id, 65 + "tags": json.dumps(list(tags)), 66 + } 67 + post_response = await client._client.post( 68 + client._url("/tracks/"), 69 + headers=client._auth_headers, 70 + files=files, 71 + data=data, 72 + timeout=timeout, 73 + ) 74 + post_response.raise_for_status() 75 + upload_id = post_response.json()["upload_id"] 76 + 77 + # poll SSE for completion 78 + async with client._client.stream( 79 + "GET", 80 + client._url(f"/tracks/uploads/{upload_id}/progress"), 81 + headers=client._auth_headers, 82 + timeout=timeout, 83 + ) as stream: 84 + async for line in stream.aiter_lines(): 85 + if not line.startswith("data: "): 86 + continue 87 + payload = json.loads(line[6:]) 88 + status = payload.get("status") 89 + if status == "completed": 90 + track_id = payload.get("track_id") 91 + assert track_id is not None, ( 92 + f"completed event missing track_id: {payload}" 93 + ) 94 + # regression: SSE completion must surface atproto_uri/cid so 95 + # the frontend can build strongRefs for finalize 96 + assert "atproto_uri" in payload, ( 97 + f"completed event missing atproto_uri: {payload}" 98 + ) 99 + assert "atproto_cid" in payload, ( 100 + f"completed event missing atproto_cid: {payload}" 101 + ) 102 + return int(track_id) 103 + if status == "failed": 104 + raise ValueError( 105 + f"track upload failed: {payload.get('error', 'unknown')}" 106 + ) 107 + raise ValueError("upload stream ended without completion") 108 + 109 + 110 + async def _finalize_album( 111 + client: AsyncPlyrClient, 112 + *, 113 + album_id: str, 114 + track_ids: list[int], 115 + ) -> dict[str, Any]: 116 + """POST /albums/{id}/finalize via raw http. returns album metadata.""" 117 + response = await client._client.post( 118 + client._url(f"/albums/{album_id}/finalize"), 119 + headers=client._auth_headers, 120 + json={"track_ids": track_ids}, 121 + ) 122 + response.raise_for_status() 123 + return response.json() 124 + 125 + 126 + async def _delete_album_cascade( 127 + client: AsyncPlyrClient, 128 + *, 129 + album_id: str, 130 + ) -> None: 131 + """delete an album and all its tracks — cleanup helper for integration 132 + tests. fails silently on 404 so double-cleanup doesn't break teardown.""" 133 + response = await client._client.delete( 134 + client._url(f"/albums/{album_id}?cascade=true"), 135 + headers=client._auth_headers, 136 + ) 137 + if response.status_code == 404: 138 + return 139 + response.raise_for_status() 140 + 141 + 142 + async def test_album_upload_preserves_finalize_order( 143 + user1_client: AsyncPlyrClient, 144 + drone_a4: Path, 145 + drone_c4: Path, 146 + drone_e4: Path, 147 + ) -> None: 148 + """full album upload flow: create shell, upload tracks in one order, 149 + finalize in the REVERSE order, verify the album returns tracks in 150 + finalize order (not upload order). 151 + 152 + regression for #1260: under concurrent uploads the list record was 153 + built by per-track sync tasks sorting by Track.created_at, so user- 154 + intended order was lost. finalize must be the authoritative source 155 + of order at upload time. 156 + """ 157 + client = user1_client 158 + album_id: str | None = None 159 + try: 160 + # step 1: create album shell 161 + album = await _create_album( 162 + client, 163 + title="Integration Test Album (Ordering)", 164 + description="created by test_album_upload_preserves_finalize_order", 165 + ) 166 + album_id = album["id"] 167 + assert album_id is not None 168 + assert album["track_count"] == 0 169 + assert album["list_uri"] is None 170 + 171 + # step 2: upload three tracks in upload order A → C → E 172 + # (sequential via await — intentionally not concurrent so ordering 173 + # by created_at is predictable; this makes the regression assertion 174 + # stronger because finalize-order must still override created_at) 175 + track_a = await _upload_track_with_album_id( 176 + client, 177 + file=drone_a4, 178 + title="Integration Ordering — First Uploaded (A4)", 179 + album_id=album_id, 180 + tags={"integration-test", "album-ordering"}, 181 + ) 182 + track_c = await _upload_track_with_album_id( 183 + client, 184 + file=drone_c4, 185 + title="Integration Ordering — Second Uploaded (C4)", 186 + album_id=album_id, 187 + tags={"integration-test", "album-ordering"}, 188 + ) 189 + track_e = await _upload_track_with_album_id( 190 + client, 191 + file=drone_e4, 192 + title="Integration Ordering — Third Uploaded (E4)", 193 + album_id=album_id, 194 + tags={"integration-test", "album-ordering"}, 195 + ) 196 + 197 + # step 3: finalize with REVERSE order — E4 → C4 → A4 198 + finalize_order = [track_e, track_c, track_a] 199 + finalized = await _finalize_album( 200 + client, album_id=album_id, track_ids=finalize_order 201 + ) 202 + assert finalized["list_uri"] is not None, ( 203 + "finalize must write an ATProto list record" 204 + ) 205 + 206 + # step 4: GET the album and verify tracks come back in finalize order, 207 + # not upload order. the public detail endpoint reads from the list 208 + # record's items[] array. 209 + artist_handle = finalized["artist_handle"] 210 + slug = finalized["slug"] 211 + detail_response = await client._client.get( 212 + client._url(f"/albums/{artist_handle}/{slug}"), 213 + headers=client._auth_headers, 214 + ) 215 + detail_response.raise_for_status() 216 + detail = detail_response.json() 217 + 218 + returned_ids = [t["id"] for t in detail["tracks"]] 219 + assert returned_ids == finalize_order, ( 220 + f"album tracks must follow finalize order {finalize_order}, " 221 + f"got {returned_ids}" 222 + ) 223 + finally: 224 + if album_id: 225 + await _delete_album_cascade(client, album_id=album_id) 226 + 227 + 228 + async def test_album_finalize_preserves_existing_tracks_on_append( 229 + user1_client: AsyncPlyrClient, 230 + drone_a4: Path, 231 + drone_c4: Path, 232 + ) -> None: 233 + """upload one track to a brand-new album, then upload a second track 234 + referencing the same album (via the idempotent create → add flow), 235 + and verify the final list record contains BOTH tracks. 236 + 237 + regression for the claim-2 fix in the external review of #1260: 238 + finalize used to replace the list record with exactly the requested 239 + track_ids, which truncated prior tracks when appending to an existing 240 + album. now finalize must preserve tracks already on the album. 241 + """ 242 + client = user1_client 243 + album_id: str | None = None 244 + try: 245 + # session 1: create album + upload first track + finalize 246 + album_1 = await _create_album(client, title="Integration Test Album (Append)") 247 + album_id = album_1["id"] 248 + assert album_id is not None 249 + 250 + track_a = await _upload_track_with_album_id( 251 + client, 252 + file=drone_a4, 253 + title="Integration Append — Session 1 Track (A4)", 254 + album_id=album_id, 255 + tags={"integration-test", "album-append"}, 256 + ) 257 + finalized_1 = await _finalize_album( 258 + client, album_id=album_id, track_ids=[track_a] 259 + ) 260 + assert finalized_1["list_uri"] is not None 261 + 262 + # session 2: "re-create" the album with the same title — idempotent 263 + # path returns the existing album row, mirroring the UX where a user 264 + # types an existing album name on the upload form to add more tracks 265 + album_2 = await _create_album(client, title="Integration Test Album (Append)") 266 + assert album_2["id"] == album_id, ( 267 + "create_album must be idempotent on duplicate title" 268 + ) 269 + 270 + track_c = await _upload_track_with_album_id( 271 + client, 272 + file=drone_c4, 273 + title="Integration Append — Session 2 Track (C4)", 274 + album_id=album_id, 275 + tags={"integration-test", "album-append"}, 276 + ) 277 + # finalize the SECOND session with ONLY the new track. 278 + # pre-fix behavior would have truncated the list record to [track_c]; 279 + # post-fix must preserve track_a and append track_c. 280 + await _finalize_album(client, album_id=album_id, track_ids=[track_c]) 281 + 282 + # GET the album and verify both tracks are present, with track_a 283 + # preserved from session 1 and track_c appended 284 + artist_handle = album_2["artist_handle"] 285 + slug = album_2["slug"] 286 + detail_response = await client._client.get( 287 + client._url(f"/albums/{artist_handle}/{slug}"), 288 + headers=client._auth_headers, 289 + ) 290 + detail_response.raise_for_status() 291 + detail = detail_response.json() 292 + 293 + returned_ids = [t["id"] for t in detail["tracks"]] 294 + assert track_a in returned_ids, ( 295 + f"existing track {track_a} must be preserved after append finalize, " 296 + f"got {returned_ids}" 297 + ) 298 + assert track_c in returned_ids, ( 299 + f"new track {track_c} must be appended, got {returned_ids}" 300 + ) 301 + # preserved-then-new order: track_a first, then track_c 302 + assert returned_ids.index(track_a) < returned_ids.index(track_c), ( 303 + f"preserved tracks must appear before newly-appended tracks, " 304 + f"got {returned_ids}" 305 + ) 306 + finally: 307 + if album_id: 308 + await _delete_album_cascade(client, album_id=album_id)
+1
docs/internal/README.md
··· 16 16 - **[database/](./backend/database/)** - connection pooling, neon-specific patterns 17 17 - **[feature-flags.md](./backend/feature-flags.md)** - per-user feature rollout system 18 18 - **[streaming-uploads.md](./backend/streaming-uploads.md)** - SSE progress tracking 19 + - **[album-uploads.md](./backend/album-uploads.md)** - multi-track album upload flow (create → finalize) and why the ATProto list record is authoritative for track order 19 20 - **[transcoder.md](./backend/transcoder.md)** - rust audio conversion service (lossless support) 20 21 - **[mood-search.md](./backend/mood-search.md)** - semantic search with CLAP embeddings (Modal + turbopuffer) 21 22 - **[genre-classification.md](./backend/genre-classification.md)** - ML genre tagging via effnet-discogs (Replicate)
+162
docs/internal/backend/album-uploads.md
··· 1 + --- 2 + title: "album uploads" 3 + --- 4 + 5 + # album uploads 6 + 7 + how multi-track album upload works end-to-end, and why. 8 + 9 + ## the three-step flow 10 + 11 + bulk album uploads go through **three** backend calls, not one: 12 + 13 + 1. `POST /albums/` — create an empty album shell (title, description). returns `{id, slug}`. no ATProto list record yet; no tracks yet. 14 + 2. `POST /albums/{id}/cover` — upload cover art (optional). reuses the pre-existing endpoint from the album edit page. 15 + 3. concurrent `POST /tracks/` with the optional `album_id` form field, one per track. each request goes through the full track upload pipeline (PDS blob upload, ATProto track record, R2 storage, transcoder if lossless). per-track toasts surface via SSE. 16 + 4. `POST /albums/{id}/finalize` — body `{track_ids: [int]}` in user-intended order. writes the album's ATProto list record **once** with strongRefs in the exact order requested. 17 + 18 + single-track uploads do **not** use this flow. they post to `POST /tracks/` with the legacy `album: str` form field and go through `get_or_create_album` + per-track `schedule_album_list_sync`. 19 + 20 + ## why it's shaped this way 21 + 22 + album track order is not stored in the database. it lives in the ATProto list record's `items[]` array on the owner's PDS. the album read path (`backend/src/backend/api/albums.py`) fetches that list record and uses `items[]` as the display order; the album edit page's reorder UI writes directly to the list record via `PUT /lists/{rkey}/reorder` and never touches the DB. **the list record is authoritative for order.** 23 + 24 + before this flow existed, the album upload form just fired N concurrent `POST /tracks/` calls with the same `album: str` string. each track's post-upload `schedule_album_list_sync` docket task rebuilt the list record by sorting `Track.album_id == album_id` tracks by `created_at`. under concurrent inserts, `created_at` is effectively random — whichever row the DB commits first wins — so the list record reflected the upload race, not the user's chosen order. this was a regression introduced alongside concurrent album uploads; see #1260 for the fix and #1238 for the regression origin. 25 + 26 + the fix does **not** add a DB ordering column. that would fork the source of truth and require ongoing DB↔PDS synchronization. instead, the frontend owns the authoritative order during upload and passes it to the backend at finalize time, which writes the list record once, correctly, in a single operation. 27 + 28 + ## what happens when 29 + 30 + ### `POST /albums/` (`backend.api.albums.create_album`) 31 + 32 + - creates an `Album` row with `title`, `slug` (derived via `slugify(title)` if not provided), `description` 33 + - **does not** emit `album_release` — that's deferred to `finalize_album` so a total upload failure doesn't leave a fake release in the activity feed 34 + - **does not** create any ATProto record — the list record is deferred to `finalize` 35 + - idempotent on `(artist_did, slug)`: if an album with the same slug already exists, returns the existing row (matches `get_or_create_album` semantics and supports the "type an existing album name to append tracks" UX) 36 + - returns `AlbumMetadata`; `list_uri` is `null` until finalize runs 37 + - the resulting album is invisible to public listings (`GET /albums/`, `GET /albums/{handle}`, search, sitemap) until it has at least one track — so abandoned album shells don't leak onto artist profiles 38 + 39 + ### `POST /tracks/` with `album_id` (`backend.api.tracks.uploads.upload_track`) 40 + 41 + - mutually exclusive with the legacy `album` form field; passing both → 400 42 + - in `_create_records`: 43 + - resolves the album row up front, verifies `album.artist_did == ctx.artist_did` 44 + - sets `ctx.album = album_row.title` so the ATProto track record embeds the correct album title 45 + - sets `Track.album_id = album_row.id` at row creation (not deferred until post-PDS-success as the legacy path does) 46 + - in `_schedule_post_upload`: 47 + - **skips** `schedule_album_list_sync` — the frontend will call `finalize` explicitly 48 + - still invalidates the album cache so the album page reflects the new track when finalize runs 49 + - SSE completion payload adds `atproto_uri` and `atproto_cid` so the frontend can collect strongRefs for the finalize call 50 + 51 + ### `POST /albums/{id}/finalize` (`backend.api.albums.finalize_album`) 52 + 53 + - body: `{"track_ids": [int, ...]}` — the current upload session's tracks in user-intended order 54 + - validates: 55 + - album exists and belongs to the authenticated artist 56 + - every requested `track_id` exists (400 with the missing ids otherwise) 57 + - every track's `album_id` matches the target album (400 with the wrong-album ids otherwise) 58 + - every track has both `atproto_record_uri` and `atproto_record_cid` set (400 with the pending ids otherwise — this guards against finalize firing before a track's PDS write has committed) 59 + - fetches **all** PDS-ref'd tracks currently on the album and partitions them: 60 + - **preserved** = tracks already on the album but not in `track_ids` (i.e. prior upload sessions) 61 + - **new** = tracks in `track_ids` (the current session) 62 + - for preserved tracks, fetches the existing PDS list record (if any) to read its `items[]` order, so any manual reorderings the owner made from the album edit page are honored. falls back to `created_at` order for tracks not in the existing list, or if the PDS fetch fails 63 + - final list = `preserved (existing order) + new (in requested order)` — so appending new tracks to an existing album keeps the prior tracks in their current position instead of truncating the list record 64 + - builds `track_refs: list[{uri, cid}]` in final order 65 + - calls `upsert_album_list_record(auth_session, album_id, album.title, track_refs, existing_uri=album.atproto_record_uri, existing_created_at=album.created_at)` — idempotent, handles both first-create and updates 66 + - persists the returned `uri` and `cid` onto the `Album` row 67 + - emits an `album_release` `CollectionEvent` **only on the first successful finalize** (deduped by checking for any existing event for this album_id) — so re-finalizing doesn't duplicate the activity feed event, and a total upload failure never publishes one 68 + - invalidates the album cache 69 + - returns `AlbumMetadata` 70 + 71 + finalize is safe to call multiple times. semantics for repeated calls: 72 + - **fresh album + full set**: writes list with `track_ids` in order. first call. 73 + - **fresh album + re-finalize with same set**: all tracks become "new" (none in preserved, since the album has no list record yet? no — the album DOES have a list record after the first call). wait — after the first call, the tracks exist in the list record and are now "preserved" unless they're in `track_ids`. so if you re-finalize with the same set, `track_ids` still names them as "new" and they get placed after empty preserved → same order. idempotent ✅ 74 + - **append** (type an existing album name, upload 3 more tracks): `track_ids` carries only the 3 new tracks. preserved = the existing tracks (order honored from the existing list record). final = existing + 3 new at end. 75 + - **partial re-finalize** (rare): naming a subset treats those as "new" and positions them at the end; unnamed tracks stay put at their current positions. this is a defensible but slightly weird semantic — the album edit page's explicit reorder endpoint is the canonical way to shuffle full ordering. 76 + 77 + ## frontend wiring 78 + 79 + `frontend/src/lib/components/AlbumUploadForm.svelte` drives the flow: 80 + 81 + ``` 82 + handleUploadAlbum: 83 + POST /albums/ → {id, slug} 84 + if coverArtFile: 85 + POST /albums/{id}/cover (failure is non-fatal, warning toast only) 86 + 87 + indexedResults: Array<UploadResult | null> = tracks.map(() => null) 88 + 89 + Promise.allSettled( 90 + tracks.map((track, i) => 91 + uploader.upload( 92 + file, title, '', // empty album string 93 + features, null, // no per-track cover 94 + tags, supportGated, autoTag, 95 + description, 96 + (result) => { indexedResults[i] = result }, 97 + { onSuccess, onError }, 98 + track.title, 99 + albumId // new albumId parameter 100 + ) 101 + ) 102 + ) 103 + 104 + orderedTrackIds = indexedResults 105 + .filter((r): r is UploadResult => r !== null) 106 + .map(r => r.trackId) 107 + 108 + if (albumId && orderedTrackIds.length > 0) 109 + POST /albums/{id}/finalize { track_ids: orderedTrackIds } 110 + ``` 111 + 112 + key invariants: 113 + - `indexedResults[i]` holds the result for the track at form position `i` — **not** the completion order. this is how `Promise.allSettled` preserves the user's chosen order across the concurrent race. 114 + - a failed track leaves `indexedResults[i] === null` and is simply omitted from `orderedTrackIds`. successful tracks stay in their relative original positions. 115 + - cover art is uploaded **once** to the album row, not per-track. the old racy flow sent the cover with every track request. 116 + - per-track toasts and concurrent throughput (from #1238) are unchanged — this only replaces the post-upload ordering mechanism. 117 + 118 + ## partial failure 119 + 120 + if some tracks fail during upload: 121 + - successful tracks have the correct `album_id` on their DB row and are included in the finalize call at their original form position 122 + - failed tracks surface as error toasts; `indexedResults[i]` stays `null` 123 + - finalize writes a list record containing only the successful tracks in their relative original order 124 + - gaps in the user's intended sequence are fine — the list record just has the tracks that landed 125 + 126 + if **all** tracks fail: the album row still exists (empty), **but** it's invisible to public listings because `list_albums` / `list_artist_albums` / search / sitemap all filter to albums with at least one track. no `album_release` `CollectionEvent` is emitted either, since that only fires on successful finalize — so the activity feed doesn't surface the failed upload. the frontend shows an error toast. GC of empty album shells is not implemented — followup work. 127 + 128 + if the `POST /albums/` call itself fails: the flow bails with an error toast before any track uploads start. 129 + 130 + if the `POST /albums/{id}/cover` call fails: the flow proceeds without the cover; a warning toast points the user to the album edit page to add it later. 131 + 132 + if `POST /albums/{id}/finalize` fails (e.g. PDS unreachable): the per-track DB rows are correct, but the list record isn't written. a warning toast tells the user to reorder from the album edit page. a future "resync album" button could re-trigger finalize without requiring a manual reorder. 133 + 134 + ## legacy single-track upload path (still supported) 135 + 136 + `POST /tracks/` with the `album: str` form field (and no `album_id`) takes the original path: 137 + 138 + - `get_or_create_album(artist, album_title, image_id, image_url)` creates or finds the album 139 + - cover art is applied to the album on first-create 140 + - `schedule_album_list_sync` is called via docket after the track's PDS write 141 + - `sync_album_list` orders tracks by `Track.created_at` and upserts the list record 142 + 143 + this path is still used by: 144 + - the single-track `/upload` page 145 + - any API client posting a single track with `album: str` 146 + 147 + the racy ordering problem still exists here in principle, but it's not exercised — the single-track form can't produce concurrent inserts into the same album by construction. if a user uploads multiple singles with the same album name in quick succession via the API, they'd land in `created_at` order (usually what you'd want, but not guaranteed under heavy concurrency). 148 + 149 + ## references 150 + 151 + - PR: #1260 — fix: preserve user-chosen order on album upload 152 + - regression origin: #1238 — concurrent album uploads with per-track toasts 153 + - plan doc: `docs/internal/plans/2026-04-09-album-upload-ordering.md` 154 + - code: 155 + - `backend/src/backend/api/albums.py` — `create_album`, `finalize_album`, `AlbumFinalizePayload` 156 + - `backend/src/backend/api/tracks/uploads.py` — `album_id` form field, `UploadContext.album_id`, conditional skip of per-track sync 157 + - `backend/src/backend/_internal/atproto/records/fm_plyr/list.py` — `upsert_album_list_record` 158 + - `backend/src/backend/_internal/tasks/sync.py` — `sync_album_list` (legacy path only) 159 + - `frontend/src/lib/uploader.svelte.ts` — `UploadResult`, `albumId` parameter 160 + - `frontend/src/lib/components/AlbumUploadForm.svelte` — `handleUploadAlbum` 161 + - tests: 162 + - `backend/tests/api/test_albums.py` — `test_create_album_endpoint`, `test_finalize_album_writes_list_in_requested_order`, `test_finalize_album_rejects_foreign_tracks`, `test_finalize_album_rejects_tracks_missing_pds_record`
+2 -2
docs/internal/backend/streaming-uploads.md
··· 260 260 261 261 ### API contract 262 262 - endpoint: `POST /tracks/` (unchanged) 263 - - parameters: title, file, album, features, image (unchanged) 264 - - response: same structure (unchanged) 263 + - parameters: title, file, album, features, image (unchanged); `album_id` added as an optional form field for the multi-track album upload flow — see [album-uploads.md](./album-uploads.md) 264 + - response: same structure; SSE completion payload adds `atproto_uri` and `atproto_cid` for callers that need the PDS strongRef 265 265 - result: no breaking changes for clients 266 266 267 267 ## edge cases
+216
docs/internal/plans/2026-04-09-album-upload-ordering.md
··· 1 + --- 2 + title: "plan: fix album upload ordering" 3 + date: 2026-04-09 4 + --- 5 + 6 + # plan: fix album upload ordering 7 + 8 + ## goal 9 + 10 + when a user uploads an album via the album upload form, the resulting album should display its tracks in the order the user arranged them in the form. today it doesn't — tracks appear in a non-deterministic order because the underlying atproto list record is built from `Track.created_at`, and `created_at` reflects the race winners of concurrent uploads (introduced by #1238). 11 + 12 + ## current state 13 + 14 + ### upload path 15 + - `AlbumUploadForm.svelte:232-295` fires N concurrent `POST /tracks/` calls via `Promise.allSettled` 16 + - each request carries `album: str` (the display name) and the cover art file 17 + - backend `get_or_create_album()` (`tracks/services.py:11`) creates the `Album` row on whichever track commits first; cover art is applied to that first-wins row 18 + - after each track's upload pipeline finishes, `schedule_album_list_sync` (`uploads.py:843`) schedules a docket task that rebuilds the album's atproto list record from DB state 19 + - `sync_album_list` (`_internal/tasks/sync.py:128`) sorts tracks by `Track.created_at` (line 171) and calls `upsert_album_list_record` 20 + 21 + ### read path 22 + - `albums.py:349-375` fetches the album's atproto list record from the owner's PDS and uses `items[]` as track order 23 + - only when the fetch fails or `list_uri` is None does it fall back to `Track.created_at` 24 + 25 + ### reorder path (album edit page) 26 + - `+page.svelte:255-291` — user drags → frontend builds `items: [{uri, cid}]` from current order → `PUT /lists/{rkey}/reorder` → backend calls `update_list_record` directly on the PDS 27 + - DB is never written to; the next album read refetches the list record and sees the new order 28 + - `canReorder` is gated on `albumMetadata.list_uri` being set — you can't reorder an album whose list record doesn't exist yet on the PDS 29 + 30 + ### the problem 31 + 32 + the list record is the source of truth for order, but the `sync_album_list` task that builds it is inherently racy under concurrent uploads. each task reads whichever subset of tracks has committed so far, sorts by `created_at`, and upserts the list record. whichever task fires last wins, and `created_at` is effectively random under concurrency. 33 + 34 + ### what's not the problem 35 + 36 + the DB is an index; the atproto list record is the truth. this plan does **not** introduce a parallel ordering column in the DB. fixing this by adding `track_number` to `Track` would fork the truth and require ongoing sync between DB and PDS. the correct fix is to ensure the list record is written with the user-intended order in a single operation, not rebuilt per-track via racing background tasks. 37 + 38 + ## approach 39 + 40 + 1. albums become first-class: add `POST /albums/` so the album row exists **before** any track uploads start. tracks reference `album_id` explicitly instead of racing on `get_or_create_album`. 41 + 2. reuse the existing `POST /albums/{id}/cover` endpoint for cover art upload (already used from the album edit page). 42 + 3. extend `POST /tracks/` with an optional `album_id` form field. when provided: skip `get_or_create_album`, skip cover art, skip per-track `schedule_album_list_sync`. the track still goes through the full upload pipeline (PDS write, CID capture, etc.). 43 + 4. surface the PDS strongRef (`atproto_record_uri`, `atproto_record_cid`) in the SSE completion payload so the frontend can collect strongRefs for the finalize call. 44 + 5. add `POST /albums/{id}/finalize` that accepts an ordered `track_ids` array, builds strongRefs in that exact order, and writes the atproto list record **once**. 45 + 6. update `AlbumUploadForm.svelte` to drive the new flow: create album → upload cover → concurrent track uploads with `album_id` → collect strongRefs on completion → call finalize → redirect. 46 + 47 + no schema migration. no new DB column. the frontend owns the authoritative order during upload and hands it to the backend at finalize time. 48 + 49 + ## not doing 50 + 51 + - adding `track_number` or any ordering column to the `Track` model 52 + - touching the existing reorder endpoint (`PUT /lists/{rkey}/reorder`) — it already works correctly as designed 53 + - stripping the legacy `album: str` form field from `POST /tracks/` — the single-track `/upload` page still uses it, and it's fine for single uploads where there's no ordering question 54 + - retry-failed-tracks UX — out of scope; failed tracks in an album upload just surface as error toasts, user can re-upload them via the existing add-to-album flow later 55 + - garbage-collecting empty albums created via the new endpoint that never receive tracks — worth tracking but out of scope 56 + - touching playlists — same list pattern, different UX, different PR 57 + - debouncing per-track `sync_album_list` on the legacy path — still racy there, accepted as a pre-existing issue documented as such in the PR 58 + 59 + ## phases 60 + 61 + ### phase 1: backend — first-class album creation 62 + 63 + **changes**: 64 + - `backend/src/backend/api/albums.py` — new `POST /albums/` endpoint 65 + - JSON body `CreateAlbumRequest { title: str, description: str | None = None }` 66 + - creates `Album` row (no tracks, no cover, no atproto list record) 67 + - emits `CollectionEvent(event_type="album_release", actor_did=..., album_id=...)` on creation (matches the existing post-track-upload flow at `uploads.py:794`) 68 + - returns `AlbumMetadata` (same response model as `PATCH /albums/{id}`) 69 + - uses `slugify(title)` + the existing `uq_albums_artist_slug` unique constraint; on `IntegrityError`, returns the existing album row (same pattern as `get_or_create_album`) 70 + - `backend/src/backend/api/albums.py` — `CreateAlbumRequest` Pydantic model at module top 71 + 72 + **success criteria**: 73 + - [ ] `just backend test` passes 74 + - [ ] new test `test_create_album_endpoint` covers: success, duplicate title returns existing, auth required, artist-profile required 75 + - [ ] creating an album via `POST /albums/` then `GET /albums/me` returns it 76 + 77 + ### phase 2: backend — `album_id` on track upload 78 + 79 + **changes**: 80 + - `backend/src/backend/api/tracks/uploads.py`: 81 + - add `album_id: Annotated[str | None, Form()] = None` to `upload_track` parameters 82 + - add `album_id: str | None = None` to `UploadContext` dataclass 83 + - in the background pipeline, when `ctx.album_id` is set: 84 + - skip `get_or_create_album` entirely 85 + - look up the album row directly by id; raise `UploadPhaseError` if missing or not owned by the artist 86 + - skip image processing in `_store_image` (`ctx.image_path` will be None from the frontend anyway, but defense-in-depth: if both are somehow set, ignore the track image) 87 + - derive `ctx.album` (the display name string, used by `build_track_record` for the ATProto track record) from the fetched album's title 88 + - still run the full PDS write pipeline 89 + - set `Track.album_id` directly in the atomic publish update 90 + - **skip** the `schedule_album_list_sync` call in `_schedule_post_upload` (the frontend will call finalize explicitly) 91 + - request validation: if both `album` (legacy string) and `album_id` are provided, return 400 92 + - `backend/src/backend/api/tracks/uploads.py:905` — extend the result dict in `_process_upload_background`: 93 + ```python 94 + result: dict[str, Any] = { 95 + "track_id": track.id, 96 + "atproto_uri": track.atproto_record_uri, 97 + "atproto_cid": track.atproto_record_cid, 98 + } 99 + ``` 100 + 101 + **success criteria**: 102 + - [ ] existing tests (`just backend test`) still pass 103 + - [ ] new test: `POST /tracks/` with `album_id` for an album not owned by the caller → 403/404 surfaced via SSE failure 104 + - [ ] new test: `POST /tracks/` with both `album` and `album_id` → 400 105 + - [ ] new test: `POST /tracks/` with valid `album_id` succeeds, track row has `album_id` set, no per-track sync task was scheduled (verify via mock) 106 + - [ ] SSE completion event includes `atproto_uri` and `atproto_cid` 107 + 108 + ### phase 3: backend — album finalize endpoint 109 + 110 + **changes**: 111 + - `backend/src/backend/api/albums.py` — new `POST /albums/{album_id}/finalize` endpoint 112 + - JSON body `FinalizeAlbumRequest { track_ids: list[int] }` 113 + - verify album exists and belongs to authenticated artist 114 + - fetch all referenced tracks in a single query; verify: 115 + - every requested id exists 116 + - every track has `album_id == album_id` 117 + - every track has `atproto_record_uri` and `atproto_record_cid` 118 + - any mismatch → 400 with a specific error naming the offending track ids 119 + - build `track_refs = [{"uri": t.atproto_record_uri, "cid": t.atproto_record_cid} for t in tracks_in_requested_order]` 120 + - call `upsert_album_list_record(auth_session, album_id, album.title, track_refs, existing_uri=album.atproto_record_uri, existing_created_at=album.created_at)` 121 + - persist the returned uri/cid onto the album row 122 + - invalidate album cache (`invalidate_album_cache`) 123 + - return `AlbumMetadata` 124 + - idempotent — safe to call multiple times 125 + 126 + **success criteria**: 127 + - [ ] `just backend test` passes 128 + - [ ] new test: finalize with valid ordered track_ids → list record written in that order, album has `atproto_record_uri` set 129 + - [ ] new test: finalize with track_id not belonging to album → 400 130 + - [ ] new test: finalize with track missing `atproto_record_uri` → 400 (race where upload didn't complete PDS write) 131 + - [ ] new test: finalize twice with different orders → second call updates the list record to the new order 132 + - [ ] new test: auth required, artist-profile required, not-owner → 403 133 + 134 + ### phase 4: frontend — uploader result plumbing 135 + 136 + **changes**: 137 + - `frontend/src/lib/uploader.svelte.ts`: 138 + - extend `UploadProgressCallback.onSuccess` signature to accept an optional result object: `onSuccess?: (result: { trackId: number; atprotoUri: string | null; atprotoCid: string | null }) => void` — keep backward compat by making it optional and passing undefined-safe values 139 + - actually — the current `onSuccess` callback is the XHR-upload-completed callback (line 148), fires as soon as the POST responds with `upload_id`. we need a **separate** signal for the SSE `completed` event (line 184), which is where the full result arrives. rename the SSE-completion hook from the current `onSuccess` function-arg (line 75) to something clearer, or add a new `onComplete` callback. the current `onSuccess` function-arg at line 75 is what the album form uses to get notified of completion. 140 + - **concrete change**: extend the `onSuccess?: () => void` function-arg at line 75 to `onSuccess?: (result?: { trackId: number; atprotoUri: string | null; atprotoCid: string | null }) => void` and pass the result object at line 203 when invoking it. the XHR-load `callbacks.onSuccess` at line 149 is unchanged (it only wants `upload_id`). existing callsites that ignore the arg keep working. 141 + - read `update.atproto_uri` and `update.atproto_cid` from the SSE completion payload; pass them to `onSuccess` 142 + - `frontend/src/routes/upload/+page.svelte` — no change needed (doesn't use the result) 143 + - `frontend/src/routes/record/+page.svelte` — no change needed 144 + 145 + **success criteria**: 146 + - [ ] `just frontend check` — 0 errors, 0 warnings 147 + - [ ] record page, single-track upload, album upload: all still complete without regressions 148 + 149 + ### phase 5: frontend — album form uses the new flow 150 + 151 + **changes**: 152 + - `frontend/src/lib/components/AlbumUploadForm.svelte`: 153 + - replace `handleUploadAlbum` (lines 232-295): 154 + 1. `POST /albums/` with `{ title, description }` → capture `{ id, slug }`; if this fails, bail early with a single error toast ("failed to create album") 155 + 2. if `coverArtFile`, `POST /albums/{id}/cover` with the image; failure is a warning toast but does not block (same forgiveness as the current flow, where cover art is applied on first-track-commits) 156 + 3. build a map `track index → upload result` to preserve the user-intended order across `Promise.allSettled` 157 + 4. for each track at index `i`, call `uploader.upload(...)` but pass: 158 + - `album: ''` (empty — new flow uses album_id) 159 + - `coverArtFile: null` (no per-track cover) 160 + - a new form field `album_id` — requires a small extension to `uploader.upload` to accept `albumId?: string` and append `album_id` to the FormData when set 161 + - `onSuccess: (result) => { indexedResults[i] = result; tracks[i] = { ...tracks[i], status: 'completed' } }` 162 + 5. `Promise.allSettled` on the per-track promise wrappers (existing timeout/retry machinery kept) 163 + 6. after all settle, collect `orderedTrackIds = indexedResults.filter(r => r?.trackId).map(r => r!.trackId)` — preserves form-index order 164 + 7. if `orderedTrackIds.length > 0`, `POST /albums/{id}/finalize` with `{ track_ids: orderedTrackIds }`; on failure surface a warning toast ("tracks uploaded but failed to save order — reorder from the album page") 165 + 8. final summary toast (same phrasing as today), redirect to `/u/{handle}/album/{slug}` if any track succeeded 166 + - `frontend/src/lib/uploader.svelte.ts` — extend `upload` signature to accept `albumId?: string` as a new named parameter; append to FormData as `album_id` when present. existing callsites unchanged. 167 + 168 + **success criteria**: 169 + - [ ] `just frontend check` — 0 errors, 0 warnings 170 + - [ ] manual test: upload a 5-track album via the form, verify tracks appear in chosen order on the album page 171 + - [ ] manual test: upload, drag one track to a new position in the form before submitting, verify the displayed order matches 172 + - [ ] manual test: partial failure (force-fail one track via oversized file or invalid format) — remaining tracks land in correct relative order, failed track shows error toast 173 + - [ ] manual test: album upload with no cover art still succeeds 174 + - [ ] per-track toasts still fire with track titles and succeed/fail messages (no regression from #1238) 175 + - [ ] single-track `/upload` page still works (untouched code path) 176 + - [ ] `/record` page still works (untouched code path) 177 + 178 + ### phase 6: regression tests 179 + 180 + **changes**: 181 + - `backend/tests/api/test_albums.py` (or wherever album tests live) — add integration test that exercises the full new flow: create album → upload N tracks with `album_id` in scrambled relative `created_at` order → finalize with explicit order → fetch album → assert the returned track order matches the finalize order, not the `created_at` order 182 + - `frontend` — if there are existing album-upload integration tests, update them for the new flow; otherwise leave manual test plan above 183 + 184 + **success criteria**: 185 + - [ ] backend integration test asserts finalize-order-beats-created_at-order 186 + - [ ] `just backend test` all green 187 + - [ ] `just frontend check` clean 188 + 189 + ## testing 190 + 191 + ### core scenarios 192 + - **happy path**: 5-track album in form order A B C D E → uploaded album displays A B C D E 193 + - **scrambled creation order**: intentionally make track C commit first (e.g. smallest file) → displayed order still A B C D E 194 + - **partial failure**: track C fails during upload → displayed order is A B D E 195 + - **cover art**: uploaded once via `/cover` endpoint; appears on the album row 196 + - **idempotent finalize**: call finalize twice with different orders; the second call wins 197 + - **reorder-after-upload**: upload album → go to album edit → drag tracks → save; existing reorder flow still works unchanged (sanity regression) 198 + 199 + ### edge cases 200 + - all tracks fail → no finalize call, album row still exists (empty); user sees errors; garbage collection of empty albums is out of scope 201 + - album creation succeeds but cover upload fails → warning toast, upload proceeds 202 + - finalize is called but one of the tracks hasn't yet finished its PDS write (race) → 400, frontend retries finalize once on 400-missing-uri, then surfaces a warning toast if still failing 203 + - user navigates away mid-upload → existing upload singleton behavior unchanged; toasts persist, SSE keeps firing, finalize won't be called because the form component unmounts — acceptable edge, the existing resync-from-album-edit button is future work but the user can just reorder manually 204 + - legacy `album: str` upload from single-track `/upload` page — unchanged behavior 205 + 206 + ## open risks / followups 207 + 208 + - **finalize retry on race**: if a track's SSE `completed` event arrives but the track row's `atproto_record_uri` hasn't been committed yet (unlikely — `_create_records` commits before the completion signal), finalize would 400. the spec says SSE completed is fired after the atomic publish update, so this shouldn't happen in practice, but worth a single-retry with 500ms backoff in the frontend finalize call. 209 + - **empty album GC**: creating an album then closing the tab leaves an empty album row. not a regression (today's flow also leaves the album if the first track fails after `get_or_create_album` runs), but worth tracking as a followup issue. 210 + - **legacy path still racy**: the single-track `/upload` form with an `album: str` value that collides with an existing album still hits `schedule_album_list_sync`. not regressed by this PR but worth documenting. 211 + 212 + ## rollout 213 + 214 + - single PR, single merge, no feature flag 215 + - the change is a pure fix: the old album upload path is broken (tracks unordered), the new path works. no user is relying on the broken ordering. 216 + - backend is backward compatible: legacy `album: str` path is untouched. old clients (none in the wild — this is a web app) would continue to work.
+12 -6
docs/public/developers/api-reference/activity.md
··· 10 10 11 11 ## Functions 12 12 13 - ### `get_activity_feed` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/activity.py#L190) 13 + ### `get_activity_feed` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/activity.py#L256) 14 14 15 15 ```python 16 16 get_activity_feed(db: Annotated[AsyncSession, Depends(get_db)], cursor: str | None = Query(None), limit: int = Query(20)) -> ActivityFeedResponse ··· 20 20 get the platform-wide activity feed. 21 21 22 22 23 - ### `get_activity_histogram` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/activity.py#L271) 23 + ### `get_activity_histogram` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/activity.py#L352) 24 24 25 25 ```python 26 26 get_activity_histogram(db: Annotated[AsyncSession, Depends(get_db)], days: int = Query(7, ge=1, le=30)) -> ActivityHistogramResponse ··· 60 60 normalize_avatar(cls, v: str | None) -> str | None 61 61 ``` 62 62 63 - ### `ActivityEvent` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/activity.py#L50) 63 + ### `ActivityCollection` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/activity.py#L50) 64 + 65 + 66 + collection referenced in an activity event. 67 + 68 + 69 + ### `ActivityEvent` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/activity.py#L61) 64 70 65 71 66 72 single activity event. 67 73 68 74 69 - ### `ActivityFeedResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/activity.py#L60) 75 + ### `ActivityFeedResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/activity.py#L80) 70 76 71 77 72 78 paginated activity feed. 73 79 74 80 75 - ### `ActivityHistogramBucket` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/activity.py#L68) 81 + ### `ActivityHistogramBucket` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/activity.py#L88) 76 82 77 83 78 84 single day in the activity histogram. 79 85 80 86 81 - ### `ActivityHistogramResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/activity.py#L75) 87 + ### `ActivityHistogramResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/activity.py#L95) 82 88 83 89 84 90 activity counts per day over a time window.
+78 -17
docs/public/developers/api-reference/albums.md
··· 10 10 11 11 ## Functions 12 12 13 - ### `invalidate_album_cache` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L47) 13 + ### `invalidate_album_cache` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L48) 14 14 15 15 ```python 16 16 invalidate_album_cache(handle: str, slug: str) -> None ··· 20 20 delete cached album response. fails silently. 21 21 22 22 23 - ### `invalidate_album_cache_by_id` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L56) 23 + ### `invalidate_album_cache_by_id` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L57) 24 24 25 25 ```python 26 26 invalidate_album_cache_by_id(db: AsyncSession, album_id: str) -> None ··· 30 30 look up album handle+slug and invalidate cache. fails silently. 31 31 32 32 33 - ### `list_albums` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L224) 33 + ### `list_albums` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L236) 34 34 35 35 ```python 36 36 list_albums(db: Annotated[AsyncSession, Depends(get_db)]) -> dict[str, list[AlbumListItem]] ··· 39 39 40 40 list all albums with basic metadata. 41 41 42 + albums with zero tracks are hidden — they're either unfinalized drafts 43 + from the multi-track upload flow or legacy albums awaiting sync. only 44 + albums that have at least one track appear in public listings. 42 45 43 - ### `list_artist_albums` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L257) 46 + 47 + ### `list_artist_albums` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L275) 44 48 45 49 ```python 46 50 list_artist_albums(handle: str, db: Annotated[AsyncSession, Depends(get_db)]) -> dict[str, list[ArtistAlbumListItem]] ··· 50 54 list albums for a specific artist. 51 55 52 56 53 - ### `get_album` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L294) 57 + ### `get_album` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L313) 54 58 55 59 ```python 56 60 get_album(handle: str, slug: str, db: Annotated[AsyncSession, Depends(get_db)], session: AuthSession | None = Depends(get_optional_session)) -> AlbumResponse ··· 60 64 get album details with tracks (ordered by ATProto list record or created_at). 61 65 62 66 63 - ### `upload_album_cover` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L440) 67 + ### `create_album` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L459) 68 + 69 + ```python 70 + create_album(body: AlbumCreatePayload, db: Annotated[AsyncSession, Depends(get_db)], auth_session: Annotated[AuthSession, Depends(require_artist_profile)]) -> AlbumMetadata 71 + ``` 72 + 73 + 74 + create an empty album shell for the multi-track upload flow. 75 + 76 + the ATProto list record is NOT written here — it is deferred to 77 + `POST /albums/{id}/finalize`, which runs after tracks have actually 78 + been published so a total upload failure doesn't leave a fake release 79 + behind. for the same reason, the `album_release` CollectionEvent is 80 + also deferred to finalize (first successful call only, deduped). 81 + 82 + idempotent on (artist_did, slug): if an album with the same slug 83 + already exists, the existing row is returned instead of failing. 84 + this preserves the "type an existing album name to add tracks to it" 85 + UX — see finalize_album for the append semantics. 86 + 87 + 88 + ### `upload_album_cover` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L529) 64 89 65 90 ```python 66 91 upload_album_cover(album_id: str, db: Annotated[AsyncSession, Depends(get_db)], auth_session: Annotated[AuthSession, Depends(require_artist_profile)], image: UploadFile = File(...)) -> dict[str, str | None] ··· 70 95 upload cover art for an album (requires authentication). 71 96 72 97 73 - ### `update_album` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L490) 98 + ### `finalize_album` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L579) 99 + 100 + ```python 101 + finalize_album(album_id: str, body: AlbumFinalizePayload, db: Annotated[AsyncSession, Depends(get_db)], auth_session: Annotated[AuthSession, Depends(require_artist_profile)]) -> AlbumMetadata 102 + ``` 103 + 104 + 105 + write the album's ATProto list record using an explicit track order. 106 + 107 + called by the frontend after per-track uploads have settled. this is 108 + the single place the list record is created/updated for albums built 109 + via `POST /albums/` + `POST /tracks/?album_id=...`. 110 + 111 + append semantics: `track_ids` carries only the tracks from the current 112 + upload session. any tracks already on the album that are NOT in 113 + `track_ids` are preserved in the list record at their current positions 114 + (fetched from the existing list record if present, falling back to 115 + created_at order). new tracks are appended at the end in the order 116 + requested. this matches the "type an existing album name to add tracks 117 + to it" UX without truncating prior track history. 118 + 119 + also emits an `album_release` CollectionEvent on the first successful 120 + finalize for the album — so total upload failures don't leave a fake 121 + release event in the activity feed. 122 + 123 + 124 + ### `update_album` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L778) 74 125 75 126 ```python 76 127 update_album(album_id: str, db: Annotated[AsyncSession, Depends(get_db)], auth_session: Annotated[AuthSession, Depends(require_artist_profile)], title: Annotated[str | None, Query(description='new album title')] = None, description: Annotated[str | None, Query(description='new album description')] = None) -> AlbumMetadata ··· 80 131 update album metadata (title, description). syncs ATProto records on title change. 81 132 82 133 83 - ### `remove_track_from_album` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L596) 134 + ### `remove_track_from_album` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L884) 84 135 85 136 ```python 86 137 remove_track_from_album(album_id: str, track_id: int, db: Annotated[AsyncSession, Depends(get_db)], auth_session: Annotated[AuthSession, Depends(require_artist_profile)]) -> RemoveTrackFromAlbumResponse ··· 92 143 the track remains available as a standalone track. 93 144 94 145 95 - ### `delete_album` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L634) 146 + ### `delete_album` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L922) 96 147 97 148 ```python 98 149 delete_album(album_id: str, db: Annotated[AsyncSession, Depends(get_db)], auth_session: Annotated[AuthSession, Depends(require_artist_profile)], cascade: Annotated[bool, Query(description='if true, also delete all tracks in the album')] = False) -> DeleteAlbumResponse ··· 104 155 105 156 ## Classes 106 157 107 - ### `AlbumMetadata` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L72) 158 + ### `AlbumMetadata` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L73) 108 159 109 160 110 161 album metadata response. 111 162 112 163 113 - ### `AlbumResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L88) 164 + ### `AlbumResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L89) 114 165 115 166 116 167 album detail response with tracks. 117 168 118 169 119 - ### `AlbumListItem` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L95) 170 + ### `AlbumListItem` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L96) 120 171 121 172 122 173 minimal album info for listing. 123 174 124 175 125 - ### `RemoveTrackFromAlbumResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L106) 176 + ### `RemoveTrackFromAlbumResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L107) 126 177 127 178 128 179 response for removing a track from an album. 129 180 130 181 131 - ### `DeleteAlbumResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L113) 182 + ### `DeleteAlbumResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L114) 132 183 133 184 134 185 response for deleting an album. 135 186 136 187 137 - ### `ArtistAlbumListItem` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L120) 188 + ### `ArtistAlbumListItem` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L121) 138 189 139 190 140 191 album info for a specific artist (used on artist pages). 141 192 142 193 143 - ### `AlbumCreatePayload` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L131) 194 + ### `AlbumCreatePayload` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L132) 195 + 196 + ### `AlbumUpdatePayload` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L138) 197 + 198 + ### `AlbumFinalizePayload` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L144) 199 + 200 + 201 + request body for POST /albums/{id}/finalize. 202 + 203 + track_ids is the authoritative user-intended order for the album's 204 + ATProto list record. every id must belong to this album and have a 205 + completed PDS write (atproto_record_uri + cid set). 144 206 145 - ### `AlbumUpdatePayload` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/albums.py#L137)
+16 -16
docs/public/developers/api-reference/artists.md
··· 10 10 11 11 ## Functions 12 12 13 - ### `create_artist` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L84) 13 + ### `create_artist` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L89) 14 14 15 15 ```python 16 16 create_artist(request: CreateArtistRequest, db: Annotated[AsyncSession, Depends(get_db)], auth_session: Session = Depends(require_auth)) -> ArtistResponse ··· 23 23 with the user's profile setup choices. otherwise creates a new record. 24 24 25 25 26 - ### `get_my_artist_profile` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L161) 26 + ### `get_my_artist_profile` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L166) 27 27 28 28 ```python 29 29 get_my_artist_profile(db: Annotated[AsyncSession, Depends(get_db)], auth_session: Session = Depends(require_auth)) -> ArtistResponse ··· 33 33 get authenticated user's artist profile. 34 34 35 35 36 - ### `update_my_artist_profile` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L178) 36 + ### `update_my_artist_profile` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L183) 37 37 38 38 ```python 39 39 update_my_artist_profile(request: UpdateArtistRequest, db: Annotated[AsyncSession, Depends(get_db)], auth_session: Session = Depends(require_auth)) -> ArtistResponse ··· 43 43 update authenticated user's artist profile. 44 44 45 45 46 - ### `get_artists_batch` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L221) 46 + ### `get_artists_batch` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L226) 47 47 48 48 ```python 49 49 get_artists_batch(dids: list[str], db: Annotated[AsyncSession, Depends(get_db)]) -> dict[str, ArtistResponse] ··· 56 56 DIDs not found are simply omitted from the response. 57 57 58 58 59 - ### `get_artist_profile_by_handle` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L240) 59 + ### `get_artist_profile_by_handle` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L245) 60 60 61 61 ```python 62 62 get_artist_profile_by_handle(handle: str, db: Annotated[AsyncSession, Depends(get_db)]) -> ArtistResponse ··· 66 66 get artist profile by handle (public endpoint). 67 67 68 68 69 - ### `get_artist_profile_by_did` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L262) 69 + ### `get_artist_profile_by_did` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L267) 70 70 71 71 ```python 72 72 get_artist_profile_by_did(did: str, db: Annotated[AsyncSession, Depends(get_db)]) -> ArtistResponse ··· 76 76 get artist profile by DID (public endpoint). 77 77 78 78 79 - ### `get_artist_analytics` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L284) 79 + ### `get_artist_analytics` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L326) 80 80 81 81 ```python 82 82 get_artist_analytics(artist_did: str, db: Annotated[AsyncSession, Depends(get_db)]) -> AnalyticsResponse ··· 88 88 returns zeros if artist has no tracks. 89 89 90 90 91 - ### `get_my_analytics` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L351) 91 + ### `get_my_analytics` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L397) 92 92 93 93 ```python 94 94 get_my_analytics(db: Annotated[AsyncSession, Depends(get_db)], auth_session: Session = Depends(require_auth)) -> AnalyticsResponse ··· 100 100 returns zeros if artist has no tracks - no need to verify artist exists. 101 101 102 102 103 - ### `refresh_artist_avatar` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L369) 103 + ### `refresh_artist_avatar` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L415) 104 104 105 105 ```python 106 106 refresh_artist_avatar(did: str, db: Annotated[AsyncSession, Depends(get_db)]) -> RefreshAvatarResponse ··· 115 115 116 116 ## Classes 117 117 118 - ### `CreateArtistRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L26) 118 + ### `CreateArtistRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L30) 119 119 120 120 121 121 request to create artist profile. 122 122 123 123 124 - ### `UpdateArtistRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L34) 124 + ### `UpdateArtistRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L38) 125 125 126 126 127 127 request to update artist profile. 128 128 129 129 130 - ### `ArtistResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L42) 130 + ### `ArtistResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L46) 131 131 132 132 133 133 artist profile response. ··· 135 135 136 136 **Methods:** 137 137 138 - #### `normalize_avatar` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L59) 138 + #### `normalize_avatar` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L63) 139 139 140 140 ```python 141 141 normalize_avatar(cls, v: str | None) -> str | None ··· 144 144 normalize avatar URL to use Bluesky CDN. 145 145 146 146 147 - ### `TopItemResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L64) 147 + ### `TopItemResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L68) 148 148 149 149 150 150 top item in analytics. 151 151 152 152 153 - ### `AnalyticsResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L72) 153 + ### `AnalyticsResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L76) 154 154 155 155 156 156 analytics data for artist. 157 157 158 158 159 - ### `RefreshAvatarResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L362) 159 + ### `RefreshAvatarResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/artists.py#L408) 160 160 161 161 162 162 response from avatar refresh.
+20 -20
docs/public/developers/api-reference/auth.md
··· 58 58 4. regular login flow - creates session, redirects to portal or profile setup 59 59 60 60 61 - ### `exchange_token` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L319) 61 + ### `exchange_token` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L327) 62 62 63 63 ```python 64 64 exchange_token(request: Request, exchange_request: ExchangeTokenRequest, response: Response) -> ExchangeTokenResponse ··· 75 75 for dev token exchanges: returns session_id but does NOT set cookie 76 76 77 77 78 - ### `logout` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L370) 78 + ### `logout` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L381) 79 79 80 80 ```python 81 81 logout(session: Session = Depends(require_auth), switch_to: Annotated[str | None, Query(description='DID to switch to after logout')] = None, db = Depends(get_db)) -> JSONResponse ··· 88 88 to the specified account. otherwise, fully logs out. 89 89 90 90 91 - ### `get_current_user` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L436) 91 + ### `get_current_user` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L447) 92 92 93 93 ```python 94 94 get_current_user(session: Session = Depends(require_auth), db = Depends(get_db)) -> CurrentUserResponse ··· 98 98 get current authenticated user with linked accounts. 99 99 100 100 101 - ### `get_developer_tokens` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L471) 101 + ### `get_developer_tokens` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L482) 102 102 103 103 ```python 104 104 get_developer_tokens(session: Session = Depends(require_auth)) -> DeveloperTokenListResponse ··· 108 108 list all developer tokens for the current user. 109 109 110 110 111 - ### `delete_developer_token` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L491) 111 + ### `delete_developer_token` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L502) 112 112 113 113 ```python 114 114 delete_developer_token(token_prefix: str, session: Session = Depends(require_auth)) -> JSONResponse ··· 118 118 revoke a developer token by its prefix (first 8 chars of session_id). 119 119 120 120 121 - ### `start_developer_token_flow` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L531) 121 + ### `start_developer_token_flow` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L542) 122 122 123 123 ```python 124 124 start_developer_token_flow(request: Request, body: DevTokenStartRequest, session: Session = Depends(require_auth)) -> DevTokenStartResponse ··· 135 135 returns the authorization URL that the frontend should redirect to. 136 136 137 137 138 - ### `start_scope_upgrade_flow` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L592) 138 + ### `start_scope_upgrade_flow` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L603) 139 139 140 140 ```python 141 141 start_scope_upgrade_flow(request: Request, body: ScopeUpgradeStartRequest, session: Session = Depends(require_auth)) -> ScopeUpgradeStartResponse ··· 154 154 returns the authorization URL that the frontend should redirect to. 155 155 156 156 157 - ### `start_add_account_flow` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L644) 157 + ### `start_add_account_flow` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L655) 158 158 159 159 ```python 160 160 start_add_account_flow(request: Request, body: AddAccountStartRequest, session: Session = Depends(require_auth)) -> AddAccountStartResponse ··· 171 171 returns the authorization URL that the frontend should redirect to. 172 172 173 173 174 - ### `switch_account` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L694) 174 + ### `switch_account` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L705) 175 175 176 176 ```python 177 177 switch_account(body: SwitchAccountRequest, response: Response, session: Session = Depends(require_auth), db = Depends(get_db)) -> SwitchAccountResponse ··· 186 186 returns the new active account's info. 187 187 188 188 189 - ### `logout_all` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L756) 189 + ### `logout_all` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L767) 190 190 191 191 ```python 192 192 logout_all(session: Session = Depends(require_auth), db = Depends(get_db)) -> JSONResponse ··· 247 247 response model for PDS options endpoint. 248 248 249 249 250 - ### `ExchangeTokenRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L305) 250 + ### `ExchangeTokenRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L313) 251 251 252 252 253 253 request model for exchanging token for session_id. 254 254 255 255 256 - ### `ExchangeTokenResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L311) 256 + ### `ExchangeTokenResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L319) 257 257 258 258 259 259 response model for exchange token endpoint. 260 260 261 261 262 - ### `DevTokenStartRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L516) 262 + ### `DevTokenStartRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L527) 263 263 264 264 265 265 request model for starting developer token OAuth flow. 266 266 267 267 268 - ### `DevTokenStartResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L523) 268 + ### `DevTokenStartResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L534) 269 269 270 270 271 271 response model with OAuth authorization URL. 272 272 273 273 274 - ### `ScopeUpgradeStartRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L577) 274 + ### `ScopeUpgradeStartRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L588) 275 275 276 276 277 277 request model for starting scope upgrade OAuth flow. 278 278 279 279 280 - ### `ScopeUpgradeStartResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L584) 280 + ### `ScopeUpgradeStartResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L595) 281 281 282 282 283 283 response model with OAuth authorization URL. 284 284 285 285 286 - ### `AddAccountStartRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L630) 286 + ### `AddAccountStartRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L641) 287 287 288 288 289 289 request model for starting add-account flow. 290 290 291 291 292 - ### `AddAccountStartResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L636) 292 + ### `AddAccountStartResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L647) 293 293 294 294 295 295 response model with OAuth authorization URL for adding account. 296 296 297 297 298 - ### `SwitchAccountRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L679) 298 + ### `SwitchAccountRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L690) 299 299 300 300 301 301 request model for switching to a different account. 302 302 303 303 304 - ### `SwitchAccountResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L685) 304 + ### `SwitchAccountResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/auth.py#L696) 305 305 306 306 307 307 response model after switching accounts.
-41
docs/public/developers/api-reference/feeds.md
··· 1 - --- 2 - title: feeds 3 - sidebarTitle: feeds 4 - --- 5 - 6 - # `backend.api.feeds` 7 - 8 - 9 - RSS feed generation for artist, album, and playlist collections. 10 - 11 - ## Functions 12 - 13 - ### `artist_feed` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/feeds.py#L83) 14 - 15 - ```python 16 - artist_feed(handle: str, db: Annotated[AsyncSession, Depends(get_db)]) -> Response 17 - ``` 18 - 19 - 20 - RSS feed of all public tracks by an artist, newest first. 21 - 22 - 23 - ### `album_feed` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/feeds.py#L133) 24 - 25 - ```python 26 - album_feed(handle: str, slug: str, db: Annotated[AsyncSession, Depends(get_db)]) -> Response 27 - ``` 28 - 29 - 30 - RSS feed of tracks in an album. 31 - 32 - 33 - ### `playlist_feed` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/feeds.py#L188) 34 - 35 - ```python 36 - playlist_feed(playlist_id: str, db: Annotated[AsyncSession, Depends(get_db)]) -> Response 37 - ``` 38 - 39 - 40 - RSS feed of tracks in a playlist. 41 -
+54
docs/public/developers/api-reference/for_you.md
··· 1 + --- 2 + title: for_you 3 + sidebarTitle: for_you 4 + --- 5 + 6 + # `backend.api.for_you` 7 + 8 + 9 + For You feed — collaborative filtering over user engagement edges. 10 + 11 + Ports the scoring algorithm from grain.social's foryou.ts (originally tuned 12 + by @spacecowboy17.bsky.social), adapted for plyr.fm in two ways: 13 + 14 + 1. **edges are not just likes**: a user "engages" with a track via a like OR 15 + a track_added_to_playlist event. both edges get weight 1.0 for v1. 16 + 2. **slower time decay**: audio ages slower than photo galleries, so we use 17 + a 48h half-life (grain uses 6h). 18 + 19 + Scoring recipe per candidate track: 20 + score = (sum over paths of 1 / total_edges(coengager) ** DIVISOR_POWER) 21 + * paths ** SMOOTHING_FACTOR 22 + * 0.5 ** (age_hours / HALF_LIFE_HOURS) 23 + / popularity ** POPULARITY_PENALTY 24 + 25 + Final output diversifies by artist (MAX_PER_ARTIST hard cap). 26 + 27 + 28 + ## Functions 29 + 30 + ### `get_for_you_feed` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/for_you.py#L272) 31 + 32 + ```python 33 + get_for_you_feed(db: Annotated[AsyncSession, Depends(get_db)], auth_session: AuthSession = Depends(require_auth), cursor: str | None = Query(None), limit: int = Query(30, ge=1, le=50)) -> ForYouResponse 34 + ``` 35 + 36 + 37 + Personalized feed for the authenticated user. 38 + 39 + Uses collaborative filtering over engagement edges (likes + playlist adds). 40 + Falls back to cold-start (most-engaged last 30 days) if the user has no 41 + taste signal yet. 42 + 43 + Cursor is an integer offset into the materialized ranked list. Scores may 44 + drift between pages — this is fine for v1; if it becomes annoying we can 45 + cache the ranked list per-user in Redis. 46 + 47 + 48 + ## Classes 49 + 50 + ### `ForYouResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/for_you.py#L131) 51 + 52 + 53 + Paginated For You feed. 54 +
+13 -13
docs/public/developers/api-reference/jams.md
··· 10 10 11 11 ## Functions 12 12 13 - ### `create_jam` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L77) 13 + ### `create_jam` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L84) 14 14 15 15 ```python 16 16 create_jam(body: CreateJamRequest, session: Session = Depends(require_auth)) -> JamResponse ··· 20 20 create a new jam. 21 21 22 22 23 - ### `get_active_jam` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L94) 23 + ### `get_active_jam` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L101) 24 24 25 25 ```python 26 26 get_active_jam(session: Session = Depends(require_auth)) -> JamResponse | None ··· 30 30 get the user's current active jam. 31 31 32 32 33 - ### `get_jam_preview` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L105) 33 + ### `get_jam_preview` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L112) 34 34 35 35 ```python 36 36 get_jam_preview(code: str) -> JamPreviewResponse ··· 40 40 public preview info for a jam (no auth required). 41 41 42 42 43 - ### `get_jam` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L140) 43 + ### `get_jam` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L147) 44 44 45 45 ```python 46 46 get_jam(code: str, session: Session = Depends(require_auth)) -> JamResponse ··· 50 50 get jam details by code. 51 51 52 52 53 - ### `join_jam` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L152) 53 + ### `join_jam` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L159) 54 54 55 55 ```python 56 56 join_jam(code: str, session: Session = Depends(require_auth)) -> JamResponse ··· 60 60 join a jam. 61 61 62 62 63 - ### `leave_jam` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L164) 63 + ### `leave_jam` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L171) 64 64 65 65 ```python 66 66 leave_jam(code: str, session: Session = Depends(require_auth)) -> dict[str, bool] ··· 70 70 leave a jam. 71 71 72 72 73 - ### `end_jam` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L179) 73 + ### `end_jam` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L186) 74 74 75 75 ```python 76 76 end_jam(code: str, session: Session = Depends(require_auth)) -> dict[str, bool] ··· 80 80 end a jam (host only). 81 81 82 82 83 - ### `jam_command` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L194) 83 + ### `jam_command` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L201) 84 84 85 85 ```python 86 86 jam_command(code: str, body: CommandRequest, session: Session = Depends(require_auth)) -> dict[str, Any] ··· 90 90 send a playback command to the jam. 91 91 92 92 93 - ### `jam_websocket` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L234) 93 + ### `jam_websocket` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L264) 94 94 95 95 ```python 96 96 jam_websocket(ws: WebSocket, code: str, session_id: Annotated[str | None, Cookie()] = None) -> None ··· 102 102 103 103 ## Classes 104 104 105 - ### `CreateJamRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L31) 105 + ### `CreateJamRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L38) 106 106 107 - ### `CommandRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L39) 107 + ### `CommandRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L46) 108 108 109 - ### `JamResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L48) 109 + ### `JamResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L55) 110 110 111 - ### `JamPreviewResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L63) 111 + ### `JamPreviewResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/jams.py#L70)
+47 -21
docs/public/developers/api-reference/lists.md
··· 10 10 11 11 ## Functions 12 12 13 - ### `reorder_liked_list` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L125) 13 + ### `reorder_liked_list` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L127) 14 14 15 15 ```python 16 16 reorder_liked_list(body: ReorderRequest, session: AuthSession = Depends(require_auth), db: AsyncSession = Depends(get_db)) -> ReorderResponse ··· 23 23 only the list owner can reorder their own list. 24 24 25 25 26 - ### `reorder_list` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L164) 26 + ### `reorder_list` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L166) 27 27 28 28 ```python 29 29 reorder_list(rkey: str, body: ReorderRequest, session: AuthSession = Depends(require_auth), db: AsyncSession = Depends(get_db)) -> ReorderResponse ··· 33 33 reorder items in a list by rkey. items array order = new display order. 34 34 35 35 36 - ### `create_playlist` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L203) 36 + ### `resolve_list_by_uri` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L214) 37 + 38 + ```python 39 + resolve_list_by_uri(uri: Annotated[str, Query(description='AT-URI of a list record')], db: AsyncSession = Depends(get_db)) -> ListByUriResponse 40 + ``` 41 + 42 + 43 + resolve a list AT-URI to its type (album or playlist) with routing info. 44 + 45 + 46 + ### `create_playlist` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L245) 37 47 38 48 ```python 39 49 create_playlist(body: CreatePlaylistRequest, session: AuthSession = Depends(require_auth), db: AsyncSession = Depends(get_db)) -> PlaylistResponse ··· 46 56 metadata in the database for fast indexing. 47 57 48 58 49 - ### `list_playlists` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L257) 59 + ### `list_playlists` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L312) 50 60 51 61 ```python 52 62 list_playlists(session: AuthSession = Depends(require_auth), db: AsyncSession = Depends(get_db)) -> list[PlaylistResponse] ··· 56 66 list all playlists owned by the current user. 57 67 58 68 59 - ### `list_artist_public_playlists` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L287) 69 + ### `list_artist_public_playlists` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L342) 60 70 61 71 ```python 62 72 list_artist_public_playlists(artist_did: str, db: AsyncSession = Depends(get_db)) -> list[PlaylistResponse] ··· 66 76 list public playlists for an artist (no auth required). 67 77 68 78 69 - ### `get_playlist_meta` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L318) 79 + ### `get_playlist_by_uri` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L373) 80 + 81 + ```python 82 + get_playlist_by_uri(uri: Annotated[str, Query(description='AT-URI of the playlist list record')], db: AsyncSession = Depends(get_db), session: AuthSession | None = Depends(get_optional_session)) -> PlaylistWithTracksResponse 83 + ``` 84 + 85 + 86 + get a playlist by its ATProto record URI (public, auth optional for liked state). 87 + 88 + 89 + ### `get_playlist_meta` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L469) 70 90 71 91 ```python 72 92 get_playlist_meta(playlist_id: str, db: AsyncSession = Depends(get_db)) -> PlaylistResponse ··· 76 96 get playlist metadata (public, no auth required). used for link previews. 77 97 78 98 79 - ### `get_playlist` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L349) 99 + ### `get_playlist` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L500) 80 100 81 101 ```python 82 102 get_playlist(playlist_id: str, db: AsyncSession = Depends(get_db), session: AuthSession | None = Depends(get_optional_session)) -> PlaylistWithTracksResponse ··· 89 109 track metadata from the database. if authenticated, includes liked state. 90 110 91 111 92 - ### `add_track_to_playlist` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L451) 112 + ### `add_track_to_playlist` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L606) 93 113 94 114 ```python 95 115 add_track_to_playlist(playlist_id: str, body: AddTrackRequest, session: AuthSession = Depends(require_auth), db: AsyncSession = Depends(get_db)) -> PlaylistResponse ··· 102 122 and updates the cached track count. 103 123 104 124 105 - ### `remove_track_from_playlist` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L551) 125 + ### `remove_track_from_playlist` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L722) 106 126 107 127 ```python 108 128 remove_track_from_playlist(playlist_id: str, track_uri: str, session: AuthSession = Depends(require_auth), db: AsyncSession = Depends(get_db)) -> PlaylistResponse ··· 112 132 remove a track from a playlist. 113 133 114 134 115 - ### `delete_playlist` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L645) 135 + ### `delete_playlist` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L815) 116 136 117 137 ```python 118 138 delete_playlist(playlist_id: str, session: AuthSession = Depends(require_auth), db: AsyncSession = Depends(get_db)) -> DeletedResponse ··· 124 144 deletes both the ATProto list record and the database cache. 125 145 126 146 127 - ### `upload_playlist_cover` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L681) 147 + ### `upload_playlist_cover` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L851) 128 148 129 149 ```python 130 150 upload_playlist_cover(playlist_id: str, session: AuthSession = Depends(require_auth), db: AsyncSession = Depends(get_db), image: UploadFile = File(...)) -> dict[str, str | None] ··· 136 156 accepts jpg, jpeg, png, webp images up to 20MB. 137 157 138 158 139 - ### `update_playlist` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L742) 159 + ### `update_playlist` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L912) 140 160 141 161 ```python 142 162 update_playlist(playlist_id: str, name: Annotated[str | None, Form()] = None, show_on_profile: Annotated[bool | None, Form()] = None, session: AuthSession = Depends(require_auth), db: AsyncSession = Depends(get_db)) -> PlaylistResponse ··· 148 168 use POST /playlists/{id}/cover to update cover art separately. 149 169 150 170 151 - ### `get_playlist_recommendations_endpoint` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L840) 171 + ### `get_playlist_recommendations_endpoint` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L1009) 152 172 153 173 ```python 154 174 get_playlist_recommendations_endpoint(playlist_id: str, session: AuthSession = Depends(require_auth), db: AsyncSession = Depends(get_db), limit: int = Query(3, ge=1, le=10, description='max recommendations')) -> PlaylistRecommendationsResponse ··· 164 184 165 185 ## Classes 166 186 167 - ### `CreatePlaylistRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L53) 187 + ### `CreatePlaylistRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L55) 168 188 169 189 170 190 request body for creating a playlist. 171 191 172 192 173 - ### `PlaylistResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L60) 193 + ### `PlaylistResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L62) 174 194 175 195 176 196 playlist metadata response. 177 197 178 198 179 - ### `PlaylistWithTracksResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L74) 199 + ### `PlaylistWithTracksResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L76) 180 200 181 201 182 202 playlist with full track details. 183 203 184 204 185 - ### `AddTrackRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L81) 205 + ### `AddTrackRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L83) 186 206 187 207 188 208 request body for adding a track to a playlist. 189 209 190 210 191 - ### `ReorderRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L93) 211 + ### `ReorderRequest` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L95) 192 212 193 213 194 214 request body for reordering list items. 195 215 196 216 197 - ### `ReorderResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L100) 217 + ### `ReorderResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L102) 198 218 199 219 200 220 response from reorder operation. 201 221 202 222 203 - ### `RecommendedTrack` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L107) 223 + ### `RecommendedTrack` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L109) 204 224 205 225 206 226 a recommended track for a playlist. 207 227 208 228 209 - ### `PlaylistRecommendationsResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L117) 229 + ### `PlaylistRecommendationsResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L119) 210 230 211 231 212 232 response for playlist recommendations. 213 233 234 + 235 + ### `ListByUriResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/lists.py#L204) 236 + 237 + 238 + resolved list type and routing info for an AT-URI. 239 +
+20 -6
docs/public/developers/api-reference/meta.md
··· 10 10 11 11 ## Functions 12 12 13 - ### `health` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/meta.py#L18) 13 + ### `health` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/meta.py#L19) 14 14 15 15 ```python 16 16 health() -> dict[str, str] ··· 20 20 health check endpoint. 21 21 22 22 23 - ### `get_public_config` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/meta.py#L24) 23 + ### `get_public_config` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/meta.py#L25) 24 24 25 25 ```python 26 26 get_public_config() -> dict[str, int | str | list[str]] ··· 30 30 expose public configuration to frontend. 31 31 32 32 33 - ### `client_metadata` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/meta.py#L43) 33 + ### `client_metadata` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/meta.py#L44) 34 34 35 35 ```python 36 36 client_metadata() -> dict[str, Any] ··· 43 43 whether OAUTH_JWK is configured. 44 44 45 45 46 - ### `jwks_endpoint` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/meta.py#L80) 46 + ### `jwks_endpoint` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/meta.py#L81) 47 47 48 48 ```python 49 49 jwks_endpoint() -> dict[str, Any] ··· 55 55 returns 404 if confidential client is not configured. 56 56 57 57 58 - ### `robots_txt` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/meta.py#L96) 58 + ### `robots_txt` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/meta.py#L97) 59 59 60 60 ```python 61 61 robots_txt() ··· 65 65 serve robots.txt to tell crawlers this is an API, not a website. 66 66 67 67 68 - ### `sitemap_data` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/meta.py#L105) 68 + ### `sitemap_data` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/meta.py#L106) 69 69 70 70 ```python 71 71 sitemap_data(db: Annotated[AsyncSession, Depends(get_db)]) -> dict[str, Any] ··· 77 77 returns tracks, artists, and albums with just IDs/slugs and timestamps. 78 78 the frontend renders this into XML at /sitemap.xml. 79 79 80 + 81 + ### `proxy_browser_telemetry` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/meta.py#L158) 82 + 83 + ```python 84 + proxy_browser_telemetry(request: Request) 85 + ``` 86 + 87 + 88 + forward browser telemetry to Logfire. 89 + 90 + proxies OpenTelemetry data from the browser SDK so the write token 91 + stays server-side. protected by CORS (allowed origins only) and 92 + global rate limiting. 93 +
+11 -10
docs/public/developers/api-reference/now_playing.md
··· 10 10 11 11 exposes real-time playback state for services like teal.fm/Piper. 12 12 13 - note: these endpoints are exempt from rate limiting because they're 14 - already throttled client-side (10-second intervals, 1-second debounce). 13 + note: POST/DELETE are rate-limited server-side as a safety net. 14 + the frontend also throttles client-side (10-second intervals). 15 + GET endpoints for Piper are exempt since they're read-only. 15 16 16 17 17 18 ## Functions 18 19 19 - ### `update_now_playing` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/now_playing.py#L68) 20 + ### `update_now_playing` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/now_playing.py#L69) 20 21 21 22 ```python 22 - update_now_playing(update: NowPlayingUpdate, session: Session = Depends(require_auth)) -> StatusResponse 23 + update_now_playing(request: Request, update: NowPlayingUpdate, session: Session = Depends(require_auth)) -> StatusResponse 23 24 ``` 24 25 25 26 ··· 29 30 state expires after 5 minutes of no updates. 30 31 31 32 32 - ### `clear_now_playing` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/now_playing.py#L98) 33 + ### `clear_now_playing` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/now_playing.py#L100) 33 34 34 35 ```python 35 - clear_now_playing(session: Session = Depends(require_auth)) -> StatusResponse 36 + clear_now_playing(request: Request, session: Session = Depends(require_auth)) -> StatusResponse 36 37 ``` 37 38 38 39 ··· 41 42 called when user explicitly stops playback. 42 43 43 44 44 - ### `get_now_playing_by_handle` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/now_playing.py#L111) 45 + ### `get_now_playing_by_handle` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/now_playing.py#L114) 45 46 46 47 ```python 47 48 get_now_playing_by_handle(handle: str, response: Response, db: Annotated[AsyncSession, Depends(get_db)]) -> NowPlayingResponse ··· 64 65 - service_base_url: "plyr.fm" for Piper to identify the source 65 66 66 67 67 - ### `get_now_playing_by_did` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/now_playing.py#L163) 68 + ### `get_now_playing_by_did` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/now_playing.py#L166) 68 69 69 70 ```python 70 71 get_now_playing_by_did(did: str, response: Response) -> NowPlayingResponse ··· 79 80 80 81 ## Classes 81 82 82 - ### `NowPlayingUpdate` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/now_playing.py#L25) 83 + ### `NowPlayingUpdate` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/now_playing.py#L26) 83 84 84 85 85 86 request to update now playing state. 86 87 87 88 88 - ### `NowPlayingResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/now_playing.py#L39) 89 + ### `NowPlayingResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/now_playing.py#L40) 89 90 90 91 91 92 now playing state response.
+4 -4
docs/public/developers/api-reference/preferences.md
··· 10 10 11 11 ## Functions 12 12 13 - ### `get_preferences` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/preferences.py#L80) 13 + ### `get_preferences` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/preferences.py#L82) 14 14 15 15 ```python 16 16 get_preferences(db: Annotated[AsyncSession, Depends(get_db)], session: Session = Depends(require_auth)) -> PreferencesResponse ··· 20 20 get user preferences (creates default if not exists). 21 21 22 22 23 - ### `update_preferences` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/preferences.py#L121) 23 + ### `update_preferences` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/preferences.py#L124) 24 24 25 25 ```python 26 26 update_preferences(update: PreferencesUpdate, db: Annotated[AsyncSession, Depends(get_db)], session: Session = Depends(require_auth)) -> PreferencesResponse ··· 38 38 user preferences response model. 39 39 40 40 41 - ### `PreferencesUpdate` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/preferences.py#L41) 41 + ### `PreferencesUpdate` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/preferences.py#L42) 42 42 43 43 44 44 user preferences update model. ··· 46 46 47 47 **Methods:** 48 48 49 - #### `validate_support_url` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/preferences.py#L56) 49 + #### `validate_support_url` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/preferences.py#L58) 50 50 51 51 ```python 52 52 validate_support_url(cls, v: str | None) -> str | None
+3 -3
docs/public/developers/api-reference/search.md
··· 23 23 results are sorted by relevance within each type. 24 24 25 25 26 - ### `semantic_search` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/search.py#L362) 26 + ### `semantic_search` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/search.py#L364) 27 27 28 28 ```python 29 29 semantic_search(db: Annotated[AsyncSession, Depends(get_db)], q: str = Query(..., min_length=3, max_length=200, description='text description of desired audio'), limit: int = Query(10, ge=1, le=50, description='max results')) -> SemanticSearchResponse ··· 75 75 unified search response. 76 76 77 77 78 - ### `SemanticTrackResult` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/search.py#L341) 78 + ### `SemanticTrackResult` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/search.py#L343) 79 79 80 80 81 81 a track result from semantic audio search. 82 82 83 83 84 - ### `SemanticSearchResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/search.py#L353) 84 + ### `SemanticSearchResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/search.py#L355) 85 85 86 86 87 87 response from semantic search endpoint.
+8 -8
docs/public/developers/api-reference/tracks/listing.md
··· 13 13 ### `list_tracks` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/listing.py#L69) 14 14 15 15 ```python 16 - list_tracks(db: Annotated[AsyncSession, Depends(get_db)], artist_did: str | None = None, filter_hidden_tags: bool | None = None, cursor: str | None = None, limit: int | None = None, session: AuthSession | None = Depends(get_optional_session)) -> TracksListResponse 16 + list_tracks(db: Annotated[AsyncSession, Depends(get_db)], artist_did: str | None = None, filter_hidden_tags: bool | None = None, tags: Annotated[list[str] | None, Query()] = None, cursor: str | None = None, limit: int | None = None, session: AuthSession | None = Depends(get_optional_session)) -> TracksListResponse 17 17 ``` 18 18 19 19 ··· 31 31 - `limit`: Maximum number of tracks to return (default from settings, max 100). 32 32 33 33 34 - ### `list_top_tracks` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/listing.py#L322) 34 + ### `list_top_tracks` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/listing.py#L344) 35 35 36 36 ```python 37 - list_top_tracks(db: Annotated[AsyncSession, Depends(get_db)], limit: int = 10, session: AuthSession | None = Depends(get_optional_session)) -> list[TrackResponse] 37 + list_top_tracks(db: Annotated[AsyncSession, Depends(get_db)], limit: int = 10, period: Literal['all_time', 'month', 'week', 'day'] = 'all_time', session: AuthSession | None = Depends(get_optional_session)) -> list[TrackResponse] 38 38 ``` 39 39 40 40 41 41 get top tracks by like count (most liked first, at least one like). 42 42 43 43 44 - ### `list_my_tracks` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/listing.py#L403) 44 + ### `list_my_tracks` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/listing.py#L431) 45 45 46 46 ```python 47 47 list_my_tracks(db: Annotated[AsyncSession, Depends(get_db)], auth_session: AuthSession = Depends(require_auth), limit: int = Query(10, ge=1, le=100), offset: int = Query(0, ge=0)) -> MyTracksResponse ··· 51 51 List tracks uploaded by authenticated user. 52 52 53 53 54 - ### `list_broken_tracks` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/listing.py#L461) 54 + ### `list_broken_tracks` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/listing.py#L489) 55 55 56 56 ```python 57 57 list_broken_tracks(db: Annotated[AsyncSession, Depends(get_db)], auth_session: AuthSession = Depends(require_auth)) -> BrokenTracksResponse ··· 65 65 recreation. 66 66 67 67 68 - ### `get_my_file_sizes` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/listing.py#L507) 68 + ### `get_my_file_sizes` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/listing.py#L535) 69 69 70 70 ```python 71 71 get_my_file_sizes(db: Annotated[AsyncSession, Depends(get_db)], auth_session: AuthSession = Depends(require_auth)) -> FileSizesResponse ··· 89 89 Response for listing authenticated user's tracks. 90 90 91 91 92 - ### `BrokenTracksResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/listing.py#L453) 92 + ### `BrokenTracksResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/listing.py#L481) 93 93 94 94 95 95 Response for broken tracks endpoint. 96 96 97 97 98 - ### `FileSizesResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/listing.py#L502) 98 + ### `FileSizesResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/listing.py#L530)
+1 -1
docs/public/developers/api-reference/tracks/metadata_service.md
··· 30 30 Apply album updates to the track, returning whether a change occurred. 31 31 32 32 33 - ### `upload_track_image` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/metadata_service.py#L109) 33 + ### `upload_track_image` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/metadata_service.py#L120) 34 34 35 35 ```python 36 36 upload_track_image(image: UploadFile) -> tuple[str, str | None, str | None]
+12 -2
docs/public/developers/api-reference/tracks/playback.md
··· 10 10 11 11 ## Functions 12 12 13 - ### `get_track` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/playback.py#L41) 13 + ### `get_track_by_uri` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/playback.py#L68) 14 + 15 + ```python 16 + get_track_by_uri(uri: Annotated[str, Query(description='AT-URI of the track record')], db: Annotated[AsyncSession, Depends(get_db)], session: Session | None = Depends(get_optional_session)) -> TrackResponse 17 + ``` 18 + 19 + 20 + Get a track by its ATProto record URI. 21 + 22 + 23 + ### `get_track` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/playback.py#L87) 14 24 15 25 ```python 16 26 get_track(track_id: int, db: Annotated[AsyncSession, Depends(get_db)], session: Session | None = Depends(get_optional_session)) -> TrackResponse ··· 20 30 Get a specific track. 21 31 22 32 23 - ### `increment_play_count` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/playback.py#L78) 33 + ### `increment_play_count` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/playback.py#L106) 24 34 25 35 ```python 26 36 increment_play_count(track_id: int, db: Annotated[AsyncSession, Depends(get_db)], session: Session | None = Depends(get_optional_session), body: PlayRequest | None = Body(default=None)) -> PlayCountResponse
+8 -8
docs/public/developers/api-reference/tracks/tags.md
··· 10 10 11 11 ## Functions 12 12 13 - ### `get_tracks_by_tag` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/tags.py#L52) 13 + ### `get_tracks_by_tag` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/tags.py#L53) 14 14 15 15 ```python 16 16 get_tracks_by_tag(tag_name: str, db: Annotated[AsyncSession, Depends(get_db)], session: AuthSession | None = Depends(get_optional_session)) -> TagTracksResponse ··· 22 22 returns tag info and list of tracks tagged with that tag. 23 23 24 24 25 - ### `list_tags` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/tags.py#L126) 25 + ### `list_tags` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/tags.py#L127) 26 26 27 27 ```python 28 28 list_tags(db: Annotated[AsyncSession, Depends(get_db)], q: Annotated[str | None, Query(description='search query for tag names')] = None, limit: Annotated[int, Query(ge=1, le=100)] = 20) -> list[TagWithCount] ··· 35 35 use `q` parameter for prefix search (case-insensitive). 36 36 37 37 38 - ### `get_recommended_tags` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/tags.py#L171) 38 + ### `get_recommended_tags` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/tags.py#L182) 39 39 40 40 ```python 41 41 get_recommended_tags(track_id: int, db: Annotated[AsyncSession, Depends(get_db)], limit: Annotated[int, Query(ge=1, le=20)] = 5) -> RecommendedTagsResponse ··· 53 53 ### `TagWithCount` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/tags.py#L29) 54 54 55 55 56 - tag with track count for autocomplete. 56 + tag with track count and total plays for ranking. 57 57 58 58 59 - ### `TagDetail` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/tags.py#L36) 59 + ### `TagDetail` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/tags.py#L37) 60 60 61 61 62 62 tag detail with metadata. 63 63 64 64 65 - ### `TagTracksResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/tags.py#L43) 65 + ### `TagTracksResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/tags.py#L44) 66 66 67 67 68 68 response for getting tracks by tag. 69 69 70 70 71 - ### `RecommendedTag` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/tags.py#L155) 71 + ### `RecommendedTag` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/tags.py#L166) 72 72 73 73 74 74 a recommended tag with confidence score. 75 75 76 76 77 - ### `RecommendedTagsResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/tags.py#L162) 77 + ### `RecommendedTagsResponse` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/tags.py#L173) 78 78 79 79 80 80 response for tag recommendations based on genre classification.
+9 -9
docs/public/developers/api-reference/tracks/uploads.md
··· 10 10 11 11 ## Functions 12 12 13 - ### `upload_track` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/uploads.py#L910) 13 + ### `upload_track` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/uploads.py#L973) 14 14 15 15 ```python 16 - upload_track(request: Request, title: Annotated[str, Form()], background_tasks: BackgroundTasks, auth_session: AuthSession = Depends(require_artist_profile), album: Annotated[str | None, Form()] = None, features: Annotated[str | None, Form()] = None, tags: Annotated[str | None, Form(description='JSON array of tag names')] = None, support_gate: Annotated[str | None, Form(description='JSON object for supporter gating, e.g., {"type": "any"}')] = None, description: Annotated[str | None, Form(description='Track description (liner notes, show notes, etc.)')] = None, auto_tag: Annotated[str | None, Form(description='auto-apply recommended genre tags after classification')] = None, file: UploadFile = File(...), image: UploadFile | None = File(None)) -> UploadStartResponse 16 + upload_track(request: Request, title: Annotated[str, Form()], background_tasks: BackgroundTasks, auth_session: AuthSession = Depends(require_artist_profile), album: Annotated[str | None, Form()] = None, album_id: Annotated[str | None, Form(description='explicit album id to attach to (mutually exclusive with album)')] = None, features: Annotated[str | None, Form()] = None, tags: Annotated[str | None, Form(description='JSON array of tag names')] = None, support_gate: Annotated[str | None, Form(description='JSON object for supporter gating, e.g., {"type": "any"}')] = None, description: Annotated[str | None, Form(description='Track description (liner notes, show notes, etc.)')] = None, auto_tag: Annotated[str | None, Form(description='auto-apply recommended genre tags after classification')] = None, file: UploadFile = File(...), image: UploadFile | None = File(None)) -> UploadStartResponse 17 17 ``` 18 18 19 19 ··· 28 28 Requires atprotofans to be enabled in settings. 29 29 Example\: {"type"\: "any"} - requires any atprotofans support. 30 30 - `file`: Audio file to upload (required). 31 - - `image`: Optional image file for track artwork. Accepted formats: JPG, PNG, WebP, GIF. Max 20 MB. Square images (1:1) recommended — non-square images are center-cropped. 31 + - `image`: Optional image file for track artwork. 32 32 - `background_tasks`: FastAPI background-task runner. 33 33 - `auth_session`: Authenticated artist session (dependency-injected). 34 34 ··· 36 36 - A payload containing `upload_id` for monitoring progress via SSE. 37 37 38 38 39 - ### `upload_progress` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/uploads.py#L1095) 39 + ### `upload_progress` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/uploads.py#L1162) 40 40 41 41 ```python 42 42 upload_progress(upload_id: str) -> StreamingResponse ··· 60 60 all data needed to process an upload in the background. 61 61 62 62 63 - ### `AudioInfo` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/uploads.py#L107) 63 + ### `AudioInfo` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/uploads.py#L108) 64 64 65 65 66 66 result of audio validation phase. 67 67 68 68 69 - ### `StorageResult` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/uploads.py#L116) 69 + ### `StorageResult` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/uploads.py#L117) 70 70 71 71 72 72 result of audio storage phase. 73 73 74 74 75 - ### `UploadPhaseError` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/uploads.py#L127) 75 + ### `UploadPhaseError` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/uploads.py#L128) 76 76 77 77 78 78 raised when an upload phase fails with a user-facing message. 79 79 80 80 81 - ### `TranscodeInfo` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/uploads.py#L227) 81 + ### `TranscodeInfo` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/uploads.py#L228) 82 82 83 83 84 84 result of transcoding an audio file. 85 85 86 86 87 - ### `PdsBlobResult` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/uploads.py#L238) 87 + ### `PdsBlobResult` [source](https://github.com/zzstoatzz/plyr.fm/blob/main/backend/src/backend/api/tracks/uploads.py#L239) 88 88 89 89 90 90 result of attempting to upload a blob to user's PDS.
+90 -10
frontend/src/lib/components/AlbumUploadForm.svelte
··· 3 3 import type { TrackEntry } from '$lib/components/TrackEntryCard.svelte'; 4 4 import TrackEntryCard from '$lib/components/TrackEntryCard.svelte'; 5 5 import PdsTooltip from '$lib/components/PdsTooltip.svelte'; 6 - import { uploader } from '$lib/uploader.svelte'; 6 + import { uploader, type UploadResult } from '$lib/uploader.svelte'; 7 7 import { toast } from '$lib/toast.svelte'; 8 - import { getServerConfig } from '$lib/config'; 8 + import { getServerConfig, API_URL } from '$lib/config'; 9 9 10 10 const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.m4a', '.aiff', '.aif', '.flac']; 11 11 const FILE_INPUT_ACCEPT = ··· 238 238 let completed = 0; 239 239 let failed = 0; 240 240 241 + // step 1: create the album shell up front. tracks will reference this 242 + // album_id explicitly, which avoids the get_or_create_album race that 243 + // scrambled track order under concurrent uploads. 244 + let albumId: string | null = null; 245 + let albumSlug: string | null = null; 246 + try { 247 + const createResponse = await fetch(`${API_URL}/albums/`, { 248 + method: 'POST', 249 + headers: { 'Content-Type': 'application/json' }, 250 + credentials: 'include', 251 + body: JSON.stringify({ 252 + title: albumTitle.trim(), 253 + }), 254 + }); 255 + if (!createResponse.ok) { 256 + const err = await createResponse.json().catch(() => ({ detail: 'unknown error' })); 257 + throw new Error(err.detail || 'failed to create album'); 258 + } 259 + const created = await createResponse.json(); 260 + albumId = created.id; 261 + albumSlug = created.slug; 262 + } catch (err) { 263 + toast.error( 264 + err instanceof Error ? `failed to create album: ${err.message}` : 'failed to create album', 265 + ); 266 + uploading = false; 267 + return; 268 + } 269 + 270 + // step 2: upload cover art once (if provided). failure is non-fatal — 271 + // the album still exists, user can add cover later from the edit page. 272 + if (coverArtFile && albumId) { 273 + try { 274 + const coverForm = new FormData(); 275 + coverForm.append('image', coverArtFile); 276 + const coverResponse = await fetch(`${API_URL}/albums/${albumId}/cover`, { 277 + method: 'POST', 278 + credentials: 'include', 279 + body: coverForm, 280 + }); 281 + if (!coverResponse.ok) { 282 + toast.warning('cover art failed to upload — you can add it later from the album page'); 283 + } 284 + } catch { 285 + toast.warning('cover art failed to upload — you can add it later from the album page'); 286 + } 287 + } 288 + 289 + // step 3: upload all tracks concurrently with the explicit album_id. 290 + // indexedResults preserves the user-intended order across Promise.allSettled 291 + // so finalize can pass track_ids in that exact order. 292 + const indexedResults: Array<UploadResult | null> = tracks.map(() => null); 293 + 241 294 const promises = tracks.map((track, i) => { 242 295 tracks[i] = { ...tracks[i], status: 'uploading' }; 243 296 ··· 261 314 uploader.upload( 262 315 track.file!, 263 316 track.title, 264 - albumTitle.trim(), 317 + '', // album name lives on the album row now, not on per-track form data 265 318 [...track.featuredArtists], 266 - coverArtFile, 319 + null, // no per-track cover — album cover was uploaded above 267 320 [...track.tags], 268 321 track.supportGated, 269 322 track.autoTag, 270 323 track.description, 271 - () => { 324 + (result) => { 272 325 // SSE completed 273 326 clearTimeout(timeout); 274 327 tracks[i] = { ...tracks[i], status: 'completed' }; 275 328 completed++; 329 + if (result) { 330 + indexedResults[i] = result; 331 + } 276 332 onAlbumsReload(); 277 333 safeResolve(); 278 334 }, ··· 289 345 }, 290 346 }, 291 347 track.title, 348 + albumId ?? undefined, 292 349 ); 293 350 }); 294 351 }); 295 352 await Promise.allSettled(promises); 296 353 297 - // refresh albums so we can find the slug for the "view album" link 354 + // step 4: finalize the album — writes the ATProto list record with the 355 + // user-intended track order. skipped entries (failed uploads) are filtered 356 + // out; relative order of successful tracks is preserved. 357 + const orderedTrackIds = indexedResults 358 + .filter((r): r is UploadResult => r !== null) 359 + .map((r) => r.trackId); 360 + 361 + if (albumId && orderedTrackIds.length > 0) { 362 + try { 363 + const finalizeResponse = await fetch(`${API_URL}/albums/${albumId}/finalize`, { 364 + method: 'POST', 365 + headers: { 'Content-Type': 'application/json' }, 366 + credentials: 'include', 367 + body: JSON.stringify({ track_ids: orderedTrackIds }), 368 + }); 369 + if (!finalizeResponse.ok) { 370 + toast.warning( 371 + 'tracks uploaded but failed to save order — you can reorder from the album page', 372 + ); 373 + } 374 + } catch { 375 + toast.warning( 376 + 'tracks uploaded but failed to save order — you can reorder from the album page', 377 + ); 378 + } 379 + } 380 + 381 + // refresh albums so the list view reflects the new album 298 382 await onAlbumsReload(); 299 383 300 384 if (completed > 0) { 301 - const albumSlug = albums.find( 302 - (a) => a.title.toLowerCase() === albumTitle.trim().toLowerCase(), 303 - )?.slug; 304 - 305 385 toast.success( 306 386 `${completed} of ${tracks.length} track${tracks.length > 1 ? 's' : ''} uploaded`, 307 387 5000,
+23 -4
frontend/src/lib/uploader.svelte.ts
··· 23 23 onError?: (_error: string) => void; 24 24 } 25 25 26 + export interface UploadResult { 27 + trackId: number; 28 + atprotoUri: string | null; 29 + atprotoCid: string | null; 30 + } 31 + 26 32 function isMobileDevice(): boolean { 27 33 if (!browser) return false; 28 34 return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); ··· 72 78 supportGated: boolean, 73 79 autoTag: boolean, 74 80 description: string, 75 - onSuccess?: () => void, 81 + onSuccess?: (_result?: UploadResult) => void, 76 82 callbacks?: UploadProgressCallback, 77 - label?: string 83 + label?: string, 84 + albumId?: string 78 85 ): void { 79 86 const taskId = crypto.randomUUID(); 80 87 const fileSizeMB = file.size / 1024 / 1024; ··· 99 106 const formData = new FormData(); 100 107 formData.append('file', file); 101 108 formData.append('title', title); 102 - if (album) formData.append('album', album); 109 + if (albumId) { 110 + formData.append('album_id', albumId); 111 + } else if (album) { 112 + formData.append('album', album); 113 + } 103 114 if (features.length > 0) { 104 115 const handles = features.map(a => a.handle); 105 116 formData.append('features', JSON.stringify(handles)); ··· 200 211 tracksCache.invalidate(); 201 212 tracksCache.fetch(true); 202 213 if (onSuccess) { 203 - onSuccess(); 214 + onSuccess( 215 + typeof trackId === 'number' 216 + ? { 217 + trackId, 218 + atprotoUri: update.atproto_uri ?? null, 219 + atprotoCid: update.atproto_cid ?? null 220 + } 221 + : undefined 222 + ); 204 223 } 205 224 } 206 225
+4 -4
loq.toml
··· 28 28 29 29 [[rules]] 30 30 path = "backend/src/backend/api/albums.py" 31 - max_lines = 784 31 + max_lines = 989 32 32 33 33 [[rules]] 34 34 path = "backend/src/backend/api/auth.py" ··· 48 48 49 49 [[rules]] 50 50 path = "backend/src/backend/api/tracks/uploads.py" 51 - max_lines = 1167 51 + max_lines = 1215 52 52 53 53 [[rules]] 54 54 path = "backend/src/backend/config.py" ··· 64 64 65 65 [[rules]] 66 66 path = "backend/tests/api/test_albums.py" 67 - max_lines = 917 67 + max_lines = 1503 68 68 69 69 [[rules]] 70 70 path = "backend/tests/api/test_list_record_sync.py" ··· 248 248 249 249 [[rules]] 250 250 path = "frontend/src/lib/components/AlbumUploadForm.svelte" 251 - max_lines = 743 251 + max_lines = 823 252 252 253 253 [[rules]] 254 254 path = "frontend/src/lib/components/TrackEntryCard.svelte"