Compare commits

...

3 Commits

Author SHA1 Message Date
Jonathan Sykes
5e78374efb Explain save failures and keep download diagnostics available 2026-10-03 19:23:07 +08:00
Jonathan Sykes
f341b0c76e Restart failed saves through a guarded server retry 2026-10-03 19:16:08 +08:00
Jonathan Sykes
5485a3c2f4 Try alternate YouTube clients for unavailable save sources 2026-10-03 19:12:48 +08:00
17 changed files with 475 additions and 51 deletions

View File

@@ -0,0 +1,109 @@
# Rock Medley save investigation
Video: `wZzRoXymOUU` — **Rock Medley**, Petra - Topic, 605 seconds.
Investigation date: 2026-10-03. Main was pulled with `--ff-only` before changes.
No production shell, credentials, cookies, vault, deployment or proxy was used.
## Evidence
YouTube's public oEmbed endpoint returns HTTP 200, the correct title/artist and
thumbnail. This confirms the ID, but not playback permission. The watch page's
initial player response on this development machine returns:
```json
{"status":"UNPLAYABLE","reason":"Video unavailable"}
```
The server's metadata invocation (`-J --no-warnings URL`) and the exact cache
save invocation fail with `ERROR: [youtube] wZzRoXymOUU: Video unavailable`.
Providing Bun as the JS runtime mirrors the Docker image's `/etc/yt-dlp.conf`.
The error occurs during player extraction, before selecting/downloading a
format. The server uses a ladder of selectors, not a hard-coded format ID.
| Local extractor | Default result |
| --- | --- |
| PATH yt-dlp 2026.07.04 | Video unavailable |
| Repository binary 2026.08.19 | Video unavailable |
| Downloaded nightly 2026.09.27.232945 | Video unavailable |
With 2026.08.19, explicit `web_embedded`, `web_safari`, `tv_simply`, `android_vr`,
`mweb` and `web_music` clients all fail for this song too. A control video
(`0gfX0dFLaBc`) succeeds from the same machine/runtime. This is video-specific
upstream playability refusal, not a demonstrated stale-extractor, missing-format
or broken-JS-runtime problem. It is **not exclusively reproducible on prod**.
The generic response does not establish region, IP, account or licensing as the
specific cause. Cookies or PO tokens were not requested or tested.
Upstream release notes: [2026.08.19](https://github.com/yt-dlp/yt-dlp/releases/tag/2026.08.19).
[yt-dlp documentation](https://github.com/yt-dlp/yt-dlp#dependencies) describes
runtime dependencies; no dependency update resolved this local reproduction.
The Dockerfile is unchanged because an update alone is not a proven remedy.
Its existing release-download layer is cached by Docker, so a future intentional
extractor update must rebuild that layer, rather than assume new app code updates
it automatically.
## Changes
1. Shared extraction now tries a bounded client ladder for generic unavailable
and missing-format errors, as well as bot checks. This covers both metadata
probes and actual saves. Explicit private/member/age/country/removal errors,
local disk errors, cancellation and explicit client overrides do not trigger
that ladder. The final diagnostic preserves the original failure and lists
attempted clients; it does not mislabel the song as deleted.
2. Explicit Retry POSTs `/api/download/:id/retry` before preparing a device save.
It clears stale preparation/stream hints and calls the normal cache with
`force:true` to bypass a **failed row's** cooldown. It does not delete ready
media or duplicate running jobs. USB-volume, free-space/budget, duration and
validation rules still belong to the existing cache. Repeated failed retries
are limited to one per song per 15 seconds. Normal automatic resumes do not
force a retry. Manual Retry follows the same new client ladder.
3. Downloads, Settings → Downloads & storage, and save-failure toasts use plain
language. Expandable “Technical details” retains the raw tool error as text,
never HTML. Controls remain usable in both themes at 390/1440 px.
## Remaining reviewer check
**These changes do not demonstrate a successful download of Rock Medley.**
The local default and all tested clients still receive YouTube's refusal.
To establish the production-specific reason, the reviewer should compare:
- Actual yt-dlp version and the invocation below inside the deployed container.
- Logged-out YouTube playback on the homelab's outbound network versus the
working phone network; repeat with the phone on that same network if possible.
- If only authenticated playback works, confirm whether the video needs account
access before considering the existing optional read-only cookies mechanism.
Do not assume a PO token is required without extractor diagnostics saying so.
- If the failure tracks a network/region, use a permitted outbound network where
the video is available, or a permitted alternate source/upload/device copy.
Merely changing a region hint does not prove actual playback eligibility.
No network, cookies, token, Docker or deployment workaround was guessed or
silently enabled. After any confirmed environment correction, Retry now starts
a genuine fresh attempt instead of just returning the old backoff failure.
```sh
# Metadata probe, same as the server (Docker config supplies the Bun runtime)
yt-dlp -J --no-warnings 'https://www.youtube.com/watch?v=wZzRoXymOUU'
# Cache save, same selector/merge/rate args as the server's defaults
yt-dlp 'https://www.youtube.com/watch?v=wZzRoXymOUU' --no-warnings --no-playlist \
-f 'bv*[height<=720][vcodec^=avc1]+ba[ext=m4a]/bv*[height<=720][vcodec^=avc1]+ba/b[ext=mp4][vcodec^=avc1]/bv*[height<=720]+ba/b[ext=mp4]/b' \
--merge-output-format mp4 --limit-rate 2M -o /tmp/rock-medley-check.mp4
```
Tests: `node --test frontend/*.test.js`; app syntax check; unbundled server build;
`bun test server/ytdlp-resilience.test.js server/download-retry.test.js
server/media-cache.test.js`; and
`npx playwright test --config playwright.download-retry.config.js`.
The existing pool tests additionally need an importable yt-dlp zipapp:
`YTDLP_PATH=/path/to/zipapp bun test server/ytdlp-pool.test.js`. The installed
PATH executable here is a Python entry-point script, not an importable zipapp;
providing the downloaded nightly zipapp makes all pool tests pass without any
production code or environment changes.
Final validation: **118 frontend unit tests, 44 server tests and 13 browser cases
pass**, plus the app syntax check and server build. Browser cases cover the real
Retry controls in both Downloads and Settings, normal resumes, friendly failures,
44 px details controls, safe raw text and overflow at phone/desktop widths.
A real iPhone Safari/PWA save should still be checked after deployment, especially
pause/resume of a partial copy and retry after a network change.

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`);
@@ -1833,7 +1842,7 @@ async function preload(video, { quiet = false, mux = false } = {}) {
warmThumb(thumbUrlFor(id, video));
if (!quiet) toast(`Saved “${video.title}” ✓`);
} else if (!quiet && !(res && res.paused)) {
toast('⚠ ' + ((res && res.error) || 'Could not save video'));
toast('⚠ ' + (window.DownloadErrors ? DownloadErrors.message(res?.error) : res?.error || 'Could not save video'));
}
} catch (e) {
if (e.name !== 'AbortError') SaveQueue.state(id, 'failed', e.message || 'Save failed');
@@ -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,28 @@
/* Plain-language save failures, with the original diagnostic kept on demand. */
(function(root) {
'use strict';
function message(error) {
const raw = String(error?.message || error || '').trim();
if (!raw) return 'Could not save this video. Try Retry.';
if (/not available in your country|geo.?restricted/i.test(raw)) return 'This video is unavailable in the server’s region. It may still play on your device.';
if (/private video|members.only|join this channel|confirm your age|age.restricted/i.test(raw)) return 'YouTube requires access this server does not have to save this video.';
if (/removed by|video has been removed|copyright claim/i.test(raw)) return 'YouTube is no longer making this video available to the server.';
if (/sign in to confirm|not a bot/i.test(raw)) return 'YouTube temporarily blocked the server’s download request. Try Retry later.';
if (/video unavailable/i.test(raw)) return 'YouTube isn’t letting the server save this video. Try Retry; it may still play on YouTube.';
if (/requested format (?:is )?not available|no video formats found/i.test(raw)) return 'No downloadable version was available. Try Retry to check other download methods.';
if (/^ERROR:|^WARNING:|Traceback|yt-dlp exited|yt-dlp not found/i.test(raw)) return 'The server could not save this video. Try Retry.';
// Existing plain-language connection/quota/validation messages remain useful.
return raw.length <= 180 && !raw.includes('\n') ? raw : 'Could not save this video. Try Retry.';
}
function appendDetails(container, error) {
if (!error) return;
const doc = container.ownerDocument;
const details = doc.createElement('details'); details.className = 'download-error-details';
const summary = doc.createElement('summary'); summary.textContent = 'Technical details';
const pre = doc.createElement('pre'); pre.textContent = String(error?.message || error);
details.append(summary, pre); container.append(details);
return details;
}
const api = { message, appendDetails };
if (typeof module !== 'undefined') module.exports = api; else root.DownloadErrors = api;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,24 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { message } = require('./download-errors.js');
const { formatJobStatus } = require('./downloads-page.js');
test('unavailable songs get useful copy without claiming that they were removed', () => {
const raw = 'ERROR: [youtube] wZzRoXymOUU: Video unavailable\nYouTube clients tried: default, web_embedded, web_safari';
assert.equal(message(raw), 'YouTube isn’t letting the server save this video. Try Retry; it may still play on YouTube.');
assert.equal(formatJobStatus({ status: 'failed', error: raw }), 'Failed: ' + message(raw));
});
test('known restrictions and bot failures have distinct explanations', () => {
assert.match(message('Video unavailable. not available in your country'), /server’s region/);
assert.match(message('Video unavailable. Private video'), /requires access/);
assert.match(message('Video unavailable. Confirm your age'), /requires access/);
assert.match(message('Video has been removed'), /no longer/);
assert.match(message('Sign in to confirm you are not a bot'), /temporarily blocked/);
assert.match(message('Requested format is not available'), /other download methods/);
});
test('missing and raw tool failures get a short fallback; existing useful messages survive', () => {
assert.equal(message(null), 'Could not save this video. Try Retry.');
assert.equal(message('ERROR: mysterious extractor failure'), 'The server could not save this video. Try Retry.');
assert.equal(message('Connection lost'), 'Connection lost');
assert.equal(message('Please wait a few seconds before trying again.'), 'Please wait a few seconds before trying again.');
assert.equal(message('a'.repeat(200)), 'Could not save this video. Try Retry.');
});

View File

@@ -1,6 +1,7 @@
/* Downloads page UI module. Grouped jobs, storage info, confirmed cancellation. */
(function (root) {
'use strict';
const errors = typeof module !== 'undefined' ? require('./download-errors.js') : root.DownloadErrors;
const bytes = n => {
if (typeof n !== 'number' || isNaN(n) || n <= 0) return '0 MB';
@@ -18,7 +19,7 @@
function formatJobStatus(job) {
if (job.status === 'failed') {
return job.error ? `Failed: ${job.error}` : 'Save failed';
return job.error ? `Failed: ${errors.message(job.error)}` : 'Save failed';
}
if (job.status === 'paused') {
return 'Paused — resume when you’re ready';
@@ -215,6 +216,8 @@
</div>
`;
if (isFailed && job.error) errors.appendDetails(row.querySelector('.card-info'), job.error);
const acts = doc.createElement('div');
acts.className = 'dl-actions';

View File

@@ -1,6 +1,7 @@
/* Download controls and device storage accounting. */
(function(root) {
'use strict';
const errors = typeof module !== 'undefined' ? require('./download-errors.js') : root.DownloadErrors;
const bytes = n => n >= 1073741824 ? `${(n / 1073741824).toFixed(1)} GB` : `${(n / 1048576).toFixed(1)} MB`;
function breakdown(files, playlists) {
const kinds = { video: 0, audio: 0, eq: 0 }, byId = new Map();
@@ -24,13 +25,15 @@
for (const job of items) {
const row = doc.createElement('div'); row.className = 'download-job';
const title = doc.createElement('b'); title.textContent = job.title || job.id;
const status = doc.createElement('small'); status.textContent = job.error || job.status;
const status = doc.createElement('small'); status.textContent = job.error ? errors.message(job.error) : job.status;
const progress = doc.createElement('progress'); progress.max = job.progress?.total || 1; if (job.progress?.total) progress.value = job.progress.received || 0; progress.setAttribute('aria-label', `Saving ${job.title || job.id}`);
const actions = doc.createElement('div');
for (const action of [...(['active', 'queued'].includes(job.status) ? ['pause'] : [job.status === 'failed' ? 'retry' : 'resume']), 'cancel']) {
const button = doc.createElement('button'); button.type = 'button'; button.textContent = action[0].toUpperCase() + action.slice(1); button.setAttribute('aria-label', `${button.textContent} ${job.title || job.id}`); button.onclick = () => { adapter.action(job.id, action); signature = ''; paint(); }; actions.append(button);
}
row.append(title, status, progress, actions); jobs.append(row);
row.append(title, status, progress, actions);
if (job.error) errors.appendDetails(row, job.error);
jobs.append(row);
}
}
async function disk() {

View File

@@ -660,6 +660,7 @@
<script src="async-guard.js"></script>
<script src="sw-update.js"></script>
<script src="settings-sections.js"></script>
<script src="download-errors.js"></script>
<script src="downloads.js"></script>
<script src="downloads-page.js"></script>
<script src="saved-page.js"></script>

View File

@@ -21,3 +21,13 @@ html[data-layout] .dl-row .card-info { grid-column:2; grid-row:1; min-width:0; }
.dl-row .dl-act.danger { color:var(--accent); background:transparent; }
.dl-row .dl-act:disabled { opacity:.5; cursor:wait; }
@media(min-width:900px) { html[data-layout] .card.dl-row { grid-template-columns:88px minmax(0,1fr); } html[data-layout] .dl-row .thumb { width:88px; } }
/* Save failures remain readable; raw extractor output is opt-in, never HTML. */
.dl-failed .card-channel { white-space:normal; line-height:1.5; }
.download-error-details { min-width:0; color:var(--text-2); font:12px/1.5 var(--ui); }
.download-error-details summary { min-height:44px; padding:12px 0; cursor:pointer; }
.download-error-details summary:focus-visible { outline:2px solid var(--accent); outline-offset:2px; border-radius:4px; }
.download-error-details pre { white-space:pre-wrap; overflow-wrap:anywhere; max-height:240px; overflow:auto; margin:0 0 8px; font:12px/1.5 var(--mono); }
.download-job { grid-template-columns:minmax(0,1fr); }
.download-job > div { flex-wrap:wrap; }
.download-job progress { min-width:0; }

View File

@@ -84,6 +84,7 @@ const SHELL = [
'/p2p-transfer.js',
'/p2p-recv-worker.js',
'/settings-sections.js',
'/download-errors.js',
'/downloads.js',
'/downloads-page.js',
'/offline-pages.css',

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,8 @@
* 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';
import { logger } from 'hono/logger';
@@ -207,55 +209,17 @@ function runYtdlp(args, opts = {}) {
);
}
// YouTube intermittently answers the default (web) innertube client with
// "Sign in to confirm you're not a bot" — a per-IP rate signal, not a
// per-video one, so the SAME video that just saved fine fails minutes later
// and the user sees "sign in required". Other player clients are not gated by
// that check from a datacentre IP, so retry the whole yt-dlp call against each
// in turn instead of demanding cookies. Order is quality-first: web_embedded
// still exposes the adaptive DASH ladder (399+251), while tv_simply /
// android_vr / mweb typically only offer progressive itag 18 (360p) — a 360p
// save beats a failed save.
const BOT_CHECK_RE = /Sign in to confirm|not a bot|confirm you.{0,3}re not a bot/i;
// Retry client-specific availability/format failures in both metadata probes
// and downloads. Explicit restrictions remain errors; this cannot make a
// region/account-restricted song available to the server.
const FALLBACK_CLIENTS = (process.env.YTDLP_FALLBACK_CLIENTS
|| 'web_embedded,tv_simply,android_vr,mweb').split(',').map((s) => s.trim()).filter(Boolean);
// Optional cookies jar (Netscape format) for the rare case every client is
// gated. Mounted read-only; absent by default and never required.
|| DEFAULT_FALLBACK_CLIENTS.join(',')).split(',').map(s => s.trim()).filter(Boolean);
const YTDLP_COOKIES = process.env.YTDLP_COOKIES || '';
function withCookies(args) {
if (!YTDLP_COOKIES || !existsSync(YTDLP_COOKIES)) return args;
return ['--cookies', YTDLP_COOKIES, ...args];
}
// runYtdlp + bot-check fallback. Every YouTube-facing call goes through this.
async function runYtdlpResilient(args, opts = {}) {
const hasClientArg = args.some((a) => String(a).includes('player_client='));
try {
return await runYtdlp(withCookies(args), opts);
} catch (err) {
if (hasClientArg || !BOT_CHECK_RE.test(err.message)) throw err;
if (opts.signal?.aborted) throw err;
let last = err;
for (const client of FALLBACK_CLIENTS) {
if (opts.signal?.aborted) throw last;
try {
const out = await runYtdlp(
withCookies(['--extractor-args', `youtube:player_client=${client}`, ...args]),
opts,
);
console.warn(`[ytplayer] bot check on default client, succeeded via player_client=${client}`);
return out;
} catch (e) {
last = e;
// A client that simply lacks the requested format is not a bot check;
// keep walking the list either way, but surface the last real error.
if (!BOT_CHECK_RE.test(e.message) && !/format is not available/i.test(e.message)) throw e;
}
}
throw last;
}
}
const runYtdlpResilient = createResilientYtdlp({ run: runYtdlp, withCookies, clients: FALLBACK_CLIENTS });
// Run ffmpeg the same way — async spawn so a multi-minute trim/concat never
// blocks Bun's event loop. Rejects on non-zero exit with ffmpeg's stderr tail.
@@ -1637,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,39 @@
// Bounded client fallbacks shared by metadata probes and actual downloads.
export const BOT_CHECK_RE = /Sign in to confirm|not a bot|confirm you.{0,3}re not a bot/i;
export const DEFAULT_FALLBACK_CLIENTS = ['web_embedded', 'web_safari', 'tv_simply', 'android_vr', 'mweb'];
export function canTryAnotherClient(error) {
const message = String(error?.message || error || '');
// Explicit access restrictions are not fixed by selecting a different client.
if (/private video|members.only|join this channel|not available in your country|geo.?restricted|removed by|video has been removed|copyright claim|age.restricted|confirm your age/i.test(message)) return false;
return BOT_CHECK_RE.test(message) || /video unavailable|requested format (?:is )?not available|no video formats found/i.test(message);
}
export function createResilientYtdlp({ run, withCookies = args => args, clients = DEFAULT_FALLBACK_CLIENTS, log = console }) {
return async function runResilient(args, opts = {}) {
const explicitClient = args.some(arg => String(arg).includes('player_client=') || String(arg).includes('player-client='));
if (opts.signal?.aborted) throw opts.signal.reason || new Error('save cancelled');
try {
return await run(withCookies(args), opts);
} catch (initial) {
if (explicitClient || opts.signal?.aborted || !canTryAnotherClient(initial)) throw initial;
const failures = [{ client: 'default', error: initial }];
for (const client of [...new Set(clients)]) {
if (opts.signal?.aborted) throw opts.signal.reason || initial;
try {
const out = await run(withCookies(['--extractor-args', `youtube:player_client=${client}`, ...args]), opts);
log.warn?.(`[ytplayer] YouTube fallback succeeded via player_client=${client}`);
return out;
} catch (error) {
failures.push({ client, error });
if (opts.signal?.aborted || !canTryAnotherClient(error)) throw error;
}
}
// Keep the original useful diagnosis, not the final client's weaker
// "no formats" error. Retain the bounded attempt trail for diagnostics.
const error = new Error(`${initial.message}\nYouTube clients tried: ${failures.map(f => f.client).join(', ')}`, { cause: initial });
error.attempts = failures.map(({ client, error }) => ({ client, detail: String(error.message || error) }));
throw error;
}
};
}

View File

@@ -0,0 +1,59 @@
import { test, expect } from 'bun:test';
import { createResilientYtdlp, canTryAnotherClient } from './ytdlp-resilience.js';
const quiet = { warn() {} };
const unavailable = () => new Error('ERROR: [youtube] wZzRoXymOUU: Video unavailable');
test('metadata and save calls retry unavailable videos with alternate clients', async () => {
for (const args of [['-J', '--no-warnings', 'https://www.youtube.com/watch?v=wZzRoXymOUU'], ['https://www.youtube.com/watch?v=wZzRoXymOUU', '-f', 'bv*+ba/b', '--merge-output-format', 'mp4', '-o', '/tmp/save.mp4']]) {
const calls = [];
const signal = new AbortController().signal;
const run = createResilientYtdlp({ clients: ['web_embedded', 'web_safari'], log: quiet,
withCookies: argv => ['--cookies', '/fixture/cookies.txt', ...argv],
run: async (argv, opts) => { calls.push([argv, opts]); if (calls.length < 3) throw calls.length === 1 ? unavailable() : new Error('Requested format is not available'); return 'success'; },
});
expect(await run(args, { signal })).toBe('success');
expect(calls).toHaveLength(3);
expect(calls[2][0]).toEqual(['--cookies', '/fixture/cookies.txt', '--extractor-args', 'youtube:player_client=web_safari', ...args]);
expect(calls.every(([, opts]) => opts.signal === signal)).toBe(true);
}
});
test('an initial missing-format error also gets a client fallback', async () => {
let calls = 0;
const run = createResilientYtdlp({ log: quiet, clients: ['web_safari'], run: async () => { if (++calls === 1) throw new Error('Requested format is not available'); return 'ok'; } });
expect(await run(['-f', 'b'])).toBe('ok');
});
test('exhaustion preserves the original diagnosis and the attempt trail', async () => {
let calls = 0;
const run = createResilientYtdlp({ clients: ['web_embedded', 'web_embedded', 'mweb'], log: quiet, run: async () => { if (++calls === 1) throw unavailable(); throw new Error('Requested format is not available'); } });
const err = await run(['-J']).catch(e => e);
expect(calls).toBe(3);
expect(err.message).toContain('wZzRoXymOUU: Video unavailable');
expect(err.message).toContain('default, web_embedded, mweb');
expect(err.attempts).toHaveLength(3);
});
test('explicit client selection and unrelated failures are not retried', async () => {
for (const [args, message] of [[['--extractor-args', 'youtube:player_client=mweb'], 'Video unavailable'], [['--extractor-args', 'youtube:player-client=mweb'], 'Video unavailable'], [[], 'No space left on device'], [[], 'yt-dlp not found'], [[], 'HTTP Error 429: Too Many Requests']]) {
let calls = 0;
const run = createResilientYtdlp({ log: quiet, run: async () => { calls++; throw new Error(message); } });
await expect(run(args)).rejects.toThrow(message);
expect(calls).toBe(1);
}
});
test('explicit access restrictions stop retries, even with an unavailable prefix', () => {
for (const detail of ['Private video', 'Video has been removed', 'not available in your country', 'members-only', 'Confirm your age', 'copyright claim']) expect(canTryAnotherClient(new Error('Video unavailable. ' + detail))).toBe(false);
expect(canTryAnotherClient(new Error('Sign in to confirm you are not a bot'))).toBe(true);
});
test('cancellation stops the fallback chain', async () => {
const controller = new AbortController();
let calls = 0;
const run = createResilientYtdlp({ log: quiet, run: async () => { calls++; controller.abort(); throw unavailable(); } });
await expect(run(['-J'], { signal: controller.signal })).rejects.toThrow('Video unavailable');
expect(calls).toBe(1);
await expect(run(['-J'], { signal: controller.signal })).rejects.toThrow();
expect(calls).toBe(1);
});

View File

@@ -0,0 +1,72 @@
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);
});
for (const layout of ['classic', 'glass-stage']) for (const width of [390, 1440]) for (const screen of ['downloads', 'settings']) {
test(`${layout} ${width} ${screen}: friendly failure retains safe technical details`, async ({ page }) => {
await page.setViewportSize({ width, height: 844 });
await openClassic(page, { settings: { autoPreload: false } });
const raw = `ERROR: [youtube] ${id}: Video unavailable\nYouTube clients tried: default, web_embedded, web_safari\n<script>window.unsafeDetail=true</script>`;
await page.evaluate(({ layout, screen, id, raw }) => {
data.settings.layout = layout; applyAppearance();
SaveQueue.add({ id, title: 'Rock Medley', channel: 'Petra - Topic' });
SaveQueue.state(id, 'failed', raw);
view = { type: screen }; render();
}, { layout, screen, id, raw });
if (screen === 'settings') await page.locator('[data-section="downloads-storage"]').click();
const row = page.locator(screen === 'settings' ? '.download-job' : '.dl-failed').first();
await expect(row).toContainText('YouTube isn’t letting the server save this video.');
await expect(row.locator('pre')).toBeHidden();
const summary = row.locator('summary');
expect((await summary.boundingBox()).height).toBeGreaterThanOrEqual(44);
await summary.click();
await expect(row.locator('pre')).toHaveText(raw);
expect(await page.evaluate(() => window.unsafeDetail)).toBeUndefined();
const bounds = await row.evaluate(el => ({ width: el.clientWidth, content: el.scrollWidth, children: [...el.querySelectorAll('*')].filter(child => child.scrollWidth > child.clientWidth + 1).map(child => [child.tagName, child.className, child.clientWidth, child.scrollWidth]) }));
expect(bounds.content <= bounds.width + 1, JSON.stringify(bounds)).toBe(true);
await expect(row.getByRole('button', { name: 'Retry Rock Medley', exact: true })).toBeVisible();
});
}