feat: analiz zincirini atlayıp ham segmenti doğrudan Telegram'a gönderme modu

Yeni "Ham Kayıt Modu" ayarı (send_raw_segments_to_telegram, varsayılan
kapalı): açıksa her ham segment tamamlanır tamamlanmaz sinyal analizi/
STT/9:16 render zincirine hiç girmeden doğrudan Telegram'a gönderiliyor
ve sunucudan siliniyor. Kullanıcı transkript/9:16 istemediğinde ("sen
bana direk videoyu gönder") tüm analiz pipeline'ını atlayıp sadece
kayıt+gönder+sil yapan basit bir mod.

- RawSegmentStatus'a SENT_TO_TELEGRAM eklendi, RawSegment.filePath
  nullable yapıldı (aynı ShortVideo.filePath deseni — gönderim sonrası
  null, dosya diskten silinmiş demek).
- api-daemon/src/telegram.ts'e sendTelegramVideo eklendi (native fetch+
  FormData+Blob ile multipart upload, ~50MB bot-limiti önceden kontrol
  ediliyor) — worker/telegram.py'deki aynı fonksiyonun Node karşılığı.
- streamIngest.ts: segment tamamlanınca, mod açıksa signal-detection
  kuyruğuna hiç eklemeden doğrudan gönderiyor (kuyruğa eklemek, aşağıda
  dosyayı silmenin sinyal analizini bozmasına sebep olurdu).
- Panel: segment listelerinde filePath=null durumunda video/indir yerine
  "Telegram'a gönderildi" notu (segments, channels/[id]).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 19:57:14 +03:00
