audio streaming app plyr.fm

plyr.fm Status History - January 2026#

Early January 2026 Work (Jan 1-9)#

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/me returns linked_accounts array with avatars

backend changes:

  • new group_id column on user_sessions links accounts together
  • new pending_add_accounts table 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_sessionmaker per engine instead of recreating on every request (PR #712)
  • changed _refresh_locks from 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 using window.location.reload() (PR #713)

docs: research/2026-01-03-multi-account-experience.md


integration test harness (PR #744, Jan 9)#

automated tests running against staging:

  • pure Python audio generation (sine waves, no FFmpeg dependency)
  • multi-user test fixtures via PLYR_TEST_TOKEN_1/2/3 secrets
  • track lifecycle tests: upload, edit, delete, search indexing
  • cross-user interaction tests: like/unlike, permission boundaries
  • GitHub Actions workflow triggers after staging deploy

track edit UX improvements (PRs #741-742, Jan 9)#

why: the portal track editing experience had several UX issues - users couldn't remove artwork (only replace), no preview when selecting new images, and buttons were poorly styled icon-only squares with overly aggressive hover effects.

artwork management (PR #742):

  • add ability to remove track artwork via new remove_image form field on PATCH /tracks/{id}
  • show image preview when selecting new artwork before saving
  • hover overlay on current artwork with trash icon to remove
  • "undo" option when artwork is marked for removal
  • clear status labels: "current artwork", "new artwork selected", "artwork will be removed"

button styling (PR #742):

  • replace icon-only squares with labeled pill buttons (edit, delete)
  • subtle outlined save/cancel buttons in edit mode
  • fix global button hover styles bleeding into all buttons (scoped to form submit only)

shutdown fix (PR #742):

  • add 2s timeouts to docket worker and service shutdown
  • prevents backend hanging on Ctrl+C or hot-reload during development

beartype fix (PR #741):

  • starlette.UploadFile vs fastapi.UploadFile type mismatch was causing 500 errors on image upload
  • fixed by importing UploadFile from starlette in metadata_service.py

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 rejecting BytesIO objects due to beartype's strict BinaryIO protocol checking
  • changed type hint to BinaryIO | BytesIO to explicitly accept both
  • found via Logfire: only 2 failures in production, both on Jan 3

timestamped comment sharing (PR #739):

  • timed comments now show share button on hover
  • copies URL with ?t= parameter (e.g., plyr.fm/track/123?t=45)
  • visiting timestamped URL auto-seeks to that position on play

autoplay error suppression (PR #740):

  • suppress browser autoplay errors when deep linking to timestamps
  • browsers block autoplay without user interaction; now fails silently

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

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_THRESHOLD env 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_at field added to copyright_scans table 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.schema on plyr.fm authority repo
  • DNS TXT records at _lexicon.plyr.fm and _lexicon.stg.plyr.fm link 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/batch endpoint 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 run across frontend/backend modules

Mid-to-Late January 2026 Work (Jan 11-31)#

per-track PDS migration + UX polish (PRs #835-839, Jan 30-31)#

selective migration: replaced all-or-nothing PDS backfill with a modal where users pick individual tracks to migrate. modal shows file sizes (via R2 HEAD requests), track status badges (on PDS / gated / eligible), and a select-all toggle.

non-blocking UX (PR #839): the modal initially blocked the user during migration. reworked so the modal is selection-only — picks tracks, fires a callback, closes immediately. POST + SSE progress tracking moved to the parent component with persistent toast updates ("migrating 3/7...", "5 migrated, 2 skipped"). user is never trapped.

backend changes (PR #838):

  • GET /tracks/me/file-sizes — parallel R2 HEAD requests (semaphore-capped at 10) to get byte sizes for the migration modal
  • POST /pds-backfill/audio now accepts optional track_ids body to backfill specific tracks (backward-compatible — no body = all eligible)
  • SSE progress stream includes last_processed_track_id and last_status for per-track updates

copy fixes (PRs #835-836): removed "R2" from user-facing text (settings toggle, upload notes). users see "plyr.fm storage" instead of infrastructure detail.

share link clutter (PR #837): share links with zero interactions (self-clicks filtered) were cluttering the portal stats section. now hidden until someone else actually clicks the link.


PDS blob storage for audio (PRs #823-833, Jan 29)#

audio files can now be stored on the user's PDS - embraces ATProto's data ownership model. PDS uploads are feature-flagged and opt-in via a user setting, with R2 CDN as the primary delivery path.

core implementation (PR #823):

  • new uploads: audio blob uploaded to PDS, BlobRef stored in track record
  • dual-write: R2 copy kept for streaming performance (PDS getBlob isn't CDN-optimized)
  • graceful fallback: if PDS rejects blob (size limit), track stays R2-only
  • gated tracks skip PDS (need auth-protected access)

database changes:

  • audio_storage: "r2" | "pds" | "both"
  • pds_blob_cid: CID of blob on user's PDS
  • pds_blob_size: size in bytes

bug fixes and hardening (PRs #824-828):

  • fix atproto headers lost on DPoP retry (#824)
  • fail upload on unexpected PDS errors instead of silent fallback (#825)
  • add blob:*/* OAuth scope to both permission sets and granular paths (#826, #827)
  • remove PDS indicator from track UI — PDS will be the default, no need to badge it (#828)

batch backfill (PR #829): POST /pds-backfill/audio starts a background job (docket) to backfill existing tracks to the user's PDS with SSE progress streaming. frontend PdsBackfillControl component in the portal.

copyright DM fix (PR #831): removed misleading "0% confidence" from copyright notification DMs — the enterprise AudD API doesn't return confidence values.

feature flag gating (PR #833): PDS uploads during track upload are now gated behind two checks: admin-assigned pds-audio-uploads feature flag + per-user toggle in Settings > Experimental. default behavior is R2-only unless both are enabled.

terms update (PR #832): clarified PDS delisting language in terms of service.

research: documented emerging ATProto media service patterns from community discourse — the ecosystem is converging on dedicated sidecar media services rather than PDS-as-media-host. our layered architecture (R2 + CDN + PDS records) aligns well. see docs/research/2026-01-29-atproto-media-service-patterns.md.


PDS-based account creation (PRs #813-815, Jan 27)#

create ATProto accounts directly from plyr.fm - users without an existing ATProto identity can now create one during sign-up by selecting a PDS host.

how it works:

  • login page shows "create account" tab when feature is enabled
  • user selects a PDS (currently selfhosted.social)
  • OAuth flow uses prompt=create to trigger account creation on the PDS
  • after account creation, user is redirected back and logged in

implementation details:

  • /auth/pds-options endpoint returns available PDS hosts from config
  • /auth/start accepts pds_url parameter for account creation flow
  • handle resolution falls back to PDS directly (via com.atproto.repo.describeRepo) when Bluesky AppView hasn't indexed the new account yet

configuration (AccountCreationSettings):

  • enabled: feature flag for account creation
  • recommended_pds: list of PDS options with name, url, and description

lossless audio support (PRs #794-801, Jan 25)#

transcoding integration complete - users can now upload AIFF and FLAC files. the system transcodes them to MP3 for browser compatibility while preserving originals for lossless playback.

how it works:

  • upload AIFF/FLAC → original saved to R2, transcoded MP3 created
  • database stores both file_id (transcoded) and original_file_id (lossless)
  • frontend detects browser capabilities via canPlayType()
  • Safari/native apps get lossless, Chrome/Firefox get transcoded MP3
  • lossless badge shown on track cards when browser supports the format

key changes:

  • original_file_id and original_file_type added to Track model and API
  • audio endpoint serves either version based on requested file_id
  • feature-flagged via lossless-uploads user flag

bug fixes during rollout:

  • PR #796: audio endpoint now queries by file_id OR original_file_id
  • PR #797: store actual extension (.aif) not normalized format name (.aiff)

UI polish (PRs #799-801):

  • lossless badge positioned in top-right corner of track card (not artwork)
  • subtle glowing animation draws attention to premium quality tracks
  • whole card gets accent-colored border treatment when lossless
  • theme-aware styling, responsive sizing, respects prefers-reduced-motion

auth check optimization (PRs #781-782, Jan 23)#

eliminated redundant /auth/me calls - previously, every navigation triggered an auth check via the layout load function. for unauthenticated users, this meant a 401 on every page click (117 errors in 24 hours observed via Logfire).

fix: auth singleton now tracks initialization state. +layout.svelte checks auth once on mount instead of every navigation. follow-up PR fixed library/liked pages that were broken by the layout simplification (they were using await parent() to get isAuthenticated which was no longer provided).


remove SSR sensitive-images fetch (PR #785, Jan 24)#

eliminated unnecessary SSR fetch - the frontend SSR (+layout.server.ts) was fetching /moderation/sensitive-images on every page load to pre-populate the client-side moderation filter. during traffic spikes, this hammered the backend (1,179 rate limit hits over 7 days).

root cause: the SSR fetch was premature optimization. cloudflare pages workers make direct fetch calls to fly.io - there's no CDN layer to cache responses. the cache-control headers we added in PR #784 only help browser caching, not SSR-to-origin requests.

fix: removed the SSR fetch entirely. the client-side ModerationManager singleton already has caching and will fetch the data once on page load. the "flash of sensitive content" risk is theoretical - images load slower than a single API call completes, and there are only 2 flagged images.

  • deleted +layout.server.ts
  • simplified +layout.ts
  • updated pages to use moderation.isSensitive() singleton instead of SSR data

listen receipts (PR #773, Jan 22)#

share links now track who clicked and played - when you share a track, you get a URL with a ?ref= code that records visitors and listeners:

  • POST /tracks/{id}/share creates tracked share link with unique 8-character code (48 bits entropy)
  • frontend captures ?ref= param on page load, fires click event to backend
  • play endpoint accepts optional ref param to record play attribution
  • GET /tracks/me/shares returns paginated stats: visitors, listeners, anonymous counts

portal share stats section:

  • expandable cards per share link with copyable tracked URL
  • visitors (who clicked) and listeners (who played) shown as avatar circles
  • individual interaction counts per user
  • self-clicks/plays filtered out to avoid inflating stats

data model:

  • ShareLink table: code, track_id, creator_did, created_at
  • ShareLinkEvent table: share_link_id, visitor_did (nullable for anonymous), event_type (click/play)

handle display fix (PR #774, Jan 22)#

DIDs were displaying instead of handles in share link stats and other places (comments, track likers):

  • root cause: Artist records were only created during profile setup
  • users who authenticated but skipped setup had no Artist record
  • fix: create minimal Artist record (did, handle, avatar) during OAuth callback
  • profile setup now updates existing record instead of erroring

responsive embed v2 (PRs #771-772, Jan 20-21)#

complete rewrite of embed CSS using container queries and proportional scaling:

layout modes:

  • wide (width >= 400px): side art, proportional sizing
  • very wide (width >= 600px): larger art, more breathing room
  • square/tall (aspect <= 1.2, width >= 200px): art on top, 2-line titles
  • very tall (aspect <= 0.7, width >= 200px): blurred background overlay
  • narrow (width < 280px): compact blurred background
  • micro (width < 200px): hide time labels and logo

key technical changes:

  • all sizes use clamp() with cqi units (container query units)
  • grid-based header layout instead of absolute positioning
  • gradient overlay (top-heavy to bottom-heavy) for text readability

terms of service and privacy policy (PRs #567, #761-770, Jan 19-20)#

legal foundation shipped with ATProto-aware design:

terms cover:

  • AT Protocol context (decentralized identity, user-controlled PDS)
  • content ownership (users retain ownership, plyr.fm gets license for streaming)
  • DMCA safe harbor with designated agent (DMCA-1069186)
  • federation disclaimer: audio files in blob storage we control, but ATProto records may persist on user's PDS

privacy policy:

  • explicit third-party list with links (Cloudflare, Fly.io, Neon, Logfire, AudD, Anthropic, ATProtoFans)
  • data ownership clarity (DID, profile, tracks on user's PDS)
  • MIT license added to repo

acceptance flow (TermsOverlay component):

  • shown on first login if terms_accepted_at is null
  • 4-bullet summary with links to full documents
  • "I Accept" or "Decline & Logout" options
  • POST /account/accept-terms records timestamp

polish PRs (#761-770): corrected ATProto vs "our servers" terminology, standardized AT Protocol naming, added email fallbacks, capitalized sentence starts


content gating research (Jan 18)#

researched ATProtoFans architecture and JSONLogic rule evaluation. documented findings in docs/content-gating-roadmap.md:

  • current ATProtoFans records and API (supporter, supporterProof, brokerProof, terms)
  • the gap: terms exist but aren't exposed via validateSupporter
  • how magazi uses datalogic-rs for flexible rule evaluation
  • open questions about upcoming metadata extensions

no implementation changes - waiting to align with what ATProtoFans will support.

logout modal UX (PRs #755-757, Jan 17-18)#

tooltip scroll fix (PR #755):

  • leftmost avatar in likers/commenters tooltip was clipped with no way to scroll to it
  • changed justify-content: center to flex-start so most recent (leftmost) is always visible

logout modal copy (PRs #756-757):

  • simplified from two confusing questions to one clear question
  • before: "stay logged in?" + "you're logging out of @handle?"
  • after: "switch accounts?"
  • "logout completely" → "log out of all accounts"

idempotent teal scrobbles (PR #754, Jan 16)#

prevents duplicate scrobbles when same play is submitted multiple times:

  • use putRecord with deterministic TID rkeys derived from playedTime instead of createRecord
  • network retries, multiple teal-compatible services, or background task retries won't create duplicates
  • adds played_time parameter to build_teal_play_record for deterministic record keys

avatar refresh and tooltip polish (PRs #750-752, Jan 13)#

avatar refresh from anywhere (PR #751):

  • previously, stale avatar URLs were only fixed when visiting the artist detail page
  • now any broken avatar triggers a background refresh from Bluesky
  • shared avatar-refresh.svelte.ts provides global cache and request deduplication
  • works from: track items, likers tooltip, commenters tooltip, profile page

interactive tooltips (PR #750):

  • hovering on like count shows avatar circles of users who liked
  • hovering on comment count shows avatar circles of commenters
  • lazy-loaded with 5-minute cache, invalidated when likes/comments change
  • elegant centered layout with horizontal scroll when needed

UX polish (PR #752):

  • added prettier config with useTabs: true to match existing style
  • reduced avatar hover effect intensity (scale 1.2 → 1.08)
  • fixed avatar hover clipping at tooltip edge (added top padding)
  • track title now links to detail page (color change on hover)

switched from score-based to dominant match detection:

  • AudD's enterprise API doesn't return confidence scores (always 0)
  • previous threshold-based detection was broken
  • new approach: flag if one song appears in >= 30% of matched segments
  • filters false positives where random segments match different songs

Neon cold start fix (Jan 11)#

why: first requests after idle periods would fail with 500 errors due to Neon serverless scaling to zero after 5 minutes of inactivity. previous mitigations (larger pool, longer timeouts) helped but didn't eliminate the problem.

fix: disabled scale-to-zero on plyr-prd via Neon console. this is the recommended approach for production workloads.

configuration:

  • plyr-prd: scale-to-zero disabled (suspend_timeout_seconds: -1)
  • plyr-stg, plyr-dev: scale-to-zero enabled (cold starts acceptable)

docs: updated connection-pooling.md with production guidance and how to verify settings via Neon MCP.

closes #733