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