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/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
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/3secrets - 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_imageform field onPATCH /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.UploadFilevsfastapi.UploadFiletype 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 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
timestamp deep links (PRs #739-740, Jan 8)#
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
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
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 modalPOST /pds-backfill/audionow accepts optionaltrack_idsbody to backfill specific tracks (backward-compatible — no body = all eligible)- SSE progress stream includes
last_processed_track_idandlast_statusfor 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
getBlobisn'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 PDSpds_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=createto trigger account creation on the PDS - after account creation, user is redirected back and logged in
implementation details:
/auth/pds-optionsendpoint returns available PDS hosts from config/auth/startacceptspds_urlparameter 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 creationrecommended_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) andoriginal_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_idandoriginal_file_typeadded to Track model and API- audio endpoint serves either version based on requested file_id
- feature-flagged via
lossless-uploadsuser flag
bug fixes during rollout:
- PR #796: audio endpoint now queries by
file_idORoriginal_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}/sharecreates 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
refparam to record play attribution GET /tracks/me/sharesreturns 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:
ShareLinktable: code, track_id, creator_did, created_atShareLinkEventtable: 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()withcqiunits (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_atis null - 4-bullet summary with links to full documents
- "I Accept" or "Decline & Logout" options
POST /account/accept-termsrecords 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: centertoflex-startso 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
putRecordwith deterministic TID rkeys derived fromplayedTimeinstead ofcreateRecord - network retries, multiple teal-compatible services, or background task retries won't create duplicates
- adds
played_timeparameter tobuild_teal_play_recordfor 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.tsprovides 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: trueto 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)
copyright flagging fix (PR #748, Jan 12)#
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