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.