# Client Cache Correctness: Revoke Blob URLs, LRU Pruning, Real `streamOnly` Priority: **Medium** · Effort: **Medium** · Risk: **Low** ## Problem Three independent correctness/efficiency bugs in the client caching layer: 1. **Blob URL leak.** `URL.createObjectURL(blob)` results stored in `M.trackBlobs` (`public/audioCache.js:80,127`) are never revoked. `M.trackBlobs.clear()` (`queue.js:188`) drops the references but the URLs stay live in the document until unload — a slow memory leak over long sessions with many cached tracks. 2. **`pruneCache` is expensive and non-LRU.** `public/audioCache.js:27-54` calls `TrackStorage.get(key)` for every cached track (pulling each blob out of IndexedDB) just to read `.size`, and evicts oldest-first by Map iteration order — *not* by the `cachedAt` timestamp that is actually written at `public/trackStorage.js:86`. So eviction is arbitrary, not least-recently-used. 3. **`streamOnly` doesn't do what it says.** The flag is stored (`audioCache.js:8-15`) and checked by exactly one context-menu item (`trackContainer.js:752`), but the background prefetch loop (`audioCache.js:226`) never consults it — so "stream-only mode" still fetches every segment in the background and still triggers bulk caching. ## Affected Locations - `public/audioCache.js:27-54` — `pruneCache` - `public/audioCache.js:80,127` — blob URL creation - `public/audioCache.js:224-262` — prefetch loop (no `streamOnly` check) - `public/trackStorage.js:139-155` — `getStats` (loads all blobs) - `public/trackStorage.js:86` — `cachedAt` is written but unused - `public/queue.js:188` — `M.trackBlobs.clear()` without revoke ## Implementation Plan ### Part A — Track and revoke blob URLs **Step A1: Track blob URLs in a single Map with a revocation helper.** `M.trackBlobs` is already `Map`. Add: ```js // public/audioCache.js M.revokeTrackBlob = function (trackId) { const url = M.trackBlobs.get(trackId); if (url) { URL.revokeObjectURL(url); M.trackBlobs.delete(trackId); } }; ``` **Step A2: Revoke on eviction and on explicit clear.** - In `pruneCache` (after Part B), when a track is evicted from IndexedDB, also call `M.revokeTrackBlob(trackId)` so the in-memory URL is released. - In `queue.js:188` (`clearAllCaches`), revoke every entry before clearing: ```js for (const url of M.trackBlobs.values()) URL.revokeObjectURL(url); M.trackBlobs.clear(); ``` - When a track is removed from the library (`track_removed` handler in `channelSync.js`), revoke its blob if present (it can't be played anymore). **Step A3: Don't double-revoke.** `URL.revokeObjectURL` is safe to call on an already-revoked URL, but guard with the Map check anyway to keep state clean. ### Part B — Make pruning LRU and cheap **Step B1: Store size at write time, avoid loading blobs at prune time.** Extend the IndexedDB record to carry `size` (the blob already has `.size` — store it alongside). In `trackStorage.js`: ```js // in set(): store { filename (keyPath), blob, size, cachedAt } const record = { filename: trackId, blob, size: blob.size, cachedAt: Date.now() }; ``` Add a lightweight metadata accessor that uses a cursor and reads only `size`/`cachedAt`, not the blob: ```js // trackStorage.js getMetaList: function () { return new Promise((resolve) => { const result = []; const tx = db.transaction(STORE, "readonly"); const store = tx.objectStore(STORE); const req = store.openCursor(); req.onsuccess = (e) => { const cursor = e.target.result; if (cursor) { const v = cursor.value; result.push({ id: v.filename, size: v.size || 0, cachedAt: v.cachedAt || 0 }); cursor.continue(); } else { resolve(result); } }; req.onerror = () => resolve([]); }); } ``` **Step B2: Rewrite `pruneCache` to be LRU and blob-free.** ```js // audioCache.js async function pruneCache() { const limit = M.cacheLimitBytes ?? 500 * 1024 * 1024; // tune as needed const meta = await TrackStorage.getMetaList(); let total = meta.reduce((s, m) => s + m.size, 0); if (total <= limit) return; // Evict least-recently-used first. meta.sort((a, b) => a.cachedAt - b.cachedAt); for (const m of meta) { if (total <= limit) break; await TrackStorage.delete(m.id); M.revokeTrackBlob(m.id); M.cachedTracks.delete(m.id); total -= m.size; } } ``` **Step B3: Update `cachedAt` on access** (optional, makes LRU reflect actual use). When a cached track is played (`loadTrackBlob` returns a URL), bump its `cachedAt`. If skipped, the policy becomes "first-in-first-out", which is still better than today's arbitrary order. **Step B4: Rewrite `getStats`** to use `getMetaList` so the settings/stats panel doesn't load every blob either. ### Part C — Make `streamOnly` actually disable background fetching **Step C1: Consult the flag in the prefetch loop.** In `audioCache.js:226` (top of the prefetch loop): ```js M.prefetchSegments = async function () { if (prefetching) return; if (M.streamOnly) return; // <-- new prefetching = true; try { // ... existing logic ... } finally { prefetching = false; } }; ``` **Step C2: Also short-circuit the buffered-range-scan-triggered bulk download when in stream-only mode**, since streaming clients don't want IndexedDB writes. Easiest: have `maybeStartBulkDownload` (see `todo/duplicate-download-race/overview.md`) check `M.streamOnly` and return `false`. **Step C3: Decide intent for the already-playing track.** Even in stream-only mode the *current* track is being buffered by the browser's own media element — that's fine and desirable. The flag is about suppressing *additional* segment prefetching ahead of the playhead and *bulk* caching. Confirm the UX label reflects this ("Don't pre-cache ahead" rather than "stream nothing"). ## Validation - **Blob revoke**: open DevTools → Memory, take a snapshot; play and fully cache ~10 tracks; confirm `trackBlobs` grows by 10 and the document's blob URL count grows accordingly. Trigger a prune (or set a tiny `cacheLimitBytes`) and confirm the count drops as tracks are evicted. Run `clearAllCaches()` and confirm all blob URLs are revoked. - **LRU**: with a small cache limit, cache tracks A, B, C; play A again (bump cachedAt if Part B3 is implemented); cache D. Confirm B (not A) is evicted. - **Prune performance**: with a large cache (e.g. 200 tracks), instrument `pruneCache` and confirm it no longer reads blob payloads — wall time should drop from "loads every blob" to a cheap cursor scan. - **streamOnly**: toggle the flag on, play a track, seek around, and confirm via DevTools Network that only the media element's own range requests fire (no `/api/tracks/:id` segment prefetches), and IndexedDB write count stays flat. Toggle off and confirm prefetching resumes. - **Regression**: with streamOnly off, confirm normal caching behavior (green indicators, blob swap, IndexedDB entries) is unchanged. ## Risk / Rollback - **IndexedDB schema change** (adding `size`/`cachedAt` fields to the record): old records written before this change will lack those fields. `getMetaList` must default missing fields to `0` (shown above), and `pruneCache` must handle `size === 0` gracefully (skip eviction of unknown-size entries rather than evicting everything). Consider a one-time migration that deletes records lacking `size`, or simply let them age out. - Revoking a blob URL that is *currently* the `src` of the audio element will break playback. Only revoke blobs for tracks that are not the currently-playing track, or that have just been removed from the library. Add a guard: `if (trackId === M.currentTrackId) return;` in `revokeTrackBlob` callers that run during playback. - streamOnly is additive; default stays off. - Rollback per part is independent — A, B, C can land separately.