audio streaming app plyr.fm

revert: status maintenance #882, exclude from filter (#884)

This reverts commit b3a57234c68c5e10462baa2eac55e868d0632165.

Also excludes #882 from the status maintenance jq filter (like #724 and #846).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

authored by zzstoatzz.io

Claude Opus 4.6 and committed by
GitHub
1e7c7aec 3644e46c

+276 -333
+1 -1
.github/workflows/status-maintenance.yml
··· 66 date 67 # get the most recently merged status-maintenance PR (filter by branch name, sort by merge date) 68 # NOTE: excluding #724 and #846 which were reverted - remove these exclusions after next successful run 69 - gh pr list --state merged --search "status-maintenance" --limit 20 --json number,title,mergedAt,headRefName | jq '[.[] | select(.headRefName | startswith("status-maintenance-")) | select(.number != 724 and .number != 846)] | sort_by(.mergedAt) | reverse | .[0]' 70 git log --oneline -50 71 ls -la .status_history/ 2>/dev/null || echo "no archive directory yet" 72 wc -l STATUS.md
··· 66 date 67 # get the most recently merged status-maintenance PR (filter by branch name, sort by merge date) 68 # NOTE: excluding #724 and #846 which were reverted - remove these exclusions after next successful run 69 + gh pr list --state merged --search "status-maintenance" --limit 20 --json number,title,mergedAt,headRefName | jq '[.[] | select(.headRefName | startswith("status-maintenance-")) | select(.number != 724 and .number != 846 and .number != 882)] | sort_by(.mergedAt) | reverse | .[0]' 70 git log --oneline -50 71 ls -la .status_history/ 2>/dev/null || echo "no archive directory yet" 72 wc -l STATUS.md
-278
.status_history/2026-01.md
··· 158 - **feed/library toggle** (PR #692): consistent header layout with toggle between feed and library views 159 - **shuffle button moved** (PR #693): shuffle now in queue component instead of player controls 160 - **justfile consistency** (PR #694): standardized `just run` across frontend/backend modules 161 - 162 - --- 163 - 164 - ## Mid-to-Late January 2026 Work (Jan 10-31) 165 - 166 - ### per-track PDS migration + UX polish (PRs #835-839, Jan 30-31) 167 - 168 - **selective migration**: replaced all-or-nothing PDS backfill with a modal where users pick individual tracks to migrate. modal shows file sizes (via R2 HEAD requests), track status badges (on PDS / gated / eligible), and a select-all toggle. 169 - 170 - **non-blocking UX (PR #839)**: the modal initially blocked the user during migration. reworked so the modal is selection-only — picks tracks, fires a callback, closes immediately. POST + SSE progress tracking moved to the parent component with persistent toast updates ("migrating 3/7...", "5 migrated, 2 skipped"). user is never trapped. 171 - 172 - **backend changes (PR #838)**: 173 - - `GET /tracks/me/file-sizes` — parallel R2 HEAD requests (semaphore-capped at 10) to get byte sizes for the migration modal 174 - - `POST /pds-backfill/audio` now accepts optional `track_ids` body to backfill specific tracks (backward-compatible — no body = all eligible) 175 - - SSE progress stream includes `last_processed_track_id` and `last_status` for per-track updates 176 - 177 - **copy fixes (PRs #835-836)**: removed "R2" from user-facing text (settings toggle, upload notes). users see "plyr.fm storage" instead of infrastructure detail. 178 - 179 - **share link clutter (PR #837)**: share links with zero interactions (self-clicks filtered) were cluttering the portal stats section. now hidden until someone else actually clicks the link. 180 - 181 - --- 182 - 183 - ### PDS blob storage for audio (PRs #823-833, Jan 29) 184 - 185 - **audio files can now be stored on the user's PDS** - embraces ATProto's data ownership model. PDS uploads are feature-flagged and opt-in via a user setting, with R2 CDN as the primary delivery path. 186 - 187 - **core implementation (PR #823)**: 188 - - new uploads: audio blob uploaded to PDS, BlobRef stored in track record 189 - - dual-write: R2 copy kept for streaming performance (PDS `getBlob` isn't CDN-optimized) 190 - - graceful fallback: if PDS rejects blob (size limit), track stays R2-only 191 - - gated tracks skip PDS (need auth-protected access) 192 - 193 - **database changes**: 194 - - `audio_storage`: "r2" | "pds" | "both" 195 - - `pds_blob_cid`: CID of blob on user's PDS 196 - - `pds_blob_size`: size in bytes 197 - 198 - **bug fixes and hardening (PRs #824-828)**: 199 - - fix atproto headers lost on DPoP retry (#824) 200 - - fail upload on unexpected PDS errors instead of silent fallback (#825) 201 - - add `blob:*/*` OAuth scope to both permission sets and granular paths (#826, #827) 202 - - remove PDS indicator from track UI — PDS will be the default, no need to badge it (#828) 203 - 204 - **batch backfill (PR #829)**: `POST /pds-backfill/audio` starts a background job (docket) to backfill existing tracks to the user's PDS with SSE progress streaming. frontend `PdsBackfillControl` component in the portal. 205 - 206 - **copyright DM fix (PR #831)**: removed misleading "0% confidence" from copyright notification DMs — the enterprise AudD API doesn't return confidence values. 207 - 208 - **feature flag gating (PR #833)**: PDS uploads during track upload are now gated behind two checks: admin-assigned `pds-audio-uploads` feature flag + per-user toggle in Settings > Experimental. default behavior is R2-only unless both are enabled. 209 - 210 - **terms update (PR #832)**: clarified PDS delisting language in terms of service. 211 - 212 - **research**: documented emerging ATProto media service patterns from [community discourse](https://discourse.atprotocol.community/t/media-pds-service/297) — the ecosystem is converging on dedicated sidecar media services rather than PDS-as-media-host. our layered architecture (R2 + CDN + PDS records) aligns well. see `docs/research/2026-01-29-atproto-media-service-patterns.md`. 213 - 214 - --- 215 - 216 - ### PDS-based account creation (PRs #813-815, Jan 27) 217 - 218 - **create ATProto accounts directly from plyr.fm** - users without an existing ATProto identity can now create one during sign-up by selecting a PDS host. 219 - 220 - **how it works**: 221 - - login page shows "create account" tab when feature is enabled 222 - - user selects a PDS (currently selfhosted.social) 223 - - OAuth flow uses `prompt=create` to trigger account creation on the PDS 224 - - after account creation, user is redirected back and logged in 225 - 226 - **implementation details**: 227 - - `/auth/pds-options` endpoint returns available PDS hosts from config 228 - - `/auth/start` accepts `pds_url` parameter for account creation flow 229 - - handle resolution falls back to PDS directly (via `com.atproto.repo.describeRepo`) when Bluesky AppView hasn't indexed the new account yet 230 - 231 - **configuration** (`AccountCreationSettings`): 232 - - `enabled`: feature flag for account creation 233 - - `recommended_pds`: list of PDS options with name, url, and description 234 - 235 - --- 236 - 237 - ### lossless audio support (PRs #794-801, Jan 25) 238 - 239 - **transcoding integration complete** - users can now upload AIFF and FLAC files. the system transcodes them to MP3 for browser compatibility while preserving originals for lossless playback. 240 - 241 - **how it works**: 242 - - upload AIFF/FLAC → original saved to R2, transcoded MP3 created 243 - - database stores both `file_id` (transcoded) and `original_file_id` (lossless) 244 - - frontend detects browser capabilities via `canPlayType()` 245 - - Safari/native apps get lossless, Chrome/Firefox get transcoded MP3 246 - - lossless badge shown on track cards when browser supports the format 247 - 248 - **key changes**: 249 - - `original_file_id` and `original_file_type` added to Track model and API 250 - - audio endpoint serves either version based on requested file_id 251 - - feature-flagged via `lossless-uploads` user flag 252 - 253 - **bug fixes during rollout**: 254 - - PR #796: audio endpoint now queries by `file_id` OR `original_file_id` 255 - - PR #797: store actual extension (`.aif`) not normalized format name (`.aiff`) 256 - 257 - **UI polish (PRs #799-801)**: 258 - - lossless badge positioned in top-right corner of track card (not artwork) 259 - - subtle glowing animation draws attention to premium quality tracks 260 - - whole card gets accent-colored border treatment when lossless 261 - - theme-aware styling, responsive sizing, respects `prefers-reduced-motion` 262 - 263 - --- 264 - 265 - ### auth check optimization (PRs #781-782, Jan 23) 266 - 267 - **eliminated redundant /auth/me calls** - previously, every navigation triggered an auth check via the layout load function. for unauthenticated users, this meant a 401 on every page click (117 errors in 24 hours observed via Logfire). 268 - 269 - **fix**: auth singleton now tracks initialization state. `+layout.svelte` checks auth once on mount instead of every navigation. follow-up PR fixed library/liked pages that were broken by the layout simplification (they were using `await parent()` to get `isAuthenticated` which was no longer provided). 270 - 271 - --- 272 - 273 - ### remove SSR sensitive-images fetch (PR #785, Jan 24) 274 - 275 - **eliminated unnecessary SSR fetch** - the frontend SSR (`+layout.server.ts`) was fetching `/moderation/sensitive-images` on every page load to pre-populate the client-side moderation filter. during traffic spikes, this hammered the backend (1,179 rate limit hits over 7 days). 276 - 277 - **root cause**: the SSR fetch was premature optimization. cloudflare pages workers make direct fetch calls to fly.io - there's no CDN layer to cache responses. the cache-control headers we added in PR #784 only help browser caching, not SSR-to-origin requests. 278 - 279 - **fix**: removed the SSR fetch entirely. the client-side `ModerationManager` singleton already has caching and will fetch the data once on page load. the "flash of sensitive content" risk is theoretical - images load slower than a single API call completes, and there are only 2 flagged images. 280 - 281 - - deleted `+layout.server.ts` 282 - - simplified `+layout.ts` 283 - - updated pages to use `moderation.isSensitive()` singleton instead of SSR data 284 - 285 - --- 286 - 287 - ### listen receipts (PR #773, Jan 22) 288 - 289 - **share links now track who clicked and played** - when you share a track, you get a URL with a `?ref=` code that records visitors and listeners: 290 - - `POST /tracks/{id}/share` creates tracked share link with unique 8-character code (48 bits entropy) 291 - - frontend captures `?ref=` param on page load, fires click event to backend 292 - - play endpoint accepts optional `ref` param to record play attribution 293 - - `GET /tracks/me/shares` returns paginated stats: visitors, listeners, anonymous counts 294 - 295 - **portal share stats section**: 296 - - expandable cards per share link with copyable tracked URL 297 - - visitors (who clicked) and listeners (who played) shown as avatar circles 298 - - individual interaction counts per user 299 - - self-clicks/plays filtered out to avoid inflating stats 300 - 301 - **data model**: 302 - - `ShareLink` table: code, track_id, creator_did, created_at 303 - - `ShareLinkEvent` table: share_link_id, visitor_did (nullable for anonymous), event_type (click/play) 304 - 305 - --- 306 - 307 - ### handle display fix (PR #774, Jan 22) 308 - 309 - **DIDs were displaying instead of handles** in share link stats and other places (comments, track likers): 310 - - root cause: Artist records were only created during profile setup 311 - - users who authenticated but skipped setup had no Artist record 312 - - fix: create minimal Artist record (did, handle, avatar) during OAuth callback 313 - - profile setup now updates existing record instead of erroring 314 - 315 - --- 316 - 317 - ### responsive embed v2 (PRs #771-772, Jan 20-21) 318 - 319 - **complete rewrite of embed CSS** using container queries and proportional scaling: 320 - 321 - **layout modes**: 322 - - **wide** (width >= 400px): side art, proportional sizing 323 - - **very wide** (width >= 600px): larger art, more breathing room 324 - - **square/tall** (aspect <= 1.2, width >= 200px): art on top, 2-line titles 325 - - **very tall** (aspect <= 0.7, width >= 200px): blurred background overlay 326 - - **narrow** (width < 280px): compact blurred background 327 - - **micro** (width < 200px): hide time labels and logo 328 - 329 - **key technical changes**: 330 - - all sizes use `clamp()` with `cqi` units (container query units) 331 - - grid-based header layout instead of absolute positioning 332 - - gradient overlay (top-heavy to bottom-heavy) for text readability 333 - 334 - --- 335 - 336 - ### terms of service and privacy policy (PRs #567, #761-770, Jan 19-20) 337 - 338 - **legal foundation shipped** with ATProto-aware design: 339 - 340 - **terms cover**: 341 - - AT Protocol context (decentralized identity, user-controlled PDS) 342 - - content ownership (users retain ownership, plyr.fm gets license for streaming) 343 - - DMCA safe harbor with designated agent (DMCA-1069186) 344 - - federation disclaimer: audio files in blob storage we control, but ATProto records may persist on user's PDS 345 - 346 - **privacy policy**: 347 - - explicit third-party list with links (Cloudflare, Fly.io, Neon, Logfire, AudD, Anthropic, ATProtoFans) 348 - - data ownership clarity (DID, profile, tracks on user's PDS) 349 - - MIT license added to repo 350 - 351 - **acceptance flow** (TermsOverlay component): 352 - - shown on first login if `terms_accepted_at` is null 353 - - 4-bullet summary with links to full documents 354 - - "I Accept" or "Decline & Logout" options 355 - - `POST /account/accept-terms` records timestamp 356 - 357 - **polish PRs** (#761-770): corrected ATProto vs "our servers" terminology, standardized AT Protocol naming, added email fallbacks, capitalized sentence starts 358 - 359 - --- 360 - 361 - ### content gating research (Jan 18) 362 - 363 - researched ATProtoFans architecture and JSONLogic rule evaluation. documented findings in `docs/content-gating-roadmap.md`: 364 - - current ATProtoFans records and API (supporter, supporterProof, brokerProof, terms) 365 - - the gap: terms exist but aren't exposed via validateSupporter 366 - - how magazi uses datalogic-rs for flexible rule evaluation 367 - - open questions about upcoming metadata extensions 368 - 369 - no implementation changes - waiting to align with what ATProtoFans will support. 370 - 371 - ### logout modal UX (PRs #755-757, Jan 17-18) 372 - 373 - **tooltip scroll fix** (PR #755): 374 - - leftmost avatar in likers/commenters tooltip was clipped with no way to scroll to it 375 - - changed `justify-content: center` to `flex-start` so most recent (leftmost) is always visible 376 - 377 - **logout modal copy** (PRs #756-757): 378 - - simplified from two confusing questions to one clear question 379 - - before: "stay logged in?" + "you're logging out of @handle?" 380 - - after: "switch accounts?" 381 - - "logout completely" → "log out of all accounts" 382 - 383 - --- 384 - 385 - ### idempotent teal scrobbles (PR #754, Jan 16) 386 - 387 - **prevents duplicate scrobbles** when same play is submitted multiple times: 388 - - use `putRecord` with deterministic TID rkeys derived from `playedTime` instead of `createRecord` 389 - - network retries, multiple teal-compatible services, or background task retries won't create duplicates 390 - - adds `played_time` parameter to `build_teal_play_record` for deterministic record keys 391 - 392 - --- 393 - 394 - ### avatar refresh and tooltip polish (PRs #750-752, Jan 13) 395 - 396 - **avatar refresh from anywhere** (PR #751): 397 - - previously, stale avatar URLs were only fixed when visiting the artist detail page 398 - - now any broken avatar triggers a background refresh from Bluesky 399 - - shared `avatar-refresh.svelte.ts` provides global cache and request deduplication 400 - - works from: track items, likers tooltip, commenters tooltip, profile page 401 - 402 - **interactive tooltips** (PR #750): 403 - - hovering on like count shows avatar circles of users who liked 404 - - hovering on comment count shows avatar circles of commenters 405 - - lazy-loaded with 5-minute cache, invalidated when likes/comments change 406 - - elegant centered layout with horizontal scroll when needed 407 - 408 - **UX polish** (PR #752): 409 - - added prettier config with `useTabs: true` to match existing style 410 - - reduced avatar hover effect intensity (scale 1.2 → 1.08) 411 - - fixed avatar hover clipping at tooltip edge (added top padding) 412 - - track title now links to detail page (color change on hover) 413 - 414 - --- 415 - 416 - ### copyright flagging fix (PR #748, Jan 12) 417 - 418 - **switched from score-based to dominant match detection**: 419 - - AudD's enterprise API doesn't return confidence scores (always 0) 420 - - previous threshold-based detection was broken 421 - - new approach: flag if one song appears in >= 30% of matched segments 422 - - filters false positives where random segments match different songs 423 - 424 - --- 425 - 426 - ### Neon cold start fix (Jan 11) 427 - 428 - **why**: first requests after idle periods would fail with 500 errors due to Neon serverless scaling to zero after 5 minutes of inactivity. previous mitigations (larger pool, longer timeouts) helped but didn't eliminate the problem. 429 - 430 - **fix**: disabled scale-to-zero on `plyr-prd` via Neon console. this is the [recommended approach](https://neon.com/blog/6-best-practices-for-running-neon-in-production) for production workloads. 431 - 432 - **configuration**: 433 - - `plyr-prd`: scale-to-zero **disabled** (`suspend_timeout_seconds: -1`) 434 - - `plyr-stg`, `plyr-dev`: scale-to-zero enabled (cold starts acceptable) 435 - 436 - **docs**: updated [connection-pooling.md](docs/backend/database/connection-pooling.md) with production guidance and how to verify settings via Neon MCP. 437 - 438 - closes #733
··· 158 - **feed/library toggle** (PR #692): consistent header layout with toggle between feed and library views 159 - **shuffle button moved** (PR #693): shuffle now in queue component instead of player controls 160 - **justfile consistency** (PR #694): standardized `just run` across frontend/backend modules
+275 -54
STATUS.md
··· 105 106 --- 107 108 - #### mood search MVP + semantic search hardening (PRs #848-858, Feb 5-6) 109 110 - **mood search shipped** (PR #848): users can search by vibe/mood using natural language queries like "chill beats for studying". built on CLAP audio embeddings stored in turbopuffer, with a feature flag (`vibe-search`) and version-aware terms re-acceptance (privacy policy updated for ML processing). 111 112 **how it works**: 113 - - text query → CLAP text embedding → turbopuffer cosine similarity → ranked tracks 114 - - results merged with keyword search in parallel, deduplicated by score 115 - - feature-flagged behind `vibe-search` user flag + terms version check 116 117 - **CLAP pipeline fixes (PRs #849-850)**: m4a support added (was failing on non-wav), R2 URL corrected, Modal API updated. embedding pipeline now normalizes similarity scores properly. 118 119 - **search quality hardening (PRs #851-858)**: 120 - - unified search runs keyword + semantic in parallel, merges by score instead of separating by type (#851, #858) 121 - - distance threshold + result cap to filter low-signal semantic matches (#852) 122 - - spread check filters results where all scores are similarly mediocre (#853) 123 - - switched CLAP model from `larger_clap_music` to `clap-htsat-unfused` (better quality, smaller) (#854) 124 - - handle empty turbopuffer namespace without error (#855) 125 - - concurrent backfill script, renamed "vibe search" → "mood search" throughout (#856) 126 - - cold start latency fix for search endpoint (#857) 127 128 --- 129 130 - #### paginate portal tracks list (PR #878, Feb 8) 131 132 - portal tracks list now loads 25 tracks initially with a "load more" button instead of fetching all tracks at once. reduces initial page load for artists with large catalogs. 133 134 --- 135 136 - #### repo reorganization (PR #876, Feb 8) 137 138 - moved `transcoder/`, `moderation/`, `redis/`, and `clap/` into `services/` and `infrastructure/` directories to match the documented project structure. updated all justfiles, Dockerfiles, GitHub Actions workflows, and deployment configs. 139 140 --- 141 142 - #### smaller fixes (Feb 2-8) 143 144 - - **mobile login handle hint** (PRs #843-845): prevent text wrap, hide hint text on mobile (show only service links), adaptively size based on viewport 145 - - **shared ShareButton** (PR #841): track detail page now uses the same ShareButton component as everywhere else instead of a one-off implementation 146 - - **PDS backfill feature flag** (PR #842): gate PDS backfill behind `pds-audio-uploads` feature flag (was previously accessible to all users) 147 - - **split genre/subgenre tags** (PR #870): genre predictions like "Electronic---Ambient" now produce two separate tags (`electronic`, `ambient`) instead of one combined tag 148 - - **remove duplicate toggle** (PR #874): duplicate auto-download toggle removed from playback settings section 149 - - **recommended tags endpoint** (PR #859, #861-862): `GET /tracks/{id}/recommended-tags` with turbopuffer vector fetch fix and namespace naming docs 150 151 --- 152 153 - ### January 2026 154 155 See `.status_history/2026-01.md` for detailed history including: 156 - - per-track PDS migration + UX polish (PRs #835-839, Jan 30-31) 157 - - PDS blob storage for audio (PRs #823-833, Jan 29) 158 - - PDS-based account creation (PRs #813-815, Jan 27) 159 - - lossless audio support (PRs #794-801, Jan 25) 160 - - auth check optimization (PRs #781-782, Jan 23) 161 - - remove SSR sensitive-images fetch (PR #785, Jan 24) 162 - - listen receipts (PR #773, Jan 22) 163 - - responsive embed v2 (PRs #771-772, Jan 20-21) 164 - - terms of service and privacy policy (PRs #567, #761-770, Jan 19-20) 165 - - content gating research, logout modal UX (Jan 16-18) 166 - - avatar refresh and tooltip polish (PRs #750-752, Jan 13) 167 - - copyright flagging fix (PR #748, Jan 12) 168 - - Neon cold start fix (Jan 11) 169 - - multi-account experience (PRs #707-714, Jan 3-5) 170 - - integration tests, track edit UX, auth stabilization (Jan 6-9) 171 - - artist bio links, copyright moderation, OAuth permission sets (Jan 1-3) 172 173 ### December 2025 174 ··· 213 214 ### current focus 215 216 - ML-powered track features shipped: genre classification (Replicate effnet-discogs) auto-runs on upload with optional auto-tagging. mood search (CLAP embeddings + turbopuffer) feature-flagged behind `vibe-search` with unified keyword+semantic results. performance optimization pass on hot endpoints (GET /tracks/top p95 cut in half). portal pagination for large catalogs. repo reorganization to match documented project structure. 217 218 ### known issues 219 - iOS PWA audio may hang on first play after backgrounding ··· 267 - ✅ lossless audio (AIFF/FLAC) with automatic transcoding for browser compatibility 268 - ✅ PDS blob storage for audio (user data ownership) 269 - ✅ play count tracking, likes, queue management 270 - - ✅ unified search with Cmd/Ctrl+K (keyword + mood/semantic in parallel) 271 - ✅ teal.fm scrobbling 272 - ✅ copyright moderation with ATProto labeler 273 - ✅ ML genre classification with suggested tags in edit modal + auto-tag at upload (Replicate effnet-discogs) 274 - - ✅ mood search via CLAP audio embeddings + turbopuffer (feature-flagged) 275 - ✅ docket background tasks (copyright scan, export, atproto sync, scrobble, genre classification) 276 - ✅ media export with concurrent downloads 277 - ✅ supporter-gated content via atprotofans ··· 316 - cloudflare (R2 + pages + domain): ~$1/month 317 - audd audio fingerprinting: $5-10/month (usage-based) 318 - replicate (genre classification): <$1/month (scales to zero, ~$0.00019/run) 319 - - modal (CLAP embeddings): usage-based (scales to zero) 320 - - turbopuffer (vector search): usage-based 321 - logfire: $0 (free tier) 322 323 ## admin tooling ··· 366 ├── frontend/ # SvelteKit app 367 │ ├── src/lib/ # components & state 368 │ └── src/routes/ # pages 369 - ├── services/ 370 - │ ├── transcoder/ # Rust audio transcoding (Fly.io) 371 - │ ├── moderation/ # Rust content moderation (Fly.io) 372 - │ └── clap/ # ML audio embeddings (Python, Modal) 373 - ├── infrastructure/ 374 - │ └── redis/ # self-hosted Redis (Fly.io) 375 ├── docs/ # documentation 376 └── justfile # task runner 377 ``` ··· 387 388 --- 389 390 - this is a living document. last updated 2026-02-08 (status maintenance: archived January 2026 to `.status_history/2026-01.md`).
··· 105 106 --- 107 108 + ### January 2026 109 + 110 + #### per-track PDS migration + UX polish (PRs #835-839, Jan 30-31) 111 + 112 + **selective migration**: replaced all-or-nothing PDS backfill with a modal where users pick individual tracks to migrate. modal shows file sizes (via R2 HEAD requests), track status badges (on PDS / gated / eligible), and a select-all toggle. 113 + 114 + **non-blocking UX (PR #839)**: the modal initially blocked the user during migration. reworked so the modal is selection-only — picks tracks, fires a callback, closes immediately. POST + SSE progress tracking moved to the parent component with persistent toast updates ("migrating 3/7...", "5 migrated, 2 skipped"). user is never trapped. 115 + 116 + **backend changes (PR #838)**: 117 + - `GET /tracks/me/file-sizes` — parallel R2 HEAD requests (semaphore-capped at 10) to get byte sizes for the migration modal 118 + - `POST /pds-backfill/audio` now accepts optional `track_ids` body to backfill specific tracks (backward-compatible — no body = all eligible) 119 + - SSE progress stream includes `last_processed_track_id` and `last_status` for per-track updates 120 + 121 + **copy fixes (PRs #835-836)**: removed "R2" from user-facing text (settings toggle, upload notes). users see "plyr.fm storage" instead of infrastructure detail. 122 + 123 + **share link clutter (PR #837)**: share links with zero interactions (self-clicks filtered) were cluttering the portal stats section. now hidden until someone else actually clicks the link. 124 + 125 + --- 126 + 127 + #### PDS blob storage for audio (PRs #823-833, Jan 29) 128 + 129 + **audio files can now be stored on the user's PDS** - embraces ATProto's data ownership model. PDS uploads are feature-flagged and opt-in via a user setting, with R2 CDN as the primary delivery path. 130 + 131 + **core implementation (PR #823)**: 132 + - new uploads: audio blob uploaded to PDS, BlobRef stored in track record 133 + - dual-write: R2 copy kept for streaming performance (PDS `getBlob` isn't CDN-optimized) 134 + - graceful fallback: if PDS rejects blob (size limit), track stays R2-only 135 + - gated tracks skip PDS (need auth-protected access) 136 + 137 + **database changes**: 138 + - `audio_storage`: "r2" | "pds" | "both" 139 + - `pds_blob_cid`: CID of blob on user's PDS 140 + - `pds_blob_size`: size in bytes 141 + 142 + **bug fixes and hardening (PRs #824-828)**: 143 + - fix atproto headers lost on DPoP retry (#824) 144 + - fail upload on unexpected PDS errors instead of silent fallback (#825) 145 + - add `blob:*/*` OAuth scope to both permission sets and granular paths (#826, #827) 146 + - remove PDS indicator from track UI — PDS will be the default, no need to badge it (#828) 147 + 148 + **batch backfill (PR #829)**: `POST /pds-backfill/audio` starts a background job (docket) to backfill existing tracks to the user's PDS with SSE progress streaming. frontend `PdsBackfillControl` component in the portal. 149 + 150 + **copyright DM fix (PR #831)**: removed misleading "0% confidence" from copyright notification DMs — the enterprise AudD API doesn't return confidence values. 151 + 152 + **feature flag gating (PR #833)**: PDS uploads during track upload are now gated behind two checks: admin-assigned `pds-audio-uploads` feature flag + per-user toggle in Settings > Experimental. default behavior is R2-only unless both are enabled. 153 + 154 + **terms update (PR #832)**: clarified PDS delisting language in terms of service. 155 + 156 + **research**: documented emerging ATProto media service patterns from [community discourse](https://discourse.atprotocol.community/t/media-pds-service/297) — the ecosystem is converging on dedicated sidecar media services rather than PDS-as-media-host. our layered architecture (R2 + CDN + PDS records) aligns well. see `docs/research/2026-01-29-atproto-media-service-patterns.md`. 157 + 158 + --- 159 + 160 + #### PDS-based account creation (PRs #813-815, Jan 27) 161 + 162 + **create ATProto accounts directly from plyr.fm** - users without an existing ATProto identity can now create one during sign-up by selecting a PDS host. 163 + 164 + **how it works**: 165 + - login page shows "create account" tab when feature is enabled 166 + - user selects a PDS (currently selfhosted.social) 167 + - OAuth flow uses `prompt=create` to trigger account creation on the PDS 168 + - after account creation, user is redirected back and logged in 169 + 170 + **implementation details**: 171 + - `/auth/pds-options` endpoint returns available PDS hosts from config 172 + - `/auth/start` accepts `pds_url` parameter for account creation flow 173 + - handle resolution falls back to PDS directly (via `com.atproto.repo.describeRepo`) when Bluesky AppView hasn't indexed the new account yet 174 175 + **configuration** (`AccountCreationSettings`): 176 + - `enabled`: feature flag for account creation 177 + - `recommended_pds`: list of PDS options with name, url, and description 178 + 179 + --- 180 + 181 + #### lossless audio support (PRs #794-801, Jan 25) 182 + 183 + **transcoding integration complete** - users can now upload AIFF and FLAC files. the system transcodes them to MP3 for browser compatibility while preserving originals for lossless playback. 184 185 **how it works**: 186 + - upload AIFF/FLAC → original saved to R2, transcoded MP3 created 187 + - database stores both `file_id` (transcoded) and `original_file_id` (lossless) 188 + - frontend detects browser capabilities via `canPlayType()` 189 + - Safari/native apps get lossless, Chrome/Firefox get transcoded MP3 190 + - lossless badge shown on track cards when browser supports the format 191 + 192 + **key changes**: 193 + - `original_file_id` and `original_file_type` added to Track model and API 194 + - audio endpoint serves either version based on requested file_id 195 + - feature-flagged via `lossless-uploads` user flag 196 + 197 + **bug fixes during rollout**: 198 + - PR #796: audio endpoint now queries by `file_id` OR `original_file_id` 199 + - PR #797: store actual extension (`.aif`) not normalized format name (`.aiff`) 200 + 201 + **UI polish (PRs #799-801)**: 202 + - lossless badge positioned in top-right corner of track card (not artwork) 203 + - subtle glowing animation draws attention to premium quality tracks 204 + - whole card gets accent-colored border treatment when lossless 205 + - theme-aware styling, responsive sizing, respects `prefers-reduced-motion` 206 + 207 + --- 208 + 209 + #### auth check optimization (PRs #781-782, Jan 23) 210 + 211 + **eliminated redundant /auth/me calls** - previously, every navigation triggered an auth check via the layout load function. for unauthenticated users, this meant a 401 on every page click (117 errors in 24 hours observed via Logfire). 212 + 213 + **fix**: auth singleton now tracks initialization state. `+layout.svelte` checks auth once on mount instead of every navigation. follow-up PR fixed library/liked pages that were broken by the layout simplification (they were using `await parent()` to get `isAuthenticated` which was no longer provided). 214 + 215 + --- 216 + 217 + #### remove SSR sensitive-images fetch (PR #785, Jan 24) 218 + 219 + **eliminated unnecessary SSR fetch** - the frontend SSR (`+layout.server.ts`) was fetching `/moderation/sensitive-images` on every page load to pre-populate the client-side moderation filter. during traffic spikes, this hammered the backend (1,179 rate limit hits over 7 days). 220 + 221 + **root cause**: the SSR fetch was premature optimization. cloudflare pages workers make direct fetch calls to fly.io - there's no CDN layer to cache responses. the cache-control headers we added in PR #784 only help browser caching, not SSR-to-origin requests. 222 + 223 + **fix**: removed the SSR fetch entirely. the client-side `ModerationManager` singleton already has caching and will fetch the data once on page load. the "flash of sensitive content" risk is theoretical - images load slower than a single API call completes, and there are only 2 flagged images. 224 + 225 + - deleted `+layout.server.ts` 226 + - simplified `+layout.ts` 227 + - updated pages to use `moderation.isSensitive()` singleton instead of SSR data 228 + 229 + --- 230 + 231 + #### listen receipts (PR #773, Jan 22) 232 + 233 + **share links now track who clicked and played** - when you share a track, you get a URL with a `?ref=` code that records visitors and listeners: 234 + - `POST /tracks/{id}/share` creates tracked share link with unique 8-character code (48 bits entropy) 235 + - frontend captures `?ref=` param on page load, fires click event to backend 236 + - play endpoint accepts optional `ref` param to record play attribution 237 + - `GET /tracks/me/shares` returns paginated stats: visitors, listeners, anonymous counts 238 + 239 + **portal share stats section**: 240 + - expandable cards per share link with copyable tracked URL 241 + - visitors (who clicked) and listeners (who played) shown as avatar circles 242 + - individual interaction counts per user 243 + - self-clicks/plays filtered out to avoid inflating stats 244 + 245 + **data model**: 246 + - `ShareLink` table: code, track_id, creator_did, created_at 247 + - `ShareLinkEvent` table: share_link_id, visitor_did (nullable for anonymous), event_type (click/play) 248 + 249 + --- 250 + 251 + #### handle display fix (PR #774, Jan 22) 252 + 253 + **DIDs were displaying instead of handles** in share link stats and other places (comments, track likers): 254 + - root cause: Artist records were only created during profile setup 255 + - users who authenticated but skipped setup had no Artist record 256 + - fix: create minimal Artist record (did, handle, avatar) during OAuth callback 257 + - profile setup now updates existing record instead of erroring 258 + 259 + --- 260 + 261 + #### responsive embed v2 (PRs #771-772, Jan 20-21) 262 + 263 + **complete rewrite of embed CSS** using container queries and proportional scaling: 264 + 265 + **layout modes**: 266 + - **wide** (width >= 400px): side art, proportional sizing 267 + - **very wide** (width >= 600px): larger art, more breathing room 268 + - **square/tall** (aspect <= 1.2, width >= 200px): art on top, 2-line titles 269 + - **very tall** (aspect <= 0.7, width >= 200px): blurred background overlay 270 + - **narrow** (width < 280px): compact blurred background 271 + - **micro** (width < 200px): hide time labels and logo 272 + 273 + **key technical changes**: 274 + - all sizes use `clamp()` with `cqi` units (container query units) 275 + - grid-based header layout instead of absolute positioning 276 + - gradient overlay (top-heavy to bottom-heavy) for text readability 277 + 278 + --- 279 + 280 + #### terms of service and privacy policy (PRs #567, #761-770, Jan 19-20) 281 + 282 + **legal foundation shipped** with ATProto-aware design: 283 + 284 + **terms cover**: 285 + - AT Protocol context (decentralized identity, user-controlled PDS) 286 + - content ownership (users retain ownership, plyr.fm gets license for streaming) 287 + - DMCA safe harbor with designated agent (DMCA-1069186) 288 + - federation disclaimer: audio files in blob storage we control, but ATProto records may persist on user's PDS 289 + 290 + **privacy policy**: 291 + - explicit third-party list with links (Cloudflare, Fly.io, Neon, Logfire, AudD, Anthropic, ATProtoFans) 292 + - data ownership clarity (DID, profile, tracks on user's PDS) 293 + - MIT license added to repo 294 295 + **acceptance flow** (TermsOverlay component): 296 + - shown on first login if `terms_accepted_at` is null 297 + - 4-bullet summary with links to full documents 298 + - "I Accept" or "Decline & Logout" options 299 + - `POST /account/accept-terms` records timestamp 300 301 + **polish PRs** (#761-770): corrected ATProto vs "our servers" terminology, standardized AT Protocol naming, added email fallbacks, capitalized sentence starts 302 303 --- 304 305 + #### content gating research (Jan 18) 306 307 + researched ATProtoFans architecture and JSONLogic rule evaluation. documented findings in `docs/content-gating-roadmap.md`: 308 + - current ATProtoFans records and API (supporter, supporterProof, brokerProof, terms) 309 + - the gap: terms exist but aren't exposed via validateSupporter 310 + - how magazi uses datalogic-rs for flexible rule evaluation 311 + - open questions about upcoming metadata extensions 312 + 313 + no implementation changes - waiting to align with what ATProtoFans will support. 314 + 315 + #### logout modal UX (PRs #755-757, Jan 17-18) 316 + 317 + **tooltip scroll fix** (PR #755): 318 + - leftmost avatar in likers/commenters tooltip was clipped with no way to scroll to it 319 + - changed `justify-content: center` to `flex-start` so most recent (leftmost) is always visible 320 + 321 + **logout modal copy** (PRs #756-757): 322 + - simplified from two confusing questions to one clear question 323 + - before: "stay logged in?" + "you're logging out of @handle?" 324 + - after: "switch accounts?" 325 + - "logout completely" → "log out of all accounts" 326 327 --- 328 329 + #### idempotent teal scrobbles (PR #754, Jan 16) 330 331 + **prevents duplicate scrobbles** when same play is submitted multiple times: 332 + - use `putRecord` with deterministic TID rkeys derived from `playedTime` instead of `createRecord` 333 + - network retries, multiple teal-compatible services, or background task retries won't create duplicates 334 + - adds `played_time` parameter to `build_teal_play_record` for deterministic record keys 335 336 --- 337 338 + #### avatar refresh and tooltip polish (PRs #750-752, Jan 13) 339 340 + **avatar refresh from anywhere** (PR #751): 341 + - previously, stale avatar URLs were only fixed when visiting the artist detail page 342 + - now any broken avatar triggers a background refresh from Bluesky 343 + - shared `avatar-refresh.svelte.ts` provides global cache and request deduplication 344 + - works from: track items, likers tooltip, commenters tooltip, profile page 345 + 346 + **interactive tooltips** (PR #750): 347 + - hovering on like count shows avatar circles of users who liked 348 + - hovering on comment count shows avatar circles of commenters 349 + - lazy-loaded with 5-minute cache, invalidated when likes/comments change 350 + - elegant centered layout with horizontal scroll when needed 351 + 352 + **UX polish** (PR #752): 353 + - added prettier config with `useTabs: true` to match existing style 354 + - reduced avatar hover effect intensity (scale 1.2 → 1.08) 355 + - fixed avatar hover clipping at tooltip edge (added top padding) 356 + - track title now links to detail page (color change on hover) 357 358 --- 359 360 + #### copyright flagging fix (PR #748, Jan 12) 361 + 362 + **switched from score-based to dominant match detection**: 363 + - AudD's enterprise API doesn't return confidence scores (always 0) 364 + - previous threshold-based detection was broken 365 + - new approach: flag if one song appears in >= 30% of matched segments 366 + - filters false positives where random segments match different songs 367 + 368 + --- 369 + 370 + #### Neon cold start fix (Jan 11) 371 + 372 + **why**: first requests after idle periods would fail with 500 errors due to Neon serverless scaling to zero after 5 minutes of inactivity. previous mitigations (larger pool, longer timeouts) helped but didn't eliminate the problem. 373 + 374 + **fix**: disabled scale-to-zero on `plyr-prd` via Neon console. this is the [recommended approach](https://neon.com/blog/6-best-practices-for-running-neon-in-production) for production workloads. 375 + 376 + **configuration**: 377 + - `plyr-prd`: scale-to-zero **disabled** (`suspend_timeout_seconds: -1`) 378 + - `plyr-stg`, `plyr-dev`: scale-to-zero enabled (cold starts acceptable) 379 + 380 + **docs**: updated [connection-pooling.md](docs/backend/database/connection-pooling.md) with production guidance and how to verify settings via Neon MCP. 381 + 382 + closes #733 383 + 384 + --- 385 + 386 + #### early January work (Jan 1-9) 387 388 See `.status_history/2026-01.md` for detailed history including: 389 + - multi-account experience (PRs #707, #710, #712-714, Jan 3-5) 390 + - integration test harness (PR #744, Jan 9) 391 + - track edit UX improvements (PRs #741-742, Jan 9) 392 + - auth stabilization (PRs #734-736, Jan 6-7) 393 + - timestamp deep links (PRs #739-740, Jan 8) 394 + - artist bio links (PRs #700-701, Jan 2) 395 + - copyright moderation improvements (PRs #703-704, Jan 2-3) 396 + - ATProto OAuth permission sets (PRs #697-698, Jan 1-2) 397 + - atprotofans supporters display (PRs #695-696, Jan 1) 398 + - UI polish (PRs #692-694, Dec 31 - Jan 1) 399 400 ### December 2025 401 ··· 440 441 ### current focus 442 443 + ML-powered track features rolling out: genre classification (Replicate effnet-discogs) auto-runs on upload, with optional auto-tagging checkbox on the upload form. mood search (CLAP embeddings + turbopuffer) feature-flagged behind `vibe-search`. ML audit script (`scripts/ml_audit.py`) tracks which tracks/artists have been processed for privacy/ToS compliance. 444 445 ### known issues 446 - iOS PWA audio may hang on first play after backgrounding ··· 494 - ✅ lossless audio (AIFF/FLAC) with automatic transcoding for browser compatibility 495 - ✅ PDS blob storage for audio (user data ownership) 496 - ✅ play count tracking, likes, queue management 497 + - ✅ unified search with Cmd/Ctrl+K 498 - ✅ teal.fm scrobbling 499 - ✅ copyright moderation with ATProto labeler 500 - ✅ ML genre classification with suggested tags in edit modal + auto-tag at upload (Replicate effnet-discogs) 501 - ✅ docket background tasks (copyright scan, export, atproto sync, scrobble, genre classification) 502 - ✅ media export with concurrent downloads 503 - ✅ supporter-gated content via atprotofans ··· 542 - cloudflare (R2 + pages + domain): ~$1/month 543 - audd audio fingerprinting: $5-10/month (usage-based) 544 - replicate (genre classification): <$1/month (scales to zero, ~$0.00019/run) 545 - logfire: $0 (free tier) 546 547 ## admin tooling ··· 590 ├── frontend/ # SvelteKit app 591 │ ├── src/lib/ # components & state 592 │ └── src/routes/ # pages 593 + ├── moderation/ # Rust moderation service (ATProto labeler) 594 + ├── transcoder/ # Rust audio transcoding service 595 + ├── redis/ # self-hosted Redis config 596 ├── docs/ # documentation 597 └── justfile # task runner 598 ``` ··· 608 609 --- 610 611 + this is a living document. last updated 2026-02-08.