audio streaming app
plyr.fm
1# plyr.fm Status History - December 2025
2
3## Early December 2025 Work (Dec 1-7)
4
5### playlists, ATProto sync, and library hub (feat/playlists branch, PR #499, Dec 6-7)
6
7**status**: shipped and deployed.
8
9**playlists** (full CRUD):
10- `playlists` and `playlist_tracks` tables with Alembic migration
11- `POST /lists/playlists` - create playlist
12- `PUT /lists/playlists/{id}` - rename playlist
13- `DELETE /lists/playlists/{id}` - delete playlist
14- `POST /lists/playlists/{id}/tracks` - add track to playlist
15- `DELETE /lists/playlists/{id}/tracks/{track_id}` - remove track
16- `PUT /lists/playlists/{id}/tracks/reorder` - reorder tracks
17- `POST /lists/playlists/{id}/cover` - upload cover art
18- playlist detail page (`/playlist/[id]`) with edit modal, drag-and-drop reordering
19- playlists in global search results
20- "add to playlist" menu on tracks (filters out current playlist when on playlist page)
21- inline "create new playlist" in add-to menu (creates playlist and adds track in one action)
22- playlist sharing with OpenGraph link previews
23
24**ATProto integration**:
25- `fm.plyr.list` lexicon for syncing playlists and albums to user PDSes
26- `fm.plyr.actor.profile` lexicon for syncing artist profiles
27- automatic sync of albums, liked tracks, and profile on login (fire-and-forget)
28- scope upgrade OAuth flow for teal.fm integration (#503)
29
30**library hub** (`/library`):
31- unified page with tabs: liked, playlists, albums
32- create playlist modal with inline form
33- consistent card layouts across sections
34- nav changed from "liked" → "library"
35
36**user experience**:
37- public liked pages for any user (`/liked/[handle]`)
38- `show_liked_on_profile` preference
39- portal album/playlist section visual consistency
40- toast notifications for all mutations (playlist CRUD, profile updates)
41- z-index fixes for dropdown menus
42
43**accessibility fixes**:
44- fixed 32 svelte-check warnings (ARIA roles, button nesting, unused CSS)
45- proper roles on modals, menus, and drag-drop elements
46
47**design decisions**:
48- lists are generic ordered collections of any ATProto records
49- `listType` semantically categorizes (album, playlist, liked) but doesn't restrict content
50- array order = display order, reorder via `putRecord`
51- strongRef (uri + cid) for content-addressable item references
52- "library" = umbrella term for personal collections
53
54**sync architecture**:
55- **profile, albums, liked tracks**: synced on login via `GET /artists/me` (fire-and-forget background tasks)
56- **playlists**: synced on create/modify (not at login) - avoids N playlist syncs on every login
57- sync tasks don't block the response (~300-500ms for the endpoint, PDS calls happen in background)
58- putRecord calls take ~50-100ms each, with automatic DPoP nonce retry on 401
59
60**file size audit** (candidates for future modularization):
61- `portal/+page.svelte`: 2,436 lines (58% CSS)
62- `playlist/[id]/+page.svelte`: 1,644 lines (48% CSS)
63- `api/lists.py`: 855 lines
64- CSS-heavy files could benefit from shared style extraction in future
65
66**related issues**: #221, #146, #498
67
68---
69
70### list reordering UI (feat/playlists branch, Dec 7)
71
72**what's done**:
73- `PUT /lists/liked/reorder` endpoint - reorder user's liked tracks list
74- `PUT /lists/{rkey}/reorder` endpoint - reorder any list by ATProto rkey
75- both endpoints take `items` array of strongRefs (uri + cid) in desired order
76- liked tracks page (`/liked`) now has "reorder" button for authenticated users
77- album page has "reorder" button for album owner (if album has ATProto list record)
78- drag-and-drop reordering on desktop (HTML5 drag API)
79- touch reordering on mobile (6-dot grip handle, same pattern as queue)
80- visual feedback during drag: `.drag-over` and `.is-dragging` states
81- saves order to ATProto via `putRecord` when user clicks "done"
82- added `atproto_record_cid` to TrackResponse schema (needed for strongRefs)
83- added `artist_did` and `list_uri` to AlbumMetadata response
84
85**UX design**:
86- button toggles between "reorder" and "done" states
87- in edit mode, drag handles appear next to each track
88- saving shows spinner, success/error toast on completion
89- only owners can see/use reorder button (liked list = current user, album = artist)
90
91---
92
93### scope upgrade OAuth flow (feat/scope-invalidation branch, Dec 7) - merged to feat/playlists
94
95**problem**: when users enabled teal.fm scrobbling, the app showed a passive "please log out and back in" message because the session lacked the required OAuth scopes. this was confusing UX.
96
97**solution**: immediate OAuth handshake when enabling features that require new scopes (same pattern as developer tokens).
98
99**what's done**:
100- `POST /auth/scope-upgrade/start` endpoint initiates OAuth with expanded scopes
101- `pending_scope_upgrades` table tracks in-flight upgrades (10min TTL)
102- callback replaces old session with new one, redirects to `/settings?scope_upgraded=true`
103- frontend shows spinner during redirect, success toast on return
104- fixed preferences bug where toggling settings reset theme to dark mode
105
106**code quality**:
107- eliminated bifurcated OAuth clients (`oauth_client` vs `oauth_client_with_teal`)
108- replaced with `get_oauth_client(include_teal=False)` factory function
109- at ~17 OAuth flows/day, instantiation cost is negligible
110- explicit scope selection at call site instead of module-level state
111
112**developer token UX**:
113- full-page overlay when returning from OAuth after creating a developer token
114- token displayed prominently with warning that it won't be shown again
115- copy button with success feedback, link to python SDK docs
116- prevents users from missing their token (was buried at bottom of page)
117
118**test fixes**:
119- fixed connection pool exhaustion in tests (was hitting Neon's connection limit)
120- added `DATABASE_POOL_SIZE=2`, `DATABASE_MAX_OVERFLOW=0` to pytest env vars
121- dispose cached engines after each test to prevent connection accumulation
122- fixed mock function signatures for `refresh_session` tests
123
124**tests**: 4 new tests for scope upgrade flow, all 281 tests passing
125
126---
127
128### settings consolidation (PR #496, Dec 6)
129
130**problem**: user preferences were scattered across multiple locations with confusing terminology:
131- SensitiveImage tooltip said "enable in portal" but mobile menu said "profile"
132- clicking gear icon (SettingsMenu) only showed appearance/playback, not all settings
133- portal mixed content management with preferences
134
135**solution**: clear separation between **settings** (preferences) and **portal** (content & data):
136
137| page | purpose |
138|------|---------|
139| `/settings` | preferences: theme, accent color, auto-advance, sensitive artwork, timed comments, teal.fm, developer tokens |
140| `/portal` | your content & data: profile, tracks, albums, export, delete account |
141
142**changes**:
143- created dedicated `/settings` route consolidating all user preferences
144- slimmed portal to focus on content management
145- added "all settings →" link to SettingsMenu and ProfileMenu
146- renamed mobile menu "profile" → "portal" to match route
147- moved delete account to portal's "your data" section (it's about data, not preferences)
148- fixed `font-family: inherit` on all settings page buttons
149- updated SensitiveImage tooltip: "enable in settings"
150
151---
152
153### bufo easter egg improvements (PRs #491-492, Dec 6)
154
155**what shipped**:
156- configurable exclude/include patterns via env vars for bufo easter egg
157- `BUFO_EXCLUDE_PATTERNS`: regex patterns to filter out (default: `^bigbufo_`)
158- `BUFO_INCLUDE_PATTERNS`: allowlist that overrides exclude (default: `bigbufo_0_0`, `bigbufo_2_1`)
159- cache key now includes patterns so config changes take effect immediately
160
161**reusable type**:
162- added `CommaSeparatedStringSet` type for parsing comma-delimited env vars into sets
163- uses pydantic `BeforeValidator` with `Annotated` pattern (not class-coupled validators)
164- handles: `VAR=a,b,c` → `{"a", "b", "c"}`
165
166**context**: bigbufo tiles are 4x4 grid fragments that looked weird floating individually. now excluded by default, with two specific tiles allowed through.
167
168**thread**: https://bsky.app/profile/zzstoatzzdevlog.bsky.social/post/3m7e3ndmgwl2m
169
170---
171
172### mobile artwork upload fix (PR #489, Dec 6)
173
174**problem**: artwork uploads from iOS Photos library silently failed - track uploaded successfully but without artwork.
175
176**root cause**: iOS stores photos in HEIC format. when selected, iOS converts content to JPEG but often keeps the `.heic` filename. backend validated format using only filename extension → rejected as "unsupported format".
177
178**fix**:
179- backend now prefers MIME content_type over filename extension for format detection
180- added `ImageFormat.from_content_type()` method
181- frontend uses `accept="image/*"` for broader iOS compatibility
182
183---
184
185### sensitive image moderation (PRs #471-488, Dec 5-6)
186
187**what shipped**:
188- `sensitive_images` table to flag problematic images by R2 `image_id` or external URL
189- `show_sensitive_artwork` user preference (default: hidden, toggle in portal → "your data")
190- flagged images blurred everywhere: track lists, player, artist pages, likers tooltip, search results, embeds
191- Media Session API (CarPlay, lock screen, control center) respects sensitive preference
192- SSR-safe filtering: link previews (og:image) exclude sensitive images on track, artist, and album pages
193- likers tooltip UX: max-height with scroll, hover interaction fix, viewport-aware flip positioning
194- likers tooltip z-index: elevates entire track-container when tooltip open (prevents sibling tracks bleeding through)
195
196**how it works**:
197- frontend fetches `/moderation/sensitive-images` and stores flagged IDs/URLs
198- `SensitiveImage` component wraps images and checks against flagged list
199- server-side check via `+layout.server.ts` for meta tag filtering
200- users can opt-in to view sensitive artwork via portal toggle
201
202**coverage** (PR #488):
203
204| context | approach |
205|---------|----------|
206| DOM images needing blur | `SensitiveImage` component |
207| small avatars in lists | `SensitiveImage` with `compact` prop |
208| SSR meta tags (og:image) | `checkImageSensitive()` function |
209| non-DOM APIs (media session) | direct `isSensitive()` + `showSensitiveArtwork` check |
210
211**moderation workflow**:
212- admin adds row to `sensitive_images` with `image_id` (R2) or `url` (external)
213- images are blurred immediately for all users
214- users who enable `show_sensitive_artwork` see unblurred images
215
216---
217
218### teal.fm scrobbling integration (PR #467, Dec 4)
219
220**what shipped**:
221- native teal.fm scrobbling: when users enable the toggle, plays are recorded to their PDS using teal's ATProto lexicons
222- scrobble triggers at 30% or 30 seconds (whichever comes first) - same threshold as play counts
223- user preference stored in database, toggleable from portal → "your data"
224- settings link to pdsls.dev so users can view their scrobble records
225
226**lexicons used**:
227- `fm.teal.alpha.feed.play` - individual play records (scrobbles)
228- `fm.teal.alpha.actor.status` - now-playing status updates
229
230**configuration** (all optional, sensible defaults):
231- `TEAL_ENABLED` (default: `true`) - feature flag for entire integration
232- `TEAL_PLAY_COLLECTION` (default: `fm.teal.alpha.feed.play`)
233- `TEAL_STATUS_COLLECTION` (default: `fm.teal.alpha.actor.status`)
234
235**code quality improvements** (same PR):
236- added `settings.frontend.domain` computed property for environment-aware URLs
237- extracted `get_session_id_from_request()` utility for bearer token parsing
238- added field validator on `DeveloperTokenInfo.session_id` for auto-truncation
239- applied walrus operators throughout auth and playback code
240- fixed now-playing endpoint firing every 1 second (fingerprint update bug in scheduled reports)
241
242**documentation**: `backend/src/backend/_internal/atproto/teal.py` contains inline docs on the scrobbling flow
243
244---
245
246### unified search (PR #447, Dec 3)
247
248**what shipped**:
249- `Cmd+K` (mac) / `Ctrl+K` (windows/linux) opens search modal from anywhere
250- fuzzy matching across tracks, artists, albums, and tags using PostgreSQL `pg_trgm`
251- results grouped by type with relevance scores (0.0-1.0)
252- keyboard navigation (arrow keys, enter, esc)
253- artwork/avatars displayed with lazy loading and fallback icons
254- glassmorphism modal styling with backdrop blur
255- debounced input (150ms) with client-side validation
256
257**database**:
258- enabled `pg_trgm` extension for trigram-based similarity search
259- GIN indexes on `tracks.title`, `artists.handle`, `artists.display_name`, `albums.title`, `tags.name`
260
261**documentation**: `docs/frontend/search.md`, `docs/frontend/keyboard-shortcuts.md`
262
263**follow-up polish** (PRs #449-463):
264- mobile search icon in header (PRs #455-456)
265- theme-aware modal styling with styled scrollbar (#450)
266- ILIKE fallback for substring matches when trigram fails (#452)
267- tag collapse with +N button (#453)
268- input focus fix: removed `visibility: hidden` so focus works on open (#457, #463)
269- album artwork fallback in player when track has no image (#458)
270- rate limiting exemption for now-playing endpoints (#460)
271- `--no-dev` flag for release command to prevent dev dep installation (#461)
272
273---
274
275### light/dark theme and mobile UX overhaul (Dec 2-3)
276
277**theme system** (PR #441):
278- replaced hardcoded colors across 35 files with CSS custom properties
279- semantic tokens: `--bg-primary`, `--text-secondary`, `--accent`, etc.
280- theme switcher in settings: dark / light / system (follows OS preference)
281- removed zen mode feature (superseded by proper theme support)
282
283**mobile UX improvements** (PR #443):
284- new `ProfileMenu` component — collapses profile, upload, settings, logout into touch-optimized menu (44px tap targets)
285- dedicated `/upload` page — extracted from portal for cleaner mobile flow
286- portal overhaul — tighter forms, track detail links under artwork, fixed icon alignment
287- standardized section headers across home and liked tracks pages
288
289**player scroll timing fix** (PR #445):
290- reduced title scroll cycle from 10s → 8s, artist/album from 15s → 10s
291- eliminated 1.5s invisible pause at end of scroll animation
292- fixed duplicate upload toast (was firing twice on success)
293- upload success toast now includes "view track" link
294
295**CI optimization** (PR #444):
296- pre-commit hooks now skip based on changed paths
297- result: ~10s for most PRs instead of ~1m20s
298- only installs tooling (uv, bun) needed for changed directories
299
300---
301
302### tag filtering system and SDK tag support (Dec 2)
303
304**tag filtering** (PRs #431-434):
305- users can now hide tracks by tag via eye icon filter in discovery feed
306- preferences centralized in root layout (fetched once, shared across app)
307- `HiddenTagsFilter` component with expandable UI for managing hidden tags
308- default hidden tags: `["ai"]` for new users
309- tag detail pages at `/tag/[name]` with all tracks for that tag
310- clickable tag badges on tracks navigate to tag pages
311
312**navigation fix** (PR #435):
313- fixed tag links interrupting audio playback
314- root cause: `stopPropagation()` on links breaks SvelteKit's client-side router
315- documented pattern in `docs/frontend/navigation.md` to prevent recurrence
316
317**SDK tag support** (plyr-python-client v0.0.1-alpha.10):
318- added `tags: set[str]` parameter to `upload()` in SDK
319- added `-t/--tag` CLI option (can be used multiple times)
320- updated MCP `upload_guide` prompt with tag examples
321- status maintenance workflow now tags AI-generated podcasts with `ai` (#436)
322
323**tags in detail pages** (PR #437):
324- track detail endpoint (`/tracks/{id}`) now returns tags
325- album detail endpoint (`/albums/{handle}/{slug}`) now returns tags for all tracks
326- track detail page displays clickable tag badges
327
328**bufo easter egg** (PR #438, improved in #491-492):
329- tracks tagged with `bufo` trigger animated toad GIFs on the detail page
330- uses track title as semantic search query against [find-bufo API](https://find-bufo.fly.dev/)
331- toads are semantically matched to the song's vibe (e.g., "Happy Vibes" gets happy toads)
332- results cached in localStorage (1 week TTL) to minimize API calls
333- `TagEffects` wrapper component provides extensibility for future tag-based plugins
334- respects `prefers-reduced-motion`; fails gracefully if API unavailable
335- configurable exclude/include patterns via env vars (see Dec 6 entry above)
336
337---
338
339### queue touch reordering and header stats fix (Dec 2)
340
341**queue mobile UX** (PR #428):
342- added 6-dot drag handle to queue items for touch-friendly reordering
343- implemented touch event handlers for mobile drag-and-drop
344- track follows finger during drag with smooth translateY transform
345- drop target highlights while dragging over other tracks
346
347**header stats positioning** (PR #426):
348- fixed platform stats not adjusting when queue sidebar opens/closes
349- added `--queue-width` CSS custom property updated dynamically
350- stats now shift left with smooth transition when queue opens
351
352---
353
354### connection pool resilience for Neon cold starts (Dec 2)
355
356**incident**: ~5 minute API outage (01:55-02:00 UTC) - all requests returned 500 errors
357
358**root cause**: Neon serverless cold start after 5 minutes of idle traffic
359- queue listener heartbeat detected dead connection, began reconnection
360- first 5 user requests each held a connection waiting for Neon to wake up (3-5 min each)
361- with pool_size=5 and max_overflow=0, pool exhausted immediately
362- all subsequent requests got `QueuePool limit of size 5 overflow 0 reached`
363
364**fix**:
365- increased `pool_size` from 5 → 10 (handle more concurrent cold start requests)
366- increased `max_overflow` from 0 → 5 (allow burst to 15 connections)
367- increased `connection_timeout` from 3s → 10s (wait for Neon wake-up)
368
369**related**: this is a recurrence of the Nov 17 incident. that fix addressed the queue listener's asyncpg connection but not the SQLAlchemy pool connections.
370
371---
372
373### now-playing API (PR #416, Dec 1)
374
375**what shipped**:
376- `GET /now-playing/{did}` and `GET /now-playing/by-handle/{handle}` endpoints
377- returns track metadata, playback position, timestamp
378- 204 when nothing playing, 200 with track data otherwise
379
380**teal.fm integration**:
381- native scrobbling shipped in PR #467 (Dec 4) - plyr.fm writes directly to user's PDS
382- Piper integration (external polling) still open: https://github.com/teal-fm/piper/pull/27
383
384---
385
386### admin UI improvements for moderation (PRs #408-414, Dec 1)
387
388**what shipped**:
389- dropdown menu for false positive reasons (fingerprint noise, original artist, fair use, other)
390- artist/track links open in new tabs for verification
391- AuDD score normalization (scores shown as 0-100 range)
392- filter controls to show only high-confidence matches
393- form submission fixes for htmx POST requests
394
395---
396
397### ATProto labeler and copyright moderation (PRs #382-395, Nov 29-Dec 1)
398
399**what shipped**:
400- standalone labeler service integrated into moderation Rust service
401- implements `com.atproto.label.queryLabels` and `subscribeLabels` XRPC endpoints
402- k256 ECDSA signing for cryptographic label verification
403- web interface at `/admin` for reviewing copyright flags
404- htmx for server-rendered interactivity
405- integrates with AuDD enterprise API for audio fingerprinting
406- fire-and-forget background task on track upload
407- review workflow with resolution tracking (violation, false_positive, original_artist)
408
409**initial review results** (25 flagged tracks):
410- 8 violations (actual copyright issues)
411- 11 false positives (fingerprint noise)
412- 6 original artists (people uploading their own distributed music)
413
414**documentation**: see `docs/moderation/atproto-labeler.md`
415
416---
417
418## Mid-December 2025 Work (Dec 8-16)
419
420### visual customization (PRs #595-596, Dec 16)
421
422**custom backgrounds** (PR #595):
423- users can set a custom background image URL in settings with optional tiling
424- new "playing artwork as background" toggle - uses current track's artwork as blurred page background
425- glass effect styling for track items (translucent backgrounds, subtle shadows)
426- new `ui_settings` JSONB column in preferences for extensible UI settings
427
428**bug fix** (PR #596):
429- removed 3D wheel scroll effect that was blocking like/share button clicks
430- root cause: `translateZ` transforms created z-index stacking that intercepted pointer events
431
432---
433
434### performance & UX polish (PRs #586-593, Dec 14-15)
435
436**performance improvements** (PRs #590-591):
437- removed moderation service call from `/tracks/` listing endpoint
438- removed copyright check from tag listing endpoint
439- faster page loads for track feeds
440
441**moderation agent** (PRs #586, #588):
442- added moderation agent script with audit trail support
443- improved moderation prompt and UI layout
444
445**bug fixes** (PRs #589, #592, #593):
446- fixed liked state display on playlist detail page
447- preserved album track order during ATProto sync
448- made header sticky on scroll for better mobile navigation
449
450**iOS Safari fixes** (PRs #573-576):
451- fixed AddToMenu visibility issue on iOS Safari
452- menu now correctly opens upward when near viewport bottom
453
454---
455
456### mobile UI polish & background task expansion (PRs #558-572, Dec 10-12)
457
458**background task expansion** (PRs #558, #561):
459- moved like/unlike and comment PDS writes to docket background tasks
460- API responses now immediate; PDS sync happens asynchronously
461- added targeted album list sync background task for ATProto record updates
462
463**performance caching** (PR #566):
464- added Redis cache for copyright label lookups (5-minute TTL)
465- fixed 2-3s latency spikes on `/tracks/` endpoint
466- batch operations via `mget`/pipeline for efficiency
467
468**mobile UX improvements** (PRs #569, #572):
469- mobile action menus now open from top with all actions visible
470- UI polish for album and artist pages on small screens
471
472**misc** (PRs #559, #562, #563, #570):
473- reduced docket Redis polling from 250ms to 5s (lower resource usage)
474- added atprotofans support link mode for ko-fi integration
475- added alpha badge to header branding
476- fixed web manifest ID for PWA stability
477
478---
479
480### confidential OAuth client (PRs #578, #580-582, Dec 12-13)
481
482**confidential client support** (PR #578):
483- implemented ATProto OAuth confidential client using `private_key_jwt` authentication
484- when `OAUTH_JWK` is configured, plyr.fm authenticates with a cryptographic key
485- confidential clients earn 180-day refresh tokens (vs 2-week for public clients)
486- added `/.well-known/jwks.json` endpoint for public key discovery
487- updated `/oauth-client-metadata.json` with confidential client fields
488
489**bug fixes** (PRs #580-582):
490- fixed client assertion JWT to use Authorization Server's issuer as `aud` claim (not token endpoint URL)
491- fixed JWKS endpoint to preserve `kid` field from original JWK
492- fixed `OAuthClient` to pass `client_secret_kid` for JWT header
493
494**atproto fork updates** (zzstoatzz/atproto#6, #7):
495- added `issuer` parameter to `_make_token_request()` for correct `aud` claim
496- added `client_secret_kid` parameter to include `kid` in client assertion JWT header
497
498**outcome**: users now get 180-day refresh tokens, and "remember this account" on the PDS authorization page works (auto-approves subsequent logins). see #583 for future work on account switching via OAuth `prompt` parameter.
499
500---
501
502### pagination & album management (PRs #550-554, Dec 9-10)
503
504**tracks list pagination** (PR #554):
505- cursor-based pagination on `/tracks/` endpoint (default 50 per page)
506- infinite scroll on homepage using native IntersectionObserver
507- zero new dependencies - uses browser APIs only
508- pagination state persisted to localStorage for fast subsequent loads
509
510**album management improvements** (PRs #550-552, #557):
511- album delete and track reorder fixes
512- album page edit mode matching playlist UX (inline title editing, cover upload)
513- optimistic UI updates for album title changes (instant feedback)
514- ATProto record sync when album title changes (updates all track records + list record)
515- fixed album slug sync on rename (prevented duplicate albums when adding tracks)
516
517**playlist show on profile** (PR #553):
518- restored "show on profile" toggle that was lost during inline editing refactor
519- users can now control whether playlists appear on their public profile
520
521---
522
523### public cost dashboard (PRs #548-549, Dec 9)
524
525- `/costs` page showing live platform infrastructure costs
526- daily export to R2 via GitHub Action, proxied through `/stats/costs` endpoint
527- dedicated `plyr-stats` R2 bucket with public access (shared across environments)
528- includes fly.io, neon, cloudflare, and audd API costs
529- ko-fi integration for community support
530
531### docket background tasks & concurrent exports (PRs #534-546, Dec 9)
532
533**docket integration** (PRs #534, #536, #539):
534- migrated background tasks from inline asyncio to docket (Redis-backed task queue)
535- copyright scanning, media export, ATProto sync, and teal scrobbling now run via docket
536- graceful fallback to asyncio for local development without Redis
537- parallel test execution with xdist template databases (#540)
538
539**concurrent export downloads** (PR #545):
540- exports now download tracks in parallel (up to 4 concurrent) instead of sequentially
541- significantly faster for users with many tracks or large files
542- zip creation remains sequential (zipfile constraint)
543
544**ATProto refactor** (PR #534):
545- reorganized ATProto record code into `_internal/atproto/records/` by lexicon namespace
546- extracted `client.py` for low-level PDS operations
547- cleaner separation between plyr.fm and teal.fm lexicons
548
549**documentation & observability**:
550- AudD API cost tracking dashboard (#546)
551- promoted runbooks from sandbox to `docs/runbooks/`
552- updated CLAUDE.md files across the codebase
553
554---
555
556### artist support links & inline playlist editing (PRs #520-532, Dec 8)
557
558**artist support link** (PR #532):
559- artists can set a support URL (Ko-fi, Patreon, etc.) in their portal profile
560- support link displays as a button on artist profile pages next to the share button
561- URLs validated to require https:// prefix
562
563**inline playlist editing** (PR #531):
564- edit playlist name and description directly on playlist detail page
565- click-to-upload cover art replacement without modal
566- cleaner UX - no more edit modal popup
567
568**platform stats enhancements** (PRs #522, #528):
569- total duration displayed in platform stats (e.g., "42h 15m of music")
570- duration shown per artist in analytics section
571- combined stats and search into single centered container for cleaner layout
572
573**navigation & data loading fixes** (PR #527):
574- fixed stale data when navigating between detail pages of the same type
575- e.g., clicking from one artist to another now properly reloads data
576
577**copyright moderation improvements** (PR #480):
578- enhanced moderation workflow for copyright claims
579- improved labeler integration
580
581**status maintenance workflow** (PR #529):
582- automated status maintenance using claude-code-action
583- reviews merged PRs and updates STATUS.md narratively
584
585---
586
587### playlist fast-follow fixes (PRs #507-519, Dec 7-8)
588
589**public playlist viewing** (PR #519):
590- playlists now publicly viewable without authentication
591- ATProto records are public by design - auth was unnecessary for read access
592- shared playlist URLs no longer redirect unauthenticated users to homepage
593
594**inline playlist creation** (PR #510):
595- clicking "create new playlist" from AddToMenu previously navigated to `/library?create=playlist`
596- this caused SvelteKit to reinitialize the layout, destroying the audio element and stopping playback
597- fix: added inline create form that creates playlist and adds track in one action without navigation
598
599**UI polish** (PRs #507-509, #515):
600- include `image_url` in playlist SSR data for og:image link previews
601- invalidate layout data after token exchange - fixes stale auth state after login
602- fixed stopPropagation blocking "create new playlist" link clicks
603- detail page button layouts: all buttons visible on mobile, centered AddToMenu on track detail
604- AddToMenu smart positioning: menu opens upward when near viewport bottom
605
606**documentation** (PR #514):
607- added lexicons overview documentation at `docs/lexicons/overview.md`
608- covers `fm.plyr.track`, `fm.plyr.like`, `fm.plyr.comment`, `fm.plyr.list`, `fm.plyr.actor.profile`
609
610---
611
612## Late December 2025 Work (Dec 17-31)
613
614### offline mode foundation (PRs #610-611, Dec 17)
615
616**experimental offline playback**:
617- storage layer using Cache API for audio bytes + IndexedDB for metadata
618- `GET /audio/{file_id}/url` backend endpoint returns direct R2 URLs for client-side caching
619- "auto-download liked" toggle in experimental settings section
620- Player checks for cached audio before streaming from R2
621
622---
623
624### UX polish (PRs #604-607, #613, #615, Dec 16-18)
625
626**login improvements** (PRs #604, #613):
627- login page now uses "internet handle" terminology for clarity
628- input normalization: strips `@` and `at://` prefixes automatically
629
630**artist page fixes** (PR #615):
631- track pagination on artist pages now works correctly
632- fixed mobile album card overflow
633
634**mobile + metadata** (PRs #605-607):
635- Open Graph tags added to tag detail pages for link previews
636- mobile modals now use full screen positioning
637- fixed `/tag/` routes in hasPageMetadata check
638
639---
640
641### beartype + moderation cleanup (PRs #617-619, Dec 19)
642
643**runtime type checking** (PR #619):
644- enabled beartype runtime type validation across the backend
645- catches type errors at runtime instead of silently passing bad data
646- test infrastructure improvements: session-scoped TestClient fixture (5x faster tests)
647
648**moderation cleanup** (PRs #617-618):
649- consolidated moderation code, addressing issues #541-543
650- `sync_copyright_resolutions` now runs automatically via docket Perpetual task
651- removed dead `init_db()` from lifespan (handled by alembic migrations)
652
653---
654
655### end-of-year sprint (PR #626, Dec 20)
656
657**focus**: two foundational systems with experimental implementations.
658
659| track | focus | status |
660|-------|-------|--------|
661| moderation | consolidate architecture, batch review, Claude vision | shipped |
662| atprotofans | supporter validation, content gating | shipped |
663
664**research docs**:
665- [moderation architecture overhaul](docs/research/2025-12-20-moderation-architecture-overhaul.md)
666- [atprotofans paywall integration](docs/research/2025-12-20-atprotofans-paywall-integration.md)
667
668---
669
670### rate limit moderation endpoint (PR #629, Dec 21)
671
672**incident response**: detected suspicious activity - 72 requests in 17 seconds from a single IP targeting `/moderation/sensitive-images`. added `10/minute` rate limit using existing slowapi infrastructure. this was the first real probe of our moderation endpoints, validating the decision to add rate limiting before it became a problem.
673
674---
675
676### supporter badges (PR #627, Dec 21-22)
677
678**phase 1 of atprotofans integration**:
679- supporter badge displays on artist pages when logged-in viewer supports the artist
680- calls atprotofans `validateSupporter` API directly from frontend (public endpoint)
681- badge only shows when viewer is authenticated and not viewing their own profile
682
683---
684
685### supporter-gated content (PR #637, Dec 22-23)
686
687**atprotofans paywall integration** - artists can now mark tracks as "supporters only":
688- tracks with `support_gate` require atprotofans validation before playback
689- non-supporters see lock icon and "become a supporter" CTA linking to atprotofans
690- artists can always play their own gated tracks
691
692**backend architecture**:
693- audio endpoint validates supporter status via atprotofans API before serving gated content
694- HEAD requests return 200/401/402 for pre-flight auth checks (avoids CORS issues with cross-origin redirects)
695- gated files stored in private R2 bucket, served via presigned URLs (SigV4 signatures)
696- `R2Storage.move_audio()` moves files between public/private buckets when toggling gate
697- background task handles bucket migration asynchronously
698- ATProto record syncs when toggling gate (updates `supportGate` field and `audioUrl` to point at our endpoint instead of R2)
699
700**frontend**:
701- `playback.svelte.ts` guards queue operations with gated checks BEFORE modifying state
702- clicking locked track shows toast with CTA - does NOT interrupt current playback
703- portal shows support gate toggle in track edit UI
704
705**key decision**: gated status is resolved server-side in track listings, not client-side. this means the lock icon appears instantly without additional API calls, and prevents information leakage about which tracks are gated vs which the user simply can't access.
706
707---
708
709### CSS design tokens (PRs #662-664, Dec 29-30)
710
711**design system foundations**:
712- border-radius tokens (`--radius-sm`, `--radius-md`, etc.)
713- typography scale tokens
714- consolidated form styles
715- documented in `docs/frontend/design-tokens.md`
716
717---
718
719### self-hosted redis (PRs #674-675, Dec 30)
720
721**replaced Upstash with self-hosted Redis on Fly.io** - ~$75/month → ~$4/month:
722- Upstash pay-as-you-go was charging per command (37M commands = $75) - discovered when reviewing December costs
723- docket's heartbeat mechanism is chatty by design, making pay-per-command pricing unsuitable
724- self-hosted Redis on 256MB Fly VMs costs fixed ~$2/month per environment
725- deployed `plyr-redis` (prod) and `plyr-redis-stg` (staging)
726- added CI workflow for redis deployments on merge
727
728**no state migration needed** - docket stores ephemeral task queue data, job progress lives in postgres.
729
730**incident (Dec 30)**: while optimizing redis overhead, a `heartbeat_interval=30s` change broke docket task execution. likes created Dec 29-30 were missing ATProto records. reverted in PR #669, documented in `docs/backend/background-tasks.md`. filed upstream: https://github.com/chrisguidry/docket/issues/267
731
732---
733
734### batch review system (PR #672, Dec 30)
735
736**moderation batch review UI** - mobile-friendly interface for reviewing flagged content:
737- filter by flag status, paginated results
738- auto-resolve flags for deleted tracks (PR #681)
739- full URL in DM notifications (PR #678)
740- required auth flow fix (PR #679) - review page was accessible without login
741
742---
743
744### top tracks homepage (PR #684, Dec 31)
745
746**homepage now shows top tracks** - quick access to popular content for new visitors.
747
748---
749
750### avatar sync on login (PR #685, Dec 31)
751
752**avatars now stay fresh** - previously set once at artist creation, causing stale/broken avatars throughout the app:
753- on login, avatar is refreshed from Bluesky and synced to both postgres and ATProto profile record
754- added `avatar` field to `fm.plyr.actor.profile` lexicon (optional, URI format)
755- one-time backfill script (`scripts/backfill_avatars.py`) refreshed 28 stale avatars in production
756
757---
758
759### automated image moderation (PRs #687-690, Dec 31)
760
761**Claude vision integration** for sensitive image detection:
762- images analyzed on upload via Claude Sonnet 4.5 (had to fix model ID - was using wrong identifier)
763- flagged images trigger DM notifications to admin
764- non-false-positive flags sent to batch review queue
765- complements the batch review system built earlier in the sprint
766
767---
768
769### header redesign (PR #691, Dec 31)
770
771**new header layout** with UserMenu dropdown and even spacing across the top bar.