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.