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); /** * Live detection failures are kept per channel and surfaced via GET /status * for debugging. */ 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. */ 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", ...ytdlpAntiBotArgs(), "--print", "%(id)s", liveUrl], { timeout: 20_000 }, ); lastPollErrors.delete(channelId); const videoId = stdout.trim().split("\n")[0]; return videoId || null; } catch (err) { const message = err instanceof Error ? err.message : String(err); lastPollErrors.set(channelId, message.slice(0, 500)); return null; } } async function startSession(channel: { id: string; name: string }, liveVideoId: string) { const existing = await prisma.streamSession.findFirst({ where: { channelId: channel.id, endedAt: null }, }); if (existing) return; const session = await prisma.streamSession.create({ data: { channelId: channel.id, liveVideoId }, }); await streamIngestQueue.add("start-capture", { sessionId: session.id, channelDbId: channel.id, youtubeUrl: `https://www.youtube.com/watch?v=${liveVideoId}`, }); await sendTelegramMessage(`🔴 *${channel.name}* canlıya geçti, kayıt başlatılıyor.`); console.log(`[youtube-polling] started session ${session.id} for channel ${channel.name}`); } export async function pollOnce(): Promise { const channels = await prisma.channel.findMany({ where: { isActive: true } }); for (const channel of channels) { try { const liveVideoId = await findLiveVideoId(channel.channelId); await prisma.channel.update({ where: { id: channel.id }, data: { lastCheckedAt: new Date() }, }); if (liveVideoId) { await startSession(channel, liveVideoId); } } catch (err) { console.error(`[youtube-polling] error polling channel ${channel.name}:`, err); } } } export function startPollingLoop(): NodeJS.Timeout { console.log(`[youtube-polling] polling every ${env.pollIntervalMs}ms`); pollOnce().catch((err) => console.error("[youtube-polling] initial poll failed:", err)); return setInterval(() => { pollOnce().catch((err) => console.error("[youtube-polling] poll failed:", err)); }, env.pollIntervalMs); }