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