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>
196 lines
7.5 KiB
TypeScript
196 lines
7.5 KiB
TypeScript
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();
|
||
}
|