Try alternate YouTube clients for unavailable save sources

This commit is contained in:
Jonathan Sykes
2026-10-03 19:12:48 +08:00
parent 613f8fb902
commit 5485a3c2f4
3 changed files with 104 additions and 43 deletions

View File

@@ -30,6 +30,7 @@
* UI code works without modification in WEB mode.
* ========================================================================== */
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 +208,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.

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