feat: playlists, albums as ATProto lists, library hub, and graceful degradation (#499)

* feat: add public liked pages for any user (#498)

likes are stored on users' PDSes as ATProto records, making them public data.
this enables viewing any user's liked tracks without authentication.

backend:
- add GET /users/{handle}/likes endpoint
- add get_optional_session dependency for endpoints that benefit from optional auth
- endpoint returns user info, tracks, and count

frontend:
- add /liked/[handle] route with user header and track list
- add fetchUserLikes API function with UserLikesResponse type
- update LikersTooltip to link to user's liked page instead of profile

tests:
- add test_users.py with 4 tests for the new endpoint

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: consolidate optional auth into get_optional_session dependency

replaces inline session_id extraction pattern with reusable FastAPI dependency:
- listing.py: uses get_optional_session instead of manual cookie/header parsing
- playback.py: uses get_optional_session for get_track and increment_play_count
- removes utilities/auth.py (get_session_id_from_request no longer needed)
- updates test_hidden_tags_filter.py to override dependency instead of patching

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: add show_liked_on_profile preference

Adds a new user preference (defaults to false) that will allow users
to display their liked tracks on their artist page.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: add show_liked_on_profile setting with artist page integration

- Add show_liked_on_profile to preferences API (get/update)
- Expose preference in artist API response for public profiles
- Add settings toggle in frontend privacy & display section
- Update artist page to conditionally fetch and display liked tracks
- Update Preferences interface and layout to include new field

When enabled, a user's liked tracks are displayed on their artist page
below albums, allowing others to discover music they enjoy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: add fm.plyr.list lexicon for playlists and albums

introduces ATProto record type for ordered track collections:
- lexicons/list.json: defines fm.plyr.list with strongRef track references
- purpose field distinguishes "album", "playlist", "collection"
- items array ordering determines display order (reorder via putRecord)
- each item uses strongRef (uri + cid) for content-addressability

backend infrastructure:
- create_list_record/update_list_record in records.py
- list_collection added to OAuth scopes
- exported from _internal.atproto module

design notes:
- strongRef ensures list items point to specific track versions
- when tracks are deleted, list records should be updated to remove refs
- albums can be formalized as list records with purpose="album"
- no records created yet - this is infrastructure for future integration

relates to #221 (ATProto records for albums) and #146 (content-addressable storage)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: generalize fm.plyr.list to support any record type

- renamed listItem.track to listItem.subject for generic references
- added more purpose knownValues: discography, favorites
- updated descriptions to clarify any ATProto record can be referenced
- subject.uri indicates record type (e.g., fm.plyr.track, fm.plyr.list)

enables:
- lists of tracks (albums, playlists)
- lists of albums (discographies)
- lists of lists (playlist collections)
- lists of artists (following, featured)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: simplify fm.plyr.list lexicon to minimal fields

stripped to essentials per lexicon style guide:
- required: items, createdAt
- optional: name (display name), listType (semantic category)
- removed: purpose, description, imageUrl, addedAt

listType knownValues: album, playlist, liked
(extensible - any string valid per ATProto knownValues spec)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: add optional updatedAt field to fm.plyr.list

- lexicon: optional updatedAt datetime field
- update_list_record auto-sets updatedAt to now
- create_list_record omits updatedAt (new records)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: add feat/playlists branch status to STATUS.md

documents current state of list infrastructure:
- what's done (lexicon, backend functions, oauth scopes)
- what's NOT done (no UI, no records created, no migrations)
- design decisions and next steps

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: add fm.plyr.actor.profile lexicon and upsert function

- create profile.json lexicon with bio, createdAt, updatedAt fields
- add profile_collection to AtprotoSettings config
- add profile collection to OAuth scopes
- implement build_profile_record and upsert_profile_record
- uses putRecord with rkey="self" for upsert semantics

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: wire up profile record upsert to artist bio endpoints

- call upsert_profile_record when bio is set on create/update
- handle ATProto failures gracefully (log but don't fail request)
- add tests for profile record integration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: sync profile record on login with proper background task handling

- add background sync in GET /artists/me for existing users with bios
- skip write if ATProto record already exists with same bio (no-op)
- use proper task lifecycle management to prevent GC before completion
- return None from upsert_profile_record when skipped

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: simplify profile sync to silent fire-and-forget

remove over-engineered response field for toast notification.
profile record sync happens silently in background on GET /artists/me.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: remove unused check_profile_record_sync_needed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: always sync profile record, not just when bio exists

profile record should be created on login regardless of whether
user has a bio set. bio is optional in the lexicon.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: sync albums and liked tracks as ATProto list records on login

Backend:
- Add upsert_album_list_record() and upsert_liked_list_record() functions
- Wire up fire-and-forget sync on GET /artists/me for all artist albums
- Wire up fire-and-forget sync for user's liked tracks list
- Persist ATProto URIs/CIDs back to database after sync
- Migration: add liked_list_uri and liked_list_cid to user_preferences

Frontend:
- Artist page: replace inline liked tracks with link card to /liked/{handle}
- Add "collections" section header to distinguish from albums
- Liked page: handle link now goes to artist page, not Bluesky

Design decisions:
- Liked list references track records directly (not like records) for simplicity
- Array order = display order (ATProto-native approach)
- Albums ordered by track created_at asc, likes by created_at desc

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: add library hub page and update navigation

- create /library route as hub for personal collections
- show liked tracks card with count
- add placeholder for future playlists
- change nav from "liked" → "library" (heart icon goes to /library)
- keep /liked route for direct access

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: update feat/playlists branch status in STATUS.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: scope upgrade OAuth flow for teal.fm integration (#503)

* feat: add scope upgrade OAuth flow for teal.fm integration

- Add /auth/scope-upgrade/start endpoint that initiates OAuth flow with
expanded scopes (mirrors developer token pattern)
- Replace passive "please re-login" message with immediate OAuth redirect
when user enables teal.fm scrobbling
- Fix preferences bug where toggling settings reset theme to dark mode
(theme is client-side only, preserved from localStorage on fetch)
- Add PendingScopeUpgrade model to track in-flight scope upgrades
- Handle scope_upgraded callback to replace old session with new one
- Add tests for scope upgrade flow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: update STATUS.md with scope upgrade OAuth flow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: resolve test failures from connection pool exhaustion

- add DATABASE_POOL_SIZE=2, DATABASE_MAX_OVERFLOW=0 to pytest env vars
- dispose ENGINES cache after each test in conftest to prevent connection accumulation
- fix mock_refresh_session functions to accept `self` parameter (method signature)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: add full-page overlay for developer token display

when users return from OAuth after creating a developer token,
show a prominent overlay so they don't miss it. the token won't
be shown again after dismissing, so this ensures visibility.

- full-page modal with blur backdrop
- copy button with success feedback
- warning text emphasizing save-now urgency
- link to python SDK docs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* docs: update STATUS.md with completed scope upgrade work

- mark feat/scope-invalidation as merged to feat/playlists
- document developer token overlay feature
- document test fixes for connection pool exhaustion
- note all 281 tests passing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: remove confusing album migration note from STATUS.md

albums sync as list records on login - no migration needed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: implement playlist CRUD with ATProto integration

- add playlist endpoints to lists.py (create, list, get, add/remove tracks, delete)
- add Playlist model for database caching of ATProto list records
- add playlist types to frontend (Playlist, PlaylistWithTracks, PlaylistTrack)
- update library page with playlist list and create modal
- fix font inheritance on create button
- filter search results to exclude tracks already in playlist

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: use API_URL for playlist endpoints and fix button font inheritance

- fix all fetch calls to use API_URL instead of /api/ relative paths
- add font-family: inherit to all modal buttons
- library page: create playlist modal buttons
- playlist page: add-btn, empty-add-btn, cancel/confirm buttons

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: add share button and link previews for playlists

- add font-family: inherit to search input in playlist modal
- add font-family: inherit to create playlist input
- add ShareButton component to playlist page (visible to all users)
- add public /lists/playlists/{id}/meta endpoint (no auth required)
- add +page.server.ts to fetch playlist meta for SSR
- add OG meta tags for link previews (og:type, og:title, og:description, twitter:card)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: playlist enhancements and UX improvements

- add "add to playlist" menu to track items (AddToMenu component)
- include playlists in global search results
- filter current playlist from add-to-playlist picker on playlist detail page
- add "create new playlist" link in playlist picker menus
- show playlist artwork in library page list
- fix portal empty playlist state link to open create modal
- update edit button tooltip to "edit playlist metadata"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: portal album/playlist consistency

- use consistent edit icon (document+pencil) for album edit button
- match playlist grid sizing to album grid (200px min, 1.5rem gap)
- match playlist card padding and font sizes to album cards
- update placeholder icon size from 32 to 48

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: standardize liked tracks page buttons to match playlist/album style

* fix: populate is_liked on playlist tracks and resolve svelte-check warnings

* fix: add toast notifications for mutations (playlist CRUD, profile updates)

* feat: graceful degradation for unavailable tracks in playlists

when a track in someone's PDS list no longer exists in the database
(e.g., the track owner deleted it), we now show it grayed out with
"track unavailable" instead of silently hiding it.

- add UnavailableTrack schema to backend
- return unavailable_tracks array from get_playlist endpoint
- render unavailable tracks with muted styling in frontend

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* jpeg fix and a couple other tings

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub 19ae79fb 8efa66ca

Changed files
+8641 -405
backend
docs
backend
frontend
lexicons
+133 -3
STATUS.md
··· 47 47 48 48 ### December 2025 49 49 50 + #### playlists, ATProto sync, and library hub (feat/playlists branch, PR #499, Dec 6-7) 51 + 52 + **status**: feature-complete, ready for final review. ~8k lines changed. 53 + 54 + **playlists** (full CRUD): 55 + - `playlists` and `playlist_tracks` tables with Alembic migration 56 + - `POST /lists/playlists` - create playlist 57 + - `PUT /lists/playlists/{id}` - rename playlist 58 + - `DELETE /lists/playlists/{id}` - delete playlist 59 + - `POST /lists/playlists/{id}/tracks` - add track to playlist 60 + - `DELETE /lists/playlists/{id}/tracks/{track_id}` - remove track 61 + - `PUT /lists/playlists/{id}/tracks/reorder` - reorder tracks 62 + - `POST /lists/playlists/{id}/cover` - upload cover art 63 + - playlist detail page (`/playlist/[id]`) with edit modal, drag-and-drop reordering 64 + - playlists in global search results 65 + - "add to playlist" menu on tracks (filters out current playlist when on playlist page) 66 + - "create new playlist" link in add-to menu → `/library?create=playlist` 67 + - playlist sharing with OpenGraph link previews 68 + 69 + **ATProto integration**: 70 + - `fm.plyr.list` lexicon for syncing playlists and albums to user PDSes 71 + - `fm.plyr.actor.profile` lexicon for syncing artist profiles 72 + - automatic sync of albums, liked tracks, and profile on login (fire-and-forget) 73 + - scope upgrade OAuth flow for teal.fm integration (#503) 74 + 75 + **library hub** (`/library`): 76 + - unified page with tabs: liked, playlists, albums 77 + - create playlist modal (accessible via `/library?create=playlist` deep link) 78 + - consistent card layouts across sections 79 + - nav changed from "liked" → "library" 80 + 81 + **user experience**: 82 + - public liked pages for any user (`/liked/[handle]`) 83 + - `show_liked_on_profile` preference 84 + - portal album/playlist section visual consistency 85 + - toast notifications for all mutations (playlist CRUD, profile updates) 86 + - z-index fixes for dropdown menus 87 + 88 + **accessibility fixes**: 89 + - fixed 32 svelte-check warnings (ARIA roles, button nesting, unused CSS) 90 + - proper roles on modals, menus, and drag-drop elements 91 + 92 + **design decisions**: 93 + - lists are generic ordered collections of any ATProto records 94 + - `listType` semantically categorizes (album, playlist, liked) but doesn't restrict content 95 + - array order = display order, reorder via `putRecord` 96 + - strongRef (uri + cid) for content-addressable item references 97 + - "library" = umbrella term for personal collections 98 + 99 + **sync architecture**: 100 + - **profile, albums, liked tracks**: synced on login via `GET /artists/me` (fire-and-forget background tasks) 101 + - **playlists**: synced on create/modify (not at login) - avoids N playlist syncs on every login 102 + - sync tasks don't block the response (~300-500ms for the endpoint, PDS calls happen in background) 103 + - putRecord calls take ~50-100ms each, with automatic DPoP nonce retry on 401 104 + 105 + **file size audit** (candidates for future modularization): 106 + - `portal/+page.svelte`: 2,436 lines (58% CSS) 107 + - `playlist/[id]/+page.svelte`: 1,644 lines (48% CSS) 108 + - `api/lists.py`: 855 lines 109 + - CSS-heavy files could benefit from shared style extraction in future 110 + 111 + **related issues**: #221, #146, #498 112 + 113 + --- 114 + 115 + #### list reordering UI (feat/playlists branch, Dec 7) 116 + 117 + **what's done**: 118 + - `PUT /lists/liked/reorder` endpoint - reorder user's liked tracks list 119 + - `PUT /lists/{rkey}/reorder` endpoint - reorder any list by ATProto rkey 120 + - both endpoints take `items` array of strongRefs (uri + cid) in desired order 121 + - liked tracks page (`/liked`) now has "reorder" button for authenticated users 122 + - album page has "reorder" button for album owner (if album has ATProto list record) 123 + - drag-and-drop reordering on desktop (HTML5 drag API) 124 + - touch reordering on mobile (6-dot grip handle, same pattern as queue) 125 + - visual feedback during drag: `.drag-over` and `.is-dragging` states 126 + - saves order to ATProto via `putRecord` when user clicks "done" 127 + - added `atproto_record_cid` to TrackResponse schema (needed for strongRefs) 128 + - added `artist_did` and `list_uri` to AlbumMetadata response 129 + 130 + **UX design**: 131 + - button toggles between "reorder" and "done" states 132 + - in edit mode, drag handles appear next to each track 133 + - saving shows spinner, success/error toast on completion 134 + - only owners can see/use reorder button (liked list = current user, album = artist) 135 + 136 + --- 137 + 138 + #### scope upgrade OAuth flow (feat/scope-invalidation branch, Dec 7) - merged to feat/playlists 139 + 140 + **problem**: when users enabled teal.fm scrobbling, the app showed a passive "please log out and back in" message because the session lacked the required OAuth scopes. this was confusing UX. 141 + 142 + **solution**: immediate OAuth handshake when enabling features that require new scopes (same pattern as developer tokens). 143 + 144 + **what's done**: 145 + - `POST /auth/scope-upgrade/start` endpoint initiates OAuth with expanded scopes 146 + - `pending_scope_upgrades` table tracks in-flight upgrades (10min TTL) 147 + - callback replaces old session with new one, redirects to `/settings?scope_upgraded=true` 148 + - frontend shows spinner during redirect, success toast on return 149 + - fixed preferences bug where toggling settings reset theme to dark mode 150 + 151 + **code quality**: 152 + - eliminated bifurcated OAuth clients (`oauth_client` vs `oauth_client_with_teal`) 153 + - replaced with `get_oauth_client(include_teal=False)` factory function 154 + - at ~17 OAuth flows/day, instantiation cost is negligible 155 + - explicit scope selection at call site instead of module-level state 156 + 157 + **developer token UX**: 158 + - full-page overlay when returning from OAuth after creating a developer token 159 + - token displayed prominently with warning that it won't be shown again 160 + - copy button with success feedback, link to python SDK docs 161 + - prevents users from missing their token (was buried at bottom of page) 162 + 163 + **test fixes**: 164 + - fixed connection pool exhaustion in tests (was hitting Neon's connection limit) 165 + - added `DATABASE_POOL_SIZE=2`, `DATABASE_MAX_OVERFLOW=0` to pytest env vars 166 + - dispose cached engines after each test to prevent connection accumulation 167 + - fixed mock function signatures for `refresh_session` tests 168 + 169 + **tests**: 4 new tests for scope upgrade flow, all 281 tests passing 170 + 171 + --- 172 + 50 173 #### settings consolidation (PR #496, Dec 6) 51 174 52 175 **problem**: user preferences were scattered across multiple locations with confusing terminology: ··· 398 521 399 522 ### known issues 400 523 - playback auto-start on refresh (#225) - investigating localStorage/queue state persistence 401 - - no ATProto records for albums yet (#221 - consciously deferred) 402 524 - no AIFF/AIF transcoding support (#153) 403 525 - iOS PWA audio may hang on first play after backgrounding - service worker caching interacts poorly with 307 redirects to R2 CDN. PR #466 added `NetworkOnly` for audio routes which should fix this, but iOS PWAs are slow to update service workers. workaround: delete home screen bookmark and re-add. may need further investigation if issue persists after SW propagates. 404 526 ··· 466 588 - ✅ album browsing and detail pages 467 589 - ✅ album cover art upload and display 468 590 - ✅ server-side rendering for SEO 469 - - ⏸ ATProto records for albums (deferred, see issue #221) 591 + - ✅ ATProto list records for albums (auto-synced on login) 592 + 593 + **playlists** 594 + - ✅ full CRUD (create, rename, delete, reorder tracks) 595 + - ✅ playlist detail pages with drag-and-drop reordering 596 + - ✅ playlist cover art upload 597 + - ✅ ATProto list records (synced on create/modify) 598 + - ✅ "add to playlist" menu on tracks 599 + - ✅ playlists in global search results 470 600 471 601 **deployment (fully automated)** 472 602 - **production**: ··· 602 732 603 733 --- 604 734 605 - this is a living document. last updated 2025-12-06. 735 + this is a living document. last updated 2025-12-07.
+35
backend/alembic/versions/2025_12_06_212328_358af8d5d40a_add_show_liked_on_profile_preference.py
··· 1 + """add show_liked_on_profile preference 2 + 3 + Revision ID: 358af8d5d40a 4 + Revises: effe28dd977b 5 + Create Date: 2025-12-06 21:23:28.804641 6 + 7 + """ 8 + 9 + import sqlalchemy as sa 10 + 11 + from alembic import op 12 + 13 + # revision identifiers, used by Alembic. 14 + revision: str = "358af8d5d40a" 15 + down_revision: str | None = "effe28dd977b" 16 + branch_labels: str | None = None 17 + depends_on: str | None = None 18 + 19 + 20 + def upgrade() -> None: 21 + """Add show_liked_on_profile column to user_preferences.""" 22 + op.add_column( 23 + "user_preferences", 24 + sa.Column( 25 + "show_liked_on_profile", 26 + sa.Boolean(), 27 + server_default=sa.text("false"), 28 + nullable=False, 29 + ), 30 + ) 31 + 32 + 33 + def downgrade() -> None: 34 + """Remove show_liked_on_profile column from user_preferences.""" 35 + op.drop_column("user_preferences", "show_liked_on_profile")
+37
backend/alembic/versions/2025_12_06_233456_89f7fd3db111_add_liked_list_uri_and_liked_list_cid_.py
··· 1 + """add liked_list_uri and liked_list_cid to user_preferences 2 + 3 + Revision ID: 89f7fd3db111 4 + Revises: 358af8d5d40a 5 + Create Date: 2025-12-06 23:34:56.508424 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + 13 + from alembic import op 14 + 15 + # revision identifiers, used by Alembic. 16 + revision: str = "89f7fd3db111" 17 + down_revision: str | Sequence[str] | None = "358af8d5d40a" 18 + branch_labels: str | Sequence[str] | None = None 19 + depends_on: str | Sequence[str] | None = None 20 + 21 + 22 + def upgrade() -> None: 23 + """Add ATProto liked list record fields to user_preferences.""" 24 + op.add_column( 25 + "user_preferences", 26 + sa.Column("liked_list_uri", sa.String(), nullable=True), 27 + ) 28 + op.add_column( 29 + "user_preferences", 30 + sa.Column("liked_list_cid", sa.String(), nullable=True), 31 + ) 32 + 33 + 34 + def downgrade() -> None: 35 + """Remove ATProto liked list record fields from user_preferences.""" 36 + op.drop_column("user_preferences", "liked_list_cid") 37 + op.drop_column("user_preferences", "liked_list_uri")
+49
backend/alembic/versions/2025_12_07_004958_6c07ebda9721_add_pending_scope_upgrades_table.py
··· 1 + """add pending_scope_upgrades table 2 + 3 + Revision ID: 6c07ebda9721 4 + Revises: 89f7fd3db111 5 + Create Date: 2025-12-07 00:49:58.146707 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + 13 + from alembic import op 14 + 15 + # revision identifiers, used by Alembic. 16 + revision: str = "6c07ebda9721" 17 + down_revision: str | Sequence[str] | None = "89f7fd3db111" 18 + branch_labels: str | Sequence[str] | None = None 19 + depends_on: str | Sequence[str] | None = None 20 + 21 + 22 + def upgrade() -> None: 23 + """Upgrade schema.""" 24 + op.create_table( 25 + "pending_scope_upgrades", 26 + sa.Column("state", sa.String(64), primary_key=True), 27 + sa.Column("did", sa.String(256), nullable=False, index=True), 28 + sa.Column("old_session_id", sa.String(64), nullable=False), 29 + sa.Column("requested_scopes", sa.String(512), nullable=False), 30 + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), 31 + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), 32 + ) 33 + op.create_index( 34 + "ix_pending_scope_upgrades_state", "pending_scope_upgrades", ["state"] 35 + ) 36 + op.create_index( 37 + "ix_pending_scope_upgrades_created_at", "pending_scope_upgrades", ["created_at"] 38 + ) 39 + 40 + 41 + def downgrade() -> None: 42 + """Downgrade schema.""" 43 + op.drop_index( 44 + "ix_pending_scope_upgrades_created_at", table_name="pending_scope_upgrades" 45 + ) 46 + op.drop_index( 47 + "ix_pending_scope_upgrades_state", table_name="pending_scope_upgrades" 48 + ) 49 + op.drop_table("pending_scope_upgrades")
+64
backend/alembic/versions/2025_12_07_121500_add_playlists_table.py
··· 1 + """add playlists table 2 + 3 + Revision ID: add_playlists_table 4 + Revises: 6c07ebda9721 5 + Create Date: 2025-12-07 12:15:00.000000 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + 13 + from alembic import op 14 + 15 + # revision identifiers, used by Alembic. 16 + revision: str = "add_playlists_table" 17 + down_revision: str | Sequence[str] | None = "6c07ebda9721" 18 + branch_labels: str | Sequence[str] | None = None 19 + depends_on: str | Sequence[str] | None = None 20 + 21 + 22 + def upgrade() -> None: 23 + """Create playlists table for caching ATProto list records.""" 24 + op.create_table( 25 + "playlists", 26 + sa.Column("id", sa.String(), primary_key=True), 27 + sa.Column( 28 + "owner_did", 29 + sa.String(), 30 + sa.ForeignKey("artists.did"), 31 + nullable=False, 32 + index=True, 33 + ), 34 + sa.Column("name", sa.String(), nullable=False), 35 + sa.Column("atproto_record_uri", sa.String(), nullable=False, unique=True), 36 + sa.Column("atproto_record_cid", sa.String(), nullable=False), 37 + sa.Column("track_count", sa.Integer(), default=0), 38 + sa.Column( 39 + "created_at", 40 + sa.DateTime(timezone=True), 41 + nullable=False, 42 + server_default=sa.func.now(), 43 + ), 44 + sa.Column( 45 + "updated_at", 46 + sa.DateTime(timezone=True), 47 + nullable=False, 48 + server_default=sa.func.now(), 49 + onupdate=sa.func.now(), 50 + ), 51 + ) 52 + # create index on atproto_record_uri for fast lookups 53 + op.create_index( 54 + "ix_playlists_atproto_record_uri", 55 + "playlists", 56 + ["atproto_record_uri"], 57 + unique=True, 58 + ) 59 + 60 + 61 + def downgrade() -> None: 62 + """Drop playlists table.""" 63 + op.drop_index("ix_playlists_atproto_record_uri", table_name="playlists") 64 + op.drop_table("playlists")
+31
backend/alembic/versions/2025_12_07_124259_7a1bce049e3f_add_playlist_image_columns.py
··· 1 + """add playlist image columns 2 + 3 + Revision ID: 7a1bce049e3f 4 + Revises: add_playlists_table 5 + Create Date: 2025-12-07 12:42:59.996580 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + 13 + from alembic import op 14 + 15 + # revision identifiers, used by Alembic. 16 + revision: str = "7a1bce049e3f" 17 + down_revision: str | Sequence[str] | None = "add_playlists_table" 18 + branch_labels: str | Sequence[str] | None = None 19 + depends_on: str | Sequence[str] | None = None 20 + 21 + 22 + def upgrade() -> None: 23 + """Upgrade schema.""" 24 + op.add_column("playlists", sa.Column("image_id", sa.String(), nullable=True)) 25 + op.add_column("playlists", sa.Column("image_url", sa.String(), nullable=True)) 26 + 27 + 28 + def downgrade() -> None: 29 + """Downgrade schema.""" 30 + op.drop_column("playlists", "image_url") 31 + op.drop_column("playlists", "image_id")
+34
backend/alembic/versions/2025_12_07_130605_0ccb2cff4cec_add_show_on_profile_to_playlists.py
··· 1 + """add show_on_profile to playlists 2 + 3 + Revision ID: 0ccb2cff4cec 4 + Revises: 7a1bce049e3f 5 + Create Date: 2025-12-07 13:06:05.533671 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + 13 + from alembic import op 14 + 15 + # revision identifiers, used by Alembic. 16 + revision: str = "0ccb2cff4cec" 17 + down_revision: str | Sequence[str] | None = "7a1bce049e3f" 18 + branch_labels: str | Sequence[str] | None = None 19 + depends_on: str | Sequence[str] | None = None 20 + 21 + 22 + def upgrade() -> None: 23 + """Upgrade schema.""" 24 + op.add_column( 25 + "playlists", 26 + sa.Column( 27 + "show_on_profile", sa.Boolean(), nullable=False, server_default="false" 28 + ), 29 + ) 30 + 31 + 32 + def downgrade() -> None: 33 + """Downgrade schema.""" 34 + op.drop_column("playlists", "show_on_profile")
+3
backend/pyproject.toml
··· 76 76 "RELAY_TEST_MODE=1", 77 77 "OAUTH_ENCRYPTION_KEY=hnSkDmgbbuK0rt7Ab3eJHAktb18gmebsdwKdTmq9mes=", 78 78 "LOGFIRE_IGNORE_NO_CONFIG=1", 79 + # reduce connection pool for tests to avoid exhausting Neon's connection limit 80 + "DATABASE_POOL_SIZE=2", 81 + "DATABASE_MAX_OVERFLOW=0", 79 82 ] 80 83 markers = [ 81 84 "integration: marks tests as integration tests (deselect with '-m \"not integration\"')",
+14 -2
backend/src/backend/_internal/__init__.py
··· 3 3 from backend._internal.auth import ( 4 4 DeveloperToken, 5 5 PendingDevTokenData, 6 + PendingScopeUpgradeData, 6 7 Session, 7 8 check_artist_profile_exists, 8 9 consume_exchange_token, 9 10 create_exchange_token, 10 11 create_session, 11 12 delete_pending_dev_token, 13 + delete_pending_scope_upgrade, 12 14 delete_session, 15 + get_oauth_client, 16 + get_optional_session, 13 17 get_pending_dev_token, 18 + get_pending_scope_upgrade, 14 19 get_session, 15 20 handle_oauth_callback, 16 21 list_developer_tokens, 17 - oauth_client, 18 22 require_artist_profile, 19 23 require_auth, 20 24 revoke_developer_token, 21 25 save_pending_dev_token, 26 + save_pending_scope_upgrade, 22 27 start_oauth_flow, 28 + start_oauth_flow_with_scopes, 23 29 update_session_tokens, 24 30 ) 25 31 from backend._internal.constellation import get_like_count_safe ··· 30 36 __all__ = [ 31 37 "DeveloperToken", 32 38 "PendingDevTokenData", 39 + "PendingScopeUpgradeData", 33 40 "Session", 34 41 "check_artist_profile_exists", 35 42 "consume_exchange_token", 36 43 "create_exchange_token", 37 44 "create_session", 38 45 "delete_pending_dev_token", 46 + "delete_pending_scope_upgrade", 39 47 "delete_session", 40 48 "get_like_count_safe", 49 + "get_oauth_client", 50 + "get_optional_session", 41 51 "get_pending_dev_token", 52 + "get_pending_scope_upgrade", 42 53 "get_session", 43 54 "handle_oauth_callback", 44 55 "list_developer_tokens", 45 56 "notification_service", 46 57 "now_playing_service", 47 - "oauth_client", 48 58 "queue_service", 49 59 "require_artist_profile", 50 60 "require_auth", 51 61 "revoke_developer_token", 52 62 "save_pending_dev_token", 63 + "save_pending_scope_upgrade", 53 64 "start_oauth_flow", 65 + "start_oauth_flow_with_scopes", 54 66 "update_session_tokens", 55 67 ]
+10
backend/src/backend/_internal/atproto/__init__.py
··· 8 8 from backend._internal.atproto.records import ( 9 9 create_comment_record, 10 10 create_like_record, 11 + create_list_record, 11 12 create_track_record, 12 13 delete_record_by_uri, 13 14 update_comment_record, 15 + update_list_record, 16 + upsert_album_list_record, 17 + upsert_liked_list_record, 18 + upsert_profile_record, 14 19 ) 15 20 16 21 __all__ = [ 17 22 "create_comment_record", 18 23 "create_like_record", 24 + "create_list_record", 19 25 "create_track_record", 20 26 "delete_record_by_uri", 21 27 "fetch_user_avatar", 22 28 "fetch_user_profile", 23 29 "normalize_avatar_url", 24 30 "update_comment_record", 31 + "update_list_record", 32 + "upsert_album_list_record", 33 + "upsert_liked_list_record", 34 + "upsert_profile_record", 25 35 ]
+310 -3
backend/src/backend/_internal/atproto/records.py
··· 9 9 from atproto_oauth.models import OAuthSession 10 10 11 11 from backend._internal import Session as AuthSession 12 - from backend._internal import get_session, oauth_client, update_session_tokens 12 + from backend._internal import get_oauth_client, get_session, update_session_tokens 13 13 from backend.config import settings 14 14 15 15 logger = logging.getLogger(__name__) ··· 96 96 97 97 try: 98 98 # use OAuth client to refresh tokens 99 - refreshed_session = await oauth_client.refresh_session( 99 + refreshed_session = await get_oauth_client().refresh_session( 100 100 current_oauth_session 101 101 ) 102 102 ··· 183 183 url = f"{oauth_data['pds_url']}/xrpc/{endpoint}" 184 184 185 185 for attempt in range(2): 186 - response = await oauth_client.make_authenticated_request( 186 + response = await get_oauth_client().make_authenticated_request( 187 187 session=oauth_session, 188 188 method=method, 189 189 url=url, ··· 485 485 return result["uri"] 486 486 487 487 488 + def build_list_record( 489 + items: list[dict[str, str]], 490 + name: str | None = None, 491 + list_type: str | None = None, 492 + created_at: datetime | None = None, 493 + updated_at: datetime | None = None, 494 + ) -> dict[str, Any]: 495 + """Build a list record dict for ATProto. 496 + 497 + args: 498 + items: list of record references, each with {"uri": str, "cid": str} 499 + name: optional display name 500 + list_type: optional semantic type (e.g., "album", "playlist", "liked") 501 + created_at: creation timestamp (defaults to now) 502 + updated_at: optional last modification timestamp 503 + 504 + returns: 505 + record dict ready for ATProto 506 + """ 507 + record: dict[str, Any] = { 508 + "$type": settings.atproto.list_collection, 509 + "items": [ 510 + {"subject": {"uri": item["uri"], "cid": item["cid"]}} for item in items 511 + ], 512 + "createdAt": (created_at or datetime.now(UTC)) 513 + .isoformat() 514 + .replace("+00:00", "Z"), 515 + } 516 + 517 + if name: 518 + record["name"] = name 519 + if list_type: 520 + record["listType"] = list_type 521 + if updated_at: 522 + record["updatedAt"] = updated_at.isoformat().replace("+00:00", "Z") 523 + 524 + return record 525 + 526 + 527 + async def create_list_record( 528 + auth_session: AuthSession, 529 + items: list[dict[str, str]], 530 + name: str | None = None, 531 + list_type: str | None = None, 532 + ) -> tuple[str, str]: 533 + """Create a list record on the user's PDS. 534 + 535 + args: 536 + auth_session: authenticated user session 537 + items: list of record references, each with {"uri": str, "cid": str} 538 + name: optional display name 539 + list_type: optional semantic type (e.g., "album", "playlist", "liked") 540 + 541 + returns: 542 + tuple of (record_uri, record_cid) 543 + """ 544 + record = build_list_record(items=items, name=name, list_type=list_type) 545 + 546 + payload = { 547 + "repo": auth_session.did, 548 + "collection": settings.atproto.list_collection, 549 + "record": record, 550 + } 551 + 552 + result = await _make_pds_request( 553 + auth_session, "POST", "com.atproto.repo.createRecord", payload 554 + ) 555 + return result["uri"], result["cid"] 556 + 557 + 558 + async def update_list_record( 559 + auth_session: AuthSession, 560 + list_uri: str, 561 + items: list[dict[str, str]], 562 + name: str | None = None, 563 + list_type: str | None = None, 564 + created_at: datetime | None = None, 565 + ) -> tuple[str, str]: 566 + """Update an existing list record on the user's PDS. 567 + 568 + args: 569 + auth_session: authenticated user session 570 + list_uri: AT URI of the list record to update 571 + items: list of record references (array order = display order) 572 + name: optional display name 573 + list_type: optional semantic type (e.g., "album", "playlist", "liked") 574 + created_at: original creation timestamp (preserved on updates) 575 + 576 + returns: 577 + tuple of (record_uri, new_record_cid) 578 + """ 579 + record = build_list_record( 580 + items=items, 581 + name=name, 582 + list_type=list_type, 583 + created_at=created_at, 584 + updated_at=datetime.now(UTC), 585 + ) 586 + 587 + return await update_record( 588 + auth_session=auth_session, 589 + record_uri=list_uri, 590 + record=record, 591 + ) 592 + 593 + 488 594 async def update_comment_record( 489 595 auth_session: AuthSession, 490 596 comment_uri: str, ··· 532 638 record=record, 533 639 ) 534 640 return new_cid 641 + 642 + 643 + def build_profile_record( 644 + bio: str | None = None, 645 + created_at: datetime | None = None, 646 + updated_at: datetime | None = None, 647 + ) -> dict[str, Any]: 648 + """Build a profile record dict for ATProto. 649 + 650 + args: 651 + bio: artist bio/description 652 + created_at: creation timestamp (defaults to now) 653 + updated_at: optional last modification timestamp 654 + 655 + returns: 656 + record dict ready for ATProto 657 + """ 658 + record: dict[str, Any] = { 659 + "$type": settings.atproto.profile_collection, 660 + "createdAt": (created_at or datetime.now(UTC)) 661 + .isoformat() 662 + .replace("+00:00", "Z"), 663 + } 664 + 665 + if bio: 666 + record["bio"] = bio 667 + if updated_at: 668 + record["updatedAt"] = updated_at.isoformat().replace("+00:00", "Z") 669 + 670 + return record 671 + 672 + 673 + async def upsert_profile_record( 674 + auth_session: AuthSession, 675 + bio: str | None = None, 676 + ) -> tuple[str, str] | None: 677 + """Create or update the user's plyr.fm profile record. 678 + 679 + uses putRecord with rkey="self" for upsert semantics - creates if 680 + doesn't exist, updates if it does. skips write if record already 681 + exists with the same bio (no-op for unchanged data). 682 + 683 + args: 684 + auth_session: authenticated user session 685 + bio: artist bio/description 686 + 687 + returns: 688 + tuple of (record_uri, record_cid) or None if skipped (unchanged) 689 + """ 690 + # check if profile already exists to preserve createdAt and skip if unchanged 691 + existing_created_at = None 692 + existing_bio = None 693 + existing_uri = None 694 + existing_cid = None 695 + 696 + try: 697 + # try to get existing record 698 + oauth_data = auth_session.oauth_session 699 + if oauth_data and "pds_url" in oauth_data: 700 + oauth_session = _reconstruct_oauth_session(oauth_data) 701 + url = f"{oauth_data['pds_url']}/xrpc/com.atproto.repo.getRecord" 702 + params = { 703 + "repo": auth_session.did, 704 + "collection": settings.atproto.profile_collection, 705 + "rkey": "self", 706 + } 707 + response = await get_oauth_client().make_authenticated_request( 708 + session=oauth_session, 709 + method="GET", 710 + url=url, 711 + params=params, 712 + ) 713 + if response.status_code == 200: 714 + existing = response.json() 715 + existing_uri = existing.get("uri") 716 + existing_cid = existing.get("cid") 717 + if "value" in existing: 718 + existing_bio = existing["value"].get("bio") 719 + if "createdAt" in existing["value"]: 720 + existing_created_at = datetime.fromisoformat( 721 + existing["value"]["createdAt"].replace("Z", "+00:00") 722 + ) 723 + except Exception: 724 + # record doesn't exist yet, that's fine 725 + pass 726 + 727 + # skip write if record exists with same bio (no changes needed) 728 + if existing_uri and existing_cid and existing_bio == bio: 729 + return None 730 + 731 + record = build_profile_record( 732 + bio=bio, 733 + created_at=existing_created_at, 734 + updated_at=datetime.now(UTC) if existing_created_at else None, 735 + ) 736 + 737 + payload = { 738 + "repo": auth_session.did, 739 + "collection": settings.atproto.profile_collection, 740 + "rkey": "self", 741 + "record": record, 742 + } 743 + 744 + result = await _make_pds_request( 745 + auth_session, "POST", "com.atproto.repo.putRecord", payload 746 + ) 747 + return result["uri"], result["cid"] 748 + 749 + 750 + async def upsert_album_list_record( 751 + auth_session: AuthSession, 752 + album_id: str, 753 + album_title: str, 754 + track_refs: list[dict[str, str]], 755 + existing_uri: str | None = None, 756 + existing_created_at: datetime | None = None, 757 + ) -> tuple[str, str] | None: 758 + """Create or update an album as a list record. 759 + 760 + args: 761 + auth_session: authenticated user session 762 + album_id: internal album ID (for logging) 763 + album_title: album display name 764 + track_refs: list of track references [{"uri": str, "cid": str}, ...] 765 + existing_uri: existing ATProto record URI if updating 766 + existing_created_at: original creation timestamp to preserve 767 + 768 + returns: 769 + tuple of (record_uri, record_cid) or None if no tracks to sync 770 + """ 771 + if not track_refs: 772 + logger.debug(f"album {album_id} has no tracks with ATProto records, skipping") 773 + return None 774 + 775 + if existing_uri: 776 + # update existing record 777 + uri, cid = await update_list_record( 778 + auth_session=auth_session, 779 + list_uri=existing_uri, 780 + items=track_refs, 781 + name=album_title, 782 + list_type="album", 783 + created_at=existing_created_at, 784 + ) 785 + logger.info(f"updated album list record for {album_id}: {uri}") 786 + return uri, cid 787 + else: 788 + # create new record 789 + uri, cid = await create_list_record( 790 + auth_session=auth_session, 791 + items=track_refs, 792 + name=album_title, 793 + list_type="album", 794 + ) 795 + logger.info(f"created album list record for {album_id}: {uri}") 796 + return uri, cid 797 + 798 + 799 + async def upsert_liked_list_record( 800 + auth_session: AuthSession, 801 + track_refs: list[dict[str, str]], 802 + existing_uri: str | None = None, 803 + existing_created_at: datetime | None = None, 804 + ) -> tuple[str, str] | None: 805 + """Create or update the user's liked tracks list record. 806 + 807 + args: 808 + auth_session: authenticated user session 809 + track_refs: list of liked track references [{"uri": str, "cid": str}, ...] 810 + existing_uri: existing ATProto record URI if updating 811 + existing_created_at: original creation timestamp to preserve 812 + 813 + returns: 814 + tuple of (record_uri, record_cid) or None if no likes to sync 815 + """ 816 + if not track_refs: 817 + logger.debug(f"user {auth_session.did} has no liked tracks to sync") 818 + return None 819 + 820 + if existing_uri: 821 + # update existing record 822 + uri, cid = await update_list_record( 823 + auth_session=auth_session, 824 + list_uri=existing_uri, 825 + items=track_refs, 826 + name="Liked Tracks", 827 + list_type="liked", 828 + created_at=existing_created_at, 829 + ) 830 + logger.info(f"updated liked list record for {auth_session.did}: {uri}") 831 + return uri, cid 832 + else: 833 + # create new record 834 + uri, cid = await create_list_record( 835 + auth_session=auth_session, 836 + items=track_refs, 837 + name="Liked Tracks", 838 + list_type="liked", 839 + ) 840 + logger.info(f"created liked list record for {auth_session.did}: {uri}") 841 + return uri, cid
+144 -24
backend/src/backend/_internal/auth.py
··· 69 69 _state_store = PostgresStateStore() 70 70 _session_store = MemorySessionStore() 71 71 72 - # OAuth clients - base client for normal auth, teal client for users who want scrobbling 73 - oauth_client = OAuthClient( 74 - client_id=settings.atproto.client_id, 75 - redirect_uri=settings.atproto.redirect_uri, 76 - scope=settings.atproto.resolved_scope, 77 - state_store=_state_store, 78 - session_store=_session_store, 79 - ) 80 72 81 - oauth_client_with_teal = OAuthClient( 82 - client_id=settings.atproto.client_id, 83 - redirect_uri=settings.atproto.redirect_uri, 84 - scope=settings.atproto.resolved_scope_with_teal( 85 - settings.teal.play_collection, settings.teal.status_collection 86 - ), 87 - state_store=_state_store, 88 - session_store=_session_store, 89 - ) 73 + def get_oauth_client(include_teal: bool = False) -> OAuthClient: 74 + """create an OAuth client with the appropriate scopes. 75 + 76 + at ~17 OAuth flows/day, instantiation cost is negligible. 77 + this eliminates the need for pre-instantiated bifurcated clients. 78 + """ 79 + scope = ( 80 + settings.atproto.resolved_scope_with_teal( 81 + settings.teal.play_collection, settings.teal.status_collection 82 + ) 83 + if include_teal 84 + else settings.atproto.resolved_scope 85 + ) 86 + return OAuthClient( 87 + client_id=settings.atproto.client_id, 88 + redirect_uri=settings.atproto.redirect_uri, 89 + scope=scope, 90 + state_store=_state_store, 91 + session_store=_session_store, 92 + ) 90 93 91 94 92 95 def get_oauth_client_for_scope(scope: str) -> OAuthClient: 93 - """get the appropriate OAuth client for a given scope string.""" 94 - if settings.teal.play_collection in scope: 95 - return oauth_client_with_teal 96 - return oauth_client 96 + """get an OAuth client matching a given scope string. 97 + 98 + used during callback to match the scope that was used during authorization. 99 + """ 100 + include_teal = settings.teal.play_collection in scope 101 + return get_oauth_client(include_teal=include_teal) 97 102 98 103 99 104 # encryption for sensitive OAuth data at rest ··· 254 259 if resolved: 255 260 did = resolved["did"] 256 261 wants_teal = await _check_teal_preference(did) 257 - client = oauth_client_with_teal if wants_teal else oauth_client 262 + client = get_oauth_client(include_teal=wants_teal) 258 263 logger.info(f"starting OAuth for {handle} (did={did}, teal={wants_teal})") 259 264 else: 260 265 # fallback to base client if resolution fails 261 266 # (OAuth flow will resolve handle again internally) 262 - client = oauth_client 267 + client = get_oauth_client(include_teal=False) 263 268 logger.info(f"starting OAuth for {handle} (resolution failed, using base)") 264 269 265 270 auth_url, state = await client.start_authorization(handle) ··· 271 276 ) from e 272 277 273 278 279 + async def start_oauth_flow_with_scopes( 280 + handle: str, include_teal: bool 281 + ) -> tuple[str, str]: 282 + """start OAuth flow with explicit scope selection. 283 + 284 + unlike start_oauth_flow which checks user preferences, this explicitly 285 + requests the specified scopes. used for scope upgrade flows. 286 + """ 287 + try: 288 + client = get_oauth_client(include_teal=include_teal) 289 + logger.info(f"starting scope upgrade OAuth for {handle} (teal={include_teal})") 290 + auth_url, state = await client.start_authorization(handle) 291 + return auth_url, state 292 + except Exception as e: 293 + raise HTTPException( 294 + status_code=400, 295 + detail=f"failed to start OAuth flow: {e}", 296 + ) from e 297 + 298 + 274 299 async def handle_oauth_callback( 275 300 code: str, state: str, iss: str 276 301 ) -> tuple[str, str, dict]: ··· 287 312 ) 288 313 else: 289 314 # fallback to base client (state might have been cleaned up) 290 - client = oauth_client 315 + client = get_oauth_client(include_teal=False) 291 316 logger.warning(f"state {state[:8]}... not found, using base client") 292 317 293 318 oauth_session = await client.handle_callback( ··· 453 478 return session 454 479 455 480 481 + async def get_optional_session( 482 + authorization: Annotated[str | None, Header()] = None, 483 + session_id: Annotated[str | None, Cookie(alias="session_id")] = None, 484 + ) -> Session | None: 485 + """fastapi dependency to optionally get the current session. 486 + 487 + returns None if not authenticated, otherwise returns the session. 488 + useful for public endpoints that show additional info for logged-in users. 489 + """ 490 + session_id_value = None 491 + 492 + if session_id: 493 + session_id_value = session_id 494 + elif authorization and authorization.startswith("Bearer "): 495 + session_id_value = authorization.removeprefix("Bearer ") 496 + 497 + if not session_id_value: 498 + return None 499 + 500 + return await get_session(session_id_value) 501 + 502 + 456 503 async def require_artist_profile( 457 504 authorization: Annotated[str | None, Header()] = None, 458 505 session_id: Annotated[str | None, Cookie(alias="session_id")] = None, ··· 595 642 if pending := result.scalar_one_or_none(): 596 643 await db.delete(pending) 597 644 await db.commit() 645 + 646 + 647 + # scope upgrade flow helpers 648 + 649 + 650 + @dataclass 651 + class PendingScopeUpgradeData: 652 + """metadata for a pending scope upgrade OAuth flow.""" 653 + 654 + state: str 655 + did: str 656 + old_session_id: str 657 + requested_scopes: str 658 + 659 + 660 + async def save_pending_scope_upgrade( 661 + state: str, 662 + did: str, 663 + old_session_id: str, 664 + requested_scopes: str, 665 + ) -> None: 666 + """save pending scope upgrade metadata keyed by OAuth state.""" 667 + from backend.models import PendingScopeUpgrade 668 + 669 + async with db_session() as db: 670 + pending = PendingScopeUpgrade( 671 + state=state, 672 + did=did, 673 + old_session_id=old_session_id, 674 + requested_scopes=requested_scopes, 675 + ) 676 + db.add(pending) 677 + await db.commit() 678 + 679 + 680 + async def get_pending_scope_upgrade(state: str) -> PendingScopeUpgradeData | None: 681 + """get pending scope upgrade metadata by OAuth state.""" 682 + from backend.models import PendingScopeUpgrade 683 + 684 + async with db_session() as db: 685 + result = await db.execute( 686 + select(PendingScopeUpgrade).where(PendingScopeUpgrade.state == state) 687 + ) 688 + pending = result.scalar_one_or_none() 689 + 690 + if not pending: 691 + return None 692 + 693 + # check if expired 694 + if datetime.now(UTC) > pending.expires_at: 695 + await db.delete(pending) 696 + await db.commit() 697 + return None 698 + 699 + return PendingScopeUpgradeData( 700 + state=pending.state, 701 + did=pending.did, 702 + old_session_id=pending.old_session_id, 703 + requested_scopes=pending.requested_scopes, 704 + ) 705 + 706 + 707 + async def delete_pending_scope_upgrade(state: str) -> None: 708 + """delete pending scope upgrade metadata after use.""" 709 + from backend.models import PendingScopeUpgrade 710 + 711 + async with db_session() as db: 712 + result = await db.execute( 713 + select(PendingScopeUpgrade).where(PendingScopeUpgrade.state == state) 714 + ) 715 + if pending := result.scalar_one_or_none(): 716 + await db.delete(pending) 717 + await db.commit()
+2
backend/src/backend/_internal/image.py
··· 7 7 """supported image formats.""" 8 8 9 9 JPEG = "jpg" 10 + JPEG_ALT = "jpeg" 10 11 PNG = "png" 11 12 WEBP = "webp" 12 13 GIF = "gif" ··· 16 17 """get HTTP media type for this format.""" 17 18 return { 18 19 "jpg": "image/jpeg", 20 + "jpeg": "image/jpeg", 19 21 "png": "image/png", 20 22 "webp": "image/webp", 21 23 "gif": "image/gif",
+2
backend/src/backend/api/__init__.py
··· 13 13 from backend.api.search import router as search_router 14 14 from backend.api.stats import router as stats_router 15 15 from backend.api.tracks import router as tracks_router 16 + from backend.api.users import router as users_router 16 17 17 18 __all__ = [ 18 19 "account_router", ··· 28 29 "search_router", 29 30 "stats_router", 30 31 "tracks_router", 32 + "users_router", 31 33 ]
+4
backend/src/backend/api/albums.py
··· 38 38 description: str | None = None 39 39 artist: str 40 40 artist_handle: str 41 + artist_did: str 41 42 track_count: int 42 43 total_plays: int 43 44 image_url: str | None 45 + list_uri: str | None = None # ATProto list record URI for reordering 44 46 45 47 46 48 class AlbumResponse(BaseModel): ··· 158 160 description=album.description, 159 161 artist=artist.display_name, 160 162 artist_handle=artist.handle, 163 + artist_did=artist.did, 161 164 track_count=track_count, 162 165 total_plays=total_plays, 163 166 image_url=image_url, 167 + list_uri=album.atproto_record_uri, 164 168 ) 165 169 166 170
+195 -4
backend/src/backend/api/artists.py
··· 1 1 """artist profile API endpoints.""" 2 2 3 + import asyncio 3 4 import logging 4 5 from datetime import UTC, datetime 5 6 from typing import Annotated ··· 10 11 from sqlalchemy.ext.asyncio import AsyncSession 11 12 12 13 from backend._internal import Session, require_auth 13 - from backend._internal.atproto import fetch_user_avatar, normalize_avatar_url 14 - from backend.models import Artist, Track, TrackLike, get_db 14 + from backend._internal.atproto import ( 15 + fetch_user_avatar, 16 + normalize_avatar_url, 17 + upsert_album_list_record, 18 + upsert_liked_list_record, 19 + upsert_profile_record, 20 + ) 21 + from backend.models import Album, Artist, Track, TrackLike, UserPreferences, get_db 22 + from backend.utilities.database import db_session 15 23 16 24 logger = logging.getLogger(__name__) 17 25 18 26 router = APIRouter(prefix="/artists", tags=["artists"]) 19 27 28 + # hold references to background tasks to prevent GC before completion 29 + _background_tasks: set[asyncio.Task[None]] = set() 30 + 31 + 32 + def _create_background_task(coro) -> asyncio.Task: 33 + """Create a background task with proper lifecycle management.""" 34 + task = asyncio.create_task(coro) 35 + _background_tasks.add(task) 36 + task.add_done_callback(_background_tasks.discard) 37 + return task 38 + 20 39 21 40 # request/response models 22 41 class CreateArtistRequest(BaseModel): ··· 47 66 avatar_url: str | None 48 67 created_at: datetime 49 68 updated_at: datetime 69 + show_liked_on_profile: bool = False 50 70 51 71 @field_validator("avatar_url", mode="before") 52 72 @classmethod ··· 123 143 logger.info( 124 144 f"created artist profile for {auth_session.did} (@{auth_session.handle})" 125 145 ) 146 + 147 + # create ATProto profile record if bio was provided 148 + if request.bio: 149 + try: 150 + await upsert_profile_record(auth_session, bio=request.bio) 151 + logger.info(f"created ATProto profile record for {auth_session.did}") 152 + except Exception as e: 153 + # don't fail the request if ATProto record creation fails 154 + logger.warning( 155 + f"failed to create ATProto profile record for {auth_session.did}: {e}" 156 + ) 157 + 126 158 return ArtistResponse.model_validate(artist) 127 159 128 160 ··· 139 171 status_code=404, 140 172 detail="artist profile not found - please create one first", 141 173 ) 174 + 175 + # fire-and-forget sync of ATProto profile record 176 + # creates record if doesn't exist, skips if unchanged 177 + async def _sync_profile(): 178 + try: 179 + result = await upsert_profile_record(auth_session, bio=artist.bio) 180 + if result: 181 + logger.info(f"synced ATProto profile record for {auth_session.did}") 182 + except Exception as e: 183 + logger.warning( 184 + f"failed to sync ATProto profile record for {auth_session.did}: {e}" 185 + ) 186 + 187 + _create_background_task(_sync_profile()) 188 + 189 + # fire-and-forget sync of album list records 190 + # query albums and their tracks, then sync each album as a list record 191 + albums_result = await db.execute( 192 + select(Album).where(Album.artist_did == auth_session.did) 193 + ) 194 + albums = albums_result.scalars().all() 195 + 196 + for album in albums: 197 + # get tracks for this album that have ATProto records 198 + tracks_result = await db.execute( 199 + select(Track) 200 + .where( 201 + Track.album_id == album.id, 202 + Track.atproto_record_uri.isnot(None), 203 + Track.atproto_record_cid.isnot(None), 204 + ) 205 + .order_by(Track.created_at.asc()) 206 + ) 207 + tracks = tracks_result.scalars().all() 208 + 209 + if tracks: 210 + track_refs = [ 211 + {"uri": t.atproto_record_uri, "cid": t.atproto_record_cid} 212 + for t in tracks 213 + ] 214 + # capture values for closure 215 + album_id = album.id 216 + album_title = album.title 217 + existing_uri = album.atproto_record_uri 218 + 219 + async def _sync_album( 220 + aid=album_id, title=album_title, refs=track_refs, uri=existing_uri 221 + ): 222 + try: 223 + result = await upsert_album_list_record( 224 + auth_session, 225 + album_id=aid, 226 + album_title=title, 227 + track_refs=refs, 228 + existing_uri=uri, 229 + ) 230 + if result: 231 + # persist the new URI/CID to the database 232 + async with db_session() as session: 233 + album_to_update = await session.get(Album, aid) 234 + if album_to_update: 235 + album_to_update.atproto_record_uri = result[0] 236 + album_to_update.atproto_record_cid = result[1] 237 + await session.commit() 238 + logger.info(f"synced album list record for {aid}: {result[0]}") 239 + except Exception as e: 240 + logger.warning(f"failed to sync album list record for {aid}: {e}") 241 + 242 + _create_background_task(_sync_album()) 243 + 244 + # fire-and-forget sync of liked tracks list record 245 + # query user's likes and sync as a single list record 246 + prefs_result = await db.execute( 247 + select(UserPreferences).where(UserPreferences.did == auth_session.did) 248 + ) 249 + prefs = prefs_result.scalar_one_or_none() 250 + 251 + likes_result = await db.execute( 252 + select(Track) 253 + .join(TrackLike, TrackLike.track_id == Track.id) 254 + .where( 255 + TrackLike.user_did == auth_session.did, 256 + Track.atproto_record_uri.isnot(None), 257 + Track.atproto_record_cid.isnot(None), 258 + ) 259 + .order_by(TrackLike.created_at.desc()) 260 + ) 261 + liked_tracks = likes_result.scalars().all() 262 + 263 + if liked_tracks: 264 + liked_refs = [ 265 + {"uri": t.atproto_record_uri, "cid": t.atproto_record_cid} 266 + for t in liked_tracks 267 + ] 268 + existing_liked_uri = prefs.liked_list_uri if prefs else None 269 + user_did = auth_session.did 270 + 271 + async def _sync_liked(): 272 + try: 273 + result = await upsert_liked_list_record( 274 + auth_session, 275 + track_refs=liked_refs, 276 + existing_uri=existing_liked_uri, 277 + ) 278 + if result: 279 + # persist the new URI/CID to user preferences 280 + async with db_session() as session: 281 + user_prefs = await session.get(UserPreferences, user_did) 282 + if user_prefs: 283 + user_prefs.liked_list_uri = result[0] 284 + user_prefs.liked_list_cid = result[1] 285 + await session.commit() 286 + else: 287 + # create preferences if they don't exist 288 + new_prefs = UserPreferences( 289 + did=user_did, 290 + liked_list_uri=result[0], 291 + liked_list_cid=result[1], 292 + ) 293 + session.add(new_prefs) 294 + await session.commit() 295 + logger.info(f"synced liked list record for {user_did}: {result[0]}") 296 + except Exception as e: 297 + logger.warning(f"failed to sync liked list record for {user_did}: {e}") 298 + 299 + _create_background_task(_sync_liked()) 300 + 142 301 return ArtistResponse.model_validate(artist) 143 302 144 303 ··· 170 329 await db.refresh(artist) 171 330 172 331 logger.info(f"updated artist profile for {auth_session.did}") 332 + 333 + # update ATProto profile record if bio was changed 334 + if request.bio is not None: 335 + try: 336 + await upsert_profile_record(auth_session, bio=request.bio) 337 + logger.info(f"updated ATProto profile record for {auth_session.did}") 338 + except Exception as e: 339 + # don't fail the request if ATProto record update fails 340 + logger.warning( 341 + f"failed to update ATProto profile record for {auth_session.did}: {e}" 342 + ) 343 + 173 344 return ArtistResponse.model_validate(artist) 174 345 175 346 ··· 182 353 artist = result.scalar_one_or_none() 183 354 if not artist: 184 355 raise HTTPException(status_code=404, detail="artist not found") 185 - return ArtistResponse.model_validate(artist) 356 + 357 + # fetch user preference for showing liked tracks 358 + prefs_result = await db.execute( 359 + select(UserPreferences).where(UserPreferences.did == artist.did) 360 + ) 361 + prefs = prefs_result.scalar_one_or_none() 362 + show_liked = prefs.show_liked_on_profile if prefs else False 363 + 364 + response = ArtistResponse.model_validate(artist) 365 + response.show_liked_on_profile = show_liked 366 + return response 186 367 187 368 188 369 @router.get("/{did}") ··· 194 375 artist = result.scalar_one_or_none() 195 376 if not artist: 196 377 raise HTTPException(status_code=404, detail="artist not found") 197 - return ArtistResponse.model_validate(artist) 378 + 379 + # fetch user preference for showing liked tracks 380 + prefs_result = await db.execute( 381 + select(UserPreferences).where(UserPreferences.did == artist.did) 382 + ) 383 + prefs = prefs_result.scalar_one_or_none() 384 + show_liked = prefs.show_liked_on_profile if prefs else False 385 + 386 + response = ArtistResponse.model_validate(artist) 387 + response.show_liked_on_profile = show_liked 388 + return response 198 389 199 390 200 391 @router.get("/{artist_did}/analytics")
+87 -3
backend/src/backend/api/auth.py
··· 14 14 create_exchange_token, 15 15 create_session, 16 16 delete_pending_dev_token, 17 + delete_pending_scope_upgrade, 17 18 delete_session, 18 19 get_pending_dev_token, 20 + get_pending_scope_upgrade, 19 21 handle_oauth_callback, 20 22 list_developer_tokens, 21 23 require_auth, 22 24 revoke_developer_token, 23 25 save_pending_dev_token, 26 + save_pending_scope_upgrade, 24 27 start_oauth_flow, 28 + start_oauth_flow_with_scopes, 25 29 ) 26 30 from backend.config import settings 27 31 from backend.utilities.rate_limit import limiter ··· 76 80 returns exchange token in URL which frontend will exchange for session_id. 77 81 exchange token is short-lived (60s) and one-time use for security. 78 82 79 - if this is a developer token flow (state exists in pending_dev_tokens), 80 - creates a dev token session and redirects with dev_token=true flag. 83 + handles three flow types based on pending state: 84 + 1. developer token flow - creates dev token session, redirects with dev_token=true 85 + 2. scope upgrade flow - replaces old session with new one, redirects to settings 86 + 3. regular login flow - creates session, redirects to portal or profile setup 81 87 """ 82 88 did, handle, oauth_session = await handle_oauth_callback(code, state, iss) 83 89 ··· 109 115 exchange_token = await create_exchange_token(session_id, is_dev_token=True) 110 116 111 117 return RedirectResponse( 112 - url=f"{settings.frontend.url}/portal?exchange_token={exchange_token}&dev_token=true", 118 + url=f"{settings.frontend.url}/settings?exchange_token={exchange_token}&dev_token=true", 119 + status_code=303, 120 + ) 121 + 122 + # check if this is a scope upgrade OAuth flow 123 + pending_scope_upgrade = await get_pending_scope_upgrade(state) 124 + 125 + if pending_scope_upgrade: 126 + # verify the DID matches (user must be the one who started the flow) 127 + if pending_scope_upgrade.did != did: 128 + raise HTTPException( 129 + status_code=403, 130 + detail="scope upgrade flow was started by a different user", 131 + ) 132 + 133 + # delete the old session 134 + await delete_session(pending_scope_upgrade.old_session_id) 135 + 136 + # create new session with upgraded scopes 137 + session_id = await create_session(did, handle, oauth_session) 138 + 139 + # clean up pending record 140 + await delete_pending_scope_upgrade(state) 141 + 142 + # create exchange token - NOT marked as dev token so cookie gets set 143 + exchange_token = await create_exchange_token(session_id) 144 + 145 + return RedirectResponse( 146 + url=f"{settings.frontend.url}/settings?exchange_token={exchange_token}&scope_upgraded=true", 113 147 status_code=303, 114 148 ) 115 149 ··· 328 362 ) 329 363 330 364 return DevTokenStartResponse(auth_url=auth_url) 365 + 366 + 367 + class ScopeUpgradeStartRequest(BaseModel): 368 + """request model for starting scope upgrade OAuth flow.""" 369 + 370 + # for now, only teal scopes are supported 371 + include_teal: bool = True 372 + 373 + 374 + class ScopeUpgradeStartResponse(BaseModel): 375 + """response model with OAuth authorization URL.""" 376 + 377 + auth_url: str 378 + 379 + 380 + @router.post("/scope-upgrade/start") 381 + @limiter.limit(settings.rate_limit.auth_limit) 382 + async def start_scope_upgrade_flow( 383 + request: Request, 384 + body: ScopeUpgradeStartRequest, 385 + session: Session = Depends(require_auth), 386 + ) -> ScopeUpgradeStartResponse: 387 + """start OAuth flow to upgrade session scopes. 388 + 389 + this initiates a new OAuth authorization flow with expanded scopes. 390 + the user will be redirected to authorize, and on callback the old session 391 + will be replaced with a new session that has the requested scopes. 392 + 393 + use this when a user enables a feature that requires additional OAuth scopes 394 + (e.g., enabling teal.fm scrobbling which needs fm.teal.alpha.* scopes). 395 + 396 + returns the authorization URL that the frontend should redirect to. 397 + """ 398 + # start OAuth flow with the requested scopes 399 + auth_url, state = await start_oauth_flow_with_scopes( 400 + session.handle, include_teal=body.include_teal 401 + ) 402 + 403 + # build the requested scopes string for logging/tracking 404 + requested_scopes = "teal" if body.include_teal else "base" 405 + 406 + # save pending scope upgrade metadata keyed by state 407 + await save_pending_scope_upgrade( 408 + state=state, 409 + did=session.did, 410 + old_session_id=session.session_id, 411 + requested_scopes=requested_scopes, 412 + ) 413 + 414 + return ScopeUpgradeStartResponse(auth_url=auth_url)
+868
backend/src/backend/api/lists.py
··· 1 + """lists api endpoints for ATProto list records.""" 2 + 3 + import contextlib 4 + import logging 5 + from io import BytesIO 6 + from pathlib import Path 7 + from typing import Annotated 8 + 9 + from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile 10 + from pydantic import BaseModel 11 + from sqlalchemy import select 12 + from sqlalchemy.ext.asyncio import AsyncSession 13 + 14 + from backend._internal import Session as AuthSession 15 + from backend._internal import require_auth 16 + from backend._internal.atproto.records import ( 17 + _reconstruct_oauth_session, 18 + _refresh_session_tokens, 19 + create_list_record, 20 + update_list_record, 21 + ) 22 + from backend.models import Artist, Playlist, Track, TrackLike, UserPreferences, get_db 23 + from backend.schemas import TrackResponse 24 + from backend.storage import storage 25 + from backend.utilities.aggregations import get_comment_counts, get_like_counts 26 + from backend.utilities.hashing import CHUNK_SIZE 27 + 28 + logger = logging.getLogger(__name__) 29 + 30 + 31 + # --- playlist schemas --- 32 + 33 + 34 + class CreatePlaylistRequest(BaseModel): 35 + """request body for creating a playlist.""" 36 + 37 + name: str 38 + """display name for the playlist.""" 39 + 40 + 41 + class PlaylistResponse(BaseModel): 42 + """playlist metadata response.""" 43 + 44 + id: str 45 + name: str 46 + owner_did: str 47 + owner_handle: str 48 + track_count: int 49 + image_url: str | None 50 + show_on_profile: bool 51 + atproto_record_uri: str 52 + created_at: str 53 + 54 + 55 + class PlaylistWithTracksResponse(PlaylistResponse): 56 + """playlist with full track details.""" 57 + 58 + tracks: list[TrackResponse] 59 + """ordered list of track details.""" 60 + 61 + 62 + class AddTrackRequest(BaseModel): 63 + """request body for adding a track to a playlist.""" 64 + 65 + track_uri: str 66 + """ATProto URI of the track to add.""" 67 + track_cid: str 68 + """CID of the track to add.""" 69 + 70 + 71 + router = APIRouter(prefix="/lists", tags=["lists"]) 72 + 73 + 74 + class ReorderRequest(BaseModel): 75 + """request body for reordering list items.""" 76 + 77 + items: list[dict[str, str]] 78 + """ordered array of strongRefs (uri + cid). array order = display order.""" 79 + 80 + 81 + class ReorderResponse(BaseModel): 82 + """response from reorder operation.""" 83 + 84 + uri: str 85 + cid: str 86 + 87 + 88 + @router.put("/liked/reorder") 89 + async def reorder_liked_list( 90 + body: ReorderRequest, 91 + session: AuthSession = Depends(require_auth), 92 + db: AsyncSession = Depends(get_db), 93 + ) -> ReorderResponse: 94 + """reorder items in the user's liked tracks list. 95 + 96 + the items array order becomes the new display order. 97 + only the list owner can reorder their own list. 98 + """ 99 + # get the user's liked list URI from preferences 100 + prefs_result = await db.execute( 101 + select(UserPreferences).where(UserPreferences.did == session.did) 102 + ) 103 + prefs = prefs_result.scalar_one_or_none() 104 + 105 + if not prefs or not prefs.liked_list_uri: 106 + raise HTTPException( 107 + status_code=404, 108 + detail="liked list not found - try liking a track first", 109 + ) 110 + 111 + # reconstruct OAuth session for ATProto operations 112 + oauth_data = session.oauth_session 113 + if not oauth_data or "access_token" not in oauth_data: 114 + raise HTTPException(status_code=401, detail="invalid session") 115 + 116 + oauth_session = _reconstruct_oauth_session(oauth_data) 117 + 118 + # update the list record with new item order 119 + for attempt in range(2): 120 + try: 121 + uri, cid = await update_list_record( 122 + auth_session=session, 123 + list_uri=prefs.liked_list_uri, 124 + items=body.items, 125 + list_type="liked", 126 + ) 127 + return ReorderResponse(uri=uri, cid=cid) 128 + 129 + except Exception as e: 130 + error_str = str(e).lower() 131 + # token expired - refresh and retry 132 + if "expired" in error_str and attempt == 0: 133 + oauth_session = await _refresh_session_tokens(session, oauth_session) 134 + continue 135 + raise HTTPException( 136 + status_code=500, detail=f"failed to reorder list: {e}" 137 + ) from e 138 + 139 + raise HTTPException(status_code=500, detail="failed to reorder list after retry") 140 + 141 + 142 + @router.put("/{rkey}/reorder") 143 + async def reorder_list( 144 + rkey: str, 145 + body: ReorderRequest, 146 + session: AuthSession = Depends(require_auth), 147 + db: AsyncSession = Depends(get_db), 148 + ) -> ReorderResponse: 149 + """reorder items in a list by rkey. 150 + 151 + the items array order becomes the new display order. 152 + only the list owner can reorder their own list. 153 + 154 + the rkey is the last segment of the AT URI (at://did/collection/rkey). 155 + """ 156 + from backend.config import settings 157 + 158 + # construct the full AT URI 159 + list_uri = f"at://{session.did}/{settings.atproto.list_collection}/{rkey}" 160 + 161 + # reconstruct OAuth session for ATProto operations 162 + oauth_data = session.oauth_session 163 + if not oauth_data or "access_token" not in oauth_data: 164 + raise HTTPException(status_code=401, detail="invalid session") 165 + 166 + oauth_session = _reconstruct_oauth_session(oauth_data) 167 + 168 + # update the list record with new item order 169 + for attempt in range(2): 170 + try: 171 + uri, cid = await update_list_record( 172 + auth_session=session, 173 + list_uri=list_uri, 174 + items=body.items, 175 + ) 176 + return ReorderResponse(uri=uri, cid=cid) 177 + 178 + except Exception as e: 179 + error_str = str(e).lower() 180 + # token expired - refresh and retry 181 + if "expired" in error_str and attempt == 0: 182 + oauth_session = await _refresh_session_tokens(session, oauth_session) 183 + continue 184 + raise HTTPException( 185 + status_code=500, detail=f"failed to reorder list: {e}" 186 + ) from e 187 + 188 + raise HTTPException(status_code=500, detail="failed to reorder generic list") 189 + 190 + 191 + # --- playlist CRUD endpoints --- 192 + 193 + 194 + @router.post("/playlists", response_model=PlaylistResponse) 195 + async def create_playlist( 196 + body: CreatePlaylistRequest, 197 + session: AuthSession = Depends(require_auth), 198 + db: AsyncSession = Depends(get_db), 199 + ) -> PlaylistResponse: 200 + """create a new playlist. 201 + 202 + creates an ATProto list record with listType="playlist" and caches 203 + metadata in the database for fast indexing. 204 + """ 205 + # create ATProto list record 206 + try: 207 + uri, cid = await create_list_record( 208 + auth_session=session, 209 + items=[], 210 + name=body.name, 211 + list_type="playlist", 212 + ) 213 + except Exception as e: 214 + raise HTTPException( 215 + status_code=500, detail=f"failed to create playlist: {e}" 216 + ) from e 217 + 218 + # get owner handle for response 219 + artist_result = await db.execute(select(Artist).where(Artist.did == session.did)) 220 + artist = artist_result.scalar_one_or_none() 221 + owner_handle = artist.handle if artist else session.handle 222 + 223 + # cache playlist in database 224 + playlist = Playlist( 225 + owner_did=session.did, 226 + name=body.name, 227 + atproto_record_uri=uri, 228 + atproto_record_cid=cid, 229 + track_count=0, 230 + ) 231 + db.add(playlist) 232 + await db.commit() 233 + await db.refresh(playlist) 234 + 235 + return PlaylistResponse( 236 + id=playlist.id, 237 + name=playlist.name, 238 + owner_did=playlist.owner_did, 239 + owner_handle=owner_handle, 240 + track_count=playlist.track_count, 241 + image_url=playlist.image_url, 242 + show_on_profile=playlist.show_on_profile, 243 + atproto_record_uri=playlist.atproto_record_uri, 244 + created_at=playlist.created_at.isoformat(), 245 + ) 246 + 247 + 248 + @router.get("/playlists", response_model=list[PlaylistResponse]) 249 + async def list_playlists( 250 + session: AuthSession = Depends(require_auth), 251 + db: AsyncSession = Depends(get_db), 252 + ) -> list[PlaylistResponse]: 253 + """list all playlists owned by the current user.""" 254 + result = await db.execute( 255 + select(Playlist, Artist) 256 + .join(Artist, Playlist.owner_did == Artist.did) 257 + .where(Playlist.owner_did == session.did) 258 + .order_by(Playlist.created_at.desc()) 259 + ) 260 + rows = result.all() 261 + 262 + return [ 263 + PlaylistResponse( 264 + id=playlist.id, 265 + name=playlist.name, 266 + owner_did=playlist.owner_did, 267 + owner_handle=artist.handle, 268 + track_count=playlist.track_count, 269 + image_url=playlist.image_url, 270 + show_on_profile=playlist.show_on_profile, 271 + atproto_record_uri=playlist.atproto_record_uri, 272 + created_at=playlist.created_at.isoformat(), 273 + ) 274 + for playlist, artist in rows 275 + ] 276 + 277 + 278 + @router.get("/playlists/by-artist/{artist_did}", response_model=list[PlaylistResponse]) 279 + async def list_artist_public_playlists( 280 + artist_did: str, 281 + db: AsyncSession = Depends(get_db), 282 + ) -> list[PlaylistResponse]: 283 + """list public playlists for an artist (no auth required). 284 + 285 + returns playlists where show_on_profile is true. 286 + used to display collections on artist profile pages. 287 + """ 288 + result = await db.execute( 289 + select(Playlist, Artist) 290 + .join(Artist, Playlist.owner_did == Artist.did) 291 + .where(Playlist.owner_did == artist_did) 292 + .where(Playlist.show_on_profile == True) # noqa: E712 293 + .order_by(Playlist.created_at.desc()) 294 + ) 295 + rows = result.all() 296 + 297 + return [ 298 + PlaylistResponse( 299 + id=playlist.id, 300 + name=playlist.name, 301 + owner_did=playlist.owner_did, 302 + owner_handle=artist.handle, 303 + track_count=playlist.track_count, 304 + image_url=playlist.image_url, 305 + show_on_profile=playlist.show_on_profile, 306 + atproto_record_uri=playlist.atproto_record_uri, 307 + created_at=playlist.created_at.isoformat(), 308 + ) 309 + for playlist, artist in rows 310 + ] 311 + 312 + 313 + @router.get("/playlists/{playlist_id}/meta", response_model=PlaylistResponse) 314 + async def get_playlist_meta( 315 + playlist_id: str, 316 + db: AsyncSession = Depends(get_db), 317 + ) -> PlaylistResponse: 318 + """get playlist metadata (public, no auth required). 319 + 320 + used for link previews and og tags. 321 + """ 322 + result = await db.execute( 323 + select(Playlist, Artist) 324 + .join(Artist, Playlist.owner_did == Artist.did) 325 + .where(Playlist.id == playlist_id) 326 + ) 327 + row = result.first() 328 + 329 + if not row: 330 + raise HTTPException(status_code=404, detail="playlist not found") 331 + 332 + playlist, artist = row 333 + 334 + return PlaylistResponse( 335 + id=playlist.id, 336 + name=playlist.name, 337 + owner_did=playlist.owner_did, 338 + owner_handle=artist.handle, 339 + track_count=playlist.track_count, 340 + image_url=playlist.image_url, 341 + show_on_profile=playlist.show_on_profile, 342 + atproto_record_uri=playlist.atproto_record_uri, 343 + created_at=playlist.created_at.isoformat(), 344 + ) 345 + 346 + 347 + @router.get("/playlists/{playlist_id}", response_model=PlaylistWithTracksResponse) 348 + async def get_playlist( 349 + playlist_id: str, 350 + session: AuthSession = Depends(require_auth), 351 + db: AsyncSession = Depends(get_db), 352 + ) -> PlaylistWithTracksResponse: 353 + """get a playlist with full track details. 354 + 355 + fetches the ATProto list record to get track ordering, then hydrates 356 + track metadata from the database. 357 + """ 358 + # get playlist from database 359 + result = await db.execute( 360 + select(Playlist, Artist) 361 + .join(Artist, Playlist.owner_did == Artist.did) 362 + .where(Playlist.id == playlist_id) 363 + ) 364 + row = result.first() 365 + 366 + if not row: 367 + raise HTTPException(status_code=404, detail="playlist not found") 368 + 369 + playlist, artist = row 370 + 371 + # fetch ATProto list record to get track ordering 372 + oauth_data = session.oauth_session 373 + if not oauth_data or "access_token" not in oauth_data: 374 + raise HTTPException(status_code=401, detail="invalid session") 375 + 376 + oauth_session = _reconstruct_oauth_session(oauth_data) 377 + from backend._internal import get_oauth_client 378 + 379 + # parse the AT URI to get repo and rkey 380 + parts = playlist.atproto_record_uri.replace("at://", "").split("/") 381 + if len(parts) != 3: 382 + raise HTTPException(status_code=500, detail="invalid playlist URI") 383 + 384 + repo, collection, rkey = parts 385 + 386 + # get the list record from PDS 387 + url = f"{oauth_data['pds_url']}/xrpc/com.atproto.repo.getRecord" 388 + params = {"repo": repo, "collection": collection, "rkey": rkey} 389 + 390 + response = await get_oauth_client().make_authenticated_request( 391 + session=oauth_session, 392 + method="GET", 393 + url=url, 394 + params=params, 395 + ) 396 + 397 + if response.status_code != 200: 398 + raise HTTPException(status_code=500, detail="failed to fetch playlist record") 399 + 400 + record_data = response.json() 401 + items = record_data.get("value", {}).get("items", []) 402 + 403 + # extract track URIs in order 404 + track_uris = [item.get("subject", {}).get("uri") for item in items] 405 + track_uris = [uri for uri in track_uris if uri] 406 + 407 + # hydrate track metadata from database 408 + tracks: list[TrackResponse] = [] 409 + if track_uris: 410 + from sqlalchemy.orm import selectinload 411 + 412 + track_result = await db.execute( 413 + select(Track) 414 + .options(selectinload(Track.artist), selectinload(Track.album_rel)) 415 + .where(Track.atproto_record_uri.in_(track_uris)) 416 + ) 417 + all_tracks = track_result.scalars().all() 418 + track_by_uri = {t.atproto_record_uri: t for t in all_tracks} 419 + 420 + # get track IDs for aggregation queries 421 + track_ids = [t.id for t in all_tracks] 422 + like_counts = await get_like_counts(db, track_ids) if track_ids else {} 423 + comment_counts = await get_comment_counts(db, track_ids) if track_ids else {} 424 + 425 + # get authenticated user's liked tracks 426 + liked_track_ids: set[int] = set() 427 + if track_ids: 428 + liked_result = await db.execute( 429 + select(TrackLike.track_id).where( 430 + TrackLike.user_did == session.did, 431 + TrackLike.track_id.in_(track_ids), 432 + ) 433 + ) 434 + liked_track_ids = set(liked_result.scalars().all()) 435 + 436 + # maintain ATProto ordering, skip unavailable tracks 437 + for uri in track_uris: 438 + if uri in track_by_uri: 439 + track = track_by_uri[uri] 440 + track_response = await TrackResponse.from_track( 441 + track, 442 + pds_url=oauth_data.get("pds_url"), 443 + liked_track_ids=liked_track_ids, 444 + like_counts=like_counts, 445 + comment_counts=comment_counts, 446 + ) 447 + tracks.append(track_response) 448 + # else: track exists in PDS list but not in our database - skip it 449 + 450 + return PlaylistWithTracksResponse( 451 + id=playlist.id, 452 + name=playlist.name, 453 + owner_did=playlist.owner_did, 454 + owner_handle=artist.handle, 455 + track_count=len(tracks), 456 + image_url=playlist.image_url, 457 + show_on_profile=playlist.show_on_profile, 458 + atproto_record_uri=playlist.atproto_record_uri, 459 + created_at=playlist.created_at.isoformat(), 460 + tracks=tracks, 461 + ) 462 + 463 + 464 + @router.post("/playlists/{playlist_id}/tracks", response_model=PlaylistResponse) 465 + async def add_track_to_playlist( 466 + playlist_id: str, 467 + body: AddTrackRequest, 468 + session: AuthSession = Depends(require_auth), 469 + db: AsyncSession = Depends(get_db), 470 + ) -> PlaylistResponse: 471 + """add a track to a playlist. 472 + 473 + appends the track to the end of the playlist's ATProto list record 474 + and updates the cached track count. 475 + """ 476 + # get playlist and verify ownership 477 + result = await db.execute( 478 + select(Playlist, Artist) 479 + .join(Artist, Playlist.owner_did == Artist.did) 480 + .where(Playlist.id == playlist_id) 481 + ) 482 + row = result.first() 483 + 484 + if not row: 485 + raise HTTPException(status_code=404, detail="playlist not found") 486 + 487 + playlist, artist = row 488 + 489 + if playlist.owner_did != session.did: 490 + raise HTTPException(status_code=403, detail="not playlist owner") 491 + 492 + # fetch current list record 493 + oauth_data = session.oauth_session 494 + if not oauth_data or "access_token" not in oauth_data: 495 + raise HTTPException(status_code=401, detail="invalid session") 496 + 497 + oauth_session = _reconstruct_oauth_session(oauth_data) 498 + from backend._internal import get_oauth_client 499 + 500 + parts = playlist.atproto_record_uri.replace("at://", "").split("/") 501 + repo, collection, rkey = parts 502 + 503 + url = f"{oauth_data['pds_url']}/xrpc/com.atproto.repo.getRecord" 504 + params = {"repo": repo, "collection": collection, "rkey": rkey} 505 + 506 + response = await get_oauth_client().make_authenticated_request( 507 + session=oauth_session, 508 + method="GET", 509 + url=url, 510 + params=params, 511 + ) 512 + 513 + if response.status_code != 200: 514 + raise HTTPException(status_code=500, detail="failed to fetch playlist record") 515 + 516 + record_data = response.json() 517 + current_items = record_data.get("value", {}).get("items", []) 518 + 519 + # check if track already exists in playlist 520 + for item in current_items: 521 + if item.get("subject", {}).get("uri") == body.track_uri: 522 + raise HTTPException(status_code=400, detail="track already in playlist") 523 + 524 + # append new track 525 + new_items = [ 526 + {"uri": item["subject"]["uri"], "cid": item["subject"]["cid"]} 527 + for item in current_items 528 + ] 529 + new_items.append({"uri": body.track_uri, "cid": body.track_cid}) 530 + 531 + # update ATProto record 532 + try: 533 + _, cid = await update_list_record( 534 + auth_session=session, 535 + list_uri=playlist.atproto_record_uri, 536 + items=new_items, 537 + name=playlist.name, 538 + list_type="playlist", 539 + ) 540 + except Exception as e: 541 + raise HTTPException( 542 + status_code=500, detail=f"failed to update playlist: {e}" 543 + ) from e 544 + 545 + # update database cache 546 + playlist.atproto_record_cid = cid 547 + playlist.track_count = len(new_items) 548 + await db.commit() 549 + await db.refresh(playlist) 550 + 551 + return PlaylistResponse( 552 + id=playlist.id, 553 + name=playlist.name, 554 + owner_did=playlist.owner_did, 555 + owner_handle=artist.handle, 556 + track_count=playlist.track_count, 557 + image_url=playlist.image_url, 558 + show_on_profile=playlist.show_on_profile, 559 + atproto_record_uri=playlist.atproto_record_uri, 560 + created_at=playlist.created_at.isoformat(), 561 + ) 562 + 563 + 564 + @router.delete("/playlists/{playlist_id}/tracks/{track_uri:path}") 565 + async def remove_track_from_playlist( 566 + playlist_id: str, 567 + track_uri: str, 568 + session: AuthSession = Depends(require_auth), 569 + db: AsyncSession = Depends(get_db), 570 + ) -> PlaylistResponse: 571 + """remove a track from a playlist.""" 572 + # get playlist and verify ownership 573 + result = await db.execute( 574 + select(Playlist, Artist) 575 + .join(Artist, Playlist.owner_did == Artist.did) 576 + .where(Playlist.id == playlist_id) 577 + ) 578 + row = result.first() 579 + 580 + if not row: 581 + raise HTTPException(status_code=404, detail="playlist not found") 582 + 583 + playlist, artist = row 584 + 585 + if playlist.owner_did != session.did: 586 + raise HTTPException(status_code=403, detail="not playlist owner") 587 + 588 + # fetch current list record 589 + oauth_data = session.oauth_session 590 + if not oauth_data or "access_token" not in oauth_data: 591 + raise HTTPException(status_code=401, detail="invalid session") 592 + 593 + oauth_session = _reconstruct_oauth_session(oauth_data) 594 + from backend._internal import get_oauth_client 595 + 596 + parts = playlist.atproto_record_uri.replace("at://", "").split("/") 597 + repo, collection, rkey = parts 598 + 599 + url = f"{oauth_data['pds_url']}/xrpc/com.atproto.repo.getRecord" 600 + params = {"repo": repo, "collection": collection, "rkey": rkey} 601 + 602 + response = await get_oauth_client().make_authenticated_request( 603 + session=oauth_session, 604 + method="GET", 605 + url=url, 606 + params=params, 607 + ) 608 + 609 + if response.status_code != 200: 610 + raise HTTPException(status_code=500, detail="failed to fetch playlist record") 611 + 612 + record_data = response.json() 613 + current_items = record_data.get("value", {}).get("items", []) 614 + 615 + # filter out the track to remove 616 + new_items = [ 617 + {"uri": item["subject"]["uri"], "cid": item["subject"]["cid"]} 618 + for item in current_items 619 + if item.get("subject", {}).get("uri") != track_uri 620 + ] 621 + 622 + if len(new_items) == len(current_items): 623 + raise HTTPException(status_code=404, detail="track not in playlist") 624 + 625 + # update ATProto record 626 + try: 627 + _, cid = await update_list_record( 628 + auth_session=session, 629 + list_uri=playlist.atproto_record_uri, 630 + items=new_items, 631 + name=playlist.name, 632 + list_type="playlist", 633 + ) 634 + except Exception as e: 635 + raise HTTPException( 636 + status_code=500, detail=f"failed to update playlist: {e}" 637 + ) from e 638 + 639 + # update database cache 640 + playlist.atproto_record_cid = cid 641 + playlist.track_count = len(new_items) 642 + await db.commit() 643 + await db.refresh(playlist) 644 + 645 + return PlaylistResponse( 646 + id=playlist.id, 647 + name=playlist.name, 648 + owner_did=playlist.owner_did, 649 + owner_handle=artist.handle, 650 + track_count=playlist.track_count, 651 + image_url=playlist.image_url, 652 + show_on_profile=playlist.show_on_profile, 653 + atproto_record_uri=playlist.atproto_record_uri, 654 + created_at=playlist.created_at.isoformat(), 655 + ) 656 + 657 + 658 + @router.delete("/playlists/{playlist_id}") 659 + async def delete_playlist( 660 + playlist_id: str, 661 + session: AuthSession = Depends(require_auth), 662 + db: AsyncSession = Depends(get_db), 663 + ) -> dict: 664 + """delete a playlist. 665 + 666 + deletes both the ATProto list record and the database cache. 667 + """ 668 + from backend._internal.atproto.records import delete_record_by_uri 669 + 670 + # get playlist and verify ownership 671 + result = await db.execute(select(Playlist).where(Playlist.id == playlist_id)) 672 + playlist = result.scalar_one_or_none() 673 + 674 + if not playlist: 675 + raise HTTPException(status_code=404, detail="playlist not found") 676 + 677 + if playlist.owner_did != session.did: 678 + raise HTTPException(status_code=403, detail="not playlist owner") 679 + 680 + # delete ATProto record 681 + try: 682 + await delete_record_by_uri(session, playlist.atproto_record_uri) 683 + except Exception as e: 684 + logger.warning(f"failed to delete ATProto record: {e}") 685 + # continue with database cleanup even if ATProto delete fails 686 + 687 + # delete from database 688 + await db.delete(playlist) 689 + await db.commit() 690 + 691 + return {"deleted": True} 692 + 693 + 694 + @router.post("/playlists/{playlist_id}/cover") 695 + async def upload_playlist_cover( 696 + playlist_id: str, 697 + session: AuthSession = Depends(require_auth), 698 + db: AsyncSession = Depends(get_db), 699 + image: UploadFile = File(...), 700 + ) -> dict[str, str | None]: 701 + """upload cover art for a playlist (requires authentication). 702 + 703 + accepts jpg, jpeg, png, webp images up to 20MB. 704 + """ 705 + # verify playlist exists and belongs to the authenticated user 706 + result = await db.execute( 707 + select(Playlist, Artist) 708 + .join(Artist, Playlist.owner_did == Artist.did) 709 + .where(Playlist.id == playlist_id) 710 + ) 711 + row = result.first() 712 + 713 + if not row: 714 + raise HTTPException(status_code=404, detail="playlist not found") 715 + 716 + playlist, _artist = row 717 + 718 + if playlist.owner_did != session.did: 719 + raise HTTPException( 720 + status_code=403, 721 + detail="you can only upload cover art for your own playlists", 722 + ) 723 + 724 + if not image.filename: 725 + raise HTTPException(status_code=400, detail="no filename provided") 726 + 727 + # validate it's an image by extension 728 + ext = Path(image.filename).suffix.lower() 729 + if ext not in {".jpg", ".jpeg", ".png", ".webp"}: 730 + raise HTTPException( 731 + status_code=400, 732 + detail=f"unsupported image type: {ext}. supported: .jpg, .jpeg, .png, .webp", 733 + ) 734 + 735 + # read image data (enforcing size limit) 736 + try: 737 + max_image_size = 20 * 1024 * 1024 # 20MB max 738 + image_data = bytearray() 739 + 740 + while chunk := await image.read(CHUNK_SIZE): 741 + if len(image_data) + len(chunk) > max_image_size: 742 + raise HTTPException( 743 + status_code=413, 744 + detail="image too large (max 20MB)", 745 + ) 746 + image_data.extend(chunk) 747 + 748 + image_obj = BytesIO(image_data) 749 + # save returns the file_id (hash) 750 + image_id = await storage.save(image_obj, image.filename) 751 + 752 + # construct R2 URL directly (images are stored under images/ prefix) 753 + image_url = f"{storage.public_image_bucket_url}/images/{image_id}{ext}" 754 + 755 + # delete old image if exists (prevent R2 object leaks) 756 + if playlist.image_id: 757 + with contextlib.suppress(Exception): 758 + await storage.delete(playlist.image_id) 759 + 760 + # update playlist with new image 761 + playlist.image_id = image_id 762 + playlist.image_url = image_url 763 + await db.commit() 764 + 765 + return {"image_url": image_url, "image_id": image_id} 766 + 767 + except HTTPException: 768 + raise 769 + except Exception as e: 770 + logger.exception(f"failed to upload playlist cover: {e}") 771 + raise HTTPException( 772 + status_code=500, detail="failed to upload cover image" 773 + ) from e 774 + 775 + 776 + @router.patch("/playlists/{playlist_id}") 777 + async def update_playlist( 778 + playlist_id: str, 779 + name: Annotated[str | None, Form()] = None, 780 + show_on_profile: Annotated[bool | None, Form()] = None, 781 + session: AuthSession = Depends(require_auth), 782 + db: AsyncSession = Depends(get_db), 783 + ) -> PlaylistResponse: 784 + """update playlist metadata (name, show_on_profile). 785 + 786 + use POST /playlists/{id}/cover to update cover art separately. 787 + """ 788 + # verify playlist exists and belongs to the authenticated user 789 + result = await db.execute( 790 + select(Playlist, Artist) 791 + .join(Artist, Playlist.owner_did == Artist.did) 792 + .where(Playlist.id == playlist_id) 793 + ) 794 + row = result.first() 795 + 796 + if not row: 797 + raise HTTPException(status_code=404, detail="playlist not found") 798 + 799 + playlist, artist = row 800 + 801 + if playlist.owner_did != session.did: 802 + raise HTTPException(status_code=403, detail="not playlist owner") 803 + 804 + # update show_on_profile if provided 805 + if show_on_profile is not None: 806 + playlist.show_on_profile = show_on_profile 807 + 808 + # update name if provided 809 + if name is not None and name.strip(): 810 + playlist.name = name.strip() 811 + 812 + # also update the ATProto record with new name 813 + try: 814 + # fetch current list record to preserve items 815 + oauth_data = session.oauth_session 816 + if oauth_data and "access_token" in oauth_data: 817 + from backend._internal import get_oauth_client 818 + 819 + oauth_session = _reconstruct_oauth_session(oauth_data) 820 + 821 + parts = playlist.atproto_record_uri.replace("at://", "").split("/") 822 + repo, collection, rkey = parts 823 + 824 + url = f"{oauth_data['pds_url']}/xrpc/com.atproto.repo.getRecord" 825 + params = {"repo": repo, "collection": collection, "rkey": rkey} 826 + 827 + response = await get_oauth_client().make_authenticated_request( 828 + session=oauth_session, 829 + method="GET", 830 + url=url, 831 + params=params, 832 + ) 833 + 834 + if response.status_code == 200: 835 + record_data = response.json() 836 + current_items = record_data.get("value", {}).get("items", []) 837 + 838 + # update list record with new name 839 + items = [ 840 + {"uri": item["subject"]["uri"], "cid": item["subject"]["cid"]} 841 + for item in current_items 842 + ] 843 + _, cid = await update_list_record( 844 + auth_session=session, 845 + list_uri=playlist.atproto_record_uri, 846 + items=items, 847 + name=playlist.name, 848 + list_type="playlist", 849 + ) 850 + playlist.atproto_record_cid = cid 851 + except Exception as e: 852 + logger.warning(f"failed to update ATProto record name: {e}") 853 + # continue - local update is still valid 854 + 855 + await db.commit() 856 + await db.refresh(playlist) 857 + 858 + return PlaylistResponse( 859 + id=playlist.id, 860 + name=playlist.name, 861 + owner_did=playlist.owner_did, 862 + owner_handle=artist.handle, 863 + track_count=playlist.track_count, 864 + image_url=playlist.image_url, 865 + show_on_profile=playlist.show_on_profile, 866 + atproto_record_uri=playlist.atproto_record_uri, 867 + created_at=playlist.created_at.isoformat(), 868 + )
+11 -9
backend/src/backend/api/migration.py
··· 7 7 from fastapi import APIRouter, Depends, HTTPException 8 8 9 9 from backend._internal import Session as AuthSession 10 - from backend._internal import oauth_client, require_auth 10 + from backend._internal import get_oauth_client, require_auth 11 11 from backend._internal.atproto.records import ( 12 12 _reconstruct_oauth_session, 13 13 _refresh_session_tokens, ··· 76 76 77 77 # try request, refresh token if expired 78 78 for attempt in range(2): 79 - response = await oauth_client.make_authenticated_request( 79 + response = await get_oauth_client().make_authenticated_request( 80 80 session=oauth_session, 81 81 method="GET", 82 82 url=url, ··· 175 175 176 176 # fetch old records (with token refresh if needed) 177 177 for attempt in range(2): 178 - response = await oauth_client.make_authenticated_request( 178 + response = await get_oauth_client().make_authenticated_request( 179 179 session=oauth_session, 180 180 method="GET", 181 181 url=url, ··· 258 258 259 259 # try creating the record, refresh token if expired 260 260 for create_attempt in range(2): 261 - create_response = await oauth_client.make_authenticated_request( 262 - session=oauth_session, 263 - method="POST", 264 - url=create_url, 265 - json=payload, 261 + create_response = ( 262 + await get_oauth_client().make_authenticated_request( 263 + session=oauth_session, 264 + method="POST", 265 + url=create_url, 266 + json=payload, 267 + ) 266 268 ) 267 269 268 270 if create_response.status_code in (200, 201): ··· 285 287 # try deleting with token refresh 286 288 for delete_attempt in range(2): 287 289 delete_response = ( 288 - await oauth_client.make_authenticated_request( 290 + await get_oauth_client().make_authenticated_request( 289 291 session=oauth_session, 290 292 method="POST", 291 293 url=delete_url,
+9
backend/src/backend/api/preferences.py
··· 26 26 # indicates if user needs to re-login to activate teal scrobbling 27 27 teal_needs_reauth: bool = False 28 28 show_sensitive_artwork: bool = False 29 + show_liked_on_profile: bool = False 29 30 30 31 31 32 class PreferencesUpdate(BaseModel): ··· 37 38 hidden_tags: list[str] | None = None 38 39 enable_teal_scrobbling: bool | None = None 39 40 show_sensitive_artwork: bool | None = None 41 + show_liked_on_profile: bool | None = None 40 42 41 43 42 44 def _has_teal_scope(session: Session) -> bool: ··· 81 83 enable_teal_scrobbling=prefs.enable_teal_scrobbling, 82 84 teal_needs_reauth=teal_needs_reauth, 83 85 show_sensitive_artwork=prefs.show_sensitive_artwork, 86 + show_liked_on_profile=prefs.show_liked_on_profile, 84 87 ) 85 88 86 89 ··· 116 119 show_sensitive_artwork=update.show_sensitive_artwork 117 120 if update.show_sensitive_artwork is not None 118 121 else False, 122 + show_liked_on_profile=update.show_liked_on_profile 123 + if update.show_liked_on_profile is not None 124 + else False, 119 125 ) 120 126 db.add(prefs) 121 127 else: ··· 132 138 prefs.enable_teal_scrobbling = update.enable_teal_scrobbling 133 139 if update.show_sensitive_artwork is not None: 134 140 prefs.show_sensitive_artwork = update.show_sensitive_artwork 141 + if update.show_liked_on_profile is not None: 142 + prefs.show_liked_on_profile = update.show_liked_on_profile 135 143 136 144 await db.commit() 137 145 await db.refresh(prefs) ··· 148 156 enable_teal_scrobbling=prefs.enable_teal_scrobbling, 149 157 teal_needs_reauth=teal_needs_reauth, 150 158 show_sensitive_artwork=prefs.show_sensitive_artwork, 159 + show_liked_on_profile=prefs.show_liked_on_profile, 151 160 )
+65 -4
backend/src/backend/api/search.py
··· 8 8 from sqlalchemy.ext.asyncio import AsyncSession 9 9 10 10 from backend._internal.atproto.handles import search_handles 11 - from backend.models import Album, Artist, Tag, Track, TrackTag, get_db 11 + from backend.models import Album, Artist, Playlist, Tag, Track, TrackTag, get_db 12 12 13 13 router = APIRouter(prefix="/search", tags=["search"]) 14 14 ··· 60 60 relevance: float 61 61 62 62 63 + class PlaylistSearchResult(BaseModel): 64 + """playlist search result.""" 65 + 66 + type: Literal["playlist"] = "playlist" 67 + id: str 68 + name: str 69 + owner_handle: str 70 + owner_display_name: str 71 + image_url: str | None 72 + track_count: int 73 + relevance: float 74 + 75 + 63 76 SearchResult = ( 64 - TrackSearchResult | ArtistSearchResult | AlbumSearchResult | TagSearchResult 77 + TrackSearchResult 78 + | ArtistSearchResult 79 + | AlbumSearchResult 80 + | TagSearchResult 81 + | PlaylistSearchResult 65 82 ) 66 83 67 84 ··· 104 121 if type: 105 122 types = {t.strip().lower() for t in type.split(",")} 106 123 else: 107 - types = {"tracks", "artists", "albums", "tags"} 124 + types = {"tracks", "artists", "albums", "tags", "playlists"} 108 125 109 126 results: list[SearchResult] = [] 110 - counts: dict[str, int] = {"tracks": 0, "artists": 0, "albums": 0, "tags": 0} 127 + counts: dict[str, int] = { 128 + "tracks": 0, 129 + "artists": 0, 130 + "albums": 0, 131 + "tags": 0, 132 + "playlists": 0, 133 + } 111 134 112 135 # search tracks 113 136 if "tracks" in types: ··· 133 156 results.extend(tag_results) 134 157 counts["tags"] = len(tag_results) 135 158 159 + # search playlists 160 + if "playlists" in types: 161 + playlist_results = await _search_playlists(db, q, limit) 162 + results.extend(playlist_results) 163 + counts["playlists"] = len(playlist_results) 164 + 136 165 # sort all results by relevance (highest first) 137 166 results.sort(key=lambda x: x.relevance, reverse=True) 138 167 ··· 278 307 ) 279 308 for tag, track_count, relevance in rows 280 309 ] 310 + 311 + 312 + async def _search_playlists( 313 + db: AsyncSession, query: str, limit: int 314 + ) -> list[PlaylistSearchResult]: 315 + """search playlists by name using trigram similarity + substring.""" 316 + similarity = func.similarity(Playlist.name, query) 317 + substring_match = Playlist.name.ilike(f"%{query}%") 318 + 319 + stmt = ( 320 + select(Playlist, Artist, similarity.label("relevance")) 321 + .join(Artist, Playlist.owner_did == Artist.did) 322 + .where(or_(similarity > 0.1, substring_match)) 323 + .order_by(similarity.desc()) 324 + .limit(limit) 325 + ) 326 + 327 + result = await db.execute(stmt) 328 + rows = result.all() 329 + 330 + return [ 331 + PlaylistSearchResult( 332 + id=playlist.id, 333 + name=playlist.name, 334 + owner_handle=artist.handle, 335 + owner_display_name=artist.display_name, 336 + image_url=playlist.image_url, 337 + track_count=playlist.track_count, 338 + relevance=round(relevance, 3), 339 + ) 340 + for playlist, artist, relevance in rows 341 + ]
+13 -19
backend/src/backend/api/tracks/listing.py
··· 4 4 from typing import Annotated 5 5 6 6 import logfire 7 - from fastapi import Cookie, Depends, Request 7 + from fastapi import Depends 8 8 from pydantic import BaseModel 9 9 from sqlalchemy import select 10 10 from sqlalchemy.ext.asyncio import AsyncSession 11 11 from sqlalchemy.orm import selectinload 12 12 13 13 from backend._internal import Session as AuthSession 14 - from backend._internal import require_auth 15 - from backend._internal.auth import get_session 14 + from backend._internal import get_optional_session, require_auth 16 15 from backend.models import ( 17 16 Artist, 18 17 Tag, ··· 37 36 @router.get("/") 38 37 async def list_tracks( 39 38 db: Annotated[AsyncSession, Depends(get_db)], 40 - request: Request, 41 39 artist_did: str | None = None, 42 40 filter_hidden_tags: bool | None = None, 43 - session_id_cookie: Annotated[str | None, Cookie(alias="session_id")] = None, 41 + session: AuthSession | None = Depends(get_optional_session), 44 42 ) -> dict: 45 43 """List all tracks, optionally filtered by artist DID. 46 44 ··· 54 52 """ 55 53 from atproto_identity.did.resolver import AsyncDidResolver 56 54 57 - # get authenticated user if cookie or auth header present 55 + # get authenticated user's liked tracks and preferences 58 56 liked_track_ids: set[int] | None = None 59 57 hidden_tags: list[str] = list(DEFAULT_HIDDEN_TAGS) 60 - auth_session = None 61 58 62 - session_id = session_id_cookie or request.headers.get("authorization", "").replace( 63 - "Bearer ", "" 64 - ) 65 - if session_id and (auth_session := await get_session(session_id)): 59 + if session: 66 60 liked_result = await db.execute( 67 - select(TrackLike.track_id).where(TrackLike.user_did == auth_session.did) 61 + select(TrackLike.track_id).where(TrackLike.user_did == session.did) 68 62 ) 69 63 liked_track_ids = set(liked_result.scalars().all()) 70 64 71 65 # get user's hidden tags preference 72 66 prefs_result = await db.execute( 73 - select(UserPreferences).where(UserPreferences.did == auth_session.did) 67 + select(UserPreferences).where(UserPreferences.did == session.did) 74 68 ) 75 69 prefs = prefs_result.scalar_one_or_none() 76 70 if prefs and prefs.hidden_tags is not None: ··· 185 179 track.image_url = url 186 180 else: 187 181 # image_id exists but file not found in R2 188 - # clear image_id to prevent future lookups 189 - logfire.warn( 190 - "clearing invalid image_id", 182 + # log error but don't clear - this indicates a bug (e.g. extension mismatch) 183 + # clearing would destroy the reference and make debugging harder 184 + logfire.error( 185 + "image_id exists but file not found in R2", 191 186 track_id=track.id, 192 187 image_id=track.image_id, 193 188 ) 194 - track.image_id = None 195 - track.image_url = None 196 189 except Exception as e: 197 - logfire.warn( 190 + logfire.error( 198 191 "failed to resolve image", 199 192 track_id=track.id, 193 + image_id=track.image_id, 200 194 error=str(e), 201 195 ) 202 196
+3 -3
backend/src/backend/api/tracks/mutations.py
··· 14 14 from sqlalchemy.orm import selectinload 15 15 16 16 from backend._internal import Session as AuthSession 17 - from backend._internal import oauth_client, require_auth 17 + from backend._internal import get_oauth_client, require_auth 18 18 from backend._internal.atproto import delete_record_by_uri 19 19 from backend._internal.atproto.records import ( 20 20 _reconstruct_oauth_session, ··· 346 346 347 347 # try request with token refresh 348 348 for attempt in range(2): 349 - response = await oauth_client.make_authenticated_request( 349 + response = await get_oauth_client().make_authenticated_request( 350 350 session=oauth_session, 351 351 method="GET", 352 352 url=url, ··· 404 404 405 405 # try create with token refresh 406 406 for attempt in range(2): 407 - response = await oauth_client.make_authenticated_request( 407 + response = await get_oauth_client().make_authenticated_request( 408 408 session=oauth_session, 409 409 method="POST", 410 410 url=create_url,
+23 -34
backend/src/backend/api/tracks/playback.py
··· 4 4 import logging 5 5 from typing import Annotated 6 6 7 - from fastapi import BackgroundTasks, Cookie, Depends, HTTPException, Request 7 + from fastapi import BackgroundTasks, Depends, HTTPException 8 8 from sqlalchemy import select 9 9 from sqlalchemy.ext.asyncio import AsyncSession 10 10 from sqlalchemy.orm import selectinload 11 11 12 + from backend._internal import Session, get_optional_session 12 13 from backend._internal.atproto.teal import create_teal_play_record, update_teal_status 13 14 from backend._internal.auth import get_session 14 15 from backend.config import settings 15 16 from backend.models import Artist, Track, TrackLike, UserPreferences, get_db 16 17 from backend.schemas import TrackResponse 17 18 from backend.utilities.aggregations import get_like_counts, get_track_tags 18 - from backend.utilities.auth import get_session_id_from_request 19 19 20 20 from .router import router 21 21 ··· 26 26 async def get_track( 27 27 track_id: int, 28 28 db: Annotated[AsyncSession, Depends(get_db)], 29 - request: Request, 30 - session_id_cookie: Annotated[str | None, Cookie(alias="session_id")] = None, 29 + session: Session | None = Depends(get_optional_session), 31 30 ) -> TrackResponse: 32 31 """Get a specific track.""" 33 32 liked_track_ids: set[int] | None = None 34 - if ( 35 - (session_id := get_session_id_from_request(request, session_id_cookie)) 36 - and (auth_session := await get_session(session_id)) 37 - and await db.scalar( 38 - select(TrackLike.track_id).where( 39 - TrackLike.user_did == auth_session.did, TrackLike.track_id == track_id 40 - ) 33 + if session and await db.scalar( 34 + select(TrackLike.track_id).where( 35 + TrackLike.user_did == session.did, TrackLike.track_id == track_id 41 36 ) 42 37 ): 43 38 liked_track_ids = {track_id} ··· 110 105 async def increment_play_count( 111 106 track_id: int, 112 107 db: Annotated[AsyncSession, Depends(get_db)], 113 - request: Request, 114 108 background_tasks: BackgroundTasks, 115 - session_id_cookie: Annotated[str | None, Cookie(alias="session_id")] = None, 109 + session: Session | None = Depends(get_optional_session), 116 110 ) -> dict: 117 111 """Increment play count for a track (called after 30 seconds of playback). 118 112 ··· 133 127 await db.commit() 134 128 135 129 # check if user wants teal scrobbling 136 - if ( 137 - (session_id := get_session_id_from_request(request, session_id_cookie)) 138 - and (auth_session := await get_session(session_id)) 139 - and ( 140 - prefs := await db.scalar( 141 - select(UserPreferences).where(UserPreferences.did == auth_session.did) 142 - ) 130 + if session: 131 + prefs = await db.scalar( 132 + select(UserPreferences).where(UserPreferences.did == session.did) 143 133 ) 144 - and prefs.enable_teal_scrobbling 145 - ): 146 - # check if session has teal scopes 147 - scope = auth_session.oauth_session.get("scope", "") 148 - if settings.teal.play_collection in scope: 149 - background_tasks.add_task( 150 - _scrobble_to_teal, 151 - session_id=session_id, 152 - track_id=track_id, 153 - track_title=track.title, 154 - artist_name=track.artist.display_name or track.artist.handle, 155 - duration=track.duration, 156 - album_name=track.album_rel.title if track.album_rel else None, 157 - ) 134 + if prefs and prefs.enable_teal_scrobbling: 135 + # check if session has teal scopes 136 + scope = session.oauth_session.get("scope", "") 137 + if settings.teal.play_collection in scope: 138 + background_tasks.add_task( 139 + _scrobble_to_teal, 140 + session_id=session.session_id, 141 + track_id=track_id, 142 + track_title=track.title, 143 + artist_name=track.artist.display_name or track.artist.handle, 144 + duration=track.duration, 145 + album_name=track.album_rel.title if track.album_rel else None, 146 + ) 158 147 159 148 return {"play_count": track.play_count}
+92
backend/src/backend/api/users.py
··· 1 + """user-related public endpoints.""" 2 + 3 + import asyncio 4 + from typing import Annotated 5 + 6 + from fastapi import APIRouter, Depends, HTTPException 7 + from sqlalchemy import func, select 8 + from sqlalchemy.ext.asyncio import AsyncSession 9 + from sqlalchemy.orm import selectinload 10 + 11 + from backend._internal import Session, get_optional_session 12 + from backend.models import Artist, Track, TrackLike, get_db 13 + from backend.schemas import TrackResponse 14 + from backend.utilities.aggregations import get_comment_counts, get_like_counts 15 + 16 + router = APIRouter(prefix="/users", tags=["users"]) 17 + 18 + 19 + @router.get("/{handle}/likes") 20 + async def get_user_liked_tracks( 21 + handle: str, 22 + db: Annotated[AsyncSession, Depends(get_db)], 23 + session: Session | None = Depends(get_optional_session), 24 + ) -> dict: 25 + """get tracks liked by a user (public). 26 + 27 + likes are stored on the user's PDS as ATProto records, making them 28 + public data. this endpoint returns the indexed likes for any user. 29 + """ 30 + # resolve handle to DID 31 + result = await db.execute(select(Artist).where(Artist.handle == handle)) 32 + artist = result.scalar_one_or_none() 33 + 34 + if not artist: 35 + raise HTTPException(status_code=404, detail="user not found") 36 + 37 + # get tracks liked by this user 38 + stmt = ( 39 + select(Track) 40 + .join(TrackLike, TrackLike.track_id == Track.id) 41 + .join(Artist, Artist.did == Track.artist_did) 42 + .options(selectinload(Track.artist), selectinload(Track.album_rel)) 43 + .where(TrackLike.user_did == artist.did) 44 + .order_by(TrackLike.created_at.desc()) 45 + ) 46 + 47 + track_result = await db.execute(stmt) 48 + tracks = track_result.scalars().all() 49 + 50 + # get current user's liked track IDs if authenticated 51 + liked_track_ids: set[int] = set() 52 + if session: 53 + liked_stmt = select(TrackLike.track_id).where(TrackLike.user_did == session.did) 54 + liked_result = await db.execute(liked_stmt) 55 + liked_track_ids = set(liked_result.scalars().all()) 56 + 57 + track_ids = [track.id for track in tracks] 58 + like_counts, comment_counts = await asyncio.gather( 59 + get_like_counts(db, track_ids), 60 + get_comment_counts(db, track_ids), 61 + ) 62 + 63 + track_responses = await asyncio.gather( 64 + *[ 65 + TrackResponse.from_track( 66 + track, 67 + liked_track_ids=liked_track_ids, 68 + like_counts=like_counts, 69 + comment_counts=comment_counts, 70 + ) 71 + for track in tracks 72 + ] 73 + ) 74 + 75 + # get total like count for this user 76 + count_stmt = ( 77 + select(func.count()) 78 + .select_from(TrackLike) 79 + .where(TrackLike.user_did == artist.did) 80 + ) 81 + total_count = await db.scalar(count_stmt) 82 + 83 + return { 84 + "user": { 85 + "did": artist.did, 86 + "handle": artist.handle, 87 + "display_name": artist.display_name, 88 + "avatar_url": artist.avatar_url, 89 + }, 90 + "tracks": track_responses, 91 + "count": total_count or 0, 92 + }
+17 -1
backend/src/backend/config.py
··· 371 371 372 372 @computed_field 373 373 @property 374 + def list_collection(self) -> str: 375 + """Collection name for list records (playlists, albums).""" 376 + 377 + return f"{self.app_namespace}.list" 378 + 379 + @computed_field 380 + @property 381 + def profile_collection(self) -> str: 382 + """Collection name for artist profile records.""" 383 + 384 + return f"{self.app_namespace}.actor.profile" 385 + 386 + @computed_field 387 + @property 374 388 def old_track_collection(self) -> str | None: 375 389 """Collection name for old namespace, if migration is active.""" 376 390 ··· 386 400 if self.scope_override: 387 401 return self.scope_override 388 402 389 - # base scopes: our track, like, and comment collections 403 + # base scopes: our track, like, comment, list, and profile collections 390 404 scopes = [ 391 405 f"repo:{self.track_collection}", 392 406 f"repo:{self.like_collection}", 393 407 f"repo:{self.comment_collection}", 408 + f"repo:{self.list_collection}", 409 + f"repo:{self.profile_collection}", 394 410 ] 395 411 396 412 # if we have an old namespace, add old track collection too
+4
backend/src/backend/main.py
··· 38 38 search_router, 39 39 stats_router, 40 40 tracks_router, 41 + users_router, 41 42 ) 42 43 from backend.api.albums import router as albums_router 44 + from backend.api.lists import router as lists_router 43 45 from backend.api.migration import router as migration_router 44 46 from backend.config import settings 45 47 from backend.models import init_db ··· 196 198 app.include_router(artists_router) 197 199 app.include_router(tracks_router) 198 200 app.include_router(albums_router) 201 + app.include_router(lists_router) 199 202 app.include_router(audio_router) 200 203 app.include_router(search_router) 201 204 app.include_router(preferences_router) ··· 206 209 app.include_router(moderation_router) 207 210 app.include_router(oembed_router) 208 211 app.include_router(stats_router) 212 + app.include_router(users_router) 209 213 210 214 211 215 @app.get("/health")
+4
backend/src/backend/models/__init__.py
··· 9 9 from backend.models.job import Job 10 10 from backend.models.oauth_state import OAuthStateModel 11 11 from backend.models.pending_dev_token import PendingDevToken 12 + from backend.models.pending_scope_upgrade import PendingScopeUpgrade 13 + from backend.models.playlist import Playlist 12 14 from backend.models.preferences import UserPreferences 13 15 from backend.models.queue import QueueState 14 16 from backend.models.session import UserSession ··· 27 29 "Job", 28 30 "OAuthStateModel", 29 31 "PendingDevToken", 32 + "PendingScopeUpgrade", 33 + "Playlist", 30 34 "QueueState", 31 35 "ScanResolution", 32 36 "SensitiveImage",
+5 -1
backend/src/backend/models/artist.py
··· 10 10 11 11 if TYPE_CHECKING: 12 12 from backend.models.album import Album 13 + from backend.models.playlist import Playlist 13 14 from backend.models.track import Track 14 15 15 16 ··· 43 44 nullable=False, 44 45 ) 45 46 46 - # relationship 47 + # relationships 47 48 tracks: Mapped[list["Track"]] = relationship("Track", back_populates="artist") 48 49 albums: Mapped[list["Album"]] = relationship("Album", back_populates="artist") 50 + playlists: Mapped[list["Playlist"]] = relationship( 51 + "Playlist", back_populates="owner" 52 + )
+39
backend/src/backend/models/pending_scope_upgrade.py
··· 1 + """pending scope upgrade model for OAuth flow metadata.""" 2 + 3 + from datetime import UTC, datetime, timedelta 4 + 5 + from sqlalchemy import DateTime, String 6 + from sqlalchemy.orm import Mapped, mapped_column 7 + 8 + from backend.models.database import Base 9 + 10 + 11 + class PendingScopeUpgrade(Base): 12 + """temporary record linking OAuth state to scope upgrade metadata. 13 + 14 + when a user initiates an OAuth flow to upgrade their session scopes 15 + (e.g., enabling teal.fm scrobbling), we store the existing session ID 16 + here so the callback knows to replace it with the new session. 17 + 18 + records expire after 10 minutes (matching OAuth state TTL). 19 + """ 20 + 21 + __tablename__ = "pending_scope_upgrades" 22 + 23 + state: Mapped[str] = mapped_column(String(64), primary_key=True, index=True) 24 + did: Mapped[str] = mapped_column(String(256), nullable=False, index=True) 25 + # the session being upgraded - will be deleted after successful upgrade 26 + old_session_id: Mapped[str] = mapped_column(String(64), nullable=False) 27 + # the additional scopes being requested 28 + requested_scopes: Mapped[str] = mapped_column(String(512), nullable=False) 29 + created_at: Mapped[datetime] = mapped_column( 30 + DateTime(timezone=True), 31 + default=lambda: datetime.now(UTC), 32 + nullable=False, 33 + index=True, # for cleanup queries 34 + ) 35 + expires_at: Mapped[datetime] = mapped_column( 36 + DateTime(timezone=True), 37 + default=lambda: datetime.now(UTC) + timedelta(minutes=10), 38 + nullable=False, 39 + )
+68
backend/src/backend/models/playlist.py
··· 1 + """playlist model for caching ATProto list records.""" 2 + 3 + from __future__ import annotations 4 + 5 + from datetime import UTC, datetime 6 + from uuid import uuid4 7 + 8 + from sqlalchemy import Boolean, DateTime, ForeignKey, String 9 + from sqlalchemy.orm import Mapped, mapped_column, relationship 10 + 11 + from backend.models.database import Base 12 + 13 + 14 + class Playlist(Base): 15 + """cached playlist metadata from ATProto list records. 16 + 17 + the source of truth is the ATProto list record on the user's PDS. 18 + this table exists for fast indexing and querying. 19 + """ 20 + 21 + __tablename__ = "playlists" 22 + 23 + id: Mapped[str] = mapped_column( 24 + String, 25 + primary_key=True, 26 + default=lambda: str(uuid4()), 27 + ) 28 + owner_did: Mapped[str] = mapped_column( 29 + String, 30 + ForeignKey("artists.did"), 31 + nullable=False, 32 + index=True, 33 + ) 34 + name: Mapped[str] = mapped_column(String, nullable=False) 35 + image_id: Mapped[str | None] = mapped_column(String, nullable=True) 36 + image_url: Mapped[str | None] = mapped_column(String, nullable=True) 37 + atproto_record_uri: Mapped[str] = mapped_column( 38 + String, 39 + nullable=False, 40 + unique=True, 41 + index=True, 42 + ) 43 + atproto_record_cid: Mapped[str] = mapped_column(String, nullable=False) 44 + track_count: Mapped[int] = mapped_column(default=0) 45 + show_on_profile: Mapped[bool] = mapped_column( 46 + Boolean, default=False, nullable=False 47 + ) 48 + created_at: Mapped[datetime] = mapped_column( 49 + DateTime(timezone=True), 50 + default=lambda: datetime.now(UTC), 51 + nullable=False, 52 + ) 53 + updated_at: Mapped[datetime] = mapped_column( 54 + DateTime(timezone=True), 55 + default=lambda: datetime.now(UTC), 56 + onupdate=lambda: datetime.now(UTC), 57 + nullable=False, 58 + ) 59 + 60 + owner = relationship("Artist", back_populates="playlists") 61 + 62 + async def get_image_url(self) -> str | None: 63 + """resolve image URL from storage.""" 64 + if not self.image_id: 65 + return None 66 + from backend.storage import storage 67 + 68 + return await storage.get_url(self.image_id, file_type="image")
+10
backend/src/backend/models/preferences.py
··· 52 52 Boolean, nullable=False, default=False, server_default=text("false") 53 53 ) 54 54 55 + # profile preferences 56 + # when enabled, liked tracks are displayed on the user's artist page 57 + show_liked_on_profile: Mapped[bool] = mapped_column( 58 + Boolean, nullable=False, default=False, server_default=text("false") 59 + ) 60 + 61 + # ATProto liked list record (fm.plyr.list with listType="liked") 62 + liked_list_uri: Mapped[str | None] = mapped_column(String, nullable=True) 63 + liked_list_cid: Mapped[str | None] = mapped_column(String, nullable=True) 64 + 55 65 # metadata 56 66 created_at: Mapped[datetime] = mapped_column( 57 67 DateTime(timezone=True),
+2
backend/src/backend/schemas.py
··· 56 56 features: list[dict[str, Any]] 57 57 r2_url: str | None 58 58 atproto_record_uri: str | None 59 + atproto_record_cid: str | None 59 60 atproto_record_url: str | None 60 61 play_count: int 61 62 created_at: str ··· 145 146 features=track.features, 146 147 r2_url=track.r2_url, 147 148 atproto_record_uri=track.atproto_record_uri, 149 + atproto_record_cid=track.atproto_record_cid, 148 150 atproto_record_url=atproto_record_url, 149 151 play_count=track.play_count, 150 152 created_at=track.created_at.isoformat(),
-20
backend/src/backend/utilities/auth.py
··· 1 - """authentication utilities.""" 2 - 3 - from fastapi import Request 4 - 5 - 6 - def get_session_id_from_request( 7 - request: Request, session_id_cookie: str | None = None 8 - ) -> str | None: 9 - """extract session ID from cookie or authorization header. 10 - 11 - checks cookie first (browser requests), then falls back to bearer token 12 - in authorization header (SDK/CLI clients). 13 - """ 14 - if session_id_cookie: 15 - return session_id_cookie 16 - 17 - if authorization := request.headers.get("authorization"): 18 - return authorization.removeprefix("Bearer ") 19 - 20 - return None
+203
backend/tests/api/test_artist_profile_record.py
··· 1 + """tests for ATProto profile record integration with artist endpoints.""" 2 + 3 + from collections.abc import Generator 4 + from unittest.mock import AsyncMock, patch 5 + 6 + import pytest 7 + from fastapi import FastAPI 8 + from httpx import ASGITransport, AsyncClient 9 + from sqlalchemy.ext.asyncio import AsyncSession 10 + 11 + from backend._internal import Session, require_auth 12 + from backend.main import app 13 + from backend.models import Artist 14 + 15 + 16 + class MockSession(Session): 17 + """mock session for auth bypass in tests.""" 18 + 19 + def __init__(self, did: str = "did:test:user123"): 20 + self.did = did 21 + self.handle = "testuser.bsky.social" 22 + self.session_id = "test_session_id" 23 + self.access_token = "test_token" 24 + self.refresh_token = "test_refresh" 25 + self.oauth_session = { 26 + "did": did, 27 + "handle": "testuser.bsky.social", 28 + "pds_url": "https://test.pds", 29 + "authserver_iss": "https://auth.test", 30 + "scope": "atproto transition:generic", 31 + "access_token": "test_token", 32 + "refresh_token": "test_refresh", 33 + "dpop_private_key_pem": "fake_key", 34 + "dpop_authserver_nonce": "", 35 + "dpop_pds_nonce": "", 36 + } 37 + 38 + 39 + @pytest.fixture 40 + def test_app(db_session: AsyncSession) -> Generator[FastAPI, None, None]: 41 + """create test app with mocked auth.""" 42 + 43 + async def mock_require_auth() -> Session: 44 + return MockSession(did="did:plc:testartist123") 45 + 46 + app.dependency_overrides[require_auth] = mock_require_auth 47 + 48 + yield app 49 + 50 + app.dependency_overrides.clear() 51 + 52 + 53 + @pytest.fixture 54 + async def test_artist(db_session: AsyncSession) -> Artist: 55 + """create a test artist.""" 56 + artist = Artist( 57 + did="did:plc:testartist123", 58 + handle="testartist.bsky.social", 59 + display_name="Test Artist", 60 + ) 61 + db_session.add(artist) 62 + await db_session.commit() 63 + await db_session.refresh(artist) 64 + return artist 65 + 66 + 67 + async def test_update_bio_creates_atproto_profile_record( 68 + test_app: FastAPI, db_session: AsyncSession, test_artist: Artist 69 + ): 70 + """test that updating bio triggers ATProto profile record upsert.""" 71 + with patch( 72 + "backend.api.artists.upsert_profile_record", 73 + new_callable=AsyncMock, 74 + return_value=( 75 + "at://did:plc:testartist123/fm.plyr.actor.profile/self", 76 + "bafytest123", 77 + ), 78 + ) as mock_upsert: 79 + async with AsyncClient( 80 + transport=ASGITransport(app=test_app), base_url="http://test" 81 + ) as client: 82 + response = await client.put( 83 + "/artists/me", 84 + json={"bio": "my new artist bio"}, 85 + ) 86 + 87 + assert response.status_code == 200 88 + data = response.json() 89 + assert data["bio"] == "my new artist bio" 90 + 91 + # verify ATProto record was created 92 + mock_upsert.assert_called_once() 93 + call_args = mock_upsert.call_args 94 + assert call_args.kwargs["bio"] == "my new artist bio" 95 + 96 + 97 + async def test_update_bio_continues_on_atproto_failure( 98 + test_app: FastAPI, db_session: AsyncSession, test_artist: Artist 99 + ): 100 + """test that bio update succeeds even if ATProto call fails. 101 + 102 + database is source of truth, ATProto failure should not fail the request. 103 + """ 104 + with patch( 105 + "backend.api.artists.upsert_profile_record", 106 + side_effect=Exception("PDS connection failed"), 107 + ) as mock_upsert: 108 + async with AsyncClient( 109 + transport=ASGITransport(app=test_app), base_url="http://test" 110 + ) as client: 111 + response = await client.put( 112 + "/artists/me", 113 + json={"bio": "bio that should still save"}, 114 + ) 115 + 116 + # should still succeed despite ATProto failure 117 + assert response.status_code == 200 118 + data = response.json() 119 + assert data["bio"] == "bio that should still save" 120 + 121 + # verify ATProto was attempted 122 + mock_upsert.assert_called_once() 123 + 124 + 125 + async def test_update_without_bio_skips_atproto( 126 + test_app: FastAPI, db_session: AsyncSession, test_artist: Artist 127 + ): 128 + """test that updating only display_name does not call ATProto.""" 129 + with patch( 130 + "backend.api.artists.upsert_profile_record", 131 + new_callable=AsyncMock, 132 + ) as mock_upsert: 133 + async with AsyncClient( 134 + transport=ASGITransport(app=test_app), base_url="http://test" 135 + ) as client: 136 + response = await client.put( 137 + "/artists/me", 138 + json={"display_name": "New Display Name"}, 139 + ) 140 + 141 + assert response.status_code == 200 142 + data = response.json() 143 + assert data["display_name"] == "New Display Name" 144 + 145 + # ATProto should NOT be called when bio is not updated 146 + mock_upsert.assert_not_called() 147 + 148 + 149 + async def test_create_artist_with_bio_creates_atproto_record( 150 + test_app: FastAPI, db_session: AsyncSession 151 + ): 152 + """test that creating artist with bio triggers ATProto profile record creation.""" 153 + with patch( 154 + "backend.api.artists.upsert_profile_record", 155 + new_callable=AsyncMock, 156 + return_value=( 157 + "at://did:plc:testartist123/fm.plyr.actor.profile/self", 158 + "bafytest456", 159 + ), 160 + ) as mock_upsert: 161 + async with AsyncClient( 162 + transport=ASGITransport(app=test_app), base_url="http://test" 163 + ) as client: 164 + response = await client.post( 165 + "/artists/", 166 + json={ 167 + "display_name": "New Artist", 168 + "bio": "my initial bio", 169 + }, 170 + ) 171 + 172 + assert response.status_code == 200 173 + data = response.json() 174 + assert data["bio"] == "my initial bio" 175 + 176 + # verify ATProto record was created 177 + mock_upsert.assert_called_once() 178 + call_args = mock_upsert.call_args 179 + assert call_args.kwargs["bio"] == "my initial bio" 180 + 181 + 182 + async def test_create_artist_without_bio_skips_atproto( 183 + test_app: FastAPI, db_session: AsyncSession 184 + ): 185 + """test that creating artist without bio does not call ATProto.""" 186 + with patch( 187 + "backend.api.artists.upsert_profile_record", 188 + new_callable=AsyncMock, 189 + ) as mock_upsert: 190 + async with AsyncClient( 191 + transport=ASGITransport(app=test_app), base_url="http://test" 192 + ) as client: 193 + response = await client.post( 194 + "/artists/", 195 + json={"display_name": "Artist Without Bio"}, 196 + ) 197 + 198 + assert response.status_code == 200 199 + data = response.json() 200 + assert data["bio"] is None 201 + 202 + # ATProto should NOT be called when no bio provided 203 + mock_upsert.assert_not_called()
+5 -8
backend/tests/api/test_hidden_tags_filter.py
··· 1 1 """tests for hidden tags filtering on track listing endpoints.""" 2 2 3 3 from collections.abc import Generator 4 - from unittest.mock import patch 5 4 6 5 import pytest 7 6 from fastapi import FastAPI 8 7 from httpx import ASGITransport, AsyncClient 9 8 from sqlalchemy.ext.asyncio import AsyncSession 10 9 11 - from backend._internal import Session, require_auth 10 + from backend._internal import Session, get_optional_session, require_auth 12 11 from backend.main import app 13 12 from backend.models import Artist, Tag, Track, TrackTag, UserPreferences, get_db 14 13 ··· 125 124 async def mock_require_auth() -> Session: 126 125 return MockSession() 127 126 128 - async def mock_get_session(session_id: str) -> Session | None: 129 - if session_id == "test_session": 130 - return MockSession() 131 - return None 127 + async def mock_get_optional_session() -> Session | None: 128 + return MockSession() 132 129 133 130 async def mock_get_db(): 134 131 yield db_session 135 132 136 133 app.dependency_overrides[require_auth] = mock_require_auth 134 + app.dependency_overrides[get_optional_session] = mock_get_optional_session 137 135 app.dependency_overrides[get_db] = mock_get_db 138 136 139 - with patch("backend.api.tracks.listing.get_session", mock_get_session): 140 - yield app 137 + yield app 141 138 142 139 app.dependency_overrides.clear() 143 140
+359
backend/tests/api/test_list_record_sync.py
··· 1 + """tests for ATProto list record sync on login.""" 2 + 3 + import asyncio 4 + from collections.abc import Generator 5 + from unittest.mock import AsyncMock, patch 6 + 7 + import pytest 8 + from fastapi import FastAPI 9 + from httpx import ASGITransport, AsyncClient 10 + from sqlalchemy.ext.asyncio import AsyncSession 11 + 12 + from backend._internal import Session, require_auth 13 + from backend.main import app 14 + from backend.models import Album, Artist, Track, TrackLike 15 + 16 + 17 + class MockSession(Session): 18 + """mock session for auth bypass in tests.""" 19 + 20 + def __init__(self, did: str = "did:test:user123"): 21 + self.did = did 22 + self.handle = "testuser.bsky.social" 23 + self.session_id = "test_session_id" 24 + self.access_token = "test_token" 25 + self.refresh_token = "test_refresh" 26 + self.oauth_session = { 27 + "did": did, 28 + "handle": "testuser.bsky.social", 29 + "pds_url": "https://test.pds", 30 + "authserver_iss": "https://auth.test", 31 + "scope": "atproto transition:generic", 32 + "access_token": "test_token", 33 + "refresh_token": "test_refresh", 34 + "dpop_private_key_pem": "fake_key", 35 + "dpop_authserver_nonce": "", 36 + "dpop_pds_nonce": "", 37 + } 38 + 39 + 40 + @pytest.fixture 41 + def test_app(db_session: AsyncSession) -> Generator[FastAPI, None, None]: 42 + """create test app with mocked auth.""" 43 + 44 + async def mock_require_auth() -> Session: 45 + return MockSession(did="did:plc:testartist123") 46 + 47 + app.dependency_overrides[require_auth] = mock_require_auth 48 + 49 + yield app 50 + 51 + app.dependency_overrides.clear() 52 + 53 + 54 + @pytest.fixture 55 + async def test_artist(db_session: AsyncSession) -> Artist: 56 + """create a test artist.""" 57 + artist = Artist( 58 + did="did:plc:testartist123", 59 + handle="testartist.bsky.social", 60 + display_name="Test Artist", 61 + ) 62 + db_session.add(artist) 63 + await db_session.commit() 64 + await db_session.refresh(artist) 65 + return artist 66 + 67 + 68 + @pytest.fixture 69 + async def test_album_with_tracks( 70 + db_session: AsyncSession, test_artist: Artist 71 + ) -> tuple[Album, list[Track]]: 72 + """create a test album with tracks that have ATProto records.""" 73 + album = Album( 74 + artist_did=test_artist.did, 75 + title="Test Album", 76 + slug="test-album", 77 + ) 78 + db_session.add(album) 79 + await db_session.flush() 80 + 81 + tracks = [] 82 + for i in range(3): 83 + track = Track( 84 + title=f"Track {i + 1}", 85 + file_id=f"fileid{i}", 86 + file_type="audio/mpeg", 87 + artist_did=test_artist.did, 88 + album_id=album.id, 89 + atproto_record_uri=f"at://did:plc:testartist123/fm.plyr.track/track{i}", 90 + atproto_record_cid=f"bafytrack{i}", 91 + ) 92 + db_session.add(track) 93 + tracks.append(track) 94 + 95 + await db_session.commit() 96 + for track in tracks: 97 + await db_session.refresh(track) 98 + await db_session.refresh(album) 99 + 100 + return album, tracks 101 + 102 + 103 + @pytest.fixture 104 + async def test_liked_tracks( 105 + db_session: AsyncSession, test_artist: Artist 106 + ) -> list[Track]: 107 + """create tracks liked by the test user.""" 108 + # create another artist who owns the tracks 109 + other_artist = Artist( 110 + did="did:plc:otherartist", 111 + handle="otherartist.bsky.social", 112 + display_name="Other Artist", 113 + ) 114 + db_session.add(other_artist) 115 + 116 + tracks = [] 117 + for i in range(2): 118 + track = Track( 119 + title=f"Liked Track {i + 1}", 120 + file_id=f"likedfileid{i}", 121 + file_type="audio/mpeg", 122 + artist_did=other_artist.did, 123 + atproto_record_uri=f"at://did:plc:otherartist/fm.plyr.track/liked{i}", 124 + atproto_record_cid=f"bafyliked{i}", 125 + ) 126 + db_session.add(track) 127 + tracks.append(track) 128 + 129 + await db_session.flush() 130 + 131 + # create likes from the test user 132 + for track in tracks: 133 + like = TrackLike( 134 + user_did="did:plc:testartist123", 135 + track_id=track.id, 136 + atproto_like_uri=f"at://did:plc:testartist123/fm.plyr.like/{track.id}", 137 + ) 138 + db_session.add(like) 139 + 140 + await db_session.commit() 141 + for track in tracks: 142 + await db_session.refresh(track) 143 + 144 + return tracks 145 + 146 + 147 + async def test_get_profile_syncs_albums( 148 + test_app: FastAPI, 149 + db_session: AsyncSession, 150 + test_artist: Artist, 151 + test_album_with_tracks: tuple[Album, list[Track]], 152 + ): 153 + """test that GET /artists/me triggers album list record sync.""" 154 + album, _ = test_album_with_tracks 155 + 156 + with ( 157 + patch( 158 + "backend.api.artists.upsert_profile_record", 159 + new_callable=AsyncMock, 160 + return_value=None, 161 + ), 162 + patch( 163 + "backend.api.artists.upsert_album_list_record", 164 + new_callable=AsyncMock, 165 + return_value=( 166 + "at://did:plc:testartist123/fm.plyr.list/album123", 167 + "bafyalbum123", 168 + ), 169 + ) as mock_album_sync, 170 + patch( 171 + "backend.api.artists.upsert_liked_list_record", 172 + new_callable=AsyncMock, 173 + return_value=None, 174 + ), 175 + ): 176 + async with AsyncClient( 177 + transport=ASGITransport(app=test_app), base_url="http://test" 178 + ) as client: 179 + response = await client.get("/artists/me") 180 + 181 + # give background tasks time to run 182 + await asyncio.sleep(0.1) 183 + 184 + assert response.status_code == 200 185 + 186 + # verify album sync was called with correct track refs 187 + mock_album_sync.assert_called_once() 188 + call_args = mock_album_sync.call_args 189 + assert call_args.kwargs["album_id"] == album.id 190 + assert call_args.kwargs["album_title"] == "Test Album" 191 + assert len(call_args.kwargs["track_refs"]) == 3 192 + 193 + 194 + async def test_get_profile_syncs_liked_list( 195 + test_app: FastAPI, 196 + db_session: AsyncSession, 197 + test_artist: Artist, 198 + test_liked_tracks: list[Track], 199 + ): 200 + """test that GET /artists/me triggers liked list record sync.""" 201 + with ( 202 + patch( 203 + "backend.api.artists.upsert_profile_record", 204 + new_callable=AsyncMock, 205 + return_value=None, 206 + ), 207 + patch( 208 + "backend.api.artists.upsert_album_list_record", 209 + new_callable=AsyncMock, 210 + return_value=None, 211 + ), 212 + patch( 213 + "backend.api.artists.upsert_liked_list_record", 214 + new_callable=AsyncMock, 215 + return_value=( 216 + "at://did:plc:testartist123/fm.plyr.list/liked456", 217 + "bafyliked456", 218 + ), 219 + ) as mock_liked_sync, 220 + ): 221 + async with AsyncClient( 222 + transport=ASGITransport(app=test_app), base_url="http://test" 223 + ) as client: 224 + response = await client.get("/artists/me") 225 + 226 + # give background tasks time to run 227 + await asyncio.sleep(0.1) 228 + 229 + assert response.status_code == 200 230 + 231 + # verify liked sync was called with correct track refs 232 + mock_liked_sync.assert_called_once() 233 + call_args = mock_liked_sync.call_args 234 + assert len(call_args.kwargs["track_refs"]) == 2 235 + 236 + 237 + async def test_get_profile_skips_albums_without_atproto_tracks( 238 + test_app: FastAPI, db_session: AsyncSession, test_artist: Artist 239 + ): 240 + """test that albums with no ATProto-enabled tracks are skipped.""" 241 + # create album with tracks that have no ATProto records 242 + album = Album( 243 + artist_did=test_artist.did, 244 + title="Album Without ATProto", 245 + slug="album-without-atproto", 246 + ) 247 + db_session.add(album) 248 + await db_session.flush() 249 + 250 + track = Track( 251 + title="Track Without ATProto", 252 + file_id="noatproto", 253 + file_type="audio/mpeg", 254 + artist_did=test_artist.did, 255 + album_id=album.id, 256 + atproto_record_uri=None, # no ATProto record 257 + atproto_record_cid=None, 258 + ) 259 + db_session.add(track) 260 + await db_session.commit() 261 + 262 + with ( 263 + patch( 264 + "backend.api.artists.upsert_profile_record", 265 + new_callable=AsyncMock, 266 + return_value=None, 267 + ), 268 + patch( 269 + "backend.api.artists.upsert_album_list_record", 270 + new_callable=AsyncMock, 271 + ) as mock_album_sync, 272 + patch( 273 + "backend.api.artists.upsert_liked_list_record", 274 + new_callable=AsyncMock, 275 + ), 276 + ): 277 + async with AsyncClient( 278 + transport=ASGITransport(app=test_app), base_url="http://test" 279 + ) as client: 280 + response = await client.get("/artists/me") 281 + 282 + await asyncio.sleep(0.1) 283 + 284 + assert response.status_code == 200 285 + 286 + # album sync should NOT be called for albums without ATProto tracks 287 + mock_album_sync.assert_not_called() 288 + 289 + 290 + async def test_get_profile_continues_on_album_sync_failure( 291 + test_app: FastAPI, 292 + db_session: AsyncSession, 293 + test_artist: Artist, 294 + test_album_with_tracks: tuple[Album, list[Track]], 295 + ): 296 + """test that profile fetch succeeds even if album sync fails.""" 297 + with ( 298 + patch( 299 + "backend.api.artists.upsert_profile_record", 300 + new_callable=AsyncMock, 301 + return_value=None, 302 + ), 303 + patch( 304 + "backend.api.artists.upsert_album_list_record", 305 + side_effect=Exception("PDS error"), 306 + ), 307 + patch( 308 + "backend.api.artists.upsert_liked_list_record", 309 + new_callable=AsyncMock, 310 + return_value=None, 311 + ), 312 + ): 313 + async with AsyncClient( 314 + transport=ASGITransport(app=test_app), base_url="http://test" 315 + ) as client: 316 + response = await client.get("/artists/me") 317 + 318 + await asyncio.sleep(0.1) 319 + 320 + # request should still succeed 321 + assert response.status_code == 200 322 + data = response.json() 323 + assert data["did"] == "did:plc:testartist123" 324 + 325 + 326 + async def test_get_profile_continues_on_liked_sync_failure( 327 + test_app: FastAPI, 328 + db_session: AsyncSession, 329 + test_artist: Artist, 330 + test_liked_tracks: list[Track], 331 + ): 332 + """test that profile fetch succeeds even if liked sync fails.""" 333 + with ( 334 + patch( 335 + "backend.api.artists.upsert_profile_record", 336 + new_callable=AsyncMock, 337 + return_value=None, 338 + ), 339 + patch( 340 + "backend.api.artists.upsert_album_list_record", 341 + new_callable=AsyncMock, 342 + return_value=None, 343 + ), 344 + patch( 345 + "backend.api.artists.upsert_liked_list_record", 346 + side_effect=Exception("PDS error"), 347 + ), 348 + ): 349 + async with AsyncClient( 350 + transport=ASGITransport(app=test_app), base_url="http://test" 351 + ) as client: 352 + response = await client.get("/artists/me") 353 + 354 + await asyncio.sleep(0.1) 355 + 356 + # request should still succeed 357 + assert response.status_code == 200 358 + data = response.json() 359 + assert data["did"] == "did:plc:testartist123"
+137
backend/tests/api/test_scope_upgrade.py
··· 1 + """tests for scope upgrade OAuth flow.""" 2 + 3 + from collections.abc import Generator 4 + from unittest.mock import AsyncMock, patch 5 + 6 + import pytest 7 + from fastapi import FastAPI 8 + from httpx import ASGITransport, AsyncClient 9 + from sqlalchemy.ext.asyncio import AsyncSession 10 + 11 + from backend._internal import Session, require_auth 12 + from backend.main import app 13 + 14 + 15 + class MockSession(Session): 16 + """mock session for auth bypass in tests.""" 17 + 18 + def __init__(self, did: str = "did:test:user123"): 19 + self.did = did 20 + self.handle = "testuser.bsky.social" 21 + self.session_id = "test_session_id_for_upgrade" 22 + self.access_token = "test_token" 23 + self.refresh_token = "test_refresh" 24 + self.oauth_session = { 25 + "did": did, 26 + "handle": "testuser.bsky.social", 27 + "pds_url": "https://test.pds", 28 + "authserver_iss": "https://auth.test", 29 + "scope": "atproto transition:generic", 30 + "access_token": "test_token", 31 + "refresh_token": "test_refresh", 32 + "dpop_private_key_pem": "fake_key", 33 + "dpop_authserver_nonce": "", 34 + "dpop_pds_nonce": "", 35 + } 36 + 37 + 38 + @pytest.fixture 39 + def test_app(db_session: AsyncSession) -> Generator[FastAPI, None, None]: 40 + """create test app with mocked auth.""" 41 + 42 + async def mock_require_auth() -> Session: 43 + return MockSession() 44 + 45 + app.dependency_overrides[require_auth] = mock_require_auth 46 + 47 + yield app 48 + 49 + app.dependency_overrides.clear() 50 + 51 + 52 + async def test_start_scope_upgrade_flow(test_app: FastAPI, db_session: AsyncSession): 53 + """test starting the scope upgrade OAuth flow.""" 54 + with patch( 55 + "backend.api.auth.start_oauth_flow_with_scopes", new_callable=AsyncMock 56 + ) as mock_oauth: 57 + mock_oauth.return_value = ( 58 + "https://auth.example.com/authorize?scope=teal", 59 + "test_state", 60 + ) 61 + 62 + async with AsyncClient( 63 + transport=ASGITransport(app=test_app), base_url="http://test" 64 + ) as client: 65 + response = await client.post( 66 + "/auth/scope-upgrade/start", 67 + json={"include_teal": True}, 68 + ) 69 + 70 + assert response.status_code == 200 71 + data = response.json() 72 + assert "auth_url" in data 73 + assert data["auth_url"].startswith("https://auth.example.com") 74 + mock_oauth.assert_called_once_with("testuser.bsky.social", include_teal=True) 75 + 76 + 77 + async def test_start_scope_upgrade_default_includes_teal( 78 + test_app: FastAPI, db_session: AsyncSession 79 + ): 80 + """test that scope upgrade defaults to including teal scopes.""" 81 + with patch( 82 + "backend.api.auth.start_oauth_flow_with_scopes", new_callable=AsyncMock 83 + ) as mock_oauth: 84 + mock_oauth.return_value = ("https://auth.example.com/authorize", "test_state") 85 + 86 + async with AsyncClient( 87 + transport=ASGITransport(app=test_app), base_url="http://test" 88 + ) as client: 89 + response = await client.post( 90 + "/auth/scope-upgrade/start", 91 + json={}, # empty body - should default to include_teal=True 92 + ) 93 + 94 + assert response.status_code == 200 95 + mock_oauth.assert_called_once_with("testuser.bsky.social", include_teal=True) 96 + 97 + 98 + async def test_scope_upgrade_requires_auth(db_session: AsyncSession): 99 + """test that scope upgrade requires authentication.""" 100 + async with AsyncClient( 101 + transport=ASGITransport(app=app), base_url="http://test" 102 + ) as client: 103 + response = await client.post( 104 + "/auth/scope-upgrade/start", 105 + json={"include_teal": True}, 106 + ) 107 + 108 + assert response.status_code == 401 109 + 110 + 111 + async def test_scope_upgrade_saves_pending_record( 112 + test_app: FastAPI, db_session: AsyncSession 113 + ): 114 + """test that starting scope upgrade saves pending record.""" 115 + from backend._internal import get_pending_scope_upgrade 116 + 117 + with patch( 118 + "backend.api.auth.start_oauth_flow_with_scopes", new_callable=AsyncMock 119 + ) as mock_oauth: 120 + mock_oauth.return_value = ("https://auth.example.com/authorize", "test_state") 121 + 122 + async with AsyncClient( 123 + transport=ASGITransport(app=test_app), base_url="http://test" 124 + ) as client: 125 + response = await client.post( 126 + "/auth/scope-upgrade/start", 127 + json={"include_teal": True}, 128 + ) 129 + 130 + assert response.status_code == 200 131 + 132 + # verify pending record was saved 133 + pending = await get_pending_scope_upgrade("test_state") 134 + assert pending is not None 135 + assert pending.did == "did:test:user123" 136 + assert pending.old_session_id == "test_session_id_for_upgrade" 137 + assert pending.requested_scopes == "teal"
+146
backend/tests/api/test_users.py
··· 1 + """tests for user api endpoints.""" 2 + 3 + from httpx import ASGITransport, AsyncClient 4 + from sqlalchemy.ext.asyncio import AsyncSession 5 + 6 + from backend.main import app 7 + from backend.models import Artist, Track, TrackLike 8 + 9 + 10 + async def test_get_user_likes_success(db_session: AsyncSession): 11 + """test fetching a user's liked tracks returns correct data.""" 12 + # create liker 13 + liker = Artist( 14 + did="did:plc:liker123", 15 + handle="liker.bsky.social", 16 + display_name="Test Liker", 17 + ) 18 + db_session.add(liker) 19 + 20 + # create artist who uploaded the track 21 + artist = Artist( 22 + did="did:plc:artist123", 23 + handle="artist.bsky.social", 24 + display_name="Test Artist", 25 + ) 26 + db_session.add(artist) 27 + await db_session.flush() 28 + 29 + # create tracks 30 + track1 = Track( 31 + title="Test Track 1", 32 + artist_did=artist.did, 33 + file_id="test001", 34 + file_type="mp3", 35 + extra={"duration": 180}, 36 + atproto_record_uri="at://did:plc:artist123/fm.plyr.track/test001", 37 + atproto_record_cid="bafytest001", 38 + ) 39 + track2 = Track( 40 + title="Test Track 2", 41 + artist_did=artist.did, 42 + file_id="test002", 43 + file_type="mp3", 44 + extra={"duration": 200}, 45 + atproto_record_uri="at://did:plc:artist123/fm.plyr.track/test002", 46 + atproto_record_cid="bafytest002", 47 + ) 48 + db_session.add(track1) 49 + db_session.add(track2) 50 + await db_session.flush() 51 + 52 + # liker likes both tracks 53 + like1 = TrackLike( 54 + track_id=track1.id, 55 + user_did=liker.did, 56 + atproto_like_uri="at://did:plc:liker123/fm.plyr.like/abc123", 57 + ) 58 + like2 = TrackLike( 59 + track_id=track2.id, 60 + user_did=liker.did, 61 + atproto_like_uri="at://did:plc:liker123/fm.plyr.like/def456", 62 + ) 63 + db_session.add(like1) 64 + db_session.add(like2) 65 + await db_session.commit() 66 + 67 + async with AsyncClient( 68 + transport=ASGITransport(app=app), base_url="http://test" 69 + ) as client: 70 + response = await client.get("/users/liker.bsky.social/likes") 71 + 72 + assert response.status_code == 200 73 + data = response.json() 74 + 75 + # verify user info 76 + assert data["user"]["did"] == liker.did 77 + assert data["user"]["handle"] == liker.handle 78 + assert data["user"]["display_name"] == liker.display_name 79 + 80 + # verify tracks 81 + assert data["count"] == 2 82 + assert len(data["tracks"]) == 2 83 + 84 + # tracks should be ordered by like time (newest first) 85 + track_titles = [t["title"] for t in data["tracks"]] 86 + assert "Test Track 1" in track_titles 87 + assert "Test Track 2" in track_titles 88 + 89 + 90 + async def test_get_user_likes_not_found(db_session: AsyncSession): 91 + """test fetching likes for non-existent user returns 404.""" 92 + async with AsyncClient( 93 + transport=ASGITransport(app=app), base_url="http://test" 94 + ) as client: 95 + response = await client.get("/users/nonexistent.bsky.social/likes") 96 + 97 + assert response.status_code == 404 98 + assert response.json()["detail"] == "user not found" 99 + 100 + 101 + async def test_get_user_likes_empty(db_session: AsyncSession): 102 + """test fetching likes for user with no likes returns empty list.""" 103 + # create user with no likes 104 + user = Artist( 105 + did="did:plc:nolikes123", 106 + handle="nolikes.bsky.social", 107 + display_name="No Likes User", 108 + ) 109 + db_session.add(user) 110 + await db_session.commit() 111 + 112 + async with AsyncClient( 113 + transport=ASGITransport(app=app), base_url="http://test" 114 + ) as client: 115 + response = await client.get("/users/nolikes.bsky.social/likes") 116 + 117 + assert response.status_code == 200 118 + data = response.json() 119 + 120 + assert data["user"]["handle"] == "nolikes.bsky.social" 121 + assert data["count"] == 0 122 + assert data["tracks"] == [] 123 + 124 + 125 + async def test_get_user_likes_public_no_auth_required(db_session: AsyncSession): 126 + """test that fetching user likes does not require authentication.""" 127 + # create user 128 + user = Artist( 129 + did="did:plc:publicuser", 130 + handle="publicuser.bsky.social", 131 + display_name="Public User", 132 + ) 133 + db_session.add(user) 134 + await db_session.commit() 135 + 136 + # make request without any auth cookies/headers 137 + async with AsyncClient( 138 + transport=ASGITransport(app=app), base_url="http://test" 139 + ) as client: 140 + response = await client.get( 141 + "/users/publicuser.bsky.social/likes", 142 + # explicitly no cookies or auth headers 143 + ) 144 + 145 + # should work without auth 146 + assert response.status_code == 200
+7
backend/tests/conftest.py
··· 185 185 yield engine 186 186 finally: 187 187 await engine.dispose() 188 + # also dispose any engines cached by production code (database.py) 189 + # to prevent connection accumulation across tests 190 + from backend.utilities.database import ENGINES 191 + 192 + for cached_engine in list(ENGINES.values()): 193 + await cached_engine.dispose() 194 + ENGINES.clear() 188 195 189 196 190 197 @pytest.fixture()
+15
backend/tests/test_image_formats.py
··· 134 134 ) 135 135 assert is_valid is False 136 136 assert image_format is None 137 + 138 + def test_enum_iteration_includes_jpeg_extension(self): 139 + """test that iterating over ImageFormat includes both jpg and jpeg. 140 + 141 + regression test: files uploaded as .jpeg were not found by get_url() 142 + because the enum only had JPEG="jpg", so iteration only checked .jpg files. 143 + """ 144 + extensions = [fmt.value for fmt in ImageFormat] 145 + assert "jpg" in extensions 146 + assert "jpeg" in extensions 147 + 148 + def test_jpeg_alt_media_type(self): 149 + """test that JPEG_ALT has the same media type as JPEG.""" 150 + assert ImageFormat.JPEG_ALT.media_type == "image/jpeg" 151 + assert ImageFormat.JPEG_ALT.media_type == ImageFormat.JPEG.media_type
+26 -9
backend/tests/test_token_refresh.py
··· 83 83 refresh_call_count = 0 84 84 new_token = "new-refreshed-token" 85 85 86 - async def mock_refresh_session(session: OAuthSession) -> OAuthSession: 86 + async def mock_refresh_session(self, session: OAuthSession) -> OAuthSession: 87 87 """mock OAuth client refresh with delay to simulate race.""" 88 88 nonlocal refresh_call_count 89 89 refresh_call_count += 1 ··· 108 108 """mock session update.""" 109 109 mock_auth_session.oauth_session.update(oauth_session_data) 110 110 111 + # create a mock OAuth client with the refresh method 112 + mock_oauth_client = type( 113 + "MockOAuthClient", (), {"refresh_session": mock_refresh_session} 114 + )() 115 + 111 116 with ( 112 117 patch( 113 - "backend._internal.atproto.records.oauth_client.refresh_session", 114 - side_effect=mock_refresh_session, 118 + "backend._internal.atproto.records.get_oauth_client", 119 + return_value=mock_oauth_client, 115 120 ), 116 121 patch( 117 122 "backend._internal.atproto.records.get_session", ··· 142 147 new_token = "new-refreshed-token" 143 148 refresh_called = False 144 149 145 - async def mock_refresh_session_fails(session: OAuthSession) -> OAuthSession: 150 + async def mock_refresh_session_fails( 151 + self, session: OAuthSession 152 + ) -> OAuthSession: 146 153 """mock refresh that always fails.""" 147 154 nonlocal refresh_called 148 155 refresh_called = True ··· 168 175 """mock session update.""" 169 176 mock_auth_session.oauth_session.update(oauth_session_data) 170 177 178 + # create a mock OAuth client with the failing refresh method 179 + mock_oauth_client = type( 180 + "MockOAuthClient", (), {"refresh_session": mock_refresh_session_fails} 181 + )() 182 + 171 183 with ( 172 184 patch( 173 - "backend._internal.atproto.records.oauth_client.refresh_session", 174 - side_effect=mock_refresh_session_fails, 185 + "backend._internal.atproto.records.get_oauth_client", 186 + return_value=mock_oauth_client, 175 187 ), 176 188 patch( 177 189 "backend._internal.atproto.records.get_session", ··· 200 212 refresh_call_count = 0 201 213 new_token = "already-refreshed-token" 202 214 203 - async def mock_refresh_session(session: OAuthSession) -> OAuthSession: 215 + async def mock_refresh_session(self, session: OAuthSession) -> OAuthSession: 204 216 """mock OAuth client refresh.""" 205 217 nonlocal refresh_call_count 206 218 refresh_call_count += 1 ··· 227 239 """mock session update.""" 228 240 mock_auth_session.oauth_session.update(oauth_session_data) 229 241 242 + # create a mock OAuth client with the refresh method 243 + mock_oauth_client = type( 244 + "MockOAuthClient", (), {"refresh_session": mock_refresh_session} 245 + )() 246 + 230 247 with ( 231 248 patch( 232 - "backend._internal.atproto.records.oauth_client.refresh_session", 233 - side_effect=mock_refresh_session, 249 + "backend._internal.atproto.records.get_oauth_client", 250 + return_value=mock_oauth_client, 234 251 ), 235 252 patch( 236 253 "backend._internal.atproto.records.get_session",
+36 -1
docs/backend/liked-tracks.md
··· 8 8 9 9 ### fm.plyr.like namespace 10 10 11 - likes are stored as ATProto records using the `fm.plyr.like` collection: 11 + individual likes are stored as ATProto records using the `fm.plyr.like` collection: 12 12 13 13 ```json 14 14 { ··· 25 25 - `subject.uri` - AT-URI of the liked track 26 26 - `subject.cid` - CID of the track record at time of like 27 27 - `createdAt` - ISO 8601 timestamp 28 + 29 + ### fm.plyr.list namespace (liked list) 30 + 31 + in addition to individual like records, users have a single aggregated "liked tracks" list record: 32 + 33 + ```json 34 + { 35 + "$type": "fm.plyr.list", 36 + "name": "Liked Tracks", 37 + "listType": "liked", 38 + "items": [ 39 + { "subject": { "uri": "at://did:plc:.../fm.plyr.track/abc", "cid": "bafy..." } }, 40 + { "subject": { "uri": "at://did:plc:.../fm.plyr.track/def", "cid": "bafy..." } } 41 + ], 42 + "createdAt": "2025-12-07T00:00:00.000Z", 43 + "updatedAt": "2025-12-07T12:00:00.000Z" 44 + } 45 + ``` 46 + 47 + **fields**: 48 + - `name` - always "Liked Tracks" 49 + - `listType` - always "liked" 50 + - `items` - ordered array of strongRefs (uri + cid) to liked tracks 51 + - `createdAt` - when the list was first created 52 + - `updatedAt` - when the list was last modified 53 + 54 + **sync behavior**: 55 + - the liked list record is synced automatically on login (via `GET /artists/me`) 56 + - sync happens as a fire-and-forget background task, doesn't block the response 57 + - the list URI/CID are stored in `user_preferences.liked_list_uri` and `liked_list_cid` 58 + - liking/unliking a track also triggers a list update via the backend 59 + 60 + **reordering**: 61 + - users can reorder their liked tracks via `PUT /lists/liked/reorder` 62 + - the reordered list is written to the PDS via `putRecord` 28 63 29 64 ### record lifecycle 30 65
+547
frontend/src/lib/components/AddToMenu.svelte
··· 1 + <script lang="ts"> 2 + import { likeTrack, unlikeTrack } from '$lib/tracks.svelte'; 3 + import { toast } from '$lib/toast.svelte'; 4 + import { API_URL } from '$lib/config'; 5 + import type { Playlist } from '$lib/types'; 6 + 7 + interface Props { 8 + trackId: number; 9 + trackTitle: string; 10 + trackUri?: string; 11 + trackCid?: string; 12 + initialLiked?: boolean; 13 + disabled?: boolean; 14 + disabledReason?: string; 15 + onLikeChange?: (_liked: boolean) => void; 16 + excludePlaylistId?: string; 17 + } 18 + 19 + let { 20 + trackId, 21 + trackTitle, 22 + trackUri, 23 + trackCid, 24 + initialLiked = false, 25 + disabled = false, 26 + disabledReason, 27 + onLikeChange, 28 + excludePlaylistId 29 + }: Props = $props(); 30 + 31 + let liked = $state(initialLiked); 32 + let loading = $state(false); 33 + let menuOpen = $state(false); 34 + let showPlaylistPicker = $state(false); 35 + let playlists = $state<Playlist[]>([]); 36 + let loadingPlaylists = $state(false); 37 + let addingToPlaylist = $state<string | null>(null); 38 + 39 + // filter out the excluded playlist (must be after playlists state declaration) 40 + let filteredPlaylists = $derived( 41 + excludePlaylistId ? playlists.filter(p => p.id !== excludePlaylistId) : playlists 42 + ); 43 + 44 + // update liked state when initialLiked changes 45 + $effect(() => { 46 + liked = initialLiked; 47 + }); 48 + 49 + // close menu when clicking outside 50 + function handleClickOutside(event: MouseEvent) { 51 + const target = event.target as HTMLElement; 52 + if (!target.closest('.add-to-menu')) { 53 + menuOpen = false; 54 + showPlaylistPicker = false; 55 + } 56 + } 57 + 58 + $effect(() => { 59 + if (menuOpen) { 60 + document.addEventListener('click', handleClickOutside); 61 + return () => document.removeEventListener('click', handleClickOutside); 62 + } 63 + }); 64 + 65 + function toggleMenu(e: Event) { 66 + e.stopPropagation(); 67 + if (disabled) return; 68 + menuOpen = !menuOpen; 69 + if (!menuOpen) { 70 + showPlaylistPicker = false; 71 + } 72 + } 73 + 74 + async function handleLike(e: Event) { 75 + e.stopPropagation(); 76 + if (loading || disabled) return; 77 + 78 + loading = true; 79 + const previousState = liked; 80 + liked = !liked; 81 + 82 + try { 83 + const success = liked 84 + ? await likeTrack(trackId) 85 + : await unlikeTrack(trackId); 86 + 87 + if (!success) { 88 + liked = previousState; 89 + toast.error('failed to update like'); 90 + } else { 91 + onLikeChange?.(liked); 92 + if (liked) { 93 + toast.success(`liked ${trackTitle}`); 94 + } else { 95 + toast.info(`unliked ${trackTitle}`); 96 + } 97 + } 98 + } catch { 99 + liked = previousState; 100 + toast.error('failed to update like'); 101 + } finally { 102 + loading = false; 103 + menuOpen = false; 104 + } 105 + } 106 + 107 + async function showPlaylists(e: Event) { 108 + e.stopPropagation(); 109 + if (!trackUri || !trackCid) { 110 + toast.error('track cannot be added to playlists'); 111 + return; 112 + } 113 + 114 + showPlaylistPicker = true; 115 + if (playlists.length === 0) { 116 + loadingPlaylists = true; 117 + try { 118 + const response = await fetch(`${API_URL}/lists/playlists`, { 119 + credentials: 'include' 120 + }); 121 + if (response.ok) { 122 + playlists = await response.json(); 123 + } 124 + } catch { 125 + toast.error('failed to load playlists'); 126 + } finally { 127 + loadingPlaylists = false; 128 + } 129 + } 130 + } 131 + 132 + async function addToPlaylist(playlist: Playlist, e: Event) { 133 + e.stopPropagation(); 134 + if (!trackUri || !trackCid) return; 135 + 136 + addingToPlaylist = playlist.id; 137 + try { 138 + const response = await fetch(`${API_URL}/lists/playlists/${playlist.id}/tracks`, { 139 + method: 'POST', 140 + credentials: 'include', 141 + headers: { 'Content-Type': 'application/json' }, 142 + body: JSON.stringify({ 143 + track_uri: trackUri, 144 + track_cid: trackCid 145 + }) 146 + }); 147 + 148 + if (response.ok) { 149 + toast.success(`added to ${playlist.name}`); 150 + menuOpen = false; 151 + showPlaylistPicker = false; 152 + } else { 153 + const data = await response.json().catch(() => ({})); 154 + toast.error(data.detail || 'failed to add to playlist'); 155 + } 156 + } catch { 157 + toast.error('failed to add to playlist'); 158 + } finally { 159 + addingToPlaylist = null; 160 + } 161 + } 162 + 163 + function goBack(e: Event) { 164 + e.stopPropagation(); 165 + showPlaylistPicker = false; 166 + } 167 + </script> 168 + 169 + <div class="add-to-menu"> 170 + <button 171 + class="trigger-button" 172 + class:liked 173 + class:loading 174 + class:disabled-state={disabled} 175 + class:menu-open={menuOpen} 176 + onclick={toggleMenu} 177 + title={disabled && disabledReason ? disabledReason : 'add to...'} 178 + {disabled} 179 + > 180 + <svg width="16" height="16" viewBox="0 0 24 24" fill={liked ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 181 + <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path> 182 + </svg> 183 + </button> 184 + 185 + {#if menuOpen} 186 + <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 187 + <div class="menu-dropdown" role="menu" tabindex="-1" onclick={(e) => e.stopPropagation()}> 188 + {#if !showPlaylistPicker} 189 + <button class="menu-item" onclick={handleLike} disabled={loading}> 190 + <svg width="18" height="18" viewBox="0 0 24 24" fill={liked ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2"> 191 + <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path> 192 + </svg> 193 + <span>{liked ? 'remove from liked' : 'add to liked'}</span> 194 + </button> 195 + {#if trackUri && trackCid} 196 + <button class="menu-item" onclick={showPlaylists}> 197 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 198 + <line x1="8" y1="6" x2="21" y2="6"></line> 199 + <line x1="8" y1="12" x2="21" y2="12"></line> 200 + <line x1="8" y1="18" x2="21" y2="18"></line> 201 + <line x1="3" y1="6" x2="3.01" y2="6"></line> 202 + <line x1="3" y1="12" x2="3.01" y2="12"></line> 203 + <line x1="3" y1="18" x2="3.01" y2="18"></line> 204 + </svg> 205 + <span>add to playlist</span> 206 + <svg class="chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 207 + <path d="M9 18l6-6-6-6"/> 208 + </svg> 209 + </button> 210 + {/if} 211 + {:else} 212 + <div class="playlist-picker"> 213 + <button class="back-button" onclick={goBack}> 214 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 215 + <path d="M15 18l-6-6 6-6"/> 216 + </svg> 217 + <span>back</span> 218 + </button> 219 + <div class="playlist-list"> 220 + {#if loadingPlaylists} 221 + <div class="loading-state"> 222 + <span class="spinner"></span> 223 + <span>loading playlists...</span> 224 + </div> 225 + {:else if filteredPlaylists.length === 0} 226 + <div class="empty-state"> 227 + <span>no playlists yet</span> 228 + </div> 229 + {:else} 230 + {#each filteredPlaylists as playlist} 231 + <button 232 + class="playlist-item" 233 + onclick={(e) => addToPlaylist(playlist, e)} 234 + disabled={addingToPlaylist === playlist.id} 235 + > 236 + {#if playlist.image_url} 237 + <img src={playlist.image_url} alt="" class="playlist-thumb" /> 238 + {:else} 239 + <div class="playlist-thumb-placeholder"> 240 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 241 + <line x1="8" y1="6" x2="21" y2="6"></line> 242 + <line x1="8" y1="12" x2="21" y2="12"></line> 243 + <line x1="8" y1="18" x2="21" y2="18"></line> 244 + <line x1="3" y1="6" x2="3.01" y2="6"></line> 245 + <line x1="3" y1="12" x2="3.01" y2="12"></line> 246 + <line x1="3" y1="18" x2="3.01" y2="18"></line> 247 + </svg> 248 + </div> 249 + {/if} 250 + <span class="playlist-name">{playlist.name}</span> 251 + {#if addingToPlaylist === playlist.id} 252 + <span class="spinner small"></span> 253 + {/if} 254 + </button> 255 + {/each} 256 + {/if} 257 + <a href="/library?create=playlist" class="create-playlist-link" onclick={() => { menuOpen = false; showPlaylistPicker = false; }}> 258 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 259 + <line x1="12" y1="5" x2="12" y2="19"></line> 260 + <line x1="5" y1="12" x2="19" y2="12"></line> 261 + </svg> 262 + <span>create new playlist</span> 263 + </a> 264 + </div> 265 + </div> 266 + {/if} 267 + </div> 268 + {/if} 269 + </div> 270 + 271 + <style> 272 + .add-to-menu { 273 + position: relative; 274 + } 275 + 276 + .trigger-button { 277 + width: 32px; 278 + height: 32px; 279 + display: flex; 280 + align-items: center; 281 + justify-content: center; 282 + background: transparent; 283 + border: 1px solid var(--border-default); 284 + border-radius: 4px; 285 + color: var(--text-tertiary); 286 + cursor: pointer; 287 + transition: all 0.2s; 288 + } 289 + 290 + .trigger-button:hover, 291 + .trigger-button.menu-open { 292 + background: var(--bg-tertiary); 293 + border-color: var(--accent); 294 + color: var(--accent); 295 + } 296 + 297 + .trigger-button.liked { 298 + color: var(--accent); 299 + border-color: var(--accent); 300 + } 301 + 302 + .trigger-button:disabled { 303 + opacity: 0.5; 304 + cursor: not-allowed; 305 + } 306 + 307 + .trigger-button.disabled-state { 308 + opacity: 0.4; 309 + border-color: var(--text-muted); 310 + color: var(--text-muted); 311 + } 312 + 313 + .trigger-button.loading { 314 + animation: pulse 1s ease-in-out infinite; 315 + } 316 + 317 + @keyframes pulse { 318 + 0%, 100% { opacity: 1; } 319 + 50% { opacity: 0.5; } 320 + } 321 + 322 + .trigger-button svg { 323 + width: 16px; 324 + height: 16px; 325 + transition: transform 0.2s; 326 + } 327 + 328 + .menu-dropdown { 329 + position: absolute; 330 + top: calc(100% + 4px); 331 + right: 0; 332 + min-width: 200px; 333 + background: var(--bg-secondary); 334 + border: 1px solid var(--border-default); 335 + border-radius: 8px; 336 + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); 337 + overflow: hidden; 338 + z-index: 10; 339 + } 340 + 341 + .menu-item { 342 + width: 100%; 343 + display: flex; 344 + align-items: center; 345 + gap: 0.75rem; 346 + padding: 0.75rem 1rem; 347 + background: transparent; 348 + border: none; 349 + color: var(--text-primary); 350 + font-size: 0.9rem; 351 + font-family: inherit; 352 + cursor: pointer; 353 + transition: background 0.15s; 354 + text-align: left; 355 + } 356 + 357 + .menu-item:hover { 358 + background: var(--bg-tertiary); 359 + } 360 + 361 + .menu-item:disabled { 362 + opacity: 0.5; 363 + } 364 + 365 + .menu-item .chevron { 366 + margin-left: auto; 367 + color: var(--text-muted); 368 + } 369 + 370 + .playlist-picker { 371 + display: flex; 372 + flex-direction: column; 373 + } 374 + 375 + .back-button { 376 + display: flex; 377 + align-items: center; 378 + gap: 0.5rem; 379 + padding: 0.75rem 1rem; 380 + background: transparent; 381 + border: none; 382 + border-bottom: 1px solid var(--border-subtle); 383 + color: var(--text-secondary); 384 + font-size: 0.85rem; 385 + font-family: inherit; 386 + cursor: pointer; 387 + transition: background 0.15s; 388 + } 389 + 390 + .back-button:hover { 391 + background: var(--bg-tertiary); 392 + } 393 + 394 + .playlist-list { 395 + max-height: 240px; 396 + overflow-y: auto; 397 + } 398 + 399 + .playlist-item { 400 + width: 100%; 401 + display: flex; 402 + align-items: center; 403 + gap: 0.75rem; 404 + padding: 0.625rem 1rem; 405 + background: transparent; 406 + border: none; 407 + color: var(--text-primary); 408 + font-size: 0.9rem; 409 + font-family: inherit; 410 + cursor: pointer; 411 + transition: background 0.15s; 412 + text-align: left; 413 + } 414 + 415 + .playlist-item:hover { 416 + background: var(--bg-tertiary); 417 + } 418 + 419 + .playlist-item:disabled { 420 + opacity: 0.6; 421 + } 422 + 423 + .playlist-thumb, 424 + .playlist-thumb-placeholder { 425 + width: 32px; 426 + height: 32px; 427 + border-radius: 4px; 428 + flex-shrink: 0; 429 + } 430 + 431 + .playlist-thumb { 432 + object-fit: cover; 433 + } 434 + 435 + .playlist-thumb-placeholder { 436 + background: var(--bg-tertiary); 437 + display: flex; 438 + align-items: center; 439 + justify-content: center; 440 + color: var(--text-muted); 441 + } 442 + 443 + .playlist-name { 444 + flex: 1; 445 + min-width: 0; 446 + overflow: hidden; 447 + text-overflow: ellipsis; 448 + white-space: nowrap; 449 + } 450 + 451 + .loading-state, 452 + .empty-state { 453 + display: flex; 454 + align-items: center; 455 + justify-content: center; 456 + gap: 0.5rem; 457 + padding: 1.5rem 1rem; 458 + color: var(--text-tertiary); 459 + font-size: 0.85rem; 460 + } 461 + 462 + .create-playlist-link { 463 + display: flex; 464 + align-items: center; 465 + gap: 0.75rem; 466 + padding: 0.625rem 1rem; 467 + color: var(--accent); 468 + font-size: 0.9rem; 469 + font-family: inherit; 470 + text-decoration: none; 471 + border-top: 1px solid var(--border-subtle); 472 + transition: background 0.15s; 473 + } 474 + 475 + .create-playlist-link:hover { 476 + background: var(--bg-tertiary); 477 + } 478 + 479 + .spinner { 480 + width: 16px; 481 + height: 16px; 482 + border: 2px solid var(--border-default); 483 + border-top-color: var(--accent); 484 + border-radius: 50%; 485 + animation: spin 0.8s linear infinite; 486 + } 487 + 488 + .spinner.small { 489 + width: 14px; 490 + height: 14px; 491 + } 492 + 493 + @keyframes spin { 494 + to { transform: rotate(360deg); } 495 + } 496 + 497 + /* mobile: show as bottom sheet */ 498 + @media (max-width: 768px) { 499 + .trigger-button { 500 + width: 28px; 501 + height: 28px; 502 + } 503 + 504 + .trigger-button svg { 505 + width: 14px; 506 + height: 14px; 507 + } 508 + 509 + .menu-dropdown { 510 + position: fixed; 511 + top: auto; 512 + bottom: 0; 513 + left: 0; 514 + right: 0; 515 + min-width: 100%; 516 + border-radius: 16px 16px 0 0; 517 + padding-bottom: env(safe-area-inset-bottom, 0); 518 + animation: slideUp 0.2s ease-out; 519 + } 520 + 521 + @keyframes slideUp { 522 + from { 523 + transform: translateY(100%); 524 + } 525 + to { 526 + transform: translateY(0); 527 + } 528 + } 529 + 530 + .menu-item { 531 + padding: 1rem 1.25rem; 532 + font-size: 1rem; 533 + } 534 + 535 + .back-button { 536 + padding: 1rem 1.25rem; 537 + } 538 + 539 + .playlist-item { 540 + padding: 0.875rem 1.25rem; 541 + } 542 + 543 + .playlist-list { 544 + max-height: 50vh; 545 + } 546 + } 547 + </style>
+5 -24
frontend/src/lib/components/Header.svelte
··· 94 94 </svg> 95 95 </a> 96 96 {/if} 97 - {#if isAuthenticated && $page.url.pathname !== '/liked'} 98 - <a href="/liked" class="nav-icon" title="go to liked tracks"> 97 + {#if isAuthenticated && !$page.url.pathname.startsWith('/library')} 98 + <a href="/library" class="nav-icon" title="go to library"> 99 99 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 100 100 <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path> 101 101 </svg> ··· 117 117 <span>feed</span> 118 118 </a> 119 119 {/if} 120 - {#if $page.url.pathname !== '/liked'} 121 - <a href="/liked" class="nav-link" title="go to liked tracks"> 120 + {#if !$page.url.pathname.startsWith('/library')} 121 + <a href="/library" class="nav-link" title="go to library"> 122 122 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 123 123 <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path> 124 124 </svg> 125 - <span>liked</span> 125 + <span>library</span> 126 126 </a> 127 127 {/if} 128 128 {#if $page.url.pathname !== '/portal'} ··· 371 371 color: var(--bg-primary); 372 372 } 373 373 374 - .btn-logout { 375 - background: transparent; 376 - border: 1px solid var(--border-emphasis); 377 - color: var(--text-secondary); 378 - padding: 0.5rem 1rem; 379 - border-radius: 6px; 380 - font-size: 0.9rem; 381 - font-family: inherit; 382 - cursor: pointer; 383 - transition: all 0.2s; 384 - white-space: nowrap; 385 - } 386 - 387 - .btn-logout:hover { 388 - border-color: var(--accent); 389 - color: var(--accent); 390 - } 391 - 392 374 /* Show LinksMenu (with stats) when sidebar is hidden */ 393 375 @media (max-width: 1299px) { 394 376 .desktop-only { ··· 441 423 padding: 0.3rem 0.5rem; 442 424 } 443 425 444 - .btn-logout, 445 426 .btn-primary { 446 427 font-size: 0.8rem; 447 428 padding: 0.3rem 0.65rem;
+2 -1
frontend/src/lib/components/LikersTooltip.svelte
··· 99 99 <div class="likers-list"> 100 100 {#each likers as liker} 101 101 <a 102 - href="/u/{liker.handle}" 102 + href="/liked/{liker.handle}" 103 103 class="liker" 104 + title="view {liker.display_name}'s liked tracks" 104 105 > 105 106 {#if liker.avatar_url} 106 107 <SensitiveImage src={liker.avatar_url} compact>
+22 -34
frontend/src/lib/components/SearchModal.svelte
··· 76 76 return result.image_url; 77 77 case 'tag': 78 78 return null; 79 + case 'playlist': 80 + return result.image_url; 79 81 } 80 82 } 81 83 ··· 89 91 return result.title; 90 92 case 'tag': 91 93 return result.name; 94 + case 'playlist': 95 + return result.name; 92 96 } 93 97 } 94 98 ··· 102 106 return `by ${result.artist_display_name}`; 103 107 case 'tag': 104 108 return `${result.track_count} track${result.track_count === 1 ? '' : 's'}`; 109 + case 'playlist': 110 + return `by ${result.owner_display_name} · ${result.track_count} track${result.track_count === 1 ? '' : 's'}`; 105 111 } 106 112 } 107 113 ··· 129 135 <div 130 136 class="search-backdrop" 131 137 class:open={search.isOpen} 138 + role="presentation" 132 139 onclick={handleBackdropClick} 133 140 > 134 141 <div class="search-modal" role="dialog" aria-modal="true" aria-label="search"> ··· 141 148 bind:this={inputRef} 142 149 type="text" 143 150 class="search-input" 144 - placeholder="search tracks, artists, albums, tags..." 151 + placeholder="search tracks, artists, albums, playlists..." 145 152 value={search.query} 146 153 oninput={(e) => search.setQuery(e.currentTarget.value)} 147 154 autocomplete="off" ··· 158 165 159 166 {#if search.results.length > 0} 160 167 <div class="search-results"> 161 - {#each search.results as result, index (result.type + '-' + (result.type === 'track' ? result.id : result.type === 'artist' ? result.did : result.type === 'album' ? result.id : result.id))} 168 + {#each search.results as result, index (result.type + '-' + (result.type === 'track' ? result.id : result.type === 'artist' ? result.did : result.type === 'album' ? result.id : result.type === 'playlist' ? result.id : result.id))} 162 169 {@const imageUrl = getResultImage(result)} 163 170 <button 164 171 class="search-result" ··· 174 181 alt="" 175 182 class="result-image" 176 183 loading="lazy" 177 - onerror={(e) => ((e.currentTarget as HTMLImageElement).style.display = 'none')} 178 184 /> 179 185 </SensitiveImage> 180 - <!-- fallback icon shown if image fails to load --> 181 - <span class="result-icon-fallback"> 182 - {#if result.type === 'track'} 183 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 184 - <path d="M9 18V5l12-2v13"></path> 185 - <circle cx="6" cy="18" r="3"></circle> 186 - <circle cx="18" cy="16" r="3"></circle> 187 - </svg> 188 - {:else if result.type === 'artist'} 189 - <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"> 190 - <circle cx="8" cy="5" r="3" fill="none" /> 191 - <path d="M3 14c0-2.5 2-4.5 5-4.5s5 2 5 4.5" stroke-linecap="round" /> 192 - </svg> 193 - {:else if result.type === 'album'} 194 - <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"> 195 - <rect x="2" y="2" width="12" height="12" fill="none" /> 196 - <circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none" /> 197 - </svg> 198 - {/if} 199 - </span> 200 186 {:else if result.type === 'track'} 201 187 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 202 188 <path d="M9 18V5l12-2v13"></path> ··· 217 203 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 218 204 <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path> 219 205 <line x1="7" y1="7" x2="7.01" y2="7"></line> 206 + </svg> 207 + {:else if result.type === 'playlist'} 208 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 209 + <line x1="8" y1="6" x2="21" y2="6"></line> 210 + <line x1="8" y1="12" x2="21" y2="12"></line> 211 + <line x1="8" y1="18" x2="21" y2="18"></line> 212 + <line x1="3" y1="6" x2="3.01" y2="6"></line> 213 + <line x1="3" y1="12" x2="3.01" y2="12"></line> 214 + <line x1="3" y1="18" x2="3.01" y2="18"></line> 220 215 </svg> 221 216 {/if} 222 217 </span> ··· 417 412 border-radius: 8px; 418 413 } 419 414 420 - .result-icon-fallback { 421 - /* shown behind image, visible if image fails */ 422 - position: relative; 423 - z-index: 0; 424 - } 425 - 426 - .result-image + .result-icon-fallback { 427 - /* hide fallback when image is present and loaded */ 428 - opacity: 0; 429 - } 430 - 431 415 .result-icon[data-type='track'] { 432 416 color: var(--accent); 433 417 } ··· 442 426 443 427 .result-icon[data-type='tag'] { 444 428 color: #fbbf24; 429 + } 430 + 431 + .result-icon[data-type='playlist'] { 432 + color: #f472b6; 445 433 } 446 434 447 435 .result-content {
+427 -64
frontend/src/lib/components/TrackActionsMenu.svelte
··· 1 1 <script lang="ts"> 2 2 import { likeTrack, unlikeTrack } from '$lib/tracks.svelte'; 3 3 import { toast } from '$lib/toast.svelte'; 4 + import { API_URL } from '$lib/config'; 5 + import type { Playlist } from '$lib/types'; 4 6 5 7 interface Props { 6 8 trackId: number; 7 9 trackTitle: string; 10 + trackUri?: string; 11 + trackCid?: string; 8 12 initialLiked: boolean; 9 13 shareUrl: string; 10 14 onQueue: () => void; 11 15 isAuthenticated: boolean; 12 16 likeDisabled?: boolean; 17 + excludePlaylistId?: string; 13 18 } 14 19 15 - let { trackId, trackTitle, initialLiked, shareUrl, onQueue, isAuthenticated, likeDisabled = false }: Props = $props(); 20 + let { 21 + trackId, 22 + trackTitle, 23 + trackUri, 24 + trackCid, 25 + initialLiked, 26 + shareUrl, 27 + onQueue, 28 + isAuthenticated, 29 + likeDisabled = false, 30 + excludePlaylistId 31 + }: Props = $props(); 16 32 17 33 let showMenu = $state(false); 34 + let showPlaylistPicker = $state(false); 18 35 let liked = $state(initialLiked); 19 36 let loading = $state(false); 37 + let playlists = $state<Playlist[]>([]); 38 + let loadingPlaylists = $state(false); 39 + let addingToPlaylist = $state<string | null>(null); 40 + 41 + // filter out the excluded playlist (must be after playlists state declaration) 42 + let filteredPlaylists = $derived( 43 + excludePlaylistId ? playlists.filter(p => p.id !== excludePlaylistId) : playlists 44 + ); 20 45 21 46 // update liked state when initialLiked changes 22 47 $effect(() => { ··· 26 51 function toggleMenu(e: Event) { 27 52 e.stopPropagation(); 28 53 showMenu = !showMenu; 54 + if (!showMenu) { 55 + showPlaylistPicker = false; 56 + } 29 57 } 30 58 31 59 function closeMenu() { 32 60 showMenu = false; 61 + showPlaylistPicker = false; 33 62 } 34 63 35 64 function handleQueue(e: Event) { ··· 42 71 e.stopPropagation(); 43 72 try { 44 73 await navigator.clipboard.writeText(shareUrl); 45 - console.log('copied to clipboard:', shareUrl); 46 74 toast.success('link copied'); 47 75 closeMenu(); 48 - } catch (err) { 49 - console.error('failed to copy:', err); 76 + } catch { 50 77 toast.error('failed to copy link'); 51 78 } 52 79 } ··· 63 90 64 91 loading = true; 65 92 const previousState = liked; 66 - 67 - // optimistic update 68 93 liked = !liked; 69 94 70 95 try { ··· 73 98 : await unlikeTrack(trackId); 74 99 75 100 if (!success) { 76 - // revert on failure 77 101 liked = previousState; 78 102 toast.error('failed to update like'); 79 103 } else { 80 - // show success feedback 81 104 if (liked) { 82 105 toast.success(`liked ${trackTitle}`); 83 106 } else { ··· 86 109 } 87 110 closeMenu(); 88 111 } catch { 89 - // revert on error 90 112 liked = previousState; 91 113 toast.error('failed to update like'); 92 114 } finally { 93 115 loading = false; 94 116 } 95 117 } 118 + 119 + async function showPlaylists(e: Event) { 120 + e.stopPropagation(); 121 + if (!trackUri || !trackCid) { 122 + toast.error('track cannot be added to playlists'); 123 + return; 124 + } 125 + 126 + showPlaylistPicker = true; 127 + if (playlists.length === 0) { 128 + loadingPlaylists = true; 129 + try { 130 + const response = await fetch(`${API_URL}/lists/playlists`, { 131 + credentials: 'include' 132 + }); 133 + if (response.ok) { 134 + playlists = await response.json(); 135 + } 136 + } catch { 137 + toast.error('failed to load playlists'); 138 + } finally { 139 + loadingPlaylists = false; 140 + } 141 + } 142 + } 143 + 144 + async function addToPlaylist(playlist: Playlist, e: Event) { 145 + e.stopPropagation(); 146 + if (!trackUri || !trackCid) return; 147 + 148 + addingToPlaylist = playlist.id; 149 + try { 150 + const response = await fetch(`${API_URL}/lists/playlists/${playlist.id}/tracks`, { 151 + method: 'POST', 152 + credentials: 'include', 153 + headers: { 'Content-Type': 'application/json' }, 154 + body: JSON.stringify({ 155 + track_uri: trackUri, 156 + track_cid: trackCid 157 + }) 158 + }); 159 + 160 + if (response.ok) { 161 + toast.success(`added to ${playlist.name}`); 162 + closeMenu(); 163 + } else { 164 + const data = await response.json().catch(() => ({})); 165 + toast.error(data.detail || 'failed to add to playlist'); 166 + } 167 + } catch { 168 + toast.error('failed to add to playlist'); 169 + } finally { 170 + addingToPlaylist = null; 171 + } 172 + } 173 + 174 + function goBack(e: Event) { 175 + e.stopPropagation(); 176 + showPlaylistPicker = false; 177 + } 96 178 </script> 97 179 98 180 <div class="actions-menu"> ··· 105 187 </button> 106 188 107 189 {#if showMenu} 108 - <!-- svelte-ignore a11y_click_events_have_key_events --> 109 - <!-- svelte-ignore a11y_no_static_element_interactions --> 110 - <div class="menu-backdrop" onclick={closeMenu}></div> 111 - <div class="menu-panel"> 112 - {#if isAuthenticated} 113 - <button class="menu-item" onclick={handleLike} disabled={loading || likeDisabled} class:disabled={likeDisabled}> 114 - <svg width="18" height="18" viewBox="0 0 24 24" fill={liked ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 115 - <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path> 190 + <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 191 + <div class="menu-backdrop" role="presentation" onclick={closeMenu}></div> 192 + <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 193 + <div class="menu-panel" role="menu" tabindex="-1" onclick={(e) => e.stopPropagation()}> 194 + {#if !showPlaylistPicker} 195 + {#if isAuthenticated} 196 + <button class="menu-item" onclick={handleLike} disabled={loading || likeDisabled} class:disabled={likeDisabled}> 197 + <svg width="18" height="18" viewBox="0 0 24 24" fill={liked ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 198 + <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path> 199 + </svg> 200 + <span>{liked ? 'remove from liked' : 'add to liked'}</span> 201 + </button> 202 + {#if trackUri && trackCid} 203 + <button class="menu-item" onclick={showPlaylists}> 204 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 205 + <line x1="8" y1="6" x2="21" y2="6"></line> 206 + <line x1="8" y1="12" x2="21" y2="12"></line> 207 + <line x1="8" y1="18" x2="21" y2="18"></line> 208 + <line x1="3" y1="6" x2="3.01" y2="6"></line> 209 + <line x1="3" y1="12" x2="3.01" y2="12"></line> 210 + <line x1="3" y1="18" x2="3.01" y2="18"></line> 211 + </svg> 212 + <span>add to playlist</span> 213 + <svg class="chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 214 + <path d="M9 18l6-6-6-6"/> 215 + </svg> 216 + </button> 217 + {/if} 218 + {/if} 219 + <button class="menu-item" onclick={handleQueue}> 220 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> 221 + <line x1="5" y1="15" x2="5" y2="21"></line> 222 + <line x1="2" y1="18" x2="8" y2="18"></line> 223 + <line x1="9" y1="6" x2="21" y2="6"></line> 224 + <line x1="9" y1="12" x2="21" y2="12"></line> 225 + <line x1="9" y1="18" x2="21" y2="18"></line> 116 226 </svg> 117 - <span>{liked ? 'unlike' : 'like'}{likeDisabled ? ' (unavailable)' : ''}</span> 227 + <span>add to queue</span> 118 228 </button> 229 + <button class="menu-item" onclick={handleShare}> 230 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 231 + <circle cx="18" cy="5" r="3"></circle> 232 + <circle cx="6" cy="12" r="3"></circle> 233 + <circle cx="18" cy="19" r="3"></circle> 234 + <line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line> 235 + <line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line> 236 + </svg> 237 + <span>share</span> 238 + </button> 239 + {:else} 240 + <div class="playlist-picker"> 241 + <button class="back-button" onclick={goBack}> 242 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 243 + <path d="M15 18l-6-6 6-6"/> 244 + </svg> 245 + <span>back</span> 246 + </button> 247 + <div class="playlist-list"> 248 + {#if loadingPlaylists} 249 + <div class="loading-state"> 250 + <span class="spinner"></span> 251 + <span>loading...</span> 252 + </div> 253 + {:else if filteredPlaylists.length === 0} 254 + <div class="empty-state"> 255 + <span>no playlists</span> 256 + </div> 257 + {:else} 258 + {#each filteredPlaylists as playlist} 259 + <button 260 + class="playlist-item" 261 + onclick={(e) => addToPlaylist(playlist, e)} 262 + disabled={addingToPlaylist === playlist.id} 263 + > 264 + {#if playlist.image_url} 265 + <img src={playlist.image_url} alt="" class="playlist-thumb" /> 266 + {:else} 267 + <div class="playlist-thumb-placeholder"> 268 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 269 + <line x1="8" y1="6" x2="21" y2="6"></line> 270 + <line x1="8" y1="12" x2="21" y2="12"></line> 271 + <line x1="8" y1="18" x2="21" y2="18"></line> 272 + <line x1="3" y1="6" x2="3.01" y2="6"></line> 273 + <line x1="3" y1="12" x2="3.01" y2="12"></line> 274 + <line x1="3" y1="18" x2="3.01" y2="18"></line> 275 + </svg> 276 + </div> 277 + {/if} 278 + <span class="playlist-name">{playlist.name}</span> 279 + {#if addingToPlaylist === playlist.id} 280 + <span class="spinner small"></span> 281 + {/if} 282 + </button> 283 + {/each} 284 + {/if} 285 + <a href="/library?create=playlist" class="create-playlist-link" onclick={closeMenu}> 286 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 287 + <line x1="12" y1="5" x2="12" y2="19"></line> 288 + <line x1="5" y1="12" x2="19" y2="12"></line> 289 + </svg> 290 + <span>create new playlist</span> 291 + </a> 292 + </div> 293 + </div> 119 294 {/if} 120 - <button class="menu-item" onclick={handleQueue}> 121 - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> 122 - <line x1="5" y1="15" x2="5" y2="21"></line> 123 - <line x1="2" y1="18" x2="8" y2="18"></line> 124 - <line x1="9" y1="6" x2="21" y2="6"></line> 125 - <line x1="9" y1="12" x2="21" y2="12"></line> 126 - <line x1="9" y1="18" x2="21" y2="18"></line> 127 - </svg> 128 - <span>add to queue</span> 129 - </button> 130 - <button class="menu-item" onclick={handleShare}> 131 - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 132 - <circle cx="18" cy="5" r="3"></circle> 133 - <circle cx="6" cy="12" r="3"></circle> 134 - <circle cx="18" cy="19" r="3"></circle> 135 - <line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line> 136 - <line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line> 137 - </svg> 138 - <span>share</span> 139 - </button> 140 295 </div> 141 296 {/if} 142 297 </div> ··· 173 328 right: 0; 174 329 bottom: 0; 175 330 z-index: 100; 331 + background: rgba(0, 0, 0, 0.4); 176 332 } 177 333 178 334 .menu-panel { 179 - position: absolute; 180 - right: 100%; 181 - top: 50%; 182 - transform: translateY(-50%); 183 - margin-right: 0.5rem; 335 + position: fixed; 336 + bottom: 0; 337 + left: 0; 338 + right: 0; 184 339 background: var(--bg-secondary); 185 - border: 1px solid var(--border-default); 186 - border-radius: 8px; 187 - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); 340 + border-radius: 16px 16px 0 0; 341 + box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.4); 188 342 z-index: 101; 189 - animation: slideIn 0.15s cubic-bezier(0.16, 1, 0.3, 1); 190 - min-width: 140px; 343 + animation: slideUp 0.2s ease-out; 344 + padding-bottom: env(safe-area-inset-bottom, 0); 345 + max-height: 70vh; 346 + overflow-y: auto; 347 + } 348 + 349 + @keyframes slideUp { 350 + from { 351 + transform: translateY(100%); 352 + } 353 + to { 354 + transform: translateY(0); 355 + } 191 356 } 192 357 193 358 .menu-item { ··· 196 361 gap: 0.75rem; 197 362 background: transparent; 198 363 border: none; 199 - color: var(--text-secondary); 364 + color: var(--text-primary); 200 365 cursor: pointer; 201 - padding: 0.75rem 1rem; 202 - transition: all 0.2s; 366 + padding: 1rem 1.25rem; 367 + transition: all 0.15s; 203 368 font-family: inherit; 204 369 width: 100%; 205 370 text-align: left; ··· 210 375 border-bottom: none; 211 376 } 212 377 213 - .menu-item:hover { 214 - background: var(--bg-hover); 215 - color: var(--text-primary); 378 + .menu-item:hover, 379 + .menu-item:active { 380 + background: var(--bg-tertiary); 216 381 } 217 382 218 383 .menu-item span { 219 - font-size: 0.9rem; 384 + font-size: 1rem; 220 385 font-weight: 400; 221 386 flex: 1; 222 387 } 223 388 224 389 .menu-item svg { 225 - width: 18px; 226 - height: 18px; 390 + width: 20px; 391 + height: 20px; 227 392 flex-shrink: 0; 228 393 } 229 394 395 + .menu-item .chevron { 396 + color: var(--text-muted); 397 + } 398 + 230 399 .menu-item:disabled { 231 400 opacity: 0.5; 232 401 cursor: not-allowed; 233 402 } 234 403 235 - @keyframes slideIn { 236 - from { 237 - opacity: 0; 238 - transform: translateY(-50%) scale(0.95); 404 + .playlist-picker { 405 + display: flex; 406 + flex-direction: column; 407 + } 408 + 409 + .back-button { 410 + display: flex; 411 + align-items: center; 412 + gap: 0.5rem; 413 + padding: 1rem 1.25rem; 414 + background: transparent; 415 + border: none; 416 + border-bottom: 1px solid var(--border-default); 417 + color: var(--text-secondary); 418 + font-size: 0.9rem; 419 + font-family: inherit; 420 + cursor: pointer; 421 + transition: background 0.15s; 422 + } 423 + 424 + .back-button:hover, 425 + .back-button:active { 426 + background: var(--bg-tertiary); 427 + } 428 + 429 + .playlist-list { 430 + max-height: 50vh; 431 + overflow-y: auto; 432 + } 433 + 434 + .playlist-item { 435 + width: 100%; 436 + display: flex; 437 + align-items: center; 438 + gap: 0.75rem; 439 + padding: 0.875rem 1.25rem; 440 + background: transparent; 441 + border: none; 442 + border-bottom: 1px solid var(--border-subtle); 443 + color: var(--text-primary); 444 + font-size: 1rem; 445 + font-family: inherit; 446 + cursor: pointer; 447 + transition: background 0.15s; 448 + text-align: left; 449 + } 450 + 451 + .playlist-item:last-child { 452 + border-bottom: none; 453 + } 454 + 455 + .playlist-item:hover, 456 + .playlist-item:active { 457 + background: var(--bg-tertiary); 458 + } 459 + 460 + .playlist-item:disabled { 461 + opacity: 0.6; 462 + } 463 + 464 + .playlist-thumb, 465 + .playlist-thumb-placeholder { 466 + width: 36px; 467 + height: 36px; 468 + border-radius: 4px; 469 + flex-shrink: 0; 470 + } 471 + 472 + .playlist-thumb { 473 + object-fit: cover; 474 + } 475 + 476 + .playlist-thumb-placeholder { 477 + background: var(--bg-tertiary); 478 + display: flex; 479 + align-items: center; 480 + justify-content: center; 481 + color: var(--text-muted); 482 + } 483 + 484 + .playlist-name { 485 + flex: 1; 486 + min-width: 0; 487 + overflow: hidden; 488 + text-overflow: ellipsis; 489 + white-space: nowrap; 490 + } 491 + 492 + .loading-state, 493 + .empty-state { 494 + display: flex; 495 + align-items: center; 496 + justify-content: center; 497 + gap: 0.5rem; 498 + padding: 2rem 1rem; 499 + color: var(--text-tertiary); 500 + font-size: 0.9rem; 501 + } 502 + 503 + .create-playlist-link { 504 + display: flex; 505 + align-items: center; 506 + gap: 0.75rem; 507 + padding: 0.875rem 1.25rem; 508 + color: var(--accent); 509 + font-size: 1rem; 510 + font-family: inherit; 511 + text-decoration: none; 512 + border-top: 1px solid var(--border-subtle); 513 + transition: background 0.15s; 514 + } 515 + 516 + .create-playlist-link:hover, 517 + .create-playlist-link:active { 518 + background: var(--bg-tertiary); 519 + } 520 + 521 + .spinner { 522 + width: 18px; 523 + height: 18px; 524 + border: 2px solid var(--border-default); 525 + border-top-color: var(--accent); 526 + border-radius: 50%; 527 + animation: spin 0.8s linear infinite; 528 + } 529 + 530 + .spinner.small { 531 + width: 16px; 532 + height: 16px; 533 + } 534 + 535 + @keyframes spin { 536 + to { transform: rotate(360deg); } 537 + } 538 + 539 + /* desktop: show as dropdown instead of bottom sheet */ 540 + @media (min-width: 769px) { 541 + .menu-backdrop { 542 + background: transparent; 239 543 } 240 - to { 241 - opacity: 1; 242 - transform: translateY(-50%) scale(1); 544 + 545 + .menu-panel { 546 + position: absolute; 547 + bottom: auto; 548 + left: auto; 549 + right: 100%; 550 + top: 50%; 551 + transform: translateY(-50%); 552 + margin-right: 0.5rem; 553 + border-radius: 8px; 554 + min-width: 180px; 555 + max-height: none; 556 + animation: slideIn 0.15s cubic-bezier(0.16, 1, 0.3, 1); 557 + padding-bottom: 0; 558 + } 559 + 560 + @keyframes slideIn { 561 + from { 562 + opacity: 0; 563 + transform: translateY(-50%) scale(0.95); 564 + } 565 + to { 566 + opacity: 1; 567 + transform: translateY(-50%) scale(1); 568 + } 569 + } 570 + 571 + .menu-item { 572 + padding: 0.75rem 1rem; 573 + } 574 + 575 + .menu-item span { 576 + font-size: 0.9rem; 577 + } 578 + 579 + .menu-item svg { 580 + width: 18px; 581 + height: 18px; 582 + } 583 + 584 + .back-button { 585 + padding: 0.75rem 1rem; 586 + } 587 + 588 + .playlist-item { 589 + padding: 0.625rem 1rem; 590 + font-size: 0.9rem; 591 + } 592 + 593 + .playlist-thumb, 594 + .playlist-thumb-placeholder { 595 + width: 32px; 596 + height: 32px; 597 + } 598 + 599 + .playlist-list { 600 + max-height: 200px; 601 + } 602 + 603 + .loading-state, 604 + .empty-state { 605 + padding: 1.5rem 1rem; 243 606 } 244 607 } 245 608 </style>
+35 -7
frontend/src/lib/components/TrackItem.svelte
··· 1 1 <script lang="ts"> 2 2 import ShareButton from './ShareButton.svelte'; 3 - import LikeButton from './LikeButton.svelte'; 3 + import AddToMenu from './AddToMenu.svelte'; 4 4 import TrackActionsMenu from './TrackActionsMenu.svelte'; 5 5 import LikersTooltip from './LikersTooltip.svelte'; 6 6 import SensitiveImage from './SensitiveImage.svelte'; ··· 16 16 hideAlbum?: boolean; 17 17 hideArtist?: boolean; 18 18 index?: number; 19 + showIndex?: boolean; 20 + excludePlaylistId?: string; 19 21 } 20 22 21 23 let { ··· 25 27 isAuthenticated = false, 26 28 hideAlbum = false, 27 29 hideArtist = false, 28 - index = 0 30 + index = 0, 31 + showIndex = false, 32 + excludePlaylistId 29 33 }: Props = $props(); 30 34 31 35 // optimize image loading: eager for first 3, lazy for rest ··· 113 117 </script> 114 118 115 119 <div class="track-container" class:playing={isPlaying} class:likers-tooltip-open={showLikersTooltip}> 120 + {#if showIndex} 121 + <span class="track-index">{index + 1}</span> 122 + {/if} 116 123 <button 117 124 class="track" 118 125 onclick={(e) => { ··· 208 215 <a href="/tag/{encodeURIComponent(tag)}" class="tag-badge">{tag}</a> 209 216 {/each} 210 217 {#if hiddenTagCount > 0 && !tagsExpanded} 211 - <button 218 + <span 212 219 class="tags-more" 220 + role="button" 221 + tabindex="0" 213 222 onclick={(e) => { e.stopPropagation(); tagsExpanded = true; }} 223 + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); tagsExpanded = true; } }} 214 224 > 215 225 +{hiddenTagCount} 216 - </button> 226 + </span> 217 227 {/if} 218 228 </span> 219 229 {/if} ··· 265 275 <!-- desktop: show individual buttons --> 266 276 <div class="desktop-actions"> 267 277 {#if isAuthenticated} 268 - <LikeButton 278 + <AddToMenu 269 279 trackId={track.id} 270 280 trackTitle={track.title} 281 + trackUri={track.atproto_record_uri} 282 + trackCid={track.atproto_record_cid} 271 283 initialLiked={track.is_liked || false} 272 284 disabled={!track.atproto_record_uri} 273 - disabledReason={!track.atproto_record_uri ? "track's record is unavailable and cannot be liked" : undefined} 285 + disabledReason={!track.atproto_record_uri ? "track's record is unavailable" : undefined} 274 286 onLikeChange={handleLikeChange} 287 + {excludePlaylistId} 275 288 /> 276 289 {/if} 277 290 <button ··· 297 310 <TrackActionsMenu 298 311 trackId={track.id} 299 312 trackTitle={track.title} 313 + trackUri={track.atproto_record_uri} 314 + trackCid={track.atproto_record_cid} 300 315 initialLiked={track.is_liked || false} 301 316 shareUrl={shareUrl} 302 317 onQueue={handleQueue} 303 318 isAuthenticated={isAuthenticated} 304 319 likeDisabled={!track.atproto_record_uri} 320 + {excludePlaylistId} 305 321 /> 306 322 </div> 307 323 </div> ··· 319 335 transition: all 0.15s ease-in-out; 320 336 } 321 337 338 + .track-index { 339 + width: 24px; 340 + font-size: 0.85rem; 341 + color: var(--text-muted); 342 + text-align: center; 343 + flex-shrink: 0; 344 + font-variant-numeric: tabular-nums; 345 + } 346 + 322 347 .track-container:hover { 323 348 background: var(--bg-tertiary); 324 349 border-left-color: var(--accent); 325 350 border-color: var(--border-default); 326 - transform: translateX(2px); 327 351 } 328 352 329 353 .track-container.playing { ··· 691 715 .track-container { 692 716 padding: 0.65rem 0.75rem; 693 717 gap: 0.5rem; 718 + } 719 + 720 + .track-index { 721 + display: none; 694 722 } 695 723 696 724 .track {
+18 -5
frontend/src/lib/preferences.svelte.ts
··· 14 14 enable_teal_scrobbling: boolean; 15 15 teal_needs_reauth: boolean; 16 16 show_sensitive_artwork: boolean; 17 + show_liked_on_profile: boolean; 17 18 } 18 19 19 20 const DEFAULT_PREFERENCES: Preferences = { ··· 24 25 theme: 'dark', 25 26 enable_teal_scrobbling: false, 26 27 teal_needs_reauth: false, 27 - show_sensitive_artwork: false 28 + show_sensitive_artwork: false, 29 + show_liked_on_profile: false 28 30 }; 29 31 30 32 class PreferencesManager { ··· 68 70 return this.data?.show_sensitive_artwork ?? DEFAULT_PREFERENCES.show_sensitive_artwork; 69 71 } 70 72 73 + get showLikedOnProfile(): boolean { 74 + return this.data?.show_liked_on_profile ?? DEFAULT_PREFERENCES.show_liked_on_profile; 75 + } 76 + 71 77 setTheme(theme: Theme): void { 72 78 if (browser) { 73 79 localStorage.setItem('theme', theme); ··· 101 107 102 108 this.loading = true; 103 109 try { 110 + // preserve theme from localStorage (theme is client-side only) 111 + const storedTheme = localStorage.getItem('theme') as Theme | null; 112 + const currentTheme = storedTheme ?? this.data?.theme ?? DEFAULT_PREFERENCES.theme; 113 + 104 114 const response = await fetch(`${API_URL}/preferences/`, { 105 115 credentials: 'include' 106 116 }); ··· 111 121 auto_advance: data.auto_advance ?? DEFAULT_PREFERENCES.auto_advance, 112 122 allow_comments: data.allow_comments ?? DEFAULT_PREFERENCES.allow_comments, 113 123 hidden_tags: data.hidden_tags ?? DEFAULT_PREFERENCES.hidden_tags, 114 - theme: data.theme ?? DEFAULT_PREFERENCES.theme, 124 + theme: currentTheme, 115 125 enable_teal_scrobbling: data.enable_teal_scrobbling ?? DEFAULT_PREFERENCES.enable_teal_scrobbling, 116 126 teal_needs_reauth: data.teal_needs_reauth ?? DEFAULT_PREFERENCES.teal_needs_reauth, 117 - show_sensitive_artwork: data.show_sensitive_artwork ?? DEFAULT_PREFERENCES.show_sensitive_artwork 127 + show_sensitive_artwork: data.show_sensitive_artwork ?? DEFAULT_PREFERENCES.show_sensitive_artwork, 128 + show_liked_on_profile: data.show_liked_on_profile ?? DEFAULT_PREFERENCES.show_liked_on_profile 118 129 }; 119 130 } else { 120 - this.data = { ...DEFAULT_PREFERENCES }; 131 + this.data = { ...DEFAULT_PREFERENCES, theme: currentTheme }; 121 132 } 122 133 // apply theme after fetching 123 134 if (browser) { ··· 125 136 } 126 137 } catch (error) { 127 138 console.error('failed to fetch preferences:', error); 128 - this.data = { ...DEFAULT_PREFERENCES }; 139 + // preserve theme on error too 140 + const storedTheme = localStorage.getItem('theme') as Theme | null; 141 + this.data = { ...DEFAULT_PREFERENCES, theme: storedTheme ?? DEFAULT_PREFERENCES.theme }; 129 142 } finally { 130 143 this.loading = false; 131 144 }
+25 -6
frontend/src/lib/search.svelte.ts
··· 1 1 // global search state using Svelte 5 runes 2 2 import { API_URL } from '$lib/config'; 3 3 4 - export type SearchResultType = 'track' | 'artist' | 'album' | 'tag'; 4 + export type SearchResultType = 'track' | 'artist' | 'album' | 'tag' | 'playlist'; 5 5 6 6 export interface TrackSearchResult { 7 7 type: 'track'; ··· 41 41 relevance: number; 42 42 } 43 43 44 - export type SearchResult = TrackSearchResult | ArtistSearchResult | AlbumSearchResult | TagSearchResult; 44 + export interface PlaylistSearchResult { 45 + type: 'playlist'; 46 + id: string; 47 + name: string; 48 + owner_handle: string; 49 + owner_display_name: string; 50 + image_url: string | null; 51 + track_count: number; 52 + relevance: number; 53 + } 54 + 55 + export type SearchResult = 56 + | TrackSearchResult 57 + | ArtistSearchResult 58 + | AlbumSearchResult 59 + | TagSearchResult 60 + | PlaylistSearchResult; 45 61 46 62 export interface SearchResponse { 47 63 results: SearchResult[]; ··· 50 66 artists: number; 51 67 albums: number; 52 68 tags: number; 69 + playlists: number; 53 70 }; 54 71 } 55 72 ··· 59 76 isOpen = $state(false); 60 77 query = $state(''); 61 78 results = $state<SearchResult[]>([]); 62 - counts = $state<SearchResponse['counts']>({ tracks: 0, artists: 0, albums: 0, tags: 0 }); 79 + counts = $state<SearchResponse['counts']>({ tracks: 0, artists: 0, albums: 0, tags: 0, playlists: 0 }); 63 80 loading = $state(false); 64 81 error = $state<string | null>(null); 65 82 selectedIndex = $state(0); ··· 83 100 this.isOpen = true; 84 101 this.query = ''; 85 102 this.results = []; 86 - this.counts = { tracks: 0, artists: 0, albums: 0, tags: 0 }; 103 + this.counts = { tracks: 0, artists: 0, albums: 0, tags: 0, playlists: 0 }; 87 104 this.error = null; 88 105 this.selectedIndex = 0; 89 106 } ··· 120 137 if (value.length > MAX_QUERY_LENGTH) { 121 138 this.error = `query too long (max ${MAX_QUERY_LENGTH} characters)`; 122 139 this.results = []; 123 - this.counts = { tracks: 0, artists: 0, albums: 0, tags: 0 }; 140 + this.counts = { tracks: 0, artists: 0, albums: 0, tags: 0, playlists: 0 }; 124 141 return; 125 142 } 126 143 ··· 133 150 }, 150); 134 151 } else { 135 152 this.results = []; 136 - this.counts = { tracks: 0, artists: 0, albums: 0, tags: 0 }; 153 + this.counts = { tracks: 0, artists: 0, albums: 0, tags: 0, playlists: 0 }; 137 154 } 138 155 } 139 156 ··· 191 208 return `/u/${result.artist_handle}/album/${result.slug}`; 192 209 case 'tag': 193 210 return `/tag/${result.name}`; 211 + case 'playlist': 212 + return `/playlist/${result.id}`; 194 213 } 195 214 } 196 215 }
+32
frontend/src/lib/tracks.svelte.ts
··· 128 128 return []; 129 129 } 130 130 } 131 + 132 + export interface UserLikesResponse { 133 + user: { 134 + did: string; 135 + handle: string; 136 + display_name: string | null; 137 + avatar_url: string | null; 138 + }; 139 + tracks: Track[]; 140 + count: number; 141 + } 142 + 143 + export async function fetchUserLikes(handle: string): Promise<UserLikesResponse | null> { 144 + try { 145 + const response = await fetch(`${API_URL}/users/${handle}/likes`, { 146 + credentials: 'include' 147 + }); 148 + 149 + if (response.status === 404) { 150 + return null; 151 + } 152 + 153 + if (!response.ok) { 154 + throw new Error(`failed to fetch user likes: ${response.statusText}`); 155 + } 156 + 157 + return await response.json(); 158 + } catch (e) { 159 + console.error('failed to fetch user likes:', e); 160 + return null; 161 + } 162 + }
+20
frontend/src/lib/types.ts
··· 18 18 description?: string | null; 19 19 artist: string; 20 20 artist_handle: string; 21 + artist_did: string; 22 + list_uri?: string | null; 21 23 } 22 24 23 25 export interface AlbumResponse { ··· 36 38 artist_avatar_url?: string; 37 39 r2_url?: string; 38 40 atproto_record_uri?: string; 41 + atproto_record_cid?: string; 39 42 atproto_record_url?: string; 40 43 play_count: number; 41 44 like_count?: number; ··· 60 63 display_name: string; 61 64 avatar_url?: string; 62 65 bio?: string; 66 + show_liked_on_profile?: boolean; 63 67 } 64 68 65 69 export interface QueueState { ··· 99 103 expires_at: string | null; 100 104 } 101 105 106 + export interface Playlist { 107 + id: string; 108 + name: string; 109 + owner_did: string; 110 + owner_handle: string; 111 + track_count: number; 112 + image_url?: string; 113 + show_on_profile: boolean; 114 + atproto_record_uri: string; 115 + created_at: string; 116 + } 117 + 118 + export interface PlaylistWithTracks extends Playlist { 119 + tracks: Track[]; 120 + } 121 +
+4 -2
frontend/src/routes/+layout.ts
··· 20 20 theme: 'dark', 21 21 enable_teal_scrobbling: false, 22 22 teal_needs_reauth: false, 23 - show_sensitive_artwork: false 23 + show_sensitive_artwork: false, 24 + show_liked_on_profile: false 24 25 }; 25 26 26 27 export async function load({ fetch, data }: LoadEvent): Promise<LayoutData> { ··· 59 60 theme: prefsData.theme ?? 'dark', 60 61 enable_teal_scrobbling: prefsData.enable_teal_scrobbling ?? false, 61 62 teal_needs_reauth: prefsData.teal_needs_reauth ?? false, 62 - show_sensitive_artwork: prefsData.show_sensitive_artwork ?? false 63 + show_sensitive_artwork: prefsData.show_sensitive_artwork ?? false, 64 + show_liked_on_profile: prefsData.show_liked_on_profile ?? false 63 65 }; 64 66 } 65 67 } catch (e) {
+607
frontend/src/routes/library/+page.svelte
··· 1 + <script lang="ts"> 2 + import Header from '$lib/components/Header.svelte'; 3 + import { auth } from '$lib/auth.svelte'; 4 + import { goto } from '$app/navigation'; 5 + import { page } from '$app/stores'; 6 + import { API_URL } from '$lib/config'; 7 + import type { PageData } from './$types'; 8 + import type { Playlist } from '$lib/types'; 9 + import { toast } from '$lib/toast.svelte'; 10 + 11 + let { data }: { data: PageData } = $props(); 12 + let playlists = $state<Playlist[]>(data.playlists); 13 + let showCreateModal = $state(false); 14 + let newPlaylistName = $state(''); 15 + let creating = $state(false); 16 + let error = $state(''); 17 + 18 + // open create modal if ?create=playlist query param is present 19 + $effect(() => { 20 + if ($page.url.searchParams.get('create') === 'playlist') { 21 + showCreateModal = true; 22 + // clear the query param from URL without navigation 23 + const url = new URL($page.url); 24 + url.searchParams.delete('create'); 25 + window.history.replaceState({}, '', url.pathname); 26 + } 27 + }); 28 + 29 + async function handleLogout() { 30 + await auth.logout(); 31 + window.location.href = '/'; 32 + } 33 + 34 + async function createPlaylist() { 35 + if (!newPlaylistName.trim()) { 36 + error = 'please enter a name'; 37 + return; 38 + } 39 + 40 + creating = true; 41 + error = ''; 42 + 43 + try { 44 + const response = await fetch(`${API_URL}/lists/playlists`, { 45 + method: 'POST', 46 + credentials: 'include', 47 + headers: { 'Content-Type': 'application/json' }, 48 + body: JSON.stringify({ name: newPlaylistName.trim() }) 49 + }); 50 + 51 + if (!response.ok) { 52 + const data = await response.json(); 53 + throw new Error(data.detail || 'failed to create playlist'); 54 + } 55 + 56 + const playlist = await response.json(); 57 + playlists = [playlist, ...playlists]; 58 + showCreateModal = false; 59 + newPlaylistName = ''; 60 + 61 + toast.success(`created "${playlist.name}"`); 62 + 63 + // navigate to new playlist 64 + goto(`/playlist/${playlist.id}`); 65 + } catch (e) { 66 + error = e instanceof Error ? e.message : 'failed to create playlist'; 67 + toast.error(error); 68 + } finally { 69 + creating = false; 70 + } 71 + } 72 + 73 + function handleKeydown(event: KeyboardEvent) { 74 + if (event.key === 'Escape') { 75 + showCreateModal = false; 76 + newPlaylistName = ''; 77 + error = ''; 78 + } else if (event.key === 'Enter' && showCreateModal) { 79 + createPlaylist(); 80 + } 81 + } 82 + </script> 83 + 84 + <svelte:window on:keydown={handleKeydown} /> 85 + 86 + <svelte:head> 87 + <title>library • plyr</title> 88 + </svelte:head> 89 + 90 + <Header user={auth.user} isAuthenticated={auth.isAuthenticated} onLogout={handleLogout} /> 91 + 92 + <div class="page"> 93 + <div class="page-header"> 94 + <h1>library</h1> 95 + <p>your collections on plyr.fm</p> 96 + </div> 97 + 98 + <section class="collections"> 99 + <a href="/liked" class="collection-card"> 100 + <div class="collection-icon liked"> 101 + <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> 102 + <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/> 103 + </svg> 104 + </div> 105 + <div class="collection-info"> 106 + <h3>liked tracks</h3> 107 + <p>{data.likedCount} {data.likedCount === 1 ? 'track' : 'tracks'}</p> 108 + </div> 109 + <div class="collection-arrow"> 110 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 111 + <polyline points="9 18 15 12 9 6"></polyline> 112 + </svg> 113 + </div> 114 + </a> 115 + </section> 116 + 117 + <section class="playlists-section"> 118 + <div class="section-header"> 119 + <h2>playlists</h2> 120 + <button class="create-btn" onclick={() => showCreateModal = true}> 121 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 122 + <line x1="12" y1="5" x2="12" y2="19"></line> 123 + <line x1="5" y1="12" x2="19" y2="12"></line> 124 + </svg> 125 + new playlist 126 + </button> 127 + </div> 128 + 129 + {#if playlists.length === 0} 130 + <div class="empty-state"> 131 + <div class="empty-icon"> 132 + <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 133 + <line x1="8" y1="6" x2="21" y2="6"></line> 134 + <line x1="8" y1="12" x2="21" y2="12"></line> 135 + <line x1="8" y1="18" x2="21" y2="18"></line> 136 + <line x1="3" y1="6" x2="3.01" y2="6"></line> 137 + <line x1="3" y1="12" x2="3.01" y2="12"></line> 138 + <line x1="3" y1="18" x2="3.01" y2="18"></line> 139 + </svg> 140 + </div> 141 + <p>no playlists yet</p> 142 + <span>create your first playlist to organize your favorite tracks</span> 143 + </div> 144 + {:else} 145 + <div class="playlists-list"> 146 + {#each playlists as playlist} 147 + <a href="/playlist/{playlist.id}" class="collection-card"> 148 + {#if playlist.image_url} 149 + <img src={playlist.image_url} alt="" class="playlist-artwork" /> 150 + {:else} 151 + <div class="collection-icon playlist"> 152 + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 153 + <line x1="8" y1="6" x2="21" y2="6"></line> 154 + <line x1="8" y1="12" x2="21" y2="12"></line> 155 + <line x1="8" y1="18" x2="21" y2="18"></line> 156 + <line x1="3" y1="6" x2="3.01" y2="6"></line> 157 + <line x1="3" y1="12" x2="3.01" y2="12"></line> 158 + <line x1="3" y1="18" x2="3.01" y2="18"></line> 159 + </svg> 160 + </div> 161 + {/if} 162 + <div class="collection-info"> 163 + <h3>{playlist.name}</h3> 164 + <p>{playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'}</p> 165 + </div> 166 + <div class="collection-arrow"> 167 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 168 + <polyline points="9 18 15 12 9 6"></polyline> 169 + </svg> 170 + </div> 171 + </a> 172 + {/each} 173 + </div> 174 + {/if} 175 + </section> 176 + </div> 177 + 178 + {#if showCreateModal} 179 + <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 180 + <div class="modal-overlay" role="presentation" onclick={() => { showCreateModal = false; newPlaylistName = ''; error = ''; }}> 181 + <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 182 + <div class="modal" role="dialog" aria-modal="true" aria-labelledby="create-playlist-title" tabindex="-1" onclick={(e) => e.stopPropagation()}> 183 + <div class="modal-header"> 184 + <h3 id="create-playlist-title">create playlist</h3> 185 + <button class="close-btn" aria-label="close" onclick={() => { showCreateModal = false; newPlaylistName = ''; error = ''; }}> 186 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 187 + <line x1="18" y1="6" x2="6" y2="18"></line> 188 + <line x1="6" y1="6" x2="18" y2="18"></line> 189 + </svg> 190 + </button> 191 + </div> 192 + <div class="modal-body"> 193 + <label for="playlist-name">name</label> 194 + <!-- svelte-ignore a11y_autofocus --> 195 + <input 196 + id="playlist-name" 197 + type="text" 198 + bind:value={newPlaylistName} 199 + placeholder="my playlist" 200 + disabled={creating} 201 + autofocus 202 + /> 203 + {#if error} 204 + <p class="error">{error}</p> 205 + {/if} 206 + </div> 207 + <div class="modal-footer"> 208 + <button class="cancel-btn" onclick={() => { showCreateModal = false; newPlaylistName = ''; error = ''; }} disabled={creating}> 209 + cancel 210 + </button> 211 + <button class="confirm-btn" onclick={createPlaylist} disabled={creating || !newPlaylistName.trim()}> 212 + {creating ? 'creating...' : 'create'} 213 + </button> 214 + </div> 215 + </div> 216 + </div> 217 + {/if} 218 + 219 + <style> 220 + .page { 221 + max-width: 800px; 222 + margin: 0 auto; 223 + padding: 0 1rem calc(var(--player-height, 0px) + 2rem + env(safe-area-inset-bottom, 0px)); 224 + min-height: 100vh; 225 + } 226 + 227 + .page-header { 228 + margin-bottom: 2rem; 229 + padding-bottom: 1.5rem; 230 + border-bottom: 1px solid var(--border-default); 231 + } 232 + 233 + .page-header h1 { 234 + font-size: 1.75rem; 235 + font-weight: 700; 236 + color: var(--text-primary); 237 + margin: 0 0 0.25rem 0; 238 + } 239 + 240 + .page-header p { 241 + font-size: 0.9rem; 242 + color: var(--text-tertiary); 243 + margin: 0; 244 + } 245 + 246 + .collections { 247 + display: flex; 248 + flex-direction: column; 249 + gap: 0.75rem; 250 + } 251 + 252 + .collection-card { 253 + display: flex; 254 + align-items: center; 255 + gap: 1rem; 256 + padding: 1rem 1.25rem; 257 + background: var(--bg-secondary); 258 + border: 1px solid var(--border-default); 259 + border-radius: 12px; 260 + text-decoration: none; 261 + color: inherit; 262 + transition: all 0.15s; 263 + } 264 + 265 + .collection-card:hover { 266 + border-color: var(--accent); 267 + background: var(--bg-hover); 268 + } 269 + 270 + .collection-card:active { 271 + transform: scale(0.99); 272 + } 273 + 274 + .collection-icon { 275 + width: 48px; 276 + height: 48px; 277 + border-radius: 10px; 278 + display: flex; 279 + align-items: center; 280 + justify-content: center; 281 + flex-shrink: 0; 282 + } 283 + 284 + .collection-icon.liked { 285 + background: linear-gradient(135deg, rgba(239, 68, 68, 0.15), rgba(239, 68, 68, 0.05)); 286 + color: #ef4444; 287 + } 288 + 289 + .collection-icon.playlist { 290 + background: linear-gradient(135deg, rgba(var(--accent-rgb, 139, 92, 246), 0.15), rgba(var(--accent-rgb, 139, 92, 246), 0.05)); 291 + color: var(--accent); 292 + } 293 + 294 + .playlist-artwork { 295 + width: 48px; 296 + height: 48px; 297 + border-radius: 10px; 298 + object-fit: cover; 299 + flex-shrink: 0; 300 + } 301 + 302 + .collection-info { 303 + flex: 1; 304 + min-width: 0; 305 + } 306 + 307 + .collection-info h3 { 308 + font-size: 1rem; 309 + font-weight: 600; 310 + color: var(--text-primary); 311 + margin: 0 0 0.15rem 0; 312 + overflow: hidden; 313 + text-overflow: ellipsis; 314 + white-space: nowrap; 315 + } 316 + 317 + .collection-info p { 318 + font-size: 0.85rem; 319 + color: var(--text-tertiary); 320 + margin: 0; 321 + } 322 + 323 + .collection-arrow { 324 + color: var(--text-muted); 325 + flex-shrink: 0; 326 + transition: transform 0.15s; 327 + } 328 + 329 + .collection-card:hover .collection-arrow { 330 + transform: translateX(3px); 331 + color: var(--accent); 332 + } 333 + 334 + /* playlists section */ 335 + .playlists-section { 336 + margin-top: 2rem; 337 + } 338 + 339 + .section-header { 340 + display: flex; 341 + align-items: center; 342 + justify-content: space-between; 343 + margin-bottom: 1rem; 344 + } 345 + 346 + .section-header h2 { 347 + font-size: 1.1rem; 348 + font-weight: 600; 349 + color: var(--text-primary); 350 + margin: 0; 351 + } 352 + 353 + .create-btn { 354 + display: flex; 355 + align-items: center; 356 + gap: 0.5rem; 357 + padding: 0.5rem 1rem; 358 + background: var(--accent); 359 + color: white; 360 + border: none; 361 + border-radius: 8px; 362 + font-family: inherit; 363 + font-size: 0.875rem; 364 + font-weight: 500; 365 + cursor: pointer; 366 + transition: all 0.15s; 367 + } 368 + 369 + .create-btn:hover { 370 + opacity: 0.9; 371 + transform: translateY(-1px); 372 + } 373 + 374 + .create-btn:active { 375 + transform: translateY(0); 376 + } 377 + 378 + .playlists-list { 379 + display: flex; 380 + flex-direction: column; 381 + gap: 0.75rem; 382 + } 383 + 384 + .empty-state { 385 + display: flex; 386 + flex-direction: column; 387 + align-items: center; 388 + justify-content: center; 389 + padding: 3rem 2rem; 390 + background: var(--bg-secondary); 391 + border: 1px dashed var(--border-default); 392 + border-radius: 12px; 393 + text-align: center; 394 + } 395 + 396 + .empty-icon { 397 + width: 64px; 398 + height: 64px; 399 + border-radius: 16px; 400 + display: flex; 401 + align-items: center; 402 + justify-content: center; 403 + background: var(--bg-tertiary); 404 + color: var(--text-muted); 405 + margin-bottom: 1rem; 406 + } 407 + 408 + .empty-state p { 409 + font-size: 1rem; 410 + font-weight: 500; 411 + color: var(--text-secondary); 412 + margin: 0 0 0.25rem 0; 413 + } 414 + 415 + .empty-state span { 416 + font-size: 0.85rem; 417 + color: var(--text-muted); 418 + } 419 + 420 + /* modal */ 421 + .modal-overlay { 422 + position: fixed; 423 + top: 0; 424 + left: 0; 425 + right: 0; 426 + bottom: 0; 427 + background: rgba(0, 0, 0, 0.5); 428 + display: flex; 429 + align-items: center; 430 + justify-content: center; 431 + z-index: 1000; 432 + padding: 1rem; 433 + } 434 + 435 + .modal { 436 + background: var(--bg-primary); 437 + border: 1px solid var(--border-default); 438 + border-radius: 16px; 439 + width: 100%; 440 + max-width: 400px; 441 + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); 442 + } 443 + 444 + .modal-header { 445 + display: flex; 446 + align-items: center; 447 + justify-content: space-between; 448 + padding: 1.25rem 1.5rem; 449 + border-bottom: 1px solid var(--border-default); 450 + } 451 + 452 + .modal-header h3 { 453 + font-size: 1.1rem; 454 + font-weight: 600; 455 + color: var(--text-primary); 456 + margin: 0; 457 + } 458 + 459 + .close-btn { 460 + display: flex; 461 + align-items: center; 462 + justify-content: center; 463 + width: 32px; 464 + height: 32px; 465 + background: transparent; 466 + border: none; 467 + border-radius: 8px; 468 + color: var(--text-secondary); 469 + cursor: pointer; 470 + transition: all 0.15s; 471 + } 472 + 473 + .close-btn:hover { 474 + background: var(--bg-hover); 475 + color: var(--text-primary); 476 + } 477 + 478 + .modal-body { 479 + padding: 1.5rem; 480 + } 481 + 482 + .modal-body label { 483 + display: block; 484 + font-size: 0.85rem; 485 + font-weight: 500; 486 + color: var(--text-secondary); 487 + margin-bottom: 0.5rem; 488 + } 489 + 490 + .modal-body input { 491 + width: 100%; 492 + padding: 0.75rem 1rem; 493 + background: var(--bg-secondary); 494 + border: 1px solid var(--border-default); 495 + border-radius: 8px; 496 + font-family: inherit; 497 + font-size: 1rem; 498 + color: var(--text-primary); 499 + transition: border-color 0.15s; 500 + } 501 + 502 + .modal-body input:focus { 503 + outline: none; 504 + border-color: var(--accent); 505 + } 506 + 507 + .modal-body input::placeholder { 508 + color: var(--text-muted); 509 + } 510 + 511 + .modal-body .error { 512 + margin: 0.5rem 0 0 0; 513 + font-size: 0.85rem; 514 + color: #ef4444; 515 + } 516 + 517 + .modal-footer { 518 + display: flex; 519 + justify-content: flex-end; 520 + gap: 0.75rem; 521 + padding: 1rem 1.5rem 1.25rem; 522 + } 523 + 524 + .cancel-btn, 525 + .confirm-btn { 526 + padding: 0.625rem 1.25rem; 527 + border-radius: 8px; 528 + font-family: inherit; 529 + font-size: 0.9rem; 530 + font-weight: 500; 531 + cursor: pointer; 532 + transition: all 0.15s; 533 + } 534 + 535 + .cancel-btn { 536 + background: var(--bg-secondary); 537 + border: 1px solid var(--border-default); 538 + color: var(--text-secondary); 539 + } 540 + 541 + .cancel-btn:hover:not(:disabled) { 542 + background: var(--bg-hover); 543 + color: var(--text-primary); 544 + } 545 + 546 + .confirm-btn { 547 + background: var(--accent); 548 + border: 1px solid var(--accent); 549 + color: white; 550 + } 551 + 552 + .confirm-btn:hover:not(:disabled) { 553 + opacity: 0.9; 554 + } 555 + 556 + .confirm-btn:disabled, 557 + .cancel-btn:disabled { 558 + opacity: 0.5; 559 + cursor: not-allowed; 560 + } 561 + 562 + @media (max-width: 768px) { 563 + .page { 564 + padding: 0 0.75rem calc(var(--player-height, 0px) + 1.25rem + env(safe-area-inset-bottom, 0px)); 565 + } 566 + 567 + .page-header { 568 + margin-bottom: 1.5rem; 569 + padding-bottom: 1rem; 570 + } 571 + 572 + .page-header h1 { 573 + font-size: 1.5rem; 574 + } 575 + 576 + .collection-card { 577 + padding: 0.875rem 1rem; 578 + } 579 + 580 + .collection-icon { 581 + width: 44px; 582 + height: 44px; 583 + } 584 + 585 + .collection-info h3 { 586 + font-size: 0.95rem; 587 + } 588 + 589 + .section-header h2 { 590 + font-size: 1rem; 591 + } 592 + 593 + .create-btn { 594 + padding: 0.5rem 0.875rem; 595 + font-size: 0.85rem; 596 + } 597 + 598 + .empty-state { 599 + padding: 2rem 1.5rem; 600 + } 601 + 602 + .empty-icon { 603 + width: 56px; 604 + height: 56px; 605 + } 606 + } 607 + </style>
+46
frontend/src/routes/library/+page.ts
··· 1 + import { browser } from '$app/environment'; 2 + import { redirect } from '@sveltejs/kit'; 3 + import { fetchLikedTracks } from '$lib/tracks.svelte'; 4 + import { API_URL } from '$lib/config'; 5 + import type { LoadEvent } from '@sveltejs/kit'; 6 + import type { Playlist } from '$lib/types'; 7 + 8 + export interface PageData { 9 + likedCount: number; 10 + playlists: Playlist[]; 11 + } 12 + 13 + export const ssr = false; 14 + 15 + async function fetchPlaylists(): Promise<Playlist[]> { 16 + const response = await fetch(`${API_URL}/lists/playlists`, { 17 + credentials: 'include' 18 + }); 19 + if (!response.ok) { 20 + throw new Error('failed to fetch playlists'); 21 + } 22 + return response.json(); 23 + } 24 + 25 + export async function load({ parent }: LoadEvent): Promise<PageData> { 26 + if (!browser) { 27 + return { likedCount: 0, playlists: [] }; 28 + } 29 + 30 + // check auth from parent layout data 31 + const { isAuthenticated } = await parent(); 32 + if (!isAuthenticated) { 33 + throw redirect(302, '/'); 34 + } 35 + 36 + try { 37 + const [tracks, playlists] = await Promise.all([ 38 + fetchLikedTracks(), 39 + fetchPlaylists().catch(() => [] as Playlist[]) 40 + ]); 41 + return { likedCount: tracks.length, playlists }; 42 + } catch (e) { 43 + console.error('failed to load library data:', e); 44 + return { likedCount: 0, playlists: [] }; 45 + } 46 + }
+361 -41
frontend/src/routes/liked/+page.svelte
··· 5 5 import { queue } from '$lib/queue.svelte'; 6 6 import { toast } from '$lib/toast.svelte'; 7 7 import { auth } from '$lib/auth.svelte'; 8 + import { API_URL } from '$lib/config'; 8 9 import type { Track } from '$lib/types'; 9 10 import type { PageData } from './$types'; 10 11 11 12 let { data }: { data: PageData } = $props(); 12 13 14 + // local mutable copy of tracks for reordering 15 + let tracks = $state<Track[]>([...data.tracks]); 16 + 17 + // sync when data changes (e.g., navigation) 18 + $effect(() => { 19 + tracks = [...data.tracks]; 20 + }); 21 + 22 + // edit mode state 23 + let isEditMode = $state(false); 24 + let isSaving = $state(false); 25 + 26 + // drag state 27 + let draggedIndex = $state<number | null>(null); 28 + let dragOverIndex = $state<number | null>(null); 29 + 30 + // touch drag state 31 + let touchDragIndex = $state<number | null>(null); 32 + let touchStartY = $state(0); 33 + let touchDragElement = $state<HTMLElement | null>(null); 34 + let tracksListElement = $state<HTMLElement | null>(null); 35 + 13 36 async function handleLogout() { 14 37 await auth.logout(); 15 38 window.location.href = '/'; ··· 20 43 } 21 44 22 45 function queueAll() { 23 - if (data.tracks.length === 0) return; 24 - queue.addTracks(data.tracks); 25 - toast.success(`queued ${data.tracks.length} ${data.tracks.length === 1 ? 'track' : 'tracks'}`); 46 + if (tracks.length === 0) return; 47 + queue.addTracks(tracks); 48 + toast.success(`queued ${tracks.length} ${tracks.length === 1 ? 'track' : 'tracks'}`); 49 + } 50 + 51 + function toggleEditMode() { 52 + if (isEditMode) { 53 + // exiting edit mode - save the new order 54 + saveOrder(); 55 + } 56 + isEditMode = !isEditMode; 57 + } 58 + 59 + async function saveOrder() { 60 + // build strongRefs from current track order 61 + const items = tracks 62 + .filter((t) => t.atproto_record_uri && t.atproto_record_cid) 63 + .map((t) => ({ 64 + uri: t.atproto_record_uri!, 65 + cid: t.atproto_record_cid! 66 + })); 67 + 68 + if (items.length === 0) return; 69 + 70 + isSaving = true; 71 + try { 72 + const response = await fetch(`${API_URL}/lists/liked/reorder`, { 73 + method: 'PUT', 74 + headers: { 'Content-Type': 'application/json' }, 75 + credentials: 'include', 76 + body: JSON.stringify({ items }) 77 + }); 78 + 79 + if (!response.ok) { 80 + const error = await response.json().catch(() => ({ detail: 'unknown error' })); 81 + throw new Error(error.detail || 'failed to save order'); 82 + } 83 + 84 + toast.success('order saved'); 85 + } catch (e) { 86 + toast.error(e instanceof Error ? e.message : 'failed to save order'); 87 + } finally { 88 + isSaving = false; 89 + } 90 + } 91 + 92 + // move track from one index to another 93 + function moveTrack(fromIndex: number, toIndex: number) { 94 + if (fromIndex === toIndex) return; 95 + const newTracks = [...tracks]; 96 + const [moved] = newTracks.splice(fromIndex, 1); 97 + newTracks.splice(toIndex, 0, moved); 98 + tracks = newTracks; 99 + } 100 + 101 + // desktop drag and drop 102 + function handleDragStart(event: DragEvent, index: number) { 103 + draggedIndex = index; 104 + if (event.dataTransfer) { 105 + event.dataTransfer.effectAllowed = 'move'; 106 + } 107 + } 108 + 109 + function handleDragOver(event: DragEvent, index: number) { 110 + event.preventDefault(); 111 + dragOverIndex = index; 112 + } 113 + 114 + function handleDrop(event: DragEvent, index: number) { 115 + event.preventDefault(); 116 + if (draggedIndex !== null && draggedIndex !== index) { 117 + moveTrack(draggedIndex, index); 118 + } 119 + draggedIndex = null; 120 + dragOverIndex = null; 121 + } 122 + 123 + function handleDragEnd() { 124 + draggedIndex = null; 125 + dragOverIndex = null; 126 + } 127 + 128 + // touch drag and drop 129 + function handleTouchStart(event: TouchEvent, index: number) { 130 + const touch = event.touches[0]; 131 + touchDragIndex = index; 132 + touchStartY = touch.clientY; 133 + touchDragElement = event.currentTarget as HTMLElement; 134 + touchDragElement.classList.add('touch-dragging'); 135 + } 136 + 137 + function handleTouchMove(event: TouchEvent) { 138 + if (touchDragIndex === null || !touchDragElement || !tracksListElement) return; 139 + 140 + event.preventDefault(); 141 + const touch = event.touches[0]; 142 + const offset = touch.clientY - touchStartY; 143 + touchDragElement.style.transform = `translateY(${offset}px)`; 144 + 145 + // find which track we're hovering over 146 + const trackElements = tracksListElement.querySelectorAll('.track-row'); 147 + for (let i = 0; i < trackElements.length; i++) { 148 + const trackEl = trackElements[i] as HTMLElement; 149 + const rect = trackEl.getBoundingClientRect(); 150 + const midY = rect.top + rect.height / 2; 151 + 152 + if (touch.clientY < midY && i > 0) { 153 + const targetIndex = parseInt(trackEl.dataset.index || '0'); 154 + if (targetIndex !== touchDragIndex) { 155 + dragOverIndex = targetIndex; 156 + } 157 + break; 158 + } else if (touch.clientY >= midY) { 159 + const targetIndex = parseInt(trackEl.dataset.index || '0'); 160 + if (targetIndex !== touchDragIndex) { 161 + dragOverIndex = targetIndex; 162 + } 163 + } 164 + } 165 + } 166 + 167 + function handleTouchEnd() { 168 + if (touchDragIndex !== null && dragOverIndex !== null && touchDragIndex !== dragOverIndex) { 169 + moveTrack(touchDragIndex, dragOverIndex); 170 + } 171 + 172 + if (touchDragElement) { 173 + touchDragElement.classList.remove('touch-dragging'); 174 + touchDragElement.style.transform = ''; 175 + } 176 + 177 + touchDragIndex = null; 178 + dragOverIndex = null; 179 + touchDragElement = null; 26 180 } 27 181 </script> 28 182 ··· 40 194 <span class="count">{data.tracks.length}</span> 41 195 {/if} 42 196 </h2> 43 - {#if data.tracks.length > 0} 197 + {#if tracks.length > 0} 44 198 <div class="header-actions"> 45 - <button class="btn-action" onclick={queueAll} title="queue all liked tracks"> 46 - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 47 - <line x1="8" y1="6" x2="21" y2="6"></line> 48 - <line x1="8" y1="12" x2="21" y2="12"></line> 49 - <line x1="8" y1="18" x2="21" y2="18"></line> 50 - <line x1="3" y1="6" x2="3.01" y2="6"></line> 51 - <line x1="3" y1="12" x2="3.01" y2="12"></line> 52 - <line x1="3" y1="18" x2="3.01" y2="18"></line> 199 + <button class="queue-button" onclick={queueAll} title="add all liked tracks to queue"> 200 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> 201 + <line x1="5" y1="15" x2="5" y2="21"></line> 202 + <line x1="2" y1="18" x2="8" y2="18"></line> 203 + <line x1="9" y1="6" x2="21" y2="6"></line> 204 + <line x1="9" y1="12" x2="21" y2="12"></line> 205 + <line x1="9" y1="18" x2="21" y2="18"></line> 53 206 </svg> 54 - <span>queue all</span> 207 + add to queue 55 208 </button> 209 + {#if auth.isAuthenticated && tracks.length > 1} 210 + <button 211 + class="reorder-button" 212 + class:active={isEditMode} 213 + onclick={toggleEditMode} 214 + disabled={isSaving} 215 + title={isEditMode ? 'save order' : 'reorder tracks'} 216 + > 217 + {#if isEditMode} 218 + {#if isSaving} 219 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spinner"> 220 + <circle cx="12" cy="12" r="10" stroke-dasharray="31.4" stroke-dashoffset="10"></circle> 221 + </svg> 222 + saving... 223 + {:else} 224 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 225 + <polyline points="20 6 9 17 4 12"></polyline> 226 + </svg> 227 + done 228 + {/if} 229 + {:else} 230 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 231 + <line x1="3" y1="12" x2="21" y2="12"></line> 232 + <line x1="3" y1="6" x2="21" y2="6"></line> 233 + <line x1="3" y1="18" x2="21" y2="18"></line> 234 + </svg> 235 + reorder 236 + {/if} 237 + </button> 238 + {/if} 56 239 </div> 57 240 {/if} 58 241 </div> ··· 71 254 {/if} 72 255 </div> 73 256 {:else} 74 - <div class="tracks-list"> 75 - {#each data.tracks as track, i (track.id)} 76 - <TrackItem 77 - {track} 78 - index={i} 79 - isPlaying={player.currentTrack?.id === track.id && !player.paused} 80 - onPlay={playTrack} 81 - isAuthenticated={auth.isAuthenticated} 82 - /> 257 + <div 258 + class="tracks-list" 259 + class:edit-mode={isEditMode} 260 + bind:this={tracksListElement} 261 + ontouchmove={isEditMode ? handleTouchMove : undefined} 262 + ontouchend={isEditMode ? handleTouchEnd : undefined} 263 + ontouchcancel={isEditMode ? handleTouchEnd : undefined} 264 + > 265 + {#each tracks as track, i (track.id)} 266 + {#if isEditMode} 267 + <div 268 + class="track-row" 269 + class:drag-over={dragOverIndex === i && touchDragIndex !== i} 270 + class:is-dragging={touchDragIndex === i || draggedIndex === i} 271 + data-index={i} 272 + role="listitem" 273 + draggable="true" 274 + ondragstart={(e) => handleDragStart(e, i)} 275 + ondragover={(e) => handleDragOver(e, i)} 276 + ondrop={(e) => handleDrop(e, i)} 277 + ondragend={handleDragEnd} 278 + > 279 + <!-- drag handle --> 280 + <button 281 + class="drag-handle" 282 + ontouchstart={(e) => handleTouchStart(e, i)} 283 + onclick={(e) => e.stopPropagation()} 284 + aria-label="drag to reorder" 285 + title="drag to reorder" 286 + > 287 + <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> 288 + <circle cx="5" cy="3" r="1.5"></circle> 289 + <circle cx="11" cy="3" r="1.5"></circle> 290 + <circle cx="5" cy="8" r="1.5"></circle> 291 + <circle cx="11" cy="8" r="1.5"></circle> 292 + <circle cx="5" cy="13" r="1.5"></circle> 293 + <circle cx="11" cy="13" r="1.5"></circle> 294 + </svg> 295 + </button> 296 + <div class="track-content"> 297 + <TrackItem 298 + {track} 299 + index={i} 300 + isPlaying={player.currentTrack?.id === track.id && !player.paused} 301 + onPlay={playTrack} 302 + isAuthenticated={auth.isAuthenticated} 303 + /> 304 + </div> 305 + </div> 306 + {:else} 307 + <TrackItem 308 + {track} 309 + index={i} 310 + isPlaying={player.currentTrack?.id === track.id && !player.paused} 311 + onPlay={playTrack} 312 + isAuthenticated={auth.isAuthenticated} 313 + /> 314 + {/if} 83 315 {/each} 84 316 </div> 85 317 {/if} ··· 124 356 .header-actions { 125 357 display: flex; 126 358 align-items: center; 127 - gap: 0.75rem; 359 + gap: 1rem; 128 360 } 129 361 130 - .btn-action { 362 + .queue-button, 363 + .reorder-button { 364 + padding: 0.75rem 1.5rem; 365 + border-radius: 24px; 366 + font-weight: 600; 367 + font-size: 0.95rem; 368 + font-family: inherit; 369 + cursor: pointer; 370 + transition: all 0.2s; 131 371 display: flex; 132 372 align-items: center; 133 373 gap: 0.5rem; 134 - padding: 0.5rem 0.85rem; 374 + border: none; 135 375 background: transparent; 376 + color: var(--text-primary); 136 377 border: 1px solid var(--border-default); 137 - color: var(--text-secondary); 138 - border-radius: 6px; 139 - font-size: 0.85rem; 140 - font-family: inherit; 141 - cursor: pointer; 142 - transition: all 0.15s; 143 - white-space: nowrap; 378 + } 379 + 380 + .queue-button:hover, 381 + .reorder-button:hover { 382 + border-color: var(--accent); 383 + color: var(--accent); 384 + } 385 + 386 + .reorder-button:disabled { 387 + opacity: 0.6; 388 + cursor: not-allowed; 144 389 } 145 390 146 - .btn-action:hover { 391 + .reorder-button.active { 147 392 border-color: var(--accent); 148 393 color: var(--accent); 394 + background: color-mix(in srgb, var(--accent) 10%, transparent); 149 395 } 150 396 151 - .btn-action:active { 152 - transform: scale(0.97); 397 + .spinner { 398 + animation: spin 1s linear infinite; 153 399 } 154 400 155 - .btn-action svg { 156 - flex-shrink: 0; 401 + @keyframes spin { 402 + from { 403 + transform: rotate(0deg); 404 + } 405 + to { 406 + transform: rotate(360deg); 407 + } 157 408 } 158 409 159 410 .empty-state { ··· 185 436 gap: 0.5rem; 186 437 } 187 438 439 + /* edit mode styles */ 440 + .track-row { 441 + display: flex; 442 + align-items: center; 443 + gap: 0.5rem; 444 + border-radius: 8px; 445 + transition: all 0.2s; 446 + position: relative; 447 + } 448 + 449 + .track-row.drag-over { 450 + background: color-mix(in srgb, var(--accent) 12%, transparent); 451 + outline: 2px dashed var(--accent); 452 + outline-offset: -2px; 453 + } 454 + 455 + .track-row.is-dragging { 456 + opacity: 0.9; 457 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 458 + z-index: 10; 459 + } 460 + 461 + :global(.track-row.touch-dragging) { 462 + z-index: 100; 463 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); 464 + } 465 + 466 + .drag-handle { 467 + display: flex; 468 + align-items: center; 469 + justify-content: center; 470 + padding: 0.5rem; 471 + background: transparent; 472 + border: none; 473 + color: var(--text-muted); 474 + cursor: grab; 475 + touch-action: none; 476 + border-radius: 4px; 477 + transition: all 0.2s; 478 + flex-shrink: 0; 479 + } 480 + 481 + .drag-handle:hover { 482 + color: var(--text-secondary); 483 + background: var(--bg-tertiary); 484 + } 485 + 486 + .drag-handle:active { 487 + cursor: grabbing; 488 + color: var(--accent); 489 + } 490 + 491 + @media (pointer: coarse) { 492 + .drag-handle { 493 + color: var(--text-tertiary); 494 + } 495 + } 496 + 497 + .track-content { 498 + flex: 1; 499 + min-width: 0; 500 + } 501 + 188 502 @media (max-width: 768px) { 189 503 .page { 190 504 padding: 0 0.75rem calc(var(--player-height, 0px) + 1.25rem + env(safe-area-inset-bottom, 0px)); ··· 207 521 font-size: 1.25rem; 208 522 } 209 523 210 - .btn-action { 211 - padding: 0.45rem 0.7rem; 212 - font-size: 0.8rem; 524 + .header-actions { 525 + gap: 0.75rem; 213 526 } 214 527 215 - .btn-action svg { 528 + .queue-button, 529 + .reorder-button { 530 + padding: 0.6rem 1rem; 531 + font-size: 0.85rem; 532 + } 533 + 534 + .queue-button svg, 535 + .reorder-button svg { 216 536 width: 16px; 217 537 height: 16px; 218 538 }
+315
frontend/src/routes/liked/[handle]/+page.svelte
··· 1 + <script lang="ts"> 2 + import Header from '$lib/components/Header.svelte'; 3 + import TrackItem from '$lib/components/TrackItem.svelte'; 4 + import { player } from '$lib/player.svelte'; 5 + import { queue } from '$lib/queue.svelte'; 6 + import { toast } from '$lib/toast.svelte'; 7 + import { auth } from '$lib/auth.svelte'; 8 + import type { Track } from '$lib/types'; 9 + import type { PageData } from './$types'; 10 + 11 + let { data }: { data: PageData } = $props(); 12 + 13 + const user = $derived(data.userLikes?.user); 14 + const tracks = $derived(data.userLikes?.tracks ?? []); 15 + const displayName = $derived(user?.display_name || user?.handle || 'unknown'); 16 + const isOwnProfile = $derived(auth.user?.handle === user?.handle); 17 + 18 + async function handleLogout() { 19 + await auth.logout(); 20 + window.location.href = '/'; 21 + } 22 + 23 + function playTrack(track: Track) { 24 + queue.playNow(track); 25 + } 26 + 27 + function queueAll() { 28 + if (tracks.length === 0) return; 29 + queue.addTracks(tracks); 30 + toast.success(`queued ${tracks.length} ${tracks.length === 1 ? 'track' : 'tracks'}`); 31 + } 32 + </script> 33 + 34 + <svelte:head> 35 + <title>{displayName}'s liked tracks • plyr</title> 36 + <meta name="description" content="tracks liked by {displayName} on plyr.fm" /> 37 + {#if user?.avatar_url} 38 + <meta property="og:image" content={user.avatar_url} /> 39 + {/if} 40 + </svelte:head> 41 + 42 + <Header user={auth.user} isAuthenticated={auth.isAuthenticated} onLogout={handleLogout} /> 43 + 44 + <div class="page"> 45 + <div class="user-header"> 46 + {#if user?.avatar_url} 47 + <img src={user.avatar_url} alt={displayName} class="avatar" /> 48 + {:else} 49 + <div class="avatar avatar-placeholder"> 50 + {displayName.charAt(0).toUpperCase()} 51 + </div> 52 + {/if} 53 + <div class="user-info"> 54 + <h1>{displayName}'s liked tracks</h1> 55 + <a href="/u/{user?.handle}" class="handle"> 56 + @{user?.handle} 57 + </a> 58 + </div> 59 + </div> 60 + 61 + <div class="section-header"> 62 + <h2> 63 + {#if tracks.length > 0} 64 + <span class="count">{tracks.length} {tracks.length === 1 ? 'track' : 'tracks'}</span> 65 + {:else} 66 + no liked tracks 67 + {/if} 68 + </h2> 69 + {#if tracks.length > 0} 70 + <div class="header-actions"> 71 + <button class="btn-action" onclick={queueAll} title="queue all liked tracks"> 72 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 73 + <line x1="8" y1="6" x2="21" y2="6"></line> 74 + <line x1="8" y1="12" x2="21" y2="12"></line> 75 + <line x1="8" y1="18" x2="21" y2="18"></line> 76 + <line x1="3" y1="6" x2="3.01" y2="6"></line> 77 + <line x1="3" y1="12" x2="3.01" y2="12"></line> 78 + <line x1="3" y1="18" x2="3.01" y2="18"></line> 79 + </svg> 80 + <span>queue all</span> 81 + </button> 82 + </div> 83 + {/if} 84 + </div> 85 + 86 + {#if tracks.length === 0} 87 + <div class="empty-state"> 88 + <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 89 + <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path> 90 + </svg> 91 + <h2>{isOwnProfile ? "you haven't" : `${displayName} hasn't`} liked any tracks yet</h2> 92 + <p>liked tracks will appear here</p> 93 + </div> 94 + {:else} 95 + <div class="tracks-list"> 96 + {#each tracks as track, i (track.id)} 97 + <TrackItem 98 + {track} 99 + index={i} 100 + isPlaying={player.currentTrack?.id === track.id && !player.paused} 101 + onPlay={playTrack} 102 + isAuthenticated={auth.isAuthenticated} 103 + /> 104 + {/each} 105 + </div> 106 + {/if} 107 + </div> 108 + 109 + <style> 110 + .page { 111 + max-width: 800px; 112 + margin: 0 auto; 113 + padding: 0 1rem calc(var(--player-height, 0px) + 2rem + env(safe-area-inset-bottom, 0px)); 114 + min-height: 100vh; 115 + } 116 + 117 + .user-header { 118 + display: flex; 119 + align-items: center; 120 + gap: 1rem; 121 + margin-bottom: 2rem; 122 + padding-bottom: 1.5rem; 123 + border-bottom: 1px solid var(--border-default); 124 + } 125 + 126 + .avatar { 127 + width: 64px; 128 + height: 64px; 129 + border-radius: 50%; 130 + object-fit: cover; 131 + flex-shrink: 0; 132 + } 133 + 134 + .avatar-placeholder { 135 + display: flex; 136 + align-items: center; 137 + justify-content: center; 138 + background: var(--bg-tertiary); 139 + color: var(--text-secondary); 140 + font-size: 1.5rem; 141 + font-weight: 600; 142 + } 143 + 144 + .user-info { 145 + display: flex; 146 + flex-direction: column; 147 + gap: 0.25rem; 148 + min-width: 0; 149 + } 150 + 151 + .user-info h1 { 152 + font-size: 1.5rem; 153 + font-weight: 700; 154 + color: var(--text-primary); 155 + margin: 0; 156 + overflow: hidden; 157 + text-overflow: ellipsis; 158 + white-space: nowrap; 159 + } 160 + 161 + .handle { 162 + font-size: 0.9rem; 163 + color: var(--text-tertiary); 164 + text-decoration: none; 165 + transition: color 0.15s; 166 + } 167 + 168 + .handle:hover { 169 + color: var(--accent); 170 + } 171 + 172 + .section-header { 173 + display: flex; 174 + justify-content: space-between; 175 + align-items: center; 176 + gap: 1rem; 177 + margin-bottom: 1.5rem; 178 + flex-wrap: wrap; 179 + } 180 + 181 + .section-header h2 { 182 + font-size: var(--text-page-heading); 183 + font-weight: 700; 184 + color: var(--text-primary); 185 + margin: 0; 186 + display: flex; 187 + align-items: center; 188 + gap: 0.6rem; 189 + } 190 + 191 + .count { 192 + font-size: 0.95rem; 193 + font-weight: 500; 194 + color: var(--text-secondary); 195 + } 196 + 197 + .header-actions { 198 + display: flex; 199 + align-items: center; 200 + gap: 0.75rem; 201 + } 202 + 203 + .btn-action { 204 + display: flex; 205 + align-items: center; 206 + gap: 0.5rem; 207 + padding: 0.5rem 0.85rem; 208 + background: transparent; 209 + border: 1px solid var(--border-default); 210 + color: var(--text-secondary); 211 + border-radius: 6px; 212 + font-size: 0.85rem; 213 + font-family: inherit; 214 + cursor: pointer; 215 + transition: all 0.15s; 216 + white-space: nowrap; 217 + } 218 + 219 + .btn-action:hover { 220 + border-color: var(--accent); 221 + color: var(--accent); 222 + } 223 + 224 + .btn-action:active { 225 + transform: scale(0.97); 226 + } 227 + 228 + .btn-action svg { 229 + flex-shrink: 0; 230 + } 231 + 232 + .empty-state { 233 + text-align: center; 234 + padding: 4rem 1rem; 235 + color: var(--text-tertiary); 236 + } 237 + 238 + .empty-state svg { 239 + margin: 0 auto 1.5rem; 240 + color: var(--text-muted); 241 + } 242 + 243 + .empty-state h2 { 244 + font-size: 1.5rem; 245 + font-weight: 600; 246 + color: var(--text-secondary); 247 + margin: 0 0 0.5rem 0; 248 + } 249 + 250 + .empty-state p { 251 + font-size: 0.95rem; 252 + margin: 0; 253 + } 254 + 255 + .tracks-list { 256 + display: flex; 257 + flex-direction: column; 258 + gap: 0.5rem; 259 + } 260 + 261 + @media (max-width: 768px) { 262 + .page { 263 + padding: 0 0.75rem calc(var(--player-height, 0px) + 1.25rem + env(safe-area-inset-bottom, 0px)); 264 + } 265 + 266 + .user-header { 267 + gap: 0.75rem; 268 + margin-bottom: 1.5rem; 269 + padding-bottom: 1rem; 270 + } 271 + 272 + .avatar { 273 + width: 48px; 274 + height: 48px; 275 + } 276 + 277 + .avatar-placeholder { 278 + font-size: 1.25rem; 279 + } 280 + 281 + .user-info h1 { 282 + font-size: 1.25rem; 283 + } 284 + 285 + .handle { 286 + font-size: 0.85rem; 287 + } 288 + 289 + .section-header h2 { 290 + font-size: 1.25rem; 291 + } 292 + 293 + .count { 294 + font-size: 0.85rem; 295 + } 296 + 297 + .empty-state { 298 + padding: 3rem 1rem; 299 + } 300 + 301 + .empty-state h2 { 302 + font-size: 1.25rem; 303 + } 304 + 305 + .btn-action { 306 + padding: 0.45rem 0.7rem; 307 + font-size: 0.8rem; 308 + } 309 + 310 + .btn-action svg { 311 + width: 16px; 312 + height: 16px; 313 + } 314 + } 315 + </style>
+24
frontend/src/routes/liked/[handle]/+page.ts
··· 1 + import { browser } from '$app/environment'; 2 + import { error } from '@sveltejs/kit'; 3 + import { fetchUserLikes, type UserLikesResponse } from '$lib/tracks.svelte'; 4 + import type { PageLoad } from './$types'; 5 + 6 + export interface PageData { 7 + userLikes: UserLikesResponse; 8 + } 9 + 10 + export const ssr = false; 11 + 12 + export const load: PageLoad = async ({ params }) => { 13 + if (!browser) { 14 + return { userLikes: null }; 15 + } 16 + 17 + const userLikes = await fetchUserLikes(params.handle); 18 + 19 + if (!userLikes) { 20 + throw error(404, 'user not found'); 21 + } 22 + 23 + return { userLikes }; 24 + };
+20
frontend/src/routes/playlist/[id]/+page.server.ts
··· 1 + import { API_URL } from '$lib/config'; 2 + import type { Playlist } from '$lib/types'; 3 + import type { PageServerLoad } from './$types'; 4 + 5 + export const load: PageServerLoad = async ({ params, fetch }) => { 6 + try { 7 + // fetch public metadata for OG tags (no auth required) 8 + const response = await fetch(`${API_URL}/lists/playlists/${params.id}/meta`); 9 + 10 + if (!response.ok) { 11 + return { playlistMeta: null }; 12 + } 13 + 14 + const playlistMeta: Playlist = await response.json(); 15 + return { playlistMeta }; 16 + } catch (e) { 17 + console.error('failed to load playlist meta:', e); 18 + return { playlistMeta: null }; 19 + } 20 + };
+1660
frontend/src/routes/playlist/[id]/+page.svelte
··· 1 + <script lang="ts"> 2 + import Header from '$lib/components/Header.svelte'; 3 + import ShareButton from '$lib/components/ShareButton.svelte'; 4 + import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 5 + import TrackItem from '$lib/components/TrackItem.svelte'; 6 + import { auth } from '$lib/auth.svelte'; 7 + import { goto } from '$app/navigation'; 8 + import { page } from '$app/stores'; 9 + import { API_URL } from '$lib/config'; 10 + import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding'; 11 + import { toast } from '$lib/toast.svelte'; 12 + import { player } from '$lib/player.svelte'; 13 + import { queue } from '$lib/queue.svelte'; 14 + import type { PageData } from './$types'; 15 + import type { PlaylistWithTracks, Track } from '$lib/types'; 16 + 17 + let { data }: { data: PageData } = $props(); 18 + let playlist = $state<PlaylistWithTracks>(data.playlist); 19 + let tracks = $state<Track[]>(data.playlist.tracks); 20 + 21 + // search state 22 + let showSearch = $state(false); 23 + let searchQuery = $state(''); 24 + let searchResults = $state<any[]>([]); 25 + let searching = $state(false); 26 + let searchError = $state(''); 27 + 28 + // UI state 29 + let deleting = $state(false); 30 + let addingTrack = $state<number | null>(null); 31 + let showDeleteConfirm = $state(false); 32 + 33 + // edit modal state 34 + let showEdit = $state(false); 35 + let editName = $state(''); 36 + let editShowOnProfile = $state(false); 37 + let editImageFile = $state<File | null>(null); 38 + let editImagePreview = $state<string | null>(null); 39 + let saving = $state(false); 40 + let uploadingCover = $state(false); 41 + 42 + // reorder state 43 + let isEditMode = $state(false); 44 + let isSavingOrder = $state(false); 45 + 46 + // drag state 47 + let draggedIndex = $state<number | null>(null); 48 + let dragOverIndex = $state<number | null>(null); 49 + 50 + // touch drag state 51 + let touchDragIndex = $state<number | null>(null); 52 + let touchStartY = $state(0); 53 + let touchDragElement = $state<HTMLElement | null>(null); 54 + let tracksListElement = $state<HTMLElement | null>(null); 55 + 56 + async function handleLogout() { 57 + await auth.logout(); 58 + window.location.href = '/'; 59 + } 60 + 61 + function playTrack(track: Track) { 62 + queue.playNow(track); 63 + } 64 + 65 + function playNow() { 66 + if (tracks.length > 0) { 67 + queue.setQueue(tracks); 68 + queue.playNow(tracks[0]); 69 + toast.success(`playing ${playlist.name}`, 1800); 70 + } 71 + } 72 + 73 + function addToQueue() { 74 + if (tracks.length > 0) { 75 + queue.addTracks(tracks); 76 + toast.success(`added ${playlist.name} to queue`, 1800); 77 + } 78 + } 79 + 80 + async function searchTracks() { 81 + if (!searchQuery.trim() || searchQuery.trim().length < 2) { 82 + searchResults = []; 83 + return; 84 + } 85 + 86 + searching = true; 87 + searchError = ''; 88 + 89 + try { 90 + const response = await fetch(`${API_URL}/search?q=${encodeURIComponent(searchQuery)}&type=tracks&limit=10`, { 91 + credentials: 'include' 92 + }); 93 + 94 + if (!response.ok) { 95 + throw new Error('search failed'); 96 + } 97 + 98 + const data = await response.json(); 99 + // filter out tracks already in playlist 100 + const existingUris = new Set(tracks.map(t => t.atproto_record_uri)); 101 + searchResults = data.results.filter((r: any) => r.type === 'track' && !existingUris.has(r.atproto_record_uri)); 102 + } catch (e) { 103 + searchError = 'failed to search tracks'; 104 + searchResults = []; 105 + } finally { 106 + searching = false; 107 + } 108 + } 109 + 110 + async function addTrack(track: any) { 111 + addingTrack = track.id; 112 + 113 + try { 114 + // first fetch full track details to get ATProto URI and CID 115 + const trackResponse = await fetch(`${API_URL}/tracks/${track.id}`, { 116 + credentials: 'include' 117 + }); 118 + 119 + if (!trackResponse.ok) { 120 + throw new Error('failed to fetch track details'); 121 + } 122 + 123 + const trackData = await trackResponse.json(); 124 + 125 + if (!trackData.atproto_record_uri || !trackData.atproto_record_cid) { 126 + throw new Error('track does not have ATProto record'); 127 + } 128 + 129 + // add to playlist 130 + const response = await fetch(`${API_URL}/lists/playlists/${playlist.id}/tracks`, { 131 + method: 'POST', 132 + credentials: 'include', 133 + headers: { 'Content-Type': 'application/json' }, 134 + body: JSON.stringify({ 135 + track_uri: trackData.atproto_record_uri, 136 + track_cid: trackData.atproto_record_cid 137 + }) 138 + }); 139 + 140 + if (!response.ok) { 141 + const data = await response.json(); 142 + throw new Error(data.detail || 'failed to add track'); 143 + } 144 + 145 + // add full track to local state 146 + tracks = [...tracks, trackData as Track]; 147 + 148 + // update playlist track count 149 + playlist.track_count = tracks.length; 150 + 151 + // remove from search results 152 + searchResults = searchResults.filter(r => r.id !== track.id); 153 + 154 + toast.success(`added "${trackData.title}" to playlist`); 155 + } catch (e) { 156 + console.error('failed to add track:', e); 157 + toast.error(e instanceof Error ? e.message : 'failed to add track'); 158 + } finally { 159 + addingTrack = null; 160 + } 161 + } 162 + 163 + // reorder functions 164 + function toggleEditMode() { 165 + if (isEditMode) { 166 + saveOrder(); 167 + } 168 + isEditMode = !isEditMode; 169 + } 170 + 171 + async function saveOrder() { 172 + if (!playlist.atproto_record_uri) return; 173 + 174 + // extract rkey from list URI (at://did/collection/rkey) 175 + const rkey = playlist.atproto_record_uri.split('/').pop(); 176 + if (!rkey) return; 177 + 178 + // build strongRefs from current track order 179 + const items = tracks 180 + .filter((t) => t.atproto_record_uri && t.atproto_record_cid) 181 + .map((t) => ({ 182 + uri: t.atproto_record_uri!, 183 + cid: t.atproto_record_cid! 184 + })); 185 + 186 + if (items.length === 0) return; 187 + 188 + isSavingOrder = true; 189 + try { 190 + const response = await fetch(`${API_URL}/lists/${rkey}/reorder`, { 191 + method: 'PUT', 192 + headers: { 'Content-Type': 'application/json' }, 193 + credentials: 'include', 194 + body: JSON.stringify({ items }) 195 + }); 196 + 197 + if (!response.ok) { 198 + const error = await response.json().catch(() => ({ detail: 'unknown error' })); 199 + throw new Error(error.detail || 'failed to save order'); 200 + } 201 + 202 + toast.success('order saved'); 203 + } catch (e) { 204 + toast.error(e instanceof Error ? e.message : 'failed to save order'); 205 + } finally { 206 + isSavingOrder = false; 207 + } 208 + } 209 + 210 + // move track from one index to another 211 + function moveTrack(fromIndex: number, toIndex: number) { 212 + if (fromIndex === toIndex) return; 213 + const newTracks = [...tracks]; 214 + const [moved] = newTracks.splice(fromIndex, 1); 215 + newTracks.splice(toIndex, 0, moved); 216 + tracks = newTracks; 217 + } 218 + 219 + // desktop drag and drop 220 + function handleDragStart(event: DragEvent, index: number) { 221 + draggedIndex = index; 222 + if (event.dataTransfer) { 223 + event.dataTransfer.effectAllowed = 'move'; 224 + } 225 + } 226 + 227 + function handleDragOver(event: DragEvent, index: number) { 228 + event.preventDefault(); 229 + dragOverIndex = index; 230 + } 231 + 232 + function handleDrop(event: DragEvent, index: number) { 233 + event.preventDefault(); 234 + if (draggedIndex !== null && draggedIndex !== index) { 235 + moveTrack(draggedIndex, index); 236 + } 237 + draggedIndex = null; 238 + dragOverIndex = null; 239 + } 240 + 241 + function handleDragEnd() { 242 + draggedIndex = null; 243 + dragOverIndex = null; 244 + } 245 + 246 + // touch drag and drop 247 + function handleTouchStart(event: TouchEvent, index: number) { 248 + const touch = event.touches[0]; 249 + touchDragIndex = index; 250 + touchStartY = touch.clientY; 251 + touchDragElement = event.currentTarget as HTMLElement; 252 + touchDragElement.classList.add('touch-dragging'); 253 + } 254 + 255 + function handleTouchMove(event: TouchEvent) { 256 + if (touchDragIndex === null || !touchDragElement || !tracksListElement) return; 257 + 258 + event.preventDefault(); 259 + const touch = event.touches[0]; 260 + const offset = touch.clientY - touchStartY; 261 + touchDragElement.style.transform = `translateY(${offset}px)`; 262 + 263 + const trackElements = tracksListElement.querySelectorAll('.track-row'); 264 + for (let i = 0; i < trackElements.length; i++) { 265 + const trackEl = trackElements[i] as HTMLElement; 266 + const rect = trackEl.getBoundingClientRect(); 267 + const midY = rect.top + rect.height / 2; 268 + 269 + if (touch.clientY < midY && i > 0) { 270 + const targetIndex = parseInt(trackEl.dataset.index || '0'); 271 + if (targetIndex !== touchDragIndex) { 272 + dragOverIndex = targetIndex; 273 + } 274 + break; 275 + } else if (touch.clientY >= midY) { 276 + const targetIndex = parseInt(trackEl.dataset.index || '0'); 277 + if (targetIndex !== touchDragIndex) { 278 + dragOverIndex = targetIndex; 279 + } 280 + } 281 + } 282 + } 283 + 284 + function handleTouchEnd() { 285 + if (touchDragIndex !== null && dragOverIndex !== null && touchDragIndex !== dragOverIndex) { 286 + moveTrack(touchDragIndex, dragOverIndex); 287 + } 288 + 289 + if (touchDragElement) { 290 + touchDragElement.classList.remove('touch-dragging'); 291 + touchDragElement.style.transform = ''; 292 + } 293 + 294 + touchDragIndex = null; 295 + dragOverIndex = null; 296 + touchDragElement = null; 297 + } 298 + 299 + async function deletePlaylist() { 300 + deleting = true; 301 + 302 + try { 303 + const response = await fetch(`${API_URL}/lists/playlists/${playlist.id}`, { 304 + method: 'DELETE', 305 + credentials: 'include' 306 + }); 307 + 308 + if (!response.ok) { 309 + throw new Error('failed to delete playlist'); 310 + } 311 + 312 + toast.success('playlist deleted'); 313 + goto('/library'); 314 + } catch (e) { 315 + console.error('failed to delete playlist:', e); 316 + toast.error(e instanceof Error ? e.message : 'failed to delete playlist'); 317 + deleting = false; 318 + showDeleteConfirm = false; 319 + } 320 + } 321 + 322 + function openEditModal() { 323 + editName = playlist.name; 324 + editShowOnProfile = playlist.show_on_profile; 325 + editImageFile = null; 326 + editImagePreview = null; 327 + showEdit = true; 328 + } 329 + 330 + function handleEditImageSelect(event: Event) { 331 + const input = event.target as HTMLInputElement; 332 + const file = input.files?.[0]; 333 + if (!file) return; 334 + 335 + // validate file type 336 + if (!file.type.startsWith('image/')) { 337 + return; 338 + } 339 + 340 + // validate file size (20MB max) 341 + if (file.size > 20 * 1024 * 1024) { 342 + return; 343 + } 344 + 345 + editImageFile = file; 346 + editImagePreview = URL.createObjectURL(file); 347 + } 348 + 349 + async function savePlaylistChanges() { 350 + saving = true; 351 + 352 + try { 353 + // update name and/or show_on_profile if changed 354 + const nameChanged = editName.trim() && editName.trim() !== playlist.name; 355 + const showOnProfileChanged = editShowOnProfile !== playlist.show_on_profile; 356 + 357 + if (nameChanged || showOnProfileChanged) { 358 + const formData = new FormData(); 359 + if (nameChanged) { 360 + formData.append('name', editName.trim()); 361 + } 362 + if (showOnProfileChanged) { 363 + formData.append('show_on_profile', String(editShowOnProfile)); 364 + } 365 + 366 + const response = await fetch(`${API_URL}/lists/playlists/${playlist.id}`, { 367 + method: 'PATCH', 368 + credentials: 'include', 369 + body: formData 370 + }); 371 + 372 + if (!response.ok) { 373 + throw new Error('failed to update playlist'); 374 + } 375 + 376 + const updated = await response.json(); 377 + playlist.name = updated.name; 378 + playlist.show_on_profile = updated.show_on_profile; 379 + } 380 + 381 + // upload cover if selected 382 + if (editImageFile) { 383 + uploadingCover = true; 384 + const formData = new FormData(); 385 + formData.append('image', editImageFile); 386 + 387 + const response = await fetch(`${API_URL}/lists/playlists/${playlist.id}/cover`, { 388 + method: 'POST', 389 + credentials: 'include', 390 + body: formData 391 + }); 392 + 393 + if (!response.ok) { 394 + throw new Error('failed to upload cover'); 395 + } 396 + 397 + const result = await response.json(); 398 + playlist.image_url = result.image_url; 399 + uploadingCover = false; 400 + } 401 + 402 + showEdit = false; 403 + toast.success('playlist updated'); 404 + } catch (e) { 405 + console.error('failed to save playlist:', e); 406 + toast.error(e instanceof Error ? e.message : 'failed to save playlist'); 407 + } finally { 408 + saving = false; 409 + uploadingCover = false; 410 + if (editImagePreview) { 411 + URL.revokeObjectURL(editImagePreview); 412 + editImagePreview = null; 413 + } 414 + } 415 + } 416 + 417 + function handleKeydown(event: KeyboardEvent) { 418 + if (event.key === 'Escape') { 419 + if (showSearch) { 420 + showSearch = false; 421 + searchQuery = ''; 422 + searchResults = []; 423 + } 424 + if (showDeleteConfirm) { 425 + showDeleteConfirm = false; 426 + } 427 + if (showEdit) { 428 + showEdit = false; 429 + if (editImagePreview) { 430 + URL.revokeObjectURL(editImagePreview); 431 + editImagePreview = null; 432 + } 433 + } 434 + } 435 + } 436 + 437 + // debounced search 438 + let searchTimeout: ReturnType<typeof setTimeout>; 439 + $effect(() => { 440 + clearTimeout(searchTimeout); 441 + if (searchQuery.trim().length >= 2) { 442 + searchTimeout = setTimeout(searchTracks, 300); 443 + } else { 444 + searchResults = []; 445 + } 446 + }); 447 + 448 + // check if user owns this playlist 449 + const isOwner = $derived(auth.user?.did === playlist.owner_did); 450 + </script> 451 + 452 + <svelte:window on:keydown={handleKeydown} /> 453 + 454 + <svelte:head> 455 + <title>{playlist.name} • plyr</title> 456 + <meta 457 + name="description" 458 + content="playlist by @{playlist.owner_handle} • {playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'} on {APP_NAME}" 459 + /> 460 + 461 + <!-- Open Graph / Facebook --> 462 + <meta property="og:type" content="music.playlist" /> 463 + <meta property="og:title" content="{playlist.name}" /> 464 + <meta 465 + property="og:description" 466 + content="playlist by @{playlist.owner_handle} • {playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'}" 467 + /> 468 + <meta property="og:url" content="{APP_CANONICAL_URL}/playlist/{playlist.id}" /> 469 + <meta property="og:site_name" content={APP_NAME} /> 470 + {#if playlist.image_url} 471 + <meta property="og:image" content={playlist.image_url} /> 472 + {/if} 473 + 474 + <!-- Twitter --> 475 + <meta name="twitter:card" content={playlist.image_url ? "summary_large_image" : "summary"} /> 476 + <meta name="twitter:title" content="{playlist.name}" /> 477 + <meta 478 + name="twitter:description" 479 + content="playlist by @{playlist.owner_handle} • {playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'}" 480 + /> 481 + {#if playlist.image_url} 482 + <meta name="twitter:image" content={playlist.image_url} /> 483 + {/if} 484 + </svelte:head> 485 + 486 + <Header user={auth.user} isAuthenticated={auth.isAuthenticated} onLogout={handleLogout} /> 487 + 488 + <div class="container"> 489 + <main> 490 + <div class="playlist-hero"> 491 + {#if playlist.image_url} 492 + <SensitiveImage src={playlist.image_url} tooltipPosition="center"> 493 + <img src={playlist.image_url} alt="{playlist.name} artwork" class="playlist-art" /> 494 + </SensitiveImage> 495 + {:else} 496 + <div class="playlist-art-placeholder"> 497 + <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 498 + <line x1="8" y1="6" x2="21" y2="6"></line> 499 + <line x1="8" y1="12" x2="21" y2="12"></line> 500 + <line x1="8" y1="18" x2="21" y2="18"></line> 501 + <line x1="3" y1="6" x2="3.01" y2="6"></line> 502 + <line x1="3" y1="12" x2="3.01" y2="12"></line> 503 + <line x1="3" y1="18" x2="3.01" y2="18"></line> 504 + </svg> 505 + </div> 506 + {/if} 507 + <div class="playlist-info-wrapper"> 508 + <div class="playlist-info"> 509 + <p class="playlist-type">playlist</p> 510 + <h1 class="playlist-title">{playlist.name}</h1> 511 + <div class="playlist-meta"> 512 + <a href="/u/{playlist.owner_handle}" class="owner-link"> 513 + {playlist.owner_handle} 514 + </a> 515 + <span class="meta-separator">•</span> 516 + <span>{playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'}</span> 517 + </div> 518 + </div> 519 + 520 + <div class="side-buttons"> 521 + <ShareButton url={$page.url.href} title="share playlist" /> 522 + {#if isOwner} 523 + <button class="icon-btn" onclick={openEditModal} aria-label="edit playlist metadata" title="edit playlist metadata"> 524 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 525 + <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path> 526 + <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path> 527 + </svg> 528 + </button> 529 + <button class="icon-btn danger" onclick={() => showDeleteConfirm = true} aria-label="delete playlist" title="delete playlist"> 530 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 531 + <polyline points="3 6 5 6 21 6"></polyline> 532 + <path d="m19 6-.867 12.142A2 2 0 0 1 16.138 20H7.862a2 2 0 0 1-1.995-1.858L5 6"></path> 533 + <path d="M10 11v6"></path> 534 + <path d="M14 11v6"></path> 535 + <path d="m9 6 .5-2h5l.5 2"></path> 536 + </svg> 537 + </button> 538 + {/if} 539 + </div> 540 + </div> 541 + </div> 542 + 543 + <div class="playlist-actions"> 544 + <button class="play-button" onclick={playNow}> 545 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 546 + <path d="M8 5v14l11-7z"/> 547 + </svg> 548 + play now 549 + </button> 550 + <button class="queue-button" onclick={addToQueue}> 551 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> 552 + <line x1="5" y1="15" x2="5" y2="21"></line> 553 + <line x1="2" y1="18" x2="8" y2="18"></line> 554 + <line x1="9" y1="6" x2="21" y2="6"></line> 555 + <line x1="9" y1="12" x2="21" y2="12"></line> 556 + <line x1="9" y1="18" x2="21" y2="18"></line> 557 + </svg> 558 + add to queue 559 + </button> 560 + {#if isOwner} 561 + <button class="add-tracks-button" onclick={() => showSearch = true}> 562 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 563 + <line x1="12" y1="5" x2="12" y2="19"></line> 564 + <line x1="5" y1="12" x2="19" y2="12"></line> 565 + </svg> 566 + add tracks 567 + </button> 568 + {#if tracks.length > 1} 569 + <button 570 + class="reorder-button" 571 + class:active={isEditMode} 572 + onclick={toggleEditMode} 573 + disabled={isSavingOrder} 574 + title={isEditMode ? 'save order' : 'reorder tracks'} 575 + > 576 + {#if isEditMode} 577 + {#if isSavingOrder} 578 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spinner"> 579 + <circle cx="12" cy="12" r="10" stroke-dasharray="31.4" stroke-dashoffset="10"></circle> 580 + </svg> 581 + saving... 582 + {:else} 583 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 584 + <polyline points="20 6 9 17 4 12"></polyline> 585 + </svg> 586 + done 587 + {/if} 588 + {:else} 589 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 590 + <line x1="3" y1="12" x2="21" y2="12"></line> 591 + <line x1="3" y1="6" x2="21" y2="6"></line> 592 + <line x1="3" y1="18" x2="21" y2="18"></line> 593 + </svg> 594 + reorder 595 + {/if} 596 + </button> 597 + {/if} 598 + {/if} 599 + <div class="mobile-share-button"> 600 + <ShareButton url={$page.url.href} title="share playlist" /> 601 + </div> 602 + </div> 603 + 604 + <div class="tracks-section"> 605 + <h2 class="section-heading">tracks</h2> 606 + {#if tracks.length === 0} 607 + <div class="empty-state"> 608 + <div class="empty-icon"> 609 + <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 610 + <circle cx="11" cy="11" r="8"></circle> 611 + <line x1="21" y1="21" x2="16.65" y2="16.65"></line> 612 + </svg> 613 + </div> 614 + <p>no tracks yet</p> 615 + <span>search for tracks to add to your playlist</span> 616 + {#if isOwner} 617 + <button class="empty-add-btn" onclick={() => showSearch = true}> 618 + add tracks 619 + </button> 620 + {/if} 621 + </div> 622 + {:else} 623 + <div 624 + class="tracks-list" 625 + class:edit-mode={isEditMode} 626 + bind:this={tracksListElement} 627 + ontouchmove={isEditMode ? handleTouchMove : undefined} 628 + ontouchend={isEditMode ? handleTouchEnd : undefined} 629 + ontouchcancel={isEditMode ? handleTouchEnd : undefined} 630 + > 631 + {#each tracks as track, i (track.id)} 632 + {#if isEditMode} 633 + <div 634 + class="track-row" 635 + class:drag-over={dragOverIndex === i && touchDragIndex !== i} 636 + class:is-dragging={touchDragIndex === i || draggedIndex === i} 637 + data-index={i} 638 + role="listitem" 639 + draggable="true" 640 + ondragstart={(e) => handleDragStart(e, i)} 641 + ondragover={(e) => handleDragOver(e, i)} 642 + ondrop={(e) => handleDrop(e, i)} 643 + ondragend={handleDragEnd} 644 + > 645 + <button 646 + class="drag-handle" 647 + ontouchstart={(e) => handleTouchStart(e, i)} 648 + onclick={(e) => e.stopPropagation()} 649 + aria-label="drag to reorder" 650 + title="drag to reorder" 651 + > 652 + <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> 653 + <circle cx="5" cy="3" r="1.5"></circle> 654 + <circle cx="11" cy="3" r="1.5"></circle> 655 + <circle cx="5" cy="8" r="1.5"></circle> 656 + <circle cx="11" cy="8" r="1.5"></circle> 657 + <circle cx="5" cy="13" r="1.5"></circle> 658 + <circle cx="11" cy="13" r="1.5"></circle> 659 + </svg> 660 + </button> 661 + <div class="track-content"> 662 + <TrackItem 663 + {track} 664 + index={i} 665 + showIndex={true} 666 + isPlaying={player.currentTrack?.id === track.id} 667 + onPlay={playTrack} 668 + isAuthenticated={auth.isAuthenticated} 669 + hideAlbum={true} 670 + excludePlaylistId={playlist.id} 671 + /> 672 + </div> 673 + </div> 674 + {:else} 675 + <TrackItem 676 + {track} 677 + index={i} 678 + showIndex={true} 679 + isPlaying={player.currentTrack?.id === track.id} 680 + onPlay={playTrack} 681 + isAuthenticated={auth.isAuthenticated} 682 + hideAlbum={true} 683 + excludePlaylistId={playlist.id} 684 + /> 685 + {/if} 686 + {/each} 687 + </div> 688 + {/if} 689 + </div> 690 + </main> 691 + </div> 692 + 693 + {#if showSearch} 694 + <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 695 + <div class="modal-overlay" role="presentation" onclick={() => { showSearch = false; searchQuery = ''; searchResults = []; }}> 696 + <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 697 + <div class="modal search-modal" role="dialog" aria-modal="true" aria-labelledby="add-tracks-title" tabindex="-1" onclick={(e) => e.stopPropagation()}> 698 + <div class="modal-header"> 699 + <h3 id="add-tracks-title">add tracks</h3> 700 + <button class="close-btn" aria-label="close" onclick={() => { showSearch = false; searchQuery = ''; searchResults = []; }}> 701 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 702 + <line x1="18" y1="6" x2="6" y2="18"></line> 703 + <line x1="6" y1="6" x2="18" y2="18"></line> 704 + </svg> 705 + </button> 706 + </div> 707 + <div class="search-input-wrapper"> 708 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 709 + <circle cx="11" cy="11" r="8"></circle> 710 + <line x1="21" y1="21" x2="16.65" y2="16.65"></line> 711 + </svg> 712 + <!-- svelte-ignore a11y_autofocus --> 713 + <input 714 + type="text" 715 + bind:value={searchQuery} 716 + placeholder="search for tracks..." 717 + autofocus 718 + /> 719 + {#if searching} 720 + <span class="spinner"></span> 721 + {/if} 722 + </div> 723 + <div class="search-results"> 724 + {#if searchError} 725 + <p class="error">{searchError}</p> 726 + {:else if searchResults.length === 0 && searchQuery.length >= 2 && !searching} 727 + <p class="no-results">no tracks found</p> 728 + {:else} 729 + {#each searchResults as result} 730 + <div class="search-result-item"> 731 + {#if result.image_url} 732 + <img src={result.image_url} alt="" class="result-image" /> 733 + {:else} 734 + <div class="result-image-placeholder"> 735 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 736 + <circle cx="12" cy="12" r="10"></circle> 737 + <circle cx="12" cy="12" r="3"></circle> 738 + </svg> 739 + </div> 740 + {/if} 741 + <div class="result-info"> 742 + <span class="result-title">{result.title}</span> 743 + <span class="result-artist">{result.artist_display_name}</span> 744 + </div> 745 + <button 746 + class="add-result-btn" 747 + onclick={() => addTrack(result)} 748 + disabled={addingTrack === result.id} 749 + > 750 + {#if addingTrack === result.id} 751 + <span class="spinner"></span> 752 + {:else} 753 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 754 + <line x1="12" y1="5" x2="12" y2="19"></line> 755 + <line x1="5" y1="12" x2="19" y2="12"></line> 756 + </svg> 757 + {/if} 758 + </button> 759 + </div> 760 + {/each} 761 + {/if} 762 + </div> 763 + </div> 764 + </div> 765 + {/if} 766 + 767 + {#if showDeleteConfirm} 768 + <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 769 + <div class="modal-overlay" role="presentation" onclick={() => showDeleteConfirm = false}> 770 + <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 771 + <div class="modal" role="alertdialog" aria-modal="true" aria-labelledby="delete-confirm-title" tabindex="-1" onclick={(e) => e.stopPropagation()}> 772 + <div class="modal-header"> 773 + <h3 id="delete-confirm-title">delete playlist?</h3> 774 + </div> 775 + <div class="modal-body"> 776 + <p>are you sure you want to delete "{playlist.name}"? this action cannot be undone.</p> 777 + </div> 778 + <div class="modal-footer"> 779 + <button class="cancel-btn" onclick={() => showDeleteConfirm = false} disabled={deleting}> 780 + cancel 781 + </button> 782 + <button class="confirm-btn danger" onclick={deletePlaylist} disabled={deleting}> 783 + {deleting ? 'deleting...' : 'delete'} 784 + </button> 785 + </div> 786 + </div> 787 + </div> 788 + {/if} 789 + 790 + {#if showEdit} 791 + <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 792 + <div class="modal-overlay" role="presentation" onclick={() => { showEdit = false; if (editImagePreview) { URL.revokeObjectURL(editImagePreview); editImagePreview = null; } }}> 793 + <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 794 + <div class="modal edit-modal" role="dialog" aria-modal="true" aria-labelledby="edit-playlist-title" tabindex="-1" onclick={(e) => e.stopPropagation()}> 795 + <div class="modal-header"> 796 + <h3 id="edit-playlist-title">edit playlist</h3> 797 + <button class="close-btn" aria-label="close" onclick={() => { showEdit = false; if (editImagePreview) { URL.revokeObjectURL(editImagePreview); editImagePreview = null; } }}> 798 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 799 + <line x1="18" y1="6" x2="6" y2="18"></line> 800 + <line x1="6" y1="6" x2="18" y2="18"></line> 801 + </svg> 802 + </button> 803 + </div> 804 + <div class="modal-body"> 805 + <div class="edit-cover-section"> 806 + <label class="cover-picker"> 807 + {#if editImagePreview} 808 + <img src={editImagePreview} alt="preview" class="cover-preview" /> 809 + {:else if playlist.image_url} 810 + <SensitiveImage src={playlist.image_url} tooltipPosition="center"> 811 + <img src={playlist.image_url} alt="current cover" class="cover-preview" /> 812 + </SensitiveImage> 813 + {:else} 814 + <div class="cover-placeholder"> 815 + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 816 + <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect> 817 + <circle cx="8.5" cy="8.5" r="1.5"></circle> 818 + <polyline points="21 15 16 10 5 21"></polyline> 819 + </svg> 820 + <span>add cover</span> 821 + </div> 822 + {/if} 823 + <input type="file" accept="image/jpeg,image/png,image/webp" onchange={handleEditImageSelect} hidden /> 824 + </label> 825 + <span class="cover-hint">click to change cover art</span> 826 + </div> 827 + <div class="edit-name-section"> 828 + <label for="edit-name">playlist name</label> 829 + <input 830 + id="edit-name" 831 + type="text" 832 + bind:value={editName} 833 + placeholder="playlist name" 834 + /> 835 + </div> 836 + <div class="edit-toggle-section"> 837 + <label class="toggle-row"> 838 + <input 839 + type="checkbox" 840 + bind:checked={editShowOnProfile} 841 + /> 842 + <span class="toggle-label">show on profile</span> 843 + </label> 844 + <span class="toggle-hint">when enabled, this playlist will appear in your public collections</span> 845 + </div> 846 + </div> 847 + <div class="modal-footer"> 848 + <button class="cancel-btn" onclick={() => { showEdit = false; if (editImagePreview) { URL.revokeObjectURL(editImagePreview); editImagePreview = null; } }} disabled={saving}> 849 + cancel 850 + </button> 851 + <button class="confirm-btn" onclick={savePlaylistChanges} disabled={saving || (!editImageFile && editName.trim() === playlist.name && editShowOnProfile === playlist.show_on_profile)}> 852 + {#if saving} 853 + {uploadingCover ? 'uploading cover...' : 'saving...'} 854 + {:else} 855 + save 856 + {/if} 857 + </button> 858 + </div> 859 + </div> 860 + </div> 861 + {/if} 862 + 863 + <style> 864 + .container { 865 + max-width: 1200px; 866 + margin: 0 auto; 867 + padding: 0 1rem calc(var(--player-height, 120px) + 2rem + env(safe-area-inset-bottom, 0px)) 1rem; 868 + } 869 + 870 + main { 871 + margin-top: 2rem; 872 + } 873 + 874 + .playlist-hero { 875 + display: flex; 876 + gap: 2rem; 877 + margin-bottom: 2rem; 878 + align-items: flex-end; 879 + } 880 + 881 + .playlist-art { 882 + width: 200px; 883 + height: 200px; 884 + border-radius: 8px; 885 + object-fit: cover; 886 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 887 + } 888 + 889 + .playlist-art-placeholder { 890 + width: 200px; 891 + height: 200px; 892 + border-radius: 8px; 893 + background: var(--bg-tertiary); 894 + border: 1px solid var(--border-subtle); 895 + display: flex; 896 + align-items: center; 897 + justify-content: center; 898 + color: var(--text-muted); 899 + } 900 + 901 + .playlist-info-wrapper { 902 + flex: 1; 903 + display: flex; 904 + align-items: flex-end; 905 + gap: 1rem; 906 + } 907 + 908 + .playlist-info { 909 + flex: 1; 910 + display: flex; 911 + flex-direction: column; 912 + gap: 0.5rem; 913 + } 914 + 915 + .side-buttons { 916 + flex-shrink: 0; 917 + display: flex; 918 + align-items: center; 919 + gap: 0.5rem; 920 + padding-bottom: 0.5rem; 921 + } 922 + 923 + .mobile-share-button { 924 + display: none; 925 + } 926 + 927 + .playlist-type { 928 + text-transform: uppercase; 929 + font-size: 0.75rem; 930 + font-weight: 600; 931 + letter-spacing: 0.1em; 932 + color: var(--text-tertiary); 933 + margin: 0; 934 + } 935 + 936 + .playlist-title { 937 + font-size: 3rem; 938 + font-weight: 700; 939 + margin: 0; 940 + color: var(--text-primary); 941 + line-height: 1.1; 942 + word-wrap: break-word; 943 + overflow-wrap: break-word; 944 + hyphens: auto; 945 + } 946 + 947 + .playlist-meta { 948 + display: flex; 949 + align-items: center; 950 + gap: 0.75rem; 951 + font-size: 0.95rem; 952 + color: var(--text-secondary); 953 + } 954 + 955 + .owner-link { 956 + color: var(--text-secondary); 957 + text-decoration: none; 958 + font-weight: 600; 959 + transition: color 0.2s; 960 + } 961 + 962 + .owner-link:hover { 963 + color: var(--accent); 964 + } 965 + 966 + .meta-separator { 967 + color: var(--text-muted); 968 + font-size: 0.7rem; 969 + } 970 + 971 + .icon-btn { 972 + display: flex; 973 + align-items: center; 974 + justify-content: center; 975 + width: 32px; 976 + height: 32px; 977 + background: transparent; 978 + border: 1px solid var(--border-default); 979 + border-radius: 4px; 980 + color: var(--text-tertiary); 981 + cursor: pointer; 982 + transition: all 0.15s; 983 + } 984 + 985 + .icon-btn:hover { 986 + border-color: var(--accent); 987 + color: var(--accent); 988 + } 989 + 990 + .icon-btn.danger:hover { 991 + border-color: #ef4444; 992 + color: #ef4444; 993 + } 994 + 995 + /* playlist actions */ 996 + .playlist-actions { 997 + display: flex; 998 + gap: 1rem; 999 + margin-bottom: 2rem; 1000 + } 1001 + 1002 + .play-button, 1003 + .queue-button, 1004 + .add-tracks-button, 1005 + .reorder-button { 1006 + padding: 0.75rem 1.5rem; 1007 + border-radius: 24px; 1008 + font-weight: 600; 1009 + font-size: 0.95rem; 1010 + font-family: inherit; 1011 + cursor: pointer; 1012 + transition: all 0.2s; 1013 + display: flex; 1014 + align-items: center; 1015 + gap: 0.5rem; 1016 + border: none; 1017 + } 1018 + 1019 + .play-button { 1020 + background: var(--accent); 1021 + color: var(--bg-primary); 1022 + } 1023 + 1024 + .play-button:hover { 1025 + transform: scale(1.05); 1026 + } 1027 + 1028 + .queue-button, 1029 + .add-tracks-button, 1030 + .reorder-button { 1031 + background: transparent; 1032 + color: var(--text-primary); 1033 + border: 1px solid var(--border-default); 1034 + } 1035 + 1036 + .queue-button:hover, 1037 + .add-tracks-button:hover, 1038 + .reorder-button:hover { 1039 + border-color: var(--accent); 1040 + color: var(--accent); 1041 + } 1042 + 1043 + .reorder-button:disabled { 1044 + opacity: 0.6; 1045 + cursor: not-allowed; 1046 + } 1047 + 1048 + .reorder-button.active { 1049 + border-color: var(--accent); 1050 + color: var(--accent); 1051 + background: color-mix(in srgb, var(--accent) 10%, transparent); 1052 + } 1053 + 1054 + .spinner { 1055 + animation: spin 1s linear infinite; 1056 + } 1057 + 1058 + @keyframes spin { 1059 + from { 1060 + transform: rotate(0deg); 1061 + } 1062 + to { 1063 + transform: rotate(360deg); 1064 + } 1065 + } 1066 + 1067 + /* tracks section */ 1068 + .tracks-section { 1069 + margin-top: 2rem; 1070 + padding-bottom: calc(var(--player-height, 120px) + env(safe-area-inset-bottom, 0px)); 1071 + } 1072 + 1073 + .section-heading { 1074 + font-size: 1.25rem; 1075 + font-weight: 600; 1076 + color: var(--text-primary); 1077 + margin-bottom: 1rem; 1078 + text-transform: lowercase; 1079 + } 1080 + 1081 + /* tracks list */ 1082 + .tracks-list { 1083 + display: flex; 1084 + flex-direction: column; 1085 + gap: 0.5rem; 1086 + } 1087 + 1088 + /* edit mode styles */ 1089 + .track-row { 1090 + display: flex; 1091 + align-items: center; 1092 + gap: 0.5rem; 1093 + border-radius: 8px; 1094 + transition: all 0.2s; 1095 + position: relative; 1096 + } 1097 + 1098 + .track-row.drag-over { 1099 + background: color-mix(in srgb, var(--accent) 12%, transparent); 1100 + outline: 2px dashed var(--accent); 1101 + outline-offset: -2px; 1102 + } 1103 + 1104 + .track-row.is-dragging { 1105 + opacity: 0.9; 1106 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 1107 + z-index: 10; 1108 + } 1109 + 1110 + :global(.track-row.touch-dragging) { 1111 + z-index: 100; 1112 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); 1113 + } 1114 + 1115 + .drag-handle { 1116 + display: flex; 1117 + align-items: center; 1118 + justify-content: center; 1119 + padding: 0.5rem; 1120 + background: transparent; 1121 + border: none; 1122 + color: var(--text-muted); 1123 + cursor: grab; 1124 + touch-action: none; 1125 + border-radius: 4px; 1126 + transition: all 0.2s; 1127 + flex-shrink: 0; 1128 + } 1129 + 1130 + .drag-handle:hover { 1131 + color: var(--text-secondary); 1132 + background: var(--bg-tertiary); 1133 + } 1134 + 1135 + .drag-handle:active { 1136 + cursor: grabbing; 1137 + color: var(--accent); 1138 + } 1139 + 1140 + @media (pointer: coarse) { 1141 + .drag-handle { 1142 + color: var(--text-tertiary); 1143 + } 1144 + } 1145 + 1146 + .track-content { 1147 + flex: 1; 1148 + min-width: 0; 1149 + } 1150 + 1151 + /* empty state */ 1152 + .empty-state { 1153 + display: flex; 1154 + flex-direction: column; 1155 + align-items: center; 1156 + justify-content: center; 1157 + padding: 4rem 2rem; 1158 + text-align: center; 1159 + } 1160 + 1161 + .empty-icon { 1162 + width: 64px; 1163 + height: 64px; 1164 + border-radius: 16px; 1165 + display: flex; 1166 + align-items: center; 1167 + justify-content: center; 1168 + background: var(--bg-secondary); 1169 + color: var(--text-muted); 1170 + margin-bottom: 1rem; 1171 + } 1172 + 1173 + .empty-state p { 1174 + font-size: 1rem; 1175 + font-weight: 500; 1176 + color: var(--text-secondary); 1177 + margin: 0 0 0.25rem 0; 1178 + } 1179 + 1180 + .empty-state span { 1181 + font-size: 0.85rem; 1182 + color: var(--text-muted); 1183 + margin-bottom: 1.5rem; 1184 + } 1185 + 1186 + .empty-add-btn { 1187 + padding: 0.625rem 1.25rem; 1188 + background: var(--accent); 1189 + color: white; 1190 + border: none; 1191 + border-radius: 8px; 1192 + font-family: inherit; 1193 + font-size: 0.9rem; 1194 + font-weight: 500; 1195 + cursor: pointer; 1196 + transition: all 0.15s; 1197 + } 1198 + 1199 + .empty-add-btn:hover { 1200 + opacity: 0.9; 1201 + } 1202 + 1203 + /* modal */ 1204 + .modal-overlay { 1205 + position: fixed; 1206 + top: 0; 1207 + left: 0; 1208 + right: 0; 1209 + bottom: 0; 1210 + background: rgba(0, 0, 0, 0.5); 1211 + display: flex; 1212 + align-items: center; 1213 + justify-content: center; 1214 + z-index: 1000; 1215 + padding: 1rem; 1216 + } 1217 + 1218 + .modal { 1219 + background: var(--bg-primary); 1220 + border: 1px solid var(--border-default); 1221 + border-radius: 16px; 1222 + width: 100%; 1223 + max-width: 400px; 1224 + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); 1225 + } 1226 + 1227 + .search-modal { 1228 + max-width: 500px; 1229 + max-height: 80vh; 1230 + display: flex; 1231 + flex-direction: column; 1232 + } 1233 + 1234 + .modal-header { 1235 + display: flex; 1236 + align-items: center; 1237 + justify-content: space-between; 1238 + padding: 1.25rem 1.5rem; 1239 + border-bottom: 1px solid var(--border-default); 1240 + } 1241 + 1242 + .modal-header h3 { 1243 + font-size: 1.1rem; 1244 + font-weight: 600; 1245 + color: var(--text-primary); 1246 + margin: 0; 1247 + } 1248 + 1249 + .close-btn { 1250 + display: flex; 1251 + align-items: center; 1252 + justify-content: center; 1253 + width: 32px; 1254 + height: 32px; 1255 + background: transparent; 1256 + border: none; 1257 + border-radius: 8px; 1258 + color: var(--text-secondary); 1259 + cursor: pointer; 1260 + transition: all 0.15s; 1261 + } 1262 + 1263 + .close-btn:hover { 1264 + background: var(--bg-hover); 1265 + color: var(--text-primary); 1266 + } 1267 + 1268 + .search-input-wrapper { 1269 + display: flex; 1270 + align-items: center; 1271 + gap: 0.75rem; 1272 + padding: 0.875rem 1.5rem; 1273 + border-bottom: 1px solid var(--border-default); 1274 + color: var(--text-muted); 1275 + } 1276 + 1277 + .search-input-wrapper input { 1278 + flex: 1; 1279 + background: transparent; 1280 + border: none; 1281 + font-family: inherit; 1282 + font-size: 1rem; 1283 + color: var(--text-primary); 1284 + outline: none; 1285 + } 1286 + 1287 + .search-input-wrapper input::placeholder { 1288 + color: var(--text-muted); 1289 + } 1290 + 1291 + .search-results { 1292 + flex: 1; 1293 + overflow-y: auto; 1294 + padding: 0.5rem 0; 1295 + max-height: 400px; 1296 + } 1297 + 1298 + .search-results .error, 1299 + .search-results .no-results { 1300 + padding: 2rem 1.5rem; 1301 + text-align: center; 1302 + color: var(--text-muted); 1303 + font-size: 0.9rem; 1304 + margin: 0; 1305 + } 1306 + 1307 + .search-results .error { 1308 + color: #ef4444; 1309 + } 1310 + 1311 + .search-result-item { 1312 + display: flex; 1313 + align-items: center; 1314 + gap: 0.75rem; 1315 + padding: 0.625rem 1.5rem; 1316 + transition: background 0.15s; 1317 + } 1318 + 1319 + .search-result-item:hover { 1320 + background: var(--bg-hover); 1321 + } 1322 + 1323 + .result-image, 1324 + .result-image-placeholder { 1325 + width: 40px; 1326 + height: 40px; 1327 + border-radius: 6px; 1328 + flex-shrink: 0; 1329 + } 1330 + 1331 + .result-image { 1332 + object-fit: cover; 1333 + } 1334 + 1335 + .result-image-placeholder { 1336 + background: var(--bg-tertiary); 1337 + display: flex; 1338 + align-items: center; 1339 + justify-content: center; 1340 + color: var(--text-muted); 1341 + } 1342 + 1343 + .result-info { 1344 + flex: 1; 1345 + min-width: 0; 1346 + display: flex; 1347 + flex-direction: column; 1348 + gap: 0.1rem; 1349 + } 1350 + 1351 + .result-title { 1352 + font-size: 0.9rem; 1353 + font-weight: 500; 1354 + color: var(--text-primary); 1355 + white-space: nowrap; 1356 + overflow: hidden; 1357 + text-overflow: ellipsis; 1358 + } 1359 + 1360 + .result-artist { 1361 + font-size: 0.8rem; 1362 + color: var(--text-tertiary); 1363 + white-space: nowrap; 1364 + overflow: hidden; 1365 + text-overflow: ellipsis; 1366 + } 1367 + 1368 + .add-result-btn { 1369 + display: flex; 1370 + align-items: center; 1371 + justify-content: center; 1372 + width: 36px; 1373 + height: 36px; 1374 + background: var(--accent); 1375 + border: none; 1376 + border-radius: 8px; 1377 + color: white; 1378 + cursor: pointer; 1379 + transition: all 0.15s; 1380 + flex-shrink: 0; 1381 + } 1382 + 1383 + .add-result-btn:hover:not(:disabled) { 1384 + opacity: 0.9; 1385 + } 1386 + 1387 + .add-result-btn:disabled { 1388 + opacity: 0.5; 1389 + cursor: not-allowed; 1390 + } 1391 + 1392 + .modal-body { 1393 + padding: 1.5rem; 1394 + } 1395 + 1396 + .modal-body p { 1397 + margin: 0; 1398 + color: var(--text-secondary); 1399 + font-size: 0.95rem; 1400 + line-height: 1.5; 1401 + } 1402 + 1403 + .modal-footer { 1404 + display: flex; 1405 + justify-content: flex-end; 1406 + gap: 0.75rem; 1407 + padding: 1rem 1.5rem 1.25rem; 1408 + } 1409 + 1410 + .cancel-btn, 1411 + .confirm-btn { 1412 + padding: 0.625rem 1.25rem; 1413 + border-radius: 8px; 1414 + font-family: inherit; 1415 + font-size: 0.9rem; 1416 + font-weight: 500; 1417 + cursor: pointer; 1418 + transition: all 0.15s; 1419 + } 1420 + 1421 + .cancel-btn { 1422 + background: var(--bg-secondary); 1423 + border: 1px solid var(--border-default); 1424 + color: var(--text-secondary); 1425 + } 1426 + 1427 + .cancel-btn:hover:not(:disabled) { 1428 + background: var(--bg-hover); 1429 + color: var(--text-primary); 1430 + } 1431 + 1432 + .confirm-btn { 1433 + background: var(--accent); 1434 + border: 1px solid var(--accent); 1435 + color: white; 1436 + } 1437 + 1438 + .confirm-btn.danger { 1439 + background: #ef4444; 1440 + border-color: #ef4444; 1441 + } 1442 + 1443 + .confirm-btn:hover:not(:disabled) { 1444 + opacity: 0.9; 1445 + } 1446 + 1447 + .confirm-btn:disabled, 1448 + .cancel-btn:disabled { 1449 + opacity: 0.5; 1450 + cursor: not-allowed; 1451 + } 1452 + 1453 + .spinner { 1454 + width: 16px; 1455 + height: 16px; 1456 + border: 2px solid currentColor; 1457 + border-top-color: transparent; 1458 + border-radius: 50%; 1459 + animation: spin 0.6s linear infinite; 1460 + } 1461 + 1462 + @keyframes spin { 1463 + to { 1464 + transform: rotate(360deg); 1465 + } 1466 + } 1467 + 1468 + /* edit modal */ 1469 + .edit-modal { 1470 + max-width: 400px; 1471 + } 1472 + 1473 + .edit-cover-section { 1474 + display: flex; 1475 + flex-direction: column; 1476 + align-items: center; 1477 + gap: 0.5rem; 1478 + margin-bottom: 1.5rem; 1479 + } 1480 + 1481 + .cover-picker { 1482 + width: 120px; 1483 + height: 120px; 1484 + border-radius: 12px; 1485 + overflow: hidden; 1486 + cursor: pointer; 1487 + border: 2px dashed var(--border-default); 1488 + transition: border-color 0.15s; 1489 + } 1490 + 1491 + .cover-picker:hover { 1492 + border-color: var(--accent); 1493 + } 1494 + 1495 + .cover-preview { 1496 + width: 100%; 1497 + height: 100%; 1498 + object-fit: cover; 1499 + } 1500 + 1501 + .cover-placeholder { 1502 + width: 100%; 1503 + height: 100%; 1504 + display: flex; 1505 + flex-direction: column; 1506 + align-items: center; 1507 + justify-content: center; 1508 + gap: 0.5rem; 1509 + background: var(--bg-secondary); 1510 + color: var(--text-muted); 1511 + } 1512 + 1513 + .cover-placeholder span { 1514 + font-size: 0.8rem; 1515 + } 1516 + 1517 + .cover-hint { 1518 + font-size: 0.75rem; 1519 + color: var(--text-muted); 1520 + } 1521 + 1522 + .edit-name-section { 1523 + display: flex; 1524 + flex-direction: column; 1525 + gap: 0.5rem; 1526 + } 1527 + 1528 + .edit-name-section label { 1529 + font-size: 0.85rem; 1530 + color: var(--text-secondary); 1531 + } 1532 + 1533 + .edit-name-section input { 1534 + width: 100%; 1535 + padding: 0.75rem 1rem; 1536 + background: var(--bg-secondary); 1537 + border: 1px solid var(--border-default); 1538 + border-radius: 8px; 1539 + font-family: inherit; 1540 + font-size: 1rem; 1541 + color: var(--text-primary); 1542 + outline: none; 1543 + transition: border-color 0.15s; 1544 + box-sizing: border-box; 1545 + } 1546 + 1547 + .edit-name-section input:focus { 1548 + border-color: var(--accent); 1549 + } 1550 + 1551 + .edit-name-section input::placeholder { 1552 + color: var(--text-muted); 1553 + } 1554 + 1555 + .edit-toggle-section { 1556 + display: flex; 1557 + flex-direction: column; 1558 + gap: 0.5rem; 1559 + margin-top: 0.5rem; 1560 + } 1561 + 1562 + .toggle-row { 1563 + display: flex; 1564 + align-items: center; 1565 + gap: 0.75rem; 1566 + cursor: pointer; 1567 + } 1568 + 1569 + .toggle-row input[type="checkbox"] { 1570 + width: 18px; 1571 + height: 18px; 1572 + accent-color: var(--accent); 1573 + cursor: pointer; 1574 + } 1575 + 1576 + .toggle-label { 1577 + font-size: 0.95rem; 1578 + color: var(--text-primary); 1579 + } 1580 + 1581 + .toggle-hint { 1582 + font-size: 0.75rem; 1583 + color: var(--text-muted); 1584 + padding-left: calc(18px + 0.75rem); 1585 + } 1586 + 1587 + @media (max-width: 768px) { 1588 + .playlist-hero { 1589 + flex-direction: column; 1590 + align-items: flex-start; 1591 + gap: 1.5rem; 1592 + } 1593 + 1594 + .playlist-art, 1595 + .playlist-art-placeholder { 1596 + width: 160px; 1597 + height: 160px; 1598 + } 1599 + 1600 + .playlist-info-wrapper { 1601 + flex-direction: column; 1602 + align-items: flex-start; 1603 + width: 100%; 1604 + } 1605 + 1606 + .side-buttons { 1607 + display: none; 1608 + } 1609 + 1610 + .mobile-share-button { 1611 + display: flex; 1612 + width: 100%; 1613 + justify-content: center; 1614 + } 1615 + 1616 + .playlist-title { 1617 + font-size: 2rem; 1618 + } 1619 + 1620 + .playlist-meta { 1621 + font-size: 0.85rem; 1622 + } 1623 + 1624 + .playlist-actions { 1625 + flex-direction: column; 1626 + gap: 0.75rem; 1627 + width: 100%; 1628 + } 1629 + 1630 + .play-button, 1631 + .queue-button, 1632 + .add-tracks-button, 1633 + .reorder-button { 1634 + width: 100%; 1635 + justify-content: center; 1636 + } 1637 + } 1638 + 1639 + @media (max-width: 480px) { 1640 + .container { 1641 + padding: 0 0.75rem 6rem 0.75rem; 1642 + } 1643 + 1644 + .playlist-art, 1645 + .playlist-art-placeholder { 1646 + width: 140px; 1647 + height: 140px; 1648 + } 1649 + 1650 + .playlist-title { 1651 + font-size: 1.75rem; 1652 + } 1653 + 1654 + .playlist-meta { 1655 + font-size: 0.8rem; 1656 + flex-wrap: wrap; 1657 + } 1658 + } 1659 + 1660 + </style>
+57
frontend/src/routes/playlist/[id]/+page.ts
··· 1 + import { browser } from '$app/environment'; 2 + import { redirect, error } from '@sveltejs/kit'; 3 + import { API_URL } from '$lib/config'; 4 + import type { LoadEvent } from '@sveltejs/kit'; 5 + import type { PlaylistWithTracks, Playlist } from '$lib/types'; 6 + 7 + export interface PageData { 8 + playlist: PlaylistWithTracks; 9 + playlistMeta: Playlist | null; 10 + } 11 + 12 + export async function load({ params, parent, data }: LoadEvent): Promise<PageData> { 13 + // server data for OG tags 14 + const serverData = data as { playlistMeta: Playlist | null } | undefined; 15 + 16 + if (!browser) { 17 + // during SSR, we don't have auth - just return meta for OG tags 18 + // playlist will be loaded client-side 19 + return { 20 + playlist: { 21 + id: params.id as string, 22 + name: serverData?.playlistMeta?.name ?? 'playlist', 23 + owner_did: serverData?.playlistMeta?.owner_did ?? '', 24 + owner_handle: serverData?.playlistMeta?.owner_handle ?? '', 25 + track_count: serverData?.playlistMeta?.track_count ?? 0, 26 + show_on_profile: serverData?.playlistMeta?.show_on_profile ?? false, 27 + atproto_record_uri: serverData?.playlistMeta?.atproto_record_uri ?? '', 28 + created_at: serverData?.playlistMeta?.created_at ?? '', 29 + tracks: [], 30 + }, 31 + playlistMeta: serverData?.playlistMeta ?? null, 32 + }; 33 + } 34 + 35 + // check auth from parent layout data 36 + const { isAuthenticated } = await parent(); 37 + if (!isAuthenticated) { 38 + throw redirect(302, '/'); 39 + } 40 + 41 + const response = await fetch(`${API_URL}/lists/playlists/${params.id}`, { 42 + credentials: 'include' 43 + }); 44 + 45 + if (!response.ok) { 46 + if (response.status === 404) { 47 + throw error(404, 'playlist not found'); 48 + } 49 + throw error(500, 'failed to load playlist'); 50 + } 51 + 52 + const playlist = await response.json(); 53 + return { 54 + playlist, 55 + playlistMeta: serverData?.playlistMeta ?? null, 56 + }; 57 + }
+188 -38
frontend/src/routes/portal/+page.svelte
··· 8 8 import MigrationBanner from '$lib/components/MigrationBanner.svelte'; 9 9 import BrokenTracks from '$lib/components/BrokenTracks.svelte'; 10 10 import TagInput from '$lib/components/TagInput.svelte'; 11 - import type { Track, FeaturedArtist, AlbumSummary } from '$lib/types'; 11 + import type { Track, FeaturedArtist, AlbumSummary, Playlist } from '$lib/types'; 12 + import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 12 13 import { API_URL } from '$lib/config'; 13 14 import { toast } from '$lib/toast.svelte'; 14 15 import { auth } from '$lib/auth.svelte'; ··· 33 34 let bio = $state(''); 34 35 let avatarUrl = $state(''); 35 36 let savingProfile = $state(false); 36 - let profileSuccess = $state(''); 37 - let profileError = $state(''); 38 37 39 38 // album management state 40 39 let albums = $state<AlbumSummary[]>([]); ··· 42 41 let editingAlbumId = $state<string | null>(null); 43 42 let editAlbumCoverFile = $state<File | null>(null); 44 43 44 + // playlist management state 45 + let playlists = $state<Playlist[]>([]); 46 + let loadingPlaylists = $state(false); 47 + 45 48 // export state 46 49 let exportingMedia = $state(false); 47 50 ··· 98 101 await loadMyTracks(); 99 102 await loadArtistProfile(); 100 103 await loadMyAlbums(); 104 + await loadMyPlaylists(); 101 105 } catch (_e) { 102 106 console.error('error loading portal data:', _e); 103 107 error = 'failed to load portal data'; ··· 155 159 } 156 160 } 157 161 162 + async function loadMyPlaylists() { 163 + loadingPlaylists = true; 164 + try { 165 + const response = await fetch(`${API_URL}/lists/playlists`, { 166 + credentials: 'include' 167 + }); 168 + if (response.ok) { 169 + playlists = await response.json(); 170 + } 171 + } catch (_e) { 172 + console.error('failed to load playlists:', _e); 173 + } finally { 174 + loadingPlaylists = false; 175 + } 176 + } 177 + 158 178 async function uploadAlbumCover(albumId: string) { 159 179 if (!editAlbumCoverFile) { 160 180 toast.error('no cover art selected'); ··· 199 219 async function saveProfile(e: SubmitEvent) { 200 220 e.preventDefault(); 201 221 savingProfile = true; 202 - profileError = ''; 203 - profileSuccess = ''; 204 222 205 223 try { 206 224 const response = await fetch(`${API_URL}/artists/me`, { ··· 217 235 }); 218 236 219 237 if (response.ok) { 220 - profileSuccess = 'profile updated successfully!'; 221 - setTimeout(() => { profileSuccess = ''; }, 3000); 238 + toast.success('profile updated'); 222 239 } else { 223 240 const errorData = await response.json(); 224 - profileError = errorData.detail || 'failed to update profile'; 241 + toast.error(errorData.detail || 'failed to update profile'); 225 242 } 226 243 } catch (e) { 227 - profileError = `network error: ${e instanceof Error ? e.message : 'unknown error'}`; 244 + toast.error(`network error: ${e instanceof Error ? e.message : 'unknown error'}`); 228 245 } finally { 229 246 savingProfile = false; 230 247 } ··· 483 500 <h2>profile settings</h2> 484 501 <a href="/u/{auth.user.handle}" class="view-profile-link">view public profile</a> 485 502 </div> 486 - 487 - {#if profileSuccess} 488 - <div class="message success">{profileSuccess}</div> 489 - {/if} 490 - 491 - {#if profileError} 492 - <div class="message error">{profileError}</div> 493 - {/if} 494 503 495 504 <form onsubmit={saveProfile}> 496 505 <div class="form-group"> ··· 852 861 onclick={() => startEditingAlbum(album.id)} 853 862 title="edit cover art" 854 863 > 855 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 856 - <path d="M12 20h9"></path> 857 - <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path> 864 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 865 + <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path> 866 + <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path> 858 867 </svg> 859 868 </button> 860 869 </div> ··· 865 874 {/if} 866 875 </section> 867 876 877 + <section class="playlists-section"> 878 + <div class="section-header"> 879 + <h2>your playlists</h2> 880 + <a href="/library" class="view-playlists-link">manage playlists →</a> 881 + </div> 882 + 883 + {#if loadingPlaylists} 884 + <div class="loading-container"> 885 + <WaveLoading size="lg" message="loading playlists..." /> 886 + </div> 887 + {:else if playlists.length === 0} 888 + <p class="empty">no playlists yet - <a href="/library?create=playlist">create a new playlist</a></p> 889 + {:else} 890 + <div class="playlists-grid"> 891 + {#each playlists as playlist} 892 + <a href="/playlist/{playlist.id}" class="playlist-card"> 893 + {#if playlist.image_url} 894 + <SensitiveImage src={playlist.image_url} compact tooltipPosition="above"> 895 + <img src={playlist.image_url} alt="{playlist.name} cover" class="playlist-cover" /> 896 + </SensitiveImage> 897 + {:else} 898 + <div class="playlist-cover-placeholder"> 899 + <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 900 + <line x1="8" y1="6" x2="21" y2="6"></line> 901 + <line x1="8" y1="12" x2="21" y2="12"></line> 902 + <line x1="8" y1="18" x2="21" y2="18"></line> 903 + <line x1="3" y1="6" x2="3.01" y2="6"></line> 904 + <line x1="3" y1="12" x2="3.01" y2="12"></line> 905 + <line x1="3" y1="18" x2="3.01" y2="18"></line> 906 + </svg> 907 + </div> 908 + {/if} 909 + <div class="playlist-info"> 910 + <h3 class="playlist-title">{playlist.name}</h3> 911 + <p class="playlist-stats">{playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'}</p> 912 + </div> 913 + </a> 914 + {/each} 915 + </div> 916 + {/if} 917 + </section> 918 + 868 919 <section class="data-section"> 869 920 <div class="section-header"> 870 921 <h2>your data</h2> ··· 1194 1245 margin-top: 0.35rem; 1195 1246 font-size: 0.75rem; 1196 1247 color: var(--text-muted); 1197 - } 1198 - 1199 - .message { 1200 - padding: 1rem; 1201 - border-radius: 4px; 1202 - margin-bottom: 1.5rem; 1203 - } 1204 - 1205 - .message.success { 1206 - background: color-mix(in srgb, var(--success) 10%, transparent); 1207 - border: 1px solid color-mix(in srgb, var(--success) 30%, transparent); 1208 - color: var(--success); 1209 - } 1210 - 1211 - .message.error { 1212 - background: color-mix(in srgb, var(--error) 10%, transparent); 1213 - border: 1px solid color-mix(in srgb, var(--error) 30%, transparent); 1214 - color: var(--error); 1215 1248 } 1216 1249 1217 1250 .avatar-preview { ··· 1806 1839 justify-content: flex-end; 1807 1840 } 1808 1841 1842 + /* playlists section */ 1843 + .playlists-section { 1844 + margin-top: 3rem; 1845 + } 1846 + 1847 + .playlists-section h2 { 1848 + font-size: var(--text-page-heading); 1849 + margin-bottom: 1.5rem; 1850 + } 1851 + 1852 + .view-playlists-link { 1853 + color: var(--text-secondary); 1854 + text-decoration: none; 1855 + font-size: 0.8rem; 1856 + padding: 0.35rem 0.6rem; 1857 + background: var(--bg-tertiary); 1858 + border-radius: 5px; 1859 + border: 1px solid var(--border-default); 1860 + transition: all 0.15s; 1861 + white-space: nowrap; 1862 + } 1863 + 1864 + .view-playlists-link:hover { 1865 + border-color: var(--accent); 1866 + color: var(--accent); 1867 + background: var(--bg-hover); 1868 + } 1869 + 1870 + .playlists-grid { 1871 + display: grid; 1872 + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); 1873 + gap: 1.5rem; 1874 + } 1875 + 1876 + .playlist-card { 1877 + background: var(--bg-tertiary); 1878 + border: 1px solid var(--border-subtle); 1879 + border-radius: 8px; 1880 + padding: 1rem; 1881 + transition: all 0.2s; 1882 + display: flex; 1883 + flex-direction: column; 1884 + gap: 0.75rem; 1885 + text-decoration: none; 1886 + color: inherit; 1887 + } 1888 + 1889 + .playlist-card:hover { 1890 + border-color: var(--accent); 1891 + transform: translateY(-2px); 1892 + } 1893 + 1894 + .playlist-cover { 1895 + width: 100%; 1896 + aspect-ratio: 1; 1897 + border-radius: 6px; 1898 + object-fit: cover; 1899 + } 1900 + 1901 + .playlist-cover-placeholder { 1902 + width: 100%; 1903 + aspect-ratio: 1; 1904 + border-radius: 6px; 1905 + background: linear-gradient(135deg, rgba(var(--accent-rgb, 139, 92, 246), 0.15), rgba(var(--accent-rgb, 139, 92, 246), 0.05)); 1906 + display: flex; 1907 + align-items: center; 1908 + justify-content: center; 1909 + color: var(--accent); 1910 + } 1911 + 1912 + .playlist-info { 1913 + min-width: 0; 1914 + flex: 1; 1915 + } 1916 + 1917 + .playlist-title { 1918 + font-size: 1rem; 1919 + font-weight: 600; 1920 + color: var(--text-primary); 1921 + margin: 0 0 0.25rem 0; 1922 + overflow: hidden; 1923 + text-overflow: ellipsis; 1924 + white-space: nowrap; 1925 + } 1926 + 1927 + .playlist-stats { 1928 + font-size: 0.85rem; 1929 + color: var(--text-tertiary); 1930 + margin: 0; 1931 + } 1932 + 1809 1933 /* your data section */ 1810 1934 .data-section { 1811 1935 margin-top: 2.5rem; ··· 2065 2189 .profile-section h2, 2066 2190 .tracks-section h2, 2067 2191 .albums-section h2, 2192 + .playlists-section h2, 2068 2193 .data-section h2 { 2069 2194 font-size: 1.1rem; 2070 2195 } ··· 2143 2268 /* tracks mobile */ 2144 2269 .tracks-section, 2145 2270 .albums-section, 2271 + .playlists-section, 2146 2272 .data-section { 2147 2273 margin-top: 2rem; 2148 2274 } ··· 2250 2376 2251 2377 .album-title { 2252 2378 font-size: 0.85rem; 2379 + } 2380 + 2381 + /* playlists mobile */ 2382 + .playlists-grid { 2383 + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); 2384 + gap: 0.75rem; 2385 + } 2386 + 2387 + .playlist-card { 2388 + padding: 0.75rem; 2389 + gap: 0.5rem; 2390 + } 2391 + 2392 + .playlist-title { 2393 + font-size: 0.85rem; 2394 + } 2395 + 2396 + .playlist-stats { 2397 + font-size: 0.75rem; 2398 + } 2399 + 2400 + .view-playlists-link { 2401 + font-size: 0.75rem; 2402 + padding: 0.3rem 0.5rem; 2253 2403 } 2254 2404 } 2255 2405 </style>
+256 -15
frontend/src/routes/settings/+page.svelte
··· 16 16 let enableTealScrobbling = $derived(preferences.enableTealScrobbling); 17 17 let tealNeedsReauth = $derived(preferences.tealNeedsReauth); 18 18 let showSensitiveArtwork = $derived(preferences.showSensitiveArtwork); 19 + let showLikedOnProfile = $derived(preferences.showLikedOnProfile); 19 20 let currentTheme = $derived(preferences.theme); 20 21 let currentColor = $derived(preferences.accentColor ?? '#6a9fff'); 21 22 let autoAdvance = $derived(preferences.autoAdvance); ··· 23 24 // developer token state 24 25 let creatingToken = $state(false); 25 26 let developerToken = $state<string | null>(null); 27 + let showTokenOverlay = $state(false); // full-page overlay for new tokens 26 28 let tokenExpiresDays = $state(90); 27 29 let tokenName = $state(''); 28 30 let tokenCopied = $state(false); ··· 46 48 ]; 47 49 48 50 onMount(async () => { 49 - // check if exchange_token is in URL (from OAuth callback for dev token) 51 + // check if exchange_token is in URL (from OAuth callback) 50 52 const params = new URLSearchParams(window.location.search); 51 53 const exchangeToken = params.get('exchange_token'); 52 54 const isDevToken = params.get('dev_token') === 'true'; 55 + const isScopeUpgrade = params.get('scope_upgraded') === 'true'; 53 56 54 - if (exchangeToken && isDevToken) { 57 + if (exchangeToken) { 55 58 try { 56 59 const exchangeResponse = await fetch(`${API_URL}/auth/exchange`, { 57 60 method: 'POST', ··· 62 65 63 66 if (exchangeResponse.ok) { 64 67 const data = await exchangeResponse.json(); 65 - developerToken = data.session_id; 66 - toast.success('developer token created - save it now!'); 68 + 69 + if (isDevToken) { 70 + developerToken = data.session_id; 71 + showTokenOverlay = true; // show full-page overlay immediately 72 + } else if (isScopeUpgrade) { 73 + // reload auth state with new session 74 + await auth.initialize(); 75 + await preferences.fetch(); 76 + toast.success('teal.fm scrobbling connected!'); 77 + } 67 78 } 68 79 } catch (_e) { 69 80 console.error('failed to exchange token:', _e); ··· 152 163 } 153 164 } 154 165 166 + // teal scrobbling toggle state 167 + let enablingTeal = $state(false); 168 + 155 169 async function saveTealScrobbling(enabled: boolean) { 170 + if (enabled) { 171 + // enabling teal - start scope upgrade flow 172 + enablingTeal = true; 173 + try { 174 + const response = await fetch(`${API_URL}/auth/scope-upgrade/start`, { 175 + method: 'POST', 176 + headers: { 'Content-Type': 'application/json' }, 177 + credentials: 'include', 178 + body: JSON.stringify({ include_teal: true }) 179 + }); 180 + 181 + if (!response.ok) { 182 + const error = await response.json(); 183 + toast.error(error.detail || 'failed to start teal connection'); 184 + enablingTeal = false; 185 + return; 186 + } 187 + 188 + // update the preference first 189 + await preferences.update({ enable_teal_scrobbling: true }); 190 + 191 + // redirect to OAuth 192 + const result = await response.json(); 193 + window.location.href = result.auth_url; 194 + } catch (_e) { 195 + console.error('failed to enable teal scrobbling:', _e); 196 + toast.error('failed to connect teal.fm'); 197 + enablingTeal = false; 198 + } 199 + } else { 200 + // disabling teal - just update preference 201 + try { 202 + await preferences.update({ enable_teal_scrobbling: false }); 203 + toast.success('teal.fm scrobbling disabled'); 204 + } catch (_e) { 205 + console.error('failed to save preference:', _e); 206 + toast.error('failed to update preference'); 207 + } 208 + } 209 + } 210 + 211 + async function saveShowSensitiveArtwork(enabled: boolean) { 156 212 try { 157 - await preferences.update({ enable_teal_scrobbling: enabled }); 158 - await preferences.fetch(); 159 - toast.success(enabled ? 'teal.fm scrobbling enabled' : 'teal.fm scrobbling disabled'); 213 + await preferences.update({ show_sensitive_artwork: enabled }); 214 + toast.success(enabled ? 'sensitive artwork shown' : 'sensitive artwork hidden'); 160 215 } catch (_e) { 161 216 console.error('failed to save preference:', _e); 162 217 toast.error('failed to update preference'); 163 218 } 164 219 } 165 220 166 - async function saveShowSensitiveArtwork(enabled: boolean) { 221 + async function saveShowLikedOnProfile(enabled: boolean) { 167 222 try { 168 - await preferences.update({ show_sensitive_artwork: enabled }); 169 - toast.success(enabled ? 'sensitive artwork shown' : 'sensitive artwork hidden'); 223 + await preferences.update({ show_liked_on_profile: enabled }); 224 + toast.success(enabled ? 'liked tracks shown on profile' : 'liked tracks hidden from profile'); 170 225 } catch (_e) { 171 226 console.error('failed to save preference:', _e); 172 227 toast.error('failed to update preference'); ··· 246 301 } 247 302 } 248 303 304 + function dismissTokenOverlay() { 305 + showTokenOverlay = false; 306 + // also clear the token after dismissing since they won't see it again 307 + developerToken = null; 308 + // reload tokens to show the new one in the list 309 + loadDeveloperTokens(); 310 + } 311 + 249 312 async function logout() { 250 313 await auth.logout(); 251 314 window.location.href = '/'; 252 315 } 253 316 </script> 254 317 318 + {#if showTokenOverlay && developerToken} 319 + <div class="token-overlay"> 320 + <div class="token-overlay-content"> 321 + <div class="token-overlay-icon"> 322 + <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 323 + <path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /> 324 + </svg> 325 + </div> 326 + <h2>your developer token</h2> 327 + <p class="token-overlay-warning"> 328 + copy this token now - you won't be able to see it again after closing this dialog 329 + </p> 330 + <div class="token-overlay-display"> 331 + <code>{developerToken}</code> 332 + <button class="token-overlay-copy" onclick={copyToken}> 333 + {tokenCopied ? 'copied!' : 'copy'} 334 + </button> 335 + </div> 336 + <p class="token-overlay-hint"> 337 + use this token with the <a href="https://github.com/zzstoatzz/plyr-python-client" target="_blank" rel="noopener">python SDK</a> for programmatic API access 338 + </p> 339 + <button class="token-overlay-dismiss" onclick={dismissTokenOverlay}> 340 + i've saved my token 341 + </button> 342 + </div> 343 + </div> 344 + {/if} 345 + 255 346 {#if loading} 256 347 <div class="loading"> 257 348 <WaveLoading size="lg" message="loading..." /> ··· 377 468 <span class="toggle-slider"></span> 378 469 </label> 379 470 </div> 471 + 472 + <div class="setting-row"> 473 + <div class="setting-info"> 474 + <h3>show liked on profile</h3> 475 + <p>display your liked tracks on your artist page for others to see</p> 476 + </div> 477 + <label class="toggle-switch"> 478 + <input 479 + type="checkbox" 480 + checked={showLikedOnProfile} 481 + onchange={(e) => saveShowLikedOnProfile((e.target as HTMLInputElement).checked)} 482 + /> 483 + <span class="toggle-slider"></span> 484 + </label> 485 + </div> 380 486 </div> 381 487 </section> 382 488 ··· 394 500 <input 395 501 type="checkbox" 396 502 checked={enableTealScrobbling} 503 + disabled={enablingTeal} 397 504 onchange={(e) => saveTealScrobbling((e.target as HTMLInputElement).checked)} 398 505 /> 399 506 <span class="toggle-slider"></span> 400 507 </label> 401 508 </div> 402 - {#if tealNeedsReauth} 509 + {#if enablingTeal} 510 + <div class="reauth-notice connecting"> 511 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 512 + <circle cx="12" cy="12" r="10" /> 513 + <path d="M12 6v6l4 2" /> 514 + </svg> 515 + <span>connecting to teal.fm...</span> 516 + </div> 517 + {:else if tealNeedsReauth} 403 518 <div class="reauth-notice"> 404 519 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 405 520 <circle cx="12" cy="12" r="10" /> 406 521 <path d="M12 16v-4M12 8h.01" /> 407 522 </svg> 408 - <span>please log out and back in to connect teal.fm</span> 523 + <span>toggle on to connect teal.fm scrobbling</span> 409 524 </div> 410 525 {/if} 411 526 </div> ··· 455 570 </div> 456 571 {/if} 457 572 458 - {#if developerToken} 573 + {#if developerToken && !showTokenOverlay} 574 + <!-- inline display only shown if overlay was somehow bypassed --> 459 575 <div class="token-display"> 460 576 <code class="token-value">{developerToken}</code> 461 577 <button class="copy-btn" onclick={copyToken} title="copy token"> 462 578 {tokenCopied ? '✓' : 'copy'} 463 579 </button> 464 - <button class="dismiss-btn" onclick={() => developerToken = null} title="dismiss"> 580 + <button class="dismiss-btn" onclick={dismissTokenOverlay} title="dismiss"> 465 581 466 582 </button> 467 583 </div> 468 584 <p class="token-warning">save this token now - you won't be able to see it again</p> 469 - {:else} 585 + {:else if !developerToken} 470 586 <div class="token-form"> 471 587 <input 472 588 type="text" ··· 500 616 {/if} 501 617 502 618 <style> 619 + /* token overlay - full page modal for newly created tokens */ 620 + .token-overlay { 621 + position: fixed; 622 + inset: 0; 623 + background: rgba(0, 0, 0, 0.85); 624 + backdrop-filter: blur(8px); 625 + display: flex; 626 + align-items: center; 627 + justify-content: center; 628 + z-index: 9999; 629 + padding: 1rem; 630 + } 631 + 632 + .token-overlay-content { 633 + background: var(--bg-secondary); 634 + border: 1px solid var(--border-default); 635 + border-radius: 16px; 636 + padding: 2rem; 637 + max-width: 500px; 638 + width: 100%; 639 + text-align: center; 640 + } 641 + 642 + .token-overlay-icon { 643 + color: var(--accent); 644 + margin-bottom: 1rem; 645 + } 646 + 647 + .token-overlay-content h2 { 648 + margin: 0 0 0.75rem; 649 + font-size: 1.5rem; 650 + color: var(--text-primary); 651 + } 652 + 653 + .token-overlay-warning { 654 + color: var(--warning); 655 + font-size: 0.9rem; 656 + margin: 0 0 1.5rem; 657 + line-height: 1.5; 658 + } 659 + 660 + .token-overlay-display { 661 + display: flex; 662 + gap: 0.5rem; 663 + background: var(--bg-primary); 664 + border: 1px solid var(--border-default); 665 + border-radius: 8px; 666 + padding: 1rem; 667 + margin-bottom: 1rem; 668 + } 669 + 670 + .token-overlay-display code { 671 + flex: 1; 672 + font-size: 0.85rem; 673 + word-break: break-all; 674 + color: var(--accent); 675 + text-align: left; 676 + font-family: monospace; 677 + } 678 + 679 + .token-overlay-copy { 680 + padding: 0.5rem 1rem; 681 + background: var(--accent); 682 + border: none; 683 + border-radius: 6px; 684 + color: var(--text-primary); 685 + font-family: inherit; 686 + font-size: 0.85rem; 687 + font-weight: 600; 688 + cursor: pointer; 689 + white-space: nowrap; 690 + transition: background 0.15s; 691 + } 692 + 693 + .token-overlay-copy:hover { 694 + background: var(--accent-hover); 695 + } 696 + 697 + .token-overlay-hint { 698 + font-size: 0.8rem; 699 + color: var(--text-tertiary); 700 + margin: 0 0 1.5rem; 701 + } 702 + 703 + .token-overlay-hint a { 704 + color: var(--accent); 705 + text-decoration: none; 706 + } 707 + 708 + .token-overlay-hint a:hover { 709 + text-decoration: underline; 710 + } 711 + 712 + .token-overlay-dismiss { 713 + padding: 0.75rem 2rem; 714 + background: var(--bg-tertiary); 715 + border: 1px solid var(--border-default); 716 + border-radius: 8px; 717 + color: var(--text-secondary); 718 + font-family: inherit; 719 + font-size: 0.9rem; 720 + cursor: pointer; 721 + transition: all 0.15s; 722 + } 723 + 724 + .token-overlay-dismiss:hover { 725 + border-color: var(--accent); 726 + color: var(--accent); 727 + } 728 + 503 729 .loading { 504 730 display: flex; 505 731 flex-direction: column; ··· 761 987 margin-top: 0.75rem; 762 988 font-size: 0.8rem; 763 989 color: var(--warning); 990 + } 991 + 992 + .reauth-notice.connecting { 993 + background: color-mix(in srgb, var(--accent) 10%, transparent); 994 + border-color: color-mix(in srgb, var(--accent) 30%, transparent); 995 + color: var(--accent); 996 + } 997 + 998 + .reauth-notice.connecting svg { 999 + animation: spin 1s linear infinite; 1000 + } 1001 + 1002 + @keyframes spin { 1003 + from { transform: rotate(0deg); } 1004 + to { transform: rotate(360deg); } 764 1005 } 765 1006 766 1007 /* developer tokens */
+191 -2
frontend/src/routes/u/[handle]/+page.svelte
··· 3 3 import { fade } from 'svelte/transition'; 4 4 import { API_URL } from '$lib/config'; 5 5 import { browser } from '$app/environment'; 6 - import type { Analytics, Track } from '$lib/types'; 6 + import type { Analytics, Track, Playlist } from '$lib/types'; 7 7 import TrackItem from '$lib/components/TrackItem.svelte'; 8 8 import ShareButton from '$lib/components/ShareButton.svelte'; 9 9 import Header from '$lib/components/Header.svelte'; ··· 12 12 import { player } from '$lib/player.svelte'; 13 13 import { queue } from '$lib/queue.svelte'; 14 14 import { auth } from '$lib/auth.svelte'; 15 - import { fetchLikedTracks } from '$lib/tracks.svelte'; 15 + import { fetchLikedTracks, fetchUserLikes } from '$lib/tracks.svelte'; 16 16 import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding'; 17 17 import type { PageData } from './$types'; 18 18 ··· 49 49 let tracksHydrated = $state(false); 50 50 let tracksLoading = $state(false); 51 51 52 + // liked tracks count (shown if artist has show_liked_on_profile enabled) 53 + let likedTracksCount = $state<number | null>(null); 54 + 55 + // public playlists for collections section 56 + let publicPlaylists = $state<Playlist[]>([]); 52 57 53 58 async function handleLogout() { 54 59 await auth.logout(); ··· 84 89 } 85 90 } 86 91 92 + async function loadLikedTracksCount() { 93 + if (!artist?.handle || !artist.show_liked_on_profile || likedTracksCount !== null) return; 94 + 95 + try { 96 + const response = await fetchUserLikes(artist.handle); 97 + if (response) { 98 + likedTracksCount = response.tracks.length; 99 + } 100 + } catch (_e) { 101 + console.error('failed to load liked tracks count:', _e); 102 + } 103 + } 104 + 105 + async function loadPublicPlaylists() { 106 + if (!artist?.did) return; 107 + 108 + try { 109 + const response = await fetch(`${API_URL}/lists/playlists/by-artist/${artist.did}`); 110 + if (response.ok) { 111 + publicPlaylists = await response.json(); 112 + } 113 + } catch (_e) { 114 + console.error('failed to load public playlists:', _e); 115 + } 116 + } 117 + 87 118 onMount(() => { 88 119 // load analytics in background without blocking page render 89 120 loadAnalytics(); 90 121 primeLikesFromCache(); 91 122 // immediately hydrate tracks client-side for liked state 92 123 void hydrateTracksWithLikes(); 124 + // load liked tracks count if artist has show_liked_on_profile enabled 125 + void loadLikedTracksCount(); 126 + // load public playlists for collections section 127 + void loadPublicPlaylists(); 93 128 }); 94 129 95 130 async function hydrateTracksWithLikes() { ··· 339 374 <span class="dot">•</span> 340 375 {album.total_plays.toLocaleString()} {album.total_plays === 1 ? 'play' : 'plays'} 341 376 </p> 377 + </div> 378 + </a> 379 + {/each} 380 + </div> 381 + </section> 382 + {/if} 383 + 384 + {#if artist.show_liked_on_profile || publicPlaylists.length > 0} 385 + <section class="collections-section"> 386 + <div class="section-header"> 387 + <h2>collections</h2> 388 + </div> 389 + <div class="collections-list"> 390 + {#if artist.show_liked_on_profile} 391 + <a href="/liked/{artist.handle}" class="collection-link"> 392 + <div class="collection-icon liked"> 393 + <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> 394 + <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/> 395 + </svg> 396 + </div> 397 + <div class="collection-info"> 398 + <h3>liked tracks</h3> 399 + {#if likedTracksCount !== null} 400 + <p>{likedTracksCount} {likedTracksCount === 1 ? 'track' : 'tracks'}</p> 401 + {:else} 402 + <p>view collection</p> 403 + {/if} 404 + </div> 405 + <div class="collection-arrow"> 406 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 407 + <path d="M9 18l6-6-6-6"/> 408 + </svg> 409 + </div> 410 + </a> 411 + {/if} 412 + {#each publicPlaylists as playlist} 413 + <a href="/playlist/{playlist.id}" class="collection-link"> 414 + <div class="collection-icon playlist"> 415 + {#if playlist.image_url} 416 + <img src={playlist.image_url} alt="{playlist.name} cover" /> 417 + {:else} 418 + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 419 + <path d="M9 18V5l12-2v13"/> 420 + <circle cx="6" cy="18" r="3"/> 421 + <circle cx="18" cy="16" r="3"/> 422 + </svg> 423 + {/if} 424 + </div> 425 + <div class="collection-info"> 426 + <h3>{playlist.name}</h3> 427 + <p>{playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'}</p> 428 + </div> 429 + <div class="collection-arrow"> 430 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 431 + <path d="M9 18l6-6-6-6"/> 432 + </svg> 342 433 </div> 343 434 </a> 344 435 {/each} ··· 777 868 .album-grid { 778 869 grid-template-columns: 1fr; 779 870 } 871 + } 872 + 873 + .collections-section { 874 + margin-top: 2rem; 875 + } 876 + 877 + .collections-section h2 { 878 + margin-bottom: 1.25rem; 879 + color: var(--text-primary); 880 + font-size: 1.8rem; 881 + } 882 + 883 + .collections-list { 884 + display: flex; 885 + flex-direction: column; 886 + gap: 0.75rem; 887 + } 888 + 889 + .collection-link { 890 + display: flex; 891 + align-items: center; 892 + gap: 1rem; 893 + padding: 1.25rem 1.5rem; 894 + background: var(--bg-secondary); 895 + border: 1px solid var(--border-subtle); 896 + border-radius: 10px; 897 + color: inherit; 898 + text-decoration: none; 899 + transition: transform 0.15s ease, border-color 0.15s ease; 900 + } 901 + 902 + .collection-link:hover { 903 + transform: translateY(-2px); 904 + border-color: var(--accent); 905 + } 906 + 907 + .collection-icon { 908 + width: 48px; 909 + height: 48px; 910 + border-radius: 8px; 911 + display: flex; 912 + align-items: center; 913 + justify-content: center; 914 + flex-shrink: 0; 915 + overflow: hidden; 916 + } 917 + 918 + .collection-icon.liked { 919 + background: color-mix(in srgb, var(--accent) 15%, transparent); 920 + color: var(--accent); 921 + } 922 + 923 + .collection-icon.playlist { 924 + background: var(--bg-tertiary); 925 + color: var(--text-secondary); 926 + } 927 + 928 + .collection-icon.playlist img { 929 + width: 100%; 930 + height: 100%; 931 + object-fit: cover; 932 + } 933 + 934 + .collection-info { 935 + flex: 1; 936 + min-width: 0; 937 + } 938 + 939 + .collection-info h3 { 940 + margin: 0 0 0.25rem 0; 941 + font-size: 1.1rem; 942 + color: var(--text-primary); 943 + } 944 + 945 + .collection-info p { 946 + margin: 0; 947 + font-size: 0.9rem; 948 + color: var(--text-tertiary); 949 + } 950 + 951 + .collection-arrow { 952 + color: var(--text-muted); 953 + transition: transform 0.15s ease, color 0.15s ease; 954 + } 955 + 956 + .collection-link:hover .collection-arrow { 957 + color: var(--accent); 958 + transform: translateX(4px); 959 + } 960 + 961 + .albums { 962 + margin-top: 2rem; 963 + } 964 + 965 + .albums h2 { 966 + margin-bottom: 1.5rem; 967 + color: var(--text-primary); 968 + font-size: 1.8rem; 780 969 } 781 970 </style>
+373 -18
frontend/src/routes/u/[handle]/album/[slug]/+page.svelte
··· 9 9 import { queue } from '$lib/queue.svelte'; 10 10 import { toast } from '$lib/toast.svelte'; 11 11 import { auth } from '$lib/auth.svelte'; 12 + import { API_URL } from '$lib/config'; 12 13 import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding'; 14 + import type { Track } from '$lib/types'; 13 15 import type { PageData } from './$types'; 14 16 15 17 let { data }: { data: PageData } = $props(); ··· 17 19 const album = $derived(data.album); 18 20 const isAuthenticated = $derived(auth.isAuthenticated); 19 21 22 + // check if current user owns this album 23 + const isOwner = $derived(auth.user?.did === album.metadata.artist_did); 24 + // can only reorder if owner and album has an ATProto list 25 + const canReorder = $derived(isOwner && !!album.metadata.list_uri); 26 + 27 + // local mutable copy of tracks for reordering 28 + let tracks = $state<Track[]>([...data.album.tracks]); 29 + 30 + // sync when data changes (e.g., navigation) 31 + $effect(() => { 32 + tracks = [...data.album.tracks]; 33 + }); 34 + 35 + // edit mode state 36 + let isEditMode = $state(false); 37 + let isSaving = $state(false); 38 + 39 + // drag state 40 + let draggedIndex = $state<number | null>(null); 41 + let dragOverIndex = $state<number | null>(null); 42 + 43 + // touch drag state 44 + let touchDragIndex = $state<number | null>(null); 45 + let touchStartY = $state(0); 46 + let touchDragElement = $state<HTMLElement | null>(null); 47 + let tracksListElement = $state<HTMLElement | null>(null); 48 + 20 49 // SSR-safe check for sensitive images (for og:image meta tags) 21 50 function isImageSensitiveSSR(url: string | null | undefined): boolean { 22 51 if (!url) return false; 23 52 return checkImageSensitive(url, data.sensitiveImages); 24 53 } 25 54 26 - function playTrack(track: typeof album.tracks[0]) { 55 + function playTrack(track: Track) { 27 56 queue.playNow(track); 28 57 } 29 58 30 59 function playNow() { 31 - if (album.tracks.length > 0) { 32 - queue.setQueue(album.tracks); 33 - queue.playNow(album.tracks[0]); 60 + if (tracks.length > 0) { 61 + queue.setQueue(tracks); 62 + queue.playNow(tracks[0]); 34 63 toast.success(`playing ${album.metadata.title}`, 1800); 35 64 } 36 65 } 37 66 38 67 function addToQueue() { 39 - if (album.tracks.length > 0) { 40 - queue.addTracks(album.tracks); 68 + if (tracks.length > 0) { 69 + queue.addTracks(tracks); 41 70 toast.success(`added ${album.metadata.title} to queue`, 1800); 42 71 } 43 72 } 44 73 74 + function toggleEditMode() { 75 + if (isEditMode) { 76 + saveOrder(); 77 + } 78 + isEditMode = !isEditMode; 79 + } 80 + 81 + async function saveOrder() { 82 + if (!album.metadata.list_uri) return; 83 + 84 + // extract rkey from list URI (at://did/collection/rkey) 85 + const rkey = album.metadata.list_uri.split('/').pop(); 86 + if (!rkey) return; 87 + 88 + // build strongRefs from current track order 89 + const items = tracks 90 + .filter((t) => t.atproto_record_uri && t.atproto_record_cid) 91 + .map((t) => ({ 92 + uri: t.atproto_record_uri!, 93 + cid: t.atproto_record_cid! 94 + })); 95 + 96 + if (items.length === 0) return; 97 + 98 + isSaving = true; 99 + try { 100 + const response = await fetch(`${API_URL}/lists/${rkey}/reorder`, { 101 + method: 'PUT', 102 + headers: { 'Content-Type': 'application/json' }, 103 + credentials: 'include', 104 + body: JSON.stringify({ items }) 105 + }); 106 + 107 + if (!response.ok) { 108 + const error = await response.json().catch(() => ({ detail: 'unknown error' })); 109 + throw new Error(error.detail || 'failed to save order'); 110 + } 111 + 112 + toast.success('order saved'); 113 + } catch (e) { 114 + toast.error(e instanceof Error ? e.message : 'failed to save order'); 115 + } finally { 116 + isSaving = false; 117 + } 118 + } 119 + 120 + // move track from one index to another 121 + function moveTrack(fromIndex: number, toIndex: number) { 122 + if (fromIndex === toIndex) return; 123 + const newTracks = [...tracks]; 124 + const [moved] = newTracks.splice(fromIndex, 1); 125 + newTracks.splice(toIndex, 0, moved); 126 + tracks = newTracks; 127 + } 128 + 129 + // desktop drag and drop 130 + function handleDragStart(event: DragEvent, index: number) { 131 + draggedIndex = index; 132 + if (event.dataTransfer) { 133 + event.dataTransfer.effectAllowed = 'move'; 134 + } 135 + } 136 + 137 + function handleDragOver(event: DragEvent, index: number) { 138 + event.preventDefault(); 139 + dragOverIndex = index; 140 + } 141 + 142 + function handleDrop(event: DragEvent, index: number) { 143 + event.preventDefault(); 144 + if (draggedIndex !== null && draggedIndex !== index) { 145 + moveTrack(draggedIndex, index); 146 + } 147 + draggedIndex = null; 148 + dragOverIndex = null; 149 + } 150 + 151 + function handleDragEnd() { 152 + draggedIndex = null; 153 + dragOverIndex = null; 154 + } 155 + 156 + // touch drag and drop 157 + function handleTouchStart(event: TouchEvent, index: number) { 158 + const touch = event.touches[0]; 159 + touchDragIndex = index; 160 + touchStartY = touch.clientY; 161 + touchDragElement = event.currentTarget as HTMLElement; 162 + touchDragElement.classList.add('touch-dragging'); 163 + } 164 + 165 + function handleTouchMove(event: TouchEvent) { 166 + if (touchDragIndex === null || !touchDragElement || !tracksListElement) return; 167 + 168 + event.preventDefault(); 169 + const touch = event.touches[0]; 170 + const offset = touch.clientY - touchStartY; 171 + touchDragElement.style.transform = `translateY(${offset}px)`; 172 + 173 + const trackElements = tracksListElement.querySelectorAll('.track-row'); 174 + for (let i = 0; i < trackElements.length; i++) { 175 + const trackEl = trackElements[i] as HTMLElement; 176 + const rect = trackEl.getBoundingClientRect(); 177 + const midY = rect.top + rect.height / 2; 178 + 179 + if (touch.clientY < midY && i > 0) { 180 + const targetIndex = parseInt(trackEl.dataset.index || '0'); 181 + if (targetIndex !== touchDragIndex) { 182 + dragOverIndex = targetIndex; 183 + } 184 + break; 185 + } else if (touch.clientY >= midY) { 186 + const targetIndex = parseInt(trackEl.dataset.index || '0'); 187 + if (targetIndex !== touchDragIndex) { 188 + dragOverIndex = targetIndex; 189 + } 190 + } 191 + } 192 + } 193 + 194 + function handleTouchEnd() { 195 + if (touchDragIndex !== null && dragOverIndex !== null && touchDragIndex !== dragOverIndex) { 196 + moveTrack(touchDragIndex, dragOverIndex); 197 + } 198 + 199 + if (touchDragElement) { 200 + touchDragElement.classList.remove('touch-dragging'); 201 + touchDragElement.style.transform = ''; 202 + } 203 + 204 + touchDragIndex = null; 205 + dragOverIndex = null; 206 + touchDragElement = null; 207 + } 208 + 45 209 let shareUrl = $state(''); 46 210 47 211 $effect(() => { ··· 133 297 </svg> 134 298 add to queue 135 299 </button> 300 + {#if canReorder} 301 + <button 302 + class="reorder-button" 303 + class:active={isEditMode} 304 + onclick={toggleEditMode} 305 + disabled={isSaving} 306 + title={isEditMode ? 'save order' : 'reorder tracks'} 307 + > 308 + {#if isEditMode} 309 + {#if isSaving} 310 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spinner"> 311 + <circle cx="12" cy="12" r="10" stroke-dasharray="31.4" stroke-dashoffset="10"></circle> 312 + </svg> 313 + saving... 314 + {:else} 315 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 316 + <polyline points="20 6 9 17 4 12"></polyline> 317 + </svg> 318 + done 319 + {/if} 320 + {:else} 321 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 322 + <line x1="3" y1="12" x2="21" y2="12"></line> 323 + <line x1="3" y1="6" x2="21" y2="6"></line> 324 + <line x1="3" y1="18" x2="21" y2="18"></line> 325 + </svg> 326 + reorder 327 + {/if} 328 + </button> 329 + {/if} 136 330 <div class="mobile-share-button"> 137 331 <ShareButton url={shareUrl} title="share album" /> 138 332 </div> ··· 140 334 141 335 <div class="tracks-section"> 142 336 <h2 class="section-heading">tracks</h2> 143 - <div class="tracks-list"> 144 - {#each album.tracks as track, i} 145 - <TrackItem 146 - {track} 147 - index={i} 148 - isPlaying={player.currentTrack?.id === track.id} 149 - onPlay={playTrack} 150 - {isAuthenticated} 151 - hideAlbum={true} 152 - hideArtist={true} 153 - /> 337 + <div 338 + class="tracks-list" 339 + class:edit-mode={isEditMode} 340 + bind:this={tracksListElement} 341 + ontouchmove={isEditMode ? handleTouchMove : undefined} 342 + ontouchend={isEditMode ? handleTouchEnd : undefined} 343 + ontouchcancel={isEditMode ? handleTouchEnd : undefined} 344 + > 345 + {#each tracks as track, i (track.id)} 346 + {#if isEditMode} 347 + <div 348 + class="track-row" 349 + class:drag-over={dragOverIndex === i && touchDragIndex !== i} 350 + class:is-dragging={touchDragIndex === i || draggedIndex === i} 351 + data-index={i} 352 + role="listitem" 353 + draggable="true" 354 + ondragstart={(e) => handleDragStart(e, i)} 355 + ondragover={(e) => handleDragOver(e, i)} 356 + ondrop={(e) => handleDrop(e, i)} 357 + ondragend={handleDragEnd} 358 + > 359 + <button 360 + class="drag-handle" 361 + ontouchstart={(e) => handleTouchStart(e, i)} 362 + onclick={(e) => e.stopPropagation()} 363 + aria-label="drag to reorder" 364 + title="drag to reorder" 365 + > 366 + <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> 367 + <circle cx="5" cy="3" r="1.5"></circle> 368 + <circle cx="11" cy="3" r="1.5"></circle> 369 + <circle cx="5" cy="8" r="1.5"></circle> 370 + <circle cx="11" cy="8" r="1.5"></circle> 371 + <circle cx="5" cy="13" r="1.5"></circle> 372 + <circle cx="11" cy="13" r="1.5"></circle> 373 + </svg> 374 + </button> 375 + <div class="track-content"> 376 + <TrackItem 377 + {track} 378 + index={i} 379 + showIndex={true} 380 + isPlaying={player.currentTrack?.id === track.id} 381 + onPlay={playTrack} 382 + {isAuthenticated} 383 + hideAlbum={true} 384 + hideArtist={true} 385 + /> 386 + </div> 387 + </div> 388 + {:else} 389 + <TrackItem 390 + {track} 391 + index={i} 392 + showIndex={true} 393 + isPlaying={player.currentTrack?.id === track.id} 394 + onPlay={playTrack} 395 + {isAuthenticated} 396 + hideAlbum={true} 397 + hideArtist={true} 398 + /> 399 + {/if} 154 400 {/each} 155 401 </div> 156 402 </div> ··· 307 553 color: var(--accent); 308 554 } 309 555 556 + .reorder-button { 557 + padding: 0.75rem 1.5rem; 558 + border-radius: 24px; 559 + font-weight: 600; 560 + font-size: 0.95rem; 561 + font-family: inherit; 562 + cursor: pointer; 563 + transition: all 0.2s; 564 + display: flex; 565 + align-items: center; 566 + gap: 0.5rem; 567 + background: transparent; 568 + color: var(--text-primary); 569 + border: 1px solid var(--border-default); 570 + } 571 + 572 + .reorder-button:hover { 573 + border-color: var(--accent); 574 + color: var(--accent); 575 + } 576 + 577 + .reorder-button:disabled { 578 + opacity: 0.6; 579 + cursor: not-allowed; 580 + } 581 + 582 + .reorder-button.active { 583 + border-color: var(--accent); 584 + color: var(--accent); 585 + background: color-mix(in srgb, var(--accent) 10%, transparent); 586 + } 587 + 588 + .spinner { 589 + animation: spin 1s linear infinite; 590 + } 591 + 592 + @keyframes spin { 593 + from { 594 + transform: rotate(0deg); 595 + } 596 + to { 597 + transform: rotate(360deg); 598 + } 599 + } 600 + 310 601 .tracks-section { 311 602 margin-top: 2rem; 312 603 padding-bottom: calc(var(--player-height, 120px) + env(safe-area-inset-bottom, 0px)); ··· 326 617 gap: 0.5rem; 327 618 } 328 619 620 + /* edit mode styles */ 621 + .track-row { 622 + display: flex; 623 + align-items: center; 624 + gap: 0.5rem; 625 + border-radius: 8px; 626 + transition: all 0.2s; 627 + position: relative; 628 + } 629 + 630 + .track-row.drag-over { 631 + background: color-mix(in srgb, var(--accent) 12%, transparent); 632 + outline: 2px dashed var(--accent); 633 + outline-offset: -2px; 634 + } 635 + 636 + .track-row.is-dragging { 637 + opacity: 0.9; 638 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 639 + z-index: 10; 640 + } 641 + 642 + :global(.track-row.touch-dragging) { 643 + z-index: 100; 644 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); 645 + } 646 + 647 + .drag-handle { 648 + display: flex; 649 + align-items: center; 650 + justify-content: center; 651 + padding: 0.5rem; 652 + background: transparent; 653 + border: none; 654 + color: var(--text-muted); 655 + cursor: grab; 656 + touch-action: none; 657 + border-radius: 4px; 658 + transition: all 0.2s; 659 + flex-shrink: 0; 660 + } 661 + 662 + .drag-handle:hover { 663 + color: var(--text-secondary); 664 + background: var(--bg-tertiary); 665 + } 666 + 667 + .drag-handle:active { 668 + cursor: grabbing; 669 + color: var(--accent); 670 + } 671 + 672 + @media (pointer: coarse) { 673 + .drag-handle { 674 + color: var(--text-tertiary); 675 + } 676 + } 677 + 678 + .track-content { 679 + flex: 1; 680 + min-width: 0; 681 + } 682 + 329 683 @media (max-width: 768px) { 330 684 .album-hero { 331 685 flex-direction: column; ··· 370 724 } 371 725 372 726 .play-button, 373 - .queue-button { 727 + .queue-button, 728 + .reorder-button { 374 729 width: 100%; 375 730 justify-content: center; 376 731 }
+52
lexicons/list.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.plyr.list", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "An ordered collection of ATProto records.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["items", "createdAt"], 12 + "properties": { 13 + "name": { 14 + "type": "string", 15 + "maxLength": 256, 16 + "maxGraphemes": 64 17 + }, 18 + "listType": { 19 + "type": "string", 20 + "knownValues": ["album", "playlist", "liked"] 21 + }, 22 + "items": { 23 + "type": "array", 24 + "items": { 25 + "type": "ref", 26 + "ref": "#item" 27 + }, 28 + "maxLength": 500 29 + }, 30 + "createdAt": { 31 + "type": "string", 32 + "format": "datetime" 33 + }, 34 + "updatedAt": { 35 + "type": "string", 36 + "format": "datetime" 37 + } 38 + } 39 + } 40 + }, 41 + "item": { 42 + "type": "object", 43 + "required": ["subject"], 44 + "properties": { 45 + "subject": { 46 + "type": "ref", 47 + "ref": "com.atproto.repo.strongRef" 48 + } 49 + } 50 + } 51 + } 52 + }
+33
lexicons/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.plyr.actor.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A plyr.fm artist profile with music-specific metadata.", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": ["createdAt"], 12 + "properties": { 13 + "bio": { 14 + "type": "string", 15 + "description": "Artist bio or description.", 16 + "maxLength": 2560, 17 + "maxGraphemes": 256 18 + }, 19 + "createdAt": { 20 + "type": "string", 21 + "format": "datetime", 22 + "description": "Timestamp when the profile was created." 23 + }, 24 + "updatedAt": { 25 + "type": "string", 26 + "format": "datetime", 27 + "description": "Timestamp when the profile was last updated." 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }