Compare commits

...
13 Commits
Author SHA1 Message Date
ayrisdevandClaude Sonnet 5 11e98b300e 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>
2026-09-04 19:57:14 +03:00
ayrisdevandClaude Sonnet 5 9aed4e891e perf: segment süresini 5 dakikadan 3 dakikaya düşür (Telegram video limiti)
Global SEGMENT_TIME_SEC varsayılanı 300->180 (docker-compose, .env.example,
env.ts, signal_detection.py fallback'i). 5 dakikalık segmentlerden çıkan
adaylar ~56MB'a kadar çıkabiliyordu — Telegram bot'ların sendVideo ile
yükleyebileceği ~50MB sınırını aşıp gönderim başarısız oluyordu (bkz.
delete_after_telegram_send). 3 dakika bu sınırın güvenli tarafında kalıyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 18:48:45 +03:00
ayrisdevandClaude Sonnet 5 ccd922ba37 feat: birden fazla YouTube hesabı arasında cookie'yi dağıt (Faz 6)
Proxy IP'lerini havuza dağıttığımız aynı sorun cookie/hesap seviyesinde de
vardı: tek bir Google hesabı 7-8 kanalı taramaya çalışınca YouTube hesabın
kendisini şüpheli bulup çoğu/tüm kanalda "Sign in to confirm you're not a
bot" veriyordu — proxy IP'den bağımsız bir sinyal, IP dağıtımı bunu
çözemez.

Yeni CookieProfile tablosu: her kanal, DB id'sinden aynı deterministik hash
ile (proxyUrlForChannel'daki gibi) havuzdaki hesaplardan birine sabitlenir.
Her profil kendi ayrı geçici dosyasına yazılıyor (/tmp/yt-cookies-<id>.txt)
— tek paylaşılan dosya, farklı profil kullanan kanallar eşzamanlı
çalışınca birbirinin cookie'sini ezerdi. Hiç profil eklenmemişse eski tekli
ytdlp_cookies ayarına geri düşüyor (geriye dönük uyumlu, hemen kırılmaz).

Ayarlar sayfasında yeni "Cookie Hesapları" kartı: hesap ekle/sil/yenile,
her biri için ayrı bayatlık rozeti. Eski tekli cookie kartı "tekli/eski"
etiketiyle fallback olarak duruyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 01:20:36 +03:00
ayrisdevandClaude Sonnet 5 eb2a9d28ed feat: 9:16 render'ı isteğe bağlı yap + Telegram'a video gönderip sunucudan sil (Faz 5)
- auto_render_enabled (varsayılan açık): kapatılınca transkript sonrası
  otomatik render tetiklenmez, aday TRANSCRIBED'de bekler — panelin zaten
  var olan "9:16 Render Et" butonuyla elle tetiklenebilir.
- delete_after_telegram_send (varsayılan KAPALI — geri alınamaz bir
  davranış, bilinçli açılmalı): açıksa render biten video Telegram'a
  gerçek dosya olarak gönderilir (yeni send_telegram_video, sendVideo
  multipart upload, ~50MB bot-upload limiti önceden kontrol ediliyor);
  gönderim başarılıysa dosya diskten silinip ShortVideo.file_path NULL'a
  çekiliyor (DB kaydı/transkript kalıcı kalıyor), başarısızsa dosya
  sunucuda kalıp düz metin bildirimi gidiyor. Kanal sayısı arttıkça VPS
  disk doluluğunu önlemek için.
- Panel: READY + file_path=null durumunda video/indir yerine "Telegram'a
  gönderildi, sunucuda saklanmıyor" notu (channels/[id], segments).

Şema değişikliği yok — ShortVideo.filePath zaten nullable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 01:04:57 +03:00
ayrisdevandClaude Sonnet 5 63443f3392 fix: capture'da android player_client'ı kapat (videoplayback 403 kaynağı olabilir)
@liberotv'de segment indirirken sürekli "403 Forbidden / Failed to open
segment" hatası vardı — kendi ayrılmış proxy IP'sinde bile. yt-dlp'nin
GitHub issue'larında android client'ının videoplayback isteklerinde 403'e
sebep olduğu birden fazla kez raporlanmış. Tespit adımı (--simulate, format
çözümlemesi yok) risksiz olduğu için web,mweb,android'i koruyor; asıl
capture artık sadece web,mweb kullanıyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 00:25:58 +03:00
ayrisdevandClaude Sonnet 5 57ca4b5878 perf: her kanalı Oxylabs ISP havuzundaki ayrı bir statik IP'ye sabitle
Dashboard'dan doğrulandı: isp.oxylabs.io:8001-8010 her biri farklı, statik
(rotasyon yapmayan) bir IP'ye karşılık geliyor. Ama PROXY_URL'in portu hep
sabitti — 7-8 kanalın tüm poll+capture trafiği tek bir IP'den (8001)
geçiyordu. Artık her kanal, DB id'sinden deterministik bir hash ile
havuzdaki 10 porttan birine sabitleniyor (aynı kanal hep aynı IP'yi
kullanıyor, farklı kanallar havuza yayılıyor) — hem YouTube'un tek IP
üzerindeki yük algısını azaltıyor hem de bant genişliğini paylaştırıyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 00:11:05 +03:00
ayrisdevandClaude Sonnet 5 59888a6513 fix: bildirim spam'i - kanal başına 10dk soğuma süresi
Çöküp yeniden başlayan bir kanal (bkz. streamIngest'teki crash-loop notu)
dakikalar içinde art arda "başladı" + "hata" + "düzeldi" mesajı atıyordu —
birkaç kanal aynı anda flap edince gerçek bir spam'e dönüştü (kullanıcı
raporu: "sürekli bildirim geliyor"). Üç bildirim türü de artık kanal
başına ortak 10dk'lık bir soğuma süresini paylaşıyor — bir kanal ilk
olayını bildirir, sonra durum tekrar tekrar sıçrasa bile bir süre sessiz
kalır.

Not: bu sadece bildirim gürültüsünü kesiyor, alttaki crash-loop'un kendisi
(muhtemelen kaynak/proxy kapasitesi) hâlâ duruyor — o konu ayrı, kullanıcı
ile "önce test edelim" üzerinde anlaşıldı.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 00:00:32 +03:00
ayrisdevandClaude Sonnet 5 597b1186ab perf: zaten kayıtta olan kanalları tekrar poll etme + kontroller arasına gecikme ekle
pollOnce her döngüde AKTİF kanalların hepsini (zaten kayıtta olanlar dahil)
yt-dlp'nin bot-check riskli /live sorgusuna sokuyordu — gereksiz. Birden
fazla kanal aynı anda canlıyken bu, aynı paylaşılan cookie+proxy üzerinden
gereksiz istek hacmi katlıyordu (muhtemelen 4-5 kanal eklenince birkaç
kanalın aynı anda tespit hatası vermeye başlamasının sebeplerinden biri).
Artık zaten açık bir StreamSession'ı olan kanal tamamen atlanıyor, kalan
kanallar arasına da 2sn gecikme eklendi (art arda patlama yerine seyrek).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 23:20:58 +03:00
ayrisdevandClaude Sonnet 5 b5c761afbf fix: frontend production'da next dev yerine build+start çalışsın
Dockerfile'ın CMD'si next dev kullanıyordu — Coolify'de canlı sistemde
dev-mode overlay/bildirimleri (Next.js sürüm uyarısı, hata detayları) ve
optimize edilmemiş, JIT-compile edilen bir build çalışıyordu. Artık image
build sırasında next build çalışıyor, CMD sadece next start.

DATABASE_URL build adımında gerçek bir DB'ye ihtiyaç duymuyor (tüm
sayfalar force-dynamic, build sırasında sorgu atılmıyor) ama Prisma
client import'ta connection string formatını doğruluyor — yer tutucu bir
değer verildi, Coolify'nin gerçek DATABASE_URL'i runtime'da onun üzerine
yazıyor zaten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 22:59:53 +03:00
ayrisdevandClaude Sonnet 5 dea207996e fix: kanal detay sayfasında iç içe girinti (segment/aday klip) netleştirildi
3 seviyeli border-left girinti (oturum > segment > aday klip) yerine
gerçek kutu (soft-card) blokları — her segment kendi arka planıyla
ayrışıyor, adaylar onun içinde daha da net bir alt kutuda. Segment
başlığı artık ham dosya yolu değil ("15sn segment · 02.09.2026 20:14"
gibi) insan-okur bir etiket — dosya yolu küçük/soluk referans olarak
altta kaldı. "Canlı Durum" kartındaki karışık bölümler de (hata/canlı
önizleme/log/segment ayarı/manuel başlat) Separator ile ayrıştırıldı.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 22:58:06 +03:00
ayrisdevandClaude Sonnet 5 58ef40edf8 feat: segment süresi varsayılanını 15 dk -> 5 dk düşür + dashboard'u kart-grid'e çevir
Global SEGMENT_TIME_SEC varsayılanı 900->300 (docker-compose, .env.example,
env.ts, signal_detection.py fallback'i) — kanal bazlı override zaten
panelden ayarlanabiliyordu, bu sadece varsayılanı değiştiriyor.

Dashboard'daki kanal listesi tablo yerine kart-grid: her kanal kendi
kartında, durum rozetleri üstte belirgin, aksiyonlar altta — önceki
tablo düzeni (özellikle aksiyon sütunundaki sıkışık butonlar) okunması
zor bulunuyordu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 22:55:12 +03:00
ayrisdevandClaude Sonnet 5 eca80959fb fix: aday klibi olmayan segmentlerde neden hiçbir şey olmadığı belirsizdi
UI yenilemesinde segments/page.tsx'teki "aday klip bulunamadı" boş-durum
mesajı kanal detay sayfasına taşınmamıştı — sinyal analizi bir segmentte
ilginç an bulamayınca panelde sessizce hiçbir şey görünmüyordu, bozukmuş
gibi duruyordu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 21:06:08 +03:00
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
23 changed files with 861 additions and 259 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
POLL_INTERVAL_MS=60000
SEGMENT_TIME_SEC=900
SEGMENT_TIME_SEC=180
API_DAEMON_PORT=4001
# Signs the panel's login session cookies (frontend). Generate with:
+37 -11
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;
}
@@ -96,7 +120,9 @@ async function processCompletedSegments(
}
async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
const { sessionId, youtubeUrl, segmentTimeSec } = job.data;
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 });
@@ -108,7 +134,7 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
"yt-dlp",
[
"--js-runtimes", "node",
...(await ytdlpAntiBotArgs()),
...(await ytdlpAntiBotArgs(channelDbId, { includeAndroidClient: false })),
"-f", "bestvideo+bestaudio/best", "-o", "-", youtubeUrl,
],
{ stdio: ["ignore", "pipe", "pipe"] },
@@ -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) {
+1 -1
View File
@@ -4,7 +4,7 @@ export const env = {
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",
sharedMediaRoot: process.env.SHARED_MEDIA_ROOT ?? "./shared-media",
pollIntervalMs: Number(process.env.POLL_INTERVAL_MS ?? 60_000),
segmentTimeSec: Number(process.env.SEGMENT_TIME_SEC ?? 900),
segmentTimeSec: Number(process.env.SEGMENT_TIME_SEC ?? 180),
port: Number(process.env.API_DAEMON_PORT ?? 4001),
forceLiveUrl: process.env.FORCE_LIVE_URL ?? "",
ytdlpPotProviderUrl: process.env.YTDLP_POT_PROVIDER_URL ?? "",
+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;
}
}
+61 -8
View File
@@ -43,7 +43,29 @@ const BENIGN_POLL_MESSAGE = /is not currently live|will begin in/;
* 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 findLiveVideoId(channelId: string): Promise<string | null> {
/**
* A channel whose capture keeps crashing and restarting (see streamIngest's
* crash-loop notes) fires "started" + "poll failing" + "poll recovered" over
* and over within minutes — observed directly once several channels were
* live at once. One shared per-channel cooldown across all three message
* types means a flapping channel says its piece once, then goes quiet for a
* while instead of narrating every blip.
*/
const NOTIFY_COOLDOWN_MS = 10 * 60 * 1000;
const lastNotifyAt = new Map<string, number>();
async function notifyIfEnabled(key: string, channelId: string, text: string): Promise<void> {
const last = lastNotifyAt.get(channelId) ?? 0;
if (Date.now() - last < NOTIFY_COOLDOWN_MS) return;
const setting = await prisma.appSetting.findUnique({ where: { key } });
if (setting?.value === "false") return;
lastNotifyAt.set(channelId, Date.now());
await sendTelegramMessage(text);
}
async function findLiveVideoId(channelId: string, channelName: string, channelDbId: string): Promise<string | null> {
const liveUrl = `https://www.youtube.com/channel/${channelId}/live`;
try {
@@ -54,14 +76,19 @@ async function findLiveVideoId(channelId: string): Promise<string | null> {
"--no-warnings",
"--js-runtimes",
"node",
...(await ytdlpAntiBotArgs()),
...(await ytdlpAntiBotArgs(channelDbId)),
"--print",
"%(id)s",
liveUrl,
],
{ timeout: 20_000 },
);
lastPollErrors.delete(channelId);
// 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", channelDbId, `✅ *${channelName}* canlı-tespiti tekrar çalışıyor.`);
}
const videoId = stdout.trim().split("\n")[0];
return videoId || null;
} catch (err) {
@@ -69,7 +96,17 @@ async function findLiveVideoId(channelId: string): Promise<string | null> {
// 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",
channelDbId,
`⚠️ *${channelName}* canlı-tespiti başarısız oluyor:\n\`${message.slice(0, 300)}\``,
);
}
} else {
lastPollErrors.delete(channelId);
}
@@ -105,10 +142,7 @@ export async function startCaptureSession(
segmentTimeSec: channel.segmentTimeSec ?? undefined,
});
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.`);
}
await notifyIfEnabled("notify_stream_start", channel.id, `🔴 *${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 };
}
@@ -123,7 +157,7 @@ export async function checkChannel(channel: {
liveVideoId: string | null;
error: PollError | null;
}> {
const liveVideoId = await findLiveVideoId(channel.channelId);
const liveVideoId = await findLiveVideoId(channel.channelId, channel.name, channel.id);
await prisma.channel.update({
where: { id: channel.id },
data: { lastCheckedAt: new Date() },
@@ -139,12 +173,31 @@ export async function checkChannel(channel: {
export async function pollOnce(): Promise<void> {
const channels = await prisma.channel.findMany({ where: { isActive: true } });
const openSessions = await prisma.streamSession.findMany({
where: { endedAt: null, channelId: { in: channels.map((c) => c.id) } },
select: { channelId: true },
});
const recordingChannelIds = new Set(openSessions.map((s) => s.channelId));
for (const channel of channels) {
// Already capturing this channel — we already know it's live, so skip
// the bot-check-prone /live lookup entirely. With several channels
// sharing one cookie session + one proxy IP, re-checking channels that
// don't need it multiplies request volume through that single shared
// identity for no reason — a likely contributor to multiple channels
// failing detection at once once a few are live simultaneously.
if (recordingChannelIds.has(channel.id)) continue;
try {
await checkChannel(channel);
} catch (err) {
console.error(`[youtube-polling] error polling channel ${channel.name}:`, err);
}
// Stagger checks instead of firing them back-to-back — a tight burst of
// near-simultaneous requests through the same cookie/IP looks more
// automated than the same requests spread out.
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
+81 -9
View File
@@ -2,8 +2,70 @@ import { writeFileSync } from "node:fs";
import { prisma } from "@streamclipper/db";
import { env } from "./env";
const COOKIES_PATH = "/tmp/yt-cookies.txt";
const COOKIES_SETTING_KEY = "ytdlp_cookies";
const LEGACY_COOKIES_PATH = "/tmp/yt-cookies.txt";
const LEGACY_COOKIES_SETTING_KEY = "ytdlp_cookies";
// Oxylabs' static ISP pool: one dedicated (non-rotating) IP per port,
// confirmed against the dashboard's Proxy list (isp.oxylabs.io:8001..8010 ->
// 10 distinct IPs). PROXY_URL's own port is the base; funneling every
// channel through that single port meant 7-8 simultaneous channels' worth
// of YouTube-facing traffic all looked like it came from one IP.
const PROXY_PORT_POOL_SIZE = 10;
function simpleHash(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = (hash * 31 + input.charCodeAt(i)) >>> 0;
}
return hash;
}
/** Deterministically pins a channel to one of the pool's ports — same channel always gets the same IP (so it isn't mid-session IP-mismatched against a live URL signed for a different one), different channels spread across the pool. */
function proxyUrlForChannel(baseProxyUrl: string, channelKey: string): string {
const match = baseProxyUrl.match(/^(.*:)(\d+)$/);
if (!match) return baseProxyUrl;
const [, prefix, basePortStr] = match;
const basePort = Number(basePortStr);
const offset = simpleHash(channelKey) % PROXY_PORT_POOL_SIZE;
return `${prefix}${basePort + offset}`;
}
/**
* Picks which cookie profile a channel uses (same deterministic-hash
* pinning as proxyUrlForChannel — same channel always gets the same
* account, different channels spread across the pool) and writes it to a
* profile-specific temp file. Falls back to the old single global
* ytdlp_cookies AppSetting if no profiles have been added yet, so nothing
* breaks before the panel's Settings page is used to add accounts.
*
* A single shared cookie file was a real bottleneck once several channels
* were active at once: it isn't just proxy IP reputation that YouTube can
* flag, it's the *account* — one Google session making requests that look
* like it's scanning many unrelated channels trips its own abuse signal,
* independent of which IP each request came from. Splitting across
* separate throwaway accounts dilutes that per-account signal the same way
* the proxy pool dilutes per-IP signal.
*
* Profile-specific file paths (not one shared /tmp/yt-cookies.txt) matter
* once multiple channels can be using *different* profiles concurrently —
* a shared path would let one channel's capture process read a file another
* channel's poll check just overwrote with a different account's cookies.
*/
async function cookiesFilePathForChannel(channelKey: string): Promise<string | null> {
const profiles = await prisma.cookieProfile.findMany({ orderBy: { createdAt: "asc" } });
if (profiles.length === 0) {
const legacy = await prisma.appSetting.findUnique({ where: { key: LEGACY_COOKIES_SETTING_KEY } });
if (!legacy?.value) return null;
writeFileSync(LEGACY_COOKIES_PATH, legacy.value, { mode: 0o600 });
return LEGACY_COOKIES_PATH;
}
const profile = profiles[simpleHash(channelKey) % profiles.length];
const path = `/tmp/yt-cookies-${profile.id}.txt`;
writeFileSync(path, profile.value, { mode: 0o600 });
return path;
}
/**
* YouTube's anti-bot layer against yt-dlp has three parts, all needed
@@ -21,13 +83,15 @@ const COOKIES_SETTING_KEY = "ytdlp_cookies";
* current value and rewrites the temp file, so a panel update takes effect
* on the very next yt-dlp invocation without a redeploy.
*/
export async function ytdlpAntiBotArgs(): Promise<string[]> {
export async function ytdlpAntiBotArgs(
channelKey: string,
{ includeAndroidClient = true }: { includeAndroidClient?: boolean } = {},
): Promise<string[]> {
const args: string[] = [];
const setting = await prisma.appSetting.findUnique({ where: { key: COOKIES_SETTING_KEY } });
if (setting?.value) {
writeFileSync(COOKIES_PATH, setting.value, { mode: 0o600 });
args.push("--cookies", COOKIES_PATH);
const cookiesPath = await cookiesFilePathForChannel(channelKey);
if (cookiesPath) {
args.push("--cookies", cookiesPath);
}
if (env.ytdlpPotProviderUrl) {
@@ -39,10 +103,18 @@ export async function ytdlpAntiBotArgs(): Promise<string[]> {
// you're not a bot" ile tıkanabiliyor — küçük kanallarda aynı kombinasyon
// sorunsuz çalışıyor. mweb/android'i cookie-uyumlu fallback olarak
// ekleyip yt-dlp'nin ilk başarılı client'ı kullanmasını sağlıyoruz.
args.push("--extractor-args", "youtube:player_client=web,mweb,android");
//
// Ama "android" client'ı yt-dlp'nin kendi GitHub issue'larında birden
// fazla kez videoplayback/segment isteklerinde 403 Forbidden'a sebep
// olarak raporlanmış — sade metadata/tespit isteğinde (--simulate, format
// çözümlemesi yok) risk taşımıyor ama gerçek segment indirimi sırasında
// (asıl capture) bu 403'lerin kaynağı olabilir. O yüzden capture çağrısı
// android'i devre dışı bırakıyor, sadece tespit çağrısı kullanıyor.
const clients = includeAndroidClient ? "web,mweb,android" : "web,mweb";
args.push("--extractor-args", `youtube:player_client=${clients}`);
if (env.proxyUrl) {
args.push("--proxy", env.proxyUrl);
args.push("--proxy", proxyUrlForChannel(env.proxyUrl, channelKey));
}
return args;
+9 -1
View File
@@ -7,4 +7,12 @@ COPY . .
RUN pnpm install
RUN pnpm --filter @streamclipper/db exec prisma generate
CMD ["pnpm", "--filter", "@streamclipper/frontend", "dev"]
# DATABASE_URL isn't needed for real at build time (all pages are
# force-dynamic — nothing queries the DB during `next build`), but
# Prisma's client validates the connection string format at import, so a
# well-formed placeholder keeps the build step from failing. Coolify's
# actual DATABASE_URL is supplied as a runtime env var and overrides this.
ENV DATABASE_URL="postgresql://build:build@localhost:5432/build"
RUN pnpm --filter @streamclipper/frontend build
CMD ["pnpm", "--filter", "@streamclipper/frontend", "start"]
+65 -2
View File
@@ -40,7 +40,7 @@ export async function deleteRawSegment(segmentId: string) {
await deleteShortsForCandidates(segment.candidates.map((c) => c.id));
await prisma.candidateSegment.deleteMany({ where: { rawSegmentId: segmentId } });
await prisma.rawSegment.delete({ where: { id: segmentId } });
await unlink(segment.filePath).catch(() => {});
if (segment.filePath) await unlink(segment.filePath).catch(() => {});
revalidatePath("/segments");
revalidatePath("/", "layout");
@@ -61,7 +61,7 @@ export async function deleteChannel(channelId: string) {
});
const rawSegmentIds = sessions.flatMap((s) => s.segments.map((seg) => seg.id));
const candidateIds = sessions.flatMap((s) => s.segments.flatMap((seg) => seg.candidates.map((c) => c.id)));
const filePaths = sessions.flatMap((s) => s.segments.map((seg) => seg.filePath));
const filePaths = sessions.flatMap((s) => s.segments.map((seg) => seg.filePath)).filter((p): p is string => Boolean(p));
await deleteShortsForCandidates(candidateIds);
await prisma.candidateSegment.deleteMany({ where: { rawSegmentId: { in: rawSegmentIds } } });
@@ -155,10 +155,46 @@ export async function updateSttEnabled(formData: FormData) {
revalidatePath("/settings");
}
export async function updateAutoRenderEnabled(formData: FormData) {
const enabled = formData.get("autoRenderEnabled") === "true";
await prisma.appSetting.upsert({
where: { key: "auto_render_enabled" },
create: { key: "auto_render_enabled", value: String(enabled) },
update: { value: String(enabled) },
});
revalidatePath("/settings");
}
export async function updateDeleteAfterTelegramSend(formData: FormData) {
const enabled = formData.get("deleteAfterTelegramSend") === "true";
await prisma.appSetting.upsert({
where: { key: "delete_after_telegram_send" },
create: { key: "delete_after_telegram_send", value: String(enabled) },
update: { value: String(enabled) },
});
revalidatePath("/settings");
}
export async function updateSendRawSegmentsToTelegram(formData: FormData) {
const enabled = formData.get("sendRawSegmentsToTelegram") === "true";
await prisma.appSetting.upsert({
where: { key: "send_raw_segments_to_telegram" },
create: { key: "send_raw_segments_to_telegram", value: String(enabled) },
update: { value: String(enabled) },
});
revalidatePath("/settings");
}
export async function updateNotificationPrefs(formData: FormData) {
const streamStart = formData.get("notifyStreamStart") === "true";
const renderDone = formData.get("notifyRenderDone") === "true";
const pollError = formData.get("notifyPollError") === "true";
await prisma.appSetting.upsert({
where: { key: "notify_poll_error" },
create: { key: "notify_poll_error", value: String(pollError) },
update: { value: String(pollError) },
});
await prisma.appSetting.upsert({
where: { key: "notify_stream_start" },
create: { key: "notify_stream_start", value: String(streamStart) },
@@ -188,3 +224,30 @@ export async function updateYtdlpCookies(formData: FormData) {
revalidatePath("/settings");
}
export async function addCookieProfile(formData: FormData) {
const label = String(formData.get("label") ?? "").trim();
const value = String(formData.get("value") ?? "").trim();
if (!label || !value) {
throw new Error("Hesap adı ve cookie içeriği zorunlu.");
}
await prisma.cookieProfile.create({ data: { label, value } });
revalidatePath("/settings");
}
export async function updateCookieProfile(profileId: string, formData: FormData) {
const value = String(formData.get("value") ?? "").trim();
if (!value) {
throw new Error("Cookie içeriği boş olamaz.");
}
await prisma.cookieProfile.update({ where: { id: profileId }, data: { value } });
revalidatePath("/settings");
}
export async function deleteCookieProfile(profileId: string) {
await prisma.cookieProfile.delete({ where: { id: profileId } });
revalidatePath("/settings");
}
@@ -6,7 +6,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
const { id } = await params;
const segment = await prisma.rawSegment.findUnique({ where: { id } });
if (!segment) return new Response("Not found", { status: 404 });
if (!segment?.filePath) return new Response("Not found", { status: 404 });
return streamVideoFile(req, segment.filePath, `${id}.mp4`);
}
+156 -119
View File
@@ -21,6 +21,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge, type badgeVariants } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import type { VariantProps } from "class-variance-authority";
export const dynamic = "force-dynamic";
@@ -29,6 +30,7 @@ const STATUS_BADGE: Record<string, VariantProps<typeof badgeVariants>["variant"]
PENDING: "muted",
PROCESSED: "ok",
DISCARDED: "err",
SENT_TO_TELEGRAM: "ok",
PENDING_STT: "warn",
TRANSCRIBED: "ok",
RENDERING: "warn",
@@ -111,40 +113,12 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
)}
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<CardContent className="flex flex-col gap-4">
<p className="font-mono-num text-xs text-muted-foreground">
Aktif: {channel.isActive ? "evet" : "hayır"} · Son kontrol:{" "}
{channel.lastCheckedAt ? formatTr(channel.lastCheckedAt) : "—"}
</p>
<form
action={updateChannelSegmentTime.bind(null, channel.id)}
className="flex flex-wrap items-end gap-2"
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="segmentTimeMin" className="text-xs text-muted-foreground">
Bu kanal için segment süresi (dakika)
</Label>
<Input
id="segmentTimeMin"
name="segmentTimeMin"
type="number"
min={1}
max={60}
step={0.5}
placeholder="varsayılan (15)"
defaultValue={channel.segmentTimeSec ? channel.segmentTimeSec / 60 : ""}
className="w-40"
/>
</div>
<Button type="submit" variant="outline" size="sm">
Kaydet
</Button>
<span className="text-xs text-muted-foreground">
Boş bırakılırsa global varsayılan kullanılır. Sadece bundan sonra başlayacak kayıtları etkiler.
</span>
</form>
{status === undefined && (
<p className="text-sm text-muted-foreground">
api-daemon&apos;dan canlı durum alınamadı (servis erişilemez olabilir).
@@ -152,7 +126,7 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
)}
{status?.lastPollError && (
<div className="flex flex-col gap-1.5">
<div className="flex flex-col gap-1.5 rounded-lg border border-err/30 bg-err/5 p-3">
<div className="flex items-center gap-2">
<Badge variant="err" className="w-fit">
son poll hatası
@@ -161,32 +135,64 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
{formatTr(status.lastPollError.at)}
</span>
</div>
<pre className="font-mono-num whitespace-pre-wrap rounded-md border border-border bg-background/60 p-3 text-[0.7rem] text-muted-foreground">
<pre className="font-mono-num whitespace-pre-wrap text-[0.7rem] text-muted-foreground">
{status.lastPollError.message}
</pre>
</div>
)}
{status?.recording && activeSession?.liveVideoId && activeSession.liveVideoId !== "forced" && (
<iframe
src={`https://www.youtube.com/embed/${activeSession.liveVideoId}`}
title="Canlı yayın önizleme"
className="aspect-video w-full max-w-md rounded-md border border-border"
allow="autoplay; encrypted-media"
/>
{status?.recording && (
<div className="flex flex-col gap-3">
{activeSession?.liveVideoId && activeSession.liveVideoId !== "forced" && (
<iframe
src={`https://www.youtube.com/embed/${activeSession.liveVideoId}`}
title="Canlı yayın önizleme"
className="aspect-video w-full max-w-md rounded-md border border-border"
allow="autoplay; encrypted-media"
/>
)}
{activeSession && <LiveLogViewer sessionId={activeSession.id} />}
</div>
)}
{status?.recording && activeSession && <LiveLogViewer sessionId={activeSession.id} />}
<Separator />
<details className="mt-1 text-sm">
<summary className="cursor-pointer font-mono-num text-xs text-muted-foreground">
Manuel URL ile kayıt başlat
</summary>
<form action={forceStartCapture.bind(null, channel.id)} className="mt-2 flex gap-2">
<Input name="url" placeholder="https://www.youtube.com/watch?v=..." required className="flex-1" />
<Button type="submit">Başlat</Button>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<form
action={updateChannelSegmentTime.bind(null, channel.id)}
className="flex flex-wrap items-end gap-2"
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="segmentTimeMin" className="text-xs text-muted-foreground">
Bu kanal için segment süresi (dakika)
</Label>
<Input
id="segmentTimeMin"
name="segmentTimeMin"
type="number"
min={1}
max={60}
step={0.5}
placeholder="varsayılan"
defaultValue={channel.segmentTimeSec ? channel.segmentTimeSec / 60 : ""}
className="w-32"
/>
</div>
<Button type="submit" variant="outline" size="sm">
Kaydet
</Button>
</form>
</details>
<details className="text-sm">
<summary className="cursor-pointer font-mono-num text-xs text-muted-foreground">
Manuel URL ile kayıt başlat
</summary>
<form action={forceStartCapture.bind(null, channel.id)} className="mt-2 flex gap-2">
<Input name="url" placeholder="https://www.youtube.com/watch?v=..." required className="w-64" />
<Button type="submit">Başlat</Button>
</form>
</details>
</div>
</CardContent>
</Card>
@@ -213,90 +219,121 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
) : (
<CardContent className="flex flex-col gap-4">
{session.segments.map((segment) => (
<div key={segment.id} className="border-l-2 border-border pl-4">
<div key={segment.id} className="rounded-lg border border-border bg-muted/20 p-4">
<div className="flex items-center justify-between gap-2">
<span className="font-mono-num text-xs text-muted-foreground">{segment.filePath}</span>
<span className="text-sm font-medium">
{segment.duration}sn segment · {formatTr(segment.startedAt)}
</span>
<Badge variant={STATUS_BADGE[segment.status] ?? "muted"}>{segment.status}</Badge>
</div>
<video controls preload="metadata" className="mt-2 w-full max-w-md rounded-md border border-border">
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
</video>
<div className="mt-2 flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<a href={`/api/segments/${segment.id}/video?download=1`}>İndir</a>
</Button>
<form action={deleteRawSegment.bind(null, segment.id)}>
<ConfirmButton
confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz."
label="Sil"
/>
</form>
</div>
{segment.candidates.map((c) => (
<div key={c.id} className="mt-3 border-l-2 border-border pl-4">
<div className="flex items-center justify-between gap-2">
<span className="font-mono-num text-xs text-muted-foreground">
{c.startSec}s {c.endSec}s
{c.audioPeakScore != null && ` · peak ${c.audioPeakScore.toFixed(1)}dB`}
</span>
<Badge variant={STATUS_BADGE[c.status] ?? "muted"}>{c.status}</Badge>
{segment.filePath ? (
<>
<span className="font-mono-num text-[0.7rem] text-muted-foreground">{segment.filePath}</span>
<video controls preload="metadata" className="mt-2 w-full max-w-md rounded-md border border-border">
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
</video>
<div className="mt-2 flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<a href={`/api/segments/${segment.id}/video?download=1`}>İndir</a>
</Button>
<form action={deleteRawSegment.bind(null, segment.id)}>
<ConfirmButton
confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz."
label="Sil"
/>
</form>
</div>
{transcriptPreview(c.transcriptJson) && (
<p className="mt-1.5 text-sm leading-relaxed">{transcriptPreview(c.transcriptJson)}</p>
)}
</>
) : (
<p className="mt-1 text-xs text-muted-foreground">
Telegram&apos;a gönderildi, sunucuda saklanmıyor.
</p>
)}
{c.short && (
<div className="mt-2 flex flex-col items-start gap-2">
<Badge variant={STATUS_BADGE[c.short.status] ?? "muted"}>9:16: {c.short.status}</Badge>
{c.short.status === "READY" && (
<>
<video controls preload="metadata" className="w-[220px] rounded-md border border-border">
<source src={`/api/shorts/${c.short.id}/video`} type="video/mp4" />
</video>
<Button asChild variant="outline" size="sm">
<a href={`/api/shorts/${c.short.id}/video?download=1`}>İndir</a>
</Button>
</>
{segment.status === "SENT_TO_TELEGRAM" ? (
<p className="mt-3 text-xs text-muted-foreground">
Analiz edilmeden (transkript/9:16 atlanarak) doğrudan gönderildi.
</p>
) : (
segment.candidates.length === 0 &&
segment.status !== "PENDING" && (
<p className="mt-3 text-xs text-muted-foreground">
Bu segmentte aday klip bulunamadı (sinyal analizi ilginç bir an tespit etmedi).
</p>
)
)}
{segment.candidates.length > 0 && (
<div className="mt-3 flex flex-col gap-2">
{segment.candidates.map((c) => (
<div key={c.id} className="rounded-md border border-border bg-background/60 p-3">
<div className="flex items-center justify-between gap-2">
<span className="font-mono-num text-xs text-muted-foreground">
{c.startSec}s {c.endSec}s
{c.audioPeakScore != null && ` · peak ${c.audioPeakScore.toFixed(1)}dB`}
</span>
<Badge variant={STATUS_BADGE[c.status] ?? "muted"}>{c.status}</Badge>
</div>
{transcriptPreview(c.transcriptJson) && (
<p className="mt-1.5 text-sm leading-relaxed">{transcriptPreview(c.transcriptJson)}</p>
)}
{c.short.status === "FAILED" && (
<>
{c.short.errorMessage && (
<p className="font-mono-num text-xs text-muted-foreground">{c.short.errorMessage}</p>
{c.short && (
<div className="mt-2 flex flex-col items-start gap-2">
<Badge variant={STATUS_BADGE[c.short.status] ?? "muted"}>9:16: {c.short.status}</Badge>
{c.short.status === "READY" && c.short.filePath && (
<>
<video controls preload="metadata" className="w-[220px] rounded-md border border-border">
<source src={`/api/shorts/${c.short.id}/video`} type="video/mp4" />
</video>
<Button asChild variant="outline" size="sm">
<a href={`/api/shorts/${c.short.id}/video?download=1`}>İndir</a>
</Button>
</>
)}
<form action={retryRender.bind(null, c.id)}>
<Button type="submit" size="sm">
Tekrar Dene
</Button>
</form>
</>
{c.short.status === "READY" && !c.short.filePath && (
<p className="text-xs text-muted-foreground">
Telegram&apos;a gönderildi, sunucuda saklanmıyor.
</p>
)}
{c.short.status === "FAILED" && (
<>
{c.short.errorMessage && (
<p className="font-mono-num text-xs text-muted-foreground">{c.short.errorMessage}</p>
)}
<form action={retryRender.bind(null, c.id)}>
<Button type="submit" size="sm">
Tekrar Dene
</Button>
</form>
</>
)}
{c.short.status === "RENDERING" && (
<form action={cancelRender.bind(null, c.id)}>
<ConfirmButton
confirmText="Render'ı başarısız say ve kilidi aç? (Çalışan process'i öldürmez, sadece durumu sıfırlar)"
label="Takıldıysa İptal Et"
variant="outline"
/>
</form>
)}
</div>
)}
{c.short.status === "RENDERING" && (
<form action={cancelRender.bind(null, c.id)}>
<ConfirmButton
confirmText="Render'ı başarısız say ve kilidi aç? (Çalışan process'i öldürmez, sadece durumu sıfırlar)"
label="Takıldıysa İptal Et"
variant="outline"
/>
{!c.short && c.status === "TRANSCRIBED" && (
<form action={retryRender.bind(null, c.id)} className="mt-2">
<Button type="submit" size="sm">
9:16 Render Et
</Button>
</form>
)}
</div>
)}
{!c.short && c.status === "TRANSCRIBED" && (
<form action={retryRender.bind(null, c.id)} className="mt-2">
<Button type="submit" size="sm">
9:16 Render Et
</Button>
</form>
)}
<form action={deleteCandidateSegment.bind(null, c.id)} className="mt-2">
<ConfirmButton confirmText="Bu aday klibi silmek istediğine emin misin?" label="Sil" />
</form>
<form action={deleteCandidateSegment.bind(null, c.id)} className="mt-2">
<ConfirmButton confirmText="Bu aday klibi silmek istediğine emin misin?" label="Sil" />
</form>
</div>
))}
</div>
))}
)}
</div>
))}
</CardContent>
+64 -69
View File
@@ -6,8 +6,8 @@ import { formatTr } from "../lib/formatDate";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
import { cn } from "@/lib/utils";
export const dynamic = "force-dynamic";
@@ -47,9 +47,9 @@ export default async function DashboardPage() {
{channels.length === 0 ? (
<p className="text-sm text-muted-foreground">Henüz kanal eklenmedi.</p>
) : (
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="text-sm font-medium text-muted-foreground">{channels.length} kanal</CardTitle>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">{channels.length} kanal</span>
<div className="flex gap-2">
<form action={toggleAllChannels.bind(null, true)}>
<Button type="submit" variant="outline" size="sm">
@@ -62,70 +62,65 @@ export default async function DashboardPage() {
</Button>
</form>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Kanal</TableHead>
<TableHead>Durum</TableHead>
<TableHead>Kayıt</TableHead>
<TableHead>Son Kontrol</TableHead>
<TableHead className="text-right">Aksiyon</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{channels.map((c) => {
const recording = c.sessions.length > 0;
const status = statuses.get(c.id);
return (
<TableRow key={c.id}>
<TableCell>
<Link href={`/channels/${c.id}`} className="font-medium hover:text-primary hover:underline">
{c.name}
</Link>{" "}
<span className="font-mono-num text-xs text-muted-foreground">{c.youtubeHandle}</span>
</TableCell>
<TableCell>
<Badge variant={c.isActive ? "ok" : "muted"}>{c.isActive ? "Aktif" : "Pasif"}</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
<Badge variant={recording ? "live" : "muted"}>{recording ? "🔴 Kayıtta" : "—"}</Badge>
{status?.lastPollError && (
<Badge
variant="err"
title={`${formatTr(status.lastPollError.at)}${status.lastPollError.message}`}
>
hata
</Badge>
)}
</div>
</TableCell>
<TableCell className="font-mono-num text-xs text-muted-foreground">
{c.lastCheckedAt ? formatTr(c.lastCheckedAt) : "—"}
</TableCell>
<TableCell>
<div className="flex justify-end gap-1.5">
<form action={toggleChannelActive.bind(null, c.id, !c.isActive)}>
<Button type="submit" variant="outline" size="sm">
{c.isActive ? "Duraklat" : "Devam Ettir"}
</Button>
</form>
<form action={checkChannelNow.bind(null, c.id)}>
<Button type="submit" variant="ghost" size="sm">
Şimdi Kontrol Et
</Button>
</form>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{channels.map((c) => {
const recording = c.sessions.length > 0;
const status = statuses.get(c.id);
return (
<Card
key={c.id}
className={cn("gap-4 py-5", recording && "border-live/40")}
>
<CardHeader className="gap-1 px-5">
<div className="flex items-start justify-between gap-2">
<Link href={`/channels/${c.id}`} className="font-semibold leading-tight hover:text-primary hover:underline">
{c.name}
</Link>
<Badge variant={c.isActive ? "ok" : "muted"} className="shrink-0">
{c.isActive ? "Aktif" : "Pasif"}
</Badge>
</div>
<span className="font-mono-num text-xs text-muted-foreground">{c.youtubeHandle}</span>
</CardHeader>
<CardContent className="flex flex-col gap-2 px-5">
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant={recording ? "live" : "muted"}>
{recording ? "🔴 Kayıtta" : "Kayıtta değil"}
</Badge>
{status?.lastPollError && (
<Badge
variant="err"
title={`${formatTr(status.lastPollError.at)}${status.lastPollError.message}`}
>
hata
</Badge>
)}
</div>
<span className="font-mono-num text-xs text-muted-foreground">
Son kontrol: {c.lastCheckedAt ? formatTr(c.lastCheckedAt) : "—"}
</span>
</CardContent>
<CardFooter className="gap-2 px-5">
<form action={toggleChannelActive.bind(null, c.id, !c.isActive)}>
<Button type="submit" variant="outline" size="sm">
{c.isActive ? "Duraklat" : "Devam Ettir"}
</Button>
</form>
<form action={checkChannelNow.bind(null, c.id)}>
<Button type="submit" variant="ghost" size="sm">
Şimdi Kontrol Et
</Button>
</form>
</CardFooter>
</Card>
);
})}
</div>
</div>
)}
</div>
);
+32 -17
View File
@@ -13,6 +13,7 @@ const STATUS_BADGE: Record<string, VariantProps<typeof badgeVariants>["variant"]
PENDING: "muted",
PROCESSED: "ok",
DISCARDED: "err",
SENT_TO_TELEGRAM: "ok",
PENDING_STT: "warn",
TRANSCRIBED: "ok",
RENDERING: "warn",
@@ -53,23 +54,32 @@ export default async function SegmentsPage() {
<Badge variant={STATUS_BADGE[segment.status] ?? "muted"}>{segment.status}</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<video controls preload="metadata" className="w-full max-w-md rounded-md border border-border">
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
</video>
{segment.filePath ? (
<>
<video controls preload="metadata" className="w-full max-w-md rounded-md border border-border">
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
</video>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<a href={`/api/segments/${segment.id}/video?download=1`}>İndir</a>
</Button>
<form action={deleteRawSegment.bind(null, segment.id)}>
<ConfirmButton
confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz."
label="Sil"
/>
</form>
</div>
</>
) : (
<p className="text-xs text-muted-foreground"> Telegram&apos;a gönderildi, sunucuda saklanmıyor.</p>
)}
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<a href={`/api/segments/${segment.id}/video?download=1`}>İndir</a>
</Button>
<form action={deleteRawSegment.bind(null, segment.id)}>
<ConfirmButton
confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz."
label="Sil"
/>
</form>
</div>
{segment.candidates.length === 0 ? (
{segment.status === "SENT_TO_TELEGRAM" ? (
<p className="text-sm text-muted-foreground">
Bu segment analiz edilmeden (transkript/9:16 atlanarak) doğrudan Telegram&apos;a gönderildi.
</p>
) : segment.candidates.length === 0 ? (
<p className="text-sm text-muted-foreground">Bu segmentte aday klip bulunamadı.</p>
) : (
segment.candidates.map((c) => (
@@ -89,7 +99,7 @@ export default async function SegmentsPage() {
{c.short && (
<div className="mt-2 flex flex-col items-start gap-2">
<Badge variant={STATUS_BADGE[c.short.status] ?? "muted"}>9:16: {c.short.status}</Badge>
{c.short.status === "READY" && (
{c.short.status === "READY" && c.short.filePath && (
<>
<video controls preload="metadata" className="w-[220px] rounded-md border border-border">
<source src={`/api/shorts/${c.short.id}/video`} type="video/mp4" />
@@ -99,6 +109,11 @@ export default async function SegmentsPage() {
</Button>
</>
)}
{c.short.status === "READY" && !c.short.filePath && (
<p className="text-xs text-muted-foreground">
Telegram&apos;a gönderildi, sunucuda saklanmıyor.
</p>
)}
{c.short.status === "FAILED" && (
<>
{c.short.errorMessage && (
+202 -10
View File
@@ -1,26 +1,56 @@
import { prisma } from "@streamclipper/db";
import { updateYtdlpCookies, updateSystemSettings, updateNotificationPrefs, updateSttEnabled } from "../actions";
import {
updateYtdlpCookies,
updateSystemSettings,
updateNotificationPrefs,
updateSttEnabled,
updateAutoRenderEnabled,
updateDeleteAfterTelegramSend,
updateSendRawSegmentsToTelegram,
addCookieProfile,
updateCookieProfile,
deleteCookieProfile,
} from "../actions";
import { formatTr } from "../../lib/formatDate";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { SettingSwitch } from "../components/SettingSwitch";
import { ConfirmButton } from "../components/ConfirmButton";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
export const dynamic = "force-dynamic";
const STALE_COOKIE_HOURS = 4;
export default async function SettingsPage() {
const [setting, pollSetting, ttlSetting, notifyStart, notifyRender, sttSetting] = await Promise.all([
const [
setting,
pollSetting,
ttlSetting,
notifyStart,
notifyRender,
notifyError,
sttSetting,
autoRenderSetting,
deleteAfterSendSetting,
sendRawSetting,
cookieProfiles,
] = await Promise.all([
prisma.appSetting.findUnique({ where: { key: "ytdlp_cookies" } }),
prisma.appSetting.findUnique({ where: { key: "poll_interval_ms" } }),
prisma.appSetting.findUnique({ where: { key: "raw_segment_ttl_hours" } }),
prisma.appSetting.findUnique({ where: { key: "notify_stream_start" } }),
prisma.appSetting.findUnique({ where: { key: "notify_render_done" } }),
prisma.appSetting.findUnique({ where: { key: "notify_poll_error" } }),
prisma.appSetting.findUnique({ where: { key: "stt_enabled" } }),
prisma.appSetting.findUnique({ where: { key: "auto_render_enabled" } }),
prisma.appSetting.findUnique({ where: { key: "delete_after_telegram_send" } }),
prisma.appSetting.findUnique({ where: { key: "send_raw_segments_to_telegram" } }),
prisma.cookieProfile.findMany({ orderBy: { createdAt: "asc" } }),
]);
const cookieAgeHours = setting ? (Date.now() - setting.updatedAt.getTime()) / 3_600_000 : null;
@@ -29,41 +59,130 @@ export default async function SettingsPage() {
const ttlHours = ttlSetting ? Number(ttlSetting.value) : 24;
const notifyStreamStart = notifyStart?.value !== "false";
const notifyRenderDone = notifyRender?.value !== "false";
const notifyPollError = notifyError?.value !== "false";
const sttEnabled = sttSetting?.value !== "false";
const autoRenderEnabled = autoRenderSetting?.value !== "false";
const deleteAfterTelegramSend = deleteAfterSendSetting?.value === "true";
const sendRawSegmentsToTelegram = sendRawSetting?.value === "true";
return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-semibold tracking-tight">Ayarlar</h1>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">Cookie Hesapları</CardTitle>
<CardDescription>
Her kanal, aşağıdaki hesaplardan birine sabit olarak atanır (aynı kanal hep aynı hesabı kullanır, kanallar
havuza dağılır). Tek hesap çok kanalı taramaya çalışınca YouTube hesabın kendisini şüpheli bulup
&quot;Sign in to confirm you&apos;re not a bot&quot; hatası verebiliyor birkaç ayrı, önemsiz Google
hesabı ekleyerek bu yükü dağıt. Her hesap ayrı bir tarayıcıda giriş yapılıp cookies.txt olarak dışa
aktarılmalı.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{cookieProfiles.length === 0 ? (
<p className="text-sm text-muted-foreground">
Henüz hesap eklenmedi aşağıdan en az bir tane ekle. Eklenene kadar aşağıdaki tekli/eski cookie ayarı
kullanılmaya devam eder.
</p>
) : (
<div className="flex flex-col gap-3">
{cookieProfiles.map((profile) => {
const ageHours = (Date.now() - profile.updatedAt.getTime()) / 3_600_000;
const stale = ageHours > STALE_COOKIE_HOURS;
return (
<div key={profile.id} className="rounded-lg border border-border p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{profile.label}</span>
<div className="flex items-center gap-2">
<span className="font-mono-num text-xs text-muted-foreground">
{formatTr(profile.updatedAt)}
</span>
<form action={deleteCookieProfile.bind(null, profile.id)}>
<ConfirmButton confirmText={`${profile.label} hesabını silmek istediğine emin misin?`} label="Sil" />
</form>
</div>
</div>
{stale && (
<Badge variant="warn" className="mt-2 w-fit">
{Math.round(ageHours)} saattir güncellenmedi
</Badge>
)}
<details className="mt-2 text-sm">
<summary className="cursor-pointer font-mono-num text-xs text-muted-foreground">
Cookie&apos;yi yenile
</summary>
<form action={updateCookieProfile.bind(null, profile.id)} className="mt-2 flex flex-col gap-2">
<Textarea
name="value"
required
placeholder="Netscape formatlı cookies.txt içeriğini buraya yapıştır"
rows={6}
className="font-mono-num text-xs"
/>
<Button type="submit" size="sm" className="self-start">
Kaydet
</Button>
</form>
</details>
</div>
);
})}
</div>
)}
<Separator />
<form action={addCookieProfile} className="flex flex-col gap-2">
<Label htmlFor="newProfileLabel" className="text-xs text-muted-foreground">
Yeni hesap ekle
</Label>
<Input id="newProfileLabel" name="label" placeholder="Hesap adı (ör. Hesap 2)" required />
<Textarea
name="value"
required
placeholder="Netscape formatlı cookies.txt içeriğini buraya yapıştır"
rows={6}
className="font-mono-num text-xs"
/>
<Button type="submit" className="self-start">
Hesap Ekle
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="text-sm font-medium text-muted-foreground">YouTube Cookie&apos;leri</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">
YouTube Cookie&apos;leri (tekli / eski)
</CardTitle>
<span className="font-mono-num text-xs text-muted-foreground">
{setting ? `son güncelleme: ${formatTr(setting.updatedAt)}` : "hiç ayarlanmadı"}
</span>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{cookieStale && (
{cookieStale && cookieProfiles.length === 0 && (
<Badge variant="warn" className="w-fit">
{Math.round(cookieAgeHours!)} saattir güncellenmedi büyük/popüler kanallarda bot-check hatası
görülebilir
</Badge>
)}
<CardDescription>
YouTube canlı yayın kontrolü ve kayıt için kullanılıyor. Google, oturum çerezlerinin bir kısmını birkaç
saatte bir yeniliyor burada eskidiğini fark edersen (kanal detay sayfasında &quot;son poll hatası&quot;
olarak görürsün) taze bir cookies.txt ile güncelle. Ana hesabın yerine ayrı, önemsiz bir Google hesabı
kullanman önerilir.
{cookieProfiles.length > 0
? "Yukarıda en az bir hesap eklendiği için bu ayar artık kullanılmıyor — sadece geriye dönük referans için duruyor."
: "Yukarıda hiç hesap eklenmediği sürece tüm kanallar bu tek cookie'yi paylaşır."}
</CardDescription>
<form action={updateYtdlpCookies} className="flex flex-col gap-3">
<Textarea
name="cookies"
required
placeholder="Netscape formatlı cookies.txt içeriğini buraya yapıştır"
rows={10}
rows={8}
className="font-mono-num text-xs"
/>
<Button type="submit" className="self-start">
<Button type="submit" variant="outline" className="self-start">
Kaydet
</Button>
</form>
@@ -120,12 +239,85 @@ export default async function SettingsPage() {
</form>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">9:16 Render</CardTitle>
<CardDescription>
Kapatırsan transkribe edilen adaylar için otomatik render tetiklenmez, klip
&quot;TRANSCRIBED&quot; durumunda bekler kanal/segment sayfalarındaki &quot;9:16 Render Et&quot;
butonuyla istediğini elle render edebilirsin.
</CardDescription>
</CardHeader>
<form action={updateAutoRenderEnabled}>
<CardContent>
<Label className="flex items-center justify-between gap-4 text-sm font-normal">
Otomatik 9:16 render aktif
<SettingSwitch name="autoRenderEnabled" defaultChecked={autoRenderEnabled} />
</Label>
</CardContent>
<CardFooter>
<Button type="submit">Kaydet</Button>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">Ham Kayıt Modu</CardTitle>
<CardDescription>
Açarsan her ham segment (analiz/transkript/9:16 zincirine hiç girmeden) tamamlanır tamamlanmaz doğrudan
Telegram&apos;a gönderilip sunucudan silinir transkript ve 9:16 render tamamen atlanır. Bunu açtıysan
STT ve 9:16 Render ayarlarının kapalı olması mantıklı (aksi halde ikisi de boşa kalır, çünkü bu mod
segmenti analiz kuyruğuna hiç sokmuyor). ~50MB&apos;ı geçen segmentler gönderilemez, sunucuda kalır
gerekirse yukarıdaki &quot;Sistem Parametreleri&quot;nden segment süresini kısalt.
</CardDescription>
</CardHeader>
<form action={updateSendRawSegmentsToTelegram}>
<CardContent>
<Label className="flex items-center justify-between gap-4 text-sm font-normal">
Ham segmenti doğrudan Telegram&apos;a gönder (analiz atlanır)
<SettingSwitch name="sendRawSegmentsToTelegram" defaultChecked={sendRawSegmentsToTelegram} />
</Label>
</CardContent>
<CardFooter>
<Button type="submit">Kaydet</Button>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">Depolama (9:16 klipler)</CardTitle>
<CardDescription>
Yukarıdaki &quot;Ham Kayıt Modu&quot;ndan farklı bu, STT+9:16 render zincirinden geçmiş klipler için.
Açarsan her render biten video Telegram&apos;a gönderilip <strong>sunucudan kalıcı olarak
silinir</strong> sadece Telegram&apos;daki kopyada kalır (geri alınamaz). Gönderim başarısız olursa
dosya sunucuda kalır. Kanal sayısı arttıkça disk doluluğunu önlemek için.
</CardDescription>
</CardHeader>
<form action={updateDeleteAfterTelegramSend}>
<CardContent>
<Label className="flex items-center justify-between gap-4 text-sm font-normal">
Telegram&apos;a gönderip sunucudan sil
<SettingSwitch name="deleteAfterTelegramSend" defaultChecked={deleteAfterTelegramSend} />
</Label>
</CardContent>
<CardFooter>
<Button type="submit">Kaydet</Button>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">Bildirimler</CardTitle>
</CardHeader>
<form action={updateNotificationPrefs}>
<CardContent className="flex flex-col gap-4">
<Label className="flex items-center justify-between gap-4 text-sm font-normal">
Canlı-tespit hata verince/düzelince Telegram bildirimi
<SettingSwitch name="notifyPollError" defaultChecked={notifyPollError} />
</Label>
<Label className="flex items-center justify-between gap-4 text-sm font-normal">
Yayın başladığında Telegram bildirimi
<SettingSwitch name="notifyStreamStart" defaultChecked={notifyStreamStart} />
+9
View File
@@ -158,3 +158,12 @@ async def update_short_video(
file_path,
error_message,
)
async def clear_short_video_file(short_id: str) -> None:
"""update_short_video's COALESCE keeps the old file_path when passed None — this explicitly nulls it out once the rendered file has been deleted from disk (e.g. after a successful Telegram delivery)."""
pool = await get_pool()
await pool.execute(
"UPDATE short_videos SET file_path = NULL, updated_at = now() WHERE id = $1",
short_id,
)
+1 -1
View File
@@ -17,7 +17,7 @@ WINDOW_SEC = 1.0
PEAK_STD_MULTIPLIER = 1.5
CANDIDATE_PAD_SEC = 45
CHAT_SPIKE_MULTIPLIER = 3
SEGMENT_TIME_SEC = int(os.environ.get("SEGMENT_TIME_SEC", "900"))
SEGMENT_TIME_SEC = int(os.environ.get("SEGMENT_TIME_SEC", "180"))
stt_scoring_queue = Queue(QUEUE_NAMES["STT_SCORING"], {"connection": REDIS_URL})
+4 -1
View File
@@ -72,7 +72,10 @@ async def process_stt_scoring(job, job_token=None):
f"({candidate['start_sec']}-{candidate['end_sec']}s):\n_{preview}_"
)
await video_render_queue.add("render-short", {"candidateSegmentId": candidate_id})
if await db.get_setting("auto_render_enabled", "true") != "false":
await video_render_queue.add("render-short", {"candidateSegmentId": candidate_id})
else:
print(f"[stt-scoring] auto-render kapalı, {candidate_id} render kuyruğuna eklenmedi (panelden manuel tetiklenebilir)")
print(f"[stt-scoring] candidate {candidate_id} transcribed")
+37
View File
@@ -5,6 +5,11 @@ import httpx
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
# Telegram's bot-upload limit (no local Bot API server) — checked before
# attempting an upload so an oversized file fails fast instead of burning
# time/bandwidth on a request Telegram will reject anyway.
MAX_VIDEO_BYTES = 50 * 1024 * 1024
async def send_telegram_message(text: str) -> None:
if not BOT_TOKEN or not CHAT_ID:
@@ -18,3 +23,35 @@ async def send_telegram_message(text: str) -> None:
)
if resp.status_code >= 400:
print(f"[telegram] failed to send message: {resp.status_code} {resp.text}")
async def send_telegram_video(video_path: str, caption: str) -> bool:
"""Uploads the actual rendered clip to Telegram. Returns False (without
raising) on any failure — the caller decides what that means for the
local file (e.g. keep it if delivery didn't succeed)."""
if not BOT_TOKEN or not CHAT_ID:
print(f"[telegram] not configured, skipping video: {caption}")
return False
size = os.path.getsize(video_path)
if size > MAX_VIDEO_BYTES:
print(f"[telegram] video {video_path} is {size} bytes, over Telegram's bot-upload limit — skipping")
return False
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendVideo"
try:
async with httpx.AsyncClient(timeout=120.0) as client:
with open(video_path, "rb") as f:
resp = await client.post(
url,
data={"chat_id": CHAT_ID, "caption": caption, "parse_mode": "Markdown"},
files={"video": (os.path.basename(video_path), f, "video/mp4")},
)
except httpx.HTTPError as exc:
print(f"[telegram] failed to upload video: {exc}")
return False
if resp.status_code >= 400:
print(f"[telegram] failed to send video: {resp.status_code} {resp.text}")
return False
return True
+19 -4
View File
@@ -25,7 +25,7 @@ import mediapipe as mp
from . import db
from .queues import QUEUE_NAMES, REDIS_URL, SHARED_MEDIA_ROOT, VIDEO_RENDER_CONCURRENCY
from .telegram import send_telegram_message
from .telegram import send_telegram_message, send_telegram_video
FACE_MODEL_PATH = os.environ.get(
"FACE_DETECTOR_MODEL_PATH", "/app/models/blaze_face_short_range.tflite"
@@ -245,9 +245,24 @@ async def process_video_render(job, job_token=None):
)
await db.update_short_video(short_id, status="READY", file_path=out_path)
if await db.get_setting("notify_render_done", "true") != "false":
await send_telegram_message(f"🎬 *{candidate['channel_name']}* için 9:16 kısa video hazır!")
print(f"[video-render] short {short_id} ready: {out_path}")
caption = f"🎬 *{candidate['channel_name']}* için 9:16 kısa video hazır!"
if await db.get_setting("delete_after_telegram_send", "false") == "true":
# Video delivery *is* the notification here — it doesn't also
# gate on notify_render_done. Only delete the local file once
# Telegram actually has it; a failed upload leaves it in place
# with a plain-text heads-up instead of silently losing it.
if await send_telegram_video(out_path, caption):
os.remove(out_path)
await db.clear_short_video_file(short_id)
print(f"[video-render] short {short_id} sent to Telegram and removed from disk")
else:
await send_telegram_message(f"{caption}\n(video Telegram'a gönderilemedi, sunucuda kaldı)")
print(f"[video-render] short {short_id} ready: {out_path}")
else:
if await db.get_setting("notify_render_done", "true") != "false":
await send_telegram_message(caption)
print(f"[video-render] short {short_id} ready: {out_path}")
except Exception as exc:
print(f"[video-render] failed for candidate {candidate_id}: {exc}")
await db.update_short_video(short_id, status="FAILED", error_message=str(exc)[:500])
+2 -2
View File
@@ -33,7 +33,7 @@ services:
DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper
SHARED_MEDIA_ROOT: /shared-media
POLL_INTERVAL_MS: ${POLL_INTERVAL_MS:-60000}
SEGMENT_TIME_SEC: ${SEGMENT_TIME_SEC:-900}
SEGMENT_TIME_SEC: ${SEGMENT_TIME_SEC:-180}
API_DAEMON_PORT: ${API_DAEMON_PORT:-4001}
FORCE_LIVE_URL: ${FORCE_LIVE_URL:-}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
@@ -61,7 +61,7 @@ services:
REDIS_URL: redis://sc_redis:6379
DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper
SHARED_MEDIA_ROOT: /shared-media
SEGMENT_TIME_SEC: ${SEGMENT_TIME_SEC:-900}
SEGMENT_TIME_SEC: ${SEGMENT_TIME_SEC:-180}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
@@ -0,0 +1,10 @@
-- CreateTable
CREATE TABLE "cookie_profiles" (
"id" TEXT NOT NULL,
"label" TEXT NOT NULL,
"value" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "cookie_profiles_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,5 @@
-- AlterEnum
ALTER TYPE "RawSegmentStatus" ADD VALUE 'SENT_TO_TELEGRAM';
-- AlterTable
ALTER TABLE "raw_segments" ALTER COLUMN "file_path" DROP NOT NULL;
+20 -1
View File
@@ -11,6 +11,10 @@ enum RawSegmentStatus {
PENDING
PROCESSED
DISCARDED
// Analiz zincirine (sinyal analizi/STT/9:16) hiç girmeden ham haliyle
// doğrudan Telegram'a gönderildi — file_path bu durumda null (dosya
// gönderim sonrası diskten silindi).
SENT_TO_TELEGRAM
}
enum CandidateSegmentStatus {
@@ -60,7 +64,7 @@ model RawSegment {
id String @id @default(cuid())
sessionId String @map("session_id")
session StreamSession @relation(fields: [sessionId], references: [id])
filePath String @map("file_path")
filePath String? @map("file_path")
duration Int
startedAt DateTime @map("started_at")
status RawSegmentStatus @default(PENDING)
@@ -101,6 +105,21 @@ model ShortVideo {
@@map("short_videos")
}
// Birden fazla YouTube hesabının cookie'si — her kanal (channel.id'den
// deterministik bir hash ile) bu havuzdaki bir profile sabitlenir, tek bir
// hesabın çok fazla kanalı taramasından kaynaklanan hesap-seviyesi
// bot-tespitini havuza yayarak azaltır (bkz. proxyUrlForChannel ile aynı
// mantık, ytdlpCookies.ts).
model CookieProfile {
id String @id @default(cuid())
label String
value String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("cookie_profiles")
}
model AppSetting {
key String @id
value String