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