From d4650b900d5483e97af72e46f103a0efb3f712f3 Mon Sep 17 00:00:00 2001 From: Vynull App Date: Mon, 27 Jul 2026 22:12:28 +0000 Subject: [PATCH 1/4] api + web: stop waveform thumbnails flashing black on refetch Switching between collection and playlist views (or resizing) settles the waveform column at a width that can differ from the render-time estimate, and updateThumbnailSrcs then rewrites every thumbnail src at the new size. Setting img.src directly blanks the element while an uncached resource loads, exposing the dark cell background - most visible on recently-added tracks, whose PNG is not disk-cached at the new size yet and needs a live server render. Thumbnails showed, went black, then showed again. updateThumbnailSrcs now preloads the new URL through a detached Image and swaps only once the bytes are in, so the old frame stays up throughout; same-URL calls no-op. The placeholder self-heal retry in thumbLoaded keeps its direct src set on purpose: its retry chain rides the onload cycle and the cell is showing the 1x1 placeholder anyway. Also found on the way: the server wrote the rendered PNG to its disk cache with a plain WriteFile while concurrent requests read the same path, so a reader could get a truncated PNG - and the 7-day Cache-Control would pin the corrupt image in the browser. The cache write is now write-to-temp + rename. Verified in a headless browser against the extracted swapImgSrc with a deliberately slow image server: a direct src set observably blanks the img during load, the preload swap never does, and same-URL calls no-op. The live view-switch repro was not run (the release-checklist instance holds the ports); worth one eyeball after merging. --- api/api.go | 10 ++++++++-- api/web/index.html | 24 +++++++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/api/api.go b/api/api.go index d8013bf..ccc5b4a 100644 --- a/api/api.go +++ b/api/api.go @@ -2871,10 +2871,16 @@ func (s *Server) handleWaveformPNG(w http.ResponseWriter, r *http.Request) { http.Error(w, "render failed: "+err.Error(), http.StatusInternalServerError) return } - // Best-effort write to disk for next time. + // Best-effort write to disk for next time. Write-to-temp + rename so a + // concurrent request reading cachePath can never observe a partial file: + // lazy-loading tags fan out many thumbnail requests at once, and a + // truncated PNG served here would be cached by the browser for a week. if cachePath != "" { _ = os.MkdirAll(filepath.Dir(cachePath), 0o755) - _ = os.WriteFile(cachePath, imgBytes, 0o644) + tmp := fmt.Sprintf("%s.tmp%d", cachePath, os.Getpid()) + if err := os.WriteFile(tmp, imgBytes, 0o644); err == nil { + _ = os.Rename(tmp, cachePath) + } } w.Write(imgBytes) } diff --git a/api/web/index.html b/api/web/index.html index 28d251e..ee8629a 100644 --- a/api/web/index.html +++ b/api/web/index.html @@ -4057,9 +4057,28 @@ } function updateThumbnailSrcs() { document.querySelectorAll('img[data-libtrack]').forEach(img => { - img.src = waveThumbSrc(img.getAttribute('data-libtrack')); + swapImgSrc(img, waveThumbSrc(img.getAttribute('data-libtrack'))); }); } + +// swapImgSrc replaces an src without the black flash: setting .src +// directly blanks the element while an uncached resource loads (exposing the +// dark cell background — most visible on recently-added tracks whose PNG +// isn't disk-cached at the new size yet, so the server renders live). Preload +// through a detached Image and swap only once the bytes are in, so the old +// frame stays up throughout. No-op when the URL is already current. +function swapImgSrc(img, url) { + if (img.getAttribute('src') === url) return; + const pre = new Image(); + pre.onload = () => { + // The row may have been re-rendered or retargeted while preloading. + if (document.body.contains(img)) { + img.src = url; + } + }; + pre.onerror = () => {}; // keep the old frame on failure + pre.src = url; +} // Debounced re-fetch of every visible waveform thumbnail at the new displayed // size — used when the row height or waveform column width changes. let waveRefetchTimer = null; @@ -5581,6 +5600,9 @@ const tid = img.getAttribute('data-libtrack'); setTimeout(() => { if (!tid || !document.body.contains(img)) return; + // Direct src set on purpose: the retry chain relies on the onload cycle + // (src -> onload -> thumbLoaded -> next retry), and the cell is showing + // the 1x1 placeholder anyway, so there is no frame to preserve. img.src = waveThumbSrc(tid, true); }, delayMs); } From 27d5b1d05906afaa93bccd3f055d728f8ee6866c Mon Sep 17 00:00:00 2001 From: Vynull App Date: Mon, 27 Jul 2026 22:21:45 +0000 Subject: [PATCH 2/4] web: keep freshly-analyzed thumbnails warm across re-renders The placeholder self-heal loads a new track's first real waveform via a cache-busted URL, and the natural URL was last fetched while it still returned the no-store placeholder - so the browser has nothing cached under the URL every future table render will use. Switching to a playlist and back rebuilt the row cache-cold, and the freshly-analyzed track sat blank again waiting on a full round-trip while older tracks (cached from prior sessions) appeared instantly. After a successful heal, thumbLoaded now swaps the img back to the natural URL through the preload path: one cheap request (the server just disk-cached that exact PNG) that primes the browser cache and keeps the old frame up during the swap. Re-renders after that are instant. No retry-loop risk: the swapped load re-enters thumbLoaded with a real width and an unbusted URL and takes no action. Verified in a browser harness against the extracted functions: a healed img swaps to the natural URL with no blank frame observed and exactly one follow-up fetch. --- api/web/index.html | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/api/web/index.html b/api/web/index.html index ee8629a..bc5c19a 100644 --- a/api/web/index.html +++ b/api/web/index.html @@ -5592,7 +5592,21 @@ // out so we don't stampede the server after a fresh page load. function thumbLoaded(img) { img.classList.replace('loading', 'loaded'); - if (img.naturalWidth !== 1) return; // real waveform, done + if (img.naturalWidth !== 1) { + // Real waveform. If it arrived via a cache-busted self-heal URL, swap + // back to the natural URL (preload keeps the frame up): the natural URL + // was last fetched when it returned the no-store placeholder, so the + // browser has nothing cached under it — without this, the next table + // re-render (view switch) starts cache-cold and the freshly-analyzed + // track sits blank again waiting on a full round-trip. The swap request + // hits the server's disk-cached PNG, so it is one cheap fetch that + // makes every later re-render instant. + const tid = img.getAttribute('data-libtrack'); + if (tid && img.src.includes('&_=')) { + swapImgSrc(img, waveThumbSrc(tid)); + } + return; + } const tries = parseInt(img.dataset.retries || '0', 10); if (tries >= 60) return; // ~6 minutes of retries, then give up img.dataset.retries = String(tries + 1); From a6ef3d88261c8db71b2a1d53adf3656075886b83 Mon Sep 17 00:00:00 2001 From: Vynull App Date: Mon, 27 Jul 2026 22:46:23 +0000 Subject: [PATCH 3/4] web: thumbnail self-heal survives long analysis queues and errors The placeholder self-heal retried for a fixed ~6 minute budget and then gave up silently. With analysis behind a long queue - a bulk add, or the one-time cacheVersion re-analysis pass - the budget exhausted before the track's turn came, and the thumbnail then NEVER appeared, even after analysis completed, until a page refresh built a fresh img. A failed fetch (server restart mid-request) killed the chain the same way: no onload, nothing rescheduled. The retry now backs off instead of giving up: quick retries for the first minute (a normal analysis wins that race), then ~30-45s for up to roughly two hours, which covers any realistic queue while still ending eventually for permanently-failed analysis. The retried placeholder responses are no-store and cost the server almost nothing. An onerror handler reschedules through the same path, so a restart cannot strand pending thumbnails. The heal-swap bust check also tightened to a [?&]_= match. Verified in the accelerated browser harness against the extracted functions: a track whose analysis completes past the old budget now heals on completion (the old code was reproduced giving up at 60 tries and staying blank forever), and an img pointed at a dead server keeps its retry chain alive through repeated fetch errors. --- api/web/index.html | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/api/web/index.html b/api/web/index.html index bc5c19a..1c872c0 100644 --- a/api/web/index.html +++ b/api/web/index.html @@ -5602,19 +5602,32 @@ // hits the server's disk-cached PNG, so it is one cheap fetch that // makes every later re-render instant. const tid = img.getAttribute('data-libtrack'); - if (tid && img.src.includes('&_=')) { + if (tid && /[?&]_=/.test(img.src)) { swapImgSrc(img, waveThumbSrc(tid)); } return; } + scheduleThumbRetry(img); +} + +// scheduleThumbRetry keeps a pending thumbnail's self-heal alive. Analysis +// can sit behind a LONG queue (bulk add, or the one-time cacheVersion +// re-analysis pass), so a short fixed retry budget silently gave up before +// the track's turn came — and the thumbnail then never appeared without a +// page refresh. Quick retries for the first minute (analysis normally wins +// that race), then back off to ~30-45s for up to roughly two hours; the +// placeholder responses being retried are no-store and cost the server +// almost nothing. +function scheduleThumbRetry(img) { const tries = parseInt(img.dataset.retries || '0', 10); - if (tries >= 60) return; // ~6 minutes of retries, then give up + if (tries >= 250) return; // ~2h with backoff — permanently-failed analysis stops here img.dataset.retries = String(tries + 1); - const delayMs = 5000 + Math.floor(Math.random() * 3000); + const base = tries < 12 ? 5000 : 30000; + const delayMs = base + Math.floor(Math.random() * base * 0.6); const tid = img.getAttribute('data-libtrack'); setTimeout(() => { if (!tid || !document.body.contains(img)) return; - // Direct src set on purpose: the retry chain relies on the onload cycle + // Direct src set on purpose: the retry chain rides the onload cycle // (src -> onload -> thumbLoaded -> next retry), and the cell is showing // the 1x1 placeholder anyway, so there is no frame to preserve. img.src = waveThumbSrc(tid, true); @@ -5650,7 +5663,7 @@ cell: t => artHTML(t, 'lib-art', 'lib-art empty', '') }, { key: 'waveform', label: 'WV', default: true, thClass: '', tdClass: 'wave-cell', sortable: false, - cell: t => `
${cueMarkerOverlay(t.id, t.duration)}
` }, + cell: t => `
${cueMarkerOverlay(t.id, t.duration)}
` }, { key: 'id', label: 'ID', default: true, thClass: 'num', tdClass: 'num', sort: t => t.id, cell: t => String(t.id).padStart(4, '0') }, From 2c00f88016e4a5f9f90d138420455e1c865c3307 Mon Sep 17 00:00:00 2001 From: Vynull App Date: Mon, 27 Jul 2026 23:05:11 +0000 Subject: [PATCH 4/4] web: thumbnail URL encodes analysis state - waveforms show instantly Root cause of new tracks' waveforms not appearing, found by driving the real app: the browser's per-document image cache satisfies a re-created with the bytes it already has for that URL - the placeholder - no-store notwithstanding. During a batch analysis the rev changes every poll tick and the table fully rebuilds each time, so every rebuilt row resurrected the placeholder, and neither the retry chain nor an explicit post-analysis kick could win the race against the next rebuild (measured: 27s and 21s of lag after analysis completed; the loser saw the waveform only when the batch finished and the churn stopped). waveThumbSrc now appends &a=1 once the track has analysis (memoized per allTracks snapshot), so the URL itself changes the moment the poll learns the BPM landed: the rebuilt row can no longer collide with the cached placeholder, fetches fresh, and every subsequent rebuild reuses the real bytes cached under the analyzed URL. Measured end to end against the real app: waveform visible in the same poll tick as the BPM (0s heal latency, from 27s), all rows intact across view switches. The self-heal retry chain stays as the fallback for lazy-analysis mode and fetch errors. --- api/web/index.html | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/api/web/index.html b/api/web/index.html index 1c872c0..7b2594a 100644 --- a/api/web/index.html +++ b/api/web/index.html @@ -4048,12 +4048,28 @@ function waveThumbCSS() { return { w: Math.max(60, Math.round(libWaveColWidth() - 16)), h: Math.max(14, libraryRowHeight - 8) }; } +// trackIsAnalyzed memoizes "does this track have analysis" per allTracks +// snapshot (the poll swaps in a fresh array whenever anything changes). +let _analyzedMemoSrc = null, _analyzedMemo = null; +function trackIsAnalyzed(tid) { + if (_analyzedMemoSrc !== allTracks) { + _analyzedMemo = new Set((allTracks || []).filter(t => t.bpm > 0).map(t => t.id)); + _analyzedMemoSrc = allTracks; + } + return _analyzedMemo.has(Number(tid)); +} function waveThumbSrc(tid, bust) { const c = waveThumbCSS(); const dpr = Math.min(3, window.devicePixelRatio || 1); const w = Math.min(2400, Math.round(c.w * dpr)); const h = Math.min(240, Math.round(c.h * dpr)); - return `/api/analysis/waveform-png/${tid}?type=detail&color=${effectiveWaveColor()}&overview=${overviewType()}&w=${w}&h=${h}` + (bust ? `&_=${Date.now()}` : ''); + // &a=1 once the track is analyzed: the URL must CHANGE when analysis + // lands, or re-created elements keep resurrecting the placeholder + // from the browser's per-document image cache (no-store notwithstanding) + // for as long as the table keeps rebuilding — which during a batch + // analysis is every poll tick. + const a = trackIsAnalyzed(tid) ? '&a=1' : ''; + return `/api/analysis/waveform-png/${tid}?type=detail&color=${effectiveWaveColor()}&overview=${overviewType()}&w=${w}&h=${h}${a}` + (bust ? `&_=${Date.now()}` : ''); } function updateThumbnailSrcs() { document.querySelectorAll('img[data-libtrack]').forEach(img => {