plyr.fm - status#
long-term vision#
the problem#
today's music streaming is fundamentally broken:
- spotify and apple music trap your data in proprietary silos
- artists pay distribution fees and streaming cuts to multiple gatekeepers
- listeners can't own their music collections - they rent them
- switching platforms means losing everything: playlists, play history, social connections
the atproto solution#
plyr.fm is built on the AT Protocol (the protocol powering Bluesky) and enables:
- portable identity: your music collection, playlists, and listening history belong to you, stored in your personal data server (PDS)
- decentralized distribution: artists publish directly to the network without platform gatekeepers
- interoperable data: any client can read your music records - you're not locked into plyr.fm
- authentic social: artist profiles are real ATProto identities with verifiable handles (@artist.bsky.social)
the dream state#
plyr.fm should become:
-
for artists: the easiest way to publish music to the decentralized web
- upload once, available everywhere in the ATProto network
- direct connection to listeners without platform intermediaries
- real ownership of audience relationships
-
for listeners: a streaming platform where you actually own your data
- your collection lives in your PDS, playable by any ATProto music client
- switch between plyr.fm and other clients freely - your data travels with you
- share tracks as native ATProto posts to Bluesky
-
for developers: a reference implementation showing how to build on ATProto
- open source end-to-end example of ATProto integration
- demonstrates OAuth, record creation, federation patterns
- proves decentralized music streaming is viable
started: October 28, 2025 (first commit: 454e9bc - relay MVP with ATProto authentication)
recent work#
December 2025#
offline mode foundation (PRs #610-611, Dec 17)#
experimental offline playback:
- new storage layer using Cache API for audio bytes + IndexedDB for metadata
GET /audio/{file_id}/urlbackend endpoint returns direct R2 URLs for client-side caching- "auto-download liked" toggle in experimental settings section
- when enabled, bulk-downloads all liked tracks and auto-downloads future likes
- Player checks for cached audio before streaming from R2
- works offline once tracks are downloaded
robustness improvements:
- IndexedDB connections properly closed after each operation
- concurrent downloads deduplicated via in-flight promise tracking
- stale metadata cleanup when cache entries are missing
visual customization (PRs #595-596, Dec 16)#
custom backgrounds (PR #595):
- users can set a custom background image URL in settings with optional tiling
- new "playing artwork as background" toggle - uses current track's artwork as blurred page background
- glass effect styling for track items (translucent backgrounds, subtle shadows)
- new
ui_settingsJSONB column in preferences for extensible UI settings
bug fix (PR #596):
- removed 3D wheel scroll effect that was blocking like/share button clicks
- root cause:
translateZtransforms created z-index stacking that intercepted pointer events
performance & UX polish (PRs #586-593, Dec 14-15)#
performance improvements (PRs #590-591):
- removed moderation service call from
/tracks/listing endpoint - removed copyright check from tag listing endpoint
- faster page loads for track feeds
moderation agent (PRs #586, #588):
- added moderation agent script with audit trail support
- improved moderation prompt and UI layout
bug fixes (PRs #589, #592, #593):
- fixed liked state display on playlist detail page
- preserved album track order during ATProto sync
- made header sticky on scroll for better mobile navigation
iOS Safari fixes (PRs #573-576):
- fixed AddToMenu visibility issue on iOS Safari
- menu now correctly opens upward when near viewport bottom
mobile UI polish & background task expansion (PRs #558-572, Dec 10-12)#
background task expansion (PRs #558, #561):
- moved like/unlike and comment PDS writes to docket background tasks
- API responses now immediate; PDS sync happens asynchronously
- added targeted album list sync background task for ATProto record updates
performance caching (PR #566):
- added Redis cache for copyright label lookups (5-minute TTL)
- fixed 2-3s latency spikes on
/tracks/endpoint - batch operations via
mget/pipeline for efficiency
mobile UX improvements (PRs #569, #572):
- mobile action menus now open from top with all actions visible
- UI polish for album and artist pages on small screens
misc (PRs #559, #562, #563, #570):
- reduced docket Redis polling from 250ms to 5s (lower resource usage)
- added atprotofans support link mode for ko-fi integration
- added alpha badge to header branding
- fixed web manifest ID for PWA stability
confidential OAuth client (PRs #578, #580-582, Dec 12-13)#
confidential client support (PR #578):
- implemented ATProto OAuth confidential client using
private_key_jwtauthentication - when
OAUTH_JWKis configured, plyr.fm authenticates with a cryptographic key - confidential clients earn 180-day refresh tokens (vs 2-week for public clients)
- added
/.well-known/jwks.jsonendpoint for public key discovery - updated
/oauth-client-metadata.jsonwith confidential client fields
bug fixes (PRs #580-582):
- fixed client assertion JWT to use Authorization Server's issuer as
audclaim (not token endpoint URL) - fixed JWKS endpoint to preserve
kidfield from original JWK - fixed
OAuthClientto passclient_secret_kidfor JWT header
atproto fork updates (zzstoatzz/atproto#6, #7):
- added
issuerparameter to_make_token_request()for correctaudclaim - added
client_secret_kidparameter to includekidin client assertion JWT header
outcome: users now get 180-day refresh tokens, and "remember this account" on the PDS authorization page works (auto-approves subsequent logins). see #583 for future work on account switching via OAuth prompt parameter.
pagination & album management (PRs #550-554, Dec 9-10)#
tracks list pagination (PR #554):
- cursor-based pagination on
/tracks/endpoint (default 50 per page) - infinite scroll on homepage using native IntersectionObserver
- zero new dependencies - uses browser APIs only
- pagination state persisted to localStorage for fast subsequent loads
album management improvements (PRs #550-552, #557):
- album delete and track reorder fixes
- album page edit mode matching playlist UX (inline title editing, cover upload)
- optimistic UI updates for album title changes (instant feedback)
- ATProto record sync when album title changes (updates all track records + list record)
- fixed album slug sync on rename (prevented duplicate albums when adding tracks)
playlist show on profile (PR #553):
- restored "show on profile" toggle that was lost during inline editing refactor
- users can now control whether playlists appear on their public profile
public cost dashboard (PRs #548-549, Dec 9)#
/costspage showing live platform infrastructure costs- daily export to R2 via GitHub Action, proxied through
/stats/costsendpoint - dedicated
plyr-statsR2 bucket with public access (shared across environments) - includes fly.io, neon, cloudflare, and audd API costs
- ko-fi integration for community support
docket background tasks & concurrent exports (PRs #534-546, Dec 9)#
docket integration (PRs #534, #536, #539):
- migrated background tasks from inline asyncio to docket (Redis-backed task queue)
- copyright scanning, media export, ATProto sync, and teal scrobbling now run via docket
- graceful fallback to asyncio for local development without Redis
- parallel test execution with xdist template databases (#540)
concurrent export downloads (PR #545):
- exports now download tracks in parallel (up to 4 concurrent) instead of sequentially
- significantly faster for users with many tracks or large files
- zip creation remains sequential (zipfile constraint)
ATProto refactor (PR #534):
- reorganized ATProto record code into
_internal/atproto/records/by lexicon namespace - extracted
client.pyfor low-level PDS operations - cleaner separation between plyr.fm and teal.fm lexicons
documentation & observability:
- AudD API cost tracking dashboard (#546)
- promoted runbooks from sandbox to
docs/runbooks/ - updated CLAUDE.md files across the codebase
artist support links & inline playlist editing (PRs #520-532, Dec 8)#
artist support link (PR #532):
- artists can set a support URL (Ko-fi, Patreon, etc.) in their portal profile
- support link displays as a button on artist profile pages next to the share button
- URLs validated to require https:// prefix
inline playlist editing (PR #531):
- edit playlist name and description directly on playlist detail page
- click-to-upload cover art replacement without modal
- cleaner UX - no more edit modal popup
platform stats enhancements (PRs #522, #528):
- total duration displayed in platform stats (e.g., "42h 15m of music")
- duration shown per artist in analytics section
- combined stats and search into single centered container for cleaner layout
navigation & data loading fixes (PR #527):
- fixed stale data when navigating between detail pages of the same type
- e.g., clicking from one artist to another now properly reloads data
copyright moderation improvements (PR #480):
- enhanced moderation workflow for copyright claims
- improved labeler integration
status maintenance workflow (PR #529):
- automated status maintenance using claude-code-action
- reviews merged PRs and updates STATUS.md narratively
playlist fast-follow fixes (PRs #507-519, Dec 7-8)#
public playlist viewing (PR #519):
- playlists now publicly viewable without authentication
- ATProto records are public by design - auth was unnecessary for read access
- shared playlist URLs no longer redirect unauthenticated users to homepage
inline playlist creation (PR #510):
- clicking "create new playlist" from AddToMenu previously navigated to
/library?create=playlist - this caused SvelteKit to reinitialize the layout, destroying the audio element and stopping playback
- fix: added inline create form that creates playlist and adds track in one action without navigation
UI polish (PRs #507-509, #515):
- include
image_urlin playlist SSR data for og:image link previews - invalidate layout data after token exchange - fixes stale auth state after login
- fixed stopPropagation blocking "create new playlist" link clicks
- detail page button layouts: all buttons visible on mobile, centered AddToMenu on track detail
- AddToMenu smart positioning: menu opens upward when near viewport bottom
documentation (PR #514):
- added lexicons overview documentation at
docs/lexicons/overview.md - covers
fm.plyr.track,fm.plyr.like,fm.plyr.comment,fm.plyr.list,fm.plyr.actor.profile
playlists, ATProto sync, and library hub (PR #499, Dec 6-7)#
playlists (full CRUD):
- create, rename, delete playlists with cover art upload
- add/remove/reorder tracks with drag-and-drop
- playlist detail page with edit modal
- "add to playlist" menu on tracks with inline create
- playlist sharing with OpenGraph link previews
ATProto integration:
fm.plyr.listlexicon for syncing playlists/albums to user PDSesfm.plyr.actor.profilelexicon for artist profiles- automatic sync of albums, liked tracks, profile on login
library hub (/library):
- unified page with tabs: liked, playlists, albums
- nav changed from "liked" → "library"
related: scope upgrade OAuth flow (PR #503), settings consolidation (PR #496)
sensitive image moderation (PRs #471-488, Dec 5-6)#
sensitive_imagestable flags problematic imagesshow_sensitive_artworkuser preference- flagged images blurred everywhere: track lists, player, artist pages, search, embeds
- Media Session API respects sensitive preference
- SSR-safe filtering for og:image link previews
teal.fm scrobbling (PR #467, Dec 4)#
- native scrobbling to user's PDS using teal's ATProto lexicons
- scrobble at 30% or 30 seconds (same threshold as play counts)
- toggle in settings, link to pdsls.dev to view records
Earlier December / November 2025#
See .status_history/2025-12.md and .status_history/2025-11.md for detailed history including:
- unified search with Cmd+K (PR #447)
- light/dark theme system (PR #441)
- tag filtering and bufo easter egg (PRs #431-438)
- developer tokens (PR #367)
- copyright moderation system (PRs #382-395)
- export & upload reliability (PRs #337-344)
- transcoder API deployment (PR #156)
immediate priorities#
known issues#
- playback auto-start on refresh (#225)
- iOS PWA audio may hang on first play after backgrounding
immediate focus#
- moderation cleanup: consolidate copyright detection, reduce AudD API costs, streamline labeler integration (issues #541-544)
feature ideas#
- issue #334: add 'share to bluesky' option for tracks
- issue #373: lyrics field and Genius-style annotations
backlog#
- audio transcoding pipeline integration (#153) - transcoder service deployed, integration deferred
technical state#
architecture#
backend
- language: Python 3.11+
- framework: FastAPI with uvicorn
- database: Neon PostgreSQL (serverless)
- storage: Cloudflare R2 (S3-compatible)
- background tasks: docket (Redis-backed)
- hosting: Fly.io (2x shared-cpu VMs)
- observability: Pydantic Logfire
- auth: ATProto OAuth 2.1
frontend
- framework: SvelteKit (v2.43.2)
- runtime: Bun
- hosting: Cloudflare Pages
- styling: vanilla CSS with lowercase aesthetic
- state management: Svelte 5 runes
deployment
- ci/cd: GitHub Actions
- backend: automatic on main branch merge (fly.io)
- frontend: automatic on every push to main (cloudflare pages)
- migrations: automated via fly.io release_command
what's working
core functionality
- ✅ ATProto OAuth 2.1 authentication
- ✅ secure session management via HttpOnly cookies
- ✅ developer tokens with independent OAuth grants
- ✅ platform stats and Media Session API
- ✅ timed comments with clickable timestamps
- ✅ artist profiles synced with Bluesky
- ✅ track upload with streaming
- ✅ audio streaming via 307 redirects to R2 CDN
- ✅ play count tracking, likes, queue management
- ✅ unified search with Cmd/Ctrl+K
- ✅ teal.fm scrobbling
- ✅ copyright moderation with ATProto labeler
- ✅ docket background tasks (copyright scan, export, atproto sync, scrobble)
- ✅ media export with concurrent downloads
albums
- ✅ album CRUD with cover art
- ✅ ATProto list records (auto-synced on login)
playlists
- ✅ full CRUD with drag-and-drop reordering
- ✅ ATProto list records (synced on create/modify)
- ✅ "add to playlist" menu, global search results
deployment URLs
- production frontend: https://plyr.fm
- production backend: https://api.plyr.fm
- staging: https://stg.plyr.fm / https://api-stg.plyr.fm
technical decisions#
why Python/FastAPI instead of Rust?
- rapid prototyping velocity during MVP phase
- trade-off: accepting higher latency for faster development
why Cloudflare R2 instead of S3?
- zero egress fees (critical for audio streaming)
- S3-compatible API, integrated CDN
why async everywhere?
- I/O-bound workload: most time spent waiting on network/disk
- PRs #149-151 eliminated all blocking operations
cost structure#
current monthly costs: ~$18/month (plyr.fm specific)
see live dashboard: plyr.fm/costs
- fly.io (plyr apps only): ~$12/month
- relay-api (prod): $5.80
- relay-api-staging: $5.60
- plyr-moderation: $0.24
- plyr-transcoder: $0.02
- neon postgres: $5/month
- cloudflare (R2 + pages + domain): ~$1.16/month
- audd audio fingerprinting: $0-10/month (6000 free/month)
- logfire: $0 (free tier)
admin tooling#
content moderation#
script: scripts/delete_track.py
usage:
uv run scripts/delete_track.py <track_id> --dry-run
uv run scripts/delete_track.py <track_id>
uv run scripts/delete_track.py --url https://plyr.fm/track/34
for new contributors#
getting started#
- clone:
gh repo clone zzstoatzz/plyr.fm - install dependencies:
uv sync && cd frontend && bun install - run backend:
uv run uvicorn backend.main:app --reload - run frontend:
cd frontend && bun run dev - visit http://localhost:5173
development workflow#
- create issue on github
- create PR from feature branch
- ensure pre-commit hooks pass
- merge to main → deploys to staging
- create github release → deploys to production
key principles#
- type hints everywhere
- lowercase aesthetic
- ATProto first
- async everywhere (no blocking I/O)
- mobile matters
- cost conscious
project structure#
plyr.fm/
├── backend/ # FastAPI app & Python tooling
│ ├── src/backend/ # application code
│ ├── tests/ # pytest suite
│ └── alembic/ # database migrations
├── frontend/ # SvelteKit app
│ ├── src/lib/ # components & state
│ └── src/routes/ # pages
├── moderation/ # Rust moderation service (ATProto labeler)
├── transcoder/ # Rust audio transcoding service
├── docs/ # documentation
└── justfile # task runner
documentation#
- docs/README.md - documentation index
- runbooks - production incident procedures
- background tasks - docket task system
- logfire querying - observability queries
- moderation & labeler - copyright, sensitive content
- lexicons overview - ATProto record schemas
this is a living document. last updated 2025-12-17.