chore: weekly status maintenance and inaugural podcast episode (#430)

authored by claude[bot] and committed by GitHub 9f6165b3 e666046a

Changed files
+428 -648
.status_history
+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
··· 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

This is a binary file and will not be displayed.