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:

  1. 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
  2. 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
  3. 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#

end-of-year sprint (Dec 20-31)#

focus: two foundational systems need solid experimental implementations by 2026.

track 1: moderation architecture overhaul

  • consolidate sensitive images into moderation service
  • add event-sourced audit trail
  • implement configurable rules (replace hard-coded thresholds)
  • informed by Roost Osprey patterns and Bluesky Ozone workflows

track 2: atprotofans paywall integration

  • phase 1: read-only supporter validation (show badges)
  • phase 2: platform registration (artists create support tiers)
  • phase 3: content gating (track-level access control)

research docs:

tracking: issue #625


beartype + moderation cleanup (PRs #617-619, Dec 19)#

runtime type checking (PR #619):

  • enabled beartype runtime type validation across the backend
  • catches type errors at runtime instead of silently passing bad data
  • test infrastructure improvements: session-scoped TestClient fixture (5x faster tests)
  • disabled automatic perpetual task scheduling in tests

moderation cleanup (PRs #617-618):

  • consolidated moderation code, addressing issues #541-543
  • sync_copyright_resolutions now runs automatically via docket Perpetual task
  • removed init_db() from lifespan (handled by alembic migrations)

UX polish (PRs #604-607, #613, #615, Dec 16-18)#

login improvements (PRs #604, #613):

  • login page now uses "internet handle" terminology for clarity
  • input normalization: strips @ and at:// prefixes automatically

artist page fixes (PR #615):

  • track pagination on artist pages now works correctly
  • fixed mobile album card overflow

mobile + metadata (PRs #605-607):

  • Open Graph tags added to tag detail pages for link previews
  • mobile modals now use full screen positioning
  • fixed /tag/ routes in hasPageMetadata check

misc (PRs #598-601):

  • upload button added to desktop header nav
  • background settings UX improvements
  • switched support link to atprotofans
  • AudD costs now derived from track duration for accurate billing

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}/url backend 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_settings JSONB 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: translateZ transforms 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_jwt authentication
  • when OAUTH_JWK is 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.json endpoint for public key discovery
  • updated /oauth-client-metadata.json with confidential client fields

bug fixes (PRs #580-582):

  • fixed client assertion JWT to use Authorization Server's issuer as aud claim (not token endpoint URL)
  • fixed JWKS endpoint to preserve kid field from original JWK
  • fixed OAuthClient to pass client_secret_kid for JWT header

atproto fork updates (zzstoatzz/atproto#6, #7):

  • added issuer parameter to _make_token_request() for correct aud claim
  • added client_secret_kid parameter to include kid in 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)#

  • /costs page showing live platform infrastructure costs
  • daily export to R2 via GitHub Action, proxied through /stats/costs endpoint
  • dedicated plyr-stats R2 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.py for 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 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_url in 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.list lexicon for syncing playlists/albums to user PDSes
  • fm.plyr.actor.profile lexicon 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_images table flags problematic images
  • show_sensitive_artwork user 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#

end-of-year sprint (Dec 20-31)#

see sprint tracking issue #625 for details.

track focus status
moderation consolidate architecture, add rules engine planning
atprotofans supporter validation, content gating planning

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)

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

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#

  1. clone: gh repo clone zzstoatzz/plyr.fm
  2. install dependencies: uv sync && cd frontend && bun install
  3. run backend: uv run uvicorn backend.main:app --reload
  4. run frontend: cd frontend && bun run dev
  5. visit http://localhost:5173

development workflow#

  1. create issue on github
  2. create PR from feature branch
  3. ensure pre-commit hooks pass
  4. merge to main → deploys to staging
  5. 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#


this is a living document. last updated 2025-12-20.