Cache every played or saved video on the server via background jobs with a validation gate and a Broken re-download button

This commit is contained in:
Jonathan Sykes
2026-09-13 09:45:01 +08:00
parent 292a0c7e29
commit 5fb863ba25
8 changed files with 1240 additions and 20 deletions

View File

@@ -196,8 +196,10 @@ const API = {
expandPlaylist: (url) => WEB
? webFetch(`/api/playlist/expand?url=${encodeURIComponent(url)}`)
: call('yt.expand_playlist', 'yt_expand_playlist', { url }),
getStreams: (videoId) => WEB
? webFetch(`/api/streams?v=${encodeURIComponent(videoId)}`)
// nocache=1 skips the server's cached copy and resolves fresh YouTube
// streams (used when the cached copy won't play on this device).
getStreams: (videoId, { nocache = false } = {}) => WEB
? webFetch(`/api/streams?v=${encodeURIComponent(videoId)}${nocache ? '&nocache=1' : ''}`)
: call('yt.streams', 'yt_streams', { videoId }),
loadData: () => WEB
? Promise.resolve(loadDataFromStorage())
@@ -333,6 +335,7 @@ const els = {
audioOnlyToggle: $('audioOnlyToggle'),
saveBtn: $('saveBtn'),
editBtn: $('editBtn'),
brokenBtn: $('brokenBtn'),
addPlaylistBtn: $('addPlaylistBtn'),
};
@@ -1377,7 +1380,7 @@ const Player = {
return this.mode === 'dual' ? els.audio : this.master;
},
async loadVideo(videoObj, { preferStream = false, resume = true, reveal = true } = {}) {
async loadVideo(videoObj, { preferStream = false, resume = true, reveal = true, nocache = false } = {}) {
// When false (auto-advance / prev), skip resuming the saved timestamp and
// start from the beginning (or the A marker, if an A-B loop is set).
this._resumeOnLoad = resume;
@@ -1449,7 +1452,7 @@ const Player = {
}
}
const res = await API.getStreams(videoObj.id);
const res = await API.getStreams(videoObj.id, { nocache });
if (seq !== this._loadSeq) return; // a newer load took over meanwhile
if (!res || !res.ok) throw new Error(res?.error || 'Could not load streams');
current = res.data;
@@ -1471,7 +1474,7 @@ const Player = {
btn.textContent = '↻ Retry';
btn.addEventListener('click', () => {
btn.remove();
Player.loadVideo(videoObj, { preferStream, resume, reveal });
Player.loadVideo(videoObj, { preferStream, resume, reveal, nocache });
});
els.playerPane.appendChild(btn);
}
@@ -1572,6 +1575,16 @@ const Player = {
this.loadVideo(meta, { preferStream: true, resume: this._resumeOnLoad, reveal: this._revealOnLoad });
return;
}
// The server's cached copy failed on this device — stream from YouTube
// instead. Never auto-reported: a decode failure can be specific to one
// device, so the ⚠ Broken button is how a user flags a bad server copy.
if (current && current.serverCached) {
const meta = current.meta;
current.serverCached = false;
toast('Server copy failed — streaming from YouTube. Tap ⚠ Broken to re-download it.', { duration: 5000 });
this.loadVideo(meta, { preferStream: true, nocache: true, resume: this._resumeOnLoad, reveal: this._revealOnLoad });
return;
}
// Advance to the next candidate stream; give up with a clear message at the end.
if (this.fbIndex < this.fallbackQueue.length - 1) {
this.fbIndex++;
@@ -2027,6 +2040,43 @@ function updatePlayBtn() {
const spBtn = $('servicePlayBtn');
if (spBtn) spBtn.textContent = Player.master.paused ? '▶' : '⏸';
}
// "⚠ Broken": ask the server to drop its cached copy and fetch a fresh one.
// Playback switches to live YouTube streams meanwhile (resuming where it
// was), and a device copy — most likely made from the same bad file — is
// replaced: the re-save waits on the server's refetch job.
const reportingBroken = new Set();
async function reportBroken(video) {
const id = video.id;
if (!id || video.custom || reportingBroken.has(id)) return;
reportingBroken.add(id);
updateNowPlayingActions();
try {
const res = await fetch(`/api/media/${encodeURIComponent(id)}/redownload`, { method: 'POST' });
const j = await res.json().catch(() => ({}));
if (!res.ok || !j.ok) throw new Error(j.error || `HTTP ${res.status}`);
toast('Re-downloading on the server…');
const hadLocal = cachedIds.has(id);
if (hadLocal) {
try { await API.cacheDelete(id); } catch { /* best-effort */ }
cachedIds.delete(id);
markCardCacheState(id, 'none');
}
if (current && current.meta && current.meta.id === id) {
const at = Player.master ? Player.master.currentTime || 0 : 0;
await Player.loadVideo(current.meta, { preferStream: true, nocache: true, resume: false, reveal: false });
if (at > 1 && current && current.meta && current.meta.id === id) {
Player.master.addEventListener('loadedmetadata', () => Player.seek(at), { once: true });
}
}
if (hadLocal) preload(video);
} catch (err) {
toast('⚠ Could not report: ' + err.message);
} finally {
reportingBroken.delete(id);
updateNowPlayingActions();
}
}
// Reflect cache state on the now-playing Save button.
function updateNowPlayingActions() {
if (!current || !current.meta) return;
@@ -2036,6 +2086,12 @@ function updateNowPlayingActions() {
const isCustom = !!current.meta.custom;
if (els.editBtn) els.editBtn.hidden = isCustom;
if (els.saveBtn) els.saveBtn.hidden = isCustom;
// The server cache exists only behind the web PWA backend.
if (els.brokenBtn) {
els.brokenBtn.hidden = isCustom || !WEB;
els.brokenBtn.disabled = reportingBroken.has(id);
els.brokenBtn.textContent = reportingBroken.has(id) ? '⏳ Reporting…' : '⚠ Broken';
}
const btn = els.saveBtn;
if (!btn) return;
if (removing.has(id)) {
@@ -4501,6 +4557,18 @@ document.querySelectorAll('.chip').forEach((c) => {
if (els.editBtn) els.editBtn.addEventListener('click', () => {
if (current && current.meta) openVideoEditor(current.meta);
});
if (els.brokenBtn) els.brokenBtn.addEventListener('click', () => {
if (!current || !current.meta || current.meta.custom) return;
const video = { ...current.meta };
const body = document.createElement('p');
body.textContent = `Re-download “${video.title || video.id}” on the server? Its saved copy is deleted `
+ 'and fetched again from YouTube; playback continues from YouTube meanwhile'
+ (cachedIds.has(video.id) ? ', and this device’s offline copy is replaced with the fresh one.' : '.');
showModal('Video broken?', body, [
{ label: 'Cancel', onClick: closeModal },
{ label: 'Re-download', primary: true, onClick: () => { closeModal(); reportBroken(video); } },
]);
});
els.queueBtn.addEventListener('click', () => {
if (current && current.meta) addToQueue(current.meta);
});

View File

@@ -202,6 +202,7 @@
<button id="saveBtn" class="np-btn" title="Save this video for offline playback">⬇ Save</button>
<button id="editBtn" class="np-btn" title="Cut parts out and save a custom edited copy">✂ Edit &amp; download</button>
<button id="addPlaylistBtn" class="np-btn" title="Add to a playlist">+ Playlist</button>
<button id="brokenBtn" class="np-btn" title="Video won't play or looks broken? Re-download the server's copy" hidden>⚠ Broken</button>
</div>
</div>