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. `at` lets the panel show how stale an error is — a message * on its own doesn't say whether it's from the last poll or from hours ago * before the channel started passing again. */ export interface PollError { message: string; at: string; } export const lastPollErrors = new Map(); /** Non-error outcomes yt-dlp still reports as a non-zero exit — not worth surfacing as a scary error badge in the panel. */ const BENIGN_POLL_MESSAGE = /is not currently live|will begin in/; /** * A few approaches were tried before landing here: * - A naive regex over the channel /live page HTML grabbed the FIRST * "videoId" anywhere on the page — one of dozens of unrelated ids * (channel video list, recommendations), not the live broadcast. * - Scoping to the page's `videoDetails` object fixed that, but turned out * unreliable for a different reason: YouTube doesn't always embed the * player response server-side (confirmed on a clean, unflagged IP) — the * field is sometimes just absent regardless of bot-check status. * - yt-dlp's own extractor doesn't have that inconsistency (it resolves the * live video correctly every time it isn't blocked), so the check is * back on yt-dlp — this time with every anti-bot layer combined: * authenticated cookies, the bgutil PO-token provider, and a clean * residential/ISP proxy (the Coolify server's own IP got flagged from a * day of heavy testing). No `-f` format selector here — resolving * downloadable formats is a separate, heavier check only needed later, * in the actual capture. `--js-runtimes node` (the n-challenge/EJS * solver) is included too — some channels return "The page needs to be * reloaded" on the plain metadata fetch too, not just format resolution. */ async function notifyIfEnabled(key: string, text: string): Promise { const setting = await prisma.appSetting.findUnique({ where: { key } }); if (setting?.value !== "false") await sendTelegramMessage(text); } async function findLiveVideoId(channelId: string, channelName: string): Promise { const liveUrl = `https://www.youtube.com/channel/${channelId}/live`; try { const { stdout } = await execFileAsync( "yt-dlp", [ "--simulate", "--no-warnings", "--js-runtimes", "node", ...(await ytdlpAntiBotArgs()), "--print", "%(id)s", liveUrl, ], { timeout: 20_000 }, ); // Only worth a "recovered" message if it was actually failing before — // this runs on every successful poll, most of which were never broken. if (lastPollErrors.has(channelId)) { lastPollErrors.delete(channelId); await notifyIfEnabled("notify_poll_error", `✅ *${channelName}* canlı-tespiti tekrar çalışıyor.`); } const videoId = stdout.trim().split("\n")[0]; return videoId || null; } catch (err) { const message = err instanceof Error ? err.message : String(err); // yt-dlp reports "not live" / "starts in N minutes" as a non-zero exit // too — normal, expected outcomes, not failures worth a red badge. if (!BENIGN_POLL_MESSAGE.test(message)) { // Alert only on the transition into failing — not every 60s poll // while it stays broken, or this would spam constantly. const wasAlreadyFailing = lastPollErrors.has(channelId); lastPollErrors.set(channelId, { message: message.slice(0, 500), at: new Date().toISOString() }); if (!wasAlreadyFailing) { await notifyIfEnabled( "notify_poll_error", `⚠️ *${channelName}* canlı-tespiti başarısız oluyor:\n\`${message.slice(0, 300)}\``, ); } } else { lastPollErrors.delete(channelId); } return null; } } /** * Shared by the automatic poll loop and the panel's manual "force-start" * action — both just need a channel + a URL to begin capturing. A no-op if * that channel already has an open session (covers both callers: the poll * loop re-checking a channel it's already recording, and someone hitting * force-start on a channel that's already live). */ export async function startCaptureSession( channel: { id: string; name: string; segmentTimeSec?: number | null }, youtubeUrl: string, liveVideoId: string | null = null, ): Promise<{ started: boolean }> { const existing = await prisma.streamSession.findFirst({ where: { channelId: channel.id, endedAt: null }, }); if (existing) return { started: false }; const session = await prisma.streamSession.create({ data: { channelId: channel.id, liveVideoId }, }); await streamIngestQueue.add("start-capture", { sessionId: session.id, channelDbId: channel.id, youtubeUrl, segmentTimeSec: channel.segmentTimeSec ?? undefined, }); await notifyIfEnabled("notify_stream_start", `🔴 *${channel.name}* canlıya geçti, kayıt başlatılıyor.`); console.log(`[youtube-polling] started session ${session.id} for channel ${channel.name}`); return { started: true }; } /** Runs the live-check for a single channel and applies its result — the unit both `pollOnce` and the panel's manual "şimdi kontrol et" action reuse. */ export async function checkChannel(channel: { id: string; name: string; channelId: string; segmentTimeSec?: number | null; }): Promise<{ liveVideoId: string | null; error: PollError | null; }> { const liveVideoId = await findLiveVideoId(channel.channelId, channel.name); await prisma.channel.update({ where: { id: channel.id }, data: { lastCheckedAt: new Date() }, }); if (liveVideoId) { await startCaptureSession(channel, `https://www.youtube.com/watch?v=${liveVideoId}`, liveVideoId); } return { liveVideoId, error: lastPollErrors.get(channel.channelId) ?? null }; } export async function pollOnce(): Promise { const channels = await prisma.channel.findMany({ where: { isActive: true } }); for (const channel of channels) { try { await checkChannel(channel); } catch (err) { console.error(`[youtube-polling] error polling channel ${channel.name}:`, err); } } } const POLL_INTERVAL_SETTING_KEY = "poll_interval_ms"; async function getPollIntervalMs(): Promise { const setting = await prisma.appSetting.findUnique({ where: { key: POLL_INTERVAL_SETTING_KEY } }); const parsed = setting ? Number(setting.value) : NaN; return Number.isFinite(parsed) && parsed >= 5_000 ? parsed : env.pollIntervalMs; } /** * Self-rescheduling instead of setInterval so a panel-side change to the * poll_interval_ms AppSetting (read fresh on every iteration, same pattern * as ytdlpAntiBotArgs()'s cookies) takes effect on the very next cycle * without a redeploy. */ export function startPollingLoop(): void { let stopped = false; async function tick() { if (stopped) return; await pollOnce().catch((err) => console.error("[youtube-polling] poll failed:", err)); const intervalMs = await getPollIntervalMs().catch(() => env.pollIntervalMs); if (!stopped) setTimeout(tick, intervalMs); } getPollIntervalMs() .then((ms) => console.log(`[youtube-polling] polling every ${ms}ms`)) .catch(() => {}); tick(); }