Files
screenclipper/apps/api-daemon/src/youtubePolling.ts
T
ayrisdevandClaude Sonnet 5 a53f0a5e04 feat: canlı-tespit hatası artık Telegram'a düşüyor + zaman damgası ekle
Poll hatası şu ana kadar sadece panele girip bakınca görülüyordu — proaktif
haberdar olma yolu yoktu. Şimdi bir kanal hata vermeye BAŞLADIĞINDA (her
60sn'de tekrar değil, sadece durum değişince) ve düzeldiğinde Telegram
bildirimi gidiyor. Ayarlar'dan kapatılabilir (notify_poll_error).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 20:53:56 +03:00

196 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, PollError>();
/** 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<void> {
const setting = await prisma.appSetting.findUnique({ where: { key } });
if (setting?.value !== "false") await sendTelegramMessage(text);
}
async function findLiveVideoId(channelId: string, channelName: string): Promise<string | null> {
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<void> {
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<number> {
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();
}