audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

chore: status maintenance — tag filtering, AT-URI resolution, now-playing incident (#1232)

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

authored by

claude[bot]
claude[bot]
and committed by
GitHub
41ba38e4 c0212670

+263 -261
+262 -2
.status_history/2026-03.md
··· 1 - # plyr.fm Status History - March 2026 (early) 1 + # plyr.fm Status History - March 2026 2 2 3 - Archived from STATUS.md — covers Mar 1-12, 2026. 3 + Archived from STATUS.md — covers Mar 1-29, 2026. 4 4 5 5 --- 6 6 ··· 106 106 **share button**: inline link icon next to the logo that copies the plyr.fm page URL (not the embed URL) to clipboard with "copied!" tooltip feedback. falls back to `navigator.share()` when clipboard API is unavailable. no auth dependency. hidden in MICRO mode, white-themed in blurred-bg modes (NARROW, SQUARE/TALL). 107 107 108 108 **embed layout fixes** (PRs #996-997): fixed track embed clipping at short heights, guarded collection WIDE query, and fixed track embed layout for tall/portrait containers. 109 + 110 + --- 111 + 112 + ## March 14-29 (archived Apr 3, 2026) 113 + 114 + #### CORS open access + Jetstream fixes (PRs #1106-1107, Mar 14) 115 + 116 + **why**: CORS was restricted to `*.plyr.fm` subdomains, blocking third-party ATProto clients and embeds from calling the API. separately, the Jetstream consumer was crash-looping due to OpenTelemetry span errors, and PDS uploads failed silently on transient network errors. 117 + 118 + **what shipped**: 119 + - CORS now allows any HTTPS origin to call the API 120 + - fixed Jetstream crash loop caused by OTEL span context errors 121 + - added retry logic for PDS upload network failures 122 + 123 + --- 124 + 125 + #### AT-URI lookup endpoints (PR #1123, Mar 15) 126 + 127 + **why**: external ATProto clients need to resolve `at://` URIs to plyr.fm page URLs. without this, a client that discovers a `fm.plyr.track` record on the network has no way to link to it on plyr.fm. 128 + 129 + **what shipped**: 130 + - `GET /tracks/by-uri?uri=at://...` and `GET /playlists/by-uri?uri=at://...` endpoints 131 + - returns the track/playlist object if found, 404 otherwise 132 + 133 + --- 134 + 135 + #### docs rewrite + UX polish (PRs #1108-1120, Mar 14-16) 136 + 137 + **what shipped**: 138 + - rewrote listeners and creators docs pages — lead with experience, not protocol jargon 139 + - reordered upload form: required fields first 140 + - PDS tooltip on upload form explaining what "store on your PDS" means 141 + - fixed liker profile links interrupting playback (#1121) 142 + - PDS tooltip hover uses delayed hide to prevent flicker (#1110) 143 + 144 + --- 145 + 146 + #### ambient weather theme — "live" (PRs #1127-1136, Mar 16) 147 + 148 + **why**: plyr.fm had dark/light/system themes but no personality. the ambient "live" theme adds a location-aware atmospheric background that reflects real weather conditions — making the app feel connected to the listener's environment. 149 + 150 + **what shipped**: 151 + - new "live" theme option alongside dark/light/auto in both desktop and mobile settings 152 + - fetches weather from Open-Meteo API using device geolocation, renders gradient background based on conditions (clear/cloudy/rain/snow/fog/storm × day/night × temperature warmth) 153 + - full UI tinting: 12 CSS variables (glass surfaces, borders, track cards, backgrounds) are blended with weather-derived tint colors at 6-15% strength 154 + - live is a first-class peer theme, not a toggle layer — server-persisted per account via new `theme` column on `user_preferences` (alembic migration) 155 + - localStorage is a flash-prevention cache synced from server, not source of truth 156 + 157 + **technical notes**: 158 + - initial implementation (#1127-1132) built live as a separate toggle, which caused bugs on account switch (light base + ambient gradient = broken UI). redesigned in #1134 to make live a peer of dark/light/system 159 + - theme was never server-persisted before this work — it was purely localStorage. added the DB column and wired preferences.fetch() to sync server → localStorage → DOM 160 + - accent color had the same sync gap (#1136): server stored it, but preferences.fetch() never applied it to the DOM or localStorage. on fresh loads, `--accent` fell back to CSS default blue regardless of saved preference 161 + - geolocation is cached device-global (`ambient_location` in localStorage) — survives theme switches for instant re-activation. old DID-scoped keys are auto-migrated 162 + - live resolves to dark base theme (CSS class `theme-dark`), with gradient overlay and tinted variables on top 163 + 164 + --- 165 + 166 + #### RSS feed removal (PR #1139, Mar 17) 167 + 168 + **why**: per-artist RSS feeds (shipped in PR #1045, Mar 6) were an over-eager introduction — added speculatively without clear demand. 19 total hits in 30 days confirmed it. the `feedgen` + `lxml` dependencies weren't worth carrying for something nobody asked for. 169 + 170 + **what shipped**: 171 + - removed `feeds.py` module, tests, `feedgen`/`lxml` dependencies, and `<link rel="alternate">` tags from artist/album/playlist pages 172 + 173 + --- 174 + 175 + #### handle typeahead migration (PRs #1140-1141, Mar 17) 176 + 177 + **why**: switching handle autocomplete to [`typeahead.waow.tech`](https://typeahead.waow.tech) — a community ATProto actor search service (Zig ingester on Fly.io → Cloudflare Worker + D1/FTS5) — revealed that the backend `/search/handles` endpoint was just a passthrough proxy. no auth, caching, or transformation — just an extra hop adding ~500ms of latency. 178 + 179 + **what shipped**: 180 + - switched from Bluesky's `searchActorsTypeahead` to the community typeahead service (#1140) 181 + - moved the call entirely to the frontend, eliminating the unnecessary backend proxy (#1141) 182 + - backend `/search/handles` endpoint removed from the router (underlying code stays for SDK consumers) 183 + - configurable via `PUBLIC_TYPEAHEAD_URL` env var, degrades to empty results on failure 184 + 185 + --- 186 + 187 + #### collapsible track descriptions (PRs #1144-1146, Mar 18; superseded by #1205) 188 + 189 + long track descriptions were pushing the track page layout down with no way to collapse them. added a collapsible wrapper that truncates descriptions taller than ~5 lines (128px) with a fade-out gradient. the "show more" toggle started as a bare left-aligned text link, then was restyled as a centered pill button with chevron indicators (▾/▴) to match the existing `.pill-btn` pattern used in Queue and tag badges. **replaced in #1205** with the metadata disclosure icon approach. 190 + 191 + --- 192 + 193 + #### frontend validation alignment (PR #1148, Mar 18) 194 + 195 + audited every backend validation limit (lexicon schemas, pydantic models, manual checks) against frontend form enforcement. found 8 fields with backend limits but no frontend `maxlength`, one upload path missing a client-side file size check, and two limit mismatches between the lexicon and API layer. 196 + 197 + **what shipped**: 198 + - raised API comment text limit from 300 to 1000 to match the `fm.plyr.comment` lexicon (network-wide scan confirmed no existing records exceed 254 chars) 199 + - raised `HandleSearch` default `maxFeatures` from 5 to 10 to match the `fm.plyr.track` lexicon (max in the wild: 2) 200 + - added `maxlength` on track title (256), album name (256), bio (2560), playlist name (256), tag input (50 per tag), search inputs (100) 201 + - added character counters on description, bio, and comment fields (follows existing `FeedbackModal` pattern) 202 + - capped `TagInput` at 10 tags with a "maximum 10 tags" message 203 + - added 20MB image size check on portal track artwork upload (was the only upload path without client-side validation) 204 + 205 + --- 206 + 207 + #### homepage activity integration — failed experiment (PRs #1151-1156, Mar 19) 208 + 209 + **goal**: inject life into the homepage by surfacing recent platform activity (likes, uploads, comments, joins). 210 + 211 + **attempt 1 — floating pills** (#1151): glassmorphic pill-shaped elements drifting across the page background with CSS keyframe animations. identical shapes for all event types, slow 25-45s drift cycles, visible for ~75% of each cycle. deployed to staging: looked like dopey submarines hanging around the background. not ephemeral, not legible, not visually distinct between event types. **3/10.** 212 + 213 + **attempt 2 — restyled pills** (#1152): distinct shapes per event type (circles, rectangles, speech bubbles), faster 14-24s cycles visible only ~25% of the time, type-colored backgrounds with glow. better than v1 but still fundamentally the same problem — random floating elements in the background look corny regardless of styling. **5/10.** 214 + 215 + **attempt 3 — text echoes** (#1153): researched internet.dev's text-first design philosophy. stripped all card/pill chrome, rendered bare monospace text with type-colored glow, added sonar ring ping animations, JS-driven slot cycling with staggered timing. more refined than the pills but the core concept of randomly positioned background elements was still bad. still corny. 216 + 217 + **attempt 4 — activity shelf** (#1155): abandoned the background approach entirely. built a horizontal scroll section (like top tracks / artists you know) with compact `ActivityCard` components, plus dismissible sections with localStorage persistence. closer to the right idea structurally, but the cards weren't good enough and the overall execution didn't meet the bar. 218 + 219 + **outcome**: all four attempts reverted (#1154, #1156). homepage is back to its pre-experiment state. the `ActivityCard`, `FloatingActivity`, and `dismissed-sections` modules were all deleted. 220 + 221 + **lessons**: 222 + - floating/background elements for activity feeds are a fundamentally bad idea — they look gimmicky regardless of visual polish 223 + - research into design systems (internet.dev, Spotify shelves, etc.) was useful but didn't compensate for weak visual execution 224 + - should have started with the simplest inline approach (attempt 4) instead of the most ambitious one (attempt 1) 225 + - the activity data is available via `/activity/` API and the dedicated `/activity` page — homepage integration remains an open question 226 + 227 + --- 228 + 229 + #### ambient theme polish (PRs #1158-1161, Mar 19) 230 + 231 + temperature unit detection for US users (Fahrenheit vs Celsius from browser locale), gradient banding reduction via interpolated color stops (7 stops instead of 3), and cached weather data for instant theme restore on page load. 232 + 233 + --- 234 + 235 + #### chromaprint.zig — standalone audio fingerprinting in pure zig (Mar 19) 236 + 237 + **why**: plyr.fm pays AuDD ~$5/1000 requests for copyright moderation fingerprinting. AcoustID is a free, open-source alternative that uses Chromaprint fingerprints, but the official library requires FFmpeg. we wanted a zero-dependency fingerprinting tool. 238 + 239 + **what shipped**: a standalone zig 0.16 library at [`@zzstoatzz.io/chromaprint.zig`](https://tangled.sh/@zzstoatzz.io/chromaprint.zig) that generates Chromaprint audio fingerprints and looks them up on AcoustID — no FFmpeg, no C dependencies, just a static binary. 240 + 241 + - ported the core algorithm from Andrew Kelley's [groovebasin](https://codeberg.org/andrewrk/groovebasin) implementation (~578 lines) 242 + - wrote a pure zig radix-2 Cooley-Tukey FFT (4096-point, comptime twiddle factors) to replace FFmpeg's `av.TXContext` 243 + - WAV reader for PCM16/Float32 mono 11025 Hz (replaces FFmpeg's decode pipeline) 244 + - AcoustID HTTP client with gzip-compressed POST (`std.compress.flate`) 245 + - MusicBrainz enrichment (recording ID → title/artist) 246 + - **fingerprints are an exact match against `fpcalc`** (the reference C implementation) — verified on real plyr.fm tracks including a rickroll flagged by the moderation system 247 + 248 + **technical notes**: 249 + - zig 0.16 has massive breaking changes from 0.15 (new `Io` context threading, `std.process.Init` for main, `client.fetch()` replacing `client.open()`). notes captured in memory for upcoming SDK migrations (zat, etc.) 250 + - AcoustID returns HTTP 400 (not 200) for API errors — discovered by testing with an expired test key. the JSON body must be parsed regardless of HTTP status 251 + - AcoustID's docs recommend compressed POST because fingerprints are large (~5KB base64). switched from query-string GET to gzip POST 252 + - the pure zig approach means the moderation service can embed fingerprinting directly instead of calling an external API 253 + 254 + **what happened next**: PR #1163 integrated AcoustID into the moderation service, but AcoustID does whole-file matching — it can't detect copyrighted segments within longer uploads (DJ sets, sample-heavy tracks). reverted to AuDD in #1174 (see above for full explanation). the chromaprint.zig library remains a standalone tool and learning exercise — the fingerprinting itself works perfectly, it's AcoustID's whole-file matching model that doesn't fit plyr.fm's moderation needs. 255 + 256 + --- 257 + 258 + #### costs export tied to release tags + legal date guard (PRs #1166-1168, Mar 20) 259 + 260 + **why**: merging PR #1163 (AuDD → AcoustID) changed the costs JSON shape (`audd` → `copyright_scanning`), which broke the production `/costs` page. the hourly `export_costs.py` runs from `main`, but the production frontend deploys on a separate release cadence — so the script's output shape changed before the frontend was ready for it. separately, the privacy policy content was updated (#1164) but the backend `terms_last_updated` config wasn't bumped, so users weren't prompted to re-accept. 261 + 262 + **what shipped**: 263 + - costs export workflow now checks out the **latest release tag** instead of `main`, so the script shape always matches the deployed frontend. data stays fresh (hourly from prod DB), but the shape only changes when a release is cut 264 + - CI-only guard on `export_costs.py` — refuses non-dry-run outside GitHub Actions 265 + - pre-commit hook (`check-legal-dates`) that catches two things: stale privacy policy "Last updated" dates when content changes, and `LegalSettings.terms_last_updated` falling behind the privacy policy date 266 + - removed dead `R2_BUCKET` / `R2_PUBLIC_BUCKET_URL` env vars from the workflow (script uses `R2_STATS_BUCKET` with defaults) 267 + - bumped `terms_last_updated` from `2026-02-06` to `2026-03-20` 268 + - filed #1165 for moderation staging environment (same class of problem — deploys directly to prod) 269 + 270 + --- 271 + 272 + #### AcoustID → AuDD revert (PR #1174, Mar 22) 273 + 274 + **the arc**: PR #1163 (Mar 20) replaced AuDD (~$5/1000 requests) with fpcalc + AcoustID (free) for copyright scanning. the chromaprint.zig work (see below) had proven fingerprint generation worked, and AcoustID's API was straightforward. the migration shipped with costs page updates, privacy policy changes, and a flurry of follow-up fixes (#1164, #1166-1171) for the costs export shape mismatch and legal date sync issues. 275 + 276 + **why it was reverted**: AcoustID does whole-file fingerprint matching — it generates one fingerprint for the entire upload and looks for that fingerprint in its database. this works for identifying exact songs, but plyr.fm needs segment-level detection: a 60-minute DJ set with 1 minute of copyrighted material won't match, because the whole-file fingerprint looks nothing like the original 3-minute song's fingerprint. same problem with sample-heavy tracks or remixes that contain fragments of copyrighted material surrounded by original content. AuDD does segment-level analysis — it scans within the audio to find partial matches, which is exactly what copyright moderation requires. the $5-10/month cost is worth it for the detection accuracy. 277 + 278 + **what was kept from the AcoustID arc**: 279 + - `check_legal_dates` pre-commit hook (#1168) — catches stale privacy policy dates and terms version mismatches 280 + - costs export CI-only guard and release tag checkout (#1166) — script shape always matches deployed frontend 281 + - `check-legal-dates` narrowing to `+page.svelte` only (#1171) 282 + 283 + **what was restored**: full AuDD integration in moderation service (9 files), AuDD billing logic in costs export/dashboard, privacy policy references, terms versioning date. 284 + 285 + **deploy incident**: the moderation service was down for ~1 hour during the revert (22:25–23:24 UTC, Mar 22). the new binary required `MODERATION_AUDD_API_TOKEN` but the secret hadn't been set yet — service crashed on startup in a loop. once the token was set the service recovered, but the first AuDD call returned `error_code: 19` ("Recognition failed: Internal error") from AuDD's side. subsequent scans succeeded — two copyright scans processed successfully (01:09 and 02:55 UTC, Mar 23), and `sync_copyright_resolutions` has been running clean every 5 minutes since. filed #1165 remains relevant — a staging environment for the moderation service would have caught this. 286 + 287 + --- 288 + 289 + #### DID-based profile URLs (PR #1173, Mar 22) 290 + 291 + **why**: community request from `@blooym.dev` — ATProto DIDs are immutable, so `plyr.fm/u/did:plc:xxxx` links survive handle changes, giving artists stable permalinks. 292 + 293 + **what shipped**: 294 + - `/u/did:plc:xxxx` resolves to artist profile (DID → handle lookup for backend calls) 295 + - sub-pages (liked, album) work with DID URLs 296 + - error page shows appropriate messaging for DID 404s 297 + 298 + --- 299 + 300 + #### logfire observability fix (PR #1130, Mar 22) 301 + 302 + **why**: spans in Logfire showed `service_name: unknown_service` and had no user identity attached, making it impossible to trace who was making requests or filter by service. 303 + 304 + **what shipped**: 305 + - set `service_name="plyr-api"` in `logfire.configure()` 306 + - tag HTTP spans with `user.did` and `user.handle` via `_tag_span_with_user()` in both `require_auth` and `get_optional_session` auth dependencies 307 + - rewrote logfire querying guide with `deployment_environment` filtering and top-level column usage 308 + 309 + **note**: this PR was opened Mar 16 but sat unmerged until Mar 22 when we noticed null user identity on a terms acceptance span. the fix was already correct — just never merged. 310 + 311 + --- 312 + 313 + #### artwork documentation + R2 image fix (PRs #1176-1178, Mar 22-23) 314 + 315 + **what happened**: user `jdhitsolutions.com` reported "I just don't see all of the image that I expect" on their track artwork. investigation via Logfire spans revealed two separate issues: 316 + 317 + 1. **R2 image self-deletion bug** (#1176): when editing a track and re-submitting the same image file, the content hash produces the same `image_id`. the old cleanup logic unconditionally deleted the "previous" image — which was the file just uploaded. confirmed via traces: Jeff hit this 3 times on track 833 (Mar 20-21). the image only survived the third attempt because the previous deletes had already removed the old file, so cleanup found nothing to delete. fix: one-line guard (`if old_image_id and old_image_id != new_image_id`) in tracks, albums, and playlists. 318 + 319 + 2. **no artwork guidance**: all display contexts use `object-fit: cover` on square containers, center-cropping non-square images. no documentation existed anywhere telling creators about this. 320 + 321 + **what shipped**: 322 + - R2 deletion guard + regression test in tracks, albums, and playlists (#1176) 323 + - theme-aware description fade (CSS `mask-image` instead of `linear-gradient` pseudo-element) 324 + - internal docs: `docs-internal/backend/image-formats.md` — full pipeline reference (#1177) 325 + - public docs: artwork guidelines in `docs/artists.md`, image parameter docs in API reference (#1178) 326 + 327 + --- 328 + 329 + #### collection activity feed + RichText + observability fixes (PRs #1172, 1183-1185, Mar 23-24) 330 + 331 + **RichText unification** (#1183): track descriptions and comments now auto-link URLs using the shared `RichText` component (already used on artist bios). removed 39 lines of inline URL parsing code from the track page. comments gained markdown link support (`[text](url)`) and better domain/path detection. 332 + 333 + **collection activity feed** (#1172): new `collection_events` table (append-only event log) tracking playlist creation, album releases, and tracks added to playlists. the `/activity` page now surfaces these alongside existing events (uploads, likes, comments, joins) with type-colored accents and histogram inclusion. events cascade on delete (SET NULL → omitted from feed). 334 + 335 + **CLAP text embedding timeout** (#1184): the CLAP client used a single 120s timeout for both audio (background task) and text (user-facing search) embedding requests. when Modal cold starts, semantic search hung for 14+ seconds waiting for a 408 from Modal. split into separate timeouts: 120s for audio embedding (background tasks can wait) and 5s for text embedding (user-facing search fails fast). configurable via `MODAL_TEXT_TIMEOUT_SECONDS`. 336 + 337 + **integration test notification leak** (#1185): integration tests on staging were sending DM notifications about test tracks. root cause: Jetstream processes events with a lag. tests create a track, verify it, then delete it — all before Jetstream catches up. when Jetstream finally processes the create event, the DB row is gone, so it creates the track from scratch with `notification_sent=False` and fires the DM. the tombstone system (designed to prevent ghost tracks) couldn't help because Jetstream processes events in order — the create comes before the delete. fix: suppress notifications and copyright scans from both Jetstream ingest paths on staging. defense-in-depth: the hooks layer also marks `notification_sent=True` when skipping, preventing the finalize-pending path from double-firing. 338 + 339 + --- 340 + 341 + #### graceful OAuth error handling (PR #1198, Mar 29) 342 + 343 + **why**: users with older self-hosted PDS instances that don't support `include:` permission sets got raw JSON (`{"detail":"OAuth callback failed: Scope mismatch: ..."}`) instead of a human-readable error when signing in. 344 + 345 + **what shipped**: 346 + - OAuth callback errors now redirect to the homepage with `?auth_error=<code>` instead of returning JSON 347 + - frontend shows a toast with the error — scope mismatch explains the PDS didn't grant required permissions and may not support permission sets yet (8s duration), expired/failed get standard 5s toasts 348 + - URL is cleaned after displaying the toast 349 + 350 + --- 351 + 352 + #### FLAC uploads graduated + lossless badge redesign (PRs #1189-1190, #1193, Mar 25-27) 353 + 354 + **why**: FLAC was behind a feature flag requiring transcoding, but every modern browser has supported native FLAC playback since 2017 (Chrome 56+, Firefox 51+, Safari 11+). the feature flag was outdated. 355 + 356 + **what shipped**: 357 + - FLAC uploads now GA — stored directly without transcoding, available to all users 358 + - AIFF still transcodes to MP3 (browsers can't play AIFF natively) 359 + - lossless badge evolved through three iterations: diamond icon with "lossless" text and pulsing glow (#1189) → icon-only with tooltip (#1190) → removed floating badge entirely, replaced with subtle accent-tinted card border and inline "lossless" text in the metadata row (#1193) 360 + 361 + --- 362 + 363 + #### activity page fixes (PRs #1194-1197, Mar 27-28) 364 + 365 + **what shipped**: 366 + - **ambient theme race condition** (#1197): `auth.initialize()` used a boolean flag that conflated "started" with "completed". the activity page's `onMount` (child mounts before parent in Svelte) called it without `await`, setting the flag before the fetch finished. the layout's `onMount` then returned immediately — `isAuthenticated` was still `false`, so `preferences.initialize()` and `ambient.activate()` never ran. fixed by replacing the boolean with a stored Promise so all concurrent callers wait for the same in-flight auth check 367 + - **lava-bg z-index** (#1194-1195): lava blobs were at the same z-index as the themed background, occluding the ambient gradient. moved to `z-index: 0` so blobs sit in front of the background but behind page content 368 + - **tags overflow** (#1196-1197): tag badges overflowed card boundaries on mobile. added `max-width: 100%` constraint and `text-overflow: ellipsis` on individual badges, with `flex-shrink: 0` on the "+N" counter so it stays visible
+1 -259
STATUS.md
··· 162 162 163 163 ### March 2026 164 164 165 - #### graceful OAuth error handling (PR #1198, Mar 29) 166 - 167 - **why**: users with older self-hosted PDS instances that don't support `include:` permission sets got raw JSON (`{"detail":"OAuth callback failed: Scope mismatch: ..."}`) instead of a human-readable error when signing in. 168 - 169 - **what shipped**: 170 - - OAuth callback errors now redirect to the homepage with `?auth_error=<code>` instead of returning JSON 171 - - frontend shows a toast with the error — scope mismatch explains the PDS didn't grant required permissions and may not support permission sets yet (8s duration), expired/failed get standard 5s toasts 172 - - URL is cleaned after displaying the toast 173 - 174 - --- 175 - 176 - #### FLAC uploads graduated + lossless badge redesign (PRs #1189-1190, #1193, Mar 25-27) 177 - 178 - **why**: FLAC was behind a feature flag requiring transcoding, but every modern browser has supported native FLAC playback since 2017 (Chrome 56+, Firefox 51+, Safari 11+). the feature flag was outdated. 179 - 180 - **what shipped**: 181 - - FLAC uploads now GA — stored directly without transcoding, available to all users 182 - - AIFF still transcodes to MP3 (browsers can't play AIFF natively) 183 - - lossless badge evolved through three iterations: diamond icon with "lossless" text and pulsing glow (#1189) → icon-only with tooltip (#1190) → removed floating badge entirely, replaced with subtle accent-tinted card border and inline "lossless" text in the metadata row (#1193) 184 - 185 - --- 186 - 187 - #### activity page fixes (PRs #1194-1197, Mar 27-28) 188 - 189 - **what shipped**: 190 - - **ambient theme race condition** (#1197): `auth.initialize()` used a boolean flag that conflated "started" with "completed". the activity page's `onMount` (child mounts before parent in Svelte) called it without `await`, setting the flag before the fetch finished. the layout's `onMount` then returned immediately — `isAuthenticated` was still `false`, so `preferences.initialize()` and `ambient.activate()` never ran. fixed by replacing the boolean with a stored Promise so all concurrent callers wait for the same in-flight auth check 191 - - **lava-bg z-index** (#1194-1195): lava blobs were at the same z-index as the themed background, occluding the ambient gradient. moved to `z-index: 0` so blobs sit in front of the background but behind page content 192 - - **tags overflow** (#1196-1197): tag badges overflowed card boundaries on mobile. added `max-width: 100%` constraint and `text-overflow: ellipsis` on individual badges, with `flex-shrink: 0` on the "+N" counter so it stays visible 193 - 194 - --- 195 - 196 - #### collection activity feed + RichText + observability fixes (PRs #1172, 1183-1185, Mar 23-24) 197 - 198 - **RichText unification** (#1183): track descriptions and comments now auto-link URLs using the shared `RichText` component (already used on artist bios). removed 39 lines of inline URL parsing code from the track page. comments gained markdown link support (`[text](url)`) and better domain/path detection. 199 - 200 - **collection activity feed** (#1172): new `collection_events` table (append-only event log) tracking playlist creation, album releases, and tracks added to playlists. the `/activity` page now surfaces these alongside existing events (uploads, likes, comments, joins) with type-colored accents and histogram inclusion. events cascade on delete (SET NULL → omitted from feed). 201 - 202 - **CLAP text embedding timeout** (#1184): the CLAP client used a single 120s timeout for both audio (background task) and text (user-facing search) embedding requests. when Modal cold starts, semantic search hung for 14+ seconds waiting for a 408 from Modal. split into separate timeouts: 120s for audio embedding (background tasks can wait) and 5s for text embedding (user-facing search fails fast). configurable via `MODAL_TEXT_TIMEOUT_SECONDS`. 203 - 204 - **integration test notification leak** (#1185): integration tests on staging were sending DM notifications about test tracks. root cause: Jetstream processes events with a lag. tests create a track, verify it, then delete it — all before Jetstream catches up. when Jetstream finally processes the create event, the DB row is gone, so it creates the track from scratch with `notification_sent=False` and fires the DM. the tombstone system (designed to prevent ghost tracks) couldn't help because Jetstream processes events in order — the create comes before the delete. fix: suppress notifications and copyright scans from both Jetstream ingest paths on staging. defense-in-depth: the hooks layer also marks `notification_sent=True` when skipping, preventing the finalize-pending path from double-firing. 205 - 206 - --- 207 - 208 - #### artwork documentation + R2 image fix (PRs #1176-1178, Mar 22-23) 209 - 210 - **what happened**: user `jdhitsolutions.com` reported "I just don't see all of the image that I expect" on their track artwork. investigation via Logfire spans revealed two separate issues: 211 - 212 - 1. **R2 image self-deletion bug** (#1176): when editing a track and re-submitting the same image file, the content hash produces the same `image_id`. the old cleanup logic unconditionally deleted the "previous" image — which was the file just uploaded. confirmed via traces: Jeff hit this 3 times on track 833 (Mar 20-21). the image only survived the third attempt because the previous deletes had already removed the old file, so cleanup found nothing to delete. fix: one-line guard (`if old_image_id and old_image_id != new_image_id`) in tracks, albums, and playlists. 213 - 214 - 2. **no artwork guidance**: all display contexts use `object-fit: cover` on square containers, center-cropping non-square images. no documentation existed anywhere telling creators about this. 215 - 216 - **what shipped**: 217 - - R2 deletion guard + regression test in tracks, albums, and playlists (#1176) 218 - - theme-aware description fade (CSS `mask-image` instead of `linear-gradient` pseudo-element) 219 - - internal docs: `docs-internal/backend/image-formats.md` — full pipeline reference (#1177) 220 - - public docs: artwork guidelines in `docs/artists.md`, image parameter docs in API reference (#1178) 221 - 222 - --- 223 - 224 - #### logfire observability fix (PR #1130, Mar 22) 225 - 226 - **why**: spans in Logfire showed `service_name: unknown_service` and had no user identity attached, making it impossible to trace who was making requests or filter by service. 227 - 228 - **what shipped**: 229 - - set `service_name="plyr-api"` in `logfire.configure()` 230 - - tag HTTP spans with `user.did` and `user.handle` via `_tag_span_with_user()` in both `require_auth` and `get_optional_session` auth dependencies 231 - - rewrote logfire querying guide with `deployment_environment` filtering and top-level column usage 232 - 233 - **note**: this PR was opened Mar 16 but sat unmerged until Mar 22 when we noticed null user identity on a terms acceptance span. the fix was already correct — just never merged. 234 - 235 - --- 236 - 237 - #### DID-based profile URLs (PR #1173, Mar 22) 238 - 239 - **why**: community request from `@blooym.dev` — ATProto DIDs are immutable, so `plyr.fm/u/did:plc:xxxx` links survive handle changes, giving artists stable permalinks. 240 - 241 - **what shipped**: 242 - - `/u/did:plc:xxxx` resolves to artist profile (DID → handle lookup for backend calls) 243 - - sub-pages (liked, album) work with DID URLs 244 - - error page shows appropriate messaging for DID 404s 245 - 246 - --- 247 - 248 - #### AcoustID → AuDD revert (PR #1174, Mar 22) 249 - 250 - **the arc**: PR #1163 (Mar 20) replaced AuDD (~$5/1000 requests) with fpcalc + AcoustID (free) for copyright scanning. the chromaprint.zig work (see below) had proven fingerprint generation worked, and AcoustID's API was straightforward. the migration shipped with costs page updates, privacy policy changes, and a flurry of follow-up fixes (#1164, #1166-1171) for the costs export shape mismatch and legal date sync issues. 251 - 252 - **why it was reverted**: AcoustID does whole-file fingerprint matching — it generates one fingerprint for the entire upload and looks for that fingerprint in its database. this works for identifying exact songs, but plyr.fm needs segment-level detection: a 60-minute DJ set with 1 minute of copyrighted material won't match, because the whole-file fingerprint looks nothing like the original 3-minute song's fingerprint. same problem with sample-heavy tracks or remixes that contain fragments of copyrighted material surrounded by original content. AuDD does segment-level analysis — it scans within the audio to find partial matches, which is exactly what copyright moderation requires. the $5-10/month cost is worth it for the detection accuracy. 253 - 254 - **what was kept from the AcoustID arc**: 255 - - `check_legal_dates` pre-commit hook (#1168) — catches stale privacy policy dates and terms version mismatches 256 - - costs export CI-only guard and release tag checkout (#1166) — script shape always matches deployed frontend 257 - - `check-legal-dates` narrowing to `+page.svelte` only (#1171) 258 - 259 - **what was restored**: full AuDD integration in moderation service (9 files), AuDD billing logic in costs export/dashboard, privacy policy references, terms versioning date. 260 - 261 - **deploy incident**: the moderation service was down for ~1 hour during the revert (22:25–23:24 UTC, Mar 22). the new binary required `MODERATION_AUDD_API_TOKEN` but the secret hadn't been set yet — service crashed on startup in a loop. once the token was set the service recovered, but the first AuDD call returned `error_code: 19` ("Recognition failed: Internal error") from AuDD's side. subsequent scans succeeded — two copyright scans processed successfully (01:09 and 02:55 UTC, Mar 23), and `sync_copyright_resolutions` has been running clean every 5 minutes since. filed #1165 remains relevant — a staging environment for the moderation service would have caught this. 262 - 263 - --- 264 - 265 - #### costs export tied to release tags + legal date guard (PRs #1166-1168, Mar 20) 266 - 267 - **why**: merging PR #1163 (AuDD → AcoustID) changed the costs JSON shape (`audd` → `copyright_scanning`), which broke the production `/costs` page. the hourly `export_costs.py` runs from `main`, but the production frontend deploys on a separate release cadence — so the script's output shape changed before the frontend was ready for it. separately, the privacy policy content was updated (#1164) but the backend `terms_last_updated` config wasn't bumped, so users weren't prompted to re-accept. 268 - 269 - **what shipped**: 270 - - costs export workflow now checks out the **latest release tag** instead of `main`, so the script shape always matches the deployed frontend. data stays fresh (hourly from prod DB), but the shape only changes when a release is cut 271 - - CI-only guard on `export_costs.py` — refuses non-dry-run outside GitHub Actions 272 - - pre-commit hook (`check-legal-dates`) that catches two things: stale privacy policy "Last updated" dates when content changes, and `LegalSettings.terms_last_updated` falling behind the privacy policy date 273 - - removed dead `R2_BUCKET` / `R2_PUBLIC_BUCKET_URL` env vars from the workflow (script uses `R2_STATS_BUCKET` with defaults) 274 - - bumped `terms_last_updated` from `2026-02-06` to `2026-03-20` 275 - - filed #1165 for moderation staging environment (same class of problem — deploys directly to prod) 276 - 277 - --- 278 - 279 - #### chromaprint.zig — standalone audio fingerprinting in pure zig (Mar 19) 280 - 281 - **why**: plyr.fm pays AuDD ~$5/1000 requests for copyright moderation fingerprinting. AcoustID is a free, open-source alternative that uses Chromaprint fingerprints, but the official library requires FFmpeg. we wanted a zero-dependency fingerprinting tool. 282 - 283 - **what shipped**: a standalone zig 0.16 library at [`@zzstoatzz.io/chromaprint.zig`](https://tangled.sh/@zzstoatzz.io/chromaprint.zig) that generates Chromaprint audio fingerprints and looks them up on AcoustID — no FFmpeg, no C dependencies, just a static binary. 284 - 285 - - ported the core algorithm from Andrew Kelley's [groovebasin](https://codeberg.org/andrewrk/groovebasin) implementation (~578 lines) 286 - - wrote a pure zig radix-2 Cooley-Tukey FFT (4096-point, comptime twiddle factors) to replace FFmpeg's `av.TXContext` 287 - - WAV reader for PCM16/Float32 mono 11025 Hz (replaces FFmpeg's decode pipeline) 288 - - AcoustID HTTP client with gzip-compressed POST (`std.compress.flate`) 289 - - MusicBrainz enrichment (recording ID → title/artist) 290 - - **fingerprints are an exact match against `fpcalc`** (the reference C implementation) — verified on real plyr.fm tracks including a rickroll flagged by the moderation system 291 - 292 - **technical notes**: 293 - - zig 0.16 has massive breaking changes from 0.15 (new `Io` context threading, `std.process.Init` for main, `client.fetch()` replacing `client.open()`). notes captured in memory for upcoming SDK migrations (zat, etc.) 294 - - AcoustID returns HTTP 400 (not 200) for API errors — discovered by testing with an expired test key. the JSON body must be parsed regardless of HTTP status 295 - - AcoustID's docs recommend compressed POST because fingerprints are large (~5KB base64). switched from query-string GET to gzip POST 296 - - the pure zig approach means the moderation service can embed fingerprinting directly instead of calling an external API 297 - 298 - **what happened next**: PR #1163 integrated AcoustID into the moderation service, but AcoustID does whole-file matching — it can't detect copyrighted segments within longer uploads (DJ sets, sample-heavy tracks). reverted to AuDD in #1174 (see above for full explanation). the chromaprint.zig library remains a standalone tool and learning exercise — the fingerprinting itself works perfectly, it's AcoustID's whole-file matching model that doesn't fit plyr.fm's moderation needs. 299 - 300 - --- 301 - 302 - #### ambient theme polish (PRs #1158-1161, Mar 19) 303 - 304 - temperature unit detection for US users (Fahrenheit vs Celsius from browser locale), gradient banding reduction via interpolated color stops (7 stops instead of 3), and cached weather data for instant theme restore on page load. 305 - 306 - --- 307 - 308 - #### homepage activity integration — failed experiment (PRs #1151-1156, Mar 19) 309 - 310 - **goal**: inject life into the homepage by surfacing recent platform activity (likes, uploads, comments, joins). 311 - 312 - **attempt 1 — floating pills** (#1151): glassmorphic pill-shaped elements drifting across the page background with CSS keyframe animations. identical shapes for all event types, slow 25-45s drift cycles, visible for ~75% of each cycle. deployed to staging: looked like dopey submarines hanging around the background. not ephemeral, not legible, not visually distinct between event types. **3/10.** 313 - 314 - **attempt 2 — restyled pills** (#1152): distinct shapes per event type (circles, rectangles, speech bubbles), faster 14-24s cycles visible only ~25% of the time, type-colored backgrounds with glow. better than v1 but still fundamentally the same problem — random floating elements in the background look corny regardless of styling. **5/10.** 315 - 316 - **attempt 3 — text echoes** (#1153): researched internet.dev's text-first design philosophy. stripped all card/pill chrome, rendered bare monospace text with type-colored glow, added sonar ring ping animations, JS-driven slot cycling with staggered timing. more refined than the pills but the core concept of randomly positioned background elements was still bad. still corny. 317 - 318 - **attempt 4 — activity shelf** (#1155): abandoned the background approach entirely. built a horizontal scroll section (like top tracks / artists you know) with compact `ActivityCard` components, plus dismissible sections with localStorage persistence. closer to the right idea structurally, but the cards weren't good enough and the overall execution didn't meet the bar. 319 - 320 - **outcome**: all four attempts reverted (#1154, #1156). homepage is back to its pre-experiment state. the `ActivityCard`, `FloatingActivity`, and `dismissed-sections` modules were all deleted. 321 - 322 - **lessons**: 323 - - floating/background elements for activity feeds are a fundamentally bad idea — they look gimmicky regardless of visual polish 324 - - research into design systems (internet.dev, Spotify shelves, etc.) was useful but didn't compensate for weak visual execution 325 - - should have started with the simplest inline approach (attempt 4) instead of the most ambitious one (attempt 1) 326 - - the activity data is available via `/activity/` API and the dedicated `/activity` page — homepage integration remains an open question 327 - 328 - --- 329 - 330 - #### frontend validation alignment (PR #1148, Mar 18) 331 - 332 - audited every backend validation limit (lexicon schemas, pydantic models, manual checks) against frontend form enforcement. found 8 fields with backend limits but no frontend `maxlength`, one upload path missing a client-side file size check, and two limit mismatches between the lexicon and API layer. 333 - 334 - **what shipped**: 335 - - raised API comment text limit from 300 to 1000 to match the `fm.plyr.comment` lexicon (network-wide scan confirmed no existing records exceed 254 chars) 336 - - raised `HandleSearch` default `maxFeatures` from 5 to 10 to match the `fm.plyr.track` lexicon (max in the wild: 2) 337 - - added `maxlength` on track title (256), album name (256), bio (2560), playlist name (256), tag input (50 per tag), search inputs (100) 338 - - added character counters on description, bio, and comment fields (follows existing `FeedbackModal` pattern) 339 - - capped `TagInput` at 10 tags with a "maximum 10 tags" message 340 - - added 20MB image size check on portal track artwork upload (was the only upload path without client-side validation) 341 - 342 - --- 343 - 344 - #### collapsible track descriptions (PRs #1144-1146, Mar 18; superseded by #1205) 345 - 346 - long track descriptions were pushing the track page layout down with no way to collapse them. added a collapsible wrapper that truncates descriptions taller than ~5 lines (128px) with a fade-out gradient. the "show more" toggle started as a bare left-aligned text link, then was restyled as a centered pill button with chevron indicators (▾/▴) to match the existing `.pill-btn` pattern used in Queue and tag badges. **replaced in #1205** with the metadata disclosure icon approach. 347 - 348 - --- 349 - 350 - #### handle typeahead migration (PRs #1140-1141, Mar 17) 351 - 352 - **why**: switching handle autocomplete to [`typeahead.waow.tech`](https://typeahead.waow.tech) — a community ATProto actor search service (Zig ingester on Fly.io → Cloudflare Worker + D1/FTS5) — revealed that the backend `/search/handles` endpoint was just a passthrough proxy. no auth, caching, or transformation — just an extra hop adding ~500ms of latency. 353 - 354 - **what shipped**: 355 - - switched from Bluesky's `searchActorsTypeahead` to the community typeahead service (#1140) 356 - - moved the call entirely to the frontend, eliminating the unnecessary backend proxy (#1141) 357 - - backend `/search/handles` endpoint removed from the router (underlying code stays for SDK consumers) 358 - - configurable via `PUBLIC_TYPEAHEAD_URL` env var, degrades to empty results on failure 359 - 360 - --- 361 - 362 - #### RSS feed removal (PR #1139, Mar 17) 363 - 364 - **why**: per-artist RSS feeds (shipped in PR #1045, Mar 6) were an over-eager introduction — added speculatively without clear demand. 19 total hits in 30 days confirmed it. the `feedgen` + `lxml` dependencies weren't worth carrying for something nobody asked for. 365 - 366 - **what shipped**: 367 - - removed `feeds.py` module, tests, `feedgen`/`lxml` dependencies, and `<link rel="alternate">` tags from artist/album/playlist pages 368 - 369 - --- 370 - 371 - #### ambient weather theme — "live" (PRs #1127-1136, Mar 16) 372 - 373 - **why**: plyr.fm had dark/light/system themes but no personality. the ambient "live" theme adds a location-aware atmospheric background that reflects real weather conditions — making the app feel connected to the listener's environment. 374 - 375 - **what shipped**: 376 - - new "live" theme option alongside dark/light/auto in both desktop and mobile settings 377 - - fetches weather from Open-Meteo API using device geolocation, renders gradient background based on conditions (clear/cloudy/rain/snow/fog/storm × day/night × temperature warmth) 378 - - full UI tinting: 12 CSS variables (glass surfaces, borders, track cards, backgrounds) are blended with weather-derived tint colors at 6-15% strength 379 - - live is a first-class peer theme, not a toggle layer — server-persisted per account via new `theme` column on `user_preferences` (alembic migration) 380 - - localStorage is a flash-prevention cache synced from server, not source of truth 381 - 382 - **technical notes**: 383 - - initial implementation (#1127-1132) built live as a separate toggle, which caused bugs on account switch (light base + ambient gradient = broken UI). redesigned in #1134 to make live a peer of dark/light/system 384 - - theme was never server-persisted before this work — it was purely localStorage. added the DB column and wired preferences.fetch() to sync server → localStorage → DOM 385 - - accent color had the same sync gap (#1136): server stored it, but preferences.fetch() never applied it to the DOM or localStorage. on fresh loads, `--accent` fell back to CSS default blue regardless of saved preference 386 - - geolocation is cached device-global (`ambient_location` in localStorage) — survives theme switches for instant re-activation. old DID-scoped keys are auto-migrated 387 - - live resolves to dark base theme (CSS class `theme-dark`), with gradient overlay and tinted variables on top 388 - 389 - --- 390 - 391 - #### CORS open access + Jetstream fixes (PRs #1106-1107, Mar 14) 392 - 393 - **why**: CORS was restricted to `*.plyr.fm` subdomains, blocking third-party ATProto clients and embeds from calling the API. separately, the Jetstream consumer was crash-looping due to OpenTelemetry span errors, and PDS uploads failed silently on transient network errors. 394 - 395 - **what shipped**: 396 - - CORS now allows any HTTPS origin to call the API 397 - - fixed Jetstream crash loop caused by OTEL span context errors 398 - - added retry logic for PDS upload network failures 399 - 400 - --- 401 - 402 - #### AT-URI lookup endpoints (PR #1123, Mar 15) 403 - 404 - **why**: external ATProto clients need to resolve `at://` URIs to plyr.fm page URLs. without this, a client that discovers a `fm.plyr.track` record on the network has no way to link to it on plyr.fm. 405 - 406 - **what shipped**: 407 - - `GET /tracks/by-uri?uri=at://...` and `GET /playlists/by-uri?uri=at://...` endpoints 408 - - returns the track/playlist object if found, 404 otherwise 409 - 410 - --- 411 - 412 - #### docs rewrite + UX polish (PRs #1108-1120, Mar 14-16) 413 - 414 - **what shipped**: 415 - - rewrote listeners and creators docs pages — lead with experience, not protocol jargon 416 - - reordered upload form: required fields first 417 - - PDS tooltip on upload form explaining what "store on your PDS" means 418 - - fixed liker profile links interrupting playback (#1121) 419 - - PDS tooltip hover uses delayed hide to prevent flicker (#1110) 420 - 421 - #### March 1-12 422 - 423 - See `.status_history/2026-03.md` for detailed history including Jetstream real-time ingestion, community feedback response, public docs restructure, activity feed, embed glow bar, and infrastructure fixes. 165 + See `.status_history/2026-03.md` for detailed history. 424 166 425 167 --- 426 168
update.wav

This is a binary file and will not be displayed.