Try alternate YouTube clients for unavailable save sources
This commit is contained in:
@@ -30,6 +30,7 @@
|
|||||||
* UI code works without modification in WEB mode.
|
* 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 { Hono } from 'hono';
|
||||||
import { serveStatic } from 'hono/bun';
|
import { serveStatic } from 'hono/bun';
|
||||||
import { logger } from 'hono/logger';
|
import { logger } from 'hono/logger';
|
||||||
@@ -207,55 +208,17 @@ function runYtdlp(args, opts = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// YouTube intermittently answers the default (web) innertube client with
|
// Retry client-specific availability/format failures in both metadata probes
|
||||||
// "Sign in to confirm you're not a bot" — a per-IP rate signal, not a
|
// and downloads. Explicit restrictions remain errors; this cannot make a
|
||||||
// per-video one, so the SAME video that just saved fine fails minutes later
|
// region/account-restricted song available to the server.
|
||||||
// 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;
|
|
||||||
const FALLBACK_CLIENTS = (process.env.YTDLP_FALLBACK_CLIENTS
|
const FALLBACK_CLIENTS = (process.env.YTDLP_FALLBACK_CLIENTS
|
||||||
|| 'web_embedded,tv_simply,android_vr,mweb').split(',').map((s) => s.trim()).filter(Boolean);
|
|| DEFAULT_FALLBACK_CLIENTS.join(',')).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.
|
|
||||||
const YTDLP_COOKIES = process.env.YTDLP_COOKIES || '';
|
const YTDLP_COOKIES = process.env.YTDLP_COOKIES || '';
|
||||||
|
|
||||||
function withCookies(args) {
|
function withCookies(args) {
|
||||||
if (!YTDLP_COOKIES || !existsSync(YTDLP_COOKIES)) return args;
|
if (!YTDLP_COOKIES || !existsSync(YTDLP_COOKIES)) return args;
|
||||||
return ['--cookies', YTDLP_COOKIES, ...args];
|
return ['--cookies', YTDLP_COOKIES, ...args];
|
||||||
}
|
}
|
||||||
|
const runYtdlpResilient = createResilientYtdlp({ run: runYtdlp, withCookies, clients: FALLBACK_CLIENTS });
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run ffmpeg the same way — async spawn so a multi-minute trim/concat never
|
// 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.
|
// blocks Bun's event loop. Rejects on non-zero exit with ffmpeg's stderr tail.
|
||||||
|
|||||||
39
server/ytdlp-resilience.js
Normal file
39
server/ytdlp-resilience.js
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
59
server/ytdlp-resilience.test.js
Normal file
59
server/ytdlp-resilience.test.js
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user