Retry yt-dlp against alternate player clients when YouTube demands sign-in

This commit is contained in:
Jonathan Sykes
2026-08-27 07:22:02 +08:00
parent 94fff8f862
commit d073d20060

View File

@@ -28,7 +28,7 @@ import { serveStatic } from 'hono/bun';
import { logger } from 'hono/logger';
import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { readFileSync, readdirSync, statSync, openSync, unlinkSync, createReadStream } from 'node:fs';
import { readFileSync, readdirSync, existsSync, statSync, openSync, unlinkSync, createReadStream } from 'node:fs';
import { Readable } from 'node:stream';
import { tmpdir } from 'node:os';
import { createHash } from 'node:crypto';
@@ -147,6 +147,56 @@ function runYtdlp(args, { signal } = {}) {
});
}
// 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;
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.
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;
}
}
// 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.
function runFfmpeg(args) {
@@ -292,7 +342,7 @@ app.get('/api/search', async (c) => {
if (!q) return c.json({ ok: false, error: 'empty query' }, 400);
try {
const out = await runYtdlp([
const out = await runYtdlpResilient([
`ytsearch${SEARCH_LIMIT}:${q}`,
'--dump-json', '--flat-playlist',
'--no-warnings', '--ignore-errors',
@@ -310,7 +360,7 @@ app.get('/api/channel', async (c) => {
try {
const url = channelToUrl(chan);
const out = await runYtdlp([
const out = await runYtdlpResilient([
url,
'--dump-json', '--flat-playlist',
'--no-warnings', '--ignore-errors',
@@ -370,7 +420,7 @@ async function resolveStreams(videoId) {
const cached = streamCache.get(videoId);
if (cached && now < cached.expiresAt) return cached;
const out = await runYtdlp(['-J', '--no-warnings', `https://www.youtube.com/watch?v=${videoId}`]);
const out = await runYtdlpResilient(['-J', '--no-warnings', `https://www.youtube.com/watch?v=${videoId}`]);
const info = JSON.parse(out);
const raw = Array.isArray(info.formats) ? info.formats : [];
const formats = [];
@@ -726,7 +776,7 @@ async function ytdlpDownloadResponse(videoId, fp, formatArgs, signal) {
entry = { waiters: 0, tmpBase, ctl };
entry.promise = withSaveSlot(() => {
if (ctl.signal.aborted) throw new Error('save cancelled');
return runYtdlp([
return runYtdlpResilient([
`https://www.youtube.com/watch?v=${videoId}`,
'--no-warnings', '--no-playlist',
...formatArgs,
@@ -796,7 +846,7 @@ async function ytdlpEditedDownloadResponse(videoId, fp, keep, signal) {
let size, fd;
try {
// 1) Grab the full source (video+audio merged) so ffmpeg has both streams.
await withSaveSlot(() => runYtdlp([
await withSaveSlot(() => runYtdlpResilient([
`https://www.youtube.com/watch?v=${videoId}`,
'--no-warnings', '--no-playlist',
'-f', 'bv*[height<=720][ext=mp4]+ba[ext=m4a]/bv*[height<=720]+ba/b[ext=mp4]/b',
@@ -1123,7 +1173,7 @@ app.get('/api/playlist/expand', async (c) => {
}
try {
const out = await runYtdlp([
const out = await runYtdlpResilient([
url,
'--dump-json', '--flat-playlist',
'--no-warnings', '--ignore-errors',