diff --git a/frontend/app.js b/frontend/app.js index a003b1f..96b8edf 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -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(); diff --git a/playwright.download-retry.config.js b/playwright.download-retry.config.js new file mode 100644 index 0000000..a38353d --- /dev/null +++ b/playwright.download-retry.config.js @@ -0,0 +1,2 @@ +const { defineConfig } = require('@playwright/test'); +module.exports = defineConfig({ ...require('./playwright.classic.config'), testMatch: /download-retry\.spec\.js/ }); diff --git a/server/download-retry.js b/server/download-retry.js new file mode 100644 index 0000000..dd8684f --- /dev/null +++ b/server/download-retry.js @@ -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); + }); +} diff --git a/server/download-retry.test.js b/server/download-retry.test.js new file mode 100644 index 0000000..41970a2 --- /dev/null +++ b/server/download-retry.test.js @@ -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'); +}); diff --git a/server/media-cache.test.js b/server/media-cache.test.js index 9e4e869..c44ffc4 100644 --- a/server/media-cache.test.js +++ b/server/media-cache.test.js @@ -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' }); diff --git a/server/server.js b/server/server.js index c695802..a27aa65 100644 --- a/server/server.js +++ b/server/server.js @@ -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(() => {}); diff --git a/tests/download-retry.spec.js b/tests/download-retry.spec.js new file mode 100644 index 0000000..64e4eaf --- /dev/null +++ b/tests/download-retry.spec.js @@ -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); +});