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..7b2594a 100644 --- a/api/web/index.html +++ b/api/web/index.html @@ -4048,18 +4048,53 @@ 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 => { - 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; @@ -5573,14 +5608,44 @@ // 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 && /[?&]_=/.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 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); }, delayMs); } @@ -5614,7 +5679,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') },