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