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#
January 2026#
multi-account experience (PRs #707, #710, #712-714, Jan 3-5)#
why: many users have multiple ATProto identities (personal, artist, label). forcing re-authentication to switch was friction that discouraged uploads from secondary accounts.
users can now link multiple identities to a single browser session:
- add additional accounts via "add account" in user menu (triggers OAuth with
prompt=login) - switch between linked accounts instantly without re-authenticating
- logout from individual accounts or all at once
- updated
/auth/mereturnslinked_accountsarray with avatars
backend changes:
- new
group_idcolumn onuser_sessionslinks accounts together - new
pending_add_accountstable tracks in-progress OAuth flows - new endpoints:
POST /auth/add-account/start,POST /auth/switch-account,POST /auth/logout-all
infrastructure fixes (PRs #710, #712, #714): these fixes came from reviewing Bluesky's architecture deep dive which highlighted connection/resource management as scaling concerns. applied learnings to our own codebase:
- identified Neon serverless connection overhead (~77ms per connection) via Logfire
- cached
async_sessionmakerper engine instead of recreating on every request (PR #712) - changed
_refresh_locksfrom unbounded dict to LRUCache (10k max, 1hr TTL) to prevent memory leak (PR #710) - pass db session through auth helpers to reduce connections per request (PR #714)
- result:
/auth/switch-account~1100ms → ~800ms,/auth/me~940ms → ~720ms
frontend changes:
- UserMenu (desktop): collapsible accounts submenu with linked accounts, add account, logout all
- ProfileMenu (mobile): dedicated accounts panel with avatars
- fixed
invalidateAll()not refreshing client-side loaded data by usingwindow.location.reload()(PR #713)
docs: research/2026-01-03-multi-account-experience.md
auth stabilization (PRs #734-736, Jan 6-7)#
why: multi-account support introduced edge cases where auth state could become inconsistent between frontend components, and sessions could outlive their refresh tokens.
session expiry alignment (PR #734):
- sessions now track refresh token lifetime and respect it during validation
- prevents sessions from appearing valid after their underlying OAuth grant expires
- dev token expiration handling aligned with same pattern
queue auth boundary fix (PR #735):
- queue component now uses shared layout auth state instead of localStorage session IDs
- fixes race condition where queue could attempt authenticated requests before layout resolved auth
- ensures remote queue snapshots don't inherit local update flags during hydration
playlist cover upload fix (PR #736):
R2Storage.save()was rejectingBytesIOobjects due to beartype's strictBinaryIOprotocol checking- changed type hint to
BinaryIO | BytesIOto explicitly accept both - found via Logfire: only 2 failures in production, both on Jan 3
artist bio links (PRs #700-701, Jan 2)#
links in artist bios now render as clickable - supports full URLs and bare domains (e.g., "example.com"):
- regex extracts URLs from bio text
- bare domain/path URLs handled correctly
- links open in new tab
copyright moderation improvements (PRs #703-704, Jan 2-3)#
per legal advice, redesigned copyright handling to reduce liability exposure:
- disabled auto-labeling (PR #703): labels are no longer automatically emitted when copyright matches are detected. the system now only flags and notifies, leaving takedown decisions to humans
- raised threshold (PR #703): copyright flag threshold increased from "any match" to configurable score (default 85%). controlled via
MODERATION_COPYRIGHT_SCORE_THRESHOLDenv var - DM notifications (PR #704): when a track is flagged, both the artist and admin receive BlueSky DMs with details. includes structured error handling for when users have DMs disabled
- observability (PR #704): Logfire spans added to all notification paths (
send_dm,copyright_notification) with error categorization (dm_blocked,network,auth,unknown) - notification tracking:
notified_atfield added tocopyright_scanstable to track which flags have been communicated
why this matters: DMCA safe harbor requires taking action on notices, not proactively policing. auto-labeling was creating liability by making assertions about copyright status. human review is now required before any takedown action.
ATProto OAuth permission sets (PRs #697-698, Jan 1-2)#
permission sets enabled - OAuth now uses include:fm.plyr.authFullApp instead of listing individual repo: scopes:
- users see clean "plyr.fm" permission title instead of raw collection names
- permission set lexicon published to
com.atproto.lexicon.schemaon plyr.fm authority repo - DNS TXT records at
_lexicon.plyr.fmand_lexicon.stg.plyr.fmlink namespaces to authority DID - fixed scope validation in atproto SDK fork to handle PDS permission expansion (
include:→repo?collection=)
why this matters: permission sets are ATProto's mechanism for defining platform access tiers. enables future third-party integrations (mobile apps, read-only stats dashboards) to request semantic permission bundles instead of raw collection lists.
docs: lexicons/overview.md, research/2026-01-01-atproto-oauth-permission-sets.md
atprotofans supporters display (PRs #695-696, Jan 1)#
supporters now visible on artist pages - artists using atprotofans can show their supporters:
- compact overlapping avatar circles (GitHub sponsors style) with "+N" overflow badge
- clicks link to supporter's plyr.fm artist page (keeps users in-app)
POST /artists/batchendpoint enriches supporter DIDs with avatar_url from our Artist table- frontend fetches from atprotofans, enriches via backend, renders with consistent avatar pattern
route ordering fix (PR #696): FastAPI was matching /artists/batch as /{did} with did="batch". moved POST route before the catchall GET route.
UI polish (PRs #692-694, Dec 31 - Jan 1)#
- feed/library toggle (PR #692): consistent header layout with toggle between feed and library views
- shuffle button moved (PR #693): shuffle now in queue component instead of player controls
- justfile consistency (PR #694): standardized
just runacross frontend/backend modules
December 2025#
See .status_history/2025-12.md for detailed history including:
- header redesign and UI polish (PRs #691-693, Dec 31)
- automated image moderation with Claude vision (PRs #687-690, Dec 31)
- avatar sync on login (PR #685, Dec 31)
- top tracks homepage (PR #684, Dec 31)
- batch review system (PR #672, Dec 30)
- CSS design tokens (PRs #662-664, Dec 29-30)
- self-hosted redis migration (PRs #674-675, Dec 30)
- supporter-gated content (PR #637, Dec 22-23)
- supporter badges (PR #627, Dec 21-22)
- end-of-year sprint: moderation + atprotofans (PRs #617-629, Dec 19-21)
- offline mode foundation (PRs #610-611, Dec 17)
- UX polish and login improvements (PRs #604-615, Dec 16-18)
- visual customization with custom backgrounds (PRs #595-596, Dec 16)
- performance & moderation polish (PRs #586-593, Dec 14-15)
- mobile UI polish & background task expansion (PRs #558-572, Dec 10-12)
- confidential OAuth client for 180-day sessions (PRs #578-582, Dec 12-13)
- pagination & album management (PRs #550-554, Dec 9-10)
- public cost dashboard (PRs #548-549, Dec 9)
- docket background tasks & concurrent exports (PRs #534-546, Dec 9)
- artist support links & inline playlist editing (PRs #520-532, Dec 8)
- playlist fast-follow fixes (PRs #507-519, Dec 7-8)
- playlists, ATProto sync, and library hub (PR #499, Dec 6-7)
- sensitive image moderation (PRs #471-488, Dec 5-6)
- teal.fm scrobbling (PR #467, Dec 4)
- unified search with Cmd+K (PR #447, Dec 3)
- light/dark theme system (PR #441, Dec 2-3)
- tag filtering and bufo easter egg (PRs #431-438, Dec 2)
November 2025#
See .status_history/2025-11.md for detailed history including:
- developer tokens (PR #367)
- copyright moderation system (PRs #382-395)
- export & upload reliability (PRs #337-344)
- transcoder API deployment (PR #156)
priorities#
current focus#
stabilization and polish after multi-account release. monitoring production for issues.
end-of-year sprint #625 shipped:
- moderation consolidation: sensitive images moved to moderation service (#644)
- moderation batch review UI with Claude vision integration (#672, #687-690)
- atprotofans: supporter badges (#627) and content gating (#637)
known issues#
- playback auto-start on refresh (#225)
- iOS PWA audio may hang on first play after backgrounding
backlog#
- audio transcoding pipeline integration (#153) - transcoder service deployed, integration deferred
- share to bluesky (#334)
- lyrics and annotations (#373)
- configurable rules engine for moderation
- time-release gating (#642)
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
- ✅ multi-account support (link multiple ATProto identities)
- ✅ 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
- ✅ supporter-gated content via atprotofans
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: ~$20/month (plyr.fm specific)
see live dashboard: plyr.fm/costs
- fly.io (backend + redis + moderation): ~$14/month
- neon postgres: $5/month
- cloudflare (R2 + pages + domain): ~$1/month
- audd audio fingerprinting: $5-10/month (usage-based)
- 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
├── redis/ # self-hosted Redis config
├── 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 2026-01-07.