music on atproto
plyr.fm
1# plyr.fm - status
2
3## long-term vision
4
5### the problem
6
7today's music streaming is fundamentally broken:
8- spotify and apple music trap your data in proprietary silos
9- artists pay distribution fees and streaming cuts to multiple gatekeepers
10- listeners can't own their music collections - they rent them
11- switching platforms means losing everything: playlists, play history, social connections
12
13### the atproto solution
14
15plyr.fm is built on the AT Protocol (the protocol powering Bluesky) and enables:
16- **portable identity**: your music collection, playlists, and listening history belong to you, stored in your personal data server (PDS)
17- **decentralized distribution**: artists publish directly to the network without platform gatekeepers
18- **interoperable data**: any client can read your music records - you're not locked into plyr.fm
19- **authentic social**: artist profiles are real ATProto identities with verifiable handles (@artist.bsky.social)
20
21### the dream state
22
23plyr.fm should become:
24
251. **for artists**: the easiest way to publish music to the decentralized web
26 - upload once, available everywhere in the ATProto network
27 - direct connection to listeners without platform intermediaries
28 - real ownership of audience relationships
29
302. **for listeners**: a streaming platform where you actually own your data
31 - your collection lives in your PDS, playable by any ATProto music client
32 - switch between plyr.fm and other clients freely - your data travels with you
33 - share tracks as native ATProto posts to Bluesky
34
353. **for developers**: a reference implementation showing how to build on ATProto
36 - open source end-to-end example of ATProto integration
37 - demonstrates OAuth, record creation, federation patterns
38 - proves decentralized music streaming is viable
39
40---
41
42**started**: October 28, 2025 (first commit: `454e9bc` - relay MVP with ATProto authentication)
43
44---
45
46## recent work
47
48### December 2025
49
50#### playlist release fast-follow fixes (PRs #507-510, Dec 7)
51
52**what shipped** (all merged to main):
53- **PR #507**: include `image_url` in playlist SSR data for og:image link previews
54- **PR #508**: invalidate layout data after token exchange - fixes stale auth state after login
55- **PR #509**: playlist menus and link previews - fixed stopPropagation blocking links, added `/playlist/` to hasPageMetadata
56- **PR #510**: inline playlist creation - replaced navigation-based create with inline form to avoid playback interruption
57
58**the navigation bug** (PR #510):
59- clicking "create new playlist" from AddToMenu/TrackActionsMenu previously navigated to `/library?create=playlist`
60- this caused SvelteKit to reinitialize the layout, destroying the audio element and stopping playback
61- fix: added inline create form that creates playlist and adds track in one action without navigation
62- same pattern applied to TrackActionsMenu (mobile bottom sheet menu)
63
64---
65
66#### playlists, ATProto sync, and library hub (feat/playlists branch, PR #499, Dec 6-7)
67
68**status**: shipped and deployed.
69
70**playlists** (full CRUD):
71- `playlists` and `playlist_tracks` tables with Alembic migration
72- `POST /lists/playlists` - create playlist
73- `PUT /lists/playlists/{id}` - rename playlist
74- `DELETE /lists/playlists/{id}` - delete playlist
75- `POST /lists/playlists/{id}/tracks` - add track to playlist
76- `DELETE /lists/playlists/{id}/tracks/{track_id}` - remove track
77- `PUT /lists/playlists/{id}/tracks/reorder` - reorder tracks
78- `POST /lists/playlists/{id}/cover` - upload cover art
79- playlist detail page (`/playlist/[id]`) with edit modal, drag-and-drop reordering
80- playlists in global search results
81- "add to playlist" menu on tracks (filters out current playlist when on playlist page)
82- inline "create new playlist" in add-to menu (creates playlist and adds track in one action)
83- playlist sharing with OpenGraph link previews
84
85**ATProto integration**:
86- `fm.plyr.list` lexicon for syncing playlists and albums to user PDSes
87- `fm.plyr.actor.profile` lexicon for syncing artist profiles
88- automatic sync of albums, liked tracks, and profile on login (fire-and-forget)
89- scope upgrade OAuth flow for teal.fm integration (#503)
90
91**library hub** (`/library`):
92- unified page with tabs: liked, playlists, albums
93- create playlist modal with inline form
94- consistent card layouts across sections
95- nav changed from "liked" → "library"
96
97**user experience**:
98- public liked pages for any user (`/liked/[handle]`)
99- `show_liked_on_profile` preference
100- portal album/playlist section visual consistency
101- toast notifications for all mutations (playlist CRUD, profile updates)
102- z-index fixes for dropdown menus
103
104**accessibility fixes**:
105- fixed 32 svelte-check warnings (ARIA roles, button nesting, unused CSS)
106- proper roles on modals, menus, and drag-drop elements
107
108**design decisions**:
109- lists are generic ordered collections of any ATProto records
110- `listType` semantically categorizes (album, playlist, liked) but doesn't restrict content
111- array order = display order, reorder via `putRecord`
112- strongRef (uri + cid) for content-addressable item references
113- "library" = umbrella term for personal collections
114
115**sync architecture**:
116- **profile, albums, liked tracks**: synced on login via `GET /artists/me` (fire-and-forget background tasks)
117- **playlists**: synced on create/modify (not at login) - avoids N playlist syncs on every login
118- sync tasks don't block the response (~300-500ms for the endpoint, PDS calls happen in background)
119- putRecord calls take ~50-100ms each, with automatic DPoP nonce retry on 401
120
121**file size audit** (candidates for future modularization):
122- `portal/+page.svelte`: 2,436 lines (58% CSS)
123- `playlist/[id]/+page.svelte`: 1,644 lines (48% CSS)
124- `api/lists.py`: 855 lines
125- CSS-heavy files could benefit from shared style extraction in future
126
127**related issues**: #221, #146, #498
128
129---
130
131#### list reordering UI (feat/playlists branch, Dec 7)
132
133**what's done**:
134- `PUT /lists/liked/reorder` endpoint - reorder user's liked tracks list
135- `PUT /lists/{rkey}/reorder` endpoint - reorder any list by ATProto rkey
136- both endpoints take `items` array of strongRefs (uri + cid) in desired order
137- liked tracks page (`/liked`) now has "reorder" button for authenticated users
138- album page has "reorder" button for album owner (if album has ATProto list record)
139- drag-and-drop reordering on desktop (HTML5 drag API)
140- touch reordering on mobile (6-dot grip handle, same pattern as queue)
141- visual feedback during drag: `.drag-over` and `.is-dragging` states
142- saves order to ATProto via `putRecord` when user clicks "done"
143- added `atproto_record_cid` to TrackResponse schema (needed for strongRefs)
144- added `artist_did` and `list_uri` to AlbumMetadata response
145
146**UX design**:
147- button toggles between "reorder" and "done" states
148- in edit mode, drag handles appear next to each track
149- saving shows spinner, success/error toast on completion
150- only owners can see/use reorder button (liked list = current user, album = artist)
151
152---
153
154#### scope upgrade OAuth flow (feat/scope-invalidation branch, Dec 7) - merged to feat/playlists
155
156**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.
157
158**solution**: immediate OAuth handshake when enabling features that require new scopes (same pattern as developer tokens).
159
160**what's done**:
161- `POST /auth/scope-upgrade/start` endpoint initiates OAuth with expanded scopes
162- `pending_scope_upgrades` table tracks in-flight upgrades (10min TTL)
163- callback replaces old session with new one, redirects to `/settings?scope_upgraded=true`
164- frontend shows spinner during redirect, success toast on return
165- fixed preferences bug where toggling settings reset theme to dark mode
166
167**code quality**:
168- eliminated bifurcated OAuth clients (`oauth_client` vs `oauth_client_with_teal`)
169- replaced with `get_oauth_client(include_teal=False)` factory function
170- at ~17 OAuth flows/day, instantiation cost is negligible
171- explicit scope selection at call site instead of module-level state
172
173**developer token UX**:
174- full-page overlay when returning from OAuth after creating a developer token
175- token displayed prominently with warning that it won't be shown again
176- copy button with success feedback, link to python SDK docs
177- prevents users from missing their token (was buried at bottom of page)
178
179**test fixes**:
180- fixed connection pool exhaustion in tests (was hitting Neon's connection limit)
181- added `DATABASE_POOL_SIZE=2`, `DATABASE_MAX_OVERFLOW=0` to pytest env vars
182- dispose cached engines after each test to prevent connection accumulation
183- fixed mock function signatures for `refresh_session` tests
184
185**tests**: 4 new tests for scope upgrade flow, all 281 tests passing
186
187---
188
189#### settings consolidation (PR #496, Dec 6)
190
191**problem**: user preferences were scattered across multiple locations with confusing terminology:
192- SensitiveImage tooltip said "enable in portal" but mobile menu said "profile"
193- clicking gear icon (SettingsMenu) only showed appearance/playback, not all settings
194- portal mixed content management with preferences
195
196**solution**: clear separation between **settings** (preferences) and **portal** (content & data):
197
198| page | purpose |
199|------|---------|
200| `/settings` | preferences: theme, accent color, auto-advance, sensitive artwork, timed comments, teal.fm, developer tokens |
201| `/portal` | your content & data: profile, tracks, albums, export, delete account |
202
203**changes**:
204- created dedicated `/settings` route consolidating all user preferences
205- slimmed portal to focus on content management
206- added "all settings →" link to SettingsMenu and ProfileMenu
207- renamed mobile menu "profile" → "portal" to match route
208- moved delete account to portal's "your data" section (it's about data, not preferences)
209- fixed `font-family: inherit` on all settings page buttons
210- updated SensitiveImage tooltip: "enable in settings"
211
212---
213
214#### bufo easter egg improvements (PRs #491-492, Dec 6)
215
216**what shipped**:
217- configurable exclude/include patterns via env vars for bufo easter egg
218- `BUFO_EXCLUDE_PATTERNS`: regex patterns to filter out (default: `^bigbufo_`)
219- `BUFO_INCLUDE_PATTERNS`: allowlist that overrides exclude (default: `bigbufo_0_0`, `bigbufo_2_1`)
220- cache key now includes patterns so config changes take effect immediately
221
222**reusable type**:
223- added `CommaSeparatedStringSet` type for parsing comma-delimited env vars into sets
224- uses pydantic `BeforeValidator` with `Annotated` pattern (not class-coupled validators)
225- handles: `VAR=a,b,c` → `{"a", "b", "c"}`
226
227**context**: bigbufo tiles are 4x4 grid fragments that looked weird floating individually. now excluded by default, with two specific tiles allowed through.
228
229**thread**: https://bsky.app/profile/zzstoatzzdevlog.bsky.social/post/3m7e3ndmgwl2m
230
231---
232
233#### mobile artwork upload fix (PR #489, Dec 6)
234
235**problem**: artwork uploads from iOS Photos library silently failed - track uploaded successfully but without artwork.
236
237**root cause**: iOS stores photos in HEIC format. when selected, iOS converts content to JPEG but often keeps the `.heic` filename. backend validated format using only filename extension → rejected as "unsupported format".
238
239**fix**:
240- backend now prefers MIME content_type over filename extension for format detection
241- added `ImageFormat.from_content_type()` method
242- frontend uses `accept="image/*"` for broader iOS compatibility
243
244#### sensitive image moderation (PRs #471-488, Dec 5-6)
245
246**what shipped**:
247- `sensitive_images` table to flag problematic images by R2 `image_id` or external URL
248- `show_sensitive_artwork` user preference (default: hidden, toggle in portal → "your data")
249- flagged images blurred everywhere: track lists, player, artist pages, likers tooltip, search results, embeds
250- Media Session API (CarPlay, lock screen, control center) respects sensitive preference
251- SSR-safe filtering: link previews (og:image) exclude sensitive images on track, artist, and album pages
252- likers tooltip UX: max-height with scroll, hover interaction fix, viewport-aware flip positioning
253- likers tooltip z-index: elevates entire track-container when tooltip open (prevents sibling tracks bleeding through)
254
255**how it works**:
256- frontend fetches `/moderation/sensitive-images` and stores flagged IDs/URLs
257- `SensitiveImage` component wraps images and checks against flagged list
258- server-side check via `+layout.server.ts` for meta tag filtering
259- users can opt-in to view sensitive artwork via portal toggle
260
261**coverage** (PR #488):
262
263| context | approach |
264|---------|----------|
265| DOM images needing blur | `SensitiveImage` component |
266| small avatars in lists | `SensitiveImage` with `compact` prop |
267| SSR meta tags (og:image) | `checkImageSensitive()` function |
268| non-DOM APIs (media session) | direct `isSensitive()` + `showSensitiveArtwork` check |
269
270**moderation workflow**:
271- admin adds row to `sensitive_images` with `image_id` (R2) or `url` (external)
272- images are blurred immediately for all users
273- users who enable `show_sensitive_artwork` see unblurred images
274
275---
276
277#### teal.fm scrobbling integration (PR #467, Dec 4)
278
279**what shipped**:
280- native teal.fm scrobbling: when users enable the toggle, plays are recorded to their PDS using teal's ATProto lexicons
281- scrobble triggers at 30% or 30 seconds (whichever comes first) - same threshold as play counts
282- user preference stored in database, toggleable from portal → "your data"
283- settings link to pdsls.dev so users can view their scrobble records
284
285**lexicons used**:
286- `fm.teal.alpha.feed.play` - individual play records (scrobbles)
287- `fm.teal.alpha.actor.status` - now-playing status updates
288
289**configuration** (all optional, sensible defaults):
290- `TEAL_ENABLED` (default: `true`) - feature flag for entire integration
291- `TEAL_PLAY_COLLECTION` (default: `fm.teal.alpha.feed.play`)
292- `TEAL_STATUS_COLLECTION` (default: `fm.teal.alpha.actor.status`)
293
294**code quality improvements** (same PR):
295- added `settings.frontend.domain` computed property for environment-aware URLs
296- extracted `get_session_id_from_request()` utility for bearer token parsing
297- added field validator on `DeveloperTokenInfo.session_id` for auto-truncation
298- applied walrus operators throughout auth and playback code
299- fixed now-playing endpoint firing every 1 second (fingerprint update bug in scheduled reports)
300
301**documentation**: `backend/src/backend/_internal/atproto/teal.py` contains inline docs on the scrobbling flow
302
303---
304
305#### unified search (PR #447, Dec 3)
306
307**what shipped**:
308- `Cmd+K` (mac) / `Ctrl+K` (windows/linux) opens search modal from anywhere
309- fuzzy matching across tracks, artists, albums, and tags using PostgreSQL `pg_trgm`
310- results grouped by type with relevance scores (0.0-1.0)
311- keyboard navigation (arrow keys, enter, esc)
312- artwork/avatars displayed with lazy loading and fallback icons
313- glassmorphism modal styling with backdrop blur
314- debounced input (150ms) with client-side validation
315
316**database**:
317- enabled `pg_trgm` extension for trigram-based similarity search
318- GIN indexes on `tracks.title`, `artists.handle`, `artists.display_name`, `albums.title`, `tags.name`
319
320**documentation**: `docs/frontend/search.md`, `docs/frontend/keyboard-shortcuts.md`
321
322**follow-up polish** (PRs #449-463):
323- mobile search icon in header (PRs #455-456)
324- theme-aware modal styling with styled scrollbar (#450)
325- ILIKE fallback for substring matches when trigram fails (#452)
326- tag collapse with +N button (#453)
327- input focus fix: removed `visibility: hidden` so focus works on open (#457, #463)
328- album artwork fallback in player when track has no image (#458)
329- rate limiting exemption for now-playing endpoints (#460)
330- `--no-dev` flag for release command to prevent dev dep installation (#461)
331
332---
333
334#### light/dark theme and mobile UX overhaul (Dec 2-3)
335
336**theme system** (PR #441):
337- replaced hardcoded colors across 35 files with CSS custom properties
338- semantic tokens: `--bg-primary`, `--text-secondary`, `--accent`, etc.
339- theme switcher in settings: dark / light / system (follows OS preference)
340- removed zen mode feature (superseded by proper theme support)
341
342**mobile UX improvements** (PR #443):
343- new `ProfileMenu` component — collapses profile, upload, settings, logout into touch-optimized menu (44px tap targets)
344- dedicated `/upload` page — extracted from portal for cleaner mobile flow
345- portal overhaul — tighter forms, track detail links under artwork, fixed icon alignment
346- standardized section headers across home and liked tracks pages
347
348**player scroll timing fix** (PR #445):
349- reduced title scroll cycle from 10s → 8s, artist/album from 15s → 10s
350- eliminated 1.5s invisible pause at end of scroll animation
351- fixed duplicate upload toast (was firing twice on success)
352- upload success toast now includes "view track" link
353
354**CI optimization** (PR #444):
355- pre-commit hooks now skip based on changed paths
356- result: ~10s for most PRs instead of ~1m20s
357- only installs tooling (uv, bun) needed for changed directories
358
359---
360
361#### tag filtering system and SDK tag support (Dec 2)
362
363**tag filtering** (PRs #431-434):
364- users can now hide tracks by tag via eye icon filter in discovery feed
365- preferences centralized in root layout (fetched once, shared across app)
366- `HiddenTagsFilter` component with expandable UI for managing hidden tags
367- default hidden tags: `["ai"]` for new users
368- tag detail pages at `/tag/[name]` with all tracks for that tag
369- clickable tag badges on tracks navigate to tag pages
370
371**navigation fix** (PR #435):
372- fixed tag links interrupting audio playback
373- root cause: `stopPropagation()` on links breaks SvelteKit's client-side router
374- documented pattern in `docs/frontend/navigation.md` to prevent recurrence
375
376**SDK tag support** (plyr-python-client v0.0.1-alpha.10):
377- added `tags: set[str]` parameter to `upload()` in SDK
378- added `-t/--tag` CLI option (can be used multiple times)
379- updated MCP `upload_guide` prompt with tag examples
380- status maintenance workflow now tags AI-generated podcasts with `ai` (#436)
381
382**tags in detail pages** (PR #437):
383- track detail endpoint (`/tracks/{id}`) now returns tags
384- album detail endpoint (`/albums/{handle}/{slug}`) now returns tags for all tracks
385- track detail page displays clickable tag badges
386
387**bufo easter egg** (PR #438, improved in #491-492):
388- tracks tagged with `bufo` trigger animated toad GIFs on the detail page
389- uses track title as semantic search query against [find-bufo API](https://find-bufo.fly.dev/)
390- toads are semantically matched to the song's vibe (e.g., "Happy Vibes" gets happy toads)
391- results cached in localStorage (1 week TTL) to minimize API calls
392- `TagEffects` wrapper component provides extensibility for future tag-based plugins
393- respects `prefers-reduced-motion`; fails gracefully if API unavailable
394- configurable exclude/include patterns via env vars (see Dec 6 entry above)
395
396---
397
398#### queue touch reordering and header stats fix (Dec 2)
399
400**queue mobile UX** (PR #428):
401- added 6-dot drag handle to queue items for touch-friendly reordering
402- implemented touch event handlers for mobile drag-and-drop
403- track follows finger during drag with smooth translateY transform
404- drop target highlights while dragging over other tracks
405
406**header stats positioning** (PR #426):
407- fixed platform stats not adjusting when queue sidebar opens/closes
408- added `--queue-width` CSS custom property updated dynamically
409- stats now shift left with smooth transition when queue opens
410
411---
412
413#### connection pool resilience for Neon cold starts (Dec 2)
414
415**incident**: ~5 minute API outage (01:55-02:00 UTC) - all requests returned 500 errors
416
417**root cause**: Neon serverless cold start after 5 minutes of idle traffic
418- queue listener heartbeat detected dead connection, began reconnection
419- first 5 user requests each held a connection waiting for Neon to wake up (3-5 min each)
420- with pool_size=5 and max_overflow=0, pool exhausted immediately
421- all subsequent requests got `QueuePool limit of size 5 overflow 0 reached`
422
423**fix**:
424- increased `pool_size` from 5 → 10 (handle more concurrent cold start requests)
425- increased `max_overflow` from 0 → 5 (allow burst to 15 connections)
426- increased `connection_timeout` from 3s → 10s (wait for Neon wake-up)
427
428**related**: this is a recurrence of the Nov 17 incident. that fix addressed the queue listener's asyncpg connection but not the SQLAlchemy pool connections.
429
430---
431
432#### now-playing API (PR #416, Dec 1)
433
434**what shipped**:
435- `GET /now-playing/{did}` and `GET /now-playing/by-handle/{handle}` endpoints
436- returns track metadata, playback position, timestamp
437- 204 when nothing playing, 200 with track data otherwise
438
439**teal.fm integration**:
440- native scrobbling shipped in PR #467 (Dec 4) - plyr.fm writes directly to user's PDS
441- Piper integration (external polling) still open: https://github.com/teal-fm/piper/pull/27
442
443---
444
445#### admin UI improvements for moderation (PRs #408-414, Dec 1)
446
447**what shipped**:
448- dropdown menu for false positive reasons (fingerprint noise, original artist, fair use, other)
449- artist/track links open in new tabs for verification
450- AuDD score normalization (scores shown as 0-100 range)
451- filter controls to show only high-confidence matches
452- form submission fixes for htmx POST requests
453
454---
455
456#### ATProto labeler and copyright moderation (PRs #382-395, Nov 29-Dec 1)
457
458**what shipped**:
459- standalone labeler service integrated into moderation Rust service
460- implements `com.atproto.label.queryLabels` and `subscribeLabels` XRPC endpoints
461- k256 ECDSA signing for cryptographic label verification
462- web interface at `/admin` for reviewing copyright flags
463- htmx for server-rendered interactivity
464- integrates with AuDD enterprise API for audio fingerprinting
465- fire-and-forget background task on track upload
466- review workflow with resolution tracking (violation, false_positive, original_artist)
467
468**initial review results** (25 flagged tracks):
469- 8 violations (actual copyright issues)
470- 11 false positives (fingerprint noise)
471- 6 original artists (people uploading their own distributed music)
472
473**documentation**: see `docs/moderation/atproto-labeler.md`
474
475---
476
477#### developer tokens with independent OAuth grants (PR #367, Nov 28)
478
479**what shipped**:
480- each developer token gets its own OAuth authorization flow
481- tokens have their own DPoP keypair, access/refresh tokens - completely separate from browser session
482- cookie isolation: dev token exchange doesn't set browser cookie
483- token management UI: portal → "your data" → "developer tokens"
484- create with optional name and expiration (30/90/180/365 days or never)
485
486**security properties**:
487- tokens are full sessions with encrypted OAuth credentials (Fernet)
488- each token refreshes independently
489- revokable individually without affecting browser or other tokens
490
491---
492
493#### platform stats and media session integration (PRs #359-379, Nov 27-29)
494
495**what shipped**:
496- `GET /stats` returns total plays, tracks, and artists
497- stats bar displays in homepage header (e.g., "1,691 plays • 55 tracks • 8 artists")
498- Media Session API for CarPlay, lock screens, Bluetooth devices
499- browser tab title shows "track - artist • plyr.fm" while playing
500- timed comments with clickable timestamps
501- constellation integration for network-wide like counts
502- account deletion with explicit confirmation
503
504---
505
506#### export & upload reliability (PRs #337-344, Nov 24)
507
508**what shipped**:
509- database-backed jobs (moved tracking from in-memory to postgres)
510- streaming exports (fixed OOM on large file exports)
511- 90-minute WAV files now export successfully on 1GB VM
512- upload progress bar fixes
513- export filename now includes date
514
515---
516
517### October-November 2025
518
519See `.status_history/2025-11.md` for detailed November development history including:
520- async I/O performance fixes (PRs #149-151)
521- transcoder API deployment (PR #156)
522- upload streaming + progress UX (PR #182)
523- liked tracks feature (PR #157)
524- track detail pages (PR #164)
525- mobile UI improvements (PRs #159-185)
526- oEmbed endpoint for Leaflet.pub embeds (PRs #355-358)
527
528## immediate priorities
529
530### high priority features
5311. **audio transcoding pipeline integration** (issue #153)
532 - ✅ standalone transcoder service deployed at https://plyr-transcoder.fly.dev/
533 - ⏳ next: integrate into plyr.fm upload pipeline
534 - backend calls transcoder API for unsupported formats
535 - queue-based job system for async processing
536 - R2 integration (fetch original, store MP3)
537
538### known issues
539- playback auto-start on refresh (#225) - investigating localStorage/queue state persistence
540- no AIFF/AIF transcoding support (#153)
541- 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.
542
543### new features
544- issue #146: content-addressable storage (hash-based deduplication)
545- issue #155: add track metadata (genres, tags, descriptions)
546- issue #334: add 'share to bluesky' option for tracks
547- issue #373: lyrics field and Genius-style annotations
548- issue #393: moderation - represent confirmed takedown state in labeler
549
550## technical state
551
552### architecture
553
554**backend**
555- language: Python 3.11+
556- framework: FastAPI with uvicorn
557- database: Neon PostgreSQL (serverless)
558- storage: Cloudflare R2 (S3-compatible)
559- hosting: Fly.io (2x shared-cpu VMs)
560- observability: Pydantic Logfire
561- auth: ATProto OAuth 2.1
562
563**frontend**
564- framework: SvelteKit (v2.43.2)
565- runtime: Bun
566- hosting: Cloudflare Pages
567- styling: vanilla CSS with lowercase aesthetic
568- state management: Svelte 5 runes
569
570**deployment**
571- ci/cd: GitHub Actions
572- backend: automatic on main branch merge (fly.io)
573- frontend: automatic on every push to main (cloudflare pages)
574- migrations: automated via fly.io release_command
575
576**what's working**
577
578**core functionality**
579- ✅ ATProto OAuth 2.1 authentication with encrypted state
580- ✅ secure session management via HttpOnly cookies
581- ✅ developer tokens with independent OAuth grants
582- ✅ platform stats endpoint and homepage display
583- ✅ Media Session API for CarPlay, lock screens, control center
584- ✅ timed comments on tracks with clickable timestamps
585- ✅ account deletion with explicit confirmation
586- ✅ artist profiles synced with Bluesky
587- ✅ track upload with streaming to prevent OOM
588- ✅ track edit/deletion with cascade cleanup
589- ✅ audio streaming via HTML5 player with 307 redirects to R2 CDN
590- ✅ track metadata published as ATProto records
591- ✅ play count tracking (30% or 30s threshold)
592- ✅ like functionality with counts
593- ✅ queue management (shuffle, auto-advance, reorder)
594- ✅ mobile-optimized responsive UI
595- ✅ cross-tab queue synchronization via BroadcastChannel
596- ✅ share tracks via URL with Open Graph previews
597- ✅ copyright moderation system with admin UI
598- ✅ ATProto labeler for copyright violations
599- ✅ unified search with Cmd/Ctrl+K (fuzzy matching via pg_trgm)
600- ✅ teal.fm scrobbling (records plays to user's PDS)
601
602**albums**
603- ✅ album database schema with track relationships
604- ✅ album browsing and detail pages
605- ✅ album cover art upload and display
606- ✅ server-side rendering for SEO
607- ✅ ATProto list records for albums (auto-synced on login)
608
609**playlists**
610- ✅ full CRUD (create, rename, delete, reorder tracks)
611- ✅ playlist detail pages with drag-and-drop reordering
612- ✅ playlist cover art upload
613- ✅ ATProto list records (synced on create/modify)
614- ✅ "add to playlist" menu on tracks
615- ✅ playlists in global search results
616
617**deployment (fully automated)**
618- **production**:
619 - frontend: https://plyr.fm
620 - backend: https://relay-api.fly.dev → https://api.plyr.fm
621 - database: neon postgresql
622 - storage: cloudflare R2 (audio-prod and images-prod buckets)
623
624- **staging**:
625 - backend: https://api-stg.plyr.fm
626 - frontend: https://stg.plyr.fm
627 - database: neon postgresql (relay-staging)
628 - storage: cloudflare R2 (audio-stg bucket)
629
630### technical decisions
631
632**why Python/FastAPI instead of Rust?**
633- rapid prototyping velocity during MVP phase
634- rich ecosystem for web APIs
635- excellent async support with asyncio
636- trade-off: accepting higher latency for faster development
637
638**why Cloudflare R2 instead of S3?**
639- zero egress fees (critical for audio streaming)
640- S3-compatible API (easy migration if needed)
641- integrated CDN for fast delivery
642
643**why forked atproto SDK?**
644- upstream SDK lacked OAuth 2.1 support
645- needed custom record management patterns
646- maintains compatibility with ATProto spec
647
648**why async everywhere?**
649- event loop performance: single-threaded async handles high concurrency
650- I/O-bound workload: most time spent waiting on network/disk
651- PRs #149-151 eliminated all blocking operations
652
653## cost structure
654
655current monthly costs: ~$35-40/month
656
657- fly.io backend (production): ~$5/month
658- fly.io backend (staging): ~$5/month
659- fly.io transcoder: ~$0-5/month (auto-scales to zero)
660- neon postgres: $5/month
661- audd audio fingerprinting: ~$10/month
662- cloudflare pages: $0 (free tier)
663- cloudflare R2: ~$0.16/month
664- logfire: $0 (free tier)
665- domain: $12/year (~$1/month)
666
667## deployment URLs
668
669- **production frontend**: https://plyr.fm
670- **production backend**: https://api.plyr.fm
671- **staging backend**: https://api-stg.plyr.fm
672- **staging frontend**: https://stg.plyr.fm
673- **repository**: https://github.com/zzstoatzz/plyr.fm (private)
674- **monitoring**: https://logfire-us.pydantic.dev/zzstoatzz/relay
675- **bluesky**: https://bsky.app/profile/plyr.fm
676
677## admin tooling
678
679### content moderation
680script: `scripts/delete_track.py`
681- requires `ADMIN_*` prefixed environment variables
682- deletes audio file, cover image, database record
683- notes ATProto records for manual cleanup
684
685usage:
686```bash
687uv run scripts/delete_track.py <track_id> --dry-run
688uv run scripts/delete_track.py <track_id>
689uv run scripts/delete_track.py --url https://plyr.fm/track/34
690```
691
692## for new contributors
693
694### getting started
6951. clone: `gh repo clone zzstoatzz/plyr.fm`
6962. install dependencies: `uv sync && cd frontend && bun install`
6973. run backend: `uv run uvicorn backend.main:app --reload`
6984. run frontend: `cd frontend && bun run dev`
6995. visit http://localhost:5173
700
701### development workflow
7021. create issue on github
7032. create PR from feature branch
7043. ensure pre-commit hooks pass
7054. merge to main → deploys to staging automatically
7065. verify on staging
7076. create github release → deploys to production automatically
708
709### key principles
710- type hints everywhere
711- lowercase aesthetic
712- ATProto first
713- async everywhere (no blocking I/O)
714- mobile matters
715- cost conscious
716
717### project structure
718```
719plyr.fm/
720├── backend/ # FastAPI app & Python tooling
721│ ├── src/backend/ # application code
722│ │ ├── api/ # public endpoints
723│ │ ├── _internal/ # internal services
724│ │ ├── models/ # database schemas
725│ │ └── storage/ # storage adapters
726│ ├── tests/ # pytest suite
727│ └── alembic/ # database migrations
728├── frontend/ # SvelteKit app
729│ ├── src/lib/ # components & state
730│ └── src/routes/ # pages
731├── moderation/ # Rust moderation service (ATProto labeler)
732│ ├── src/ # Axum handlers, AuDD client, label signing
733│ └── static/ # admin UI (html/css/js)
734├── transcoder/ # Rust audio transcoding service
735├── docs/ # documentation
736└── justfile # task runner
737```
738
739## documentation
740
741- [deployment overview](docs/deployment/overview.md)
742- [configuration guide](docs/configuration.md)
743- [queue design](docs/queue-design.md)
744- [logfire querying](docs/logfire-querying.md)
745- [moderation & labeler](docs/moderation/atproto-labeler.md)
746- [unified search](docs/frontend/search.md)
747- [keyboard shortcuts](docs/frontend/keyboard-shortcuts.md)
748
749---
750
751this is a living document. last updated 2025-12-07.