audio streaming app plyr.fm
38
fork

Configure Feed

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

chore: status maintenance — SDK namespace, CDN caching, feed switcher, telemetry incident (#1295)

* chore: status maintenance — SDK namespace, CDN caching, feed switcher, telemetry incident

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: add TTS audio for status update (generated locally)

gemini-2.5-pro-tts free tier quota was exhausted in CI.
generated locally with OTHER_GOOGLE_API_KEY.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: zzstoatzz <thrast36@gmail.com>

authored by

claude[bot]
Claude Opus 4.6
claude[bot]
zzstoatzz
and committed by
GitHub
ed0da8a9 05fb1219

+185 -182
+182
.status_history/2026-04.md
··· 1 + # plyr.fm Status History - April 2026 2 + 3 + #### first-class album uploads with preserved track ordering (PRs #1260-1262, Apr 9) 4 + 5 + **why**: album uploads via the album upload form lost the user's chosen track order after #1238 switched to concurrent uploads. the ATProto list record (the source of truth for album track order) was built by per-track sync tasks that sorted by `Track.created_at`, and `created_at` reflects the DB commit race under concurrency — not user intent. external review also caught two P1s: creating the album shell emitted an `album_release` CollectionEvent before any track succeeded (fake release in the activity feed), and appending tracks to an existing album truncated the list record to only the current session's tracks. 6 + 7 + **what shipped**: 8 + - **`POST /albums/`** (#1260) — first-class album creation endpoint. creates an empty album shell (title, description) without writing the ATProto list record. idempotent on `(artist_did, slug)` — matches the "type an existing album name to add tracks to it" UX. `album_release` CollectionEvent deferred to finalize so total upload failures don't publish a fake release 9 + - **`POST /tracks/` with `album_id`** (#1260) — optional form field that skips `get_or_create_album`, links the track to the existing album at row creation, and skips per-track `schedule_album_list_sync`. mutually exclusive with the legacy `album: str` field 10 + - **`POST /albums/{id}/finalize`** (#1260) — accepts `{track_ids: [int]}` in user-intended order. writes the ATProto list record **once** with strongRefs in the exact order requested. on append (when the album already has tracks from prior sessions), preserves existing tracks in their current list-record order and appends new tracks at the end. emits `album_release` on first successful finalize only (deduped) 11 + - **SSE strongRef surface** (#1262) — the upload-progress SSE endpoint now surfaces `atproto_uri` and `atproto_cid` in the completion payload. caught by staging integration tests after #1260 merged — the SSE handler whitelisted only `track_id` and `warnings`, silently dropping the strongRef fields the upload pipeline wrote to `job.result` 12 + - **dropped mid-page progress block** (#1261) — per-track toasts and per-card status pills from #1238 already provide richer feedback; the aggregate progress bar was a third view of the same data 13 + - **empty album filtering** — `GET /albums/`, `GET /albums/{handle}`, search, and sitemap now filter to albums with at least one track, so abandoned draft shells never appear on artist profiles or in search results 14 + 15 + **decisions**: 16 + - no DB schema change — the ATProto list record remains the single source of truth for track order, consistent with how the album edit page's drag-and-drop reorder already works (`PUT /lists/{rkey}/reorder` writes the PDS directly). adding a `track_number` column would have forked the truth and required ongoing DB↔PDS synchronization 17 + - the legacy `album: str` path (single-track `/upload` page) is untouched — its per-track sync is still racy in principle, but single-track uploads can't produce concurrent inserts by construction 18 + - the `album_release` dedup is application-level (`SELECT` + `INSERT`), not DB-enforced (no partial unique index on `CollectionEvent`). concurrent finalize calls have a TOCTOU window — acceptable because the frontend calls finalize exactly once and the album edit page doesn't use finalize at all. flagged as a follow-up hardening opportunity 19 + - internal docs: `docs/internal/backend/album-uploads.md` covers the full three-step flow, the atproto-native rationale, append semantics, and partial-failure behavior. plan doc at `docs/internal/plans/2026-04-09-album-upload-ordering.md` 20 + 21 + --- 22 + 23 + #### unlisted /record page + reusable Waveform component (PRs #1251-1257, Apr 8-9) 24 + 25 + **why**: uploading short audio (voice memos, field recordings, ideas) via the existing upload flow meant finding a desktop recorder, saving a file, then navigating to `/upload` and picking it — a disproportionate round-trip for a 30-second capture. inspired by Jared's Leaflet in-browser recorder: open the tab, press record, stop, preview, upload, done. separately, plyr.fm has needed a reusable waveform visualization for months — track detail, player, playlist rows all want one — so this PR ships the primitive alongside its first caller instead of duplicating the work later. 26 + 27 + **what shipped**: 28 + - **`/record` page** — unlisted (`noindex,nofollow`, not linked from nav), auth-gated. four-state machine: idle → recording → preview → uploading. `MediaRecorder` with a mime fallback chain (`audio/webm;codecs=opus` → `webm` → `mp4` for Safari → `ogg`). 10-minute hard cap with a 9:00 warning toast. default title `recording YYYY-MM-DD HH:MM`, default tag `voice-memo`. `autoTag: false` so the ML genre tagger doesn't clobber the voice-memo default. reuses the existing `$lib/uploader.svelte` singleton so progress/SSE/toast plumbing isn't duplicated; redirects to `/u/{handle}` so the user lands on their profile while the uploader toast tracks progress 29 + - **backend audio format support** — `WEBM` and `OGG` added to the `AudioFormat` enum in `backend/_internal/audio.py`, both marked `is_web_playable = False` so they route through the existing transcoder. **zero Rust transcoder changes needed** — ffmpeg already decodes webm/opus/ogg natively. stored audio normalizes to mp3 for embeds and downstream tools 30 + - **`$lib/components/Waveform.svelte`** — reusable inline-SVG waveform with dual-layer rendering (base bars + clipPath-masked progress overlay, wavesurfer-style single animatable rect instead of recoloring individual bars). mirrored/center-aligned bars, 2px minimum height so silent sections still show a hairline. accepts **either** pre-computed `peaks: number[]` **or** `source: Blob | string` (auto-decodes). click-to-seek + ArrowLeft/Right keyboard nudges, slider ARIA, per-instance random clipPath id so multiple waveforms on one page don't collide. themed via `--wf-base` and `--wf-progress` CSS custom props 31 + - **`$lib/audio/peaks.ts`** — pure helper extracting normalized peaks from a `Blob` or `ArrayBuffer` via `AudioContext.decodeAudioData`. channels reduced by per-bucket **max** (not average) so stereo transients still show up. pure and cacheable — easy to memoize later when rendering waveforms for many tracks at once 32 + - **follow-up polish** (#1252): aesthetics pass and link-preview metadata. #1255-1256 fix duration showing `Infinity/NaN` until first seek and ensure the duration upper bound is never 0 in the preview state. #1257 fixes a track-page `og:image` that could be left empty 33 + 34 + **decisions**: 35 + - waveform API designed for reuse from day one (accepts peaks OR source, optional `onSeek`, CSS-variable theming) — this is why the record page is its first caller, not an excuse to inline the rendering 36 + - iframe embed, Claude-powered metadata autofill from audio content, file picker support for `.webm`/`.ogg` on `/upload`, and rendering waveforms across track detail / playlist / player surfaces are all deliberately deferred 37 + 38 + --- 39 + 40 + #### unlisted /for-you personalized feed (PRs #1249-1250, Apr 8) 41 + 42 + **why**: the homepage surfaces "latest" and "top tracks" but has nothing personalized — there was no "based on what you like, try this" surface. @spacecowboy17.bsky.social's collaborative-filtering For You algorithm in grain.social is well-tuned and documented; porting it gives us a working baseline without reinventing the scoring. ships unlisted (not linked from nav, `noindex,nofollow`) because the ranking is v1 and benefits from testing with intentional users before being promoted. 43 + 44 + **what shipped**: 45 + - new `GET /for-you/?cursor=<int>&limit=<int>` endpoint in `backend/src/backend/api/for_you.py` 46 + - new `/for-you` route with infinite scroll, queue-all, and a cold-start "warming up" state for users with zero engagement history 47 + - auth required; unauth visitors redirect to `/` 48 + 49 + **scoring recipe** (from grain, unchanged): 50 + ``` 51 + score = sum(1 / total_edges(coengager) ** 1.0) # picky co-engagers 52 + * paths ** 0.5 # multi-path smoothing 53 + * 0.5 ** (age_hours / 48) # 48h half-life 54 + / popularity ** 0.3 # dampen globally popular 55 + ``` 56 + co-engagers are filtered to those who engaged with the seed *before* we did (+24h grace window) — grain's key insight that rewards taste-makers over bandwagoners. cold start falls back to most-engaged tracks in the last 30 days. 57 + 58 + **what's different from grain**: 59 + - **engagement edges are likes OR playlist-adds.** grain only has favorites; we have a unified activity stream in `activity.py`, and `track_added_to_playlist` is a particularly strong curation signal ("this belongs next to these other tracks"). both edges get uniform weight 1.0 in v1 60 + - **48h half-life instead of 6h** — audio ages slower than photo galleries; a two-week-old track is still meaningfully new 61 + - **per-artist diversity cap (hard 2 per page)** — without this, one prolific archiver dominates the ranking, the same failure mode that killed the artist leaderboard in #1229 62 + - **self-uploads excluded from seeds AND candidates** — your likes on your own tracks aren't a taste signal; your own uploads shouldn't be recommended back to you 63 + - hidden-tag preferences are respected 64 + 65 + **decisions**: 66 + - pure collaborative filtering in v1; CLAP-embedding rerank deferred (obvious follow-up: take top ~200 grain-scored candidates, rerank by distance to centroid of user's seed embeddings — helps long-tail and cold-start) 67 + - per-request score recomputation, no Redis cache — means pagination may drift slightly under heavy engagement churn, acceptable for v1 68 + - differential edge weights (e.g. playlist-add = 2.0, comment = 1.5, like = 1.0) deferred until there's something to A/B against 69 + - play signals as edges deferred — likes are sparse, plays are dense, needs its own experiment 70 + 71 + --- 72 + 73 + #### top tracks time-range toggle + like count fix (PRs #1228-1230, Apr 3) 74 + 75 + **why**: the homepage "top tracks" section showed all-time most-liked tracks with no way to see recent trends. separately, the artist leaderboard rank feature (play-count-based) rewarded volume uploaders and self-listeners over genuine community engagement — shipped and pulled in the same session. 76 + 77 + **what shipped**: 78 + - **period toggle** (#1228): cycling label on "top tracks" heading — "all time" → "past month" → "past week" → "past day" — filters which tracks appear by when their likes were created. selection persists in localStorage. backend `GET /tracks/top?period=month` accepts `all_time|month|week|day`, maps to `since` cutoff on `TrackLike.created_at` (indexed column) 79 + - **like count fix** (#1230): the period filter was reusing the time-scoped like count for display — filtering to "past day" showed "1 like" even if a track had many total likes. now uses period-filtered counts only for ordering/inclusion, fetches all-time totals separately for the displayed `like_count` 80 + - **rank card pulled** (#1229): artist leaderboard rank (top 10 by total plays) shipped in #1228 but immediately pulled from the frontend — the #1 ranked artist was an archiver uploading Internet Archive content and listening to it himself, not a community-recognized artist. backend infrastructure (`rank` field in analytics response, Redis-cached leaderboard) remains intact for re-enabling with better criteria (likely likes-based) 81 + 82 + **decisions**: 83 + - play count is a poor ranking signal — it rewards self-listening and volume. likes are better but still raise questions about what "top artist" means on a platform hosting archives, podcasts, ASMR, etc. 84 + - kept the backend rank infrastructure to avoid throwaway work — the leaderboard query and caching are correct, just need a better ranking formula 85 + 86 + --- 87 + 88 + #### browser observability + now-playing flood fix (PRs #1224-1225, Apr 2-3) 89 + 90 + **why**: a login redirect failure had zero frontend traces to debug — backend spans showed success, but something broke between the 303 redirect and the frontend. separately, a single user's client hammered `POST /now-playing/` every 5 seconds for an hour (2,758 requests), driving p95 latency to 2.9s and max to 13.6s across the entire API. zero 5xx errors, but the app felt down for everyone. 91 + 92 + **what shipped**: 93 + - **browser observability** (#1224): `@pydantic/logfire-browser` SDK auto-instruments fetch, document-load, user-interaction, and XHR. telemetry proxied through `POST /logfire-proxy/{path:path}` on the backend (via `logfire.experimental.forwarding.logfire_proxy`) so the write token stays server-side. `traceparent` headers propagate to the API for distributed tracing — a single trace now spans browser → API → database. service name `plyr-web` distinguishes from backend's `plyr-api` in Logfire 94 + - **now-playing throttle fix** (#1225): the frontend's `progressBucket` rounded to 5 seconds but the throttle interval was 10 seconds — the state fingerprint changed mid-throttle, bypassing the "skip if unchanged" check and firing reports every 5s instead of 10s. aligned bucket granularity to match `REPORT_INTERVAL_MS` (10s). backend: replaced `@limiter.exempt` with `30/minute` rate limit as a server-side safety net (normal playback is 6/min, generous headroom for rapid play/pause/seek) 95 + 96 + **incident timeline** (2026-04-02 23:17–23:40 UTC): 97 + - 23:17: traffic spikes to 1,624 requests/minute (10x normal), p95 = 1.6s 98 + - 23:18: 458 requests, p95 = 2.9s, max = 3.0s 99 + - 23:22: second spike, max latency hits 13.6s 100 + - 23:38: third spike, 1,945 requests/minute, max = 7.8s 101 + - 00:00: traffic returns to normal (~30 requests/minute) 102 + - root cause: joebasser.com's client firing `POST /now-playing/` every 5s for ~1 hour 103 + 104 + --- 105 + 106 + #### album AT-URI resolution + search modal polish (PRs #1222-1223, Apr 2) 107 + 108 + **what shipped**: 109 + - **album AT-URI fix** (#1223): the `/at/[...uri]` catch-all route only resolved tracks and playlists. album AT-URIs (`fm.plyr.album`) returned 404. refactored the route to use a generic list resolver that handles both playlists and albums through the existing `/lists/*/by-uri` endpoints. added regression tests 110 + - **search modal** (#1222): centered vertically in viewport, enhanced glass effect 111 + 112 + --- 113 + 114 + #### homepage tag filtering + backend performance (PRs #1216-1220, Apr 2) 115 + 116 + **why**: the homepage had no way to positively filter tracks by genre. you could hide tags (negative filter) but not say "show me electronic and ambient." the dedicated `/tag/[name]` page only supports one tag and navigates away from the homepage. separately, the `GET /tracks/` endpoint was 250-1200ms for authenticated users due to an uncached external HTTP call to atprotofans.com for supporter validation on every single request. 117 + 118 + **what shipped**: 119 + - **tag filter chips**: horizontal scrollable row of popular tag chips below "latest tracks." multi-select with OR logic (tracks matching any selected tag). per-tag hue colors via deterministic hash. selected tags sort to the left. selection persists in localStorage across page refreshes 120 + - **backend `tags` query param**: `GET /tracks/?tags=electronic&tags=ambient` filters to tracks matching any selected tag. composes with hidden tag filtering. skips Redis cache when tags are active 121 + - **tag ranking by plays**: tags are now ordered by total play count (sum of `play_count` across all tracks with that tag) instead of just track count. surfaces genres with actual listener engagement 122 + - **atprotofans Redis cache**: supporter validation results cached in Redis with 5-min TTL per (supporter, artist) pair. eliminates the 80-1100ms external HTTP call on repeat page loads 123 + - **parallelized supporter validation**: `asyncio.create_task()` kicks off the atprotofans call immediately after getting the track list, running concurrently with batch aggregations (~19ms), PDS resolution (~58ms), and image resolution. previously these all ran sequentially 124 + 125 + **performance impact** (authenticated users, production): 126 + - before: 250-1200ms (validate_supporter was 82% of request time on slow calls) 127 + - after (cache miss): ~170ms (validate_supporter runs in parallel with other work) 128 + - after (cache hit): ~170ms (no external call at all) 129 + 130 + --- 131 + 132 + #### Fly HTTP health checks + outage retro (PR #1214, Apr 2) 133 + 134 + **why**: production outage (~6 min, 02:34-02:40 UTC Apr 2). after a deploy, Fly auto-stopped one machine for low traffic. the remaining machine became unresponsive but Fly had no health checks configured — it kept reporting the machine as "started" while it served nothing. when a replacement machine finally started, it was OOM killed (1GB exhausted in 20 seconds from queued request burst). manual `fly machine restart` was required to recover. 135 + 136 + **what shipped**: 137 + - added `[[http_service.checks]]` to both `fly.toml` and `fly.staging.toml` — Fly now polls `GET /health` every 10s with a 5s timeout and 30s startup grace period 138 + - Fly will auto-restart machines that stop responding, eliminating the class of outage where a frozen process goes undetected 139 + 140 + **what we learned**: 141 + - the Dec 2025 database pool fixes (pool_size=10, max_overflow=5, connection_timeout=10s) were already in place — initial analysis incorrectly blamed stale recommendations 142 + - the queue listener is already decoupled from the app lifecycle (`asyncio.create_task`, catches all exceptions) — it was not the cause of the process death 143 + - what actually froze the remaining machine is still unknown (no Logfire output for 6 minutes while Fly reported it as "started"). possible causes: memory pressure on 1GB VM, blocked event loop, or a hard crash whose Fly event was truncated from the 5-event log 144 + - full retro: `sandbox/retrospectives/2026-04-02-deploy-outage-oom-kill.md` 145 + 146 + --- 147 + 148 + #### AT-URI top-level route resolution (PRs #1206, #1208, Apr 1) 149 + 150 + **why**: every atproto app should support AT-URIs as top-level routes ([streamplace/streamplace#1012](https://github.com/streamplace/streamplace/issues/1012)). this lets anyone paste `https://plyr.fm/at://did:plc:xxx/fm.plyr.track/rkey` and land on the right page. 151 + 152 + **what shipped**: 153 + - new SvelteKit catch-all route at `/at/[...uri]` that parses AT-URIs via `AtUri` from `@atproto/api`, resolves them against existing backend `/tracks/by-uri` and `/lists/playlists/by-uri` endpoints, and 301 redirects to the canonical page (`/track/{id}`, `/playlist/{id}`) 154 + - handles browser normalization of `://` in URL paths 155 + - follow-up (#1208): replaced 7 instances of manual `.split("/")` AT-URI parsing across backend and frontend with proper library utilities (`parse_at_uri()` wrapper in Python, `AtUri` class in TypeScript) 156 + 157 + --- 158 + 159 + #### track detail page: title width + metadata disclosure (PR #1205, Apr 1) 160 + 161 + **why**: long track titles like "better hate (jessica pratt cover)" wrapped onto two lines because `.track-info-wrapper` was capped at `max-width: 600px`. the inline description also added visual weight without user opt-in. 162 + 163 + **what shipped**: 164 + - widened `.track-info-wrapper` from `max-width: 600px` to `min(900px, 90%)` — long titles stay on one line on desktop, 90% prevents edge-touching on narrower viewports 165 + - replaced the inline collapsible description (gradient mask + "show more/less" toggle) with a circled `(i)` icon in the stats row that slides open a metadata panel on click. only renders when `track.description` exists 166 + 167 + --- 168 + 169 + #### WebSocket hardening (PRs #1203-1204, Apr 1) 170 + 171 + **what shipped**: 172 + - **security** (#1203): origin validation rejects WebSocket upgrades from non-allowlisted origins, `session_id` omitted from client-facing messages 173 + - **reliability** (#1204): idle timeout disconnects inactive connections, per-IP rate limiting and connection limits prevent abuse 174 + 175 + --- 176 + 177 + #### Jetstream identity sync + image URL fix (PRs #1200-1202, Mar 31) 178 + 179 + **what shipped**: 180 + - **handle sync** (#1200): Jetstream identity events now trigger handle updates — when an artist changes their handle on Bluesky, plyr.fm picks it up automatically instead of waiting for their next sign-in 181 + - **image URL fix** (#1202): R2 storage keys use the original file extension, but the image URL construction wasn't preserving it. images uploaded as `.jpeg` were served with `.jpg` URLs (or vice versa), returning 404s from R2 182 + - **docs homepage** (#1201): pinned track 778 on docs homepage, deduplicated tracks-by-artist in the showcase
+3 -182
STATUS.md
··· 181 181 182 182 --- 183 183 184 - #### first-class album uploads with preserved track ordering (PRs #1260-1262, Apr 9) 185 - 186 - **why**: album uploads via the album upload form lost the user's chosen track order after #1238 switched to concurrent uploads. the ATProto list record (the source of truth for album track order) was built by per-track sync tasks that sorted by `Track.created_at`, and `created_at` reflects the DB commit race under concurrency — not user intent. external review also caught two P1s: creating the album shell emitted an `album_release` CollectionEvent before any track succeeded (fake release in the activity feed), and appending tracks to an existing album truncated the list record to only the current session's tracks. 187 - 188 - **what shipped**: 189 - - **`POST /albums/`** (#1260) — first-class album creation endpoint. creates an empty album shell (title, description) without writing the ATProto list record. idempotent on `(artist_did, slug)` — matches the "type an existing album name to add tracks to it" UX. `album_release` CollectionEvent deferred to finalize so total upload failures don't publish a fake release 190 - - **`POST /tracks/` with `album_id`** (#1260) — optional form field that skips `get_or_create_album`, links the track to the existing album at row creation, and skips per-track `schedule_album_list_sync`. mutually exclusive with the legacy `album: str` field 191 - - **`POST /albums/{id}/finalize`** (#1260) — accepts `{track_ids: [int]}` in user-intended order. writes the ATProto list record **once** with strongRefs in the exact order requested. on append (when the album already has tracks from prior sessions), preserves existing tracks in their current list-record order and appends new tracks at the end. emits `album_release` on first successful finalize only (deduped) 192 - - **SSE strongRef surface** (#1262) — the upload-progress SSE endpoint now surfaces `atproto_uri` and `atproto_cid` in the completion payload. caught by staging integration tests after #1260 merged — the SSE handler whitelisted only `track_id` and `warnings`, silently dropping the strongRef fields the upload pipeline wrote to `job.result` 193 - - **dropped mid-page progress block** (#1261) — per-track toasts and per-card status pills from #1238 already provide richer feedback; the aggregate progress bar was a third view of the same data 194 - - **empty album filtering** — `GET /albums/`, `GET /albums/{handle}`, search, and sitemap now filter to albums with at least one track, so abandoned draft shells never appear on artist profiles or in search results 195 - 196 - **decisions**: 197 - - no DB schema change — the ATProto list record remains the single source of truth for track order, consistent with how the album edit page's drag-and-drop reorder already works (`PUT /lists/{rkey}/reorder` writes the PDS directly). adding a `track_number` column would have forked the truth and required ongoing DB↔PDS synchronization 198 - - the legacy `album: str` path (single-track `/upload` page) is untouched — its per-track sync is still racy in principle, but single-track uploads can't produce concurrent inserts by construction 199 - - the `album_release` dedup is application-level (`SELECT` + `INSERT`), not DB-enforced (no partial unique index on `CollectionEvent`). concurrent finalize calls have a TOCTOU window — acceptable because the frontend calls finalize exactly once and the album edit page doesn't use finalize at all. flagged as a follow-up hardening opportunity 200 - - internal docs: `docs/internal/backend/album-uploads.md` covers the full three-step flow, the atproto-native rationale, append semantics, and partial-failure behavior. plan doc at `docs/internal/plans/2026-04-09-album-upload-ordering.md` 201 - 202 - --- 203 - 204 - #### unlisted /record page + reusable Waveform component (PRs #1251-1257, Apr 8-9) 205 - 206 - **why**: uploading short audio (voice memos, field recordings, ideas) via the existing upload flow meant finding a desktop recorder, saving a file, then navigating to `/upload` and picking it — a disproportionate round-trip for a 30-second capture. inspired by Jared's Leaflet in-browser recorder: open the tab, press record, stop, preview, upload, done. separately, plyr.fm has needed a reusable waveform visualization for months — track detail, player, playlist rows all want one — so this PR ships the primitive alongside its first caller instead of duplicating the work later. 207 - 208 - **what shipped**: 209 - - **`/record` page** — unlisted (`noindex,nofollow`, not linked from nav), auth-gated. four-state machine: idle → recording → preview → uploading. `MediaRecorder` with a mime fallback chain (`audio/webm;codecs=opus` → `webm` → `mp4` for Safari → `ogg`). 10-minute hard cap with a 9:00 warning toast. default title `recording YYYY-MM-DD HH:MM`, default tag `voice-memo`. `autoTag: false` so the ML genre tagger doesn't clobber the voice-memo default. reuses the existing `$lib/uploader.svelte` singleton so progress/SSE/toast plumbing isn't duplicated; redirects to `/u/{handle}` so the user lands on their profile while the uploader toast tracks progress 210 - - **backend audio format support** — `WEBM` and `OGG` added to the `AudioFormat` enum in `backend/_internal/audio.py`, both marked `is_web_playable = False` so they route through the existing transcoder. **zero Rust transcoder changes needed** — ffmpeg already decodes webm/opus/ogg natively. stored audio normalizes to mp3 for embeds and downstream tools 211 - - **`$lib/components/Waveform.svelte`** — reusable inline-SVG waveform with dual-layer rendering (base bars + clipPath-masked progress overlay, wavesurfer-style single animatable rect instead of recoloring individual bars). mirrored/center-aligned bars, 2px minimum height so silent sections still show a hairline. accepts **either** pre-computed `peaks: number[]` **or** `source: Blob | string` (auto-decodes). click-to-seek + ArrowLeft/Right keyboard nudges, slider ARIA, per-instance random clipPath id so multiple waveforms on one page don't collide. themed via `--wf-base` and `--wf-progress` CSS custom props 212 - - **`$lib/audio/peaks.ts`** — pure helper extracting normalized peaks from a `Blob` or `ArrayBuffer` via `AudioContext.decodeAudioData`. channels reduced by per-bucket **max** (not average) so stereo transients still show up. pure and cacheable — easy to memoize later when rendering waveforms for many tracks at once 213 - - **follow-up polish** (#1252): aesthetics pass and link-preview metadata. #1255-1256 fix duration showing `Infinity/NaN` until first seek and ensure the duration upper bound is never 0 in the preview state. #1257 fixes a track-page `og:image` that could be left empty 214 - 215 - **decisions**: 216 - - waveform API designed for reuse from day one (accepts peaks OR source, optional `onSeek`, CSS-variable theming) — this is why the record page is its first caller, not an excuse to inline the rendering 217 - - iframe embed, Claude-powered metadata autofill from audio content, file picker support for `.webm`/`.ogg` on `/upload`, and rendering waveforms across track detail / playlist / player surfaces are all deliberately deferred 218 - 219 - --- 220 - 221 - #### unlisted /for-you personalized feed (PRs #1249-1250, Apr 8) 222 - 223 - **why**: the homepage surfaces "latest" and "top tracks" but has nothing personalized — there was no "based on what you like, try this" surface. @spacecowboy17.bsky.social's collaborative-filtering For You algorithm in grain.social is well-tuned and documented; porting it gives us a working baseline without reinventing the scoring. ships unlisted (not linked from nav, `noindex,nofollow`) because the ranking is v1 and benefits from testing with intentional users before being promoted. 224 - 225 - **what shipped**: 226 - - new `GET /for-you/?cursor=<int>&limit=<int>` endpoint in `backend/src/backend/api/for_you.py` 227 - - new `/for-you` route with infinite scroll, queue-all, and a cold-start "warming up" state for users with zero engagement history 228 - - auth required; unauth visitors redirect to `/` 229 - 230 - **scoring recipe** (from grain, unchanged): 231 - ``` 232 - score = sum(1 / total_edges(coengager) ** 1.0) # picky co-engagers 233 - * paths ** 0.5 # multi-path smoothing 234 - * 0.5 ** (age_hours / 48) # 48h half-life 235 - / popularity ** 0.3 # dampen globally popular 236 - ``` 237 - co-engagers are filtered to those who engaged with the seed *before* we did (+24h grace window) — grain's key insight that rewards taste-makers over bandwagoners. cold start falls back to most-engaged tracks in the last 30 days. 238 - 239 - **what's different from grain**: 240 - - **engagement edges are likes OR playlist-adds.** grain only has favorites; we have a unified activity stream in `activity.py`, and `track_added_to_playlist` is a particularly strong curation signal ("this belongs next to these other tracks"). both edges get uniform weight 1.0 in v1 241 - - **48h half-life instead of 6h** — audio ages slower than photo galleries; a two-week-old track is still meaningfully new 242 - - **per-artist diversity cap (hard 2 per page)** — without this, one prolific archiver dominates the ranking, the same failure mode that killed the artist leaderboard in #1229 243 - - **self-uploads excluded from seeds AND candidates** — your likes on your own tracks aren't a taste signal; your own uploads shouldn't be recommended back to you 244 - - hidden-tag preferences are respected 245 - 246 - **decisions**: 247 - - pure collaborative filtering in v1; CLAP-embedding rerank deferred (obvious follow-up: take top ~200 grain-scored candidates, rerank by distance to centroid of user's seed embeddings — helps long-tail and cold-start) 248 - - per-request score recomputation, no Redis cache — means pagination may drift slightly under heavy engagement churn, acceptable for v1 249 - - differential edge weights (e.g. playlist-add = 2.0, comment = 1.5, like = 1.0) deferred until there's something to A/B against 250 - - play signals as edges deferred — likes are sparse, plays are dense, needs its own experiment 251 - 252 - --- 253 - 254 - #### top tracks time-range toggle + like count fix (PRs #1228-1230, Apr 3) 255 - 256 - **why**: the homepage "top tracks" section showed all-time most-liked tracks with no way to see recent trends. separately, the artist leaderboard rank feature (play-count-based) rewarded volume uploaders and self-listeners over genuine community engagement — shipped and pulled in the same session. 257 - 258 - **what shipped**: 259 - - **period toggle** (#1228): cycling label on "top tracks" heading — "all time" → "past month" → "past week" → "past day" — filters which tracks appear by when their likes were created. selection persists in localStorage. backend `GET /tracks/top?period=month` accepts `all_time|month|week|day`, maps to `since` cutoff on `TrackLike.created_at` (indexed column) 260 - - **like count fix** (#1230): the period filter was reusing the time-scoped like count for display — filtering to "past day" showed "1 like" even if a track had many total likes. now uses period-filtered counts only for ordering/inclusion, fetches all-time totals separately for the displayed `like_count` 261 - - **rank card pulled** (#1229): artist leaderboard rank (top 10 by total plays) shipped in #1228 but immediately pulled from the frontend — the #1 ranked artist was an archiver uploading Internet Archive content and listening to it himself, not a community-recognized artist. backend infrastructure (`rank` field in analytics response, Redis-cached leaderboard) remains intact for re-enabling with better criteria (likely likes-based) 262 - 263 - **decisions**: 264 - - play count is a poor ranking signal — it rewards self-listening and volume. likes are better but still raise questions about what "top artist" means on a platform hosting archives, podcasts, ASMR, etc. 265 - - kept the backend rank infrastructure to avoid throwaway work — the leaderboard query and caching are correct, just need a better ranking formula 266 - 267 - --- 268 - 269 - #### browser observability + now-playing flood fix (PRs #1224-1225, Apr 2-3) 270 - 271 - **why**: a login redirect failure had zero frontend traces to debug — backend spans showed success, but something broke between the 303 redirect and the frontend. separately, a single user's client hammered `POST /now-playing/` every 5 seconds for an hour (2,758 requests), driving p95 latency to 2.9s and max to 13.6s across the entire API. zero 5xx errors, but the app felt down for everyone. 272 - 273 - **what shipped**: 274 - - **browser observability** (#1224): `@pydantic/logfire-browser` SDK auto-instruments fetch, document-load, user-interaction, and XHR. telemetry proxied through `POST /logfire-proxy/{path:path}` on the backend (via `logfire.experimental.forwarding.logfire_proxy`) so the write token stays server-side. `traceparent` headers propagate to the API for distributed tracing — a single trace now spans browser → API → database. service name `plyr-web` distinguishes from backend's `plyr-api` in Logfire 275 - - **now-playing throttle fix** (#1225): the frontend's `progressBucket` rounded to 5 seconds but the throttle interval was 10 seconds — the state fingerprint changed mid-throttle, bypassing the "skip if unchanged" check and firing reports every 5s instead of 10s. aligned bucket granularity to match `REPORT_INTERVAL_MS` (10s). backend: replaced `@limiter.exempt` with `30/minute` rate limit as a server-side safety net (normal playback is 6/min, generous headroom for rapid play/pause/seek) 276 - 277 - **incident timeline** (2026-04-02 23:17–23:40 UTC): 278 - - 23:17: traffic spikes to 1,624 requests/minute (10x normal), p95 = 1.6s 279 - - 23:18: 458 requests, p95 = 2.9s, max = 3.0s 280 - - 23:22: second spike, max latency hits 13.6s 281 - - 23:38: third spike, 1,945 requests/minute, max = 7.8s 282 - - 00:00: traffic returns to normal (~30 requests/minute) 283 - - root cause: joebasser.com's client firing `POST /now-playing/` every 5s for ~1 hour 284 - 285 - --- 286 - 287 - #### album AT-URI resolution + search modal polish (PRs #1222-1223, Apr 2) 288 - 289 - **what shipped**: 290 - - **album AT-URI fix** (#1223): the `/at/[...uri]` catch-all route only resolved tracks and playlists. album AT-URIs (`fm.plyr.album`) returned 404. refactored the route to use a generic list resolver that handles both playlists and albums through the existing `/lists/*/by-uri` endpoints. added regression tests 291 - - **search modal** (#1222): centered vertically in viewport, enhanced glass effect 292 - 293 - --- 294 - 295 - #### homepage tag filtering + backend performance (PRs #1216-1220, Apr 2) 296 - 297 - **why**: the homepage had no way to positively filter tracks by genre. you could hide tags (negative filter) but not say "show me electronic and ambient." the dedicated `/tag/[name]` page only supports one tag and navigates away from the homepage. separately, the `GET /tracks/` endpoint was 250-1200ms for authenticated users due to an uncached external HTTP call to atprotofans.com for supporter validation on every single request. 298 - 299 - **what shipped**: 300 - - **tag filter chips**: horizontal scrollable row of popular tag chips below "latest tracks." multi-select with OR logic (tracks matching any selected tag). per-tag hue colors via deterministic hash. selected tags sort to the left. selection persists in localStorage across page refreshes 301 - - **backend `tags` query param**: `GET /tracks/?tags=electronic&tags=ambient` filters to tracks matching any selected tag. composes with hidden tag filtering. skips Redis cache when tags are active 302 - - **tag ranking by plays**: tags are now ordered by total play count (sum of `play_count` across all tracks with that tag) instead of just track count. surfaces genres with actual listener engagement 303 - - **atprotofans Redis cache**: supporter validation results cached in Redis with 5-min TTL per (supporter, artist) pair. eliminates the 80-1100ms external HTTP call on repeat page loads 304 - - **parallelized supporter validation**: `asyncio.create_task()` kicks off the atprotofans call immediately after getting the track list, running concurrently with batch aggregations (~19ms), PDS resolution (~58ms), and image resolution. previously these all ran sequentially 305 - 306 - **performance impact** (authenticated users, production): 307 - - before: 250-1200ms (validate_supporter was 82% of request time on slow calls) 308 - - after (cache miss): ~170ms (validate_supporter runs in parallel with other work) 309 - - after (cache hit): ~170ms (no external call at all) 310 - 311 - --- 312 - 313 - #### Fly HTTP health checks + outage retro (PR #1214, Apr 2) 314 - 315 - **why**: production outage (~6 min, 02:34-02:40 UTC Apr 2). after a deploy, Fly auto-stopped one machine for low traffic. the remaining machine became unresponsive but Fly had no health checks configured — it kept reporting the machine as "started" while it served nothing. when a replacement machine finally started, it was OOM killed (1GB exhausted in 20 seconds from queued request burst). manual `fly machine restart` was required to recover. 316 - 317 - **what shipped**: 318 - - added `[[http_service.checks]]` to both `fly.toml` and `fly.staging.toml` — Fly now polls `GET /health` every 10s with a 5s timeout and 30s startup grace period 319 - - Fly will auto-restart machines that stop responding, eliminating the class of outage where a frozen process goes undetected 320 - 321 - **what we learned**: 322 - - the Dec 2025 database pool fixes (pool_size=10, max_overflow=5, connection_timeout=10s) were already in place — initial analysis incorrectly blamed stale recommendations 323 - - the queue listener is already decoupled from the app lifecycle (`asyncio.create_task`, catches all exceptions) — it was not the cause of the process death 324 - - what actually froze the remaining machine is still unknown (no Logfire output for 6 minutes while Fly reported it as "started"). possible causes: memory pressure on 1GB VM, blocked event loop, or a hard crash whose Fly event was truncated from the 5-event log 325 - - full retro: `sandbox/retrospectives/2026-04-02-deploy-outage-oom-kill.md` 326 - 327 - --- 328 - 329 - #### AT-URI top-level route resolution (PRs #1206, #1208, Apr 1) 330 - 331 - **why**: every atproto app should support AT-URIs as top-level routes ([streamplace/streamplace#1012](https://github.com/streamplace/streamplace/issues/1012)). this lets anyone paste `https://plyr.fm/at://did:plc:xxx/fm.plyr.track/rkey` and land on the right page. 332 - 333 - **what shipped**: 334 - - new SvelteKit catch-all route at `/at/[...uri]` that parses AT-URIs via `AtUri` from `@atproto/api`, resolves them against existing backend `/tracks/by-uri` and `/lists/playlists/by-uri` endpoints, and 301 redirects to the canonical page (`/track/{id}`, `/playlist/{id}`) 335 - - handles browser normalization of `://` in URL paths 336 - - follow-up (#1208): replaced 7 instances of manual `.split("/")` AT-URI parsing across backend and frontend with proper library utilities (`parse_at_uri()` wrapper in Python, `AtUri` class in TypeScript) 337 - 338 - --- 339 - 340 - #### track detail page: title width + metadata disclosure (PR #1205, Apr 1) 341 - 342 - **why**: long track titles like "better hate (jessica pratt cover)" wrapped onto two lines because `.track-info-wrapper` was capped at `max-width: 600px`. the inline description also added visual weight without user opt-in. 343 - 344 - **what shipped**: 345 - - widened `.track-info-wrapper` from `max-width: 600px` to `min(900px, 90%)` — long titles stay on one line on desktop, 90% prevents edge-touching on narrower viewports 346 - - replaced the inline collapsible description (gradient mask + "show more/less" toggle) with a circled `(i)` icon in the stats row that slides open a metadata panel on click. only renders when `track.description` exists 347 - 348 - --- 349 - 350 - #### WebSocket hardening (PRs #1203-1204, Apr 1) 351 - 352 - **what shipped**: 353 - - **security** (#1203): origin validation rejects WebSocket upgrades from non-allowlisted origins, `session_id` omitted from client-facing messages 354 - - **reliability** (#1204): idle timeout disconnects inactive connections, per-IP rate limiting and connection limits prevent abuse 355 - 356 - --- 357 - 358 - #### Jetstream identity sync + image URL fix (PRs #1200-1202, Mar 31) 359 - 360 - **what shipped**: 361 - - **handle sync** (#1200): Jetstream identity events now trigger handle updates — when an artist changes their handle on Bluesky, plyr.fm picks it up automatically instead of waiting for their next sign-in 362 - - **image URL fix** (#1202): R2 storage keys use the original file extension, but the image URL construction wasn't preserving it. images uploaded as `.jpeg` were served with `.jpg` URLs (or vice versa), returning 404s from R2 363 - - **docs homepage** (#1201): pinned track 778 on docs homepage, deduplicated tracks-by-artist in the showcase 184 + See `.status_history/2026-04.md` for April 1-9 entries (album uploads, /record, /for-you, tag filtering, WebSocket hardening, AT-URI routes, health checks, browser observability incident). 364 185 365 186 --- 366 187 ··· 392 213 393 214 ### current focus 394 215 395 - CDN caching is live (#1275-1280) — audio and images served through `audio.plyr.fm` and `images.plyr.fm` custom domains with Cloudflare edge caching (1-year TTL, Smart Tiered Cache). cache hit ratio jumped from 30% to 46% within minutes. backend API decomposed: `lists.py` and `albums.py` split into subpackages, PDS URL healing moved from lazy per-request to proactive jetstream identity events, shared ATProto list helpers extracted. next: `config.py` decomposition (993 lines → split by concern), frontend state module grouping, portal/playlist page decomposition. Leaflet mention service (#1271-1273) and ooo.audio lexicon conversation (#705) continue. waveform rollout to other surfaces still pending as follow-up to #1251. 216 + SDK namespace restructure shipped for plyr-python-client v0.0.1a16 — flat methods → namespace objects (`client.tracks.list()`, `client.playlists.create()`), playlist CRUD (9 methods), cyclopts CLI, proper `TrackRef` identifier types. CDN caching live (#1275-1280) — `audio.plyr.fm` and `images.plyr.fm` custom domains with 1-year edge TTL. feed switcher on homepage (#1282-1286) toggles between latest and for-you. browser telemetry proxy incident resolved (#1288-1289) — synchronous Logfire proxy was saturating the threadpool, backend kill switch added. avatar restore on account reactivation (#1291). next: `config.py` decomposition, frontend state module grouping, waveform rollout to other surfaces, ooo.audio lexicon conversation (#705). 396 217 397 218 ### known issues 398 219 - iOS PWA audio may hang on first play after backgrounding ··· 528 349 529 350 --- 530 351 531 - this is a living document. last updated 2026-04-13 (SDK namespace restructure, playlist support, cyclopts CLI migration). 352 + this is a living document. last updated 2026-04-13 (status maintenance — SDK namespace, CDN caching, feed switcher, telemetry incident). 532 353
update.wav

This is a binary file and will not be displayed.