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#### rate limit moderation endpoint (PR #629, Dec 21)
51
52**incident response**: detected suspicious activity - 72 requests in 17 seconds from a single IP targeting `/moderation/sensitive-images`. investigation via Logfire showed:
53- single IP generating all traffic with no User-Agent header
54- requests spaced ~230ms apart (too consistent for human browsing)
55- no corresponding user activity (page loads, audio streams)
56
57**fix**: added `10/minute` rate limit to the endpoint using existing slowapi infrastructure. verified rate limiting works correctly post-deployment.
58
59---
60
61#### end-of-year sprint (Dec 20-31)
62
63**focus**: two foundational systems need solid experimental implementations by 2026.
64
65**track 1: moderation architecture overhaul**
66- consolidate sensitive images into moderation service
67- add event-sourced audit trail
68- implement configurable rules (replace hard-coded thresholds)
69- informed by [Roost Osprey](https://github.com/roostorg/osprey) patterns and [Bluesky Ozone](https://github.com/bluesky-social/ozone) workflows
70
71**track 2: atprotofans paywall integration**
72- phase 1: read-only supporter validation (show badges)
73- phase 2: platform registration (artists create support tiers)
74- phase 3: content gating (track-level access control)
75
76**research docs**:
77- [moderation architecture overhaul](docs/research/2025-12-20-moderation-architecture-overhaul.md)
78- [atprotofans paywall integration](docs/research/2025-12-20-atprotofans-paywall-integration.md)
79
80**tracking**: issue #625
81
82---
83
84#### beartype + moderation cleanup (PRs #617-619, Dec 19)
85
86**runtime type checking** (PR #619):
87- enabled beartype runtime type validation across the backend
88- catches type errors at runtime instead of silently passing bad data
89- test infrastructure improvements: session-scoped TestClient fixture (5x faster tests)
90- disabled automatic perpetual task scheduling in tests
91
92**moderation cleanup** (PRs #617-618):
93- consolidated moderation code, addressing issues #541-543
94- `sync_copyright_resolutions` now runs automatically via docket Perpetual task
95- removed `init_db()` from lifespan (handled by alembic migrations)
96
97---
98
99#### UX polish (PRs #604-607, #613, #615, Dec 16-18)
100
101**login improvements** (PRs #604, #613):
102- login page now uses "internet handle" terminology for clarity
103- input normalization: strips `@` and `at://` prefixes automatically
104
105**artist page fixes** (PR #615):
106- track pagination on artist pages now works correctly
107- fixed mobile album card overflow
108
109**mobile + metadata** (PRs #605-607):
110- Open Graph tags added to tag detail pages for link previews
111- mobile modals now use full screen positioning
112- fixed `/tag/` routes in hasPageMetadata check
113
114**misc** (PRs #598-601):
115- upload button added to desktop header nav
116- background settings UX improvements
117- switched support link to atprotofans
118- AudD costs now derived from track duration for accurate billing
119
120---
121
122#### offline mode foundation (PRs #610-611, Dec 17)
123
124**experimental offline playback**:
125- new storage layer using Cache API for audio bytes + IndexedDB for metadata
126- `GET /audio/{file_id}/url` backend endpoint returns direct R2 URLs for client-side caching
127- "auto-download liked" toggle in experimental settings section
128- when enabled, bulk-downloads all liked tracks and auto-downloads future likes
129- Player checks for cached audio before streaming from R2
130- works offline once tracks are downloaded
131
132**robustness improvements**:
133- IndexedDB connections properly closed after each operation
134- concurrent downloads deduplicated via in-flight promise tracking
135- stale metadata cleanup when cache entries are missing
136
137---
138
139#### visual customization (PRs #595-596, Dec 16)
140
141**custom backgrounds** (PR #595):
142- users can set a custom background image URL in settings with optional tiling
143- new "playing artwork as background" toggle - uses current track's artwork as blurred page background
144- glass effect styling for track items (translucent backgrounds, subtle shadows)
145- new `ui_settings` JSONB column in preferences for extensible UI settings
146
147**bug fix** (PR #596):
148- removed 3D wheel scroll effect that was blocking like/share button clicks
149- root cause: `translateZ` transforms created z-index stacking that intercepted pointer events
150
151---
152
153#### performance & UX polish (PRs #586-593, Dec 14-15)
154
155**performance improvements** (PRs #590-591):
156- removed moderation service call from `/tracks/` listing endpoint
157- removed copyright check from tag listing endpoint
158- faster page loads for track feeds
159
160**moderation agent** (PRs #586, #588):
161- added moderation agent script with audit trail support
162- improved moderation prompt and UI layout
163
164**bug fixes** (PRs #589, #592, #593):
165- fixed liked state display on playlist detail page
166- preserved album track order during ATProto sync
167- made header sticky on scroll for better mobile navigation
168
169**iOS Safari fixes** (PRs #573-576):
170- fixed AddToMenu visibility issue on iOS Safari
171- menu now correctly opens upward when near viewport bottom
172
173---
174
175#### mobile UI polish & background task expansion (PRs #558-572, Dec 10-12)
176
177**background task expansion** (PRs #558, #561):
178- moved like/unlike and comment PDS writes to docket background tasks
179- API responses now immediate; PDS sync happens asynchronously
180- added targeted album list sync background task for ATProto record updates
181
182**performance caching** (PR #566):
183- added Redis cache for copyright label lookups (5-minute TTL)
184- fixed 2-3s latency spikes on `/tracks/` endpoint
185- batch operations via `mget`/pipeline for efficiency
186
187**mobile UX improvements** (PRs #569, #572):
188- mobile action menus now open from top with all actions visible
189- UI polish for album and artist pages on small screens
190
191**misc** (PRs #559, #562, #563, #570):
192- reduced docket Redis polling from 250ms to 5s (lower resource usage)
193- added atprotofans support link mode for ko-fi integration
194- added alpha badge to header branding
195- fixed web manifest ID for PWA stability
196
197---
198
199#### confidential OAuth client (PRs #578, #580-582, Dec 12-13)
200
201**confidential client support** (PR #578):
202- implemented ATProto OAuth confidential client using `private_key_jwt` authentication
203- when `OAUTH_JWK` is configured, plyr.fm authenticates with a cryptographic key
204- confidential clients earn 180-day refresh tokens (vs 2-week for public clients)
205- added `/.well-known/jwks.json` endpoint for public key discovery
206- updated `/oauth-client-metadata.json` with confidential client fields
207
208**bug fixes** (PRs #580-582):
209- fixed client assertion JWT to use Authorization Server's issuer as `aud` claim (not token endpoint URL)
210- fixed JWKS endpoint to preserve `kid` field from original JWK
211- fixed `OAuthClient` to pass `client_secret_kid` for JWT header
212
213**atproto fork updates** (zzstoatzz/atproto#6, #7):
214- added `issuer` parameter to `_make_token_request()` for correct `aud` claim
215- added `client_secret_kid` parameter to include `kid` in client assertion JWT header
216
217**outcome**: users now get 180-day refresh tokens, and "remember this account" on the PDS authorization page works (auto-approves subsequent logins). see #583 for future work on account switching via OAuth `prompt` parameter.
218
219---
220
221#### pagination & album management (PRs #550-554, Dec 9-10)
222
223**tracks list pagination** (PR #554):
224- cursor-based pagination on `/tracks/` endpoint (default 50 per page)
225- infinite scroll on homepage using native IntersectionObserver
226- zero new dependencies - uses browser APIs only
227- pagination state persisted to localStorage for fast subsequent loads
228
229**album management improvements** (PRs #550-552, #557):
230- album delete and track reorder fixes
231- album page edit mode matching playlist UX (inline title editing, cover upload)
232- optimistic UI updates for album title changes (instant feedback)
233- ATProto record sync when album title changes (updates all track records + list record)
234- fixed album slug sync on rename (prevented duplicate albums when adding tracks)
235
236**playlist show on profile** (PR #553):
237- restored "show on profile" toggle that was lost during inline editing refactor
238- users can now control whether playlists appear on their public profile
239
240---
241
242#### public cost dashboard (PRs #548-549, Dec 9)
243
244- `/costs` page showing live platform infrastructure costs
245- daily export to R2 via GitHub Action, proxied through `/stats/costs` endpoint
246- dedicated `plyr-stats` R2 bucket with public access (shared across environments)
247- includes fly.io, neon, cloudflare, and audd API costs
248- ko-fi integration for community support
249
250#### docket background tasks & concurrent exports (PRs #534-546, Dec 9)
251
252**docket integration** (PRs #534, #536, #539):
253- migrated background tasks from inline asyncio to docket (Redis-backed task queue)
254- copyright scanning, media export, ATProto sync, and teal scrobbling now run via docket
255- graceful fallback to asyncio for local development without Redis
256- parallel test execution with xdist template databases (#540)
257
258**concurrent export downloads** (PR #545):
259- exports now download tracks in parallel (up to 4 concurrent) instead of sequentially
260- significantly faster for users with many tracks or large files
261- zip creation remains sequential (zipfile constraint)
262
263**ATProto refactor** (PR #534):
264- reorganized ATProto record code into `_internal/atproto/records/` by lexicon namespace
265- extracted `client.py` for low-level PDS operations
266- cleaner separation between plyr.fm and teal.fm lexicons
267
268**documentation & observability**:
269- AudD API cost tracking dashboard (#546)
270- promoted runbooks from sandbox to `docs/runbooks/`
271- updated CLAUDE.md files across the codebase
272
273---
274
275#### artist support links & inline playlist editing (PRs #520-532, Dec 8)
276
277**artist support link** (PR #532):
278- artists can set a support URL (Ko-fi, Patreon, etc.) in their portal profile
279- support link displays as a button on artist profile pages next to the share button
280- URLs validated to require https:// prefix
281
282**inline playlist editing** (PR #531):
283- edit playlist name and description directly on playlist detail page
284- click-to-upload cover art replacement without modal
285- cleaner UX - no more edit modal popup
286
287**platform stats enhancements** (PRs #522, #528):
288- total duration displayed in platform stats (e.g., "42h 15m of music")
289- duration shown per artist in analytics section
290- combined stats and search into single centered container for cleaner layout
291
292**navigation & data loading fixes** (PR #527):
293- fixed stale data when navigating between detail pages of the same type
294- e.g., clicking from one artist to another now properly reloads data
295
296**copyright moderation improvements** (PR #480):
297- enhanced moderation workflow for copyright claims
298- improved labeler integration
299
300**status maintenance workflow** (PR #529):
301- automated status maintenance using claude-code-action
302- reviews merged PRs and updates STATUS.md narratively
303
304---
305
306#### playlist fast-follow fixes (PRs #507-519, Dec 7-8)
307
308**public playlist viewing** (PR #519):
309- playlists now publicly viewable without authentication
310- ATProto records are public by design - auth was unnecessary for read access
311- shared playlist URLs no longer redirect unauthenticated users to homepage
312
313**inline playlist creation** (PR #510):
314- clicking "create new playlist" from AddToMenu previously navigated to `/library?create=playlist`
315- this caused SvelteKit to reinitialize the layout, destroying the audio element and stopping playback
316- fix: added inline create form that creates playlist and adds track in one action without navigation
317
318**UI polish** (PRs #507-509, #515):
319- include `image_url` in playlist SSR data for og:image link previews
320- invalidate layout data after token exchange - fixes stale auth state after login
321- fixed stopPropagation blocking "create new playlist" link clicks
322- detail page button layouts: all buttons visible on mobile, centered AddToMenu on track detail
323- AddToMenu smart positioning: menu opens upward when near viewport bottom
324
325**documentation** (PR #514):
326- added lexicons overview documentation at `docs/lexicons/overview.md`
327- covers `fm.plyr.track`, `fm.plyr.like`, `fm.plyr.comment`, `fm.plyr.list`, `fm.plyr.actor.profile`
328
329---
330
331#### playlists, ATProto sync, and library hub (PR #499, Dec 6-7)
332
333**playlists** (full CRUD):
334- create, rename, delete playlists with cover art upload
335- add/remove/reorder tracks with drag-and-drop
336- playlist detail page with edit modal
337- "add to playlist" menu on tracks with inline create
338- playlist sharing with OpenGraph link previews
339
340**ATProto integration**:
341- `fm.plyr.list` lexicon for syncing playlists/albums to user PDSes
342- `fm.plyr.actor.profile` lexicon for artist profiles
343- automatic sync of albums, liked tracks, profile on login
344
345**library hub** (`/library`):
346- unified page with tabs: liked, playlists, albums
347- nav changed from "liked" → "library"
348
349**related**: scope upgrade OAuth flow (PR #503), settings consolidation (PR #496)
350
351---
352
353#### sensitive image moderation (PRs #471-488, Dec 5-6)
354
355- `sensitive_images` table flags problematic images
356- `show_sensitive_artwork` user preference
357- flagged images blurred everywhere: track lists, player, artist pages, search, embeds
358- Media Session API respects sensitive preference
359- SSR-safe filtering for og:image link previews
360
361---
362
363#### teal.fm scrobbling (PR #467, Dec 4)
364
365- native scrobbling to user's PDS using teal's ATProto lexicons
366- scrobble at 30% or 30 seconds (same threshold as play counts)
367- toggle in settings, link to pdsls.dev to view records
368
369---
370
371### Earlier December / November 2025
372
373See `.status_history/2025-12.md` and `.status_history/2025-11.md` for detailed history including:
374- unified search with Cmd+K (PR #447)
375- light/dark theme system (PR #441)
376- tag filtering and bufo easter egg (PRs #431-438)
377- developer tokens (PR #367)
378- copyright moderation system (PRs #382-395)
379- export & upload reliability (PRs #337-344)
380- transcoder API deployment (PR #156)
381
382## immediate priorities
383
384### end-of-year sprint (Dec 20-31)
385
386see [sprint tracking issue #625](https://github.com/zzstoatzz/plyr.fm/issues/625) for details.
387
388| track | focus | status |
389|-------|-------|--------|
390| moderation | consolidate architecture, add rules engine | planning |
391| atprotofans | supporter validation, content gating | planning |
392
393### known issues
394- playback auto-start on refresh (#225)
395- iOS PWA audio may hang on first play after backgrounding
396
397### backlog
398- audio transcoding pipeline integration (#153) - transcoder service deployed, integration deferred
399- share to bluesky (#334)
400- lyrics and annotations (#373)
401
402## technical state
403
404### architecture
405
406**backend**
407- language: Python 3.11+
408- framework: FastAPI with uvicorn
409- database: Neon PostgreSQL (serverless)
410- storage: Cloudflare R2 (S3-compatible)
411- background tasks: docket (Redis-backed)
412- hosting: Fly.io (2x shared-cpu VMs)
413- observability: Pydantic Logfire
414- auth: ATProto OAuth 2.1
415
416**frontend**
417- framework: SvelteKit (v2.43.2)
418- runtime: Bun
419- hosting: Cloudflare Pages
420- styling: vanilla CSS with lowercase aesthetic
421- state management: Svelte 5 runes
422
423**deployment**
424- ci/cd: GitHub Actions
425- backend: automatic on main branch merge (fly.io)
426- frontend: automatic on every push to main (cloudflare pages)
427- migrations: automated via fly.io release_command
428
429**what's working**
430
431**core functionality**
432- ✅ ATProto OAuth 2.1 authentication
433- ✅ secure session management via HttpOnly cookies
434- ✅ developer tokens with independent OAuth grants
435- ✅ platform stats and Media Session API
436- ✅ timed comments with clickable timestamps
437- ✅ artist profiles synced with Bluesky
438- ✅ track upload with streaming
439- ✅ audio streaming via 307 redirects to R2 CDN
440- ✅ play count tracking, likes, queue management
441- ✅ unified search with Cmd/Ctrl+K
442- ✅ teal.fm scrobbling
443- ✅ copyright moderation with ATProto labeler
444- ✅ docket background tasks (copyright scan, export, atproto sync, scrobble)
445- ✅ media export with concurrent downloads
446
447**albums**
448- ✅ album CRUD with cover art
449- ✅ ATProto list records (auto-synced on login)
450
451**playlists**
452- ✅ full CRUD with drag-and-drop reordering
453- ✅ ATProto list records (synced on create/modify)
454- ✅ "add to playlist" menu, global search results
455
456**deployment URLs**
457- production frontend: https://plyr.fm
458- production backend: https://api.plyr.fm
459- staging: https://stg.plyr.fm / https://api-stg.plyr.fm
460
461### technical decisions
462
463**why Python/FastAPI instead of Rust?**
464- rapid prototyping velocity during MVP phase
465- trade-off: accepting higher latency for faster development
466
467**why Cloudflare R2 instead of S3?**
468- zero egress fees (critical for audio streaming)
469- S3-compatible API, integrated CDN
470
471**why async everywhere?**
472- I/O-bound workload: most time spent waiting on network/disk
473- PRs #149-151 eliminated all blocking operations
474
475## cost structure
476
477current monthly costs: ~$18/month (plyr.fm specific)
478
479see live dashboard: [plyr.fm/costs](https://plyr.fm/costs)
480
481- fly.io (plyr apps only): ~$12/month
482 - relay-api (prod): $5.80
483 - relay-api-staging: $5.60
484 - plyr-moderation: $0.24
485 - plyr-transcoder: $0.02
486- neon postgres: $5/month
487- cloudflare (R2 + pages + domain): ~$1.16/month
488- audd audio fingerprinting: $0-10/month (6000 free/month)
489- logfire: $0 (free tier)
490
491## admin tooling
492
493### content moderation
494script: `scripts/delete_track.py`
495
496usage:
497```bash
498uv run scripts/delete_track.py <track_id> --dry-run
499uv run scripts/delete_track.py <track_id>
500uv run scripts/delete_track.py --url https://plyr.fm/track/34
501```
502
503## for new contributors
504
505### getting started
5061. clone: `gh repo clone zzstoatzz/plyr.fm`
5072. install dependencies: `uv sync && cd frontend && bun install`
5083. run backend: `uv run uvicorn backend.main:app --reload`
5094. run frontend: `cd frontend && bun run dev`
5105. visit http://localhost:5173
511
512### development workflow
5131. create issue on github
5142. create PR from feature branch
5153. ensure pre-commit hooks pass
5164. merge to main → deploys to staging
5175. create github release → deploys to production
518
519### key principles
520- type hints everywhere
521- lowercase aesthetic
522- ATProto first
523- async everywhere (no blocking I/O)
524- mobile matters
525- cost conscious
526
527### project structure
528```
529plyr.fm/
530├── backend/ # FastAPI app & Python tooling
531│ ├── src/backend/ # application code
532│ ├── tests/ # pytest suite
533│ └── alembic/ # database migrations
534├── frontend/ # SvelteKit app
535│ ├── src/lib/ # components & state
536│ └── src/routes/ # pages
537├── moderation/ # Rust moderation service (ATProto labeler)
538├── transcoder/ # Rust audio transcoding service
539├── docs/ # documentation
540└── justfile # task runner
541```
542
543## documentation
544
545- [docs/README.md](docs/README.md) - documentation index
546- [runbooks](docs/runbooks/) - production incident procedures
547- [background tasks](docs/backend/background-tasks.md) - docket task system
548- [logfire querying](docs/tools/logfire.md) - observability queries
549- [moderation & labeler](docs/moderation/atproto-labeler.md) - copyright, sensitive content
550- [lexicons overview](docs/lexicons/overview.md) - ATProto record schemas
551
552---
553
554this is a living document. last updated 2025-12-21.