diff --git a/server/server.js b/server/server.js index 0c6e60e..5fd089f 100644 --- a/server/server.js +++ b/server/server.js @@ -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',