audio streaming app
plyr.fm
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
191when 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
312Eliminated event loop blocking across backend with three critical PRs:
313
3141. **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
3182. **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
3233. **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)