Restart failed saves through a guarded server retry
This commit is contained in:
@@ -437,7 +437,7 @@ async function resumableOpfsSave(videoId, qs, hevc, onProgress, signal) {
|
|||||||
return { ok: false, error: 'the server copy kept changing — try again' };
|
return { ok: false, error: 'the server copy kept changing — try again' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function opfsDownload(videoId, { mux = false, onProgress = null, signal } = {}) {
|
async function opfsDownload(videoId, { mux = false, retry = false, onProgress = null, signal } = {}) {
|
||||||
if (!window.OPFS || !window.OPFS.isSupported()) {
|
if (!window.OPFS || !window.OPFS.isSupported()) {
|
||||||
return { ok: false, error: 'OPFS not supported in this browser' };
|
return { ok: false, error: 'OPFS not supported in this browser' };
|
||||||
}
|
}
|
||||||
@@ -452,6 +452,15 @@ async function opfsDownload(videoId, { mux = false, onProgress = null, signal }
|
|||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
const url = `/api/download/${encodeURIComponent(videoId)}${qs ? '?' + qs : ''}`;
|
const url = `/api/download/${encodeURIComponent(videoId)}${qs ? '?' + qs : ''}`;
|
||||||
|
|
||||||
|
if (retry) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/download/${encodeURIComponent(videoId)}/retry`, { method: 'POST', cache: 'no-store', signal });
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !result.ok) return { ok: false, error: result.error || 'Could not restart this save.' };
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, paused: true, error: error.name === 'AbortError' ? 'Save paused' : 'Connection lost' };
|
||||||
|
}
|
||||||
|
}
|
||||||
const resumed = await resumableOpfsSave(videoId, qs, params.get('hevc') === '1', onProgress, signal);
|
const resumed = await resumableOpfsSave(videoId, qs, params.get('hevc') === '1', onProgress, signal);
|
||||||
if (resumed) return resumed;
|
if (resumed) return resumed;
|
||||||
|
|
||||||
@@ -1791,7 +1800,7 @@ function recordDeviceFile(id, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Download a video into the permanent offline cache. Safe to call repeatedly.
|
// Download a video into the permanent offline cache. Safe to call repeatedly.
|
||||||
async function preload(video, { quiet = false, mux = false } = {}) {
|
async function preload(video, { quiet = false, mux = false, retry = false } = {}) {
|
||||||
const id = video.id;
|
const id = video.id;
|
||||||
// Custom (edited) videos have no YouTube source to (re)download — their
|
// Custom (edited) videos have no YouTube source to (re)download — their
|
||||||
// media is produced once by the editor. Never route them through the normal
|
// media is produced once by the editor. Never route them through the normal
|
||||||
@@ -1816,7 +1825,7 @@ async function preload(video, { quiet = false, mux = false } = {}) {
|
|||||||
await SaveSlots.acquire(controller.signal);
|
await SaveSlots.acquire(controller.signal);
|
||||||
slot = true;
|
slot = true;
|
||||||
SaveQueue.progress(id, null);
|
SaveQueue.progress(id, null);
|
||||||
const res = await API.cacheDownload(id, { mux, signal: controller.signal, onProgress: (p) => SaveQueue.progress(id, p) });
|
const res = await API.cacheDownload(id, { mux, retry, signal: controller.signal, onProgress: (p) => SaveQueue.progress(id, p) });
|
||||||
if (res && res.paused) {
|
if (res && res.paused) {
|
||||||
// The partial stays on the device; SaveQueue picks it up again.
|
// The partial stays on the device; SaveQueue picks it up again.
|
||||||
if (!quiet) toast(`Paused “${video.title}” — it will continue when the connection is back`);
|
if (!quiet) toast(`Paused “${video.title}” — it will continue when the connection is back`);
|
||||||
@@ -12560,7 +12569,7 @@ async function boot() {
|
|||||||
updateBottomChrome();
|
updateBottomChrome();
|
||||||
if (window.Downloads) Downloads.configure({
|
if (window.Downloads) Downloads.configure({
|
||||||
jobs: () => SaveQueue.pending().map(v => ({ ...v, status: downloading.has(v.id) ? (SaveQueue.get(v.id)?.phase === 'waiting' ? 'queued' : 'active') : v.status || 'paused', progress: SaveQueue.get(v.id) })),
|
jobs: () => SaveQueue.pending().map(v => ({ ...v, status: downloading.has(v.id) ? (SaveQueue.get(v.id)?.phase === 'waiting' ? 'queued' : 'active') : v.status || 'paused', progress: SaveQueue.get(v.id) })),
|
||||||
action: (id, action) => { if (action === 'pause') SaveQueue.pause(id); else if (action === 'cancel') SaveQueue.cancel(id); else { const v = SaveQueue.pending().find(v => v.id === id); if (v) preload(v, { quiet: true }); } },
|
action: (id, action) => { if (action === 'pause') SaveQueue.pause(id); else if (action === 'cancel') SaveQueue.cancel(id); else { const v = SaveQueue.pending().find(v => v.id === id); if (v) preload(v, { quiet: true, retry: action === 'retry' }); } },
|
||||||
playlists: () => data.playlists, slots: () => data.settings.parallelSaves || 4,
|
playlists: () => data.playlists, slots: () => data.settings.parallelSaves || 4,
|
||||||
files: async () => { const result = []; if (!WEB || !navigator.storage?.getDirectory) return result;
|
files: async () => { const result = []; if (!WEB || !navigator.storage?.getDirectory) return result;
|
||||||
const root = await navigator.storage.getDirectory();
|
const root = await navigator.storage.getDirectory();
|
||||||
|
|||||||
2
playwright.download-retry.config.js
Normal file
2
playwright.download-retry.config.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
const { defineConfig } = require('@playwright/test');
|
||||||
|
module.exports = defineConfig({ ...require('./playwright.classic.config'), testMatch: /download-retry\.spec\.js/ });
|
||||||
28
server/download-retry.js
Normal file
28
server/download-retry.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// Explicit user retries bypass a failed cache row's backoff, not disk guards.
|
||||||
|
export function registerDownloadRetryRoute(app, { media, priority, clearHints = () => {}, now = Date.now, log = console }) {
|
||||||
|
const recent = new Map();
|
||||||
|
app.post('/api/download/:videoId/retry', async c => {
|
||||||
|
const id = (c.req.param('videoId') || '').trim();
|
||||||
|
const headers = { 'Cache-Control': 'no-store' };
|
||||||
|
if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return c.json({ ok: false, error: 'invalid video id' }, 400, headers);
|
||||||
|
// Already-ready and active jobs are reusable, so Retry never deletes a
|
||||||
|
// playable copy or spawns a second downloader for the same song.
|
||||||
|
const status = await media.status(id);
|
||||||
|
if (status.status === 'ready' || status.status === 'queued' || ['downloading', 'validating'].includes(status.status)) {
|
||||||
|
clearHints(id);
|
||||||
|
return c.json({ ok: true, state: status.status === 'ready' ? 'ready' : 'working' }, 200, headers);
|
||||||
|
}
|
||||||
|
const time = now();
|
||||||
|
if (recent.has(id) && time - recent.get(id) < 15_000) {
|
||||||
|
return c.json({ ok: false, error: 'Please wait a few seconds before trying again.' }, 429, { ...headers, 'Retry-After': '15' });
|
||||||
|
}
|
||||||
|
recent.set(id, time);
|
||||||
|
if (recent.size > 2000) for (const [key, at] of recent) if (time - at >= 15_000) recent.delete(key);
|
||||||
|
if (recent.size > 2000) recent.delete(recent.keys().next().value);
|
||||||
|
clearHints(id);
|
||||||
|
// The existing cache owns the asynchronous job and deduplicates races.
|
||||||
|
// Its normal USB-volume, free-space, size and validation checks still run.
|
||||||
|
media.ensureCached(id, { priority, force: true }).catch(error => log.warn?.(`[download retry] ${id}: ${error.message}`));
|
||||||
|
return c.json({ ok: true, state: 'working' }, 202, headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
52
server/download-retry.test.js
Normal file
52
server/download-retry.test.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { test, expect } from 'bun:test';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { registerDownloadRetryRoute } from './download-retry.js';
|
||||||
|
function fixture(status = 'failed') {
|
||||||
|
const app = new Hono(), calls = [], hints = [];
|
||||||
|
let clock = 1000;
|
||||||
|
registerDownloadRetryRoute(app, {
|
||||||
|
media: { status: async () => ({ status }), ensureCached: async (id, options) => { calls.push([id, options]); } },
|
||||||
|
priority: 0, clearHints: id => hints.push(id), now: () => clock, log: { warn() {} },
|
||||||
|
});
|
||||||
|
return { app, calls, hints, advance: () => { clock += 16000; } };
|
||||||
|
}
|
||||||
|
const request = app => app.request('/api/download/wZzRoXymOUU/retry', { method: 'POST' });
|
||||||
|
test('explicit retry starts a fresh guarded cache job and clears stale hints', async () => {
|
||||||
|
const { app, calls, hints } = fixture();
|
||||||
|
const response = await request(app);
|
||||||
|
expect(response.status).toBe(202);
|
||||||
|
expect(await response.json()).toEqual({ ok: true, state: 'working' });
|
||||||
|
expect(calls).toEqual([['wZzRoXymOUU', { priority: 0, force: true }]]);
|
||||||
|
expect(hints).toEqual(['wZzRoXymOUU']);
|
||||||
|
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||||
|
});
|
||||||
|
test('ready and in-progress songs are reused instead of redownloaded', async () => {
|
||||||
|
for (const status of ['ready', 'queued', 'downloading', 'validating']) {
|
||||||
|
const { app, calls } = fixture(status);
|
||||||
|
expect((await request(app)).status).toBe(200);
|
||||||
|
expect(calls).toHaveLength(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('failed-job retries are bounded while later deliberate retries work', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
expect((await request(f.app)).status).toBe(202);
|
||||||
|
const blocked = await request(f.app);
|
||||||
|
expect(blocked.status).toBe(429);
|
||||||
|
expect(blocked.headers.get('retry-after')).toBe('15');
|
||||||
|
expect(f.calls).toHaveLength(1);
|
||||||
|
f.advance();
|
||||||
|
expect((await request(f.app)).status).toBe(202);
|
||||||
|
expect(f.calls).toHaveLength(2);
|
||||||
|
});
|
||||||
|
test('invalid ids never reach cache machinery and GET cannot force a retry', async () => {
|
||||||
|
const { app, calls } = fixture();
|
||||||
|
expect((await app.request('/api/download/bad-id/retry', { method: 'POST' })).status).toBe(400);
|
||||||
|
expect((await app.request('/api/download/wZzRoXymOUU/retry')).status).toBe(404);
|
||||||
|
expect(calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
test('asynchronous upstream failure is handled and remains available through status', async () => {
|
||||||
|
const app = new Hono(), warnings = [];
|
||||||
|
registerDownloadRetryRoute(app, { priority: 0, media: { status: async () => ({ status: 'failed' }), ensureCached: async () => { throw new Error('Video unavailable'); } }, log: { warn: detail => warnings.push(detail) } });
|
||||||
|
expect((await request(app)).status).toBe(202);
|
||||||
|
expect(warnings[0]).toContain('Video unavailable');
|
||||||
|
});
|
||||||
@@ -192,6 +192,23 @@ describe('media cache jobs', () => {
|
|||||||
expect(calls.length).toBe(1);
|
expect(calls.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('force retry bypasses failed backoff, deduplicates and preserves ready copies', async () => {
|
||||||
|
await clearDb();
|
||||||
|
const { cache, calls, state } = makeCache({ fixture: 'truncated.mp4' });
|
||||||
|
await cache.init();
|
||||||
|
await expect(cache.ensureCached('retryAAAAA1', { priority: HIGH })).rejects.toThrow();
|
||||||
|
await expect(cache.ensureCached('retryAAAAA1')).rejects.toMatchObject({ code: 'BACKOFF' });
|
||||||
|
state.fixture = 'good.mp4';
|
||||||
|
const [first, joined] = await Promise.all([
|
||||||
|
cache.ensureCached('retryAAAAA1', { priority: HIGH, force: true }),
|
||||||
|
cache.ensureCached('retryAAAAA1', { priority: HIGH, force: true }),
|
||||||
|
]);
|
||||||
|
expect(first.gen).toBe(joined.gen);
|
||||||
|
expect(calls).toHaveLength(2);
|
||||||
|
expect((await cache.ensureCached('retryAAAAA1', { force: true })).gen).toBe(first.gen);
|
||||||
|
expect(calls).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
test('a faststart download with cut media data is rejected too', async () => {
|
test('a faststart download with cut media data is rejected too', async () => {
|
||||||
await clearDb();
|
await clearDb();
|
||||||
const { cache, dir } = makeCache({ fixture: 'truncated-fs.mp4' });
|
const { cache, dir } = makeCache({ fixture: 'truncated-fs.mp4' });
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
* UI code works without modification in WEB mode.
|
* UI code works without modification in WEB mode.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { registerDownloadRetryRoute } from './download-retry.js';
|
||||||
import { createResilientYtdlp, BOT_CHECK_RE, DEFAULT_FALLBACK_CLIENTS } from './ytdlp-resilience.js';
|
import { createResilientYtdlp, BOT_CHECK_RE, DEFAULT_FALLBACK_CLIENTS } from './ytdlp-resilience.js';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { serveStatic } from 'hono/bun';
|
import { serveStatic } from 'hono/bun';
|
||||||
@@ -1600,6 +1601,9 @@ app.post('/api/media/:id/redownload', async (c) => {
|
|||||||
// Saves fetch the copy in byte ranges (docs/resumable-downloads-plan.md):
|
// Saves fetch the copy in byte ranges (docs/resumable-downloads-plan.md):
|
||||||
// the ETag pins the generation, so a resumed save never mixes two copies.
|
// the ETag pins the generation, so a resumed save never mixes two copies.
|
||||||
const prepareSkips = new Map(); // videoId → { reason, at } for /prepare
|
const prepareSkips = new Map(); // videoId → { reason, at } for /prepare
|
||||||
|
registerDownloadRetryRoute(app, {
|
||||||
|
media, priority: HIGH, clearHints: id => { prepareSkips.delete(id); streamCache.delete(id); },
|
||||||
|
});
|
||||||
const DOWNLOAD_EXPOSE = 'X-Content-SHA256, Content-Range, Content-Length, ETag, Accept-Ranges';
|
const DOWNLOAD_EXPOSE = 'X-Content-SHA256, Content-Range, Content-Length, ETag, Accept-Ranges';
|
||||||
function cachedDownloadResponse(c, videoId, fp, row) {
|
function cachedDownloadResponse(c, videoId, fp, row) {
|
||||||
if (fp && !c.req.header('range')) recordVideoAccess(fp, { id: videoId }).catch(() => {});
|
if (fp && !c.req.header('range')) recordVideoAccess(fp, { id: videoId }).catch(() => {});
|
||||||
|
|||||||
46
tests/download-retry.spec.js
Normal file
46
tests/download-retry.spec.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const { openClassic } = require('./helpers/classic-fixture');
|
||||||
|
const id = 'wZzRoXymOUU';
|
||||||
|
|
||||||
|
for (const layout of ['classic', 'glass-stage']) for (const screen of ['downloads', 'settings']) {
|
||||||
|
test(`${layout} ${screen}: Retry starts a fresh server job before preparation`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await openClassic(page, { settings: { autoPreload: false } });
|
||||||
|
const requests = [];
|
||||||
|
await page.route(`**/api/download/${id}/retry`, route => {
|
||||||
|
requests.push('retry:' + route.request().method());
|
||||||
|
return route.fulfill({ status: 202, contentType: 'application/json', body: '{"ok":true,"state":"working"}' });
|
||||||
|
});
|
||||||
|
await page.route(`**/api/download/${id}/prepare*`, route => {
|
||||||
|
requests.push('prepare');
|
||||||
|
return route.fulfill({ contentType: 'application/json', body: '{"ok":true,"state":"ready","etag":"fixture","size":4096,"ext":"mp4"}' });
|
||||||
|
});
|
||||||
|
await page.evaluate(({ layout, screen, id }) => {
|
||||||
|
data.settings.layout = layout; applyAppearance();
|
||||||
|
window.OPFS.isSupported = () => true;
|
||||||
|
window.OPFS.downloadVideo = async () => ({ ok: true, size: 4096 });
|
||||||
|
SaveQueue.add({ id, title: 'Rock Medley', channel: 'Petra - Topic' });
|
||||||
|
SaveQueue.state(id, 'failed', `ERROR: [youtube] ${id}: Video unavailable`);
|
||||||
|
view = { type: screen }; render();
|
||||||
|
}, { layout, screen, id });
|
||||||
|
if (screen === 'settings') await page.locator('[data-section="downloads-storage"]').click();
|
||||||
|
await page.getByRole('button', { name: 'Retry Rock Medley', exact: true }).click();
|
||||||
|
await expect.poll(() => requests).toEqual(['retry:POST', 'prepare']);
|
||||||
|
await expect.poll(() => page.evaluate(id => cachedIds.has(id), id)).toBe(true);
|
||||||
|
expect(await page.evaluate(() => SaveQueue.pending().length)).toBe(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('normal resumptions do not force the server to restart a cache job', async ({ page }) => {
|
||||||
|
await openClassic(page, { settings: { autoPreload: false } });
|
||||||
|
let retries = 0;
|
||||||
|
await page.route(`**/api/download/${id}/retry`, route => { retries++; return route.fulfill({ contentType: 'application/json', body: '{"ok":true}' }); });
|
||||||
|
await page.route(`**/api/download/${id}/prepare*`, route => route.fulfill({ contentType: 'application/json', body: '{"ok":true,"state":"ready","etag":"fixture","size":4096}' }));
|
||||||
|
await page.evaluate(async id => {
|
||||||
|
window.OPFS.isSupported = () => true;
|
||||||
|
window.OPFS.downloadVideo = async () => ({ ok: true, size: 4096 });
|
||||||
|
await preload({ id, title: 'Rock Medley' }, { quiet: true });
|
||||||
|
}, id);
|
||||||
|
expect(retries).toBe(0);
|
||||||
|
expect(await page.evaluate(id => cachedIds.has(id), id)).toBe(true);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user