StreamClipper AI Faz 1: ingestion + sinyal analizi + STT + altyapı iskeleti

yt-dlp headless capture (15dk segmentleme), ses peak + chat velocity sinyal
tespiti, OpenAI Whisper STT (kelime zaman damgalı), BullMQ/Redis/Postgres
altyapısı ve kanal durumu + transkript kütüphanesi gösteren Next.js panel.
LLM virality skorlama, render/crop/altyazı ve multi-platform dağıtım bu
fazın kapsamı dışında.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 14:36:50 +03:00
co-authored by Claude Sonnet 5
commit eccc74166a
44 changed files with 3897 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
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";
const execFileAsync = promisify(execFile);
/**
* Live detection goes through yt-dlp itself instead of the YouTube Data API.
* `search.list?eventType=live` costs 100 quota units per call against a
* 10,000/day free quota — polling one channel every 60s alone would need
* ~144,000 units/day, well over quota. yt-dlp's `/live` redirect check is
* free and needs no API key.
*/
async function findLiveVideoId(channelId: string): Promise<string | null> {
const liveUrl = `https://www.youtube.com/channel/${channelId}/live`;
try {
const { stdout } = await execFileAsync(
"yt-dlp",
["--simulate", "--no-warnings", "--print", "%(id)s", liveUrl],
{ timeout: 20_000 },
);
const videoId = stdout.trim().split("\n")[0];
return videoId || null;
} catch {
// yt-dlp exits non-zero when the channel isn't currently live
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<void> {
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);
}