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### January 2026
49
50#### multi-account experience (PRs #707, #710, #712-714, Jan 3-5)
51
52**why**: many users have multiple Bluesky identities (personal, artist, label). forcing re-authentication to switch was friction that discouraged uploads from secondary accounts.
53
54**users can now link multiple Bluesky accounts** to a single browser session:
55- add additional accounts via "add account" in user menu (triggers OAuth with `prompt=login`)
56- switch between linked accounts instantly without re-authenticating
57- logout from individual accounts or all at once
58- updated `/auth/me` returns `linked_accounts` array with avatars
59
60**backend changes**:
61- new `group_id` column on `user_sessions` links accounts together
62- new `pending_add_accounts` table tracks in-progress OAuth flows
63- new endpoints: `POST /auth/add-account/start`, `POST /auth/switch-account`, `POST /auth/logout-all`
64
65**infrastructure fixes** (PRs #710, #712, #714):
66these fixes came from reviewing [Bluesky's architecture deep dive](https://newsletter.pragmaticengineer.com/p/bluesky) which highlighted connection/resource management as scaling concerns. applied learnings to our own codebase:
67- identified Neon serverless connection overhead (~77ms per connection) via Logfire
68- cached `async_sessionmaker` per engine instead of recreating on every request (PR #712)
69- changed `_refresh_locks` from unbounded dict to LRUCache (10k max, 1hr TTL) to prevent memory leak (PR #710)
70- pass db session through auth helpers to reduce connections per request (PR #714)
71- result: `/auth/switch-account` ~1100ms → ~800ms, `/auth/me` ~940ms → ~720ms
72
73**frontend changes**:
74- UserMenu (desktop): collapsible accounts submenu with linked accounts, add account, logout all
75- ProfileMenu (mobile): dedicated accounts panel with avatars
76- fixed `invalidateAll()` not refreshing client-side loaded data by using `window.location.reload()` (PR #713)
77
78**docs**: [research/2026-01-03-multi-account-experience.md](docs/research/2026-01-03-multi-account-experience.md)
79
80---
81
82#### artist bio links (PRs #700-701, Jan 2)
83
84**links in artist bios now render as clickable** - supports full URLs and bare domains (e.g., "example.com"):
85- regex extracts URLs from bio text
86- bare domain/path URLs handled correctly
87- links open in new tab
88
89---
90
91#### copyright moderation improvements (PRs #703-704, Jan 2-3)
92
93**per legal advice**, redesigned copyright handling to reduce liability exposure:
94- **disabled auto-labeling** (PR #703): labels are no longer automatically emitted when copyright matches are detected. the system now only flags and notifies, leaving takedown decisions to humans
95- **raised threshold** (PR #703): copyright flag threshold increased from "any match" to configurable score (default 85%). controlled via `MODERATION_COPYRIGHT_SCORE_THRESHOLD` env var
96- **DM notifications** (PR #704): when a track is flagged, both the artist and admin receive BlueSky DMs with details. includes structured error handling for when users have DMs disabled
97- **observability** (PR #704): Logfire spans added to all notification paths (`send_dm`, `copyright_notification`) with error categorization (`dm_blocked`, `network`, `auth`, `unknown`)
98- **notification tracking**: `notified_at` field added to `copyright_scans` table to track which flags have been communicated
99
100**why this matters**: DMCA safe harbor requires taking action on notices, not proactively policing. auto-labeling was creating liability by making assertions about copyright status. human review is now required before any takedown action.
101
102---
103
104#### ATProto OAuth permission sets (PRs #697-698, Jan 1-2)
105
106**permission sets enabled** - OAuth now uses `include:fm.plyr.authFullApp` instead of listing individual `repo:` scopes:
107- users see clean "plyr.fm" permission title instead of raw collection names
108- permission set lexicon published to `com.atproto.lexicon.schema` on plyr.fm authority repo
109- DNS TXT records at `_lexicon.plyr.fm` and `_lexicon.stg.plyr.fm` link namespaces to authority DID
110- fixed scope validation in atproto SDK fork to handle PDS permission expansion (`include:` → `repo?collection=`)
111
112**why this matters**: permission sets are ATProto's mechanism for defining platform access tiers. enables future third-party integrations (mobile apps, read-only stats dashboards) to request semantic permission bundles instead of raw collection lists.
113
114**docs**: [lexicons/overview.md](docs/lexicons/overview.md), [research/2026-01-01-atproto-oauth-permission-sets.md](docs/research/2026-01-01-atproto-oauth-permission-sets.md)
115
116---
117
118#### atprotofans supporters display (PRs #695-696, Jan 1)
119
120**supporters now visible on artist pages** - artists using atprotofans can show their supporters:
121- compact overlapping avatar circles (GitHub sponsors style) with "+N" overflow badge
122- clicks link to supporter's plyr.fm artist page (keeps users in-app)
123- `POST /artists/batch` endpoint enriches supporter DIDs with avatar_url from our Artist table
124- frontend fetches from atprotofans, enriches via backend, renders with consistent avatar pattern
125
126**route ordering fix** (PR #696): FastAPI was matching `/artists/batch` as `/{did}` with did="batch". moved POST route before the catchall GET route.
127
128---
129
130#### UI polish (PRs #692-694, Dec 31 - Jan 1)
131
132- **feed/library toggle** (PR #692): consistent header layout with toggle between feed and library views
133- **shuffle button moved** (PR #693): shuffle now in queue component instead of player controls
134- **justfile consistency** (PR #694): standardized `just run` across frontend/backend modules
135
136---
137
138### December 2025
139
140#### header redesign (PR #691, Dec 31)
141
142**new header layout** with UserMenu dropdown and even spacing across the top bar.
143
144---
145
146#### automated image moderation (PRs #687-690, Dec 31)
147
148**Claude vision integration** for sensitive image detection:
149- images analyzed on upload via Claude Sonnet 4.5 (had to fix model ID - was using wrong identifier)
150- flagged images trigger DM notifications to admin
151- non-false-positive flags sent to batch review queue
152- complements the batch review system built earlier in the sprint
153
154---
155
156#### avatar sync on login (PR #685, Dec 31)
157
158**avatars now stay fresh** - previously set once at artist creation, causing stale/broken avatars throughout the app:
159- on login, avatar is refreshed from Bluesky and synced to both postgres and ATProto profile record
160- added `avatar` field to `fm.plyr.actor.profile` lexicon (optional, URI format)
161- one-time backfill script (`scripts/backfill_avatars.py`) refreshed 28 stale avatars in production
162
163---
164
165#### top tracks homepage (PR #684, Dec 31)
166
167**homepage now shows top tracks** - quick access to popular content for new visitors.
168
169---
170
171#### batch review system (PR #672, Dec 30)
172
173**moderation batch review UI** - mobile-friendly interface for reviewing flagged content:
174- filter by flag status, paginated results
175- auto-resolve flags for deleted tracks (PR #681)
176- full URL in DM notifications (PR #678)
177- required auth flow fix (PR #679) - review page was accessible without login
178
179---
180
181#### CSS design tokens (PRs #662-664, Dec 29-30)
182
183**design system foundations**:
184- border-radius tokens (`--radius-sm`, `--radius-md`, etc.)
185- typography scale tokens
186- consolidated form styles
187- documented in `docs/frontend/design-tokens.md`
188
189---
190
191#### self-hosted redis (PRs #674-675, Dec 30)
192
193**replaced Upstash with self-hosted Redis on Fly.io** - ~$75/month → ~$4/month:
194- Upstash pay-as-you-go was charging per command (37M commands = $75) - discovered when reviewing December costs
195- docket's heartbeat mechanism is chatty by design, making pay-per-command pricing unsuitable
196- self-hosted Redis on 256MB Fly VMs costs fixed ~$2/month per environment
197- deployed `plyr-redis` (prod) and `plyr-redis-stg` (staging)
198- added CI workflow for redis deployments on merge
199
200**no state migration needed** - docket stores ephemeral task queue data, job progress lives in postgres.
201
202**incident (Dec 30)**: while optimizing redis overhead, a `heartbeat_interval=30s` change broke docket task execution. likes created Dec 29-30 were missing ATProto records. reverted in PR #669, documented in `docs/backend/background-tasks.md`. filed upstream: https://github.com/chrisguidry/docket/issues/267
203
204---
205
206#### supporter-gated content (PR #637, Dec 22-23)
207
208**atprotofans paywall integration** - artists can now mark tracks as "supporters only":
209- tracks with `support_gate` require atprotofans validation before playback
210- non-supporters see lock icon and "become a supporter" CTA linking to atprotofans
211- artists can always play their own gated tracks
212
213**backend architecture**:
214- audio endpoint validates supporter status via atprotofans API before serving gated content
215- HEAD requests return 200/401/402 for pre-flight auth checks (avoids CORS issues with cross-origin redirects)
216- gated files stored in private R2 bucket, served via presigned URLs (SigV4 signatures)
217- `R2Storage.move_audio()` moves files between public/private buckets when toggling gate
218- background task handles bucket migration asynchronously
219- ATProto record syncs when toggling gate (updates `supportGate` field and `audioUrl` to point at our endpoint instead of R2)
220
221**frontend**:
222- `playback.svelte.ts` guards queue operations with gated checks BEFORE modifying state
223- clicking locked track shows toast with CTA - does NOT interrupt current playback
224- portal shows support gate toggle in track edit UI
225
226**key decision**: gated status is resolved server-side in track listings, not client-side. this means the lock icon appears instantly without additional API calls, and prevents information leakage about which tracks are gated vs which the user simply can't access.
227
228---
229
230#### supporter badges (PR #627, Dec 21-22)
231
232**phase 1 of atprotofans integration**:
233- supporter badge displays on artist pages when logged-in viewer supports the artist
234- calls atprotofans `validateSupporter` API directly from frontend (public endpoint)
235- badge only shows when viewer is authenticated and not viewing their own profile
236
237---
238
239#### rate limit moderation endpoint (PR #629, Dec 21)
240
241**incident response**: detected suspicious activity - 72 requests in 17 seconds from a single IP targeting `/moderation/sensitive-images`. added `10/minute` rate limit using existing slowapi infrastructure. this was the first real probe of our moderation endpoints, validating the decision to add rate limiting before it became a problem.
242
243---
244
245#### end-of-year sprint (PR #626, Dec 20)
246
247**focus**: two foundational systems with experimental implementations.
248
249| track | focus | status |
250|-------|-------|--------|
251| moderation | consolidate architecture, batch review, Claude vision | shipped |
252| atprotofans | supporter validation, content gating | shipped |
253
254**research docs**:
255- [moderation architecture overhaul](docs/research/2025-12-20-moderation-architecture-overhaul.md)
256- [atprotofans paywall integration](docs/research/2025-12-20-atprotofans-paywall-integration.md)
257
258---
259
260#### beartype + moderation cleanup (PRs #617-619, Dec 19)
261
262**runtime type checking** (PR #619):
263- enabled beartype runtime type validation across the backend
264- catches type errors at runtime instead of silently passing bad data
265- test infrastructure improvements: session-scoped TestClient fixture (5x faster tests)
266
267**moderation cleanup** (PRs #617-618):
268- consolidated moderation code, addressing issues #541-543
269- `sync_copyright_resolutions` now runs automatically via docket Perpetual task
270- removed dead `init_db()` from lifespan (handled by alembic migrations)
271
272---
273
274#### UX polish (PRs #604-607, #613, #615, Dec 16-18)
275
276**login improvements** (PRs #604, #613):
277- login page now uses "internet handle" terminology for clarity
278- input normalization: strips `@` and `at://` prefixes automatically
279
280**artist page fixes** (PR #615):
281- track pagination on artist pages now works correctly
282- fixed mobile album card overflow
283
284**mobile + metadata** (PRs #605-607):
285- Open Graph tags added to tag detail pages for link previews
286- mobile modals now use full screen positioning
287- fixed `/tag/` routes in hasPageMetadata check
288
289---
290
291#### offline mode foundation (PRs #610-611, Dec 17)
292
293**experimental offline playback**:
294- storage layer using Cache API for audio bytes + IndexedDB for metadata
295- `GET /audio/{file_id}/url` backend endpoint returns direct R2 URLs for client-side caching
296- "auto-download liked" toggle in experimental settings section
297- Player checks for cached audio before streaming from R2
298
299---
300
301### Earlier December 2025
302
303See `.status_history/2025-12.md` for detailed history including:
304- visual customization with custom backgrounds (PRs #595-596, Dec 16)
305- performance & moderation polish (PRs #586-593, Dec 14-15)
306- mobile UI polish & background task expansion (PRs #558-572, Dec 10-12)
307- confidential OAuth client for 180-day sessions (PRs #578-582, Dec 12-13)
308- pagination & album management (PRs #550-554, Dec 9-10)
309- public cost dashboard (PRs #548-549, Dec 9)
310- docket background tasks & concurrent exports (PRs #534-546, Dec 9)
311- artist support links & inline playlist editing (PRs #520-532, Dec 8)
312- playlist fast-follow fixes (PRs #507-519, Dec 7-8)
313- playlists, ATProto sync, and library hub (PR #499, Dec 6-7)
314- sensitive image moderation (PRs #471-488, Dec 5-6)
315- teal.fm scrobbling (PR #467, Dec 4)
316- unified search with Cmd+K (PR #447, Dec 3)
317- light/dark theme system (PR #441, Dec 2-3)
318- tag filtering and bufo easter egg (PRs #431-438, Dec 2)
319
320### November 2025
321
322See `.status_history/2025-11.md` for detailed history including:
323- developer tokens (PR #367)
324- copyright moderation system (PRs #382-395)
325- export & upload reliability (PRs #337-344)
326- transcoder API deployment (PR #156)
327
328## priorities
329
330### current focus
331
332stabilization and polish after multi-account release. monitoring production for issues.
333
334**end-of-year sprint [#625](https://github.com/zzstoatzz/plyr.fm/issues/625) shipped:**
335- moderation consolidation: sensitive images moved to moderation service (#644)
336- moderation batch review UI with Claude vision integration (#672, #687-690)
337- atprotofans: supporter badges (#627) and content gating (#637)
338
339### known issues
340- playback auto-start on refresh (#225)
341- iOS PWA audio may hang on first play after backgrounding
342
343### backlog
344- audio transcoding pipeline integration (#153) - transcoder service deployed, integration deferred
345- share to bluesky (#334)
346- lyrics and annotations (#373)
347- configurable rules engine for moderation
348- time-release gating (#642)
349
350## technical state
351
352### architecture
353
354**backend**
355- language: Python 3.11+
356- framework: FastAPI with uvicorn
357- database: Neon PostgreSQL (serverless)
358- storage: Cloudflare R2 (S3-compatible)
359- background tasks: docket (Redis-backed)
360- hosting: Fly.io (2x shared-cpu VMs)
361- observability: Pydantic Logfire
362- auth: ATProto OAuth 2.1
363
364**frontend**
365- framework: SvelteKit (v2.43.2)
366- runtime: Bun
367- hosting: Cloudflare Pages
368- styling: vanilla CSS with lowercase aesthetic
369- state management: Svelte 5 runes
370
371**deployment**
372- ci/cd: GitHub Actions
373- backend: automatic on main branch merge (fly.io)
374- frontend: automatic on every push to main (cloudflare pages)
375- migrations: automated via fly.io release_command
376
377**what's working**
378
379**core functionality**
380- ✅ ATProto OAuth 2.1 authentication
381- ✅ multi-account support (link multiple Bluesky accounts)
382- ✅ secure session management via HttpOnly cookies
383- ✅ developer tokens with independent OAuth grants
384- ✅ platform stats and Media Session API
385- ✅ timed comments with clickable timestamps
386- ✅ artist profiles synced with Bluesky
387- ✅ track upload with streaming
388- ✅ audio streaming via 307 redirects to R2 CDN
389- ✅ play count tracking, likes, queue management
390- ✅ unified search with Cmd/Ctrl+K
391- ✅ teal.fm scrobbling
392- ✅ copyright moderation with ATProto labeler
393- ✅ docket background tasks (copyright scan, export, atproto sync, scrobble)
394- ✅ media export with concurrent downloads
395- ✅ supporter-gated content via atprotofans
396
397**albums**
398- ✅ album CRUD with cover art
399- ✅ ATProto list records (auto-synced on login)
400
401**playlists**
402- ✅ full CRUD with drag-and-drop reordering
403- ✅ ATProto list records (synced on create/modify)
404- ✅ "add to playlist" menu, global search results
405
406**deployment URLs**
407- production frontend: https://plyr.fm
408- production backend: https://api.plyr.fm
409- staging: https://stg.plyr.fm / https://api-stg.plyr.fm
410
411### technical decisions
412
413**why Python/FastAPI instead of Rust?**
414- rapid prototyping velocity during MVP phase
415- trade-off: accepting higher latency for faster development
416
417**why Cloudflare R2 instead of S3?**
418- zero egress fees (critical for audio streaming)
419- S3-compatible API, integrated CDN
420
421**why async everywhere?**
422- I/O-bound workload: most time spent waiting on network/disk
423- PRs #149-151 eliminated all blocking operations
424
425## cost structure
426
427current monthly costs: ~$20/month (plyr.fm specific)
428
429see live dashboard: [plyr.fm/costs](https://plyr.fm/costs)
430
431- fly.io (backend + redis + moderation): ~$14/month
432- neon postgres: $5/month
433- cloudflare (R2 + pages + domain): ~$1/month
434- audd audio fingerprinting: $5-10/month (usage-based)
435- logfire: $0 (free tier)
436
437## admin tooling
438
439### content moderation
440script: `scripts/delete_track.py`
441
442usage:
443```bash
444uv run scripts/delete_track.py <track_id> --dry-run
445uv run scripts/delete_track.py <track_id>
446uv run scripts/delete_track.py --url https://plyr.fm/track/34
447```
448
449## for new contributors
450
451### getting started
4521. clone: `gh repo clone zzstoatzz/plyr.fm`
4532. install dependencies: `uv sync && cd frontend && bun install`
4543. run backend: `uv run uvicorn backend.main:app --reload`
4554. run frontend: `cd frontend && bun run dev`
4565. visit http://localhost:5173
457
458### development workflow
4591. create issue on github
4602. create PR from feature branch
4613. ensure pre-commit hooks pass
4624. merge to main → deploys to staging
4635. create github release → deploys to production
464
465### key principles
466- type hints everywhere
467- lowercase aesthetic
468- ATProto first
469- async everywhere (no blocking I/O)
470- mobile matters
471- cost conscious
472
473### project structure
474```
475plyr.fm/
476├── backend/ # FastAPI app & Python tooling
477│ ├── src/backend/ # application code
478│ ├── tests/ # pytest suite
479│ └── alembic/ # database migrations
480├── frontend/ # SvelteKit app
481│ ├── src/lib/ # components & state
482│ └── src/routes/ # pages
483├── moderation/ # Rust moderation service (ATProto labeler)
484├── transcoder/ # Rust audio transcoding service
485├── redis/ # self-hosted Redis config
486├── docs/ # documentation
487└── justfile # task runner
488```
489
490## documentation
491
492- [docs/README.md](docs/README.md) - documentation index
493- [runbooks](docs/runbooks/) - production incident procedures
494- [background tasks](docs/backend/background-tasks.md) - docket task system
495- [logfire querying](docs/tools/logfire.md) - observability queries
496- [moderation & labeler](docs/moderation/atproto-labeler.md) - copyright, sensitive content
497- [lexicons overview](docs/lexicons/overview.md) - ATProto record schemas
498
499---
500
501this is a living document. last updated 2026-01-05.