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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user