+338
.status_history/2025-11.md
+338
.status_history/2025-11.md
···
1
+
# plyr.fm Status History - November 2025
2
+
3
+
## November 2025 Work
4
+
5
+
### ATProto labeler and admin UI (PRs #385-395, Nov 29-Dec 1)
6
+
7
+
**motivation**: integrate with ATProto labeling protocol for proper copyright violation signaling, and improve admin tooling for reviewing flagged content.
8
+
9
+
**what shipped**:
10
+
- **ATProto labeler implementation** (PRs #385, #391):
11
+
- standalone labeler service integrated into moderation Rust service
12
+
- implements `com.atproto.label.queryLabels` and `subscribeLabels` XRPC endpoints
13
+
- k256 ECDSA signing for cryptographic label verification
14
+
- SQLite storage for labels with sequence numbers
15
+
- labels emitted when copyright violations detected
16
+
- negation labels for false positive resolution
17
+
- **admin UI** (PRs #390, #392, #395):
18
+
- web interface at `/admin` for reviewing copyright flags
19
+
- htmx for server-rendered interactivity (no inline JS bloat)
20
+
- static files extracted to `moderation/static/` for proper syntax highlighting
21
+
- plyr.fm design tokens for brand consistency
22
+
- shows track title, artist handle, match scores, and potential matches
23
+
- "mark false positive" button emits negation label
24
+
- **label context enrichment** (PR #392):
25
+
- labels now include track_title, artist_handle, artist_did, highest_score, matches
26
+
- backfill script (`scripts/backfill_label_context.py`) populated 25 existing flags
27
+
- admin UI displays rich context instead of just ATProto URIs
28
+
- **copyright flag visibility** (PRs #387, #389):
29
+
- artist portal shows copyright flag indicator on flagged tracks
30
+
- tooltip shows primary match (artist - title) for quick context
31
+
- **documentation** (PR #386):
32
+
- comprehensive docs at `docs/moderation/atproto-labeler.md`
33
+
- covers architecture, label schema, XRPC protocol, signing keys
34
+
35
+
**admin UI architecture**:
36
+
- `moderation/static/admin.html` - page structure
37
+
- `moderation/static/admin.css` - plyr.fm design tokens
38
+
- `moderation/static/admin.js` - auth handling (~40 lines)
39
+
- htmx endpoints: `/admin/flags-html`, `/admin/resolve-htmx`
40
+
- server-rendered HTML partials for flag cards
41
+
42
+
---
43
+
44
+
### copyright moderation system (PRs #382, #384, Nov 29-30)
45
+
46
+
**motivation**: detect potential copyright violations in uploaded tracks to avoid DMCA issues and protect the platform.
47
+
48
+
**what shipped**:
49
+
- **moderation service** (Rust/Axum on Fly.io):
50
+
- standalone service at `plyr-moderation.fly.dev`
51
+
- integrates with AuDD enterprise API for audio fingerprinting
52
+
- scans audio URLs and returns matches with metadata (artist, title, album, ISRC, timecode)
53
+
- auth via `X-Moderation-Key` header
54
+
- **backend integration** (PR #382):
55
+
- `ModerationSettings` in config (service URL, auth token, timeout)
56
+
- moderation client module (`backend/_internal/moderation.py`)
57
+
- fire-and-forget background task on track upload
58
+
- stores results in `copyright_scans` table
59
+
- scan errors stored as "clear" so tracks aren't stuck unscanned
60
+
- **flagging fix** (PR #384):
61
+
- AuDD enterprise API returns no confidence scores (all 0)
62
+
- changed from score threshold to presence-based flagging: `is_flagged = !matches.is_empty()`
63
+
- removed unused `score_threshold` config
64
+
- **backfill script** (`scripts/scan_tracks_copyright.py`):
65
+
- scans existing tracks that haven't been checked
66
+
- `--max-duration` flag to skip long DJ sets (estimated from file size)
67
+
- `--dry-run` mode to preview what would be scanned
68
+
- supports dev/staging/prod environments
69
+
- **review workflow**:
70
+
- `copyright_scans` table has `resolution`, `reviewed_at`, `reviewed_by`, `review_notes` columns
71
+
- resolution values: `violation`, `false_positive`, `original_artist`
72
+
73
+
**initial review results** (25 flagged tracks):
74
+
- 8 violations (actual copyright issues)
75
+
- 11 false positives (fingerprint noise)
76
+
- 6 original artists (people uploading their own distributed music)
77
+
78
+
---
79
+
80
+
### developer tokens with independent OAuth grants (PR #367, Nov 28)
81
+
82
+
**motivation**: programmatic API access (scripts, CLIs, automation) needed tokens that survive browser logout and don't become stale when browser sessions refresh.
83
+
84
+
**what shipped**:
85
+
- **OAuth-based dev tokens**: each developer token gets its own OAuth authorization flow
86
+
- user clicks "create token" → redirected to PDS for authorization → token created with independent credentials
87
+
- tokens have their own DPoP keypair, access/refresh tokens - completely separate from browser session
88
+
- **cookie isolation**: dev token exchange doesn't set browser cookie
89
+
- added `is_dev_token` flag to ExchangeToken model
90
+
- /auth/exchange skips Set-Cookie for dev token flows
91
+
- prevents logout from deleting dev tokens (critical bug fixed during implementation)
92
+
- **token management UI**: portal → "your data" → "developer tokens"
93
+
- create with optional name and expiration (30/90/180/365 days or never)
94
+
- list active tokens with creation/expiration dates
95
+
- revoke individual tokens
96
+
- **API endpoints**:
97
+
- `POST /auth/developer-token/start` - initiates OAuth flow, returns auth_url
98
+
- `GET /auth/developer-tokens` - list user's tokens
99
+
- `DELETE /auth/developer-tokens/{prefix}` - revoke by 8-char prefix
100
+
101
+
**security properties**:
102
+
- tokens are full sessions with encrypted OAuth credentials (Fernet)
103
+
- each token refreshes independently (no staleness from browser session refresh)
104
+
- revokable individually without affecting browser or other tokens
105
+
- explicit OAuth consent required at PDS for each token created
106
+
107
+
**documentation**: see `docs/authentication.md` "developer tokens" section
108
+
109
+
---
110
+
111
+
### platform stats and media session integration (PRs #359-379, Nov 27-29)
112
+
113
+
**motivation**: show platform activity at a glance, improve playback experience across devices, and give users control over their data.
114
+
115
+
**what shipped**:
116
+
- **platform stats endpoint and UI** (PRs #376, #378, #379):
117
+
- `GET /stats` returns total plays, tracks, and artists
118
+
- stats bar displays in homepage header (e.g., "1,691 plays • 55 tracks • 8 artists")
119
+
- skeleton loading animation while fetching
120
+
- responsive layout: visible in header on wide screens, collapses to menu on narrow
121
+
- end-of-list animation on homepage
122
+
- **Media Session API** (PR #371):
123
+
- provides track metadata to CarPlay, lock screens, Bluetooth devices, macOS control center
124
+
- artwork display with fallback to artist avatar
125
+
- play/pause, prev/next, seek controls all work from system UI
126
+
- position state syncs scrubbers on external interfaces
127
+
- **browser tab title** (PR #374):
128
+
- shows "track - artist • plyr.fm" while playing
129
+
- persists across page navigation
130
+
- reverts to page title when playback stops
131
+
- **timed comments** (PR #359):
132
+
- comments capture timestamp when added during playback
133
+
- clickable timestamp buttons seek to that moment
134
+
- compact scrollable comments section on track pages
135
+
- **constellation integration** (PR #360):
136
+
- queries constellation.microcosm.blue backlink index
137
+
- enables network-wide like counts (not just plyr.fm internal)
138
+
- environment-aware namespace handling
139
+
- **account deletion** (PR #363):
140
+
- explicit confirmation flow (type handle to confirm)
141
+
- deletes all plyr.fm data (tracks, albums, likes, comments, preferences)
142
+
- optional ATProto record cleanup with clear warnings about orphaned references
143
+
144
+
---
145
+
146
+
### oEmbed endpoint for Leaflet.pub embeds (PRs #355-358, Nov 25)
147
+
148
+
**motivation**: plyr.fm tracks embedded in Leaflet.pub (via iframely) showed a black HTML5 audio box instead of our custom embed player.
149
+
150
+
**what shipped**:
151
+
- **oEmbed endpoint** (PR #355): `/oembed` returns proper embed HTML with iframe
152
+
- follows oEmbed spec with `type: "rich"` and iframe in `html` field
153
+
- discovery link in track page `<head>` for automatic detection
154
+
- **iframely domain registration**: registered plyr.fm on iframely.com (free tier)
155
+
- this was the key fix - iframely now returns our embed iframe as `links.player[0]`
156
+
157
+
**debugging journey** (PRs #356-358):
158
+
- initially tried `og:video` meta tags to hint iframe embed - didn't work
159
+
- tried removing `og:audio` to force oEmbed fallback - resulted in no player link
160
+
- discovered iframely requires domain registration to trust oEmbed providers
161
+
- after registration, iframely correctly returns embed iframe URL
162
+
163
+
---
164
+
165
+
### export & upload reliability (PRs #337-344, Nov 24)
166
+
167
+
**motivation**: exports were failing silently on large files (OOM), uploads showed incorrect progress, and SSE connections triggered false error toasts.
168
+
169
+
**what shipped**:
170
+
- **database-backed jobs** (PR #337): moved upload/export tracking from in-memory to postgres
171
+
- jobs table persists state across server restarts
172
+
- enables reliable progress tracking via SSE polling
173
+
- **streaming exports** (PR #343): fixed OOM on large file exports
174
+
- previously loaded entire files into memory via `response["Body"].read()`
175
+
- now streams to temp files, adds to zip from disk (constant memory)
176
+
- 90-minute WAV files now export successfully on 1GB VM
177
+
- **progress tracking fix** (PR #340): upload progress was receiving bytes but treating as percentage
178
+
- `UploadProgressTracker` now properly converts bytes to percentage
179
+
- upload progress bar works correctly again
180
+
- **UX improvements** (PRs #338-339, #341-342, #344):
181
+
- export filename now includes date (`plyr-tracks-2025-11-24.zip`)
182
+
- toast notification on track deletion
183
+
- fixed false "lost connection" error when SSE completes normally
184
+
- progress now shows "downloading track X of Y" instead of confusing count
185
+
186
+
---
187
+
188
+
### queue hydration + ATProto token hardening (Nov 12)
189
+
190
+
**why**: queue endpoints were occasionally taking 2s+ and restore operations could 401
191
+
when multiple requests refreshed an expired ATProto token simultaneously.
192
+
193
+
**what shipped**:
194
+
- added persistent `image_url` on `Track` rows so queue hydration no longer probes R2
195
+
for every track. Queue payloads now pull art directly from Postgres, with a one-time
196
+
fallback for legacy rows.
197
+
- updated `_internal/queue.py` to backfill any missing URLs once (with caching) instead
198
+
of per-request GETs.
199
+
- introduced per-session locks in `_refresh_session_tokens` so only one coroutine hits
200
+
`oauth_client.refresh_session` at a time; others reuse the refreshed tokens. This
201
+
removes the race that caused the batch restore flow to intermittently 500/401.
202
+
203
+
**impact**: queue tail latency dropped back under 500 ms in staging tests, ATProto restore flows are now reliable under concurrent use, and Logfire no longer shows 500s from the PDS.
204
+
205
+
---
206
+
207
+
### performance optimization session (Nov 12)
208
+
209
+
**issue: slow /tracks/liked endpoint**
210
+
211
+
**symptoms**:
212
+
- `/tracks/liked` taking 600-900ms consistently
213
+
- only ~25ms spent in database queries
214
+
- mysterious 575ms gap with no spans in Logfire traces
215
+
216
+
**root cause**:
217
+
- PR #184 added `image_url` column to tracks table to eliminate N+1 R2 API calls
218
+
- legacy tracks (15 tracks uploaded before PR) had `image_url = NULL`
219
+
- fallback code called `track.get_image_url()` which makes uninstrumented R2 `head_object` API calls
220
+
- 5 tracks × 120ms = ~600ms of uninstrumented latency
221
+
222
+
**solution**: created `scripts/backfill_image_urls.py` to populate missing `image_url` values
223
+
224
+
**results**:
225
+
- `/tracks/liked` now sub-200ms (down from 600-900ms)
226
+
- all endpoints now consistently sub-second response times
227
+
228
+
**database cleanup**:
229
+
- discovered `queue_state` had 265% bloat (53 dead rows, 20 live rows)
230
+
- ran `VACUUM (FULL, ANALYZE) queue_state` against production
231
+
232
+
---
233
+
234
+
### track detail pages (PR #164, Nov 12)
235
+
236
+
- ✅ dedicated track detail pages with large cover art
237
+
- ✅ play button updates queue state correctly (#169)
238
+
- ✅ liked state loaded efficiently via server-side fetch
239
+
- ✅ mobile-optimized layouts with proper scrolling constraints
240
+
- ✅ origin validation for image URLs (#168)
241
+
242
+
---
243
+
244
+
### liked tracks feature (PR #157, Nov 11)
245
+
246
+
- ✅ server-side persistent collections
247
+
- ✅ ATProto record publication for cross-platform visibility
248
+
- ✅ UI for adding/removing tracks from liked collection
249
+
- ✅ like counts displayed in track responses and analytics (#170)
250
+
- ✅ analytics cards now clickable links to track detail pages (#171)
251
+
- ✅ liked state shown on artist page tracks (#163)
252
+
253
+
**status**: COMPLETE (issue #144 closed)
254
+
255
+
---
256
+
257
+
### upload streaming + progress UX (PR #182, Nov 11)
258
+
259
+
- Frontend switched from `fetch` to `XMLHttpRequest` so we can display upload progress
260
+
toasts (critical for >50 MB mixes on mobile).
261
+
- Upload form now clears only after the request succeeds; failed attempts leave the
262
+
form intact so users don't lose metadata.
263
+
- Backend writes uploads/images to temp files in 8 MB chunks before handing them to the
264
+
storage layer, eliminating whole-file buffering and iOS crashes for hour-long mixes.
265
+
- Deployment verified locally and by rerunning the exact repro Stella hit (85 minute
266
+
mix from mobile).
267
+
268
+
---
269
+
270
+
### transcoder API deployment (PR #156, Nov 11)
271
+
272
+
**standalone Rust transcoding service** 🎉
273
+
- **deployed**: https://plyr-transcoder.fly.dev/
274
+
- **purpose**: convert AIFF/FLAC/etc. to MP3 for browser compatibility
275
+
- **technology**: Axum + ffmpeg + Docker
276
+
- **security**: `X-Transcoder-Key` header authentication (shared secret)
277
+
- **capacity**: handles 1GB uploads, tested with 85-minute AIFF files (~858MB → 195MB MP3 in 32 seconds)
278
+
- **architecture**:
279
+
- 2 Fly machines for high availability
280
+
- auto-stop/start for cost efficiency
281
+
- stateless design (no R2 integration yet)
282
+
- 320kbps MP3 output with proper ID3 tags
283
+
- **status**: deployed and tested, ready for integration into plyr.fm upload pipeline
284
+
- **next steps**: wire into backend with R2 integration and job queue (see issue #153)
285
+
286
+
---
287
+
288
+
### AIFF/AIF browser compatibility fix (PR #152, Nov 11)
289
+
290
+
**format validation improvements**
291
+
- **problem discovered**: AIFF/AIF files only work in Safari, not Chrome/Firefox
292
+
- browsers throw `MediaError code 4: MEDIA_ERR_SRC_NOT_SUPPORTED`
293
+
- users could upload files but they wouldn't play in most browsers
294
+
- **immediate solution**: reject AIFF/AIF uploads at both backend and frontend
295
+
- removed AIFF/AIF from AudioFormat enum
296
+
- added format hints to upload UI: "supported: mp3, wav, m4a"
297
+
- client-side validation with helpful error messages
298
+
- **long-term solution**: deployed standalone transcoder service (see above)
299
+
- separate Rust/Axum service with ffmpeg
300
+
- accepts all formats, converts to browser-compatible MP3
301
+
- integration into upload pipeline pending (issue #153)
302
+
303
+
**observability improvements**:
304
+
- added logfire instrumentation to upload background tasks
305
+
- added logfire spans to R2 storage operations
306
+
- documented logfire querying patterns in `docs/logfire-querying.md`
307
+
308
+
---
309
+
310
+
### async I/O performance fixes (PRs #149-151, Nov 10-11)
311
+
312
+
Eliminated event loop blocking across backend with three critical PRs:
313
+
314
+
1. **PR #149: async R2 reads** - converted R2 `head_object` operations from sync boto3 to async aioboto3
315
+
- portal page load time: 2+ seconds → ~200ms
316
+
- root cause: `track.image_url` was blocking on serial R2 HEAD requests
317
+
318
+
2. **PR #150: concurrent PDS resolution** - parallelized ATProto PDS URL lookups
319
+
- homepage load time: 2-6 seconds → 200-400ms
320
+
- root cause: serial `resolve_atproto_data()` calls (8 artists × 200-300ms each)
321
+
- fix: `asyncio.gather()` for batch resolution, database caching for subsequent loads
322
+
323
+
3. **PR #151: async storage writes/deletes** - made save/delete operations non-blocking
324
+
- R2: switched to `aioboto3` for uploads/deletes (async S3 operations)
325
+
- filesystem: used `anyio.Path` and `anyio.open_file()` for chunked async I/O (64KB chunks)
326
+
- impact: multi-MB uploads no longer monopolize worker thread, constant memory usage
327
+
328
+
---
329
+
330
+
### mobile UI improvements (PRs #159-185, Nov 11-12)
331
+
332
+
- ✅ compact action menus and better navigation (#161)
333
+
- ✅ improved mobile responsiveness (#159)
334
+
- ✅ consistent button layouts across mobile/desktop (#176-181, #185)
335
+
- ✅ always show play count and like count on mobile (#177)
336
+
- ✅ login page UX improvements (#174-175)
337
+
- ✅ liked page UX improvements (#173)
338
+
- ✅ accent color for liked tracks (#160)
+90
-648
STATUS.md
+90
-648
STATUS.md
···
43
43
44
44
---
45
45
46
-
## development timeline
46
+
## recent work
47
47
48
48
### December 2025
49
49
···
54
54
- implemented touch event handlers for mobile drag-and-drop
55
55
- track follows finger during drag with smooth translateY transform
56
56
- drop target highlights while dragging over other tracks
57
-
- drag handles and remove buttons always visible on touch devices (no hover state)
58
57
59
58
**header stats positioning** (PR #426):
60
59
- fixed platform stats not adjusting when queue sidebar opens/closes
61
-
- stats were using static viewport calculation ignoring queue width
62
60
- added `--queue-width` CSS custom property updated dynamically
63
61
- stats now shift left with smooth transition when queue opens
64
62
···
81
79
- disabled scale to zero on production compute (`suspend_timeout_seconds: -1`) to eliminate cold starts entirely
82
80
83
81
**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.
84
-
85
-
**documentation**: updated `docs/backend/database/connection-pooling.md` with Neon serverless considerations and incident history.
86
82
87
83
---
88
84
89
85
#### now-playing API (PR #416, Dec 1)
90
86
91
-
**motivation**: expose what users are currently listening to via public API
92
-
93
87
**what shipped**:
94
88
- `GET /now-playing/{did}` and `GET /now-playing/by-handle/{handle}` endpoints
95
89
- returns track metadata, playback position, timestamp
96
90
- 204 when nothing playing, 200 with track data otherwise
97
-
- public endpoints (no auth required) - DIDs are already public identifiers
98
91
99
92
**speculative integration with teal.fm**:
100
93
- opened draft PR to Piper (teal.fm's scrobbling service): https://github.com/teal-fm/piper/pull/27
101
94
- adds plyr.fm as a source alongside Spotify and Last.fm
102
-
- tested end-to-end: plyr.fm → Piper → ATProto PDS (actor.status records)
103
95
- **status**: awaiting feedback from teal.fm team
104
-
- **alternative approach suggested**: teal.fm team suggested plyr.fm could write directly to `fm.teal.*` lexicons
105
-
- concern: this couples plyr.fm to teal's internal schema - if they change lexicons, we'd need to fast-follow
106
-
- Piper approach keeps cleaner boundaries: plyr.fm exposes API, Piper handles teal.fm integration
107
-
- decision pending further discussion with teal.fm maintainers
108
96
109
97
---
110
98
111
99
#### admin UI improvements for moderation (PRs #408-414, Dec 1)
112
100
113
-
**motivation**: improve usability of copyright moderation admin UI based on real-world usage
114
-
115
101
**what shipped**:
116
-
- **reason selection for false positives** (PR #408):
117
-
- dropdown menu when marking tracks as false positive
118
-
- options: "fingerprint noise", "original artist", "fair use", "other"
119
-
- stores reason in `review_notes` field
120
-
- multi-step confirmation to prevent accidental clicks
121
-
- **UI polish** (PR #414):
122
-
- artist/track links open in new tabs for easy verification
123
-
- better visual hierarchy and spacing
124
-
- improved button states and hover effects
125
-
- **AuDD score normalization** (PR #413):
126
-
- AuDD enterprise returns scores as 0-100 range (not 0-1)
127
-
- added score display to admin UI for transparency
128
-
- filter controls to show only high-confidence matches
129
-
- **form submission fix** (PR #412):
130
-
- switched from FormData to URLSearchParams
131
-
- fixes htmx POST request encoding
132
-
- ensures resolution actions work correctly
133
-
134
-
**impact**:
135
-
- faster moderation workflow (one-click access to verify tracks)
136
-
- better audit trail (reasons tracked for false positive resolutions)
137
-
- more transparent (shows match confidence scores)
138
-
- more reliable (form submission works consistently)
102
+
- dropdown menu for false positive reasons (fingerprint noise, original artist, fair use, other)
103
+
- artist/track links open in new tabs for verification
104
+
- AuDD score normalization (scores shown as 0-100 range)
105
+
- filter controls to show only high-confidence matches
106
+
- form submission fixes for htmx POST requests
139
107
140
108
---
141
109
142
-
### November 2025
143
-
144
-
#### ATProto labeler and admin UI (PRs #385-395, Nov 29-Dec 1)
145
-
146
-
**motivation**: integrate with ATProto labeling protocol for proper copyright violation signaling, and improve admin tooling for reviewing flagged content.
110
+
#### ATProto labeler and copyright moderation (PRs #382-395, Nov 29-Dec 1)
147
111
148
112
**what shipped**:
149
-
- **ATProto labeler implementation** (PRs #385, #391):
150
-
- standalone labeler service integrated into moderation Rust service
151
-
- implements `com.atproto.label.queryLabels` and `subscribeLabels` XRPC endpoints
152
-
- k256 ECDSA signing for cryptographic label verification
153
-
- SQLite storage for labels with sequence numbers
154
-
- labels emitted when copyright violations detected
155
-
- negation labels for false positive resolution
156
-
- **admin UI** (PRs #390, #392, #395):
157
-
- web interface at `/admin` for reviewing copyright flags
158
-
- htmx for server-rendered interactivity (no inline JS bloat)
159
-
- static files extracted to `moderation/static/` for proper syntax highlighting
160
-
- plyr.fm design tokens for brand consistency
161
-
- shows track title, artist handle, match scores, and potential matches
162
-
- "mark false positive" button emits negation label
163
-
- **label context enrichment** (PR #392):
164
-
- labels now include track_title, artist_handle, artist_did, highest_score, matches
165
-
- backfill script (`scripts/backfill_label_context.py`) populated 25 existing flags
166
-
- admin UI displays rich context instead of just ATProto URIs
167
-
- **copyright flag visibility** (PRs #387, #389):
168
-
- artist portal shows copyright flag indicator on flagged tracks
169
-
- tooltip shows primary match (artist - title) for quick context
170
-
- **documentation** (PR #386):
171
-
- comprehensive docs at `docs/moderation/atproto-labeler.md`
172
-
- covers architecture, label schema, XRPC protocol, signing keys
173
-
174
-
**admin UI architecture**:
175
-
- `moderation/static/admin.html` - page structure
176
-
- `moderation/static/admin.css` - plyr.fm design tokens
177
-
- `moderation/static/admin.js` - auth handling (~40 lines)
178
-
- htmx endpoints: `/admin/flags-html`, `/admin/resolve-htmx`
179
-
- server-rendered HTML partials for flag cards
180
-
181
-
---
182
-
183
-
#### copyright moderation system (PRs #382, #384, Nov 29-30)
184
-
185
-
**motivation**: detect potential copyright violations in uploaded tracks to avoid DMCA issues and protect the platform.
186
-
187
-
**what shipped**:
188
-
- **moderation service** (Rust/Axum on Fly.io):
189
-
- standalone service at `plyr-moderation.fly.dev`
190
-
- integrates with AuDD enterprise API for audio fingerprinting
191
-
- scans audio URLs and returns matches with metadata (artist, title, album, ISRC, timecode)
192
-
- auth via `X-Moderation-Key` header
193
-
- **backend integration** (PR #382):
194
-
- `ModerationSettings` in config (service URL, auth token, timeout)
195
-
- moderation client module (`backend/_internal/moderation.py`)
196
-
- fire-and-forget background task on track upload
197
-
- stores results in `copyright_scans` table
198
-
- scan errors stored as "clear" so tracks aren't stuck unscanned
199
-
- **flagging fix** (PR #384):
200
-
- AuDD enterprise API returns no confidence scores (all 0)
201
-
- changed from score threshold to presence-based flagging: `is_flagged = !matches.is_empty()`
202
-
- removed unused `score_threshold` config
203
-
- **backfill script** (`scripts/scan_tracks_copyright.py`):
204
-
- scans existing tracks that haven't been checked
205
-
- `--max-duration` flag to skip long DJ sets (estimated from file size)
206
-
- `--dry-run` mode to preview what would be scanned
207
-
- supports dev/staging/prod environments
208
-
- **review workflow**:
209
-
- `copyright_scans` table has `resolution`, `reviewed_at`, `reviewed_by`, `review_notes` columns
210
-
- resolution values: `violation`, `false_positive`, `original_artist`
113
+
- standalone labeler service integrated into moderation Rust service
114
+
- implements `com.atproto.label.queryLabels` and `subscribeLabels` XRPC endpoints
115
+
- k256 ECDSA signing for cryptographic label verification
116
+
- web interface at `/admin` for reviewing copyright flags
117
+
- htmx for server-rendered interactivity
118
+
- integrates with AuDD enterprise API for audio fingerprinting
119
+
- fire-and-forget background task on track upload
120
+
- review workflow with resolution tracking (violation, false_positive, original_artist)
211
121
212
122
**initial review results** (25 flagged tracks):
213
123
- 8 violations (actual copyright issues)
214
124
- 11 false positives (fingerprint noise)
215
125
- 6 original artists (people uploading their own distributed music)
126
+
127
+
**documentation**: see `docs/moderation/atproto-labeler.md`
216
128
217
129
---
218
130
219
131
#### developer tokens with independent OAuth grants (PR #367, Nov 28)
220
132
221
-
**motivation**: programmatic API access (scripts, CLIs, automation) needed tokens that survive browser logout and don't become stale when browser sessions refresh.
222
-
223
133
**what shipped**:
224
-
- **OAuth-based dev tokens**: each developer token gets its own OAuth authorization flow
225
-
- user clicks "create token" → redirected to PDS for authorization → token created with independent credentials
226
-
- tokens have their own DPoP keypair, access/refresh tokens - completely separate from browser session
227
-
- **cookie isolation**: dev token exchange doesn't set browser cookie
228
-
- added `is_dev_token` flag to ExchangeToken model
229
-
- /auth/exchange skips Set-Cookie for dev token flows
230
-
- prevents logout from deleting dev tokens (critical bug fixed during implementation)
231
-
- **token management UI**: portal → "your data" → "developer tokens"
232
-
- create with optional name and expiration (30/90/180/365 days or never)
233
-
- list active tokens with creation/expiration dates
234
-
- revoke individual tokens
235
-
- **API endpoints**:
236
-
- `POST /auth/developer-token/start` - initiates OAuth flow, returns auth_url
237
-
- `GET /auth/developer-tokens` - list user's tokens
238
-
- `DELETE /auth/developer-tokens/{prefix}` - revoke by 8-char prefix
134
+
- each developer token gets its own OAuth authorization flow
135
+
- tokens have their own DPoP keypair, access/refresh tokens - completely separate from browser session
136
+
- cookie isolation: dev token exchange doesn't set browser cookie
137
+
- token management UI: portal → "your data" → "developer tokens"
138
+
- create with optional name and expiration (30/90/180/365 days or never)
239
139
240
140
**security properties**:
241
141
- tokens are full sessions with encrypted OAuth credentials (Fernet)
242
-
- each token refreshes independently (no staleness from browser session refresh)
142
+
- each token refreshes independently
243
143
- revokable individually without affecting browser or other tokens
244
-
- explicit OAuth consent required at PDS for each token created
245
-
246
-
**documentation**: see `docs/authentication.md` "developer tokens" section
247
144
248
145
---
249
146
250
147
#### platform stats and media session integration (PRs #359-379, Nov 27-29)
251
-
252
-
**motivation**: show platform activity at a glance, improve playback experience across devices, and give users control over their data.
253
148
254
149
**what shipped**:
255
-
- **platform stats endpoint and UI** (PRs #376, #378, #379):
256
-
- `GET /stats` returns total plays, tracks, and artists
257
-
- stats bar displays in homepage header (e.g., "1,691 plays • 55 tracks • 8 artists")
258
-
- skeleton loading animation while fetching
259
-
- responsive layout: visible in header on wide screens, collapses to menu on narrow
260
-
- end-of-list animation on homepage
261
-
- **Media Session API** (PR #371):
262
-
- provides track metadata to CarPlay, lock screens, Bluetooth devices, macOS control center
263
-
- artwork display with fallback to artist avatar
264
-
- play/pause, prev/next, seek controls all work from system UI
265
-
- position state syncs scrubbers on external interfaces
266
-
- **browser tab title** (PR #374):
267
-
- shows "track - artist • plyr.fm" while playing
268
-
- persists across page navigation
269
-
- reverts to page title when playback stops
270
-
- **timed comments** (PR #359):
271
-
- comments capture timestamp when added during playback
272
-
- clickable timestamp buttons seek to that moment
273
-
- compact scrollable comments section on track pages
274
-
- **constellation integration** (PR #360):
275
-
- queries constellation.microcosm.blue backlink index
276
-
- enables network-wide like counts (not just plyr.fm internal)
277
-
- environment-aware namespace handling
278
-
- **account deletion** (PR #363):
279
-
- explicit confirmation flow (type handle to confirm)
280
-
- deletes all plyr.fm data (tracks, albums, likes, comments, preferences)
281
-
- optional ATProto record cleanup with clear warnings about orphaned references
282
-
283
-
---
284
-
285
-
#### oEmbed endpoint for Leaflet.pub embeds (PRs #355-358, Nov 25)
286
-
287
-
**motivation**: plyr.fm tracks embedded in Leaflet.pub (via iframely) showed a black HTML5 audio box instead of our custom embed player.
288
-
289
-
**what shipped**:
290
-
- **oEmbed endpoint** (PR #355): `/oembed` returns proper embed HTML with iframe
291
-
- follows oEmbed spec with `type: "rich"` and iframe in `html` field
292
-
- discovery link in track page `<head>` for automatic detection
293
-
- **iframely domain registration**: registered plyr.fm on iframely.com (free tier)
294
-
- this was the key fix - iframely now returns our embed iframe as `links.player[0]`
295
-
296
-
**debugging journey** (PRs #356-358):
297
-
- initially tried `og:video` meta tags to hint iframe embed - didn't work
298
-
- tried removing `og:audio` to force oEmbed fallback - resulted in no player link
299
-
- discovered iframely requires domain registration to trust oEmbed providers
300
-
- after registration, iframely correctly returns embed iframe URL
150
+
- `GET /stats` returns total plays, tracks, and artists
151
+
- stats bar displays in homepage header (e.g., "1,691 plays • 55 tracks • 8 artists")
152
+
- Media Session API for CarPlay, lock screens, Bluetooth devices
153
+
- browser tab title shows "track - artist • plyr.fm" while playing
154
+
- timed comments with clickable timestamps
155
+
- constellation integration for network-wide like counts
156
+
- account deletion with explicit confirmation
301
157
302
158
---
303
159
304
160
#### export & upload reliability (PRs #337-344, Nov 24)
305
-
306
-
**motivation**: exports were failing silently on large files (OOM), uploads showed incorrect progress, and SSE connections triggered false error toasts.
307
161
308
162
**what shipped**:
309
-
- **database-backed jobs** (PR #337): moved upload/export tracking from in-memory to postgres
310
-
- jobs table persists state across server restarts
311
-
- enables reliable progress tracking via SSE polling
312
-
- **streaming exports** (PR #343): fixed OOM on large file exports
313
-
- previously loaded entire files into memory via `response["Body"].read()`
314
-
- now streams to temp files, adds to zip from disk (constant memory)
315
-
- 90-minute WAV files now export successfully on 1GB VM
316
-
- **progress tracking fix** (PR #340): upload progress was receiving bytes but treating as percentage
317
-
- `UploadProgressTracker` now properly converts bytes to percentage
318
-
- upload progress bar works correctly again
319
-
- **UX improvements** (PRs #338-339, #341-342, #344):
320
-
- export filename now includes date (`plyr-tracks-2025-11-24.zip`)
321
-
- toast notification on track deletion
322
-
- fixed false "lost connection" error when SSE completes normally
323
-
- progress now shows "downloading track X of Y" instead of confusing count
324
-
325
-
---
326
-
327
-
#### queue hydration + ATProto token hardening (Nov 12)
328
-
329
-
**why**: queue endpoints were occasionally taking 2s+ and restore operations could 401
330
-
when multiple requests refreshed an expired ATProto token simultaneously.
331
-
332
-
**what shipped**:
333
-
- added persistent `image_url` on `Track` rows so queue hydration no longer probes R2
334
-
for every track. Queue payloads now pull art directly from Postgres, with a one-time
335
-
fallback for legacy rows.
336
-
- updated `_internal/queue.py` to backfill any missing URLs once (with caching) instead
337
-
of per-request GETs.
338
-
- introduced per-session locks in `_refresh_session_tokens` so only one coroutine hits
339
-
`oauth_client.refresh_session` at a time; others reuse the refreshed tokens. This
340
-
removes the race that caused the batch restore flow to intermittently 500/401.
341
-
342
-
**impact**: queue tail latency dropped back under 500 ms in staging tests, ATProto restore flows are now reliable under concurrent use, and Logfire no longer shows 500s from the PDS.
343
-
344
-
---
345
-
346
-
#### performance optimization session (Nov 12)
347
-
348
-
**issue: slow /tracks/liked endpoint**
349
-
350
-
**symptoms**:
351
-
- `/tracks/liked` taking 600-900ms consistently
352
-
- only ~25ms spent in database queries
353
-
- mysterious 575ms gap with no spans in Logfire traces
354
-
355
-
**root cause**:
356
-
- PR #184 added `image_url` column to tracks table to eliminate N+1 R2 API calls
357
-
- legacy tracks (15 tracks uploaded before PR) had `image_url = NULL`
358
-
- fallback code called `track.get_image_url()` which makes uninstrumented R2 `head_object` API calls
359
-
- 5 tracks × 120ms = ~600ms of uninstrumented latency
360
-
361
-
**solution**: created `scripts/backfill_image_urls.py` to populate missing `image_url` values
362
-
363
-
**results**:
364
-
- `/tracks/liked` now sub-200ms (down from 600-900ms)
365
-
- all endpoints now consistently sub-second response times
366
-
367
-
**database cleanup**:
368
-
- discovered `queue_state` had 265% bloat (53 dead rows, 20 live rows)
369
-
- ran `VACUUM (FULL, ANALYZE) queue_state` against production
370
-
371
-
---
372
-
373
-
#### track detail pages (PR #164, Nov 12)
374
-
375
-
- ✅ dedicated track detail pages with large cover art
376
-
- ✅ play button updates queue state correctly (#169)
377
-
- ✅ liked state loaded efficiently via server-side fetch
378
-
- ✅ mobile-optimized layouts with proper scrolling constraints
379
-
- ✅ origin validation for image URLs (#168)
380
-
381
-
---
382
-
383
-
#### liked tracks feature (PR #157, Nov 11)
384
-
385
-
- ✅ server-side persistent collections
386
-
- ✅ ATProto record publication for cross-platform visibility
387
-
- ✅ UI for adding/removing tracks from liked collection
388
-
- ✅ like counts displayed in track responses and analytics (#170)
389
-
- ✅ analytics cards now clickable links to track detail pages (#171)
390
-
- ✅ liked state shown on artist page tracks (#163)
391
-
392
-
**status**: COMPLETE (issue #144 closed)
393
-
394
-
---
395
-
396
-
#### upload streaming + progress UX (PR #182, Nov 11)
397
-
398
-
- Frontend switched from `fetch` to `XMLHttpRequest` so we can display upload progress
399
-
toasts (critical for >50 MB mixes on mobile).
400
-
- Upload form now clears only after the request succeeds; failed attempts leave the
401
-
form intact so users don't lose metadata.
402
-
- Backend writes uploads/images to temp files in 8 MB chunks before handing them to the
403
-
storage layer, eliminating whole-file buffering and iOS crashes for hour-long mixes.
404
-
- Deployment verified locally and by rerunning the exact repro Stella hit (85 minute
405
-
mix from mobile).
406
-
407
-
---
408
-
409
-
#### transcoder API deployment (PR #156, Nov 11)
410
-
411
-
**standalone Rust transcoding service** 🎉
412
-
- **deployed**: https://plyr-transcoder.fly.dev/
413
-
- **purpose**: convert AIFF/FLAC/etc. to MP3 for browser compatibility
414
-
- **technology**: Axum + ffmpeg + Docker
415
-
- **security**: `X-Transcoder-Key` header authentication (shared secret)
416
-
- **capacity**: handles 1GB uploads, tested with 85-minute AIFF files (~858MB → 195MB MP3 in 32 seconds)
417
-
- **architecture**:
418
-
- 2 Fly machines for high availability
419
-
- auto-stop/start for cost efficiency
420
-
- stateless design (no R2 integration yet)
421
-
- 320kbps MP3 output with proper ID3 tags
422
-
- **status**: deployed and tested, ready for integration into plyr.fm upload pipeline
423
-
- **next steps**: wire into backend with R2 integration and job queue (see issue #153)
424
-
425
-
---
426
-
427
-
#### AIFF/AIF browser compatibility fix (PR #152, Nov 11)
428
-
429
-
**format validation improvements**
430
-
- **problem discovered**: AIFF/AIF files only work in Safari, not Chrome/Firefox
431
-
- browsers throw `MediaError code 4: MEDIA_ERR_SRC_NOT_SUPPORTED`
432
-
- users could upload files but they wouldn't play in most browsers
433
-
- **immediate solution**: reject AIFF/AIF uploads at both backend and frontend
434
-
- removed AIFF/AIF from AudioFormat enum
435
-
- added format hints to upload UI: "supported: mp3, wav, m4a"
436
-
- client-side validation with helpful error messages
437
-
- **long-term solution**: deployed standalone transcoder service (see above)
438
-
- separate Rust/Axum service with ffmpeg
439
-
- accepts all formats, converts to browser-compatible MP3
440
-
- integration into upload pipeline pending (issue #153)
441
-
442
-
**observability improvements**:
443
-
- added logfire instrumentation to upload background tasks
444
-
- added logfire spans to R2 storage operations
445
-
- documented logfire querying patterns in `docs/logfire-querying.md`
446
-
447
-
---
448
-
449
-
#### async I/O performance fixes (PRs #149-151, Nov 10-11)
450
-
451
-
Eliminated event loop blocking across backend with three critical PRs:
452
-
453
-
1. **PR #149: async R2 reads** - converted R2 `head_object` operations from sync boto3 to async aioboto3
454
-
- portal page load time: 2+ seconds → ~200ms
455
-
- root cause: `track.image_url` was blocking on serial R2 HEAD requests
456
-
457
-
2. **PR #150: concurrent PDS resolution** - parallelized ATProto PDS URL lookups
458
-
- homepage load time: 2-6 seconds → 200-400ms
459
-
- root cause: serial `resolve_atproto_data()` calls (8 artists × 200-300ms each)
460
-
- fix: `asyncio.gather()` for batch resolution, database caching for subsequent loads
461
-
462
-
3. **PR #151: async storage writes/deletes** - made save/delete operations non-blocking
463
-
- R2: switched to `aioboto3` for uploads/deletes (async S3 operations)
464
-
- filesystem: used `anyio.Path` and `anyio.open_file()` for chunked async I/O (64KB chunks)
465
-
- impact: multi-MB uploads no longer monopolize worker thread, constant memory usage
466
-
467
-
---
468
-
469
-
#### mobile UI improvements (PRs #159-185, Nov 11-12)
470
-
471
-
- ✅ compact action menus and better navigation (#161)
472
-
- ✅ improved mobile responsiveness (#159)
473
-
- ✅ consistent button layouts across mobile/desktop (#176-181, #185)
474
-
- ✅ always show play count and like count on mobile (#177)
475
-
- ✅ login page UX improvements (#174-175)
476
-
- ✅ liked page UX improvements (#173)
477
-
- ✅ accent color for liked tracks (#160)
163
+
- database-backed jobs (moved tracking from in-memory to postgres)
164
+
- streaming exports (fixed OOM on large file exports)
165
+
- 90-minute WAV files now export successfully on 1GB VM
166
+
- upload progress bar fixes
167
+
- export filename now includes date
478
168
479
169
---
480
170
481
-
### October-November 2025 (early development)
171
+
### October-November 2025
482
172
483
-
#### cover art support (PRs #123-126, #132-139, early Nov)
484
-
- ✅ track cover image upload and storage (separate R2 bucket)
485
-
- ✅ image display on track pages and player
486
-
- ✅ Open Graph meta tags for track sharing
487
-
- ✅ mobile-optimized layouts with cover art
488
-
- ✅ sticky bottom player on mobile with cover
489
-
490
-
---
491
-
492
-
#### queue management improvements (PRs #110-113, #115, late Oct-early Nov)
493
-
- ✅ visual feedback on queue add/remove
494
-
- ✅ toast notifications for queue actions
495
-
- ✅ better error handling for queue operations
496
-
- ✅ improved shuffle and auto-advance UX
497
-
498
-
---
499
-
500
-
#### infrastructure and tooling (Oct-Nov)
501
-
- ✅ R2 bucket separation: audio-prod and images-prod (PR #124)
502
-
- ✅ admin script for content moderation (`scripts/delete_track.py`)
503
-
- ✅ bluesky attribution link in header
504
-
- ✅ changelog target added (#183)
505
-
- ✅ documentation updates (#158)
506
-
- ✅ track metadata edits now persist correctly (#162)
173
+
See `.status_history/2025-11.md` for detailed November development history including:
174
+
- async I/O performance fixes (PRs #149-151)
175
+
- transcoder API deployment (PR #156)
176
+
- upload streaming + progress UX (PR #182)
177
+
- liked tracks feature (PR #157)
178
+
- track detail pages (PR #164)
179
+
- mobile UI improvements (PRs #159-185)
180
+
- oEmbed endpoint for Leaflet.pub embeds (PRs #355-358)
507
181
508
182
## immediate priorities
509
183
510
184
### high priority features
511
185
1. **audio transcoding pipeline integration** (issue #153)
512
186
- ✅ standalone transcoder service deployed at https://plyr-transcoder.fly.dev/
513
-
- ✅ Rust/Axum service with ffmpeg, tested with 85-minute files
514
-
- ✅ secure auth via X-Transcoder-Key header
515
187
- ⏳ next: integrate into plyr.fm upload pipeline
516
188
- backend calls transcoder API for unsupported formats
517
189
- queue-based job system for async processing
518
190
- R2 integration (fetch original, store MP3)
519
-
- maintain original file hash for deduplication
520
-
- handle transcoding failures gracefully
521
191
522
-
### resolved bugs
523
-
1. ~~**upload reliability** (issue #147): upload returns 200 but file missing from R2, no error logged~~
524
-
- **status**: FIXED (issue #147 closed)
525
-
- improved error handling and retry logic in background upload task
526
-
527
-
2. **database connection pool SSL errors**: intermittent failures on first request
528
-
- symptom: `/tracks/` returns 500 on first request, succeeds after
529
-
- fix: set `pool_pre_ping=True`, adjust `pool_recycle` for Neon timeouts
530
-
- documented in `docs/logfire-querying.md`
531
-
532
-
### performance optimizations
533
-
3. **persist concrete file extensions in database**: currently brute-force probing all supported formats on read
534
-
- already know `Track.file_type` and image format during upload
535
-
- eliminating repeated `exists()` checks reduces filesystem/R2 HEAD spam
536
-
- improves audio streaming latency (`/audio/{file_id}` endpoint walks extensions sequentially)
537
-
538
-
4. **stream large uploads directly to storage**: current implementation reads entire file into memory before background task
539
-
- multi-GB uploads risk OOM
540
-
- stream from `UploadFile.file` → storage backend for constant memory usage
192
+
### known issues
193
+
- playback auto-start on refresh (#225) - investigating localStorage/queue state persistence
194
+
- no ATProto records for albums yet (#221 - consciously deferred)
195
+
- no AIFF/AIF transcoding support (#153)
541
196
542
197
### new features
543
-
5. **content-addressable storage** (issue #146)
544
-
- hash-based file storage for automatic deduplication
545
-
- reduces storage costs when multiple artists upload same file
546
-
- enables content verification
547
-
548
-
## open issues by timeline
549
-
550
-
### immediate
551
-
- issue #153: audio transcoding pipeline (ffmpeg worker for AIFF/FLAC→MP3)
552
-
553
-
### short-term
554
198
- issue #146: content-addressable storage (hash-based deduplication)
555
-
- issue #24: implement play count abuse prevention
556
-
- database connection pool tuning (SSL errors)
557
-
- file extension persistence in database
558
-
559
-
### medium-term
560
-
- issue #208: security - medium priority hardening tasks
561
-
- issue #207: security - add comprehensive input validation
562
-
- issue #46: consider removing init_db() from lifespan in favor of migration-only approach
563
-
- issue #56: design public developer API and versioning
564
-
- **note**: SDK (`plyrfm`) and MCP server (`plyrfm-mcp`) now available at https://github.com/zzstoatzz/plyr-python-client
565
-
- `plyrfm` on PyPI - Python SDK + CLI for plyr.fm API
566
-
- `plyrfm-mcp` on PyPI - MCP server, hosted at https://plyrfm.fastmcp.app/mcp
567
-
- issue still open for formal API versioning and public documentation
568
-
- issue #57: support multiple audio item types (voice memos/snippets)
569
-
- issue #122: fullscreen player for immersive playback
570
199
- issue #155: add track metadata (genres, tags, descriptions)
571
-
- issue #166: content moderation for user-uploaded images
572
-
- issue #167: DMCA safe harbor compliance
573
-
- issue #186: liquid glass effects as user-configurable setting
574
-
- issue #221: first-class albums (ATProto records)
575
200
- issue #334: add 'share to bluesky' option for tracks
576
201
- issue #373: lyrics field and Genius-style annotations
577
202
- issue #393: moderation - represent confirmed takedown state in labeler
578
203
579
-
### long-term
580
-
- migrate to plyr-owned lexicon (custom ATProto namespace with richer metadata)
581
-
- publish to multiple ATProto AppViews for cross-platform visibility
582
-
- explore ATProto-native notifications (replace Bluesky DM bot)
583
-
- realtime queue syncing across devices via SSE/WebSocket
584
-
- artist analytics dashboard improvements
585
-
- issue #44: modern music streaming feature parity
586
-
587
204
## technical state
588
205
589
206
### architecture
···
591
208
**backend**
592
209
- language: Python 3.11+
593
210
- framework: FastAPI with uvicorn
594
-
- database: Neon PostgreSQL (serverless, fully managed)
595
-
- storage: Cloudflare R2 (S3-compatible object storage)
596
-
- hosting: Fly.io (2x shared-cpu VMs, auto-scaling)
597
-
- observability: Pydantic Logfire (traces, metrics, logs)
598
-
- auth: ATProto OAuth 2.1 (forked SDK: github.com/zzstoatzz/atproto)
211
+
- database: Neon PostgreSQL (serverless)
212
+
- storage: Cloudflare R2 (S3-compatible)
213
+
- hosting: Fly.io (2x shared-cpu VMs)
214
+
- observability: Pydantic Logfire
215
+
- auth: ATProto OAuth 2.1
599
216
600
217
**frontend**
601
-
- framework: SvelteKit (latest v2.43.2)
602
-
- runtime: Bun (fast JS runtime)
603
-
- hosting: Cloudflare Pages (edge network)
218
+
- framework: SvelteKit (v2.43.2)
219
+
- runtime: Bun
220
+
- hosting: Cloudflare Pages
604
221
- styling: vanilla CSS with lowercase aesthetic
605
-
- state management: Svelte 5 runes ($state, $derived, $effect)
222
+
- state management: Svelte 5 runes
606
223
607
224
**deployment**
608
225
- ci/cd: GitHub Actions
609
-
- backend: automatic on main branch merge (fly.io deploy)
226
+
- backend: automatic on main branch merge (fly.io)
610
227
- frontend: automatic on every push to main (cloudflare pages)
611
228
- migrations: automated via fly.io release_command
612
-
- environments: dev → staging → production (full separation)
613
-
- versioning: nebula timestamp format (YYYY.MMDD.HHMMSS)
614
-
615
-
**key dependencies**
616
-
- atproto: forked SDK for OAuth and record management
617
-
- sqlalchemy: async ORM for postgres
618
-
- alembic: database migrations
619
-
- boto3/aioboto3: R2 storage client
620
-
- logfire: observability (FastAPI + SQLAlchemy instrumentation)
621
-
- httpx: async HTTP client
622
229
623
230
**what's working**
624
231
625
232
**core functionality**
626
233
- ✅ ATProto OAuth 2.1 authentication with encrypted state
627
-
- ✅ secure session management via HttpOnly cookies (XSS protection)
628
-
- ✅ developer tokens with independent OAuth grants (programmatic API access)
629
-
- ✅ platform stats endpoint and homepage display (plays, tracks, artists)
234
+
- ✅ secure session management via HttpOnly cookies
235
+
- ✅ developer tokens with independent OAuth grants
236
+
- ✅ platform stats endpoint and homepage display
630
237
- ✅ Media Session API for CarPlay, lock screens, control center
631
238
- ✅ timed comments on tracks with clickable timestamps
632
239
- ✅ account deletion with explicit confirmation
633
-
- ✅ artist profiles synced with Bluesky (avatar, display name, handle)
240
+
- ✅ artist profiles synced with Bluesky
634
241
- ✅ track upload with streaming to prevent OOM
635
-
- ✅ track edit (title, artist, album, features metadata)
636
-
- ✅ track deletion with cascade cleanup
242
+
- ✅ track edit/deletion with cascade cleanup
637
243
- ✅ audio streaming via HTML5 player with 307 redirects to R2 CDN
638
-
- ✅ track metadata published as ATProto records (fm.plyr.track namespace)
639
-
- ✅ play count tracking with threshold (30% or 30s, whichever comes first)
244
+
- ✅ track metadata published as ATProto records
245
+
- ✅ play count tracking (30% or 30s threshold)
640
246
- ✅ like functionality with counts
641
-
- ✅ artist analytics dashboard
642
247
- ✅ queue management (shuffle, auto-advance, reorder)
643
248
- ✅ mobile-optimized responsive UI
644
249
- ✅ cross-tab queue synchronization via BroadcastChannel
645
-
- ✅ share tracks via URL with Open Graph previews (including cover art)
646
-
- ✅ image URL caching in database (eliminates N+1 R2 calls)
647
-
- ✅ format validation (rejects AIFF/AIF, accepts MP3/WAV/M4A with helpful error messages)
648
-
- ✅ standalone audio transcoding service deployed and verified (see issue #153)
649
-
- ✅ Bluesky embed player UI changes implemented (pending upstream social-app PR)
650
-
- ✅ admin content moderation script for removing inappropriate uploads
651
-
- ✅ copyright moderation system (AuDD fingerprinting, review workflow, violation tracking)
652
-
- ✅ ATProto labeler for copyright violations (queryLabels, subscribeLabels XRPC endpoints)
653
-
- ✅ admin UI for reviewing flagged tracks with htmx (plyr-moderation.fly.dev/admin)
250
+
- ✅ share tracks via URL with Open Graph previews
251
+
- ✅ copyright moderation system with admin UI
252
+
- ✅ ATProto labeler for copyright violations
654
253
655
254
**albums**
656
255
- ✅ album database schema with track relationships
657
-
- ✅ album browsing pages (`/u/{handle}` shows discography)
658
-
- ✅ album detail pages (`/u/{handle}/album/{slug}`) with full track lists
256
+
- ✅ album browsing and detail pages
659
257
- ✅ album cover art upload and display
660
258
- ✅ server-side rendering for SEO
661
-
- ✅ rich Open Graph metadata for link previews (music.album type)
662
-
- ✅ long album title handling (100-char slugs, CSS truncation)
663
259
- ⏸ ATProto records for albums (deferred, see issue #221)
664
260
665
-
**frontend architecture**
666
-
- ✅ server-side data loading (`+page.server.ts`) for artist and album pages
667
-
- ✅ client-side data loading (`+page.ts`) for auth-dependent pages
668
-
- ✅ centralized auth manager (`lib/auth.svelte.ts`)
669
-
- ✅ layout-level auth state (`+layout.ts`) shared across all pages
670
-
- ✅ eliminated "flash of loading" via proper load functions
671
-
- ✅ consistent auth patterns (no scattered localStorage calls)
672
-
673
261
**deployment (fully automated)**
674
262
- **production**:
675
-
- frontend: https://plyr.fm (cloudflare pages)
676
-
- backend: https://relay-api.fly.dev (fly.io: 2 machines, 1GB RAM, 1 shared CPU, min 1 running)
263
+
- frontend: https://plyr.fm
264
+
- backend: https://relay-api.fly.dev → https://api.plyr.fm
677
265
- database: neon postgresql
678
266
- storage: cloudflare R2 (audio-prod and images-prod buckets)
679
-
- deploy: github release → automatic
680
267
681
268
- **staging**:
682
-
- backend: https://api-stg.plyr.fm (fly.io: relay-api-staging)
683
-
- frontend: https://stg.plyr.fm (cloudflare pages: plyr-fm-stg)
269
+
- backend: https://api-stg.plyr.fm
270
+
- frontend: https://stg.plyr.fm
684
271
- database: neon postgresql (relay-staging)
685
272
- storage: cloudflare R2 (audio-stg bucket)
686
-
- deploy: push to main → automatic
687
-
688
-
- **development**:
689
-
- backend: localhost:8000
690
-
- frontend: localhost:5173
691
-
- database: neon postgresql (relay-dev)
692
-
- storage: cloudflare R2 (audio-dev and images-dev buckets)
693
-
694
-
- **developer tooling**:
695
-
- `just serve` - run backend locally
696
-
- `just dev` - run frontend locally
697
-
- `just test` - run test suite
698
-
- `just release` - create production release (backend + frontend)
699
-
- `just release-frontend-only` - deploy only frontend changes (added Nov 13)
700
-
701
-
### what's in progress
702
-
703
-
**immediate work**
704
-
- investigating playback auto-start behavior (#225)
705
-
- page refresh sometimes starts playing immediately
706
-
- may be related to queue state restoration or localStorage caching
707
-
- `autoplay_next` preference not being respected in all cases
708
-
- liquid glass effects as user-configurable setting (#186)
709
-
710
-
**active research**
711
-
- transcoding pipeline architecture (see sandbox/transcoding-pipeline-plan.md)
712
-
- content moderation systems (#166, #167, #393 - takedown state representation)
713
-
- PWA capabilities and offline support (#165)
714
-
715
-
### known issues
716
-
717
-
**player behavior**
718
-
- playback auto-start on refresh (#225)
719
-
- sometimes plays immediately after page load
720
-
- investigating localStorage/queue state persistence
721
-
- may not respect `autoplay_next` preference in all scenarios
722
-
723
-
**missing features**
724
-
- no ATProto records for albums yet (#221 - consciously deferred)
725
-
- no track genres/tags/descriptions yet (#155)
726
-
- no AIFF/AIF transcoding support (#153)
727
-
- no PWA installation prompts (#165)
728
-
- no fullscreen player view (#122)
729
-
730
-
**technical debt**
731
-
- multi-tab playback synchronization could be more robust
732
-
- queue state conflicts can occur with rapid operations
733
273
734
274
### technical decisions
735
275
736
276
**why Python/FastAPI instead of Rust?**
737
277
- rapid prototyping velocity during MVP phase
738
-
- rich ecosystem for web APIs (fastapi, sqlalchemy, pydantic)
278
+
- rich ecosystem for web APIs
739
279
- excellent async support with asyncio
740
-
- lower barrier to contribution
741
280
- trade-off: accepting higher latency for faster development
742
-
- future: can migrate hot paths to Rust if needed (transcoding service already planned)
743
-
744
-
**why Fly.io instead of AWS/GCP?**
745
-
- simple deployment model (dockerfile → production)
746
-
- automatic SSL/TLS certificates
747
-
- built-in global load balancing
748
-
- reasonable pricing for MVP ($5/month)
749
-
- easy migration path to larger providers later
750
-
- trade-off: vendor-specific features, less control
751
281
752
282
**why Cloudflare R2 instead of S3?**
753
283
- zero egress fees (critical for audio streaming)
754
284
- S3-compatible API (easy migration if needed)
755
285
- integrated CDN for fast delivery
756
-
- significantly cheaper than S3 for bandwidth-heavy workloads
757
286
758
287
**why forked atproto SDK?**
759
288
- upstream SDK lacked OAuth 2.1 support
760
289
- needed custom record management patterns
761
290
- maintains compatibility with ATProto spec
762
-
- contributes improvements back when possible
763
-
764
-
**why SvelteKit instead of React/Next.js?**
765
-
- Svelte 5 runes provide excellent reactivity model
766
-
- smaller bundle sizes (critical for mobile)
767
-
- less boilerplate than React
768
-
- SSR + static generation flexibility
769
-
- modern DX with TypeScript
770
-
771
-
**why Neon instead of self-hosted Postgres?**
772
-
- serverless autoscaling (no capacity planning)
773
-
- branch-per-PR workflow (preview databases)
774
-
- automatic backups and point-in-time recovery
775
-
- generous free tier for MVP
776
-
- trade-off: higher latency than co-located DB, but acceptable
777
-
778
-
**why reject AIFF instead of transcoding immediately?**
779
-
- MVP speed: transcoding requires queue infrastructure, ffmpeg setup, error handling
780
-
- user communication: better to be upfront about limitations than silent failures
781
-
- resource management: transcoding is CPU-intensive, needs proper worker architecture
782
-
- future flexibility: can add transcoding as optional feature (high-quality uploads → MP3 delivery)
783
-
- trade-off: some users can't upload AIFF now, but those who can upload MP3 have working experience
784
291
785
292
**why async everywhere?**
786
293
- event loop performance: single-threaded async handles high concurrency
787
294
- I/O-bound workload: most time spent waiting on network/disk
788
-
- recent work (PRs #149-151) eliminated all blocking operations
789
-
- alternative: thread pools for blocking I/O, but increases complexity
790
-
- trade-off: debugging async code harder than sync, but worth throughput gains
791
-
792
-
**why anyio.Path over thread pools?**
793
-
- true async I/O: `anyio` uses OS-level async file operations where available
794
-
- constant memory: chunked reads/writes (64KB) prevent OOM on large files
795
-
- thread pools: would work but less efficient, more context switching
796
-
- trade-off: anyio API slightly different from stdlib `pathlib`, but cleaner async semantics
295
+
- PRs #149-151 eliminated all blocking operations
797
296
798
297
## cost structure
799
298
800
299
current monthly costs: ~$35-40/month
801
300
802
-
- fly.io backend (production): ~$5/month (shared-cpu-1x, 256MB RAM)
803
-
- fly.io backend (staging): ~$5/month (shared-cpu-1x, 256MB RAM)
804
-
- fly.io transcoder: ~$0-5/month (auto-scales to zero when idle)
805
-
- neon postgres: $5/month (starter plan)
806
-
- audd audio fingerprinting: ~$10/month (enterprise API for copyright detection)
301
+
- fly.io backend (production): ~$5/month
302
+
- fly.io backend (staging): ~$5/month
303
+
- fly.io transcoder: ~$0-5/month (auto-scales to zero)
304
+
- neon postgres: $5/month
305
+
- audd audio fingerprinting: ~$10/month
807
306
- cloudflare pages: $0 (free tier)
808
-
- cloudflare R2: ~$0.16/month (6 buckets across dev/staging/prod, no egress fees)
307
+
- cloudflare R2: ~$0.16/month
809
308
- logfire: $0 (free tier)
810
309
- domain: $12/year (~$1/month)
811
310
812
311
## deployment URLs
813
312
814
313
- **production frontend**: https://plyr.fm
815
-
- **production backend**: https://relay-api.fly.dev (redirects to https://api.plyr.fm)
314
+
- **production backend**: https://api.plyr.fm
816
315
- **staging backend**: https://api-stg.plyr.fm
817
316
- **staging frontend**: https://stg.plyr.fm
818
317
- **repository**: https://github.com/zzstoatzz/plyr.fm (private)
819
318
- **monitoring**: https://logfire-us.pydantic.dev/zzstoatzz/relay
820
319
- **bluesky**: https://bsky.app/profile/plyr.fm
821
-
- **latest release**: 2025.1129.214811
822
320
823
-
## health indicators
824
-
825
-
**production status**: ✅ healthy
826
-
- uptime: consistently available
827
-
- response times: <500ms p95 for API endpoints
828
-
- error rate: <1% (mostly invalid OAuth states)
829
-
- storage: ~12 tracks uploaded, functioning correctly
830
-
831
-
**key metrics**
832
-
- total tracks: ~12
833
-
- total artists: ~3
834
-
- play counts: tracked per-track
835
-
- storage used: <1GB R2
836
-
- database size: <10MB postgres
837
-
838
-
## next session prep
839
-
840
-
**context for new agent:**
841
-
1. Fixed R2 image upload path mismatch, ensuring images save with the correct prefix.
842
-
2. Implemented UI changes for the embed player: removed the Queue button and matched fonts to the main app.
843
-
3. Opened a draft PR to the upstream social-app repository for native Plyr.fm embed support.
844
-
4. Updated issue #153 (transcoding pipeline) with a clear roadmap for integration into the backend.
845
-
5. Developed a local verification script for the transcoder service for faster local iteration.
846
-
847
-
**useful commands:**
848
-
- `just backend run` - run backend locally
849
-
- `just frontend dev` - run frontend locally
850
-
- `just test` - run test suite (from `backend/` directory)
851
-
- `gh issue list` - check open issues
852
321
## admin tooling
853
322
854
323
### content moderation
855
324
script: `scripts/delete_track.py`
856
325
- requires `ADMIN_*` prefixed environment variables
857
-
- deletes audio file from R2
858
-
- deletes cover image from R2 (if exists)
859
-
- deletes database record (cascades to likes and queue entries)
860
-
- notes ATProto records for manual cleanup (can't delete from other users' PDS)
326
+
- deletes audio file, cover image, database record
327
+
- notes ATProto records for manual cleanup
861
328
862
329
usage:
863
330
```bash
864
-
# dry run
865
331
uv run scripts/delete_track.py <track_id> --dry-run
866
-
867
-
# delete with confirmation
868
332
uv run scripts/delete_track.py <track_id>
869
-
870
-
# delete without confirmation
871
-
uv run scripts/delete_track.py <track_id> --yes
872
-
873
-
# by URL
874
333
uv run scripts/delete_track.py --url https://plyr.fm/track/34
875
334
```
876
335
877
-
required environment variables:
878
-
- `ADMIN_DATABASE_URL` - production database connection
879
-
- `ADMIN_AWS_ACCESS_KEY_ID` - R2 access key
880
-
- `ADMIN_AWS_SECRET_ACCESS_KEY` - R2 secret
881
-
- `ADMIN_R2_ENDPOINT_URL` - R2 endpoint
882
-
- `ADMIN_R2_BUCKET` - R2 bucket name
883
-
884
-
## known issues
885
-
886
-
### non-blocking
887
-
- cloudflare pages preview URLs return 404 (production works fine)
888
-
- some "relay" references remain in docs and comments
889
-
- ATProto like records can't be deleted when removing tracks (orphaned on users' PDS)
890
-
891
336
## for new contributors
892
337
893
338
### getting started
···
901
346
1. create issue on github
902
347
2. create PR from feature branch
903
348
3. ensure pre-commit hooks pass
904
-
4. test locally
905
-
5. merge to main → deploys to staging automatically
906
-
6. verify on staging
907
-
7. create github release → deploys to production automatically
349
+
4. merge to main → deploys to staging automatically
350
+
5. verify on staging
351
+
6. create github release → deploys to production automatically
908
352
909
353
### key principles
910
354
- type hints everywhere
911
355
- lowercase aesthetic
912
-
- generic terminology (use "items" not "tracks" where appropriate)
913
356
- ATProto first
357
+
- async everywhere (no blocking I/O)
914
358
- mobile matters
915
359
- cost conscious
916
-
- async everywhere (no blocking I/O)
917
360
918
361
### project structure
919
362
```
···
934
377
│ └── static/ # admin UI (html/css/js)
935
378
├── transcoder/ # Rust audio transcoding service
936
379
├── docs/ # documentation
937
-
└── justfile # task runner (mods: backend, frontend, moderation, transcoder)
380
+
└── justfile # task runner
938
381
```
939
382
940
383
## documentation
···
943
386
- [configuration guide](docs/configuration.md)
944
387
- [queue design](docs/queue-design.md)
945
388
- [logfire querying](docs/logfire-querying.md)
946
-
- [pdsx guide](docs/pdsx-guide.md)
947
-
- [neon mcp guide](docs/neon-mcp-guide.md)
389
+
- [moderation & labeler](docs/moderation/atproto-labeler.md)
948
390
949
391
---
950
392
update.wav
update.wav
This is a binary file and will not be displayed.