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#### teal.fm scrobbling integration (PR #467, Dec 4)
51
52**what shipped**:
53- native teal.fm scrobbling: when users enable the toggle, plays are recorded to their PDS using teal's ATProto lexicons
54- scrobble triggers at 30% or 30 seconds (whichever comes first) - same threshold as play counts
55- user preference stored in database, toggleable from portal → "your data"
56- settings link to pdsls.dev so users can view their scrobble records
57
58**lexicons used**:
59- `fm.teal.alpha.feed.play` - individual play records (scrobbles)
60- `fm.teal.alpha.actor.status` - now-playing status updates
61
62**configuration** (all optional, sensible defaults):
63- `TEAL_ENABLED` (default: `true`) - feature flag for entire integration
64- `TEAL_PLAY_COLLECTION` (default: `fm.teal.alpha.feed.play`)
65- `TEAL_STATUS_COLLECTION` (default: `fm.teal.alpha.actor.status`)
66
67**code quality improvements** (same PR):
68- added `settings.frontend.domain` computed property for environment-aware URLs
69- extracted `get_session_id_from_request()` utility for bearer token parsing
70- added field validator on `DeveloperTokenInfo.session_id` for auto-truncation
71- applied walrus operators throughout auth and playback code
72- fixed now-playing endpoint firing every 1 second (fingerprint update bug in scheduled reports)
73
74**documentation**: `backend/src/backend/_internal/atproto/teal.py` contains inline docs on the scrobbling flow
75
76---
77
78#### unified search (PR #447, Dec 3)
79
80**what shipped**:
81- `Cmd+K` (mac) / `Ctrl+K` (windows/linux) opens search modal from anywhere
82- fuzzy matching across tracks, artists, albums, and tags using PostgreSQL `pg_trgm`
83- results grouped by type with relevance scores (0.0-1.0)
84- keyboard navigation (arrow keys, enter, esc)
85- artwork/avatars displayed with lazy loading and fallback icons
86- glassmorphism modal styling with backdrop blur
87- debounced input (150ms) with client-side validation
88
89**database**:
90- enabled `pg_trgm` extension for trigram-based similarity search
91- GIN indexes on `tracks.title`, `artists.handle`, `artists.display_name`, `albums.title`, `tags.name`
92
93**documentation**: `docs/frontend/search.md`, `docs/frontend/keyboard-shortcuts.md`
94
95**follow-up polish** (PRs #449-463):
96- mobile search icon in header (PRs #455-456)
97- theme-aware modal styling with styled scrollbar (#450)
98- ILIKE fallback for substring matches when trigram fails (#452)
99- tag collapse with +N button (#453)
100- input focus fix: removed `visibility: hidden` so focus works on open (#457, #463)
101- album artwork fallback in player when track has no image (#458)
102- rate limiting exemption for now-playing endpoints (#460)
103- `--no-dev` flag for release command to prevent dev dep installation (#461)
104
105---
106
107#### light/dark theme and mobile UX overhaul (Dec 2-3)
108
109**theme system** (PR #441):
110- replaced hardcoded colors across 35 files with CSS custom properties
111- semantic tokens: `--bg-primary`, `--text-secondary`, `--accent`, etc.
112- theme switcher in settings: dark / light / system (follows OS preference)
113- removed zen mode feature (superseded by proper theme support)
114
115**mobile UX improvements** (PR #443):
116- new `ProfileMenu` component — collapses profile, upload, settings, logout into touch-optimized menu (44px tap targets)
117- dedicated `/upload` page — extracted from portal for cleaner mobile flow
118- portal overhaul — tighter forms, track detail links under artwork, fixed icon alignment
119- standardized section headers across home and liked tracks pages
120
121**player scroll timing fix** (PR #445):
122- reduced title scroll cycle from 10s → 8s, artist/album from 15s → 10s
123- eliminated 1.5s invisible pause at end of scroll animation
124- fixed duplicate upload toast (was firing twice on success)
125- upload success toast now includes "view track" link
126
127**CI optimization** (PR #444):
128- pre-commit hooks now skip based on changed paths
129- result: ~10s for most PRs instead of ~1m20s
130- only installs tooling (uv, bun) needed for changed directories
131
132---
133
134#### tag filtering system and SDK tag support (Dec 2)
135
136**tag filtering** (PRs #431-434):
137- users can now hide tracks by tag via eye icon filter in discovery feed
138- preferences centralized in root layout (fetched once, shared across app)
139- `HiddenTagsFilter` component with expandable UI for managing hidden tags
140- default hidden tags: `["ai"]` for new users
141- tag detail pages at `/tag/[name]` with all tracks for that tag
142- clickable tag badges on tracks navigate to tag pages
143
144**navigation fix** (PR #435):
145- fixed tag links interrupting audio playback
146- root cause: `stopPropagation()` on links breaks SvelteKit's client-side router
147- documented pattern in `docs/frontend/navigation.md` to prevent recurrence
148
149**SDK tag support** (plyr-python-client v0.0.1-alpha.10):
150- added `tags: set[str]` parameter to `upload()` in SDK
151- added `-t/--tag` CLI option (can be used multiple times)
152- updated MCP `upload_guide` prompt with tag examples
153- status maintenance workflow now tags AI-generated podcasts with `ai` (#436)
154
155**tags in detail pages** (PR #437):
156- track detail endpoint (`/tracks/{id}`) now returns tags
157- album detail endpoint (`/albums/{handle}/{slug}`) now returns tags for all tracks
158- track detail page displays clickable tag badges
159
160**bufo easter egg** (PR #438):
161- tracks tagged with `bufo` trigger animated toad GIFs on the detail page
162- uses track title as semantic search query against [find-bufo API](https://find-bufo.fly.dev/)
163- toads are semantically matched to the song's vibe (e.g., "Happy Vibes" gets happy toads)
164- results cached in localStorage (1 week TTL) to minimize API calls
165- `TagEffects` wrapper component provides extensibility for future tag-based plugins
166- respects `prefers-reduced-motion`; fails gracefully if API unavailable
167
168---
169
170#### queue touch reordering and header stats fix (Dec 2)
171
172**queue mobile UX** (PR #428):
173- added 6-dot drag handle to queue items for touch-friendly reordering
174- implemented touch event handlers for mobile drag-and-drop
175- track follows finger during drag with smooth translateY transform
176- drop target highlights while dragging over other tracks
177
178**header stats positioning** (PR #426):
179- fixed platform stats not adjusting when queue sidebar opens/closes
180- added `--queue-width` CSS custom property updated dynamically
181- stats now shift left with smooth transition when queue opens
182
183---
184
185#### connection pool resilience for Neon cold starts (Dec 2)
186
187**incident**: ~5 minute API outage (01:55-02:00 UTC) - all requests returned 500 errors
188
189**root cause**: Neon serverless cold start after 5 minutes of idle traffic
190- queue listener heartbeat detected dead connection, began reconnection
191- first 5 user requests each held a connection waiting for Neon to wake up (3-5 min each)
192- with pool_size=5 and max_overflow=0, pool exhausted immediately
193- all subsequent requests got `QueuePool limit of size 5 overflow 0 reached`
194
195**fix**:
196- increased `pool_size` from 5 → 10 (handle more concurrent cold start requests)
197- increased `max_overflow` from 0 → 5 (allow burst to 15 connections)
198- increased `connection_timeout` from 3s → 10s (wait for Neon wake-up)
199
200**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.
201
202---
203
204#### now-playing API (PR #416, Dec 1)
205
206**what shipped**:
207- `GET /now-playing/{did}` and `GET /now-playing/by-handle/{handle}` endpoints
208- returns track metadata, playback position, timestamp
209- 204 when nothing playing, 200 with track data otherwise
210
211**teal.fm integration**:
212- native scrobbling shipped in PR #467 (Dec 4) - plyr.fm writes directly to user's PDS
213- Piper integration (external polling) still open: https://github.com/teal-fm/piper/pull/27
214
215---
216
217#### admin UI improvements for moderation (PRs #408-414, Dec 1)
218
219**what shipped**:
220- dropdown menu for false positive reasons (fingerprint noise, original artist, fair use, other)
221- artist/track links open in new tabs for verification
222- AuDD score normalization (scores shown as 0-100 range)
223- filter controls to show only high-confidence matches
224- form submission fixes for htmx POST requests
225
226---
227
228#### ATProto labeler and copyright moderation (PRs #382-395, Nov 29-Dec 1)
229
230**what shipped**:
231- standalone labeler service integrated into moderation Rust service
232- implements `com.atproto.label.queryLabels` and `subscribeLabels` XRPC endpoints
233- k256 ECDSA signing for cryptographic label verification
234- web interface at `/admin` for reviewing copyright flags
235- htmx for server-rendered interactivity
236- integrates with AuDD enterprise API for audio fingerprinting
237- fire-and-forget background task on track upload
238- review workflow with resolution tracking (violation, false_positive, original_artist)
239
240**initial review results** (25 flagged tracks):
241- 8 violations (actual copyright issues)
242- 11 false positives (fingerprint noise)
243- 6 original artists (people uploading their own distributed music)
244
245**documentation**: see `docs/moderation/atproto-labeler.md`
246
247---
248
249#### developer tokens with independent OAuth grants (PR #367, Nov 28)
250
251**what shipped**:
252- each developer token gets its own OAuth authorization flow
253- tokens have their own DPoP keypair, access/refresh tokens - completely separate from browser session
254- cookie isolation: dev token exchange doesn't set browser cookie
255- token management UI: portal → "your data" → "developer tokens"
256- create with optional name and expiration (30/90/180/365 days or never)
257
258**security properties**:
259- tokens are full sessions with encrypted OAuth credentials (Fernet)
260- each token refreshes independently
261- revokable individually without affecting browser or other tokens
262
263---
264
265#### platform stats and media session integration (PRs #359-379, Nov 27-29)
266
267**what shipped**:
268- `GET /stats` returns total plays, tracks, and artists
269- stats bar displays in homepage header (e.g., "1,691 plays • 55 tracks • 8 artists")
270- Media Session API for CarPlay, lock screens, Bluetooth devices
271- browser tab title shows "track - artist • plyr.fm" while playing
272- timed comments with clickable timestamps
273- constellation integration for network-wide like counts
274- account deletion with explicit confirmation
275
276---
277
278#### export & upload reliability (PRs #337-344, Nov 24)
279
280**what shipped**:
281- database-backed jobs (moved tracking from in-memory to postgres)
282- streaming exports (fixed OOM on large file exports)
283- 90-minute WAV files now export successfully on 1GB VM
284- upload progress bar fixes
285- export filename now includes date
286
287---
288
289### October-November 2025
290
291See `.status_history/2025-11.md` for detailed November development history including:
292- async I/O performance fixes (PRs #149-151)
293- transcoder API deployment (PR #156)
294- upload streaming + progress UX (PR #182)
295- liked tracks feature (PR #157)
296- track detail pages (PR #164)
297- mobile UI improvements (PRs #159-185)
298- oEmbed endpoint for Leaflet.pub embeds (PRs #355-358)
299
300## immediate priorities
301
302### high priority features
3031. **audio transcoding pipeline integration** (issue #153)
304 - ✅ standalone transcoder service deployed at https://plyr-transcoder.fly.dev/
305 - ⏳ next: integrate into plyr.fm upload pipeline
306 - backend calls transcoder API for unsupported formats
307 - queue-based job system for async processing
308 - R2 integration (fetch original, store MP3)
309
310### known issues
311- playback auto-start on refresh (#225) - investigating localStorage/queue state persistence
312- no ATProto records for albums yet (#221 - consciously deferred)
313- no AIFF/AIF transcoding support (#153)
314- 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.
315
316### new features
317- issue #146: content-addressable storage (hash-based deduplication)
318- issue #155: add track metadata (genres, tags, descriptions)
319- issue #334: add 'share to bluesky' option for tracks
320- issue #373: lyrics field and Genius-style annotations
321- issue #393: moderation - represent confirmed takedown state in labeler
322
323## technical state
324
325### architecture
326
327**backend**
328- language: Python 3.11+
329- framework: FastAPI with uvicorn
330- database: Neon PostgreSQL (serverless)
331- storage: Cloudflare R2 (S3-compatible)
332- hosting: Fly.io (2x shared-cpu VMs)
333- observability: Pydantic Logfire
334- auth: ATProto OAuth 2.1
335
336**frontend**
337- framework: SvelteKit (v2.43.2)
338- runtime: Bun
339- hosting: Cloudflare Pages
340- styling: vanilla CSS with lowercase aesthetic
341- state management: Svelte 5 runes
342
343**deployment**
344- ci/cd: GitHub Actions
345- backend: automatic on main branch merge (fly.io)
346- frontend: automatic on every push to main (cloudflare pages)
347- migrations: automated via fly.io release_command
348
349**what's working**
350
351**core functionality**
352- ✅ ATProto OAuth 2.1 authentication with encrypted state
353- ✅ secure session management via HttpOnly cookies
354- ✅ developer tokens with independent OAuth grants
355- ✅ platform stats endpoint and homepage display
356- ✅ Media Session API for CarPlay, lock screens, control center
357- ✅ timed comments on tracks with clickable timestamps
358- ✅ account deletion with explicit confirmation
359- ✅ artist profiles synced with Bluesky
360- ✅ track upload with streaming to prevent OOM
361- ✅ track edit/deletion with cascade cleanup
362- ✅ audio streaming via HTML5 player with 307 redirects to R2 CDN
363- ✅ track metadata published as ATProto records
364- ✅ play count tracking (30% or 30s threshold)
365- ✅ like functionality with counts
366- ✅ queue management (shuffle, auto-advance, reorder)
367- ✅ mobile-optimized responsive UI
368- ✅ cross-tab queue synchronization via BroadcastChannel
369- ✅ share tracks via URL with Open Graph previews
370- ✅ copyright moderation system with admin UI
371- ✅ ATProto labeler for copyright violations
372- ✅ unified search with Cmd/Ctrl+K (fuzzy matching via pg_trgm)
373- ✅ teal.fm scrobbling (records plays to user's PDS)
374
375**albums**
376- ✅ album database schema with track relationships
377- ✅ album browsing and detail pages
378- ✅ album cover art upload and display
379- ✅ server-side rendering for SEO
380- ⏸ ATProto records for albums (deferred, see issue #221)
381
382**deployment (fully automated)**
383- **production**:
384 - frontend: https://plyr.fm
385 - backend: https://relay-api.fly.dev → https://api.plyr.fm
386 - database: neon postgresql
387 - storage: cloudflare R2 (audio-prod and images-prod buckets)
388
389- **staging**:
390 - backend: https://api-stg.plyr.fm
391 - frontend: https://stg.plyr.fm
392 - database: neon postgresql (relay-staging)
393 - storage: cloudflare R2 (audio-stg bucket)
394
395### technical decisions
396
397**why Python/FastAPI instead of Rust?**
398- rapid prototyping velocity during MVP phase
399- rich ecosystem for web APIs
400- excellent async support with asyncio
401- trade-off: accepting higher latency for faster development
402
403**why Cloudflare R2 instead of S3?**
404- zero egress fees (critical for audio streaming)
405- S3-compatible API (easy migration if needed)
406- integrated CDN for fast delivery
407
408**why forked atproto SDK?**
409- upstream SDK lacked OAuth 2.1 support
410- needed custom record management patterns
411- maintains compatibility with ATProto spec
412
413**why async everywhere?**
414- event loop performance: single-threaded async handles high concurrency
415- I/O-bound workload: most time spent waiting on network/disk
416- PRs #149-151 eliminated all blocking operations
417
418## cost structure
419
420current monthly costs: ~$35-40/month
421
422- fly.io backend (production): ~$5/month
423- fly.io backend (staging): ~$5/month
424- fly.io transcoder: ~$0-5/month (auto-scales to zero)
425- neon postgres: $5/month
426- audd audio fingerprinting: ~$10/month
427- cloudflare pages: $0 (free tier)
428- cloudflare R2: ~$0.16/month
429- logfire: $0 (free tier)
430- domain: $12/year (~$1/month)
431
432## deployment URLs
433
434- **production frontend**: https://plyr.fm
435- **production backend**: https://api.plyr.fm
436- **staging backend**: https://api-stg.plyr.fm
437- **staging frontend**: https://stg.plyr.fm
438- **repository**: https://github.com/zzstoatzz/plyr.fm (private)
439- **monitoring**: https://logfire-us.pydantic.dev/zzstoatzz/relay
440- **bluesky**: https://bsky.app/profile/plyr.fm
441
442## admin tooling
443
444### content moderation
445script: `scripts/delete_track.py`
446- requires `ADMIN_*` prefixed environment variables
447- deletes audio file, cover image, database record
448- notes ATProto records for manual cleanup
449
450usage:
451```bash
452uv run scripts/delete_track.py <track_id> --dry-run
453uv run scripts/delete_track.py <track_id>
454uv run scripts/delete_track.py --url https://plyr.fm/track/34
455```
456
457## for new contributors
458
459### getting started
4601. clone: `gh repo clone zzstoatzz/plyr.fm`
4612. install dependencies: `uv sync && cd frontend && bun install`
4623. run backend: `uv run uvicorn backend.main:app --reload`
4634. run frontend: `cd frontend && bun run dev`
4645. visit http://localhost:5173
465
466### development workflow
4671. create issue on github
4682. create PR from feature branch
4693. ensure pre-commit hooks pass
4704. merge to main → deploys to staging automatically
4715. verify on staging
4726. create github release → deploys to production automatically
473
474### key principles
475- type hints everywhere
476- lowercase aesthetic
477- ATProto first
478- async everywhere (no blocking I/O)
479- mobile matters
480- cost conscious
481
482### project structure
483```
484plyr.fm/
485├── backend/ # FastAPI app & Python tooling
486│ ├── src/backend/ # application code
487│ │ ├── api/ # public endpoints
488│ │ ├── _internal/ # internal services
489│ │ ├── models/ # database schemas
490│ │ └── storage/ # storage adapters
491│ ├── tests/ # pytest suite
492│ └── alembic/ # database migrations
493├── frontend/ # SvelteKit app
494│ ├── src/lib/ # components & state
495│ └── src/routes/ # pages
496├── moderation/ # Rust moderation service (ATProto labeler)
497│ ├── src/ # Axum handlers, AuDD client, label signing
498│ └── static/ # admin UI (html/css/js)
499├── transcoder/ # Rust audio transcoding service
500├── docs/ # documentation
501└── justfile # task runner
502```
503
504## documentation
505
506- [deployment overview](docs/deployment/overview.md)
507- [configuration guide](docs/configuration.md)
508- [queue design](docs/queue-design.md)
509- [logfire querying](docs/logfire-querying.md)
510- [moderation & labeler](docs/moderation/atproto-labeler.md)
511- [unified search](docs/frontend/search.md)
512- [keyboard shortcuts](docs/frontend/keyboard-shortcuts.md)
513
514---
515
516this is a living document. last updated 2025-12-05.