Restart failed saves through a guarded server retry

This commit is contained in:
Jonathan Sykes
2026-10-03 19:16:08 +08:00
parent 5485a3c2f4
commit f341b0c76e
7 changed files with 162 additions and 4 deletions

View File

@@ -437,7 +437,7 @@ async function resumableOpfsSave(videoId, qs, hevc, onProgress, signal) {
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()) {
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 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);
if (resumed) return resumed;
@@ -1791,7 +1800,7 @@ function recordDeviceFile(id, res) {
}
// 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;
// Custom (edited) videos have no YouTube source to (re)download — their
// 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);
slot = true;
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) {
// 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`);
@@ -12560,7 +12569,7 @@ async function boot() {
updateBottomChrome();
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) })),
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,
files: async () => { const result = []; if (!WEB || !navigator.storage?.getDirectory) return result;
const root = await navigator.storage.getDirectory();

View 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
View 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);
});
}

View 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');
});

View File

@@ -192,6 +192,23 @@ describe('media cache jobs', () => {
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 () => {
await clearDb();
const { cache, dir } = makeCache({ fixture: 'truncated-fs.mp4' });

View File

@@ -30,6 +30,7 @@
* 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 { Hono } from 'hono';
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):
// the ETag pins the generation, so a resumed save never mixes two copies.
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';
function cachedDownloadResponse(c, videoId, fp, row) {
if (fp && !c.req.header('range')) recordVideoAccess(fp, { id: videoId }).catch(() => {});

View 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);
});