audio streaming app plyr.fm
1# plyr.fm Status History - January 2026 2 3## Early January 2026 Work (Jan 1-9) 4 5### multi-account experience (PRs #707, #710, #712-714, Jan 3-5) 6 7**why**: many users have multiple ATProto identities (personal, artist, label). forcing re-authentication to switch was friction that discouraged uploads from secondary accounts. 8 9**users can now link multiple identities** to a single browser session: 10- add additional accounts via "add account" in user menu (triggers OAuth with `prompt=login`) 11- switch between linked accounts instantly without re-authenticating 12- logout from individual accounts or all at once 13- updated `/auth/me` returns `linked_accounts` array with avatars 14 15**backend changes**: 16- new `group_id` column on `user_sessions` links accounts together 17- new `pending_add_accounts` table tracks in-progress OAuth flows 18- new endpoints: `POST /auth/add-account/start`, `POST /auth/switch-account`, `POST /auth/logout-all` 19 20**infrastructure fixes** (PRs #710, #712, #714): 21these fixes came from reviewing [Bluesky's architecture deep dive](https://newsletter.pragmaticengineer.com/p/bluesky) which highlighted connection/resource management as scaling concerns. applied learnings to our own codebase: 22- identified Neon serverless connection overhead (~77ms per connection) via Logfire 23- cached `async_sessionmaker` per engine instead of recreating on every request (PR #712) 24- changed `_refresh_locks` from unbounded dict to LRUCache (10k max, 1hr TTL) to prevent memory leak (PR #710) 25- pass db session through auth helpers to reduce connections per request (PR #714) 26- result: `/auth/switch-account` ~1100ms → ~800ms, `/auth/me` ~940ms → ~720ms 27 28**frontend changes**: 29- UserMenu (desktop): collapsible accounts submenu with linked accounts, add account, logout all 30- ProfileMenu (mobile): dedicated accounts panel with avatars 31- fixed `invalidateAll()` not refreshing client-side loaded data by using `window.location.reload()` (PR #713) 32 33**docs**: [research/2026-01-03-multi-account-experience.md](docs/research/2026-01-03-multi-account-experience.md) 34 35--- 36 37### integration test harness (PR #744, Jan 9) 38 39**automated tests running against staging**: 40- pure Python audio generation (sine waves, no FFmpeg dependency) 41- multi-user test fixtures via `PLYR_TEST_TOKEN_1/2/3` secrets 42- track lifecycle tests: upload, edit, delete, search indexing 43- cross-user interaction tests: like/unlike, permission boundaries 44- GitHub Actions workflow triggers after staging deploy 45 46--- 47 48### track edit UX improvements (PRs #741-742, Jan 9) 49 50**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. 51 52**artwork management** (PR #742): 53- add ability to **remove track artwork** via new `remove_image` form field on `PATCH /tracks/{id}` 54- show **image preview** when selecting new artwork before saving 55- hover overlay on current artwork with trash icon to remove 56- "undo" option when artwork is marked for removal 57- clear status labels: "current artwork", "new artwork selected", "artwork will be removed" 58 59**button styling** (PR #742): 60- replace icon-only squares with labeled pill buttons (`edit`, `delete`) 61- subtle outlined save/cancel buttons in edit mode 62- fix global button hover styles bleeding into all buttons (scoped to form submit only) 63 64**shutdown fix** (PR #742): 65- add 2s timeouts to docket worker and service shutdown 66- prevents backend hanging on Ctrl+C or hot-reload during development 67 68**beartype fix** (PR #741): 69- `starlette.UploadFile` vs `fastapi.UploadFile` type mismatch was causing 500 errors on image upload 70- fixed by importing UploadFile from starlette in metadata_service.py 71 72--- 73 74### auth stabilization (PRs #734-736, Jan 6-7) 75 76**why**: multi-account support introduced edge cases where auth state could become inconsistent between frontend components, and sessions could outlive their refresh tokens. 77 78**session expiry alignment** (PR #734): 79- sessions now track refresh token lifetime and respect it during validation 80- prevents sessions from appearing valid after their underlying OAuth grant expires 81- dev token expiration handling aligned with same pattern 82 83**queue auth boundary fix** (PR #735): 84- queue component now uses shared layout auth state instead of localStorage session IDs 85- fixes race condition where queue could attempt authenticated requests before layout resolved auth 86- ensures remote queue snapshots don't inherit local update flags during hydration 87 88**playlist cover upload fix** (PR #736): 89- `R2Storage.save()` was rejecting `BytesIO` objects due to beartype's strict `BinaryIO` protocol checking 90- changed type hint to `BinaryIO | BytesIO` to explicitly accept both 91- found via Logfire: only 2 failures in production, both on Jan 3 92 93--- 94 95### timestamp deep links (PRs #739-740, Jan 8) 96 97**timestamped comment sharing** (PR #739): 98- timed comments now show share button on hover 99- copies URL with `?t=` parameter (e.g., `plyr.fm/track/123?t=45`) 100- visiting timestamped URL auto-seeks to that position on play 101 102**autoplay error suppression** (PR #740): 103- suppress browser autoplay errors when deep linking to timestamps 104- browsers block autoplay without user interaction; now fails silently 105 106--- 107 108### artist bio links (PRs #700-701, Jan 2) 109 110**links in artist bios now render as clickable** - supports full URLs and bare domains (e.g., "example.com"): 111- regex extracts URLs from bio text 112- bare domain/path URLs handled correctly 113- links open in new tab 114 115--- 116 117### copyright moderation improvements (PRs #703-704, Jan 2-3) 118 119**per legal advice**, redesigned copyright handling to reduce liability exposure: 120- **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 121- **raised threshold** (PR #703): copyright flag threshold increased from "any match" to configurable score (default 85%). controlled via `MODERATION_COPYRIGHT_SCORE_THRESHOLD` env var 122- **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 123- **observability** (PR #704): Logfire spans added to all notification paths (`send_dm`, `copyright_notification`) with error categorization (`dm_blocked`, `network`, `auth`, `unknown`) 124- **notification tracking**: `notified_at` field added to `copyright_scans` table to track which flags have been communicated 125 126**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. 127 128--- 129 130### ATProto OAuth permission sets (PRs #697-698, Jan 1-2) 131 132**permission sets enabled** - OAuth now uses `include:fm.plyr.authFullApp` instead of listing individual `repo:` scopes: 133- users see clean "plyr.fm" permission title instead of raw collection names 134- permission set lexicon published to `com.atproto.lexicon.schema` on plyr.fm authority repo 135- DNS TXT records at `_lexicon.plyr.fm` and `_lexicon.stg.plyr.fm` link namespaces to authority DID 136- fixed scope validation in atproto SDK fork to handle PDS permission expansion (`include:``repo?collection=`) 137 138**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. 139 140**docs**: [lexicons/overview.md](docs/lexicons/overview.md), [research/2026-01-01-atproto-oauth-permission-sets.md](docs/research/2026-01-01-atproto-oauth-permission-sets.md) 141 142--- 143 144### atprotofans supporters display (PRs #695-696, Jan 1) 145 146**supporters now visible on artist pages** - artists using atprotofans can show their supporters: 147- compact overlapping avatar circles (GitHub sponsors style) with "+N" overflow badge 148- clicks link to supporter's plyr.fm artist page (keeps users in-app) 149- `POST /artists/batch` endpoint enriches supporter DIDs with avatar_url from our Artist table 150- frontend fetches from atprotofans, enriches via backend, renders with consistent avatar pattern 151 152**route ordering fix** (PR #696): FastAPI was matching `/artists/batch` as `/{did}` with did="batch". moved POST route before the catchall GET route. 153 154--- 155 156### UI polish (PRs #692-694, Dec 31 - Jan 1) 157 158- **feed/library toggle** (PR #692): consistent header layout with toggle between feed and library views 159- **shuffle button moved** (PR #693): shuffle now in queue component instead of player controls 160- **justfile consistency** (PR #694): standardized `just run` across frontend/backend modules 161 162--- 163 164## Mid-to-Late January 2026 Work (Jan 11-31) 165 166### per-track PDS migration + UX polish (PRs #835-839, Jan 30-31) 167 168**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. 169 170**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. 171 172**backend changes (PR #838)**: 173- `GET /tracks/me/file-sizes` — parallel R2 HEAD requests (semaphore-capped at 10) to get byte sizes for the migration modal 174- `POST /pds-backfill/audio` now accepts optional `track_ids` body to backfill specific tracks (backward-compatible — no body = all eligible) 175- SSE progress stream includes `last_processed_track_id` and `last_status` for per-track updates 176 177**copy fixes (PRs #835-836)**: removed "R2" from user-facing text (settings toggle, upload notes). users see "plyr.fm storage" instead of infrastructure detail. 178 179**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. 180 181--- 182 183### PDS blob storage for audio (PRs #823-833, Jan 29) 184 185**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. 186 187**core implementation (PR #823)**: 188- new uploads: audio blob uploaded to PDS, BlobRef stored in track record 189- dual-write: R2 copy kept for streaming performance (PDS `getBlob` isn't CDN-optimized) 190- graceful fallback: if PDS rejects blob (size limit), track stays R2-only 191- gated tracks skip PDS (need auth-protected access) 192 193**database changes**: 194- `audio_storage`: "r2" | "pds" | "both" 195- `pds_blob_cid`: CID of blob on user's PDS 196- `pds_blob_size`: size in bytes 197 198**bug fixes and hardening (PRs #824-828)**: 199- fix atproto headers lost on DPoP retry (#824) 200- fail upload on unexpected PDS errors instead of silent fallback (#825) 201- add `blob:*/*` OAuth scope to both permission sets and granular paths (#826, #827) 202- remove PDS indicator from track UI — PDS will be the default, no need to badge it (#828) 203 204**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. 205 206**copyright DM fix (PR #831)**: removed misleading "0% confidence" from copyright notification DMs — the enterprise AudD API doesn't return confidence values. 207 208**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. 209 210**terms update (PR #832)**: clarified PDS delisting language in terms of service. 211 212**research**: documented emerging ATProto media service patterns from [community discourse](https://discourse.atprotocol.community/t/media-pds-service/297) — 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`. 213 214--- 215 216### PDS-based account creation (PRs #813-815, Jan 27) 217 218**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. 219 220**how it works**: 221- login page shows "create account" tab when feature is enabled 222- user selects a PDS (currently selfhosted.social) 223- OAuth flow uses `prompt=create` to trigger account creation on the PDS 224- after account creation, user is redirected back and logged in 225 226**implementation details**: 227- `/auth/pds-options` endpoint returns available PDS hosts from config 228- `/auth/start` accepts `pds_url` parameter for account creation flow 229- handle resolution falls back to PDS directly (via `com.atproto.repo.describeRepo`) when Bluesky AppView hasn't indexed the new account yet 230 231**configuration** (`AccountCreationSettings`): 232- `enabled`: feature flag for account creation 233- `recommended_pds`: list of PDS options with name, url, and description 234 235--- 236 237### lossless audio support (PRs #794-801, Jan 25) 238 239**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. 240 241**how it works**: 242- upload AIFF/FLAC → original saved to R2, transcoded MP3 created 243- database stores both `file_id` (transcoded) and `original_file_id` (lossless) 244- frontend detects browser capabilities via `canPlayType()` 245- Safari/native apps get lossless, Chrome/Firefox get transcoded MP3 246- lossless badge shown on track cards when browser supports the format 247 248**key changes**: 249- `original_file_id` and `original_file_type` added to Track model and API 250- audio endpoint serves either version based on requested file_id 251- feature-flagged via `lossless-uploads` user flag 252 253**bug fixes during rollout**: 254- PR #796: audio endpoint now queries by `file_id` OR `original_file_id` 255- PR #797: store actual extension (`.aif`) not normalized format name (`.aiff`) 256 257**UI polish (PRs #799-801)**: 258- lossless badge positioned in top-right corner of track card (not artwork) 259- subtle glowing animation draws attention to premium quality tracks 260- whole card gets accent-colored border treatment when lossless 261- theme-aware styling, responsive sizing, respects `prefers-reduced-motion` 262 263--- 264 265### auth check optimization (PRs #781-782, Jan 23) 266 267**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). 268 269**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). 270 271--- 272 273### remove SSR sensitive-images fetch (PR #785, Jan 24) 274 275**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). 276 277**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. 278 279**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. 280 281- deleted `+layout.server.ts` 282- simplified `+layout.ts` 283- updated pages to use `moderation.isSensitive()` singleton instead of SSR data 284 285--- 286 287### listen receipts (PR #773, Jan 22) 288 289**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: 290- `POST /tracks/{id}/share` creates tracked share link with unique 8-character code (48 bits entropy) 291- frontend captures `?ref=` param on page load, fires click event to backend 292- play endpoint accepts optional `ref` param to record play attribution 293- `GET /tracks/me/shares` returns paginated stats: visitors, listeners, anonymous counts 294 295**portal share stats section**: 296- expandable cards per share link with copyable tracked URL 297- visitors (who clicked) and listeners (who played) shown as avatar circles 298- individual interaction counts per user 299- self-clicks/plays filtered out to avoid inflating stats 300 301**data model**: 302- `ShareLink` table: code, track_id, creator_did, created_at 303- `ShareLinkEvent` table: share_link_id, visitor_did (nullable for anonymous), event_type (click/play) 304 305--- 306 307### handle display fix (PR #774, Jan 22) 308 309**DIDs were displaying instead of handles** in share link stats and other places (comments, track likers): 310- root cause: Artist records were only created during profile setup 311- users who authenticated but skipped setup had no Artist record 312- fix: create minimal Artist record (did, handle, avatar) during OAuth callback 313- profile setup now updates existing record instead of erroring 314 315--- 316 317### responsive embed v2 (PRs #771-772, Jan 20-21) 318 319**complete rewrite of embed CSS** using container queries and proportional scaling: 320 321**layout modes**: 322- **wide** (width >= 400px): side art, proportional sizing 323- **very wide** (width >= 600px): larger art, more breathing room 324- **square/tall** (aspect <= 1.2, width >= 200px): art on top, 2-line titles 325- **very tall** (aspect <= 0.7, width >= 200px): blurred background overlay 326- **narrow** (width < 280px): compact blurred background 327- **micro** (width < 200px): hide time labels and logo 328 329**key technical changes**: 330- all sizes use `clamp()` with `cqi` units (container query units) 331- grid-based header layout instead of absolute positioning 332- gradient overlay (top-heavy to bottom-heavy) for text readability 333 334--- 335 336### terms of service and privacy policy (PRs #567, #761-770, Jan 19-20) 337 338**legal foundation shipped** with ATProto-aware design: 339 340**terms cover**: 341- AT Protocol context (decentralized identity, user-controlled PDS) 342- content ownership (users retain ownership, plyr.fm gets license for streaming) 343- DMCA safe harbor with designated agent (DMCA-1069186) 344- federation disclaimer: audio files in blob storage we control, but ATProto records may persist on user's PDS 345 346**privacy policy**: 347- explicit third-party list with links (Cloudflare, Fly.io, Neon, Logfire, AudD, Anthropic, ATProtoFans) 348- data ownership clarity (DID, profile, tracks on user's PDS) 349- MIT license added to repo 350 351**acceptance flow** (TermsOverlay component): 352- shown on first login if `terms_accepted_at` is null 353- 4-bullet summary with links to full documents 354- "I Accept" or "Decline & Logout" options 355- `POST /account/accept-terms` records timestamp 356 357**polish PRs** (#761-770): corrected ATProto vs "our servers" terminology, standardized AT Protocol naming, added email fallbacks, capitalized sentence starts 358 359--- 360 361### content gating research (Jan 18) 362 363researched ATProtoFans architecture and JSONLogic rule evaluation. documented findings in `docs/content-gating-roadmap.md`: 364- current ATProtoFans records and API (supporter, supporterProof, brokerProof, terms) 365- the gap: terms exist but aren't exposed via validateSupporter 366- how magazi uses datalogic-rs for flexible rule evaluation 367- open questions about upcoming metadata extensions 368 369no implementation changes - waiting to align with what ATProtoFans will support. 370 371### logout modal UX (PRs #755-757, Jan 17-18) 372 373**tooltip scroll fix** (PR #755): 374- leftmost avatar in likers/commenters tooltip was clipped with no way to scroll to it 375- changed `justify-content: center` to `flex-start` so most recent (leftmost) is always visible 376 377**logout modal copy** (PRs #756-757): 378- simplified from two confusing questions to one clear question 379- before: "stay logged in?" + "you're logging out of @handle?" 380- after: "switch accounts?" 381- "logout completely" → "log out of all accounts" 382 383--- 384 385### idempotent teal scrobbles (PR #754, Jan 16) 386 387**prevents duplicate scrobbles** when same play is submitted multiple times: 388- use `putRecord` with deterministic TID rkeys derived from `playedTime` instead of `createRecord` 389- network retries, multiple teal-compatible services, or background task retries won't create duplicates 390- adds `played_time` parameter to `build_teal_play_record` for deterministic record keys 391 392--- 393 394### avatar refresh and tooltip polish (PRs #750-752, Jan 13) 395 396**avatar refresh from anywhere** (PR #751): 397- previously, stale avatar URLs were only fixed when visiting the artist detail page 398- now any broken avatar triggers a background refresh from Bluesky 399- shared `avatar-refresh.svelte.ts` provides global cache and request deduplication 400- works from: track items, likers tooltip, commenters tooltip, profile page 401 402**interactive tooltips** (PR #750): 403- hovering on like count shows avatar circles of users who liked 404- hovering on comment count shows avatar circles of commenters 405- lazy-loaded with 5-minute cache, invalidated when likes/comments change 406- elegant centered layout with horizontal scroll when needed 407 408**UX polish** (PR #752): 409- added prettier config with `useTabs: true` to match existing style 410- reduced avatar hover effect intensity (scale 1.2 → 1.08) 411- fixed avatar hover clipping at tooltip edge (added top padding) 412- track title now links to detail page (color change on hover) 413 414--- 415 416### copyright flagging fix (PR #748, Jan 12) 417 418**switched from score-based to dominant match detection**: 419- AudD's enterprise API doesn't return confidence scores (always 0) 420- previous threshold-based detection was broken 421- new approach: flag if one song appears in >= 30% of matched segments 422- filters false positives where random segments match different songs 423 424--- 425 426### Neon cold start fix (Jan 11) 427 428**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. 429 430**fix**: disabled scale-to-zero on `plyr-prd` via Neon console. this is the [recommended approach](https://neon.com/blog/6-best-practices-for-running-neon-in-production) for production workloads. 431 432**configuration**: 433- `plyr-prd`: scale-to-zero **disabled** (`suspend_timeout_seconds: -1`) 434- `plyr-stg`, `plyr-dev`: scale-to-zero enabled (cold starts acceptable) 435 436**docs**: updated [connection-pooling.md](docs/backend/database/connection-pooling.md) with production guidance and how to verify settings via Neon MCP. 437 438closes #733