audio streaming app plyr.fm

chore: status maintenance - mood search, genre classification, perf optimization (#885)

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>

authored by

claude[bot]
claude[bot]
and committed by
GitHub
42ae7300 1e7c7aec

+327 -317
+278
.status_history/2026-01.md
··· 158 158 - **feed/library toggle** (PR #692): consistent header layout with toggle between feed and library views 159 159 - **shuffle button moved** (PR #693): shuffle now in queue component instead of player controls 160 160 - **justfile consistency** (PR #694): standardized `just run` across frontend/backend modules 161 + 162 + --- 163 + 164 + ## Mid-to-Late January 2026 Work (Jan 11-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
+49 -317
STATUS.md
··· 47 47 48 48 ### February 2026 49 49 50 - #### optimize GET /tracks/top latency (PR #879, Feb 8) 51 - 52 - **baseline (production, 171 requests over 3 days)**: p50=123ms, p95=1204ms, p99=1576ms. the p95/p99 spikes were caused by stale connection reconnects during `pool_pre_ping` and redundant DB round-trips. 53 - 54 - **optimizations**: 55 - - **merged query**: new `get_top_tracks_with_counts()` returns (track_id, like_count) tuples in a single GROUP BY — the count was already computed to sort, now it's returned instead of discarded. eliminates a redundant `get_like_counts` call (1 fewer DB round-trip) 56 - - **scoped liked query**: the authenticated user's liked-track check now filters by `track_id IN (...)` (10 rows) instead of scanning all user likes 57 - - **pool_recycle 7200s → 1800s**: connections recycle every 30min instead of 2h, reducing stale connections that trigger expensive reconnects 58 - 59 - **verified via Logfire traces**: authenticated requests dropped from 11 DB queries to 7. post-deploy requests at 517-600ms vs baseline p95 of 1.2s. 60 - 61 - **14 new regression tests** covering ordering, limit clamping, auth state, like counts, comment counts, and tags. 62 - 63 - --- 64 - 65 - #### auto-tag at upload + ML audit script (PRs #871-872, Feb 7) 66 - 67 - **auto-tag on upload (PR #871)**: checkbox on the upload form ("auto-tag with recommended genres") that automatically applies genre tags after classification completes. user doesn't need to come back to the portal to apply suggested tags. 68 - 69 - - frontend: `autoTag` state + checkbox below TagInput, threaded through `uploader.svelte.ts` into FormData 70 - - backend: `auto_tag` form param stored in `track.extra["auto_tag"]`, consumed by `classify_genres` background task 71 - - after classification, applies top tags using ratio-to-top filter (>= 50% of top score, capped at 5) 72 - - additive with manual tags — user can add their own AND get auto-tags 73 - - flag cleaned up from `track.extra` after use (no schema migration needed) 74 - 75 - **ML audit script (PR #872)**: `scripts/ml_audit.py` reports which tracks and artists have been processed by ML features (genre classification, CLAP embeddings, auto-tagging). for privacy policy and terms-of-service auditing. supports `--verbose` for track-level detail, `--check-embeddings` for turbopuffer vector counts. 76 - 77 - --- 78 - 79 - #### ML genre classification + suggested tags (PRs #864-868, Feb 6-7) 80 - 81 - **genre classification via Replicate**: tracks are now automatically classified into genre labels using the [effnet-discogs](https://replicate.com/mtg/effnet-discogs) model on Replicate (EfficientNet trained on Discogs ~400 categories). 82 - 83 - **how it works**: 84 - - on upload: if `REPLICATE_ENABLED=true`, classification runs as a docket background task 85 - - on demand: `GET /tracks/{id}/recommended-tags` classifies on the fly if no cached predictions 86 - - predictions stored in `track.extra["genre_predictions"]` with `genre_predictions_file_id` for cache invalidation when audio is replaced 87 - - raw Discogs labels (`Electronic---Ambient`) cleaned to `ambient electronic` format 88 - - cost: ~$0.00019/run (~$0.11 per 575 tracks, CPU inference) 89 - 90 - **frontend UX (PR #868)**: when editing a track on the portal, suggested genre tags appear as clickable dashed-border chips below the tag input. wave loading animation while fetching. clicking a chip adds the tag. `$derived` reactively hides suggestions matching manually-typed tags. all failures silently hide the section. 91 - 92 - **implementation details**: 93 - - Replicate Python SDK incompatible with Python 3.14 (pydantic v1) — uses httpx directly against the Replicate HTTP API with `Prefer: wait` header 94 - - `ReplicateSettings` in config, `ReplicateClient` singleton follows `clap_client.py` pattern 95 - - backfill script: `scripts/backfill_genres.py` with concurrency control 96 - - privacy policy updated to list Replicate, terms bumped for re-acceptance 97 - - docs: `docs/backend/genre-classification.md` 98 - 99 - **PRs**: 100 - - #864: core implementation (replicate client, background task, endpoint, backfill script, tests) 101 - - #865: clean Discogs genre names, add documentation 102 - - #866: link genre-classification from docs index 103 - - #867: cache invalidation keyed by file_id 104 - - #868: suggested tags UI in portal edit modal 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 50 + #### portal pagination + perf optimization (PRs #878-879, Feb 8) 238 51 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 52 + **portal pagination (PR #878)**: `GET /tracks/me` now supports `limit`/`offset` pagination (default 10 per page). portal loads first 10 tracks with a "load more" button. export section uses total count for accurate messaging. 244 53 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) 54 + **GET /tracks/top latency fix (PR #879)**: baseline p95 was 1.2s due to stale connection reconnects and redundant DB queries. 55 + - merged top-track-ids + like-counts into single `get_top_tracks_with_counts()` query (1 fewer round-trip) 56 + - scoped liked-track check to `track_id IN (...)` (10 rows) instead of all user likes 57 + - `pool_recycle` 7200s → 1800s to reduce stale connection spikes 58 + - authenticated requests dropped from 11 DB queries to 7. post-deploy p95: ~550ms 59 + - 14 new regression tests 248 60 249 61 --- 250 62 251 - #### handle display fix (PR #774, Jan 22) 63 + #### repo reorganization (PR #876, Feb 8) 252 64 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 65 + moved auxiliary services into `services/` (transcoder, moderation, clap) and infrastructure into `infrastructure/` (redis). updated all GitHub Actions workflows, pre-commit config, justfile module paths, and docs. 258 66 259 67 --- 260 68 261 - #### responsive embed v2 (PRs #771-772, Jan 20-21) 69 + #### auto-tag at upload + ML audit (PRs #870-872, Feb 7) 262 70 263 - **complete rewrite of embed CSS** using container queries and proportional scaling: 71 + **auto-tag on upload (PR #871)**: checkbox on the upload form ("auto-tag with recommended genres") that applies top genre tags after classification completes. ratio-to-top filter (>= 50% of top score, capped at 5), additive with manual tags. flag stored in `track.extra`, cleaned up after use. 264 72 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 73 + **genre/subgenre split (PR #870)**: compound Discogs labels like "Electronic---Ambient" now produce two separate tags ("electronic", "ambient") instead of one compound tag. 272 74 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 75 + **ML audit script (PR #872)**: `scripts/ml_audit.py` reports which tracks/artists have been processed by ML features. supports `--verbose` and `--check-embeddings` for privacy/ToS auditing. 277 76 278 77 --- 279 78 280 - #### terms of service and privacy policy (PRs #567, #761-770, Jan 19-20) 79 + #### ML genre classification + suggested tags (PRs #864-868, Feb 6-7) 281 80 282 - **legal foundation shipped** with ATProto-aware design: 81 + **genre classification via Replicate**: tracks classified into genre labels using [effnet-discogs](https://replicate.com/mtg/effnet-discogs) on Replicate (EfficientNet trained on Discogs ~400 categories). 283 82 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 83 + - on upload: classification runs as docket background task if `REPLICATE_ENABLED=true` 84 + - on demand: `GET /tracks/{id}/recommended-tags` classifies on the fly if no cached predictions 85 + - predictions stored in `track.extra["genre_predictions"]` with file_id-based cache invalidation 86 + - raw Discogs labels cleaned to lowercase format. cost: ~$0.00019/run 87 + - Replicate SDK incompatible with Python 3.14 (pydantic v1) — uses httpx directly with `Prefer: wait` header 300 88 301 - **polish PRs** (#761-770): corrected ATProto vs "our servers" terminology, standardized AT Protocol naming, added email fallbacks, capitalized sentence starts 89 + **frontend UX (PR #868)**: suggested genre tags appear as clickable dashed-border chips in the portal edit modal. `$derived` reactively hides suggestions matching manually-typed tags. 302 90 303 91 --- 304 92 305 - #### content gating research (Jan 18) 93 + #### mood search (PRs #848-858, Feb 5-6) 306 94 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 95 + **search by how music sounds** — type "chill lo-fi beats" into the search bar and find tracks that match the vibe, not just the title. 312 96 313 - no implementation changes - waiting to align with what ATProtoFans will support. 97 + **architecture**: CLAP (Contrastive Language-Audio Pretraining) model hosted on Modal generates audio embeddings at upload time and text embeddings at search time. vectors stored in turbopuffer. keyword and semantic searches fire in parallel — keyword results appear instantly (~50ms), semantic results append when ready (~1-2s). 314 98 315 - #### logout modal UX (PRs #755-757, Jan 17-18) 99 + **key design decisions**: 100 + - unified search: no mode toggle. keyword + semantic results merge by score, client-side deduplication removes overlap 101 + - graceful degradation: backend returns `available: false` instead of 502/503 when CLAP/turbopuffer are down 102 + - quality controls: distance threshold, spread check to filter low-signal results, result cap 103 + - gated behind `vibe-search` feature flag with version-aware terms re-acceptance 316 104 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" 105 + **hardening (PRs #849-858)**: m4a support for CLAP, correct R2 URLs, normalize similarity scores, switch from larger_clap_music to clap-htsat-unfused, handle empty turbopuffer namespace, rename "vibe search" → "mood search", concurrent backfill script. 326 106 327 107 --- 328 108 329 - #### idempotent teal scrobbles (PR #754, Jan 16) 109 + #### recommended tags via audio similarity (PR #859, Feb 6) 330 110 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 111 + `GET /tracks/{track_id}/recommended-tags` finds tracks with similar CLAP embeddings in turbopuffer, aggregates their tags weighted by similarity score. excludes existing tags, normalizes scores to 0-1. replaced by genre classification (PR #864) but the endpoint pattern persisted. 335 112 336 113 --- 337 114 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 115 + #### mobile login UX + misc fixes (PRs #841-845, Feb 2) 351 116 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) 117 + - **handle hint sizing (PRs #843-845)**: iterative fix for login page handle hint wrapping on mobile — final approach: reduced font size, gap, and `nowrap` to keep full text visible 118 + - **PDS backfill gate (PR #842)**: PDS backfill button gated behind `pds-audio-uploads` feature flag 119 + - **share button reuse (PR #841)**: track detail page now uses shared `ShareButton` component 357 120 358 121 --- 359 122 360 - #### copyright flagging fix (PR #748, Jan 12) 123 + ### January 2026 361 124 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) 125 + See `.status_history/2026-01.md` for detailed history. 399 126 400 127 ### December 2025 401 128 ··· 440 167 441 168 ### current focus 442 169 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. 170 + ML-powered track features: genre classification (Replicate effnet-discogs) auto-runs on upload with optional auto-tagging. mood search (CLAP + turbopuffer) feature-flagged behind `vibe-search`, runs parallel to keyword search. performance optimization on hot paths (GET /tracks/top p95 cut from 1.2s to ~550ms). repo reorganized — services and infrastructure in dedicated directories. 444 171 445 172 ### known issues 446 173 - iOS PWA audio may hang on first play after backgrounding ··· 494 221 - ✅ lossless audio (AIFF/FLAC) with automatic transcoding for browser compatibility 495 222 - ✅ PDS blob storage for audio (user data ownership) 496 223 - ✅ play count tracking, likes, queue management 497 - - ✅ unified search with Cmd/Ctrl+K 224 + - ✅ unified search with Cmd/Ctrl+K (keyword + mood search in parallel) 225 + - ✅ mood search via CLAP embeddings + turbopuffer (feature-flagged) 498 226 - ✅ teal.fm scrobbling 499 227 - ✅ copyright moderation with ATProto labeler 500 228 - ✅ ML genre classification with suggested tags in edit modal + auto-tag at upload (Replicate effnet-discogs) ··· 590 318 ├── frontend/ # SvelteKit app 591 319 │ ├── src/lib/ # components & state 592 320 │ └── src/routes/ # pages 593 - ├── moderation/ # Rust moderation service (ATProto labeler) 594 - ├── transcoder/ # Rust audio transcoding service 595 - ├── redis/ # self-hosted Redis config 321 + ├── services/ 322 + │ ├── transcoder/ # Rust audio transcoding (Fly.io) 323 + │ ├── moderation/ # Rust content moderation (Fly.io) 324 + │ └── clap/ # ML embeddings (Python, Modal) 325 + ├── infrastructure/ 326 + │ └── redis/ # self-hosted Redis (Fly.io) 596 327 ├── docs/ # documentation 597 328 └── justfile # task runner 598 329 ``` ··· 609 340 --- 610 341 611 342 this is a living document. last updated 2026-02-08. 343 +
update.wav

This is a binary file and will not be displayed.