audio streaming app
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### March 2026
49
50#### embed glow bar + share button (PRs #996-998, Mar 1)
51
52**glow bar**: 1px accent-colored bar (`#6a9fff`) on track and collection embeds that lights up on playback and dims on pause, matching the main Player's `::before` style. uses `color-mix()` for the box-shadow glow. works across all container query breakpoints.
53
54**share button**: inline link icon next to the logo that copies the plyr.fm page URL (not the embed URL) to clipboard with "copied!" tooltip feedback. falls back to `navigator.share()` when clipboard API is unavailable. no auth dependency. hidden in MICRO mode, white-themed in blurred-bg modes (NARROW, SQUARE/TALL).
55
56**embed layout fixes** (PRs #996-997): fixed track embed clipping at short heights, guarded collection WIDE query, and fixed track embed layout for tall/portrait containers.
57
58---
59
60### February 2026
61
62#### image thumbnails + storage cleanup (PRs #976-979, Feb 27)
63
64**96x96 WebP thumbnails for artwork**: track artwork and avatars display at 48px but full-resolution images (potentially megabytes) were being served. now generates a 96x96 WebP thumbnail (2x retina) on upload, stored as `images/{file_id}_thumb.webp` alongside the original. Pillow handles center-crop, LANCZOS resize, WebP encode. nullable `thumbnail_url` column on tracks, albums, and playlists. frontend falls back to `image_url` when `thumbnail_url` is null, so partially-backfilled states are safe. `generate_and_save()` helper wired into all image upload paths: track uploads, album covers, playlist covers, and track metadata edits.
65
66**storage protocol**: new `StorageProtocol` with `@runtime_checkable` formalizes the R2Storage contract. `build_image_url()` constructs public URLs without HEAD checks (caller knows the image exists). `save_thumbnail()` uploads WebP data to the image bucket. storage proxy typed as `StorageProtocol` in `__init__.py`. export_tasks decoupled from `settings.storage` — uses `storage.audio_bucket_name` instead.
67
68**backfill script**: `scripts/backfill_thumbnails.py` follows the embeddings backfill pattern (`--dry-run`, `--limit`, `--concurrency`). queries tracks/albums/playlists where `image_id IS NOT NULL AND thumbnail_url IS NULL`, downloads originals via httpx, generates thumbnails, uploads to R2, updates DB rows.
69
709 thumbnail tests, 5 storage protocol/regression tests. 533 total tests pass.
71
72---
73
74#### jam polish + feature flag graduations (PRs #963-975, Feb 25-27)
75
76**jam UX fixes** (PRs #963-964): eliminated the "no output" state — auto-claim output when nobody has it. restructured jam header UI.
77
78**feature flag removals** (PRs #965, #969): jams and PDS audio uploads graduated to GA — available to all users without flags.
79
80**data fix** (PR #966): `support_gate` JSONB null vs SQL NULL — gated tracks were invisible to backfill queries because `IS NULL` doesn't match JSONB `null`. fixed with `none_as_null=True`.
81
82**loading state polish** (PR #972): fade transitions and `prefers-reduced-motion` support across loading states.
83
84**network artists perf** (PRs #970, #973-975): Bluesky follow graph cached in Redis. parallelized network artists fetch with other homepage data. module-level cache persists across navigations. fixed auth race where fetch fired before session was ready.
85
86---
87
88#### unified queue/jam architecture + output device (PRs #949-960, Feb 19-25)
89
90**jams — shared listening rooms (PR #949)**: real-time shared listening rooms. one user creates a jam, gets a shareable code (`plyr.fm/jam/a1b2c3d4`), and anyone with the link can join. all participants control playback. `Jam` and `JamParticipant` models with partial indexes. `JamService` singleton manages lifecycle, WebSocket connections, and Redis Streams fan-out. playback state is server-authoritative — JSONB with monotonic revision counter. server-timestamp + client interpolation for sync. reconnect replays missed events via `XRANGE`, falls back to full DB snapshot if trimmed. personal queue preserved and restored on leave. gated behind `jams` feature flag. see `docs/architecture/jams.md`.
91
92**output device — single-speaker mode (PR #953)**: one participant's browser plays audio, everyone else is a remote control. `output_client_id` / `output_did` in jam state with `set_output` command. auto-set to host on first WS sync. output clears + pauses when the output device disconnects or leaves. "play here" button transfers audio to any participant. fixed three browser-level playback bugs during integration: autoplay policy (WS round-trip broke user gesture context), audio event fight (drift correction seeking triggered pause/play loop), and output transfer (old device didn't stop audio on transfer).
93
94**jam queue unification (PR #960)**: a jam is just a shared queue — the backend shouldn't reimplement queue manipulation. replaced ~100 lines of duplicated backend queue logic (`next`, `previous`, `add_tracks`, `remove_track`, `move_track`, `clear_upcoming`, `play_track`, `set_index`) with a single `update_queue` command. frontend does all mutation locally (same code path for solo and jam), then pushes the resulting state. `JamBridge` simplified from 11 methods to 4 (`pushQueueState`, `play`, `pause`, `seek`). enables `setQueue`, `clear`, and `playNow` in jams for free. net -189 lines.
95
96**reliability fixes**: deepcopy jam state to prevent shallow-copy clobber — `dict(jam.state)` shared nested list references, so in-place mutations went undetected by SQLAlchemy (PR #959). prevented personal queue fetch from overwriting jam state (PR #952). surfaced backend error detail on jam join failure (PR #951).
97
9842 backend tests covering lifecycle, all commands, output device, cross-client sync, revision monotonicity, flag gating.
99
100---
101
102#### ATProto spec-compliant scope parsing (PRs #955, #957, Feb 24)
103
104replaced naive set-subset scope checking with `ScopesSet` from the atproto SDK. handles the full ATProto permission grammar: positional/query format equivalence (`repo:nsid` == `repo?collection=nsid`), wildcard matching (`repo:*`), action filtering, and MIME patterns for blob scopes. follow-up fix for `include:` scope expansion — PDS servers expand `include:ns.permSet` into granular `repo:`/`rpc:` scopes, so the granted scope never contains the literal `include:` token. was causing 403 `scope_upgrade_required` for all sessions on staging. fix checks namespace authority via `IncludeScope.is_parent_authority_of()` instead of exact string match. 21 scope tests.
105
106---
107
108#### persist playback position (PR #948, Feb 19)
109
110playback position survives page reloads and session restores. `progress_ms` stored in `QueueState` JSON (zero backend changes — backend stores and returns the dict verbatim). Player syncs `currentTime` → `queue.progressMs` via `$effect`. on page close/hide, `flushSync()` pushes with `keepalive: true` so the fetch survives page teardown. on restore, `loadeddata` handler seeks to saved position (skips if near end of track). 30s periodic save for crash resilience.
111
112---
113
114#### copyright DM fix (PRs #941-942, Feb 16-17)
115
116upload notification DMs were incorrectly going to artists when copyright flags were raised. stopped DMing artists about copyright flags, restored admin-only DM notification so copyright issues go to the right people.
117
118---
119
120#### hidden tag filter autocomplete (PR #945, Feb 18)
121
122the homepage hidden tag filter's "add tag" input now has autocomplete. typing a partial tag name fetches matching tags from `GET /tracks/tags?q=...` (same endpoint the portal tag editor uses) with a 200ms debounce. suggestions appear in a compact glass-effect dropdown showing tag name and track count. supports keyboard navigation (arrow keys to cycle, enter to select, escape to close) and mouse selection. tags already in the hidden list are filtered out of suggestions. frontend-only change.
123
124---
125
126#### supporter avatar fallback (PR #943, Feb 17)
127
128atprotofans supporter avatars on artist profiles (e.g. goose.art) showed only initial letters for supporters who have Bluesky accounts but haven't used plyr.fm. root cause: `POST /artists/batch` only returns DIDs in our database, so non-plyr.fm supporters got no `avatar_url`. fix: fall back to constructing a Bluesky CDN URL from the atprotofans API's avatar blob data (`avatar.ref.$link` CID → `cdn.bsky.app/img/avatar/plain/{did}/{cid}@jpeg`). frontend-only change.
129
130---
131
132#### liked tracks empty state fix (PRs #938-939, Feb 17)
133
134both `/liked` and `/u/[handle]/liked` showed redundant headings when the track list was empty — the section header ("liked tracks" / "no liked tracks") duplicated the empty state message below it. moved section headers inside the tracks-exist branch so only the empty state (heart icon + contextual message) renders when there are no likes.
135
136---
137
138#### Dockerfile fix + album caching + session caching (PRs #930-935, Feb 16-17)
139
140**production stability fix (PR #935)**: `uv run` in the Dockerfile CMD was triggering dependency resolution on every cold start, downloading from PyPI inside the Fly network. when PyPI connections failed (connection reset), the process exited, Fly restarted it, and the machine eventually hit the 10-restart limit and died permanently — leaving only one machine to serve all traffic. fix: `--no-sync` flag tells `uv run` to use the pre-installed venv without any runtime resolution.
141
142**album detail caching (PRs #933-934)**: `GET /albums/{handle}/{slug}` averaged 745ms with outliers at 5-7s due to Neon cold compute + uncached PDS calls. added Redis read-through cache on the full `AlbumResponse` (5-min TTL, keyed by handle/slug). per-user `is_liked` state zeroed out before caching to prevent leaking between users. explicit invalidation on all mutation paths: album CRUD, track CRUD, list reorder. follow-up PR #934 fixed three gaps caught in review: reorder not invalidating, same-album metadata edits not invalidating, and delete invalidating before commit (race condition).
143
144**session cache expiry fix (PR #932)**: Redis session cache from PR #930 was returning expired sessions — the cache read skipped the `expires_at` check. fix: validate expiry on cache hits, delete and fall through to DB on stale entries.
145
146**session caching (PR #930)**: Redis read-through cache for `get_session()` to reduce Neon cold-start latency on auth checks. 5-min TTL with invalidation on session mutations.
147
148---
149
150#### homepage quality pass + likers bottom sheet (PRs #913-927, Feb 16)
151
152**top tracks redesign**: the homepage "top tracks" section now uses horizontal `TrackCard` components (row layout with 48px artwork, title/artist links, play/like counts) inside a scroll-snap container. cards use the same `--track-*` glass design tokens as `TrackItem` for visual consistency. scroll-snap with `x proximity` gives gentle anchoring without fighting the user.
153
154**likers bottom sheet**: hover tooltips for showing who liked a track were fundamentally broken on mobile — `position: fixed` gets trapped by ancestor `transform`/`transition` containing blocks inside `overflow-x: auto` scroll containers. replaced with a bottom sheet on mobile (slides up from bottom, renders at root level in `+layout.svelte` to escape all overflow/stacking contexts). desktop keeps the hover tooltip. the `(max-width: 768px)` breakpoint gates the behavior, matching the rest of the app. applied consistently across all three locations: `TrackCard`, `TrackItem`, and the track detail page.
155
156**"artists you know" section** (PRs #910-912, #927): new homepage section showing artists from your Bluesky follow graph. backend endpoint `GET /discover/network` cross-references follows with artists who have tracks on plyr.fm, ordered by follow age (oldest first). avatar refresh integration added after discovering stale DB URLs were preferred over fresh Bluesky URLs — flipped the `or` preference so the live follow-graph avatar wins.
157
158---
159
160#### oEmbed + collection embeds (PRs #903-909, Feb 13-14)
161
162**oEmbed support**: tracks, playlists, and albums now return oEmbed JSON for rich link previews. iframe embed player redesigned for collections — inline header with artwork, now-playing title links to source, narrow mode for small embeds.
163
164**misc fixes**: "ai-slop" added to default hidden tags filter. "create new playlist" CTA hoisted above existing playlists in picker. button text wrapping fixed.
165
166---
167
168See `.status_history/2026-02.md` for Feb 2-12 history including:
169- playlist track recommendations via CLAP embeddings (PRs #895-898)
170- main.py extraction + bug fixes (PRs #890-894)
171- OAuth permission set cleanup + docs audit (PRs #888-889)
172- auth state refresh + backend package split (PRs #886-887)
173- portal pagination + perf optimization (PRs #878-879)
174- repo reorganization (PR #876)
175- auto-tag at upload + ML audit (PRs #870-872)
176- ML genre classification + suggested tags (PRs #864-868)
177- mood search via CLAP + turbopuffer (PRs #848-858)
178- recommended tags via audio similarity (PR #859)
179- mobile login UX + misc fixes (PRs #841-845)
180
181---
182
183### January 2026
184
185See `.status_history/2026-01.md` for detailed history.
186
187### December 2025
188
189See `.status_history/2025-12.md` for detailed history including:
190- header redesign and UI polish (PRs #691-693, Dec 31)
191- automated image moderation with Claude vision (PRs #687-690, Dec 31)
192- avatar sync on login (PR #685, Dec 31)
193- top tracks homepage (PR #684, Dec 31)
194- batch review system (PR #672, Dec 30)
195- CSS design tokens (PRs #662-664, Dec 29-30)
196- self-hosted redis migration (PRs #674-675, Dec 30)
197- supporter-gated content (PR #637, Dec 22-23)
198- supporter badges (PR #627, Dec 21-22)
199- end-of-year sprint: moderation + atprotofans (PRs #617-629, Dec 19-21)
200- offline mode foundation (PRs #610-611, Dec 17)
201- UX polish and login improvements (PRs #604-615, Dec 16-18)
202- visual customization with custom backgrounds (PRs #595-596, Dec 16)
203- performance & moderation polish (PRs #586-593, Dec 14-15)
204- mobile UI polish & background task expansion (PRs #558-572, Dec 10-12)
205- confidential OAuth client for 180-day sessions (PRs #578-582, Dec 12-13)
206- pagination & album management (PRs #550-554, Dec 9-10)
207- public cost dashboard (PRs #548-549, Dec 9)
208- docket background tasks & concurrent exports (PRs #534-546, Dec 9)
209- artist support links & inline playlist editing (PRs #520-532, Dec 8)
210- playlist fast-follow fixes (PRs #507-519, Dec 7-8)
211- playlists, ATProto sync, and library hub (PR #499, Dec 6-7)
212- sensitive image moderation (PRs #471-488, Dec 5-6)
213- teal.fm scrobbling (PR #467, Dec 4)
214- unified search with Cmd+K (PR #447, Dec 3)
215- light/dark theme system (PR #441, Dec 2-3)
216- tag filtering and bufo easter egg (PRs #431-438, Dec 2)
217
218### November 2025
219
220See `.status_history/2025-11.md` for detailed history including:
221- developer tokens (PR #367)
222- copyright moderation system (PRs #382-395)
223- export & upload reliability (PRs #337-344)
224- transcoder API deployment (PR #156)
225
226## priorities
227
228### current focus
229
230jams shipped to all users — feature flag removed, output device mode (single-speaker) working. image performance: 96x96 WebP thumbnails for all artwork with storage protocol abstraction and backfill script. PDS audio uploads graduated to GA. homepage performance improved with Redis-cached follow graph and parallelized network artists fetch. ATProto scope parsing replaced with spec-compliant SDK implementation.
231
232### known issues
233- iOS PWA audio may hang on first play after backgrounding
234- audio may persist after closing bluesky in-app browser on iOS ([#779](https://github.com/zzstoatzz/plyr.fm/issues/779)) - user reported audio and lock screen controls continue after dismissing SFSafariViewController. expo-web-browser has a [known fix](https://github.com/expo/expo/issues/22406) that calls `dismissBrowser()` on close, and bluesky uses a version with the fix, but it didn't help in this case. we [opened an upstream issue](https://github.com/expo/expo/issues/42454) then closed it as duplicate after finding prior art. root cause unclear - may be iOS version specific or edge case timing issue.
235
236### backlog
237- share to bluesky (#334)
238- lyrics and annotations (#373)
239- configurable rules engine for moderation (Osprey rules engine PR #958 open)
240- time-release gating (#642)
241- social activity feed (#971)
242
243## technical state
244
245### architecture
246
247**backend**
248- language: Python 3.11+
249- framework: FastAPI with uvicorn
250- database: Neon PostgreSQL (serverless)
251- storage: Cloudflare R2 (S3-compatible)
252- background tasks: docket (Redis-backed)
253- hosting: Fly.io (2x shared-cpu VMs)
254- observability: Pydantic Logfire
255- auth: ATProto OAuth 2.1
256
257**frontend**
258- framework: SvelteKit (v2.43.2)
259- runtime: Bun
260- hosting: Cloudflare Pages
261- styling: vanilla CSS with lowercase aesthetic
262- state management: Svelte 5 runes
263
264**deployment**
265- ci/cd: GitHub Actions
266- backend: automatic on main branch merge (fly.io)
267- frontend: automatic on every push to main (cloudflare pages)
268- migrations: automated via fly.io release_command
269
270**what's working**
271
272**core functionality**
273- ✅ ATProto OAuth 2.1 authentication
274- ✅ multi-account support (link multiple ATProto identities)
275- ✅ secure session management via HttpOnly cookies
276- ✅ developer tokens with independent OAuth grants
277- ✅ platform stats and Media Session API
278- ✅ timed comments with clickable timestamps
279- ✅ artist profiles synced with Bluesky
280- ✅ track upload with streaming
281- ✅ audio streaming via 307 redirects to R2 CDN
282- ✅ lossless audio (AIFF/FLAC) with automatic transcoding for browser compatibility
283- ✅ PDS blob storage for audio (user data ownership)
284- ✅ play count tracking, likes, queue management
285- ✅ unified search with Cmd/Ctrl+K (keyword + mood search in parallel)
286- ✅ mood search via CLAP embeddings + turbopuffer (feature-flagged)
287- ✅ teal.fm scrobbling
288- ✅ copyright moderation with ATProto labeler
289- ✅ ML genre classification with suggested tags in edit modal + auto-tag at upload (Replicate effnet-discogs)
290- ✅ docket background tasks (copyright scan, export, atproto sync, scrobble, genre classification)
291- ✅ media export with concurrent downloads
292- ✅ supporter-gated content via atprotofans
293- ✅ listen receipts (tracked share links with visitor/listener stats)
294- ✅ jams — shared listening rooms with real-time sync via Redis Streams + WebSocket
295- ✅ 96x96 WebP thumbnails for artwork (track, album, playlist)
296
297**albums**
298- ✅ album CRUD with cover art
299- ✅ ATProto list records (auto-synced on login)
300
301**playlists**
302- ✅ full CRUD with drag-and-drop reordering
303- ✅ ATProto list records (synced on create/modify)
304- ✅ "add to playlist" menu, global search results
305- ✅ inline track recommendations when editing (CLAP embeddings + adaptive RRF/k-means)
306
307**deployment URLs**
308- production frontend: https://plyr.fm
309- production backend: https://api.plyr.fm
310- staging: https://stg.plyr.fm / https://api-stg.plyr.fm
311
312### technical decisions
313
314**why Python/FastAPI instead of Rust?**
315- rapid prototyping velocity during MVP phase
316- trade-off: accepting higher latency for faster development
317
318**why Cloudflare R2 instead of S3?**
319- zero egress fees (critical for audio streaming)
320- S3-compatible API, integrated CDN
321
322**why async everywhere?**
323- I/O-bound workload: most time spent waiting on network/disk
324- PRs #149-151 eliminated all blocking operations
325
326## cost structure
327
328current monthly costs: ~$20/month (plyr.fm specific)
329
330see live dashboard: [plyr.fm/costs](https://plyr.fm/costs)
331
332- fly.io (backend + redis + moderation): ~$14/month
333- neon postgres: $5/month
334- cloudflare (R2 + pages + domain): ~$1/month
335- audd audio fingerprinting: $5-10/month (usage-based)
336- replicate (genre classification): <$1/month (scales to zero, ~$0.00019/run)
337- logfire: $0 (free tier)
338
339## admin tooling
340
341### content moderation
342script: `scripts/delete_track.py`
343
344usage:
345```bash
346uv run scripts/delete_track.py <track_id> --dry-run
347uv run scripts/delete_track.py <track_id>
348uv run scripts/delete_track.py --url https://plyr.fm/track/34
349```
350
351## for new contributors
352
353### getting started
3541. clone: `gh repo clone zzstoatzz/plyr.fm`
3552. install dependencies: `uv sync && cd frontend && bun install`
3563. run backend: `uv run uvicorn backend.main:app --reload`
3574. run frontend: `cd frontend && bun run dev`
3585. visit http://localhost:5173
359
360### development workflow
3611. create issue on github
3622. create PR from feature branch
3633. ensure pre-commit hooks pass
3644. merge to main → deploys to staging
3655. create github release → deploys to production
366
367### key principles
368- type hints everywhere
369- lowercase aesthetic
370- ATProto first
371- async everywhere (no blocking I/O)
372- mobile matters
373- cost conscious
374
375### project structure
376```
377plyr.fm/
378├── backend/ # FastAPI app & Python tooling
379│ ├── src/backend/ # application code
380│ ├── tests/ # pytest suite
381│ └── alembic/ # database migrations
382├── frontend/ # SvelteKit app
383│ ├── src/lib/ # components & state
384│ └── src/routes/ # pages
385├── services/
386│ ├── transcoder/ # Rust audio transcoding (Fly.io)
387│ ├── moderation/ # Rust content moderation (Fly.io)
388│ └── clap/ # ML embeddings (Python, Modal)
389├── infrastructure/
390│ └── redis/ # self-hosted Redis (Fly.io)
391├── docs/ # documentation
392└── justfile # task runner
393```
394
395## documentation
396
397- [docs/README.md](docs/README.md) - documentation index
398- [runbooks](docs/runbooks/) - production incident procedures
399- [background tasks](docs/backend/background-tasks.md) - docket task system
400- [logfire querying](docs/tools/logfire.md) - observability queries
401- [moderation & labeler](docs/moderation/atproto-labeler.md) - copyright, sensitive content
402- [lexicons overview](docs/lexicons/overview.md) - ATProto record schemas
403
404---
405
406this is a living document. last updated 2026-03-01 (embed glow bar + share button, embed layout fixes).
407