+414
.status_history/2025-12.md
+414
.status_history/2025-12.md
···
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`
+71
-530
STATUS.md
+71
-530
STATUS.md
···
47
47
48
48
### December 2025
49
49
50
-
#### playlist release fast-follow fixes (PRs #507-510, Dec 7)
50
+
#### playlist fast-follow fixes (PRs #507-519, Dec 7-8)
51
51
52
-
**what shipped** (all merged to main):
53
-
- **PR #507**: include `image_url` in playlist SSR data for og:image link previews
54
-
- **PR #508**: invalidate layout data after token exchange - fixes stale auth state after login
55
-
- **PR #509**: playlist menus and link previews - fixed stopPropagation blocking links, added `/playlist/` to hasPageMetadata
56
-
- **PR #510**: inline playlist creation - replaced navigation-based create with inline form to avoid playback interruption
52
+
**public playlist viewing** (PR #519):
53
+
- playlists now publicly viewable without authentication
54
+
- ATProto records are public by design - auth was unnecessary for read access
55
+
- shared playlist URLs no longer redirect unauthenticated users to homepage
57
56
58
-
**the navigation bug** (PR #510):
59
-
- clicking "create new playlist" from AddToMenu/TrackActionsMenu previously navigated to `/library?create=playlist`
57
+
**inline playlist creation** (PR #510):
58
+
- clicking "create new playlist" from AddToMenu previously navigated to `/library?create=playlist`
60
59
- this caused SvelteKit to reinitialize the layout, destroying the audio element and stopping playback
61
60
- fix: added inline create form that creates playlist and adds track in one action without navigation
62
-
- same pattern applied to TrackActionsMenu (mobile bottom sheet menu)
63
61
64
-
---
62
+
**UI polish** (PRs #507-509, #515):
63
+
- include `image_url` in playlist SSR data for og:image link previews
64
+
- invalidate layout data after token exchange - fixes stale auth state after login
65
+
- fixed stopPropagation blocking "create new playlist" link clicks
66
+
- detail page button layouts: all buttons visible on mobile, centered AddToMenu on track detail
67
+
- AddToMenu smart positioning: menu opens upward when near viewport bottom
65
68
66
-
#### playlists, ATProto sync, and library hub (feat/playlists branch, PR #499, Dec 6-7)
69
+
**documentation** (PR #514):
70
+
- added lexicons overview documentation at `docs/lexicons/overview.md`
71
+
- covers `fm.plyr.track`, `fm.plyr.like`, `fm.plyr.comment`, `fm.plyr.list`, `fm.plyr.actor.profile`
72
+
73
+
---
67
74
68
-
**status**: shipped and deployed.
75
+
#### playlists, ATProto sync, and library hub (PR #499, Dec 6-7)
69
76
70
77
**playlists** (full CRUD):
71
-
- `playlists` and `playlist_tracks` tables with Alembic migration
72
-
- `POST /lists/playlists` - create playlist
73
-
- `PUT /lists/playlists/{id}` - rename playlist
74
-
- `DELETE /lists/playlists/{id}` - delete playlist
75
-
- `POST /lists/playlists/{id}/tracks` - add track to playlist
76
-
- `DELETE /lists/playlists/{id}/tracks/{track_id}` - remove track
77
-
- `PUT /lists/playlists/{id}/tracks/reorder` - reorder tracks
78
-
- `POST /lists/playlists/{id}/cover` - upload cover art
79
-
- playlist detail page (`/playlist/[id]`) with edit modal, drag-and-drop reordering
80
-
- playlists in global search results
81
-
- "add to playlist" menu on tracks (filters out current playlist when on playlist page)
82
-
- inline "create new playlist" in add-to menu (creates playlist and adds track in one action)
78
+
- create, rename, delete playlists with cover art upload
79
+
- add/remove/reorder tracks with drag-and-drop
80
+
- playlist detail page with edit modal
81
+
- "add to playlist" menu on tracks with inline create
83
82
- playlist sharing with OpenGraph link previews
84
83
85
84
**ATProto integration**:
86
-
- `fm.plyr.list` lexicon for syncing playlists and albums to user PDSes
87
-
- `fm.plyr.actor.profile` lexicon for syncing artist profiles
88
-
- automatic sync of albums, liked tracks, and profile on login (fire-and-forget)
89
-
- scope upgrade OAuth flow for teal.fm integration (#503)
85
+
- `fm.plyr.list` lexicon for syncing playlists/albums to user PDSes
86
+
- `fm.plyr.actor.profile` lexicon for artist profiles
87
+
- automatic sync of albums, liked tracks, profile on login
90
88
91
89
**library hub** (`/library`):
92
90
- unified page with tabs: liked, playlists, albums
93
-
- create playlist modal with inline form
94
-
- consistent card layouts across sections
95
91
- nav changed from "liked" โ "library"
96
92
97
-
**user experience**:
98
-
- public liked pages for any user (`/liked/[handle]`)
99
-
- `show_liked_on_profile` preference
100
-
- portal album/playlist section visual consistency
101
-
- toast notifications for all mutations (playlist CRUD, profile updates)
102
-
- z-index fixes for dropdown menus
103
-
104
-
**accessibility fixes**:
105
-
- fixed 32 svelte-check warnings (ARIA roles, button nesting, unused CSS)
106
-
- proper roles on modals, menus, and drag-drop elements
107
-
108
-
**design decisions**:
109
-
- lists are generic ordered collections of any ATProto records
110
-
- `listType` semantically categorizes (album, playlist, liked) but doesn't restrict content
111
-
- array order = display order, reorder via `putRecord`
112
-
- strongRef (uri + cid) for content-addressable item references
113
-
- "library" = umbrella term for personal collections
114
-
115
-
**sync architecture**:
116
-
- **profile, albums, liked tracks**: synced on login via `GET /artists/me` (fire-and-forget background tasks)
117
-
- **playlists**: synced on create/modify (not at login) - avoids N playlist syncs on every login
118
-
- sync tasks don't block the response (~300-500ms for the endpoint, PDS calls happen in background)
119
-
- putRecord calls take ~50-100ms each, with automatic DPoP nonce retry on 401
120
-
121
-
**file size audit** (candidates for future modularization):
122
-
- `portal/+page.svelte`: 2,436 lines (58% CSS)
123
-
- `playlist/[id]/+page.svelte`: 1,644 lines (48% CSS)
124
-
- `api/lists.py`: 855 lines
125
-
- CSS-heavy files could benefit from shared style extraction in future
126
-
127
-
**related issues**: #221, #146, #498
93
+
**related**: scope upgrade OAuth flow (PR #503), settings consolidation (PR #496)
128
94
129
95
---
130
96
131
-
#### list reordering UI (feat/playlists branch, Dec 7)
132
-
133
-
**what's done**:
134
-
- `PUT /lists/liked/reorder` endpoint - reorder user's liked tracks list
135
-
- `PUT /lists/{rkey}/reorder` endpoint - reorder any list by ATProto rkey
136
-
- both endpoints take `items` array of strongRefs (uri + cid) in desired order
137
-
- liked tracks page (`/liked`) now has "reorder" button for authenticated users
138
-
- album page has "reorder" button for album owner (if album has ATProto list record)
139
-
- drag-and-drop reordering on desktop (HTML5 drag API)
140
-
- touch reordering on mobile (6-dot grip handle, same pattern as queue)
141
-
- visual feedback during drag: `.drag-over` and `.is-dragging` states
142
-
- saves order to ATProto via `putRecord` when user clicks "done"
143
-
- added `atproto_record_cid` to TrackResponse schema (needed for strongRefs)
144
-
- added `artist_did` and `list_uri` to AlbumMetadata response
145
-
146
-
**UX design**:
147
-
- button toggles between "reorder" and "done" states
148
-
- in edit mode, drag handles appear next to each track
149
-
- saving shows spinner, success/error toast on completion
150
-
- only owners can see/use reorder button (liked list = current user, album = artist)
151
-
152
-
---
153
-
154
-
#### scope upgrade OAuth flow (feat/scope-invalidation branch, Dec 7) - merged to feat/playlists
155
-
156
-
**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.
157
-
158
-
**solution**: immediate OAuth handshake when enabling features that require new scopes (same pattern as developer tokens).
159
-
160
-
**what's done**:
161
-
- `POST /auth/scope-upgrade/start` endpoint initiates OAuth with expanded scopes
162
-
- `pending_scope_upgrades` table tracks in-flight upgrades (10min TTL)
163
-
- callback replaces old session with new one, redirects to `/settings?scope_upgraded=true`
164
-
- frontend shows spinner during redirect, success toast on return
165
-
- fixed preferences bug where toggling settings reset theme to dark mode
166
-
167
-
**code quality**:
168
-
- eliminated bifurcated OAuth clients (`oauth_client` vs `oauth_client_with_teal`)
169
-
- replaced with `get_oauth_client(include_teal=False)` factory function
170
-
- at ~17 OAuth flows/day, instantiation cost is negligible
171
-
- explicit scope selection at call site instead of module-level state
172
-
173
-
**developer token UX**:
174
-
- full-page overlay when returning from OAuth after creating a developer token
175
-
- token displayed prominently with warning that it won't be shown again
176
-
- copy button with success feedback, link to python SDK docs
177
-
- prevents users from missing their token (was buried at bottom of page)
178
-
179
-
**test fixes**:
180
-
- fixed connection pool exhaustion in tests (was hitting Neon's connection limit)
181
-
- added `DATABASE_POOL_SIZE=2`, `DATABASE_MAX_OVERFLOW=0` to pytest env vars
182
-
- dispose cached engines after each test to prevent connection accumulation
183
-
- fixed mock function signatures for `refresh_session` tests
184
-
185
-
**tests**: 4 new tests for scope upgrade flow, all 281 tests passing
186
-
187
-
---
188
-
189
-
#### settings consolidation (PR #496, Dec 6)
190
-
191
-
**problem**: user preferences were scattered across multiple locations with confusing terminology:
192
-
- SensitiveImage tooltip said "enable in portal" but mobile menu said "profile"
193
-
- clicking gear icon (SettingsMenu) only showed appearance/playback, not all settings
194
-
- portal mixed content management with preferences
195
-
196
-
**solution**: clear separation between **settings** (preferences) and **portal** (content & data):
197
-
198
-
| page | purpose |
199
-
|------|---------|
200
-
| `/settings` | preferences: theme, accent color, auto-advance, sensitive artwork, timed comments, teal.fm, developer tokens |
201
-
| `/portal` | your content & data: profile, tracks, albums, export, delete account |
202
-
203
-
**changes**:
204
-
- created dedicated `/settings` route consolidating all user preferences
205
-
- slimmed portal to focus on content management
206
-
- added "all settings โ" link to SettingsMenu and ProfileMenu
207
-
- renamed mobile menu "profile" โ "portal" to match route
208
-
- moved delete account to portal's "your data" section (it's about data, not preferences)
209
-
- fixed `font-family: inherit` on all settings page buttons
210
-
- updated SensitiveImage tooltip: "enable in settings"
211
-
212
-
---
213
-
214
-
#### bufo easter egg improvements (PRs #491-492, Dec 6)
215
-
216
-
**what shipped**:
217
-
- configurable exclude/include patterns via env vars for bufo easter egg
218
-
- `BUFO_EXCLUDE_PATTERNS`: regex patterns to filter out (default: `^bigbufo_`)
219
-
- `BUFO_INCLUDE_PATTERNS`: allowlist that overrides exclude (default: `bigbufo_0_0`, `bigbufo_2_1`)
220
-
- cache key now includes patterns so config changes take effect immediately
221
-
222
-
**reusable type**:
223
-
- added `CommaSeparatedStringSet` type for parsing comma-delimited env vars into sets
224
-
- uses pydantic `BeforeValidator` with `Annotated` pattern (not class-coupled validators)
225
-
- handles: `VAR=a,b,c` โ `{"a", "b", "c"}`
226
-
227
-
**context**: bigbufo tiles are 4x4 grid fragments that looked weird floating individually. now excluded by default, with two specific tiles allowed through.
228
-
229
-
**thread**: https://bsky.app/profile/zzstoatzzdevlog.bsky.social/post/3m7e3ndmgwl2m
230
-
231
-
---
232
-
233
-
#### mobile artwork upload fix (PR #489, Dec 6)
234
-
235
-
**problem**: artwork uploads from iOS Photos library silently failed - track uploaded successfully but without artwork.
236
-
237
-
**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".
238
-
239
-
**fix**:
240
-
- backend now prefers MIME content_type over filename extension for format detection
241
-
- added `ImageFormat.from_content_type()` method
242
-
- frontend uses `accept="image/*"` for broader iOS compatibility
243
-
244
97
#### sensitive image moderation (PRs #471-488, Dec 5-6)
245
98
246
-
**what shipped**:
247
-
- `sensitive_images` table to flag problematic images by R2 `image_id` or external URL
248
-
- `show_sensitive_artwork` user preference (default: hidden, toggle in portal โ "your data")
249
-
- flagged images blurred everywhere: track lists, player, artist pages, likers tooltip, search results, embeds
250
-
- Media Session API (CarPlay, lock screen, control center) respects sensitive preference
251
-
- SSR-safe filtering: link previews (og:image) exclude sensitive images on track, artist, and album pages
252
-
- likers tooltip UX: max-height with scroll, hover interaction fix, viewport-aware flip positioning
253
-
- likers tooltip z-index: elevates entire track-container when tooltip open (prevents sibling tracks bleeding through)
254
-
255
-
**how it works**:
256
-
- frontend fetches `/moderation/sensitive-images` and stores flagged IDs/URLs
257
-
- `SensitiveImage` component wraps images and checks against flagged list
258
-
- server-side check via `+layout.server.ts` for meta tag filtering
259
-
- users can opt-in to view sensitive artwork via portal toggle
260
-
261
-
**coverage** (PR #488):
262
-
263
-
| context | approach |
264
-
|---------|----------|
265
-
| DOM images needing blur | `SensitiveImage` component |
266
-
| small avatars in lists | `SensitiveImage` with `compact` prop |
267
-
| SSR meta tags (og:image) | `checkImageSensitive()` function |
268
-
| non-DOM APIs (media session) | direct `isSensitive()` + `showSensitiveArtwork` check |
269
-
270
-
**moderation workflow**:
271
-
- admin adds row to `sensitive_images` with `image_id` (R2) or `url` (external)
272
-
- images are blurred immediately for all users
273
-
- users who enable `show_sensitive_artwork` see unblurred images
99
+
- `sensitive_images` table flags problematic images
100
+
- `show_sensitive_artwork` user preference
101
+
- flagged images blurred everywhere: track lists, player, artist pages, search, embeds
102
+
- Media Session API respects sensitive preference
103
+
- SSR-safe filtering for og:image link previews
274
104
275
105
---
276
106
277
-
#### teal.fm scrobbling integration (PR #467, Dec 4)
278
-
279
-
**what shipped**:
280
-
- native teal.fm scrobbling: when users enable the toggle, plays are recorded to their PDS using teal's ATProto lexicons
281
-
- scrobble triggers at 30% or 30 seconds (whichever comes first) - same threshold as play counts
282
-
- user preference stored in database, toggleable from portal โ "your data"
283
-
- settings link to pdsls.dev so users can view their scrobble records
284
-
285
-
**lexicons used**:
286
-
- `fm.teal.alpha.feed.play` - individual play records (scrobbles)
287
-
- `fm.teal.alpha.actor.status` - now-playing status updates
107
+
#### teal.fm scrobbling (PR #467, Dec 4)
288
108
289
-
**configuration** (all optional, sensible defaults):
290
-
- `TEAL_ENABLED` (default: `true`) - feature flag for entire integration
291
-
- `TEAL_PLAY_COLLECTION` (default: `fm.teal.alpha.feed.play`)
292
-
- `TEAL_STATUS_COLLECTION` (default: `fm.teal.alpha.actor.status`)
293
-
294
-
**code quality improvements** (same PR):
295
-
- added `settings.frontend.domain` computed property for environment-aware URLs
296
-
- extracted `get_session_id_from_request()` utility for bearer token parsing
297
-
- added field validator on `DeveloperTokenInfo.session_id` for auto-truncation
298
-
- applied walrus operators throughout auth and playback code
299
-
- fixed now-playing endpoint firing every 1 second (fingerprint update bug in scheduled reports)
300
-
301
-
**documentation**: `backend/src/backend/_internal/atproto/teal.py` contains inline docs on the scrobbling flow
109
+
- native scrobbling to user's PDS using teal's ATProto lexicons
110
+
- scrobble at 30% or 30 seconds (same threshold as play counts)
111
+
- toggle in settings, link to pdsls.dev to view records
302
112
303
113
---
304
114
305
-
#### unified search (PR #447, Dec 3)
115
+
### Earlier December / November 2025
306
116
307
-
**what shipped**:
308
-
- `Cmd+K` (mac) / `Ctrl+K` (windows/linux) opens search modal from anywhere
309
-
- fuzzy matching across tracks, artists, albums, and tags using PostgreSQL `pg_trgm`
310
-
- results grouped by type with relevance scores (0.0-1.0)
311
-
- keyboard navigation (arrow keys, enter, esc)
312
-
- artwork/avatars displayed with lazy loading and fallback icons
313
-
- glassmorphism modal styling with backdrop blur
314
-
- debounced input (150ms) with client-side validation
315
-
316
-
**database**:
317
-
- enabled `pg_trgm` extension for trigram-based similarity search
318
-
- GIN indexes on `tracks.title`, `artists.handle`, `artists.display_name`, `albums.title`, `tags.name`
319
-
320
-
**documentation**: `docs/frontend/search.md`, `docs/frontend/keyboard-shortcuts.md`
321
-
322
-
**follow-up polish** (PRs #449-463):
323
-
- mobile search icon in header (PRs #455-456)
324
-
- theme-aware modal styling with styled scrollbar (#450)
325
-
- ILIKE fallback for substring matches when trigram fails (#452)
326
-
- tag collapse with +N button (#453)
327
-
- input focus fix: removed `visibility: hidden` so focus works on open (#457, #463)
328
-
- album artwork fallback in player when track has no image (#458)
329
-
- rate limiting exemption for now-playing endpoints (#460)
330
-
- `--no-dev` flag for release command to prevent dev dep installation (#461)
331
-
332
-
---
333
-
334
-
#### light/dark theme and mobile UX overhaul (Dec 2-3)
335
-
336
-
**theme system** (PR #441):
337
-
- replaced hardcoded colors across 35 files with CSS custom properties
338
-
- semantic tokens: `--bg-primary`, `--text-secondary`, `--accent`, etc.
339
-
- theme switcher in settings: dark / light / system (follows OS preference)
340
-
- removed zen mode feature (superseded by proper theme support)
341
-
342
-
**mobile UX improvements** (PR #443):
343
-
- new `ProfileMenu` component โ collapses profile, upload, settings, logout into touch-optimized menu (44px tap targets)
344
-
- dedicated `/upload` page โ extracted from portal for cleaner mobile flow
345
-
- portal overhaul โ tighter forms, track detail links under artwork, fixed icon alignment
346
-
- standardized section headers across home and liked tracks pages
347
-
348
-
**player scroll timing fix** (PR #445):
349
-
- reduced title scroll cycle from 10s โ 8s, artist/album from 15s โ 10s
350
-
- eliminated 1.5s invisible pause at end of scroll animation
351
-
- fixed duplicate upload toast (was firing twice on success)
352
-
- upload success toast now includes "view track" link
353
-
354
-
**CI optimization** (PR #444):
355
-
- pre-commit hooks now skip based on changed paths
356
-
- result: ~10s for most PRs instead of ~1m20s
357
-
- only installs tooling (uv, bun) needed for changed directories
358
-
359
-
---
360
-
361
-
#### tag filtering system and SDK tag support (Dec 2)
362
-
363
-
**tag filtering** (PRs #431-434):
364
-
- users can now hide tracks by tag via eye icon filter in discovery feed
365
-
- preferences centralized in root layout (fetched once, shared across app)
366
-
- `HiddenTagsFilter` component with expandable UI for managing hidden tags
367
-
- default hidden tags: `["ai"]` for new users
368
-
- tag detail pages at `/tag/[name]` with all tracks for that tag
369
-
- clickable tag badges on tracks navigate to tag pages
370
-
371
-
**navigation fix** (PR #435):
372
-
- fixed tag links interrupting audio playback
373
-
- root cause: `stopPropagation()` on links breaks SvelteKit's client-side router
374
-
- documented pattern in `docs/frontend/navigation.md` to prevent recurrence
375
-
376
-
**SDK tag support** (plyr-python-client v0.0.1-alpha.10):
377
-
- added `tags: set[str]` parameter to `upload()` in SDK
378
-
- added `-t/--tag` CLI option (can be used multiple times)
379
-
- updated MCP `upload_guide` prompt with tag examples
380
-
- status maintenance workflow now tags AI-generated podcasts with `ai` (#436)
381
-
382
-
**tags in detail pages** (PR #437):
383
-
- track detail endpoint (`/tracks/{id}`) now returns tags
384
-
- album detail endpoint (`/albums/{handle}/{slug}`) now returns tags for all tracks
385
-
- track detail page displays clickable tag badges
386
-
387
-
**bufo easter egg** (PR #438, improved in #491-492):
388
-
- tracks tagged with `bufo` trigger animated toad GIFs on the detail page
389
-
- uses track title as semantic search query against [find-bufo API](https://find-bufo.fly.dev/)
390
-
- toads are semantically matched to the song's vibe (e.g., "Happy Vibes" gets happy toads)
391
-
- results cached in localStorage (1 week TTL) to minimize API calls
392
-
- `TagEffects` wrapper component provides extensibility for future tag-based plugins
393
-
- respects `prefers-reduced-motion`; fails gracefully if API unavailable
394
-
- configurable exclude/include patterns via env vars (see Dec 6 entry above)
395
-
396
-
---
397
-
398
-
#### queue touch reordering and header stats fix (Dec 2)
399
-
400
-
**queue mobile UX** (PR #428):
401
-
- added 6-dot drag handle to queue items for touch-friendly reordering
402
-
- implemented touch event handlers for mobile drag-and-drop
403
-
- track follows finger during drag with smooth translateY transform
404
-
- drop target highlights while dragging over other tracks
405
-
406
-
**header stats positioning** (PR #426):
407
-
- fixed platform stats not adjusting when queue sidebar opens/closes
408
-
- added `--queue-width` CSS custom property updated dynamically
409
-
- stats now shift left with smooth transition when queue opens
410
-
411
-
---
412
-
413
-
#### connection pool resilience for Neon cold starts (Dec 2)
414
-
415
-
**incident**: ~5 minute API outage (01:55-02:00 UTC) - all requests returned 500 errors
416
-
417
-
**root cause**: Neon serverless cold start after 5 minutes of idle traffic
418
-
- queue listener heartbeat detected dead connection, began reconnection
419
-
- first 5 user requests each held a connection waiting for Neon to wake up (3-5 min each)
420
-
- with pool_size=5 and max_overflow=0, pool exhausted immediately
421
-
- all subsequent requests got `QueuePool limit of size 5 overflow 0 reached`
422
-
423
-
**fix**:
424
-
- increased `pool_size` from 5 โ 10 (handle more concurrent cold start requests)
425
-
- increased `max_overflow` from 0 โ 5 (allow burst to 15 connections)
426
-
- increased `connection_timeout` from 3s โ 10s (wait for Neon wake-up)
427
-
428
-
**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.
429
-
430
-
---
431
-
432
-
#### now-playing API (PR #416, Dec 1)
433
-
434
-
**what shipped**:
435
-
- `GET /now-playing/{did}` and `GET /now-playing/by-handle/{handle}` endpoints
436
-
- returns track metadata, playback position, timestamp
437
-
- 204 when nothing playing, 200 with track data otherwise
438
-
439
-
**teal.fm integration**:
440
-
- native scrobbling shipped in PR #467 (Dec 4) - plyr.fm writes directly to user's PDS
441
-
- Piper integration (external polling) still open: https://github.com/teal-fm/piper/pull/27
442
-
443
-
---
444
-
445
-
#### admin UI improvements for moderation (PRs #408-414, Dec 1)
446
-
447
-
**what shipped**:
448
-
- dropdown menu for false positive reasons (fingerprint noise, original artist, fair use, other)
449
-
- artist/track links open in new tabs for verification
450
-
- AuDD score normalization (scores shown as 0-100 range)
451
-
- filter controls to show only high-confidence matches
452
-
- form submission fixes for htmx POST requests
453
-
454
-
---
455
-
456
-
#### ATProto labeler and copyright moderation (PRs #382-395, Nov 29-Dec 1)
457
-
458
-
**what shipped**:
459
-
- standalone labeler service integrated into moderation Rust service
460
-
- implements `com.atproto.label.queryLabels` and `subscribeLabels` XRPC endpoints
461
-
- k256 ECDSA signing for cryptographic label verification
462
-
- web interface at `/admin` for reviewing copyright flags
463
-
- htmx for server-rendered interactivity
464
-
- integrates with AuDD enterprise API for audio fingerprinting
465
-
- fire-and-forget background task on track upload
466
-
- review workflow with resolution tracking (violation, false_positive, original_artist)
467
-
468
-
**initial review results** (25 flagged tracks):
469
-
- 8 violations (actual copyright issues)
470
-
- 11 false positives (fingerprint noise)
471
-
- 6 original artists (people uploading their own distributed music)
472
-
473
-
**documentation**: see `docs/moderation/atproto-labeler.md`
474
-
475
-
---
476
-
477
-
#### developer tokens with independent OAuth grants (PR #367, Nov 28)
478
-
479
-
**what shipped**:
480
-
- each developer token gets its own OAuth authorization flow
481
-
- tokens have their own DPoP keypair, access/refresh tokens - completely separate from browser session
482
-
- cookie isolation: dev token exchange doesn't set browser cookie
483
-
- token management UI: portal โ "your data" โ "developer tokens"
484
-
- create with optional name and expiration (30/90/180/365 days or never)
485
-
486
-
**security properties**:
487
-
- tokens are full sessions with encrypted OAuth credentials (Fernet)
488
-
- each token refreshes independently
489
-
- revokable individually without affecting browser or other tokens
490
-
491
-
---
492
-
493
-
#### platform stats and media session integration (PRs #359-379, Nov 27-29)
494
-
495
-
**what shipped**:
496
-
- `GET /stats` returns total plays, tracks, and artists
497
-
- stats bar displays in homepage header (e.g., "1,691 plays โข 55 tracks โข 8 artists")
498
-
- Media Session API for CarPlay, lock screens, Bluetooth devices
499
-
- browser tab title shows "track - artist โข plyr.fm" while playing
500
-
- timed comments with clickable timestamps
501
-
- constellation integration for network-wide like counts
502
-
- account deletion with explicit confirmation
503
-
504
-
---
505
-
506
-
#### export & upload reliability (PRs #337-344, Nov 24)
507
-
508
-
**what shipped**:
509
-
- database-backed jobs (moved tracking from in-memory to postgres)
510
-
- streaming exports (fixed OOM on large file exports)
511
-
- 90-minute WAV files now export successfully on 1GB VM
512
-
- upload progress bar fixes
513
-
- export filename now includes date
514
-
515
-
---
516
-
517
-
### October-November 2025
518
-
519
-
See `.status_history/2025-11.md` for detailed November development history including:
520
-
- async I/O performance fixes (PRs #149-151)
117
+
See `.status_history/2025-12.md` and `.status_history/2025-11.md` for detailed history including:
118
+
- unified search with Cmd+K (PR #447)
119
+
- light/dark theme system (PR #441)
120
+
- tag filtering and bufo easter egg (PRs #431-438)
121
+
- developer tokens (PR #367)
122
+
- copyright moderation system (PRs #382-395)
123
+
- export & upload reliability (PRs #337-344)
521
124
- transcoder API deployment (PR #156)
522
-
- upload streaming + progress UX (PR #182)
523
-
- liked tracks feature (PR #157)
524
-
- track detail pages (PR #164)
525
-
- mobile UI improvements (PRs #159-185)
526
-
- oEmbed endpoint for Leaflet.pub embeds (PRs #355-358)
527
125
528
126
## immediate priorities
529
127
···
531
129
1. **audio transcoding pipeline integration** (issue #153)
532
130
- โ
standalone transcoder service deployed at https://plyr-transcoder.fly.dev/
533
131
- โณ next: integrate into plyr.fm upload pipeline
534
-
- backend calls transcoder API for unsupported formats
535
-
- queue-based job system for async processing
536
-
- R2 integration (fetch original, store MP3)
537
132
538
133
### known issues
539
-
- playback auto-start on refresh (#225) - investigating localStorage/queue state persistence
134
+
- playback auto-start on refresh (#225)
540
135
- no AIFF/AIF transcoding support (#153)
541
-
- iOS PWA audio may hang on first play after backgrounding - service worker caching interacts poorly with 307 redirects to R2 CDN. PR #466 added `NetworkOnly` for audio routes which should fix this, but iOS PWAs are slow to update service workers. workaround: delete home screen bookmark and re-add. may need further investigation if issue persists after SW propagates.
136
+
- iOS PWA audio may hang on first play after backgrounding
542
137
543
138
### new features
544
139
- issue #146: content-addressable storage (hash-based deduplication)
···
576
171
**what's working**
577
172
578
173
**core functionality**
579
-
- โ
ATProto OAuth 2.1 authentication with encrypted state
174
+
- โ
ATProto OAuth 2.1 authentication
580
175
- โ
secure session management via HttpOnly cookies
581
176
- โ
developer tokens with independent OAuth grants
582
-
- โ
platform stats endpoint and homepage display
583
-
- โ
Media Session API for CarPlay, lock screens, control center
584
-
- โ
timed comments on tracks with clickable timestamps
585
-
- โ
account deletion with explicit confirmation
177
+
- โ
platform stats and Media Session API
178
+
- โ
timed comments with clickable timestamps
586
179
- โ
artist profiles synced with Bluesky
587
-
- โ
track upload with streaming to prevent OOM
588
-
- โ
track edit/deletion with cascade cleanup
589
-
- โ
audio streaming via HTML5 player with 307 redirects to R2 CDN
590
-
- โ
track metadata published as ATProto records
591
-
- โ
play count tracking (30% or 30s threshold)
592
-
- โ
like functionality with counts
593
-
- โ
queue management (shuffle, auto-advance, reorder)
594
-
- โ
mobile-optimized responsive UI
595
-
- โ
cross-tab queue synchronization via BroadcastChannel
596
-
- โ
share tracks via URL with Open Graph previews
597
-
- โ
copyright moderation system with admin UI
598
-
- โ
ATProto labeler for copyright violations
599
-
- โ
unified search with Cmd/Ctrl+K (fuzzy matching via pg_trgm)
600
-
- โ
teal.fm scrobbling (records plays to user's PDS)
180
+
- โ
track upload with streaming
181
+
- โ
audio streaming via 307 redirects to R2 CDN
182
+
- โ
play count tracking, likes, queue management
183
+
- โ
unified search with Cmd/Ctrl+K
184
+
- โ
teal.fm scrobbling
185
+
- โ
copyright moderation with ATProto labeler
601
186
602
187
**albums**
603
-
- โ
album database schema with track relationships
604
-
- โ
album browsing and detail pages
605
-
- โ
album cover art upload and display
606
-
- โ
server-side rendering for SEO
607
-
- โ
ATProto list records for albums (auto-synced on login)
188
+
- โ
album CRUD with cover art
189
+
- โ
ATProto list records (auto-synced on login)
608
190
609
191
**playlists**
610
-
- โ
full CRUD (create, rename, delete, reorder tracks)
611
-
- โ
playlist detail pages with drag-and-drop reordering
612
-
- โ
playlist cover art upload
192
+
- โ
full CRUD with drag-and-drop reordering
613
193
- โ
ATProto list records (synced on create/modify)
614
-
- โ
"add to playlist" menu on tracks
615
-
- โ
playlists in global search results
616
-
617
-
**deployment (fully automated)**
618
-
- **production**:
619
-
- frontend: https://plyr.fm
620
-
- backend: https://relay-api.fly.dev โ https://api.plyr.fm
621
-
- database: neon postgresql
622
-
- storage: cloudflare R2 (audio-prod and images-prod buckets)
194
+
- โ
"add to playlist" menu, global search results
623
195
624
-
- **staging**:
625
-
- backend: https://api-stg.plyr.fm
626
-
- frontend: https://stg.plyr.fm
627
-
- database: neon postgresql (relay-staging)
628
-
- storage: cloudflare R2 (audio-stg bucket)
196
+
**deployment URLs**
197
+
- production frontend: https://plyr.fm
198
+
- production backend: https://api.plyr.fm
199
+
- staging: https://stg.plyr.fm / https://api-stg.plyr.fm
629
200
630
201
### technical decisions
631
202
632
203
**why Python/FastAPI instead of Rust?**
633
204
- rapid prototyping velocity during MVP phase
634
-
- rich ecosystem for web APIs
635
-
- excellent async support with asyncio
636
205
- trade-off: accepting higher latency for faster development
637
206
638
207
**why Cloudflare R2 instead of S3?**
639
208
- zero egress fees (critical for audio streaming)
640
-
- S3-compatible API (easy migration if needed)
641
-
- integrated CDN for fast delivery
642
-
643
-
**why forked atproto SDK?**
644
-
- upstream SDK lacked OAuth 2.1 support
645
-
- needed custom record management patterns
646
-
- maintains compatibility with ATProto spec
209
+
- S3-compatible API, integrated CDN
647
210
648
211
**why async everywhere?**
649
-
- event loop performance: single-threaded async handles high concurrency
650
212
- I/O-bound workload: most time spent waiting on network/disk
651
213
- PRs #149-151 eliminated all blocking operations
652
214
···
654
216
655
217
current monthly costs: ~$35-40/month
656
218
657
-
- fly.io backend (production): ~$5/month
658
-
- fly.io backend (staging): ~$5/month
219
+
- fly.io backend (prod + staging): ~$10/month
659
220
- fly.io transcoder: ~$0-5/month (auto-scales to zero)
660
221
- neon postgres: $5/month
661
222
- audd audio fingerprinting: ~$10/month
662
-
- cloudflare pages: $0 (free tier)
663
-
- cloudflare R2: ~$0.16/month
223
+
- cloudflare pages + R2: ~$0.16/month
664
224
- logfire: $0 (free tier)
665
-
- domain: $12/year (~$1/month)
666
-
667
-
## deployment URLs
668
-
669
-
- **production frontend**: https://plyr.fm
670
-
- **production backend**: https://api.plyr.fm
671
-
- **staging backend**: https://api-stg.plyr.fm
672
-
- **staging frontend**: https://stg.plyr.fm
673
-
- **repository**: https://github.com/zzstoatzz/plyr.fm (private)
674
-
- **monitoring**: https://logfire-us.pydantic.dev/zzstoatzz/relay
675
-
- **bluesky**: https://bsky.app/profile/plyr.fm
225
+
- domain: ~$1/month
676
226
677
227
## admin tooling
678
228
679
229
### content moderation
680
230
script: `scripts/delete_track.py`
681
-
- requires `ADMIN_*` prefixed environment variables
682
-
- deletes audio file, cover image, database record
683
-
- notes ATProto records for manual cleanup
684
231
685
232
usage:
686
233
```bash
···
702
249
1. create issue on github
703
250
2. create PR from feature branch
704
251
3. ensure pre-commit hooks pass
705
-
4. merge to main โ deploys to staging automatically
706
-
5. verify on staging
707
-
6. create github release โ deploys to production automatically
252
+
4. merge to main โ deploys to staging
253
+
5. create github release โ deploys to production
708
254
709
255
### key principles
710
256
- type hints everywhere
···
719
265
plyr.fm/
720
266
โโโ backend/ # FastAPI app & Python tooling
721
267
โ โโโ src/backend/ # application code
722
-
โ โ โโโ api/ # public endpoints
723
-
โ โ โโโ _internal/ # internal services
724
-
โ โ โโโ models/ # database schemas
725
-
โ โ โโโ storage/ # storage adapters
726
268
โ โโโ tests/ # pytest suite
727
269
โ โโโ alembic/ # database migrations
728
270
โโโ frontend/ # SvelteKit app
729
271
โ โโโ src/lib/ # components & state
730
272
โ โโโ src/routes/ # pages
731
273
โโโ moderation/ # Rust moderation service (ATProto labeler)
732
-
โ โโโ src/ # Axum handlers, AuDD client, label signing
733
-
โ โโโ static/ # admin UI (html/css/js)
734
274
โโโ transcoder/ # Rust audio transcoding service
735
275
โโโ docs/ # documentation
736
276
โโโ justfile # task runner
···
745
285
- [moderation & labeler](docs/moderation/atproto-labeler.md)
746
286
- [unified search](docs/frontend/search.md)
747
287
- [keyboard shortcuts](docs/frontend/keyboard-shortcuts.md)
288
+
- [lexicons overview](docs/lexicons/overview.md)
748
289
749
290
---
750
291
751
-
this is a living document. last updated 2025-12-07.
292
+
this is a living document. last updated 2025-12-08.
update.wav
update.wav
This is a binary file and will not be displayed.