diff --git a/apps/api-daemon/src/youtubePolling.ts b/apps/api-daemon/src/youtubePolling.ts index ea4b914..77a82cf 100644 --- a/apps/api-daemon/src/youtubePolling.ts +++ b/apps/api-daemon/src/youtubePolling.ts @@ -1,12 +1,10 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; import { prisma } from "@streamclipper/db"; import { env } from "./env"; import { streamIngestQueue } from "./queues"; import { sendTelegramMessage } from "./telegram"; -import { ytdlpAntiBotArgs } from "./ytdlpCookies"; -const execFileAsync = promisify(execFile); +const BROWSER_USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"; /** * Live detection failures are kept per channel and surfaced via GET /status @@ -15,28 +13,45 @@ const execFileAsync = promisify(execFile); export const lastPollErrors = new Map(); /** - * A channel's /live page HTML embeds dozens of unrelated `videoId`s (channel - * video list, recommendations, etc.) — a plain regex grabbed whichever one - * happened to appear first, which had nothing to do with the actual live - * broadcast (confirmed: captured recordings didn't match the real stream). - * yt-dlp's extractor correctly resolves the channel's *current* live video, - * so the check goes back through it. Crucially, no `-f` format selector is - * passed here: format resolution is what triggers YouTube's PO-token wall, - * and this call only needs the video id / live flag, not downloadable - * formats — those are only resolved later, in the actual capture. + * Two earlier approaches both had real problems: + * - A naive regex over the channel /live page HTML grabbed the FIRST + * "videoId" anywhere on the page — which is one of dozens of unrelated + * ids (channel video list, recommendations), not the live broadcast. + * Confirmed: captured recordings didn't match the real stream. + * - Routing the check through yt-dlp resolved the correct video but is + * increasingly blocked by YouTube's bot-check/proof-of-origin-token wall + * on datacenter IPs — even a plain --simulate call, no cookies fix this + * reliably. + * + * A plain page fetch was never blocked all day — only yt-dlp invocations + * were. The fix is extracting the *right* field instead of avoiding the + * fetch: YouTube embeds a single `videoDetails` object holding the id/live + * flag/channelId of whichever video the page is actually showing (present + * only when a channel really is live — verified empty for an offline + * channel). Scoping to that one object (and cross-checking channelId) + * avoids both the wrong-video bug and yt-dlp's bot-check entirely. */ async function findLiveVideoId(channelId: string): Promise { const liveUrl = `https://www.youtube.com/channel/${channelId}/live`; try { - const { stdout } = await execFileAsync( - "yt-dlp", - ["--simulate", "--no-warnings", ...(await ytdlpAntiBotArgs()), "--print", "%(id)s", liveUrl], - { timeout: 20_000 }, - ); + const res = await fetch(liveUrl, { headers: { "User-Agent": BROWSER_USER_AGENT } }); + if (!res.ok) { + lastPollErrors.set(channelId, `HTTP ${res.status} fetching ${liveUrl}`); + return null; + } + + const html = await res.text(); lastPollErrors.delete(channelId); - const videoId = stdout.trim().split("\n")[0]; - return videoId || null; + + const match = html.match(/"videoDetails":\{"videoId":"([^"]+)"/); + if (!match) return null; + + const window = html.slice(match.index!, match.index! + 600); + if (!/"isLive":true/.test(window)) return null; + if (!window.includes(`"channelId":"${channelId}"`)) return null; + + return match[1]; } catch (err) { const message = err instanceof Error ? err.message : String(err); lastPollErrors.set(channelId, message.slice(0, 500));