From a20dd0a3a6e86833d58eb97cbac01e0705149381 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Fri, 18 Sep 2026 10:11:20 +0800 Subject: [PATCH] Replace saved copies that fail to play, reject truncated saves, and re-check the device cache when the app returns to the foreground --- frontend/app.js | 106 ++++++++++++++++++++++++++++++++++++++-- frontend/opfs-worker.js | 6 +++ frontend/opfs.js | 36 +++++++++++++- 3 files changed, 141 insertions(+), 7 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index 43638d3..c798fb8 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -999,12 +999,71 @@ function fmtBytes(n) { // ============================================================================ // Offline cache (preload) // ============================================================================ +// An empty file (a save that never got its bytes) is not a saved video. +function isUsableCacheItem(it) { + return !!(it && it.id) && !(typeof it.size === 'number' && it.size <= 0); +} + async function refreshCachedIds() { - cachedIds.clear(); + const next = new Set(); try { const res = await API.cacheList(); - if (res && res.ok) for (const it of res.items || []) cachedIds.add(it.id); + if (res && res.ok) for (const it of res.items || []) if (isUsableCacheItem(it)) next.add(it.id); } catch { /* shell without cache support — leave empty */ } + cachedIds.clear(); + for (const id of next) cachedIds.add(id); +} + +// The badges trust cachedIds, which is otherwise only read from storage at +// launch — a PWA resumed from the background would keep showing videos the +// browser has since evicted (iOS does this under storage pressure). Re-read +// the device store when the app returns to the foreground and repaint only +// what changed. Bumped on every add so a save landing mid-listing is never +// undone by the older listing. +let cacheMutations = 0; +let cacheResyncRunning = false; +async function resyncCachedIds() { + if (!WEB || !window.OPFS || !window.OPFS.isSupported() || cacheResyncRunning) return; + cacheResyncRunning = true; + try { + const before = cacheMutations; + let items; + try { items = await window.OPFS.listVideos({ strict: true }); } catch { return; } + if (before !== cacheMutations) return; // a save landed meanwhile — next time + const listed = new Set(items.filter(isUsableCacheItem).map((it) => it.id)); + const changed = []; + for (const id of [...cachedIds]) { + if (!listed.has(id)) { cachedIds.delete(id); changed.push(id); } + } + for (const id of listed) { + if (!cachedIds.has(id) && !downloading.has(id)) { cachedIds.add(id); changed.push(id); } + } + if (!changed.length) return; + for (const id of changed) markCardCacheState(id, cachedIds.has(id) ? 'cached' : 'none'); + renderSidebar(); + if (view.type === 'playlist') { + const pl = data.playlists.find((p) => p.id === view.id); + const offlineBtn = $('plOfflineBtn'); + if (offlineBtn && pl) updatePlaylistOfflineButton(offlineBtn, pl); + } else if (view.type === 'settings' || view.type === 'downloads' || view.type === 'saved') { + renderList(); + } + } finally { + cacheResyncRunning = false; + } +} + +function startCacheResyncWatch() { + if (!WEB) return; + let last = Date.now(); + const maybe = () => { + if (document.visibilityState !== 'visible') return; + if (Date.now() - last < 5000) return; + last = Date.now(); + resyncCachedIds(); + }; + document.addEventListener('visibilitychange', maybe); + window.addEventListener('pageshow', maybe); } // ---------- Offline thumbnail warm-up ---------- @@ -1067,6 +1126,7 @@ async function preload(video, { quiet = false, mux = false } = {}) { const res = await API.cacheDownload(id, { mux }); if (res && res.ok && res.cached) { cachedIds.add(id); + cacheMutations++; warmThumb(thumbUrlFor(id, video)); if (!quiet) toast(`Saved “${video.title}” ✓`); } else if (!quiet) { @@ -1090,6 +1150,36 @@ async function preload(video, { quiet = false, mux = false } = {}) { } } +// A device copy that failed to play. Left in place it stays badged "cached" +// and fails on every tap, so drop it and save a fresh one in the background. +// An HEVC copy means canPlayType over-promised: stop asking for HEVC first so +// the re-save comes back as H.264. Edited cuts exist only on the device (no +// source to re-save from), so they are never deleted. One re-save per video +// per session keeps a copy that is bad for another reason from looping. +const resavedAfterFailure = new Set(); +async function dropBrokenLocalCopy(meta) { + const id = meta && meta.id; + if (!id || meta.custom || !cachedIds.has(id)) return; + try { + if (WEB && window.OPFS && typeof window.OPFS.sniffCodec === 'function' && + (await window.OPFS.sniffCodec(id)) === 'hevc') { + try { localStorage.setItem(NO_HEVC_KEY, '1'); } catch { /* storage blocked */ } + } + } catch { /* sniffing is best-effort */ } + try { await API.cacheDelete(id); } catch { /* best-effort */ } + cachedIds.delete(id); + markCardCacheState(id, 'none'); + renderSidebar(); + if (view.type === 'playlist') { + const pl = data.playlists.find((p) => p.id === view.id); + const offlineBtn = $('plOfflineBtn'); + if (offlineBtn && pl) updatePlaylistOfflineButton(offlineBtn, pl); + } + if (resavedAfterFailure.has(id)) return; + resavedAfterFailure.add(id); + preload(meta, { quiet: true }); +} + // Sidebar badge showing how many downloads are in flight. function updateDownloadBadge() { const n = downloading.size; @@ -1179,6 +1269,7 @@ async function downloadEdited(customVideo) { const res = await API.cacheDownloadEdited(id, customVideo.sourceId, customVideo.keep); if (res && res.ok && res.cached) { cachedIds.add(id); + cacheMutations++; // An edited copy has no art of its own — warm its source's thumbnail, // which is what the Saved list falls back to. warmThumb(thumbUrlFor(customVideo.sourceId, videoById(customVideo.sourceId))); @@ -1613,12 +1704,16 @@ const Player = { this.fallbackQueue = queue; this.fbIndex = 0; }, - onMediaError() { + onMediaError(el) { // A cached file failed to play — fall back to live streaming. if (current && current.localUrl) { + // An error from the PREVIOUS track's blob (revoked by the load that + // replaced it) must not condemn the new track's good copy. + if (el && el.src && el.src !== current.localUrl) return; const meta = current.meta; current.localUrl = null; - toast('Cached copy unavailable — streaming instead…'); + dropBrokenLocalCopy(meta); + toast('Saved copy won’t play here — streaming and re-saving it…', { duration: 4000 }); // Preserve the resume intent of the load that just failed. this.loadVideo(meta, { preferStream: true, resume: this._resumeOnLoad, reveal: this._revealOnLoad }); return; @@ -2080,7 +2175,7 @@ function wirePlayerEvents() { el.addEventListener('error', () => { // A failed master stream, or a failed synced-audio track in dual mode, // both warrant falling back to the next candidate. - if (masterIs(el) || el === Player.secondary) Player.onMediaError(); + if (masterIs(el) || el === Player.secondary) Player.onMediaError(el); }); } bind(V); @@ -5400,6 +5495,7 @@ async function boot() { // Learn what's already cached, then top up any playlist videos that aren't. await refreshCachedIds(); + startCacheResyncWatch(); data.playlists.forEach(preloadPlaylist); preloadPinnedPlaylists(); // Not awaited — artwork backfill must never delay first paint. diff --git a/frontend/opfs-worker.js b/frontend/opfs-worker.js index 99162d4..6ec24b0 100644 --- a/frontend/opfs-worker.js +++ b/frontend/opfs-worker.js @@ -75,6 +75,12 @@ self.onmessage = async (e) => { } finally { access.close(); } + // A connection that ends early can close the body cleanly; never commit + // a truncated file, it would be badged "cached" yet fail to play. + const expected = Number(res.headers.get('content-length')); + if (expected > 0 && offset !== expected) { + throw new Error(`download cut short (${offset} of ${expected} bytes)`); + } // Finalize: .part → permanent name. Prefer the native rename, but treat // ANY move() failure as "unavailable" and fall back to a chunked copy — diff --git a/frontend/opfs.js b/frontend/opfs.js index 9c910e0..341cc9c 100644 --- a/frontend/opfs.js +++ b/frontend/opfs.js @@ -85,6 +85,29 @@ }, // Revoke an object URL previously returned by getFileUrl(). + // 'hevc' | 'avc' | null for a stored mp4. Server copies are faststart, so + // the sample description (hvc1/hev1 vs avc1) sits in the first megabyte. + async sniffCodec(videoId) { + try { + const found = await findHandle(videoId); + if (!found) return null; + const file = await found[0].getFile(); + const head = new Uint8Array(await file.slice(0, 1024 * 1024).arrayBuffer()); + const has = (tag) => { + const [a, b, c, d] = [...tag].map((ch) => ch.charCodeAt(0)); + for (let i = 0; i + 3 < head.length; i++) { + if (head[i] === a && head[i + 1] === b && head[i + 2] === c && head[i + 3] === d) return true; + } + return false; + }; + if (has('hvc1') || has('hev1')) return 'hevc'; + if (has('avc1')) return 'avc'; + return null; + } catch { + return null; + } + }, + revokeUrl(url) { if (url && _blobUrls.has(url)) { URL.revokeObjectURL(url); @@ -153,6 +176,13 @@ await writable.abort(); throw err; } + // A connection that ends early can close the body cleanly, leaving + // a truncated file that would be badged "cached" yet never play. + const expected = Number(response.headers.get('content-length')); + if (expected > 0) { + const got = (await tmpHandle.getFile()).size; + if (got !== expected) throw new Error(`download cut short (${got} of ${expected} bytes)`); + } } else { // No createWritable on this browser — main-thread OPFS writes are // impossible (sync access handles are worker-only). Surface a real @@ -187,7 +217,9 @@ }, // List all cached videos — returns [{ id, size, name }] - async listVideos() { + // strict: rethrow instead of returning a partial list, for callers that + // treat a missing entry as "the file is gone". + async listVideos({ strict = false } = {}) { const dir = await getRoot(); const items = []; try { @@ -200,7 +232,7 @@ const file = await handle.getFile(); items.push({ id, name, size: file.size }); } - } catch { /* OPFS not available */ } + } catch (err) { if (strict) throw err; /* OPFS not available */ } return items; },