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#### copyright moderation improvements (PRs #703-704, Jan 2)
51
52**per legal advice**, redesigned copyright handling to reduce liability exposure:
53- **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
54- **raised threshold** (PR #703): copyright flag threshold increased from "any match" to configurable score (default 85%). controlled via `MODERATION_COPYRIGHT_SCORE_THRESHOLD` env var
55- **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
56- **observability** (PR #704): Logfire spans added to all notification paths (`send_dm`, `copyright_notification`) with error categorization (`dm_blocked`, `network`, `auth`, `unknown`)
57- **notification tracking**: `notified_at` field added to `copyright_scans` table to track which flags have been communicated
58
59**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.
60
61---
62
63#### ATProto OAuth permission sets (PRs #697-698, Jan 1-2)
64
65**permission sets enabled** - OAuth now uses `include:fm.plyr.authFullApp` instead of listing individual `repo:` scopes:
66- users see clean "plyr.fm" permission title instead of raw collection names
67- permission set lexicon published to `com.atproto.lexicon.schema` on plyr.fm authority repo
68- DNS TXT records at `_lexicon.plyr.fm` and `_lexicon.stg.plyr.fm` link namespaces to authority DID
69- fixed scope validation in atproto SDK fork to handle PDS permission expansion (`include:` → `repo?collection=`)
70
71**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.
72
73**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)
74
75---
76
77#### atprotofans supporters display (PRs #695-696, Jan 1)
78
79**supporters now visible on artist pages** - artists using atprotofans can show their supporters:
80- compact overlapping avatar circles (GitHub sponsors style) with "+N" overflow badge
81- clicks link to supporter's plyr.fm artist page (keeps users in-app)
82- `POST /artists/batch` endpoint enriches supporter DIDs with avatar_url from our Artist table
83- frontend fetches from atprotofans, enriches via backend, renders with consistent avatar pattern
84
85**route ordering fix** (PR #696): FastAPI was matching `/artists/batch` as `/{did}` with did="batch". moved POST route before the catchall GET route.
86
87---
88
89#### UI polish (PRs #692-694, Dec 31 - Jan 1)
90
91- **feed/library toggle** (PR #692): consistent header layout with toggle between feed and library views
92- **shuffle button moved** (PR #693): shuffle now in queue component instead of player controls
93- **justfile consistency** (PR #694): standardized `just run` across frontend/backend modules
94
95---
96
97### December 2025
98
99#### avatar sync on login (PR #685, Dec 31)
100
101**avatars now stay fresh** - previously set once at artist creation, causing stale/broken avatars throughout the app:
102- on login, avatar is refreshed from Bluesky and synced to both postgres and ATProto profile record
103- added `avatar` field to `fm.plyr.actor.profile` lexicon (optional, URI format)
104- one-time backfill script (`scripts/backfill_avatars.py`) refreshed 28 stale avatars in production
105
106---
107
108#### self-hosted redis (PR #674-675, Dec 30)
109
110**replaced Upstash with self-hosted Redis on Fly.io** - ~$75/month → ~$4/month:
111- Upstash pay-as-you-go was charging per command (37M commands = $75)
112- self-hosted Redis on 256MB Fly VMs costs fixed ~$2/month per environment
113- deployed `plyr-redis` (prod) and `plyr-redis-stg` (staging)
114- added CI workflow for redis deployments on merge
115
116**no state migration needed** - docket stores ephemeral task queue data, job progress lives in postgres.
117
118---
119
120#### supporter-gated content (PR #637, Dec 22-23)
121
122**atprotofans paywall integration** - artists can now mark tracks as "supporters only":
123- tracks with `support_gate` require atprotofans validation before playback
124- non-supporters see lock icon and "become a supporter" CTA linking to atprotofans
125- artists can always play their own gated tracks
126
127**backend architecture**:
128- audio endpoint validates supporter status via atprotofans API before serving gated content
129- HEAD requests return 200/401/402 for pre-flight auth checks (avoids CORS issues)
130- `R2Storage.move_audio()` moves files between public/private buckets when toggling gate
131- background task handles bucket migration asynchronously
132- ATProto record syncs when toggling gate (updates `supportGate` field and `audioUrl`)
133
134**frontend**:
135- `playback.svelte.ts` guards queue operations with gated checks BEFORE modifying state
136- clicking locked track shows toast with CTA - does NOT interrupt current playback
137- portal shows support gate toggle in track edit UI
138
139---
140
141#### supporter badges (PR #627, Dec 21-22)
142
143**phase 1 of atprotofans integration**:
144- supporter badge displays on artist pages when logged-in viewer supports the artist
145- calls atprotofans `validateSupporter` API directly from frontend (public endpoint)
146- badge only shows when viewer is authenticated and not viewing their own profile
147
148---
149
150#### rate limit moderation endpoint (PR #629, Dec 21)
151
152**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.
153
154---
155
156#### end-of-year sprint planning (PR #626, Dec 20)
157
158**focus**: two foundational systems need solid experimental implementations by 2026.
159
160| track | focus | status |
161|-------|-------|--------|
162| moderation | consolidate architecture, add rules engine | in progress |
163| atprotofans | supporter validation, content gating | shipped (phase 1-3) |
164
165**research docs**:
166- [moderation architecture overhaul](docs/research/2025-12-20-moderation-architecture-overhaul.md)
167- [atprotofans paywall integration](docs/research/2025-12-20-atprotofans-paywall-integration.md)
168
169---
170
171#### beartype + moderation cleanup (PRs #617-619, Dec 19)
172
173**runtime type checking** (PR #619):
174- enabled beartype runtime type validation across the backend
175- catches type errors at runtime instead of silently passing bad data
176- test infrastructure improvements: session-scoped TestClient fixture (5x faster tests)
177
178**moderation cleanup** (PRs #617-618):
179- consolidated moderation code, addressing issues #541-543
180- `sync_copyright_resolutions` now runs automatically via docket Perpetual task
181- removed dead `init_db()` from lifespan (handled by alembic migrations)
182
183---
184
185#### UX polish (PRs #604-607, #613, #615, Dec 16-18)
186
187**login improvements** (PRs #604, #613):
188- login page now uses "internet handle" terminology for clarity
189- input normalization: strips `@` and `at://` prefixes automatically
190
191**artist page fixes** (PR #615):
192- track pagination on artist pages now works correctly
193- fixed mobile album card overflow
194
195**mobile + metadata** (PRs #605-607):
196- Open Graph tags added to tag detail pages for link previews
197- mobile modals now use full screen positioning
198- fixed `/tag/` routes in hasPageMetadata check
199
200---
201
202#### offline mode foundation (PRs #610-611, Dec 17)
203
204**experimental offline playback**:
205- storage layer using Cache API for audio bytes + IndexedDB for metadata
206- `GET /audio/{file_id}/url` backend endpoint returns direct R2 URLs for client-side caching
207- "auto-download liked" toggle in experimental settings section
208- Player checks for cached audio before streaming from R2
209
210---
211
212### Earlier December 2025
213
214See `.status_history/2025-12.md` for detailed history including:
215- visual customization with custom backgrounds (PRs #595-596, Dec 16)
216- performance & moderation polish (PRs #586-593, Dec 14-15)
217- mobile UI polish & background task expansion (PRs #558-572, Dec 10-12)
218- confidential OAuth client for 180-day sessions (PRs #578-582, Dec 12-13)
219- pagination & album management (PRs #550-554, Dec 9-10)
220- public cost dashboard (PRs #548-549, Dec 9)
221- docket background tasks & concurrent exports (PRs #534-546, Dec 9)
222- artist support links & inline playlist editing (PRs #520-532, Dec 8)
223- playlist fast-follow fixes (PRs #507-519, Dec 7-8)
224- playlists, ATProto sync, and library hub (PR #499, Dec 6-7)
225- sensitive image moderation (PRs #471-488, Dec 5-6)
226- teal.fm scrobbling (PR #467, Dec 4)
227- unified search with Cmd+K (PR #447, Dec 3)
228- light/dark theme system (PR #441, Dec 2-3)
229- tag filtering and bufo easter egg (PRs #431-438, Dec 2)
230
231### November 2025
232
233See `.status_history/2025-11.md` for detailed history including:
234- developer tokens (PR #367)
235- copyright moderation system (PRs #382-395)
236- export & upload reliability (PRs #337-344)
237- transcoder API deployment (PR #156)
238
239## immediate priorities
240
241### quality of life mode (Dec 29-31)
242
243end-of-year sprint [#625](https://github.com/zzstoatzz/plyr.fm/issues/625) complete. remaining days before 2026 are for minor polish and bug fixes as they arise.
244
245**what shipped in the sprint:**
246- moderation consolidation: sensitive images moved to moderation service (#644)
247- atprotofans: supporter badges (#627) and content gating (#637)
248
249**aspirational (deferred until scale justifies):**
250- configurable rules engine for moderation
251- time-release gating (#642)
252
253### known issues
254- playback auto-start on refresh (#225)
255- iOS PWA audio may hang on first play after backgrounding
256
257### backlog
258- audio transcoding pipeline integration (#153) - transcoder service deployed, integration deferred
259- share to bluesky (#334)
260- lyrics and annotations (#373)
261
262## technical state
263
264### architecture
265
266**backend**
267- language: Python 3.11+
268- framework: FastAPI with uvicorn
269- database: Neon PostgreSQL (serverless)
270- storage: Cloudflare R2 (S3-compatible)
271- background tasks: docket (Redis-backed)
272- hosting: Fly.io (2x shared-cpu VMs)
273- observability: Pydantic Logfire
274- auth: ATProto OAuth 2.1
275
276**frontend**
277- framework: SvelteKit (v2.43.2)
278- runtime: Bun
279- hosting: Cloudflare Pages
280- styling: vanilla CSS with lowercase aesthetic
281- state management: Svelte 5 runes
282
283**deployment**
284- ci/cd: GitHub Actions
285- backend: automatic on main branch merge (fly.io)
286- frontend: automatic on every push to main (cloudflare pages)
287- migrations: automated via fly.io release_command
288
289**what's working**
290
291**core functionality**
292- ✅ ATProto OAuth 2.1 authentication
293- ✅ secure session management via HttpOnly cookies
294- ✅ developer tokens with independent OAuth grants
295- ✅ platform stats and Media Session API
296- ✅ timed comments with clickable timestamps
297- ✅ artist profiles synced with Bluesky
298- ✅ track upload with streaming
299- ✅ audio streaming via 307 redirects to R2 CDN
300- ✅ play count tracking, likes, queue management
301- ✅ unified search with Cmd/Ctrl+K
302- ✅ teal.fm scrobbling
303- ✅ copyright moderation with ATProto labeler
304- ✅ docket background tasks (copyright scan, export, atproto sync, scrobble)
305- ✅ media export with concurrent downloads
306- ✅ supporter-gated content via atprotofans
307
308**albums**
309- ✅ album CRUD with cover art
310- ✅ ATProto list records (auto-synced on login)
311
312**playlists**
313- ✅ full CRUD with drag-and-drop reordering
314- ✅ ATProto list records (synced on create/modify)
315- ✅ "add to playlist" menu, global search results
316
317**deployment URLs**
318- production frontend: https://plyr.fm
319- production backend: https://api.plyr.fm
320- staging: https://stg.plyr.fm / https://api-stg.plyr.fm
321
322### technical decisions
323
324**why Python/FastAPI instead of Rust?**
325- rapid prototyping velocity during MVP phase
326- trade-off: accepting higher latency for faster development
327
328**why Cloudflare R2 instead of S3?**
329- zero egress fees (critical for audio streaming)
330- S3-compatible API, integrated CDN
331
332**why async everywhere?**
333- I/O-bound workload: most time spent waiting on network/disk
334- PRs #149-151 eliminated all blocking operations
335
336## cost structure
337
338current monthly costs: ~$20/month (plyr.fm specific)
339
340see live dashboard: [plyr.fm/costs](https://plyr.fm/costs)
341
342- fly.io (backend + redis + moderation): ~$14/month
343- neon postgres: $5/month
344- cloudflare (R2 + pages + domain): ~$1/month
345- audd audio fingerprinting: $5-10/month (usage-based)
346- logfire: $0 (free tier)
347
348## admin tooling
349
350### content moderation
351script: `scripts/delete_track.py`
352
353usage:
354```bash
355uv run scripts/delete_track.py <track_id> --dry-run
356uv run scripts/delete_track.py <track_id>
357uv run scripts/delete_track.py --url https://plyr.fm/track/34
358```
359
360## for new contributors
361
362### getting started
3631. clone: `gh repo clone zzstoatzz/plyr.fm`
3642. install dependencies: `uv sync && cd frontend && bun install`
3653. run backend: `uv run uvicorn backend.main:app --reload`
3664. run frontend: `cd frontend && bun run dev`
3675. visit http://localhost:5173
368
369### development workflow
3701. create issue on github
3712. create PR from feature branch
3723. ensure pre-commit hooks pass
3734. merge to main → deploys to staging
3745. create github release → deploys to production
375
376### key principles
377- type hints everywhere
378- lowercase aesthetic
379- ATProto first
380- async everywhere (no blocking I/O)
381- mobile matters
382- cost conscious
383
384### project structure
385```
386plyr.fm/
387├── backend/ # FastAPI app & Python tooling
388│ ├── src/backend/ # application code
389│ ├── tests/ # pytest suite
390│ └── alembic/ # database migrations
391├── frontend/ # SvelteKit app
392│ ├── src/lib/ # components & state
393│ └── src/routes/ # pages
394├── moderation/ # Rust moderation service (ATProto labeler)
395├── transcoder/ # Rust audio transcoding service
396├── redis/ # self-hosted Redis config
397├── docs/ # documentation
398└── justfile # task runner
399```
400
401## documentation
402
403- [docs/README.md](docs/README.md) - documentation index
404- [runbooks](docs/runbooks/) - production incident procedures
405- [background tasks](docs/backend/background-tasks.md) - docket task system
406- [logfire querying](docs/tools/logfire.md) - observability queries
407- [moderation & labeler](docs/moderation/atproto-labeler.md) - copyright, sensitive content
408- [lexicons overview](docs/lexicons/overview.md) - ATProto record schemas
409
410---
411
412this is a living document. last updated 2026-01-02.