From 5485a3c2f4d74fffff2682971ef0f5e8ba7ffc62 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sat, 3 Oct 2026 19:12:48 +0800 Subject: [PATCH] Try alternate YouTube clients for unavailable save sources --- server/server.js | 49 ++++----------------------- server/ytdlp-resilience.js | 39 ++++++++++++++++++++++ server/ytdlp-resilience.test.js | 59 +++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 43 deletions(-) create mode 100644 server/ytdlp-resilience.js create mode 100644 server/ytdlp-resilience.test.js diff --git a/server/server.js b/server/server.js index 17976c2..c695802 100644 --- a/server/server.js +++ b/server/server.js @@ -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. diff --git a/server/ytdlp-resilience.js b/server/ytdlp-resilience.js new file mode 100644 index 0000000..5cbebbc --- /dev/null +++ b/server/ytdlp-resilience.js @@ -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; + } + }; +} diff --git a/server/ytdlp-resilience.test.js b/server/ytdlp-resilience.test.js new file mode 100644 index 0000000..6b1afe8 --- /dev/null +++ b/server/ytdlp-resilience.test.js @@ -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); +});