feat: interaktif panel kontrolleri (Faz 3)

Yayın durdurma, kanal duraklat/devam (tekli+toplu), şimdi kontrol et,
manuel URL ile zorla kayıt başlatma, canlı player+log önizleme, oturum
süre sayacı, başarısız/takılı render için tekrar dene ve iptal, poll
aralığı + ham segment TTL'yi panelden canlı değiştirme, cookie bayatlık
uyarısı, Telegram bildirim aç/kapa, ve kullanım/maliyet özeti (/stats).

api-daemon'ın yeni state-değiştiren endpoint'leri (stop/force-start/
render/cancel) INTERNAL_API_TOKEN ile korunuyor — bu daemon'ın portu
Coolify'de public'e açık olduğu için korumasız bırakmak güvenlik açığı
olurdu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 13:38:47 +03:00
co-authored by Claude Sonnet 5
parent 2fc3254d64
commit 43ccbd9ec7
23 changed files with 742 additions and 75 deletions
+66 -19
View File
@@ -69,11 +69,22 @@ async function findLiveVideoId(channelId: string): Promise<string | null> {
}
}
async function startSession(channel: { id: string; name: string }, liveVideoId: string) {
/**
* 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 },
youtubeUrl: string,
liveVideoId: string | null = null,
): Promise<{ started: boolean }> {
const existing = await prisma.streamSession.findFirst({
where: { channelId: channel.id, endedAt: null },
});
if (existing) return;
if (existing) return { started: false };
const session = await prisma.streamSession.create({
data: { channelId: channel.id, liveVideoId },
@@ -82,11 +93,33 @@ async function startSession(channel: { id: string; name: string }, liveVideoId:
await streamIngestQueue.add("start-capture", {
sessionId: session.id,
channelDbId: channel.id,
youtubeUrl: `https://www.youtube.com/watch?v=${liveVideoId}`,
youtubeUrl,
});
await sendTelegramMessage(`🔴 *${channel.name}* canlıya geçti, kayıt başlatılıyor.`);
const notifySetting = await prisma.appSetting.findUnique({ where: { key: "notify_stream_start" } });
if (notifySetting?.value !== "false") {
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}`);
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 }): Promise<{
liveVideoId: string | null;
error: string | null;
}> {
const liveVideoId = await findLiveVideoId(channel.channelId);
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> {
@@ -94,25 +127,39 @@ export async function pollOnce(): Promise<void> {
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);
}
await checkChannel(channel);
} 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);
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();
}