co-authored by Claude Sonnet 5
parent 9aed4e891e
commit 11e98b300e
10 changed files with 193 additions and 52 deletions
+35 -9
View File
@@ -1,5 +1,5 @@
import { spawn, type ChildProcess } from "node:child_process";
import { mkdir, readFile } from "node:fs/promises";
import { mkdir, readFile, unlink } from "node:fs/promises";
import path from "node:path";
import { Worker, type Job } from "bullmq";
import { prisma } from "@streamclipper/db";
@@ -7,6 +7,7 @@ import { env } from "../env";
import { QUEUE_NAMES, signalDetectionQueue, type StreamIngestJob } from "../queues";
import { redisConnection } from "../redis";
import { ytdlpAntiBotArgs } from "../ytdlpCookies";
import { sendTelegramVideo } from "../telegram";
const SEGMENT_LIST_POLL_MS = 5_000;
@@ -59,13 +60,20 @@ function parseSegmentListLines(raw: string): SegmentListEntry[] {
});
}
async function sendRawSegmentsToTelegramEnabled(): Promise<boolean> {
const setting = await prisma.appSetting.findUnique({ where: { key: "send_raw_segments_to_telegram" } });
return setting?.value === "true";
}
async function processCompletedSegments(
entries: SegmentListEntry[],
fromIndex: number,
outDir: string,
sessionId: string,
channelName: string,
): Promise<number> {
let processed = fromIndex;
const sendRawToTelegram = await sendRawSegmentsToTelegramEnabled();
for (let i = fromIndex; i < entries.length; i++) {
const entry = entries[i];
@@ -82,13 +90,29 @@ async function processCompletedSegments(
},
});
await signalDetectionQueue.add("analyze-segment", {
rawSegmentId: segment.id,
filePath,
sessionId,
});
if (sendRawToTelegram) {
// Kullanıcı transkript/9:16 zincirini hiç istemiyor — ham segmenti
// analiz kuyruğuna hiç sokmadan doğrudan gönderiyoruz. Kuyruğa
// eklemiyoruz çünkü aşağıda dosyayı silmek, o segmenti daha sonra
// okumaya çalışacak sinyal analizini bozardı.
const caption = `🎬 *${channelName}* — ${duration}sn ham kayıt`;
const sent = await sendTelegramVideo(filePath, caption);
if (sent) {
await unlink(filePath).catch(() => {});
await prisma.rawSegment.update({ where: { id: segment.id }, data: { status: "SENT_TO_TELEGRAM", filePath: null } });
console.log(`[stream-ingest] segment ${entry.fileName} sent to Telegram and removed from disk`);
} else {
console.warn(`[stream-ingest] segment ${entry.fileName} failed to send to Telegram, kept on disk`);
}
} else {
await signalDetectionQueue.add("analyze-segment", {
rawSegmentId: segment.id,
filePath,
sessionId,
});
console.log(`[stream-ingest] segment ready: ${entry.fileName} -> raw_segment ${segment.id}`);
}
console.log(`[stream-ingest] segment ready: ${entry.fileName} -> raw_segment ${segment.id}`);
processed = i + 1;
}
@@ -97,6 +121,8 @@ async function processCompletedSegments(
async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
const { sessionId, youtubeUrl, segmentTimeSec, channelDbId } = job.data;
const channel = await prisma.channel.findUnique({ where: { id: channelDbId } });
const channelName = channel?.name ?? "Bilinmeyen kanal";
const outDir = path.join(env.sharedMediaRoot, "raw", sessionId);
await mkdir(outDir, { recursive: true });
@@ -150,7 +176,7 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
if (!raw) return;
const entries = parseSegmentListLines(raw);
if (entries.length > lastProcessedIndex) {
lastProcessedIndex = await processCompletedSegments(entries, lastProcessedIndex, outDir, sessionId);
lastProcessedIndex = await processCompletedSegments(entries, lastProcessedIndex, outDir, sessionId, channelName);
await prisma.streamSession.update({
where: { id: sessionId },
data: { totalSegments: lastProcessedIndex },
@@ -178,7 +204,7 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
const raw = await readFile(segmentListPath, "utf8").catch(() => "");
const entries = parseSegmentListLines(raw);
if (entries.length > lastProcessedIndex) {
lastProcessedIndex = await processCompletedSegments(entries, lastProcessedIndex, outDir, sessionId);
lastProcessedIndex = await processCompletedSegments(entries, lastProcessedIndex, outDir, sessionId, channelName);
}
await prisma.streamSession.update({
+1 -1
View File
@@ -32,7 +32,7 @@ async function cleanupOnce(): Promise<void> {
for (const segment of staleSegments) {
await prisma.candidateSegment.deleteMany({ where: { rawSegmentId: segment.id } });
await prisma.rawSegment.delete({ where: { id: segment.id } });
await unlink(segment.filePath).catch(() => {});
if (segment.filePath) await unlink(segment.filePath).catch(() => {});
}
if (staleSegments.length > 0) {
+43
View File
@@ -1,6 +1,12 @@
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN ?? "";
const CHAT_ID = process.env.TELEGRAM_CHAT_ID ?? "";
// Telegram's bot-upload limit (no local Bot API server).
const MAX_VIDEO_BYTES = 50 * 1024 * 1024;
export async function sendTelegramMessage(text: string): Promise<void> {
if (!BOT_TOKEN || !CHAT_ID) {
console.warn("[telegram] TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID not set, skipping notification:", text);
@@ -18,3 +24,40 @@ export async function sendTelegramMessage(text: string): Promise<void> {
console.error("[telegram] failed to send message:", res.status, await res.text());
}
}
/** Uploads a video file to Telegram. Returns false (never throws) on any failure — the caller decides what that means for the local file. */
export async function sendTelegramVideo(filePath: string, caption: string): Promise<boolean> {
if (!BOT_TOKEN || !CHAT_ID) {
console.warn("[telegram] TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID not set, skipping video:", caption);
return false;
}
const { size } = await stat(filePath);
if (size > MAX_VIDEO_BYTES) {
console.warn(`[telegram] video ${filePath} is ${size} bytes, over Telegram's bot-upload limit — skipping`);
return false;
}
try {
const buffer = await readFile(filePath);
const form = new FormData();
form.set("chat_id", CHAT_ID);
form.set("caption", caption);
form.set("parse_mode", "Markdown");
form.set("video", new Blob([buffer], { type: "video/mp4" }), path.basename(filePath));
const res = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendVideo`, {
method: "POST",
body: form,
});
if (!res.ok) {
console.error("[telegram] failed to send video:", res.status, await res.text());
return false;
}
return true;
} catch (err) {
console.error("[telegram] failed to upload video:", err);
return false;
}
}