Compare commits
23
Commits
5fef7460cf
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11e98b300e | ||
|
|
9aed4e891e | ||
|
|
ccd922ba37 | ||
|
|
eb2a9d28ed | ||
|
|
63443f3392 | ||
|
|
57ca4b5878 | ||
|
|
59888a6513 | ||
|
|
597b1186ab | ||
|
|
b5c761afbf | ||
|
|
dea207996e | ||
|
|
58ef40edf8 | ||
|
|
eca80959fb | ||
|
|
a53f0a5e04 | ||
|
|
ce9e01bbeb | ||
|
|
3e01116729 | ||
|
|
ee79dce340 | ||
|
|
cbb16697ca | ||
|
|
7d504d1938 | ||
|
|
d045b24c85 | ||
|
|
43ccbd9ec7 | ||
|
|
2fc3254d64 | ||
|
|
93fd7a95df | ||
|
|
f00fc73b0b |
+12
-1
@@ -12,7 +12,7 @@ TELEGRAM_BOT_TOKEN=
|
|||||||
TELEGRAM_CHAT_ID=
|
TELEGRAM_CHAT_ID=
|
||||||
|
|
||||||
POLL_INTERVAL_MS=60000
|
POLL_INTERVAL_MS=60000
|
||||||
SEGMENT_TIME_SEC=900
|
SEGMENT_TIME_SEC=180
|
||||||
API_DAEMON_PORT=4001
|
API_DAEMON_PORT=4001
|
||||||
|
|
||||||
# Signs the panel's login session cookies (frontend). Generate with:
|
# Signs the panel's login session cookies (frontend). Generate with:
|
||||||
@@ -50,3 +50,14 @@ PROXY_URL=
|
|||||||
# (DB row + file) — PRD's auto-purge lifecycle. Only segments already fully
|
# (DB row + file) — PRD's auto-purge lifecycle. Only segments already fully
|
||||||
# analyzed (no candidate still waiting on STT) are eligible.
|
# analyzed (no candidate still waiting on STT) are eligible.
|
||||||
RAW_SEGMENT_TTL_HOURS=24
|
RAW_SEGMENT_TTL_HOURS=24
|
||||||
|
|
||||||
|
# Video render (yüz takibi + 9:16 crop + altyazı) her segment/stt-scoring
|
||||||
|
# işinden çok daha ağır — düşük tutulur ki aynı anda birden fazla render
|
||||||
|
# worker container'ını boğmasın.
|
||||||
|
VIDEO_RENDER_CONCURRENCY=2
|
||||||
|
|
||||||
|
# api-daemon'ın durdur/zorla-başlat/render-tetikle gibi durum-değiştiren
|
||||||
|
# endpoint'lerini korur (bu daemon'ın portu Coolify'de public'e açık).
|
||||||
|
# frontend ve api-daemon aynı değeri kullanmalı. Generate: openssl rand -hex 24
|
||||||
|
# Boş bırakılırsa (sadece local dev için) bu endpoint'ler korumasız kalır.
|
||||||
|
INTERNAL_API_TOKEN=
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
# StreamClipper AI — Faz 1
|
# StreamClipper AI — Faz 1 + Faz 2 (render)
|
||||||
|
|
||||||
`prd.md` içindeki tam kapsamlı otonom pipeline'ın ilk fazı: ingestion (yt-dlp
|
`prd.md` içindeki tam kapsamlı otonom pipeline'ın Faz 1+2'si: ingestion
|
||||||
headless capture + 15dk segmentleme), sinyal analizi (ses peak + chat
|
(yt-dlp headless capture + 15dk segmentleme), sinyal analizi (ses peak +
|
||||||
velocity), Whisper STT (OpenAI API, kelime zaman damgalı), BullMQ/Redis/
|
chat velocity), Whisper STT (OpenAI API, kelime zaman damgalı), BullMQ/
|
||||||
Postgres altyapısı ve durum/transkript gösteren bir Next.js panel. LLM
|
Redis/Postgres altyapısı, auth korumalı bir Next.js panel — ve Faz 2 olarak
|
||||||
virality skorlama, render/crop/altyazı ve multi-platform dağıtım bu fazda
|
transkribe edilen her aday klibi otomatik yüz-takipli 9:16'ya crop'layıp
|
||||||
**yok** — bkz. `prd.md` Modül 3 (skorlama kısmı), 4 ve 5.
|
dinamik altyazı basan bir render motoru (`apps/worker/worker/video_render.py`).
|
||||||
|
LLM virality skorlama, çoklu-konuşmacı/split-screen şablonu ve
|
||||||
|
multi-platform dağıtım hâlâ **yok** — bkz. `prd.md` Modül 3 (skorlama
|
||||||
|
kısmı) ve Modül 5.
|
||||||
|
|
||||||
## Klasör Yapısı
|
## Klasör Yapısı
|
||||||
|
|
||||||
@@ -77,8 +80,8 @@ Panel: http://localhost:3000 · API daemon health: http://localhost:4001/health
|
|||||||
Panelin tüm sayfaları giriş gerektirir (middleware ile korunuyor). İlk açılışta
|
Panelin tüm sayfaları giriş gerektirir (middleware ile korunuyor). İlk açılışta
|
||||||
henüz hiç kullanıcı yoksa `/login` otomatik olarak "İlk Yönetici Hesabını
|
henüz hiç kullanıcı yoksa `/login` otomatik olarak "İlk Yönetici Hesabını
|
||||||
Oluştur" formunu gösterir — kullanıcı adı/şifre girip ilk admin hesabını
|
Oluştur" formunu gösterir — kullanıcı adı/şifre girip ilk admin hesabını
|
||||||
oradan oluşturursun. Sonraki kullanıcılar için ayrı bir "kullanıcı ekle"
|
oradan oluşturursun. `/users` sayfasından ek kullanıcı ekleyip silebilirsin
|
||||||
arayüzü henüz yok (Faz 2).
|
(kendi hesabını veya son kalan tek kullanıcıyı silmek engellenir).
|
||||||
|
|
||||||
`SESSION_SECRET` production'da zorunlu (`openssl rand -base64 32` ile
|
`SESSION_SECRET` production'da zorunlu (`openssl rand -base64 32` ile
|
||||||
üretilebilir) — local dev'de boş bırakılırsa güvensiz bir varsayılan
|
üretilebilir) — local dev'de boş bırakılırsa güvensiz bir varsayılan
|
||||||
@@ -94,15 +97,21 @@ kullanılır, sadece test için.
|
|||||||
kuyruğundan alıp ses peak + (varsa) chat velocity analiziyle
|
kuyruğundan alıp ses peak + (varsa) chat velocity analiziyle
|
||||||
`candidate_segments` üretir.
|
`candidate_segments` üretir.
|
||||||
4. Her aday, `stt-scoring` kuyruğunda OpenAI Whisper API ile transkribe edilir;
|
4. Her aday, `stt-scoring` kuyruğunda OpenAI Whisper API ile transkribe edilir;
|
||||||
sonuç `candidate_segments.transcript_json` alanına yazılır ve Telegram'a
|
sonuç `candidate_segments.transcript_json` alanına yazılır, Telegram'a
|
||||||
bildirim gider.
|
bildirim gider ve `video-render` kuyruğuna otomatik iş eklenir.
|
||||||
5. Panelde (`/` ve `/segments`) kanal durumu ve üretilen transkriptleri
|
5. `video-render` (Python, `video_render.py`) yüz tespiti + yumuşatılmış
|
||||||
kontrol et.
|
dinamik crop ile 9:16'ya çevirir, transkriptten `.ass` altyazı üretip
|
||||||
|
yakar, `/shared-media/shorts/<candidate_id>.mp4`'e yazar;
|
||||||
|
`short_videos.status` `READY`/`FAILED` olur, Telegram'a bildirim gider.
|
||||||
|
6. Panelde (`/`, `/segments`, kanal detay sayfaları) kanal durumu, ham
|
||||||
|
segment/transkript ve render edilen 9:16 kısa videoları izle/indir.
|
||||||
|
|
||||||
## Bilinen Sınırlamalar (bu faz kapsamı dışı)
|
## Bilinen Sınırlamalar (bu faz kapsamı dışı)
|
||||||
|
|
||||||
- LLM virality skorlama, metadata üretimi, telif/görüntü filtresi yok.
|
- LLM virality skorlama, metadata üretimi, telif/görüntü filtresi yok —
|
||||||
- 9:16 crop/altyazı render motoru yok.
|
transkribe edilen her aday otomatik render edilir, eşik/filtre yok.
|
||||||
|
- Çoklu konuşmacı / split-screen şablonu yok — sadece tekil konuşmacı
|
||||||
|
(yüz-merkezli dinamik crop).
|
||||||
- YouTube/Instagram/TikTok'a otomatik yayınlama yok (bu API'ler ayrı onay
|
- YouTube/Instagram/TikTok'a otomatik yayınlama yok (bu API'ler ayrı onay
|
||||||
süreçleri gerektiriyor — `prd.md` tartışmasına bkz.).
|
süreçleri gerektiriyor — `prd.md` tartışmasına bkz.).
|
||||||
- Aday pencere çıkarımı her zaman tek bir 15dk'lık segment dosyasıyla sınırlı;
|
- Aday pencere çıkarımı her zaman tek bir 15dk'lık segment dosyasıyla sınırlı;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { spawn } from "node:child_process";
|
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 path from "node:path";
|
||||||
import { Worker, type Job } from "bullmq";
|
import { Worker, type Job } from "bullmq";
|
||||||
import { prisma } from "@streamclipper/db";
|
import { prisma } from "@streamclipper/db";
|
||||||
@@ -7,6 +7,7 @@ import { env } from "../env";
|
|||||||
import { QUEUE_NAMES, signalDetectionQueue, type StreamIngestJob } from "../queues";
|
import { QUEUE_NAMES, signalDetectionQueue, type StreamIngestJob } from "../queues";
|
||||||
import { redisConnection } from "../redis";
|
import { redisConnection } from "../redis";
|
||||||
import { ytdlpAntiBotArgs } from "../ytdlpCookies";
|
import { ytdlpAntiBotArgs } from "../ytdlpCookies";
|
||||||
|
import { sendTelegramVideo } from "../telegram";
|
||||||
|
|
||||||
const SEGMENT_LIST_POLL_MS = 5_000;
|
const SEGMENT_LIST_POLL_MS = 5_000;
|
||||||
|
|
||||||
@@ -17,6 +18,24 @@ const SEGMENT_LIST_POLL_MS = 5_000;
|
|||||||
*/
|
*/
|
||||||
export const lastCaptureDebug = new Map<string, string[]>();
|
export const lastCaptureDebug = new Map<string, string[]>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live yt-dlp processes, keyed by sessionId — lets a panel "stop" action
|
||||||
|
* reach a specific in-progress capture. Only yt-dlp is tracked/killed:
|
||||||
|
* ffmpeg is downstream of it in the pipe, so ending yt-dlp closes its stdin
|
||||||
|
* with EOF and ffmpeg finishes the same way it does on a natural stream end
|
||||||
|
* (flushes the last partial segment, exits, existing finalize code runs
|
||||||
|
* unchanged).
|
||||||
|
*/
|
||||||
|
const activeCaptures = new Map<string, ChildProcess>();
|
||||||
|
|
||||||
|
/** Returns false if the session has no tracked live process (already ended, or this daemon didn't start it — e.g. after a restart). */
|
||||||
|
export function stopCapture(sessionId: string): boolean {
|
||||||
|
const ytdlp = activeCaptures.get(sessionId);
|
||||||
|
if (!ytdlp) return false;
|
||||||
|
ytdlp.kill("SIGTERM");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function appendCaptureDebug(sessionId: string, line: string) {
|
function appendCaptureDebug(sessionId: string, line: string) {
|
||||||
const lines = lastCaptureDebug.get(sessionId) ?? [];
|
const lines = lastCaptureDebug.get(sessionId) ?? [];
|
||||||
lines.push(line);
|
lines.push(line);
|
||||||
@@ -41,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(
|
async function processCompletedSegments(
|
||||||
entries: SegmentListEntry[],
|
entries: SegmentListEntry[],
|
||||||
fromIndex: number,
|
fromIndex: number,
|
||||||
outDir: string,
|
outDir: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
channelName: string,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
let processed = fromIndex;
|
let processed = fromIndex;
|
||||||
|
const sendRawToTelegram = await sendRawSegmentsToTelegramEnabled();
|
||||||
|
|
||||||
for (let i = fromIndex; i < entries.length; i++) {
|
for (let i = fromIndex; i < entries.length; i++) {
|
||||||
const entry = entries[i];
|
const entry = entries[i];
|
||||||
@@ -64,13 +90,29 @@ async function processCompletedSegments(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await signalDetectionQueue.add("analyze-segment", {
|
if (sendRawToTelegram) {
|
||||||
rawSegmentId: segment.id,
|
// Kullanıcı transkript/9:16 zincirini hiç istemiyor — ham segmenti
|
||||||
filePath,
|
// analiz kuyruğuna hiç sokmadan doğrudan gönderiyoruz. Kuyruğa
|
||||||
sessionId,
|
// 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;
|
processed = i + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +120,9 @@ async function processCompletedSegments(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
|
async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
|
||||||
const { sessionId, youtubeUrl } = 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);
|
const outDir = path.join(env.sharedMediaRoot, "raw", sessionId);
|
||||||
await mkdir(outDir, { recursive: true });
|
await mkdir(outDir, { recursive: true });
|
||||||
|
|
||||||
@@ -90,11 +134,12 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
|
|||||||
"yt-dlp",
|
"yt-dlp",
|
||||||
[
|
[
|
||||||
"--js-runtimes", "node",
|
"--js-runtimes", "node",
|
||||||
...(await ytdlpAntiBotArgs()),
|
...(await ytdlpAntiBotArgs(channelDbId, { includeAndroidClient: false })),
|
||||||
"-f", "bestvideo+bestaudio/best", "-o", "-", youtubeUrl,
|
"-f", "bestvideo+bestaudio/best", "-o", "-", youtubeUrl,
|
||||||
],
|
],
|
||||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||||
);
|
);
|
||||||
|
activeCaptures.set(sessionId, ytdlp);
|
||||||
|
|
||||||
const ffmpeg = spawn(
|
const ffmpeg = spawn(
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
@@ -102,7 +147,7 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
|
|||||||
"-i", "pipe:0",
|
"-i", "pipe:0",
|
||||||
"-c", "copy",
|
"-c", "copy",
|
||||||
"-f", "segment",
|
"-f", "segment",
|
||||||
"-segment_time", String(env.segmentTimeSec),
|
"-segment_time", String(segmentTimeSec ?? env.segmentTimeSec),
|
||||||
"-reset_timestamps", "1",
|
"-reset_timestamps", "1",
|
||||||
"-segment_list", segmentListPath,
|
"-segment_list", segmentListPath,
|
||||||
"-segment_list_type", "csv",
|
"-segment_list_type", "csv",
|
||||||
@@ -131,7 +176,7 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
|
|||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
const entries = parseSegmentListLines(raw);
|
const entries = parseSegmentListLines(raw);
|
||||||
if (entries.length > lastProcessedIndex) {
|
if (entries.length > lastProcessedIndex) {
|
||||||
lastProcessedIndex = await processCompletedSegments(entries, lastProcessedIndex, outDir, sessionId);
|
lastProcessedIndex = await processCompletedSegments(entries, lastProcessedIndex, outDir, sessionId, channelName);
|
||||||
await prisma.streamSession.update({
|
await prisma.streamSession.update({
|
||||||
where: { id: sessionId },
|
where: { id: sessionId },
|
||||||
data: { totalSegments: lastProcessedIndex },
|
data: { totalSegments: lastProcessedIndex },
|
||||||
@@ -153,12 +198,13 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
clearInterval(pollTimer);
|
clearInterval(pollTimer);
|
||||||
|
activeCaptures.delete(sessionId);
|
||||||
|
|
||||||
// final sweep in case segments closed between the last poll and process exit
|
// final sweep in case segments closed between the last poll and process exit
|
||||||
const raw = await readFile(segmentListPath, "utf8").catch(() => "");
|
const raw = await readFile(segmentListPath, "utf8").catch(() => "");
|
||||||
const entries = parseSegmentListLines(raw);
|
const entries = parseSegmentListLines(raw);
|
||||||
if (entries.length > lastProcessedIndex) {
|
if (entries.length > lastProcessedIndex) {
|
||||||
lastProcessedIndex = await processCompletedSegments(entries, lastProcessedIndex, outDir, sessionId);
|
lastProcessedIndex = await processCompletedSegments(entries, lastProcessedIndex, outDir, sessionId, channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.streamSession.update({
|
await prisma.streamSession.update({
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { prisma } from "@streamclipper/db";
|
|||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
|
|
||||||
const CHECK_INTERVAL_MS = 60 * 60 * 1000; // hourly
|
const CHECK_INTERVAL_MS = 60 * 60 * 1000; // hourly
|
||||||
|
const TTL_SETTING_KEY = "raw_segment_ttl_hours";
|
||||||
|
|
||||||
|
async function getTtlHours(): Promise<number> {
|
||||||
|
const setting = await prisma.appSetting.findUnique({ where: { key: TTL_SETTING_KEY } });
|
||||||
|
const parsed = setting ? Number(setting.value) : NaN;
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : env.rawSegmentTtlHours;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PRD's auto-purge lifecycle: processed raw recordings get deleted 24h after
|
* PRD's auto-purge lifecycle: processed raw recordings get deleted 24h after
|
||||||
@@ -11,7 +18,8 @@ const CHECK_INTERVAL_MS = 60 * 60 * 1000; // hourly
|
|||||||
* file mid-analysis or mid-transcription would break the pipeline for it.
|
* file mid-analysis or mid-transcription would break the pipeline for it.
|
||||||
*/
|
*/
|
||||||
async function cleanupOnce(): Promise<void> {
|
async function cleanupOnce(): Promise<void> {
|
||||||
const cutoff = new Date(Date.now() - env.rawSegmentTtlHours * 60 * 60 * 1000);
|
const ttlHours = await getTtlHours();
|
||||||
|
const cutoff = new Date(Date.now() - ttlHours * 60 * 60 * 1000);
|
||||||
|
|
||||||
const staleSegments = await prisma.rawSegment.findMany({
|
const staleSegments = await prisma.rawSegment.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -24,16 +32,16 @@ async function cleanupOnce(): Promise<void> {
|
|||||||
for (const segment of staleSegments) {
|
for (const segment of staleSegments) {
|
||||||
await prisma.candidateSegment.deleteMany({ where: { rawSegmentId: segment.id } });
|
await prisma.candidateSegment.deleteMany({ where: { rawSegmentId: segment.id } });
|
||||||
await prisma.rawSegment.delete({ where: { id: 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) {
|
if (staleSegments.length > 0) {
|
||||||
console.log(`[cleanup] removed ${staleSegments.length} raw segment(s) older than ${env.rawSegmentTtlHours}h`);
|
console.log(`[cleanup] removed ${staleSegments.length} raw segment(s) older than ${ttlHours}h`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startCleanupLoop(): NodeJS.Timeout {
|
export function startCleanupLoop(): NodeJS.Timeout {
|
||||||
console.log(`[cleanup] checking every ${CHECK_INTERVAL_MS}ms, TTL ${env.rawSegmentTtlHours}h`);
|
console.log(`[cleanup] checking every ${CHECK_INTERVAL_MS}ms, default TTL ${env.rawSegmentTtlHours}h`);
|
||||||
cleanupOnce().catch((err) => console.error("[cleanup] initial run failed:", err));
|
cleanupOnce().catch((err) => console.error("[cleanup] initial run failed:", err));
|
||||||
return setInterval(() => {
|
return setInterval(() => {
|
||||||
cleanupOnce().catch((err) => console.error("[cleanup] run failed:", err));
|
cleanupOnce().catch((err) => console.error("[cleanup] run failed:", err));
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ export const env = {
|
|||||||
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",
|
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",
|
||||||
sharedMediaRoot: process.env.SHARED_MEDIA_ROOT ?? "./shared-media",
|
sharedMediaRoot: process.env.SHARED_MEDIA_ROOT ?? "./shared-media",
|
||||||
pollIntervalMs: Number(process.env.POLL_INTERVAL_MS ?? 60_000),
|
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),
|
port: Number(process.env.API_DAEMON_PORT ?? 4001),
|
||||||
forceLiveUrl: process.env.FORCE_LIVE_URL ?? "",
|
forceLiveUrl: process.env.FORCE_LIVE_URL ?? "",
|
||||||
ytdlpPotProviderUrl: process.env.YTDLP_POT_PROVIDER_URL ?? "",
|
ytdlpPotProviderUrl: process.env.YTDLP_POT_PROVIDER_URL ?? "",
|
||||||
proxyUrl: process.env.PROXY_URL ?? "",
|
proxyUrl: process.env.PROXY_URL ?? "",
|
||||||
maxConcurrentCaptures: Number(process.env.MAX_CONCURRENT_CAPTURES ?? 10),
|
maxConcurrentCaptures: Number(process.env.MAX_CONCURRENT_CAPTURES ?? 10),
|
||||||
rawSegmentTtlHours: Number(process.env.RAW_SEGMENT_TTL_HOURS ?? 24),
|
rawSegmentTtlHours: Number(process.env.RAW_SEGMENT_TTL_HOURS ?? 24),
|
||||||
|
internalApiToken: process.env.INTERNAL_API_TOKEN ?? "",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface StreamIngestJob {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
channelDbId: string;
|
channelDbId: string;
|
||||||
youtubeUrl: string;
|
youtubeUrl: string;
|
||||||
|
/** Kanala özel segment süresi (sn) — verilmezse env.segmentTimeSec kullanılır. */
|
||||||
|
segmentTimeSec?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SignalDetectionJob {
|
export interface SignalDetectionJob {
|
||||||
@@ -34,3 +36,13 @@ export const streamIngestQueue = new Queue<StreamIngestJob>(QUEUE_NAMES.STREAM_I
|
|||||||
export const signalDetectionQueue = new Queue<SignalDetectionJob>(QUEUE_NAMES.SIGNAL_DETECTION, {
|
export const signalDetectionQueue = new Queue<SignalDetectionJob>(QUEUE_NAMES.SIGNAL_DETECTION, {
|
||||||
connection: redisConnection,
|
connection: redisConnection,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export interface VideoRenderJob {
|
||||||
|
candidateSegmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consumed by the Python worker (apps/worker/worker/video_render.py) — job
|
||||||
|
// name/payload shape must match what stt_scoring.py already enqueues.
|
||||||
|
export const videoRenderQueue = new Queue<VideoRenderJob>(QUEUE_NAMES.VIDEO_RENDER, {
|
||||||
|
connection: redisConnection,
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import { prisma } from "@streamclipper/db";
|
import { prisma } from "@streamclipper/db";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
import { lastPollErrors } from "./youtubePolling";
|
import { lastPollErrors, checkChannel, startCaptureSession } from "./youtubePolling";
|
||||||
import { lastCaptureDebug } from "./capture/streamIngest";
|
import { lastCaptureDebug, stopCapture } from "./capture/streamIngest";
|
||||||
|
import { videoRenderQueue } from "./queues";
|
||||||
|
|
||||||
export function startServer() {
|
export function startServer() {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
app.get("/health", (_req, res) => res.json({ ok: true }));
|
app.get("/health", (_req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
@@ -37,6 +39,70 @@ export function startServer() {
|
|||||||
res.json({ lines: lastCaptureDebug.get(req.params.sessionId) ?? [] });
|
res.json({ lines: lastCaptureDebug.get(req.params.sessionId) ?? [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These endpoints change state (stop a capture, start one against an
|
||||||
|
* arbitrary URL, re-run a render) — unlike /health and /status they must
|
||||||
|
* not be reachable by anyone who finds this daemon's public sslip.io URL.
|
||||||
|
* If INTERNAL_API_TOKEN isn't set (local dev), they stay open — matches
|
||||||
|
* SESSION_SECRET's existing "insecure default, fine for local" pattern.
|
||||||
|
*/
|
||||||
|
function requireInternalToken(req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||||
|
if (!env.internalApiToken) return next();
|
||||||
|
const header = req.header("authorization") ?? "";
|
||||||
|
if (header === `Bearer ${env.internalApiToken}`) return next();
|
||||||
|
res.status(401).json({ error: "unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post("/sessions/:sessionId/stop", requireInternalToken, (req, res) => {
|
||||||
|
const stopped = stopCapture(req.params.sessionId);
|
||||||
|
res.json({ stopped });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/channels/:channelId/check-now", requireInternalToken, async (req, res) => {
|
||||||
|
const channel = await prisma.channel.findUnique({ where: { id: req.params.channelId } });
|
||||||
|
if (!channel) return res.status(404).json({ error: "channel not found" });
|
||||||
|
const result = await checkChannel(channel);
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/channels/:channelId/force-start", requireInternalToken, async (req, res) => {
|
||||||
|
const channel = await prisma.channel.findUnique({ where: { id: req.params.channelId } });
|
||||||
|
if (!channel) return res.status(404).json({ error: "channel not found" });
|
||||||
|
const youtubeUrl = String(req.body?.url ?? "").trim();
|
||||||
|
if (!youtubeUrl) return res.status(400).json({ error: "url is required" });
|
||||||
|
const result = await startCaptureSession(channel, youtubeUrl);
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/shorts/:candidateId/render", requireInternalToken, async (req, res) => {
|
||||||
|
const candidateId = req.params.candidateId;
|
||||||
|
const candidate = await prisma.candidateSegment.findUnique({ where: { id: candidateId } });
|
||||||
|
if (!candidate) return res.status(404).json({ error: "candidate not found" });
|
||||||
|
|
||||||
|
await prisma.shortVideo.upsert({
|
||||||
|
where: { candidateId },
|
||||||
|
create: { candidateId, status: "PENDING" },
|
||||||
|
update: { status: "PENDING", errorMessage: null },
|
||||||
|
});
|
||||||
|
await videoRenderQueue.add("render-short", { candidateSegmentId: candidateId });
|
||||||
|
res.json({ queued: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/shorts/:candidateId/cancel", requireInternalToken, async (req, res) => {
|
||||||
|
const candidateId = req.params.candidateId;
|
||||||
|
const short = await prisma.shortVideo.findUnique({ where: { candidateId } });
|
||||||
|
if (!short) return res.status(404).json({ error: "short not found" });
|
||||||
|
|
||||||
|
// Doesn't kill the in-flight Python render process — just clears the
|
||||||
|
// stuck DB state so the panel can offer a retry instead of showing
|
||||||
|
// "RENDERING" forever if a worker died mid-job.
|
||||||
|
await prisma.shortVideo.update({
|
||||||
|
where: { candidateId },
|
||||||
|
data: { status: "FAILED", errorMessage: "Manuel olarak iptal edildi." },
|
||||||
|
});
|
||||||
|
res.json({ cancelled: true });
|
||||||
|
});
|
||||||
|
|
||||||
app.listen(env.port, () => {
|
app.listen(env.port, () => {
|
||||||
console.log(`[server] api-daemon listening on :${env.port}`);
|
console.log(`[server] api-daemon listening on :${env.port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN ?? "";
|
||||||
const CHAT_ID = process.env.TELEGRAM_CHAT_ID ?? "";
|
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> {
|
export async function sendTelegramMessage(text: string): Promise<void> {
|
||||||
if (!BOT_TOKEN || !CHAT_ID) {
|
if (!BOT_TOKEN || !CHAT_ID) {
|
||||||
console.warn("[telegram] TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID not set, skipping notification:", text);
|
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());
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,9 +10,18 @@ const execFileAsync = promisify(execFile);
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Live detection failures are kept per channel and surfaced via GET /status
|
* Live detection failures are kept per channel and surfaced via GET /status
|
||||||
* for debugging.
|
* 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 const lastPollErrors = new Map<string, string>();
|
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 few approaches were tried before landing here:
|
||||||
@@ -30,27 +39,74 @@ export const lastPollErrors = new Map<string, string>();
|
|||||||
* residential/ISP proxy (the Coolify server's own IP got flagged from a
|
* residential/ISP proxy (the Coolify server's own IP got flagged from a
|
||||||
* day of heavy testing). No `-f` format selector here — resolving
|
* day of heavy testing). No `-f` format selector here — resolving
|
||||||
* downloadable formats is a separate, heavier check only needed later,
|
* downloadable formats is a separate, heavier check only needed later,
|
||||||
* in the actual capture.
|
* 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 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`;
|
const liveUrl = `https://www.youtube.com/channel/${channelId}/live`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync(
|
const { stdout } = await execFileAsync(
|
||||||
"yt-dlp",
|
"yt-dlp",
|
||||||
["--simulate", "--no-warnings", ...(await ytdlpAntiBotArgs()), "--print", "%(id)s", liveUrl],
|
[
|
||||||
|
"--simulate",
|
||||||
|
"--no-warnings",
|
||||||
|
"--js-runtimes",
|
||||||
|
"node",
|
||||||
|
...(await ytdlpAntiBotArgs(channelDbId)),
|
||||||
|
"--print",
|
||||||
|
"%(id)s",
|
||||||
|
liveUrl,
|
||||||
|
],
|
||||||
{ timeout: 20_000 },
|
{ 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];
|
const videoId = stdout.trim().split("\n")[0];
|
||||||
return videoId || null;
|
return videoId || null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
// yt-dlp reports "not live" as a non-zero exit too — that's a normal,
|
// yt-dlp reports "not live" / "starts in N minutes" as a non-zero exit
|
||||||
// expected outcome on every check where the channel isn't broadcasting,
|
// too — normal, expected outcomes, not failures worth a red badge.
|
||||||
// not a failure worth surfacing as an error in the panel.
|
if (!BENIGN_POLL_MESSAGE.test(message)) {
|
||||||
if (!/is not currently live/.test(message)) {
|
// Alert only on the transition into failing — not every 60s poll
|
||||||
lastPollErrors.set(channelId, message.slice(0, 500));
|
// 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 {
|
} else {
|
||||||
lastPollErrors.delete(channelId);
|
lastPollErrors.delete(channelId);
|
||||||
}
|
}
|
||||||
@@ -58,11 +114,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; segmentTimeSec?: number | null },
|
||||||
|
youtubeUrl: string,
|
||||||
|
liveVideoId: string | null = null,
|
||||||
|
): Promise<{ started: boolean }> {
|
||||||
const existing = await prisma.streamSession.findFirst({
|
const existing = await prisma.streamSession.findFirst({
|
||||||
where: { channelId: channel.id, endedAt: null },
|
where: { channelId: channel.id, endedAt: null },
|
||||||
});
|
});
|
||||||
if (existing) return;
|
if (existing) return { started: false };
|
||||||
|
|
||||||
const session = await prisma.streamSession.create({
|
const session = await prisma.streamSession.create({
|
||||||
data: { channelId: channel.id, liveVideoId },
|
data: { channelId: channel.id, liveVideoId },
|
||||||
@@ -71,37 +138,95 @@ async function startSession(channel: { id: string; name: string }, liveVideoId:
|
|||||||
await streamIngestQueue.add("start-capture", {
|
await streamIngestQueue.add("start-capture", {
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
channelDbId: channel.id,
|
channelDbId: channel.id,
|
||||||
youtubeUrl: `https://www.youtube.com/watch?v=${liveVideoId}`,
|
youtubeUrl,
|
||||||
|
segmentTimeSec: channel.segmentTimeSec ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
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}`);
|
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, channel.id);
|
||||||
|
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> {
|
export async function pollOnce(): Promise<void> {
|
||||||
const channels = await prisma.channel.findMany({ where: { isActive: true } });
|
const channels = await prisma.channel.findMany({ where: { isActive: true } });
|
||||||
|
|
||||||
for (const channel of channels) {
|
const openSessions = await prisma.streamSession.findMany({
|
||||||
try {
|
where: { endedAt: null, channelId: { in: channels.map((c) => c.id) } },
|
||||||
const liveVideoId = await findLiveVideoId(channel.channelId);
|
select: { channelId: true },
|
||||||
await prisma.channel.update({
|
});
|
||||||
where: { id: channel.id },
|
const recordingChannelIds = new Set(openSessions.map((s) => s.channelId));
|
||||||
data: { lastCheckedAt: new Date() },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (liveVideoId) {
|
for (const channel of channels) {
|
||||||
await startSession(channel, liveVideoId);
|
// 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) {
|
} catch (err) {
|
||||||
console.error(`[youtube-polling] error polling channel ${channel.name}:`, 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startPollingLoop(): NodeJS.Timeout {
|
const POLL_INTERVAL_SETTING_KEY = "poll_interval_ms";
|
||||||
console.log(`[youtube-polling] polling every ${env.pollIntervalMs}ms`);
|
|
||||||
pollOnce().catch((err) => console.error("[youtube-polling] initial poll failed:", err));
|
async function getPollIntervalMs(): Promise<number> {
|
||||||
return setInterval(() => {
|
const setting = await prisma.appSetting.findUnique({ where: { key: POLL_INTERVAL_SETTING_KEY } });
|
||||||
pollOnce().catch((err) => console.error("[youtube-polling] poll failed:", err));
|
const parsed = setting ? Number(setting.value) : NaN;
|
||||||
}, env.pollIntervalMs);
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,70 @@ import { writeFileSync } from "node:fs";
|
|||||||
import { prisma } from "@streamclipper/db";
|
import { prisma } from "@streamclipper/db";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
|
|
||||||
const COOKIES_PATH = "/tmp/yt-cookies.txt";
|
const LEGACY_COOKIES_PATH = "/tmp/yt-cookies.txt";
|
||||||
const COOKIES_SETTING_KEY = "ytdlp_cookies";
|
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
|
* YouTube's anti-bot layer against yt-dlp has three parts, all needed
|
||||||
@@ -21,21 +83,38 @@ const COOKIES_SETTING_KEY = "ytdlp_cookies";
|
|||||||
* current value and rewrites the temp file, so a panel update takes effect
|
* current value and rewrites the temp file, so a panel update takes effect
|
||||||
* on the very next yt-dlp invocation without a redeploy.
|
* 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 args: string[] = [];
|
||||||
|
|
||||||
const setting = await prisma.appSetting.findUnique({ where: { key: COOKIES_SETTING_KEY } });
|
const cookiesPath = await cookiesFilePathForChannel(channelKey);
|
||||||
if (setting?.value) {
|
if (cookiesPath) {
|
||||||
writeFileSync(COOKIES_PATH, setting.value, { mode: 0o600 });
|
args.push("--cookies", cookiesPath);
|
||||||
args.push("--cookies", COOKIES_PATH);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (env.ytdlpPotProviderUrl) {
|
if (env.ytdlpPotProviderUrl) {
|
||||||
args.push("--extractor-args", `youtubepot-bgutilhttp:base_url=${env.ytdlpPotProviderUrl}`);
|
args.push("--extractor-args", `youtubepot-bgutilhttp:base_url=${env.ytdlpPotProviderUrl}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Büyük/popüler kanallarda (ör. NASA, çok izleyicili yayınlar) sade "web"
|
||||||
|
// client'ı cookie+PO-token kombinasyonuna rağmen "Sign in to confirm
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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) {
|
if (env.proxyUrl) {
|
||||||
args.push("--proxy", env.proxyUrl);
|
args.push("--proxy", proxyUrlForChannel(env.proxyUrl, channelKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
return args;
|
return args;
|
||||||
|
|||||||
@@ -7,4 +7,12 @@ COPY . .
|
|||||||
RUN pnpm install
|
RUN pnpm install
|
||||||
RUN pnpm --filter @streamclipper/db exec prisma generate
|
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"]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { unlink } from "node:fs/promises";
|
|||||||
import { prisma } from "@streamclipper/db";
|
import { prisma } from "@streamclipper/db";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { postToApiDaemon } from "../lib/apiDaemon";
|
||||||
|
|
||||||
export async function addChannel(formData: FormData) {
|
export async function addChannel(formData: FormData) {
|
||||||
const name = String(formData.get("name") ?? "").trim();
|
const name = String(formData.get("name") ?? "").trim();
|
||||||
@@ -21,19 +22,32 @@ export async function addChannel(formData: FormData) {
|
|||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ShortVideo has a required FK to CandidateSegment (candidate_id) — every deletion path that removes candidates must clear their shorts (row + rendered file) first, or Postgres rejects the candidate delete. */
|
||||||
|
async function deleteShortsForCandidates(candidateIds: string[]): Promise<void> {
|
||||||
|
if (candidateIds.length === 0) return;
|
||||||
|
const shorts = await prisma.shortVideo.findMany({ where: { candidateId: { in: candidateIds } } });
|
||||||
|
await prisma.shortVideo.deleteMany({ where: { candidateId: { in: candidateIds } } });
|
||||||
|
await Promise.all(shorts.filter((s) => s.filePath).map((s) => unlink(s.filePath!).catch(() => {})));
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteRawSegment(segmentId: string) {
|
export async function deleteRawSegment(segmentId: string) {
|
||||||
const segment = await prisma.rawSegment.findUnique({ where: { id: segmentId } });
|
const segment = await prisma.rawSegment.findUnique({
|
||||||
|
where: { id: segmentId },
|
||||||
|
include: { candidates: true },
|
||||||
|
});
|
||||||
if (!segment) return;
|
if (!segment) return;
|
||||||
|
|
||||||
|
await deleteShortsForCandidates(segment.candidates.map((c) => c.id));
|
||||||
await prisma.candidateSegment.deleteMany({ where: { rawSegmentId: segmentId } });
|
await prisma.candidateSegment.deleteMany({ where: { rawSegmentId: segmentId } });
|
||||||
await prisma.rawSegment.delete({ where: { id: segmentId } });
|
await prisma.rawSegment.delete({ where: { id: segmentId } });
|
||||||
await unlink(segment.filePath).catch(() => {});
|
if (segment.filePath) await unlink(segment.filePath).catch(() => {});
|
||||||
|
|
||||||
revalidatePath("/segments");
|
revalidatePath("/segments");
|
||||||
revalidatePath("/", "layout");
|
revalidatePath("/", "layout");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCandidateSegment(candidateId: string) {
|
export async function deleteCandidateSegment(candidateId: string) {
|
||||||
|
await deleteShortsForCandidates([candidateId]);
|
||||||
await prisma.candidateSegment.delete({ where: { id: candidateId } });
|
await prisma.candidateSegment.delete({ where: { id: candidateId } });
|
||||||
|
|
||||||
revalidatePath("/segments");
|
revalidatePath("/segments");
|
||||||
@@ -43,11 +57,13 @@ export async function deleteCandidateSegment(candidateId: string) {
|
|||||||
export async function deleteChannel(channelId: string) {
|
export async function deleteChannel(channelId: string) {
|
||||||
const sessions = await prisma.streamSession.findMany({
|
const sessions = await prisma.streamSession.findMany({
|
||||||
where: { channelId },
|
where: { channelId },
|
||||||
include: { segments: true },
|
include: { segments: { include: { candidates: true } } },
|
||||||
});
|
});
|
||||||
const rawSegmentIds = sessions.flatMap((s) => s.segments.map((seg) => seg.id));
|
const rawSegmentIds = sessions.flatMap((s) => s.segments.map((seg) => seg.id));
|
||||||
const filePaths = sessions.flatMap((s) => s.segments.map((seg) => seg.filePath));
|
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)).filter((p): p is string => Boolean(p));
|
||||||
|
|
||||||
|
await deleteShortsForCandidates(candidateIds);
|
||||||
await prisma.candidateSegment.deleteMany({ where: { rawSegmentId: { in: rawSegmentIds } } });
|
await prisma.candidateSegment.deleteMany({ where: { rawSegmentId: { in: rawSegmentIds } } });
|
||||||
await prisma.rawSegment.deleteMany({ where: { sessionId: { in: sessions.map((s) => s.id) } } });
|
await prisma.rawSegment.deleteMany({ where: { sessionId: { in: sessions.map((s) => s.id) } } });
|
||||||
await prisma.streamSession.deleteMany({ where: { channelId } });
|
await prisma.streamSession.deleteMany({ where: { channelId } });
|
||||||
@@ -59,6 +75,140 @@ export async function deleteChannel(channelId: string) {
|
|||||||
redirect("/");
|
redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function toggleChannelActive(channelId: string, nextActive: boolean) {
|
||||||
|
await prisma.channel.update({ where: { id: channelId }, data: { isActive: nextActive } });
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateChannelSegmentTime(channelId: string, formData: FormData) {
|
||||||
|
const raw = String(formData.get("segmentTimeMin") ?? "").trim();
|
||||||
|
const minutes = raw === "" ? null : Number(raw);
|
||||||
|
const seconds = minutes !== null && Number.isFinite(minutes) && minutes > 0 ? Math.round(minutes * 60) : null;
|
||||||
|
|
||||||
|
await prisma.channel.update({ where: { id: channelId }, data: { segmentTimeSec: seconds } });
|
||||||
|
revalidatePath(`/channels/${channelId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleAllChannels(nextActive: boolean) {
|
||||||
|
await prisma.channel.updateMany({ data: { isActive: nextActive } });
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopSession(sessionId: string) {
|
||||||
|
await postToApiDaemon(`/sessions/${sessionId}/stop`);
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkChannelNow(channelId: string) {
|
||||||
|
await postToApiDaemon(`/channels/${channelId}/check-now`);
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forceStartCapture(channelId: string, formData: FormData) {
|
||||||
|
const url = String(formData.get("url") ?? "").trim();
|
||||||
|
if (!url) throw new Error("YouTube URL zorunlu.");
|
||||||
|
await postToApiDaemon(`/channels/${channelId}/force-start`, { url });
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryRender(candidateId: string) {
|
||||||
|
await postToApiDaemon(`/shorts/${candidateId}/render`);
|
||||||
|
revalidatePath("/segments");
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelRender(candidateId: string) {
|
||||||
|
await postToApiDaemon(`/shorts/${candidateId}/cancel`);
|
||||||
|
revalidatePath("/segments");
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSystemSettings(formData: FormData) {
|
||||||
|
const pollSeconds = Number(formData.get("pollIntervalSec"));
|
||||||
|
const ttlHours = Number(formData.get("ttlHours"));
|
||||||
|
|
||||||
|
if (Number.isFinite(pollSeconds) && pollSeconds >= 5) {
|
||||||
|
await prisma.appSetting.upsert({
|
||||||
|
where: { key: "poll_interval_ms" },
|
||||||
|
create: { key: "poll_interval_ms", value: String(pollSeconds * 1000) },
|
||||||
|
update: { value: String(pollSeconds * 1000) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (Number.isFinite(ttlHours) && ttlHours > 0) {
|
||||||
|
await prisma.appSetting.upsert({
|
||||||
|
where: { key: "raw_segment_ttl_hours" },
|
||||||
|
create: { key: "raw_segment_ttl_hours", value: String(ttlHours) },
|
||||||
|
update: { value: String(ttlHours) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSttEnabled(formData: FormData) {
|
||||||
|
const enabled = formData.get("sttEnabled") === "true";
|
||||||
|
await prisma.appSetting.upsert({
|
||||||
|
where: { key: "stt_enabled" },
|
||||||
|
create: { key: "stt_enabled", value: String(enabled) },
|
||||||
|
update: { value: String(enabled) },
|
||||||
|
});
|
||||||
|
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) },
|
||||||
|
update: { value: String(streamStart) },
|
||||||
|
});
|
||||||
|
await prisma.appSetting.upsert({
|
||||||
|
where: { key: "notify_render_done" },
|
||||||
|
create: { key: "notify_render_done", value: String(renderDone) },
|
||||||
|
update: { value: String(renderDone) },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/settings");
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateYtdlpCookies(formData: FormData) {
|
export async function updateYtdlpCookies(formData: FormData) {
|
||||||
const value = String(formData.get("cookies") ?? "").trim();
|
const value = String(formData.get("cookies") ?? "").trim();
|
||||||
|
|
||||||
@@ -74,3 +224,30 @@ export async function updateYtdlpCookies(formData: FormData) {
|
|||||||
|
|
||||||
revalidatePath("/settings");
|
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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { API_DAEMON_URL } from "../../../../../lib/apiDaemon";
|
||||||
|
|
||||||
|
/** Client components can't reach api-daemon's internal Docker hostname directly — this proxies the (auth-free, read-only) debug endpoint through the frontend's own origin. */
|
||||||
|
export async function GET(_req: Request, { params }: { params: Promise<{ sessionId: string }> }) {
|
||||||
|
const { sessionId } = await params;
|
||||||
|
const res = await fetch(`${API_DAEMON_URL}/debug/capture/${sessionId}`, { cache: "no-store" });
|
||||||
|
if (!res.ok) return NextResponse.json({ lines: [] }, { status: res.status });
|
||||||
|
const data = await res.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
}
|
||||||
@@ -1,48 +1,12 @@
|
|||||||
import { createReadStream, statSync } from "node:fs";
|
|
||||||
import { Readable } from "node:stream";
|
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { prisma } from "@streamclipper/db";
|
import { prisma } from "@streamclipper/db";
|
||||||
|
import { streamVideoFile } from "../../../../../lib/videoStream";
|
||||||
|
|
||||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
const segment = await prisma.rawSegment.findUnique({ where: { id } });
|
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 });
|
||||||
|
|
||||||
let size: number;
|
return streamVideoFile(req, segment.filePath, `${id}.mp4`);
|
||||||
try {
|
|
||||||
size = statSync(segment.filePath).size;
|
|
||||||
} catch {
|
|
||||||
return new Response("File not found on disk", { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
"Content-Type": "video/mp4",
|
|
||||||
"Accept-Ranges": "bytes",
|
|
||||||
};
|
|
||||||
if (req.nextUrl.searchParams.get("download") === "1") {
|
|
||||||
headers["Content-Disposition"] = `attachment; filename="${id}.mp4"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const range = req.headers.get("range");
|
|
||||||
const match = range ? /bytes=(\d+)-(\d*)/.exec(range) : null;
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
const start = Number(match[1]);
|
|
||||||
const end = match[2] ? Number(match[2]) : size - 1;
|
|
||||||
const stream = createReadStream(segment.filePath, { start, end });
|
|
||||||
return new Response(Readable.toWeb(stream) as ReadableStream, {
|
|
||||||
status: 206,
|
|
||||||
headers: {
|
|
||||||
...headers,
|
|
||||||
"Content-Range": `bytes ${start}-${end}/${size}`,
|
|
||||||
"Content-Length": String(end - start + 1),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const stream = createReadStream(segment.filePath);
|
|
||||||
return new Response(Readable.toWeb(stream) as ReadableStream, {
|
|
||||||
headers: { ...headers, "Content-Length": String(size) },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { prisma } from "@streamclipper/db";
|
||||||
|
import { streamVideoFile } from "../../../../../lib/videoStream";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const short = await prisma.shortVideo.findUnique({ where: { id } });
|
||||||
|
if (!short?.filePath) return new Response("Not found", { status: 404 });
|
||||||
|
|
||||||
|
return streamVideoFile(req, short.filePath, `${id}.mp4`);
|
||||||
|
}
|
||||||
@@ -2,17 +2,39 @@ import { notFound } from "next/navigation";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { prisma } from "@streamclipper/db";
|
import { prisma } from "@streamclipper/db";
|
||||||
import { fetchChannelStatuses } from "../../../lib/apiDaemon";
|
import { fetchChannelStatuses } from "../../../lib/apiDaemon";
|
||||||
import { deleteRawSegment, deleteCandidateSegment, deleteChannel } from "../../actions";
|
import { formatTr } from "../../../lib/formatDate";
|
||||||
import { DeleteButton } from "../../components/DeleteButton";
|
import {
|
||||||
|
deleteRawSegment,
|
||||||
|
deleteCandidateSegment,
|
||||||
|
deleteChannel,
|
||||||
|
stopSession,
|
||||||
|
forceStartCapture,
|
||||||
|
retryRender,
|
||||||
|
cancelRender,
|
||||||
|
updateChannelSegmentTime,
|
||||||
|
} from "../../actions";
|
||||||
|
import { ConfirmButton } from "../../components/ConfirmButton";
|
||||||
|
import { LiveLogViewer } from "../../components/LiveLogViewer";
|
||||||
|
import { SessionTimer } from "../../components/SessionTimer";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
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";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const STATUS_BADGE: Record<string, string> = {
|
const STATUS_BADGE: Record<string, VariantProps<typeof badgeVariants>["variant"]> = {
|
||||||
PENDING: "muted",
|
PENDING: "muted",
|
||||||
PROCESSED: "ok",
|
PROCESSED: "ok",
|
||||||
DISCARDED: "err",
|
DISCARDED: "err",
|
||||||
|
SENT_TO_TELEGRAM: "ok",
|
||||||
PENDING_STT: "warn",
|
PENDING_STT: "warn",
|
||||||
TRANSCRIBED: "ok",
|
TRANSCRIBED: "ok",
|
||||||
|
RENDERING: "warn",
|
||||||
|
READY: "ok",
|
||||||
FAILED: "err",
|
FAILED: "err",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,7 +56,7 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
|
|||||||
include: {
|
include: {
|
||||||
segments: {
|
segments: {
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
include: { candidates: { orderBy: { createdAt: "desc" } } },
|
include: { candidates: { orderBy: { createdAt: "desc" }, include: { short: true } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -46,103 +68,281 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
|
|||||||
if (!channel) notFound();
|
if (!channel) notFound();
|
||||||
|
|
||||||
const status = statuses.get(channel.id);
|
const status = statuses.get(channel.id);
|
||||||
|
const activeSession = channel.sessions.find((s) => s.endedAt === null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-6">
|
||||||
<p><Link href="/">← Kanal Durumu</Link></p>
|
<div>
|
||||||
|
<Link href="/" className="text-sm text-muted-foreground hover:text-foreground">
|
||||||
|
← Kanal Durumu
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card-head">
|
<div className="flex items-center justify-between">
|
||||||
<h1>{channel.name}</h1>
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{channel.name}</h1>
|
||||||
|
<p className="font-mono-num text-xs text-muted-foreground">
|
||||||
|
{channel.youtubeHandle} · {channel.channelId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<form action={deleteChannel.bind(null, channel.id)}>
|
<form action={deleteChannel.bind(null, channel.id)}>
|
||||||
<DeleteButton confirmText="Bu kanalı ve tüm kayıt geçmişini silmek istediğine emin misin? Geri alınamaz." label="Kanalı Sil" />
|
<ConfirmButton
|
||||||
|
confirmText="Bu kanalı ve tüm kayıt geçmişini silmek istediğine emin misin? Geri alınamaz."
|
||||||
|
label="Kanalı Sil"
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<p className="mono">{channel.youtubeHandle} · {channel.channelId}</p>
|
|
||||||
|
|
||||||
<div className="card">
|
<Card>
|
||||||
<div className="card-head">
|
<CardHeader className="flex-row items-center justify-between">
|
||||||
<span>Canlı Durum</span>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Canlı Durum</CardTitle>
|
||||||
<span className={`badge ${status?.recording ? "warn" : "muted"}`}>
|
<div className="flex items-center gap-2">
|
||||||
{status?.recording ? "🔴 Kayıtta" : "Kayıtta değil"}
|
<Badge variant={status?.recording ? "live" : "muted"}>
|
||||||
</span>
|
{status?.recording ? "🔴 Kayıtta" : "Kayıtta değil"}
|
||||||
</div>
|
{status?.recording && activeSession && (
|
||||||
<div className="mono">
|
<>
|
||||||
Aktif: {channel.isActive ? "evet" : "hayır"} ·{" "}
|
{" · "}
|
||||||
Son kontrol: {channel.lastCheckedAt ? new Date(channel.lastCheckedAt).toLocaleString("tr-TR") : "—"}
|
<SessionTimer startedAt={activeSession.startedAt.toISOString()} />
|
||||||
</div>
|
</>
|
||||||
{status === undefined && (
|
)}
|
||||||
<p className="empty" style={{ marginTop: "0.5rem" }}>
|
</Badge>
|
||||||
api-daemon'dan canlı durum alınamadı (servis erişilemez olabilir).
|
{status?.recording && activeSession && (
|
||||||
|
<form action={stopSession.bind(null, activeSession.id)}>
|
||||||
|
<ConfirmButton confirmText="Bu kaydı şimdi durdurmak istediğine emin misin?" label="Durdur" />
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<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>
|
</p>
|
||||||
)}
|
|
||||||
{status?.lastPollError && (
|
{status === undefined && (
|
||||||
<div style={{ marginTop: "0.5rem" }}>
|
<p className="text-sm text-muted-foreground">
|
||||||
<span className="badge err">son poll hatası</span>
|
api-daemon'dan canlı durum alınamadı (servis erişilemez olabilir).
|
||||||
<pre className="mono" style={{ whiteSpace: "pre-wrap", marginTop: "0.4rem" }}>
|
</p>
|
||||||
{status.lastPollError}
|
)}
|
||||||
</pre>
|
|
||||||
|
{status?.lastPollError && (
|
||||||
|
<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ı
|
||||||
|
</Badge>
|
||||||
|
<span className="font-mono-num text-xs text-muted-foreground">
|
||||||
|
{formatTr(status.lastPollError.at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<pre className="font-mono-num whitespace-pre-wrap text-[0.7rem] text-muted-foreground">
|
||||||
|
{status.lastPollError.message}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<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 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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 text-lg font-semibold tracking-tight">Yayın Geçmişi</h2>
|
||||||
|
{channel.sessions.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Bu kanal için henüz bir kayıt oturumu yok.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{channel.sessions.map((session) => (
|
||||||
|
<Card key={session.id}>
|
||||||
|
<CardHeader className="flex-row items-center justify-between">
|
||||||
|
<span className="font-mono-num text-xs text-muted-foreground">
|
||||||
|
{formatTr(session.startedAt)}
|
||||||
|
{session.endedAt ? ` – ${formatTr(session.endedAt)}` : " (devam ediyor)"}
|
||||||
|
</span>
|
||||||
|
<Badge variant="muted">{session.totalSegments} segment</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{session.segments.length === 0 ? (
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">Henüz segment yok.</p>
|
||||||
|
</CardContent>
|
||||||
|
) : (
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
{session.segments.map((segment) => (
|
||||||
|
<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="text-sm font-medium">
|
||||||
|
{segment.duration}sn segment · {formatTr(segment.startedAt)}
|
||||||
|
</span>
|
||||||
|
<Badge variant={STATUS_BADGE[segment.status] ?? "muted"}>{segment.status}</Badge>
|
||||||
|
</div>
|
||||||
|
{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>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
✅ Telegram'a gönderildi, sunucuda saklanmıyor.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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 && (
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{c.short.status === "READY" && !c.short.filePath && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
✅ Telegram'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 && 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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<h1 style={{ marginTop: "2rem" }}>Yayın Geçmişi</h1>
|
|
||||||
{channel.sessions.length === 0 ? (
|
|
||||||
<p className="empty">Bu kanal için henüz bir kayıt oturumu yok.</p>
|
|
||||||
) : (
|
|
||||||
channel.sessions.map((session) => (
|
|
||||||
<div className="card" key={session.id}>
|
|
||||||
<div className="card-head">
|
|
||||||
<span className="mono">
|
|
||||||
{new Date(session.startedAt).toLocaleString("tr-TR")}
|
|
||||||
{session.endedAt ? ` – ${new Date(session.endedAt).toLocaleString("tr-TR")}` : " (devam ediyor)"}
|
|
||||||
</span>
|
|
||||||
<span className="badge muted">{session.totalSegments} segment</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{session.segments.length === 0 ? (
|
|
||||||
<p className="empty" style={{ marginTop: "0.5rem" }}>Henüz segment yok.</p>
|
|
||||||
) : (
|
|
||||||
session.segments.map((segment) => (
|
|
||||||
<div key={segment.id} style={{ marginTop: "0.75rem", paddingLeft: "0.75rem", borderLeft: "2px solid var(--border)" }}>
|
|
||||||
<div className="card-head">
|
|
||||||
<span className="mono">{segment.filePath}</span>
|
|
||||||
<span className={`badge ${STATUS_BADGE[segment.status] ?? "muted"}`}>{segment.status}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<video controls preload="metadata" style={{ width: "100%", maxWidth: "480px", marginTop: "0.5rem", borderRadius: "6px" }}>
|
|
||||||
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
|
|
||||||
</video>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.5rem" }}>
|
|
||||||
<a className="badge muted" href={`/api/segments/${segment.id}/video?download=1`}>İndir</a>
|
|
||||||
<form action={deleteRawSegment.bind(null, segment.id)}>
|
|
||||||
<DeleteButton confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz." />
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{segment.candidates.map((c) => (
|
|
||||||
<div key={c.id} style={{ marginTop: "0.5rem", paddingLeft: "0.75rem", borderLeft: "2px solid var(--border)" }}>
|
|
||||||
<div className="card-head">
|
|
||||||
<span className="mono">
|
|
||||||
{c.startSec}s – {c.endSec}s
|
|
||||||
{c.audioPeakScore != null && ` · peak ${c.audioPeakScore.toFixed(1)}dB`}
|
|
||||||
</span>
|
|
||||||
<span className={`badge ${STATUS_BADGE[c.status] ?? "muted"}`}>{c.status}</span>
|
|
||||||
</div>
|
|
||||||
{transcriptPreview(c.transcriptJson) && (
|
|
||||||
<p className="transcript-preview">{transcriptPreview(c.transcriptJson)}</p>
|
|
||||||
)}
|
|
||||||
<form action={deleteCandidateSegment.bind(null, c.id)} style={{ marginTop: "0.35rem" }}>
|
|
||||||
<DeleteButton confirmText="Bu aday klibi silmek istediğine emin misin?" />
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
|
import { Button, type buttonVariants } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import type { VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
/** Submits the form this button lives in — used inside the (portaled) AlertDialogAction, which isn't a DOM descendant of that form, so `type="submit"` alone wouldn't reach it. */
|
||||||
|
export function ConfirmButton({
|
||||||
|
confirmText,
|
||||||
|
label,
|
||||||
|
triggerLabel,
|
||||||
|
variant = "destructive",
|
||||||
|
}: {
|
||||||
|
confirmText: string;
|
||||||
|
label: string;
|
||||||
|
triggerLabel?: string;
|
||||||
|
variant?: VariantProps<typeof buttonVariants>["variant"];
|
||||||
|
}) {
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button ref={triggerRef} type="button" variant={variant} size="sm">
|
||||||
|
{triggerLabel ?? label}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Emin misin?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{confirmText}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Vazgeç</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant={variant}
|
||||||
|
onClick={() => triggerRef.current?.closest("form")?.requestSubmit()}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
export function DeleteButton({ confirmText, label = "Sil" }: { confirmText: string; label?: string }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="badge err"
|
|
||||||
style={{ border: "none", cursor: "pointer" }}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (!confirm(confirmText)) e.preventDefault();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export function LiveLogViewer({ sessionId }: { sessionId: string }) {
|
||||||
|
const [lines, setLines] = useState<string[]>([]);
|
||||||
|
const boxRef = useRef<HTMLPreElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/debug/capture/${sessionId}`, { cache: "no-store" });
|
||||||
|
const data = (await res.json()) as { lines: string[] };
|
||||||
|
if (!cancelled) setLines(data.lines);
|
||||||
|
} catch {
|
||||||
|
// transient fetch failures are fine to just skip — next tick retries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
poll();
|
||||||
|
const timer = setInterval(poll, 4000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (boxRef.current) boxRef.current.scrollTop = boxRef.current.scrollHeight;
|
||||||
|
}, [lines]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pre
|
||||||
|
ref={boxRef}
|
||||||
|
className="font-mono-num mt-3 max-h-56 overflow-y-auto rounded-md border border-border bg-background/60 p-3 text-[0.7rem] leading-relaxed whitespace-pre-wrap text-muted-foreground"
|
||||||
|
>
|
||||||
|
{lines.length > 0 ? lines.join("\n") : "Log bekleniyor…"}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function NavLink({ href, children }: { href: string; children: ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium transition-colors hover:text-foreground",
|
||||||
|
active ? "text-foreground" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
function formatElapsed(ms: number): string {
|
||||||
|
const totalSec = Math.max(0, Math.floor(ms / 1000));
|
||||||
|
const h = Math.floor(totalSec / 3600);
|
||||||
|
const m = Math.floor((totalSec % 3600) / 60);
|
||||||
|
const s = totalSec % 60;
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionTimer({ startedAt }: { startedAt: string }) {
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <span className="font-mono-num">{formatElapsed(now - new Date(startedAt).getTime())}</span>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Radix Switch's own hidden-input form integration wasn't reliably reaching
|
||||||
|
* the Server Action's FormData in this app (toggling off + saving silently
|
||||||
|
* kept the old value) — this owns the submitted value directly instead of
|
||||||
|
* depending on that mechanism.
|
||||||
|
*/
|
||||||
|
export function SettingSwitch({ name, defaultChecked }: { name: string; defaultChecked: boolean }) {
|
||||||
|
const [checked, setChecked] = useState(defaultChecked);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input type="hidden" name={name} value={checked ? "true" : "false"} />
|
||||||
|
<Switch checked={checked} onCheckedChange={setChecked} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+83
-143
@@ -1,154 +1,94 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Panel koyu temayla sabit tasarlandı (broadcast/monitör kimliği) — açık
|
||||||
|
* tema desteği bilinçli olarak yok, tek bir palet var.
|
||||||
|
*/
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
--radius: 0.625rem;
|
||||||
--bg: #0b0d12;
|
|
||||||
--panel: #141822;
|
--background: #0a0e15;
|
||||||
--border: #262c3a;
|
--foreground: #e8ecf2;
|
||||||
--text: #e6e9ef;
|
|
||||||
--muted: #8b93a7;
|
--card: #121722;
|
||||||
--accent: #4f8cff;
|
--card-foreground: #e8ecf2;
|
||||||
|
|
||||||
|
--popover: #121722;
|
||||||
|
--popover-foreground: #e8ecf2;
|
||||||
|
|
||||||
|
--border: #232b3a;
|
||||||
|
--input: #1b2230;
|
||||||
|
|
||||||
|
--muted: #171d29;
|
||||||
|
--muted-foreground: #8b93a7;
|
||||||
|
|
||||||
|
--primary: #22d3c8;
|
||||||
|
--primary-foreground: #072421;
|
||||||
|
|
||||||
|
--secondary: #1b2230;
|
||||||
|
--secondary-foreground: #c7cfde;
|
||||||
|
|
||||||
|
--accent: #1a2130;
|
||||||
|
--accent-foreground: #e8ecf2;
|
||||||
|
|
||||||
|
--destructive: #ef5b5b;
|
||||||
|
--ring: #22d3c8;
|
||||||
|
|
||||||
|
/* Ürünün durum sözlüğü — badge/rozet renkleri, primary'den bağımsız */
|
||||||
--ok: #37c976;
|
--ok: #37c976;
|
||||||
--warn: #f2b84b;
|
--warn: #f2b84b;
|
||||||
--err: #ef5b5b;
|
--err: #ef5b5b;
|
||||||
|
--live: #ff5c5c;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
@theme inline {
|
||||||
box-sizing: border-box;
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
|
||||||
|
--color-ok: var(--ok);
|
||||||
|
--color-warn: var(--warn);
|
||||||
|
--color-err: var(--err);
|
||||||
|
--color-live: var(--live);
|
||||||
|
|
||||||
|
--font-sans: var(--font-hanken-grotesk);
|
||||||
|
--font-mono: var(--font-jetbrains-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
@layer base {
|
||||||
margin: 0;
|
* {
|
||||||
background: var(--bg);
|
@apply border-border outline-ring/50;
|
||||||
color: var(--text);
|
}
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
@layer utilities {
|
||||||
display: flex;
|
.font-mono-num {
|
||||||
gap: 1.5rem;
|
font-family: var(--font-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
padding: 1rem 2rem;
|
font-variant-numeric: tabular-nums;
|
||||||
border-bottom: 1px solid var(--border);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.nav a {
|
|
||||||
color: var(--muted);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav a.active {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
max-width: 960px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
th, td {
|
|
||||||
text-align: left;
|
|
||||||
padding: 0.6rem 0.8rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
color: var(--muted);
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.15rem 0.5rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge.ok { background: rgba(55,201,118,0.15); color: var(--ok); }
|
|
||||||
.badge.muted { background: rgba(139,147,167,0.15); color: var(--muted); }
|
|
||||||
.badge.warn { background: rgba(242,184,75,0.15); color: var(--warn); }
|
|
||||||
.badge.err { background: rgba(239,91,91,0.15); color: var(--err); }
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-channel-form {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-channel-form input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 160px;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.5rem 0.7rem;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-channel-form input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-channel-form button {
|
|
||||||
background: var(--accent);
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-channel-form button:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-head {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: baseline;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mono {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.transcript-preview {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import { Hanken_Grotesk, JetBrains_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { getSession } from "../lib/auth";
|
import { getSession } from "../lib/auth";
|
||||||
import { logout } from "./login/actions";
|
import { logout } from "./login/actions";
|
||||||
|
import { NavLink } from "./components/NavLink";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const hankenGrotesk = Hanken_Grotesk({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-hanken-grotesk",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-jetbrains-mono",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "StreamClipper AI — Panel",
|
title: "StreamClipper AI — Panel",
|
||||||
@@ -12,28 +26,29 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="tr">
|
<html lang="tr" className={`${hankenGrotesk.variable} ${jetbrainsMono.variable}`}>
|
||||||
<body>
|
<body>
|
||||||
{session && (
|
{session && (
|
||||||
<nav className="nav">
|
<nav className="flex items-center gap-6 border-b border-border px-6 py-4">
|
||||||
<Link href="/">Kanal Durumu</Link>
|
<span className="mr-2 text-sm font-semibold tracking-tight text-foreground">
|
||||||
<Link href="/segments">Segment & Aday Kütüphanesi</Link>
|
StreamClipper<span className="text-primary">.</span>
|
||||||
<Link href="/settings">Ayarlar</Link>
|
</span>
|
||||||
<Link href="/users">Kullanıcılar</Link>
|
<NavLink href="/">Kanal Durumu</NavLink>
|
||||||
<span style={{ marginLeft: "auto", display: "flex", gap: "1rem", alignItems: "center" }}>
|
<NavLink href="/segments">Segment & Aday Kütüphanesi</NavLink>
|
||||||
<span className="mono">{session.username}</span>
|
<NavLink href="/stats">İstatistik</NavLink>
|
||||||
|
<NavLink href="/settings">Ayarlar</NavLink>
|
||||||
|
<NavLink href="/users">Kullanıcılar</NavLink>
|
||||||
|
<span className="ml-auto flex items-center gap-4">
|
||||||
|
<span className="font-mono-num text-xs text-muted-foreground">{session.username}</span>
|
||||||
<form action={logout}>
|
<form action={logout}>
|
||||||
<button
|
<Button type="submit" variant="ghost" size="sm" className="text-muted-foreground">
|
||||||
type="submit"
|
|
||||||
style={{ background: "none", border: "none", color: "var(--muted)", cursor: "pointer", font: "inherit" }}
|
|
||||||
>
|
|
||||||
Çıkış Yap
|
Çıkış Yap
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</span>
|
</span>
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
<main>{children}</main>
|
<main className="mx-auto max-w-4xl px-6 py-10">{children}</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,71 +1,34 @@
|
|||||||
import { prisma } from "@streamclipper/db";
|
import { prisma } from "@streamclipper/db";
|
||||||
import { bootstrapAdmin, login } from "./actions";
|
import { bootstrapAdmin, login } from "./actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const fieldStyle = {
|
export default async function LoginPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
|
||||||
background: "var(--bg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "6px",
|
|
||||||
padding: "0.6rem 0.7rem",
|
|
||||||
color: "var(--text)",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
};
|
|
||||||
|
|
||||||
const buttonStyle = {
|
|
||||||
background: "var(--accent)",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "6px",
|
|
||||||
padding: "0.6rem 1rem",
|
|
||||||
color: "#fff",
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
cursor: "pointer",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function LoginPage({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{ error?: string }>;
|
|
||||||
}) {
|
|
||||||
const { error } = await searchParams;
|
const { error } = await searchParams;
|
||||||
const userCount = await prisma.user.count();
|
const userCount = await prisma.user.count();
|
||||||
const isBootstrap = userCount === 0;
|
const isBootstrap = userCount === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: "360px", margin: "4rem auto" }}>
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
<h1>{isBootstrap ? "İlk Yönetici Hesabını Oluştur" : "Giriş Yap"}</h1>
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader>
|
||||||
{isBootstrap && (
|
<CardTitle className="text-xl">{isBootstrap ? "İlk Yönetici Hesabını Oluştur" : "Giriş Yap"}</CardTitle>
|
||||||
<p className="empty" style={{ marginBottom: "1rem" }}>
|
{isBootstrap && (
|
||||||
Henüz kullanıcı yok — panele erişecek ilk admin hesabını burada oluştur.
|
<CardDescription>Henüz kullanıcı yok — panele erişecek ilk admin hesabını burada oluştur.</CardDescription>
|
||||||
</p>
|
)}
|
||||||
)}
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
{!isBootstrap && error && (
|
{!isBootstrap && error && <p className="mb-3 text-sm text-destructive">Kullanıcı adı veya şifre hatalı.</p>}
|
||||||
<p style={{ color: "var(--err)", fontSize: "0.9rem", marginBottom: "1rem" }}>
|
<form action={isBootstrap ? bootstrapAdmin : login} className="flex flex-col gap-3">
|
||||||
Kullanıcı adı veya şifre hatalı.
|
<Input name="username" placeholder="Kullanıcı adı" required />
|
||||||
</p>
|
<Input name="password" type="password" placeholder="Şifre" required minLength={isBootstrap ? 8 : undefined} />
|
||||||
)}
|
<Button type="submit">{isBootstrap ? "Hesabı Oluştur" : "Giriş Yap"}</Button>
|
||||||
|
</form>
|
||||||
<form
|
</CardContent>
|
||||||
action={isBootstrap ? bootstrapAdmin : login}
|
</Card>
|
||||||
className="card"
|
|
||||||
style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}
|
|
||||||
>
|
|
||||||
<input name="username" placeholder="Kullanıcı adı" required style={fieldStyle} />
|
|
||||||
<input
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
placeholder="Şifre"
|
|
||||||
required
|
|
||||||
minLength={isBootstrap ? 8 : undefined}
|
|
||||||
style={fieldStyle}
|
|
||||||
/>
|
|
||||||
<button type="submit" style={buttonStyle}>
|
|
||||||
{isBootstrap ? "Hesabı Oluştur" : "Giriş Yap"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+91
-46
@@ -1,7 +1,13 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { prisma } from "@streamclipper/db";
|
import { prisma } from "@streamclipper/db";
|
||||||
import { addChannel } from "./actions";
|
import { addChannel, toggleChannelActive, toggleAllChannels, checkChannelNow } from "./actions";
|
||||||
import { fetchChannelStatuses } from "../lib/apiDaemon";
|
import { fetchChannelStatuses } from "../lib/apiDaemon";
|
||||||
|
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, CardFooter } from "@/components/ui/card";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -21,62 +27,101 @@ export default async function DashboardPage() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-6">
|
||||||
<h1>Kanal Durumu</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Kanal Durumu</h1>
|
||||||
|
|
||||||
<form action={addChannel} className="card add-channel-form">
|
<Card>
|
||||||
<input name="name" placeholder="Kanal adı" required />
|
<CardHeader>
|
||||||
<input name="youtubeHandle" placeholder="@handle" required />
|
<CardTitle className="text-sm font-medium text-muted-foreground">Yeni Kanal Ekle</CardTitle>
|
||||||
<input name="channelId" placeholder="channel_id (UC...)" required />
|
</CardHeader>
|
||||||
<button type="submit">Kanal Ekle</button>
|
<CardContent>
|
||||||
</form>
|
<form action={addChannel} className="flex flex-wrap gap-2">
|
||||||
|
<Input name="name" placeholder="Kanal adı" required className="flex-1 min-w-[140px]" />
|
||||||
|
<Input name="youtubeHandle" placeholder="@handle" required className="flex-1 min-w-[120px]" />
|
||||||
|
<Input name="channelId" placeholder="channel_id (UC...)" required className="flex-1 min-w-[180px]" />
|
||||||
|
<Button type="submit">Kanal Ekle</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{channels.length === 0 ? (
|
{channels.length === 0 ? (
|
||||||
<p className="empty">Henüz kanal eklenmedi.</p>
|
<p className="text-sm text-muted-foreground">Henüz kanal eklenmedi.</p>
|
||||||
) : (
|
) : (
|
||||||
<table>
|
<div className="flex flex-col gap-3">
|
||||||
<thead>
|
<div className="flex items-center justify-between">
|
||||||
<tr>
|
<span className="text-sm font-medium text-muted-foreground">{channels.length} kanal</span>
|
||||||
<th>Kanal</th>
|
<div className="flex gap-2">
|
||||||
<th>Durum</th>
|
<form action={toggleAllChannels.bind(null, true)}>
|
||||||
<th>Kayıt</th>
|
<Button type="submit" variant="outline" size="sm">
|
||||||
<th>Son Kontrol</th>
|
Tümünü Devam Ettir
|
||||||
</tr>
|
</Button>
|
||||||
</thead>
|
</form>
|
||||||
<tbody>
|
<form action={toggleAllChannels.bind(null, false)}>
|
||||||
|
<Button type="submit" variant="outline" size="sm">
|
||||||
|
Tümünü Duraklat
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{channels.map((c) => {
|
{channels.map((c) => {
|
||||||
const recording = c.sessions.length > 0;
|
const recording = c.sessions.length > 0;
|
||||||
const status = statuses.get(c.id);
|
const status = statuses.get(c.id);
|
||||||
return (
|
return (
|
||||||
<tr key={c.id}>
|
<Card
|
||||||
<td>
|
key={c.id}
|
||||||
<Link href={`/channels/${c.id}`}>{c.name}</Link>{" "}
|
className={cn("gap-4 py-5", recording && "border-live/40")}
|
||||||
<span className="mono">{c.youtubeHandle}</span>
|
>
|
||||||
</td>
|
<CardHeader className="gap-1 px-5">
|
||||||
<td>
|
<div className="flex items-start justify-between gap-2">
|
||||||
<span className={`badge ${c.isActive ? "ok" : "muted"}`}>
|
<Link href={`/channels/${c.id}`} className="font-semibold leading-tight hover:text-primary hover:underline">
|
||||||
{c.isActive ? "Aktif" : "Pasif"}
|
{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>
|
</span>
|
||||||
</td>
|
</CardContent>
|
||||||
<td>
|
|
||||||
<span className={`badge ${recording ? "warn" : "muted"}`}>
|
<CardFooter className="gap-2 px-5">
|
||||||
{recording ? "🔴 Kayıtta" : "—"}
|
<form action={toggleChannelActive.bind(null, c.id, !c.isActive)}>
|
||||||
</span>
|
<Button type="submit" variant="outline" size="sm">
|
||||||
{status?.lastPollError && (
|
{c.isActive ? "Duraklat" : "Devam Ettir"}
|
||||||
<span className="badge err" style={{ marginLeft: "0.4rem" }}>
|
</Button>
|
||||||
hata
|
</form>
|
||||||
</span>
|
<form action={checkChannelNow.bind(null, c.id)}>
|
||||||
)}
|
<Button type="submit" variant="ghost" size="sm">
|
||||||
</td>
|
Şimdi Kontrol Et
|
||||||
<td className="mono">
|
</Button>
|
||||||
{c.lastCheckedAt ? new Date(c.lastCheckedAt).toLocaleString("tr-TR") : "—"}
|
</form>
|
||||||
</td>
|
</CardFooter>
|
||||||
</tr>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import { prisma } from "@streamclipper/db";
|
import { prisma } from "@streamclipper/db";
|
||||||
import { deleteRawSegment, deleteCandidateSegment } from "../actions";
|
import { deleteRawSegment, deleteCandidateSegment, retryRender, cancelRender } from "../actions";
|
||||||
import { DeleteButton } from "../components/DeleteButton";
|
import { formatTr } from "../../lib/formatDate";
|
||||||
|
import { ConfirmButton } from "../components/ConfirmButton";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge, type badgeVariants } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
import type { VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const STATUS_BADGE: Record<string, string> = {
|
const STATUS_BADGE: Record<string, VariantProps<typeof badgeVariants>["variant"]> = {
|
||||||
PENDING: "muted",
|
PENDING: "muted",
|
||||||
PROCESSED: "ok",
|
PROCESSED: "ok",
|
||||||
DISCARDED: "err",
|
DISCARDED: "err",
|
||||||
|
SENT_TO_TELEGRAM: "ok",
|
||||||
PENDING_STT: "warn",
|
PENDING_STT: "warn",
|
||||||
TRANSCRIBED: "ok",
|
TRANSCRIBED: "ok",
|
||||||
|
RENDERING: "warn",
|
||||||
|
READY: "ok",
|
||||||
FAILED: "err",
|
FAILED: "err",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -23,63 +31,131 @@ export default async function SegmentsPage() {
|
|||||||
const segments = await prisma.rawSegment.findMany({
|
const segments = await prisma.rawSegment.findMany({
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
take: 50,
|
take: 50,
|
||||||
include: { candidates: { orderBy: { createdAt: "desc" } } },
|
include: { candidates: { orderBy: { createdAt: "desc" }, include: { short: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-6">
|
||||||
<h1>Segment & Aday Klip Kütüphanesi</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Segment & Aday Klip Kütüphanesi</h1>
|
||||||
|
|
||||||
{segments.length === 0 ? (
|
{segments.length === 0 ? (
|
||||||
<p className="empty">Henüz işlenmiş segment yok.</p>
|
<p className="text-sm text-muted-foreground">Henüz işlenmiş segment yok.</p>
|
||||||
) : (
|
) : (
|
||||||
segments.map((segment) => (
|
<div className="flex flex-col gap-4">
|
||||||
<div className="card" key={segment.id}>
|
{segments.map((segment) => (
|
||||||
<div className="card-head">
|
<Card key={segment.id}>
|
||||||
<span className="mono">{segment.filePath}</span>
|
<CardHeader className="flex-row items-center justify-between">
|
||||||
<span className={`badge ${STATUS_BADGE[segment.status] ?? "muted"}`}>{segment.status}</span>
|
<div>
|
||||||
</div>
|
<span className="font-mono-num text-xs text-muted-foreground">{segment.filePath}</span>
|
||||||
<div className="mono">
|
<p className="font-mono-num text-xs text-muted-foreground">
|
||||||
{segment.duration}sn · {new Date(segment.startedAt).toLocaleString("tr-TR")}
|
{segment.duration}sn · {formatTr(segment.startedAt)}
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
<video controls preload="metadata" style={{ width: "100%", maxWidth: "480px", marginTop: "0.5rem", borderRadius: "6px" }}>
|
|
||||||
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
|
|
||||||
</video>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.5rem" }}>
|
|
||||||
<a className="badge muted" href={`/api/segments/${segment.id}/video?download=1`}>İndir</a>
|
|
||||||
<form action={deleteRawSegment.bind(null, segment.id)}>
|
|
||||||
<DeleteButton confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz." />
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{segment.candidates.length === 0 ? (
|
|
||||||
<p className="empty" style={{ marginTop: "0.5rem" }}>
|
|
||||||
Bu segmentte aday klip bulunamadı.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
segment.candidates.map((c) => (
|
|
||||||
<div key={c.id} style={{ marginTop: "0.75rem", paddingLeft: "0.75rem", borderLeft: "2px solid var(--border)" }}>
|
|
||||||
<div className="card-head">
|
|
||||||
<span className="mono">
|
|
||||||
{c.startSec}s – {c.endSec}s
|
|
||||||
{c.audioPeakScore != null && ` · peak ${c.audioPeakScore.toFixed(1)}dB`}
|
|
||||||
{c.chatVelocityScore != null && " · chat spike"}
|
|
||||||
</span>
|
|
||||||
<span className={`badge ${STATUS_BADGE[c.status] ?? "muted"}`}>{c.status}</span>
|
|
||||||
</div>
|
|
||||||
{transcriptPreview(c.transcriptJson) && (
|
|
||||||
<p className="transcript-preview">{transcriptPreview(c.transcriptJson)}</p>
|
|
||||||
)}
|
|
||||||
<form action={deleteCandidateSegment.bind(null, c.id)} style={{ marginTop: "0.35rem" }}>
|
|
||||||
<DeleteButton confirmText="Bu aday klibi silmek istediğine emin misin?" />
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
<Badge variant={STATUS_BADGE[segment.status] ?? "muted"}>{segment.status}</Badge>
|
||||||
)}
|
</CardHeader>
|
||||||
</div>
|
<CardContent className="flex flex-col gap-3">
|
||||||
))
|
{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'a gönderildi, sunucuda saklanmıyor.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{segment.status === "SENT_TO_TELEGRAM" ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Bu segment analiz edilmeden (transkript/9:16 atlanarak) doğrudan Telegram'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) => (
|
||||||
|
<div key={c.id} className="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`}
|
||||||
|
{c.chatVelocityScore != null && " · chat spike"}
|
||||||
|
</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 && (
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{c.short.status === "READY" && !c.short.filePath && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
✅ Telegram'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 && 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>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +1,337 @@
|
|||||||
import { prisma } from "@streamclipper/db";
|
import { prisma } from "@streamclipper/db";
|
||||||
import { updateYtdlpCookies } 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";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const STALE_COOKIE_HOURS = 4;
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const setting = await prisma.appSetting.findUnique({ where: { key: "ytdlp_cookies" } });
|
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;
|
||||||
|
const cookieStale = cookieAgeHours !== null && cookieAgeHours > STALE_COOKIE_HOURS;
|
||||||
|
const pollIntervalSec = pollSetting ? Math.round(Number(pollSetting.value) / 1000) : 60;
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-6">
|
||||||
<h1>Ayarlar</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Ayarlar</h1>
|
||||||
|
|
||||||
<div className="card">
|
<Card>
|
||||||
<div className="card-head">
|
<CardHeader>
|
||||||
<span>YouTube Cookie'leri</span>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Cookie Hesapları</CardTitle>
|
||||||
<span className="mono">
|
<CardDescription>
|
||||||
{setting ? `son güncelleme: ${setting.updatedAt.toLocaleString("tr-TR")}` : "hiç ayarlanmadı"}
|
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
|
||||||
|
"Sign in to confirm you're not a bot" 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'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'leri (tekli / eski)
|
||||||
|
</CardTitle>
|
||||||
|
<span className="font-mono-num text-xs text-muted-foreground">
|
||||||
|
{setting ? `son güncelleme: ${formatTr(setting.updatedAt)}` : "hiç ayarlanmadı"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</CardHeader>
|
||||||
<p className="empty" style={{ marginBottom: "0.5rem" }}>
|
<CardContent className="flex flex-col gap-3">
|
||||||
YouTube canlı yayın kontrolü ve kayıt için kullanılıyor. Google, oturum çerezlerinin bir
|
{cookieStale && cookieProfiles.length === 0 && (
|
||||||
kısmını birkaç saatte bir yeniliyor — burada eskidiğini fark edersen (kanal detay
|
<Badge variant="warn" className="w-fit">
|
||||||
sayfasında "son poll hatası" olarak görürsün) taze bir cookies.txt ile
|
⚠️ {Math.round(cookieAgeHours!)} saattir güncellenmedi — büyük/popüler kanallarda bot-check hatası
|
||||||
güncelle. Ana hesabın yerine ayrı, önemsiz bir Google hesabı kullanman önerilir.
|
görülebilir
|
||||||
</p>
|
</Badge>
|
||||||
<form action={updateYtdlpCookies}>
|
)}
|
||||||
<textarea
|
<CardDescription>
|
||||||
name="cookies"
|
{cookieProfiles.length > 0
|
||||||
required
|
? "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."
|
||||||
placeholder="Netscape formatlı cookies.txt içeriğini buraya yapıştır"
|
: "Yukarıda hiç hesap eklenmediği sürece tüm kanallar bu tek cookie'yi paylaşır."}
|
||||||
rows={10}
|
</CardDescription>
|
||||||
style={{
|
<form action={updateYtdlpCookies} className="flex flex-col gap-3">
|
||||||
width: "100%",
|
<Textarea
|
||||||
background: "var(--bg)",
|
name="cookies"
|
||||||
border: "1px solid var(--border)",
|
required
|
||||||
borderRadius: "6px",
|
placeholder="Netscape formatlı cookies.txt içeriğini buraya yapıştır"
|
||||||
padding: "0.6rem 0.7rem",
|
rows={8}
|
||||||
color: "var(--text)",
|
className="font-mono-num text-xs"
|
||||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
/>
|
||||||
fontSize: "0.75rem",
|
<Button type="submit" variant="outline" className="self-start">
|
||||||
resize: "vertical",
|
Kaydet
|
||||||
}}
|
</Button>
|
||||||
/>
|
</form>
|
||||||
<button
|
</CardContent>
|
||||||
type="submit"
|
</Card>
|
||||||
style={{
|
|
||||||
marginTop: "0.6rem",
|
<Card>
|
||||||
background: "var(--accent)",
|
<CardHeader>
|
||||||
border: "none",
|
<CardTitle className="text-sm font-medium text-muted-foreground">Sistem Parametreleri</CardTitle>
|
||||||
borderRadius: "6px",
|
<CardDescription>Bir sonraki döngüden itibaren geçerli olur, redeploy gerekmez.</CardDescription>
|
||||||
padding: "0.5rem 1rem",
|
</CardHeader>
|
||||||
color: "#fff",
|
<CardContent>
|
||||||
fontWeight: 600,
|
<form action={updateSystemSettings} className="flex flex-col gap-4">
|
||||||
fontSize: "0.9rem",
|
<div className="flex flex-col gap-1.5">
|
||||||
cursor: "pointer",
|
<Label htmlFor="pollIntervalSec">Poll aralığı (saniye)</Label>
|
||||||
}}
|
<Input id="pollIntervalSec" name="pollIntervalSec" type="number" min={5} defaultValue={pollIntervalSec} className="max-w-40" />
|
||||||
>
|
</div>
|
||||||
Kaydet
|
<div className="flex flex-col gap-1.5">
|
||||||
</button>
|
<Label htmlFor="ttlHours">Ham segment TTL (saat)</Label>
|
||||||
|
<Input id="ttlHours" name="ttlHours" type="number" min={1} defaultValue={ttlHours} className="max-w-40" />
|
||||||
|
</div>
|
||||||
|
<p className="max-w-prose text-xs text-muted-foreground">
|
||||||
|
Eşzamanlı capture limiti ve render eşzamanlılığı BullMQ worker'ları başlatılırken sabitleniyor —
|
||||||
|
bunları değiştirmek için Coolify'deki <code className="font-mono-num">MAX_CONCURRENT_CAPTURES</code>{" "}
|
||||||
|
/ <code className="font-mono-num">VIDEO_RENDER_CONCURRENCY</code> env var'larını güncelleyip
|
||||||
|
redeploy etmek gerekiyor.
|
||||||
|
</p>
|
||||||
|
<Button type="submit" className="self-start">
|
||||||
|
Kaydet
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">STT (Transkripsiyon)</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Kapatırsan yeni aday klipler transkribe edilmez (PENDING_STT'de bekler) — OpenAI Whisper çağrısı
|
||||||
|
yapılmaz, render de tetiklenmez. Boş/konuşmasız yayınlarda (ör. sadece ambiyans müzik) gereksiz API
|
||||||
|
maliyetini önlemek için kapatabilirsin.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<form action={updateSttEnabled}>
|
||||||
|
<CardContent>
|
||||||
|
<Label className="flex items-center justify-between gap-4 text-sm font-normal">
|
||||||
|
Otomatik transkripsiyon aktif
|
||||||
|
<SettingSwitch name="sttEnabled" defaultChecked={sttEnabled} />
|
||||||
|
</Label>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button type="submit">Kaydet</Button>
|
||||||
|
</CardFooter>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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
|
||||||
|
"TRANSCRIBED" durumunda bekler — kanal/segment sayfalarındaki "9:16 Render Et"
|
||||||
|
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'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'ı geçen segmentler gönderilemez, sunucuda kalır —
|
||||||
|
gerekirse yukarıdaki "Sistem Parametreleri"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'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 "Ham Kayıt Modu"ndan farklı — bu, STT+9:16 render zincirinden geçmiş klipler için.
|
||||||
|
Açarsan her render biten video Telegram'a gönderilip <strong>sunucudan kalıcı olarak
|
||||||
|
silinir</strong> — sadece Telegram'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'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} />
|
||||||
|
</Label>
|
||||||
|
<Label className="flex items-center justify-between gap-4 text-sm font-normal">
|
||||||
|
9:16 render tamamlandığında Telegram bildirimi
|
||||||
|
<SettingSwitch name="notifyRenderDone" defaultChecked={notifyRenderDone} />
|
||||||
|
</Label>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button type="submit">Kaydet</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { prisma } from "@streamclipper/db";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const STT_COST_PER_MINUTE = 0.006; // OpenAI Whisper API, doğrulanmalı — bkz. openai.com/pricing
|
||||||
|
const GB_PER_HOUR_ESTIMATE = 1.3; // 2026-09-02 oturumunda ölçülen ~2.8 Mbps ortalama bitrate'ten
|
||||||
|
|
||||||
|
function fmt(n: number, digits = 1): string {
|
||||||
|
return n.toLocaleString("tr-TR", { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<Card className="gap-1 px-5 py-4">
|
||||||
|
<span className="font-mono-num text-xs tracking-wide text-muted-foreground uppercase">{label}</span>
|
||||||
|
<span className="font-mono-num text-2xl font-semibold">{value}</span>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function StatsPage() {
|
||||||
|
const [transcribed, durationSum, readyShorts, failedShorts, totalCandidates] = await Promise.all([
|
||||||
|
prisma.candidateSegment.findMany({
|
||||||
|
where: { status: "TRANSCRIBED" },
|
||||||
|
select: { startSec: true, endSec: true },
|
||||||
|
}),
|
||||||
|
prisma.rawSegment.aggregate({ _sum: { duration: true } }),
|
||||||
|
prisma.shortVideo.count({ where: { status: "READY" } }),
|
||||||
|
prisma.shortVideo.count({ where: { status: "FAILED" } }),
|
||||||
|
prisma.candidateSegment.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sttSeconds = transcribed.reduce((sum, c) => sum + Math.max(0, c.endSec - c.startSec), 0);
|
||||||
|
const sttMinutes = sttSeconds / 60;
|
||||||
|
const sttCost = sttMinutes * STT_COST_PER_MINUTE;
|
||||||
|
|
||||||
|
const captureSeconds = durationSum._sum.duration ?? 0;
|
||||||
|
const captureHours = captureSeconds / 3600;
|
||||||
|
const estimatedGb = captureHours * GB_PER_HOUR_ESTIMATE;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Kullanım & Maliyet</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Şimdiye kadarki toplam kullanım — tüm zamanlar. STT maliyeti ve disk kullanımı tahmini, gerçek faturayla
|
||||||
|
küçük farklar olabilir.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
<StatCard label="Toplam Kayıt Süresi" value={`${fmt(captureHours)} sa`} />
|
||||||
|
<StatCard label="Toplam STT Süresi" value={`${fmt(sttMinutes)} dk`} />
|
||||||
|
<StatCard label="Tahmini Whisper Maliyeti" value={`$${fmt(sttCost, 2)}`} />
|
||||||
|
<StatCard label="Tahmini Disk Kullanımı" value={`${fmt(estimatedGb)} GB`} />
|
||||||
|
<StatCard label="Üretilen 9:16 Klip" value={String(readyShorts)} />
|
||||||
|
<StatCard label="Başarısız Render" value={String(failedShorts)} />
|
||||||
|
<StatCard label="Toplam Aday Klip" value={String(totalCandidates)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,66 +1,77 @@
|
|||||||
import { prisma } from "@streamclipper/db";
|
import { prisma } from "@streamclipper/db";
|
||||||
import { getSession } from "../../lib/auth";
|
import { getSession } from "../../lib/auth";
|
||||||
import { addUser, deleteUser } from "./actions";
|
import { addUser, deleteUser } from "./actions";
|
||||||
import { DeleteButton } from "../components/DeleteButton";
|
import { formatTr } from "../../lib/formatDate";
|
||||||
|
import { ConfirmButton } from "../components/ConfirmButton";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const fieldStyle = {
|
|
||||||
background: "var(--bg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "6px",
|
|
||||||
padding: "0.5rem 0.7rem",
|
|
||||||
color: "var(--text)",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function UsersPage() {
|
export default async function UsersPage() {
|
||||||
const [users, session] = await Promise.all([
|
const [users, session] = await Promise.all([prisma.user.findMany({ orderBy: { createdAt: "asc" } }), getSession()]);
|
||||||
prisma.user.findMany({ orderBy: { createdAt: "asc" } }),
|
|
||||||
getSession(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-6">
|
||||||
<h1>Kullanıcılar</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Kullanıcılar</h1>
|
||||||
|
|
||||||
<form
|
<Card>
|
||||||
action={addUser}
|
<CardContent>
|
||||||
className="card add-channel-form"
|
<form action={addUser} className="flex flex-wrap gap-2">
|
||||||
style={{ marginBottom: "1.5rem" }}
|
<Input name="username" placeholder="Kullanıcı adı" required className="flex-1 min-w-[140px]" />
|
||||||
>
|
<Input
|
||||||
<input name="username" placeholder="Kullanıcı adı" required style={fieldStyle} />
|
name="password"
|
||||||
<input name="password" type="password" placeholder="Şifre (en az 8 karakter)" required minLength={8} style={fieldStyle} />
|
type="password"
|
||||||
<button type="submit">Kullanıcı Ekle</button>
|
placeholder="Şifre (en az 8 karakter)"
|
||||||
</form>
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="flex-1 min-w-[160px]"
|
||||||
|
/>
|
||||||
|
<Button type="submit">Kullanıcı Ekle</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<table>
|
<Card>
|
||||||
<thead>
|
<CardContent>
|
||||||
<tr>
|
<Table>
|
||||||
<th>Kullanıcı Adı</th>
|
<TableHeader>
|
||||||
<th>Oluşturulma</th>
|
<TableRow>
|
||||||
<th></th>
|
<TableHead>Kullanıcı Adı</TableHead>
|
||||||
</tr>
|
<TableHead>Oluşturulma</TableHead>
|
||||||
</thead>
|
<TableHead className="text-right"></TableHead>
|
||||||
<tbody>
|
</TableRow>
|
||||||
{users.map((u) => (
|
</TableHeader>
|
||||||
<tr key={u.id}>
|
<TableBody>
|
||||||
<td>
|
{users.map((u) => (
|
||||||
{u.username}
|
<TableRow key={u.id}>
|
||||||
{u.id === session?.sub && <span className="badge muted" style={{ marginLeft: "0.5rem" }}>sen</span>}
|
<TableCell>
|
||||||
</td>
|
{u.username}
|
||||||
<td className="mono">{u.createdAt.toLocaleString("tr-TR")}</td>
|
{u.id === session?.sub && (
|
||||||
<td>
|
<Badge variant="muted" className="ml-2">
|
||||||
{u.id !== session?.sub && users.length > 1 && (
|
sen
|
||||||
<form action={deleteUser.bind(null, u.id)}>
|
</Badge>
|
||||||
<DeleteButton confirmText={`${u.username} kullanıcısını silmek istediğine emin misin?`} />
|
)}
|
||||||
</form>
|
</TableCell>
|
||||||
)}
|
<TableCell className="font-mono-num text-xs text-muted-foreground">
|
||||||
</td>
|
{formatTr(u.createdAt)}
|
||||||
</tr>
|
</TableCell>
|
||||||
))}
|
<TableCell className="text-right">
|
||||||
</tbody>
|
{u.id !== session?.sub && users.length > 1 && (
|
||||||
</table>
|
<form action={deleteUser.bind(null, u.id)}>
|
||||||
</>
|
<ConfirmButton confirmText={`${u.username} kullanıcısını silmek istediğine emin misin?`} label="Sil" />
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||||
|
size?: "default" | "sm";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn(
|
||||||
|
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> & Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<Button variant={variant} size={size} asChild>
|
||||||
|
<AlertDialogPrimitive.Action data-slot="alert-dialog-action" className={cn(className)} {...props} />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> & Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<Button variant={variant} size={size} asChild>
|
||||||
|
<AlertDialogPrimitive.Cancel data-slot="alert-dialog-cancel" className={cn(className)} {...props} />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
|
||||||
|
warn: "bg-card text-warn *:data-[slot=alert-description]:text-warn/90 [&>svg]:text-current",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return <div data-slot="alert" role="alert" className={cn(alertVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div data-slot="alert-title" className={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn("col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { Slot } from "radix-ui";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
||||||
|
outline: "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||||
|
// Ürünün kendi durum sözlüğü (kayıt/render/segment durumları) —
|
||||||
|
// primary accent'ten bağımsız, sabit anlamları var.
|
||||||
|
ok: "bg-ok/15 text-ok",
|
||||||
|
warn: "bg-warn/15 text-warn",
|
||||||
|
err: "bg-err/15 text-err",
|
||||||
|
muted: "bg-muted text-muted-foreground",
|
||||||
|
live: "bg-live/15 text-live",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : "span";
|
||||||
|
|
||||||
|
return <Comp data-slot="badge" data-variant={variant} className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { Slot } from "radix-ui";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div data-slot="card-description" className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||||
|
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Label as LabelPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label };
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Separator as SeparatorPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Switch as SwitchPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||||
|
size?: "sm" | "default";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div data-slot="table-container" className="relative w-full overflow-x-auto">
|
||||||
|
<table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
|
||||||
|
return <caption data-slot="table-caption" className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
@@ -5,10 +5,28 @@ export interface ChannelStatus {
|
|||||||
lastCheckedAt: string | null;
|
lastCheckedAt: string | null;
|
||||||
recording: boolean;
|
recording: boolean;
|
||||||
activeSessionId: string | null;
|
activeSessionId: string | null;
|
||||||
lastPollError: string | null;
|
lastPollError: { message: string; at: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_DAEMON_URL = process.env.API_DAEMON_URL ?? "http://localhost:4001";
|
export const API_DAEMON_URL = process.env.API_DAEMON_URL ?? "http://localhost:4001";
|
||||||
|
|
||||||
|
/** POSTs to a state-changing api-daemon endpoint with the shared internal auth header (see INTERNAL_API_TOKEN in .env.example). */
|
||||||
|
export async function postToApiDaemon<T = unknown>(path: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${API_DAEMON_URL}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
...(process.env.INTERNAL_API_TOKEN ? { authorization: `Bearer ${process.env.INTERNAL_API_TOKEN}` } : {}),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`api-daemon ${path} failed (${res.status}): ${text.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* lastPollError only exists in api-daemon's in-memory map (not persisted to
|
* lastPollError only exists in api-daemon's in-memory map (not persisted to
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Server (Coolify/Hetzner) runs in UTC, but the panel is used from Turkey
|
||||||
|
* (UTC+3, no DST since 2016) — `.toLocaleString("tr-TR")` alone formats
|
||||||
|
* with tr-TR conventions but still uses the *server's* timezone for the
|
||||||
|
* actual time value, which was showing everything 3h behind. Always pin
|
||||||
|
* the timezone explicitly instead of relying on the runtime's default.
|
||||||
|
*/
|
||||||
|
export function formatTr(date: Date | string): string {
|
||||||
|
return new Date(date).toLocaleString("tr-TR", { timeZone: "Europe/Istanbul" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { createReadStream, statSync } from "node:fs";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
export function streamVideoFile(req: NextRequest, filePath: string, downloadName: string): Response {
|
||||||
|
let size: number;
|
||||||
|
try {
|
||||||
|
size = statSync(filePath).size;
|
||||||
|
} catch {
|
||||||
|
return new Response("File not found on disk", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "video/mp4",
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
};
|
||||||
|
if (req.nextUrl.searchParams.get("download") === "1") {
|
||||||
|
headers["Content-Disposition"] = `attachment; filename="${downloadName}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = req.headers.get("range");
|
||||||
|
const match = range ? /bytes=(\d+)-(\d*)/.exec(range) : null;
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const start = Number(match[1]);
|
||||||
|
const end = match[2] ? Number(match[2]) : size - 1;
|
||||||
|
const stream = createReadStream(filePath, { start, end });
|
||||||
|
return new Response(Readable.toWeb(stream) as ReadableStream, {
|
||||||
|
status: 206,
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
"Content-Range": `bytes ${start}-${end}/${size}`,
|
||||||
|
"Content-Length": String(end - start + 1),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = createReadStream(filePath);
|
||||||
|
return new Response(Readable.toWeb(stream) as ReadableStream, {
|
||||||
|
headers: { ...headers, "Content-Length": String(size) },
|
||||||
|
});
|
||||||
|
}
|
||||||
Vendored
+1
@@ -1,5 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -9,11 +9,19 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@streamclipper/db": "workspace:*",
|
"@streamclipper/db": "workspace:*",
|
||||||
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"jose": "^5.9.6",
|
"jose": "^5.9.6",
|
||||||
|
"lucide-react": "^1.38.0",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
|
"radix-ui": "^1.6.7",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ffmpeg libsndfile1 \
|
ffmpeg libsndfile1 libgl1 libglib2.0-0 curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -9,5 +9,11 @@ COPY apps/worker/requirements.txt ./apps/worker/requirements.txt
|
|||||||
RUN pip install --no-cache-dir -r apps/worker/requirements.txt
|
RUN pip install --no-cache-dir -r apps/worker/requirements.txt
|
||||||
COPY apps/worker ./apps/worker
|
COPY apps/worker ./apps/worker
|
||||||
|
|
||||||
|
# MediaPipe's Tasks API needs the face detector model asset on disk — not
|
||||||
|
# bundled with the pip package, downloaded once at build time instead of on
|
||||||
|
# every worker startup.
|
||||||
|
RUN mkdir -p /app/models && curl -L -o /app/models/blaze_face_short_range.tflite \
|
||||||
|
https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/latest/blaze_face_short_range.tflite
|
||||||
|
|
||||||
WORKDIR /app/apps/worker
|
WORKDIR /app/apps/worker
|
||||||
CMD ["python", "-m", "worker.main"]
|
CMD ["python", "-m", "worker.main"]
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ openai>=1.50.0
|
|||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
chat-downloader>=0.2.7
|
chat-downloader>=0.2.7
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
|
opencv-python-headless>=4.10.0
|
||||||
|
mediapipe>=0.10.14
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ async def get_pool() -> asyncpg.Pool:
|
|||||||
return _pool
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
|
async def get_setting(key: str, default: str | None = None) -> str | None:
|
||||||
|
"""Same app_settings table the Node side reads for cookies/poll-interval — settings authored from the panel are shared across both languages."""
|
||||||
|
pool = await get_pool()
|
||||||
|
row = await pool.fetchrow("SELECT value FROM app_settings WHERE key = $1", key)
|
||||||
|
return row["value"] if row else default
|
||||||
|
|
||||||
|
|
||||||
async def get_raw_segment(raw_segment_id: str) -> asyncpg.Record | None:
|
async def get_raw_segment(raw_segment_id: str) -> asyncpg.Record | None:
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
return await pool.fetchrow(
|
return await pool.fetchrow(
|
||||||
@@ -93,3 +100,70 @@ async def update_candidate_transcript(candidate_id: str, transcript: dict, statu
|
|||||||
async def get_active_sessions() -> list[asyncpg.Record]:
|
async def get_active_sessions() -> list[asyncpg.Record]:
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
return await pool.fetch("SELECT id, live_video_id FROM stream_sessions WHERE ended_at IS NULL")
|
return await pool.fetch("SELECT id, live_video_id FROM stream_sessions WHERE ended_at IS NULL")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_candidate_for_render(candidate_id: str) -> asyncpg.Record | None:
|
||||||
|
pool = await get_pool()
|
||||||
|
return await pool.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT cs.id, cs.start_sec, cs.end_sec, cs.transcript_json, rs.file_path, c.name AS channel_name
|
||||||
|
FROM candidate_segments cs
|
||||||
|
JOIN raw_segments rs ON rs.id = cs.raw_segment_id
|
||||||
|
JOIN stream_sessions ss ON ss.id = rs.session_id
|
||||||
|
JOIN channels c ON c.id = ss.channel_id
|
||||||
|
WHERE cs.id = $1
|
||||||
|
""",
|
||||||
|
candidate_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_short_video(candidate_id: str, status: str) -> str:
|
||||||
|
pool = await get_pool()
|
||||||
|
existing = await pool.fetchrow(
|
||||||
|
"SELECT id FROM short_videos WHERE candidate_id = $1", candidate_id
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
await pool.execute(
|
||||||
|
"UPDATE short_videos SET status = $2, error_message = NULL, updated_at = now() WHERE id = $1",
|
||||||
|
existing["id"],
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
return existing["id"]
|
||||||
|
|
||||||
|
short_id = str(uuid.uuid4())
|
||||||
|
await pool.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO short_videos (id, candidate_id, status, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, now(), now())
|
||||||
|
""",
|
||||||
|
short_id,
|
||||||
|
candidate_id,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
return short_id
|
||||||
|
|
||||||
|
|
||||||
|
async def update_short_video(
|
||||||
|
short_id: str, status: str, file_path: str | None = None, error_message: str | None = None
|
||||||
|
) -> None:
|
||||||
|
pool = await get_pool()
|
||||||
|
await pool.execute(
|
||||||
|
"""
|
||||||
|
UPDATE short_videos
|
||||||
|
SET status = $2, file_path = COALESCE($3, file_path), error_message = $4, updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
""",
|
||||||
|
short_id,
|
||||||
|
status,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from . import db
|
|||||||
from .chat_capture import start_chat_capture
|
from .chat_capture import start_chat_capture
|
||||||
from .signal_detection import start_signal_detection_worker
|
from .signal_detection import start_signal_detection_worker
|
||||||
from .stt_scoring import start_stt_scoring_worker
|
from .stt_scoring import start_stt_scoring_worker
|
||||||
|
from .video_render import start_video_render_worker
|
||||||
|
|
||||||
SESSION_WATCH_INTERVAL_SEC = 10
|
SESSION_WATCH_INTERVAL_SEC = 10
|
||||||
|
|
||||||
@@ -22,13 +23,15 @@ async def watch_sessions_for_chat_capture():
|
|||||||
async def main():
|
async def main():
|
||||||
signal_worker = start_signal_detection_worker()
|
signal_worker = start_signal_detection_worker()
|
||||||
stt_worker = start_stt_scoring_worker()
|
stt_worker = start_stt_scoring_worker()
|
||||||
print("[main] worker started: signal-detection + stt-scoring queues")
|
render_worker = start_video_render_worker()
|
||||||
|
print("[main] worker started: signal-detection + stt-scoring + video-render queues")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await watch_sessions_for_chat_capture()
|
await watch_sessions_for_chat_capture()
|
||||||
finally:
|
finally:
|
||||||
await signal_worker.close()
|
await signal_worker.close()
|
||||||
await stt_worker.close()
|
await stt_worker.close()
|
||||||
|
await render_worker.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -21,3 +21,8 @@ SHARED_MEDIA_ROOT = os.environ.get("SHARED_MEDIA_ROOT", "./shared-media")
|
|||||||
# producing segments/candidates around the same time would otherwise process
|
# producing segments/candidates around the same time would otherwise process
|
||||||
# strictly one at a time and build up a backlog.
|
# strictly one at a time and build up a backlog.
|
||||||
WORKER_CONCURRENCY = int(os.environ.get("WORKER_CONCURRENCY", "5"))
|
WORKER_CONCURRENCY = int(os.environ.get("WORKER_CONCURRENCY", "5"))
|
||||||
|
|
||||||
|
# Video rendering (face detection across sampled frames + two ffmpeg encodes)
|
||||||
|
# is far heavier per job than signal-detection/stt-scoring — kept separate
|
||||||
|
# and lower by default so several renders at once don't starve the container.
|
||||||
|
VIDEO_RENDER_CONCURRENCY = int(os.environ.get("VIDEO_RENDER_CONCURRENCY", "2"))
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ WINDOW_SEC = 1.0
|
|||||||
PEAK_STD_MULTIPLIER = 1.5
|
PEAK_STD_MULTIPLIER = 1.5
|
||||||
CANDIDATE_PAD_SEC = 45
|
CANDIDATE_PAD_SEC = 45
|
||||||
CHAT_SPIKE_MULTIPLIER = 3
|
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})
|
stt_scoring_queue = Queue(QUEUE_NAMES["STT_SCORING"], {"connection": REDIS_URL})
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from bullmq import Worker
|
from bullmq import Queue, Worker
|
||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
|
|
||||||
from . import db
|
from . import db
|
||||||
@@ -11,6 +11,7 @@ from .queues import QUEUE_NAMES, REDIS_URL, WORKER_CONCURRENCY
|
|||||||
from .telegram import send_telegram_message
|
from .telegram import send_telegram_message
|
||||||
|
|
||||||
_openai_client: OpenAI | None = None
|
_openai_client: OpenAI | None = None
|
||||||
|
video_render_queue = Queue(QUEUE_NAMES["VIDEO_RENDER"], {"connection": REDIS_URL})
|
||||||
|
|
||||||
|
|
||||||
def _client() -> OpenAI:
|
def _client() -> OpenAI:
|
||||||
@@ -38,6 +39,10 @@ async def process_stt_scoring(job, job_token=None):
|
|||||||
print(f"[stt-scoring] candidate {candidate_id} not found, skipping")
|
print(f"[stt-scoring] candidate {candidate_id} not found, skipping")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if await db.get_setting("stt_enabled", "true") == "false":
|
||||||
|
print(f"[stt-scoring] STT panelden kapatılmış, {candidate_id} atlanıyor (PENDING_STT'de kalır)")
|
||||||
|
return
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".mp3") as clip:
|
with tempfile.NamedTemporaryFile(suffix=".mp3") as clip:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
[
|
||||||
@@ -67,6 +72,11 @@ async def process_stt_scoring(job, job_token=None):
|
|||||||
f"({candidate['start_sec']}-{candidate['end_sec']}s):\n_{preview}_"
|
f"({candidate['start_sec']}-{candidate['end_sec']}s):\n_{preview}_"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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")
|
print(f"[stt-scoring] candidate {candidate_id} transcribed")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import httpx
|
|||||||
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||||
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
|
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:
|
async def send_telegram_message(text: str) -> None:
|
||||||
if not BOT_TOKEN or not CHAT_ID:
|
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:
|
if resp.status_code >= 400:
|
||||||
print(f"[telegram] failed to send message: {resp.status_code} {resp.text}")
|
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
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
"""
|
||||||
|
9:16 crop (yüz takipli) + dinamik altyazı render motoru.
|
||||||
|
|
||||||
|
İki geçişli işleme: (1) OpenCV ile örneklenmiş karelerde MediaPipe yüz
|
||||||
|
tespiti yapıp bir merkez-yol haritası çıkarır, yumuşatır; (2) OpenCV ile
|
||||||
|
kareleri tekrar okuyup o an için hesaplanan crop penceresini uygulayıp
|
||||||
|
ffmpeg'e pipe'lar — aynı ffmpeg çağrısı orijinal klibin sesini de
|
||||||
|
map'leyip .ass altyazısını yakar. Hiç yüz bulunamazsa sabit merkez crop'a
|
||||||
|
düşer (hata değil, sadece daha az "akıllı" bir sonuç).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from bullmq import Worker
|
||||||
|
from mediapipe.tasks.python import BaseOptions
|
||||||
|
from mediapipe.tasks.python.vision import FaceDetector, FaceDetectorOptions
|
||||||
|
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, send_telegram_video
|
||||||
|
|
||||||
|
FACE_MODEL_PATH = os.environ.get(
|
||||||
|
"FACE_DETECTOR_MODEL_PATH", "/app/models/blaze_face_short_range.tflite"
|
||||||
|
)
|
||||||
|
SAMPLE_INTERVAL_SEC = 0.5
|
||||||
|
OUTPUT_WIDTH = 1080
|
||||||
|
OUTPUT_HEIGHT = 1920
|
||||||
|
|
||||||
|
_detector: FaceDetector | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_detector() -> FaceDetector:
|
||||||
|
global _detector
|
||||||
|
if _detector is None:
|
||||||
|
options = FaceDetectorOptions(base_options=BaseOptions(model_asset_path=FACE_MODEL_PATH))
|
||||||
|
_detector = FaceDetector.create_from_options(options)
|
||||||
|
return _detector
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_face_centers(video_path: str) -> tuple[list[tuple[float, float]], int, int, float]:
|
||||||
|
"""Örneklenmiş karelerde en büyük yüzün merkezini (x_frac, y_frac) döner."""
|
||||||
|
cap = cv2.VideoCapture(video_path)
|
||||||
|
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
||||||
|
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||||
|
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||||
|
frame_interval = max(1, int(fps * SAMPLE_INTERVAL_SEC))
|
||||||
|
|
||||||
|
detector = _get_detector()
|
||||||
|
points: list[tuple[float, float]] = [] # (time_sec, x_frac)
|
||||||
|
frame_idx = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
ok, frame = cap.read()
|
||||||
|
if not ok:
|
||||||
|
break
|
||||||
|
if frame_idx % frame_interval == 0:
|
||||||
|
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||||
|
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
|
||||||
|
result = detector.detect(mp_image)
|
||||||
|
if result.detections:
|
||||||
|
best = max(
|
||||||
|
result.detections,
|
||||||
|
key=lambda d: d.bounding_box.width * d.bounding_box.height,
|
||||||
|
)
|
||||||
|
bbox = best.bounding_box
|
||||||
|
cx = (bbox.origin_x + bbox.width / 2) / width
|
||||||
|
points.append((frame_idx / fps, cx))
|
||||||
|
frame_idx += 1
|
||||||
|
|
||||||
|
cap.release()
|
||||||
|
return points, width, height, fps
|
||||||
|
|
||||||
|
|
||||||
|
def _smooth_x_path(points: list[tuple[float, float]], total_frames: int, fps: float) -> np.ndarray | None:
|
||||||
|
"""Kare başına yumuşatılmış crop merkezi (x_frac). Hiç yüz yoksa None (sabit merkez crop kullanılır)."""
|
||||||
|
if not points or total_frames <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
times = np.array([p[0] for p in points])
|
||||||
|
xs = np.array([p[1] for p in points])
|
||||||
|
|
||||||
|
frame_times = np.arange(total_frames) / fps
|
||||||
|
interp_x = np.interp(frame_times, times, xs)
|
||||||
|
|
||||||
|
window = min(max(1, int(fps)), len(interp_x))
|
||||||
|
kernel = np.ones(window) / window
|
||||||
|
return np.convolve(interp_x, kernel, mode="same")
|
||||||
|
|
||||||
|
|
||||||
|
def _seconds_to_ass_time(t: float) -> str:
|
||||||
|
t = max(0.0, t)
|
||||||
|
h = int(t // 3600)
|
||||||
|
m = int((t % 3600) // 60)
|
||||||
|
s = t % 60
|
||||||
|
return f"{h:d}:{m:02d}:{s:05.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ass_subtitles(transcript: dict, path: str, words_per_caption: int = 3) -> None:
|
||||||
|
words = transcript.get("words") or []
|
||||||
|
header = (
|
||||||
|
"[Script Info]\n"
|
||||||
|
"ScriptType: v4.00+\n"
|
||||||
|
f"PlayResX: {OUTPUT_WIDTH}\n"
|
||||||
|
f"PlayResY: {OUTPUT_HEIGHT}\n\n"
|
||||||
|
"[V4+ Styles]\n"
|
||||||
|
"Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BackColour, Bold, "
|
||||||
|
"BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV\n"
|
||||||
|
"Style: Default,Arial,64,&H00FFFFFF,&H00000000,&H80000000,1,1,3,2,2,60,60,300\n\n"
|
||||||
|
"[Events]\n"
|
||||||
|
"Format: Layer, Start, End, Style, Text\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = [header]
|
||||||
|
for i in range(0, len(words), words_per_caption):
|
||||||
|
chunk = words[i : i + words_per_caption]
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
start = chunk[0].get("start", 0.0)
|
||||||
|
end = chunk[-1].get("end", start)
|
||||||
|
text = " ".join(str(w.get("word", "")).strip() for w in chunk).strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
lines.append(f"Dialogue: 0,{_seconds_to_ass_time(start)},{_seconds_to_ass_time(end)},Default,{text}\n")
|
||||||
|
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.writelines(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_final(
|
||||||
|
clip_path: str,
|
||||||
|
smoothed_x: np.ndarray | None,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
fps: float,
|
||||||
|
ass_path: str,
|
||||||
|
out_path: str,
|
||||||
|
) -> None:
|
||||||
|
crop_w = min(width, max(2, int(height * OUTPUT_WIDTH / OUTPUT_HEIGHT) // 2 * 2))
|
||||||
|
|
||||||
|
ffmpeg = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-f", "rawvideo", "-pix_fmt", "bgr24",
|
||||||
|
"-s", f"{OUTPUT_WIDTH}x{OUTPUT_HEIGHT}",
|
||||||
|
"-r", str(fps),
|
||||||
|
"-i", "-",
|
||||||
|
"-i", clip_path,
|
||||||
|
"-vf", f"ass={ass_path}",
|
||||||
|
"-map", "0:v:0", "-map", "1:a:0?",
|
||||||
|
"-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p",
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-shortest",
|
||||||
|
out_path,
|
||||||
|
],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
cap = cv2.VideoCapture(clip_path)
|
||||||
|
frame_idx = 0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
ok, frame = cap.read()
|
||||||
|
if not ok:
|
||||||
|
break
|
||||||
|
|
||||||
|
cx_frac = 0.5
|
||||||
|
if smoothed_x is not None and frame_idx < len(smoothed_x):
|
||||||
|
cx_frac = float(smoothed_x[frame_idx])
|
||||||
|
|
||||||
|
cx_px = int(cx_frac * width)
|
||||||
|
x0 = max(0, min(width - crop_w, cx_px - crop_w // 2))
|
||||||
|
cropped = frame[0:height, x0 : x0 + crop_w]
|
||||||
|
resized = cv2.resize(cropped, (OUTPUT_WIDTH, OUTPUT_HEIGHT))
|
||||||
|
|
||||||
|
assert ffmpeg.stdin is not None
|
||||||
|
ffmpeg.stdin.write(resized.tobytes())
|
||||||
|
frame_idx += 1
|
||||||
|
finally:
|
||||||
|
cap.release()
|
||||||
|
if ffmpeg.stdin:
|
||||||
|
ffmpeg.stdin.close()
|
||||||
|
ffmpeg.wait()
|
||||||
|
|
||||||
|
|
||||||
|
def _render_sync(file_path: str, start_sec: int, end_sec: int, transcript: dict, out_path: str) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
clip_path = os.path.join(tmp_dir, "clip.mp4")
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-ss", str(start_sec),
|
||||||
|
"-to", str(end_sec),
|
||||||
|
"-i", file_path,
|
||||||
|
"-c", "copy",
|
||||||
|
clip_path,
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
points, width, height, fps = _detect_face_centers(clip_path)
|
||||||
|
total_frames = int(cv2.VideoCapture(clip_path).get(cv2.CAP_PROP_FRAME_COUNT))
|
||||||
|
smoothed = _smooth_x_path(points, total_frames, fps)
|
||||||
|
|
||||||
|
ass_path = os.path.join(tmp_dir, "subs.ass")
|
||||||
|
_build_ass_subtitles(transcript, ass_path)
|
||||||
|
|
||||||
|
_render_final(clip_path, smoothed, width, height, fps, ass_path, out_path)
|
||||||
|
|
||||||
|
|
||||||
|
async def process_video_render(job, job_token=None):
|
||||||
|
candidate_id = job.data["candidateSegmentId"]
|
||||||
|
candidate = await db.get_candidate_for_render(candidate_id)
|
||||||
|
if candidate is None:
|
||||||
|
print(f"[video-render] candidate {candidate_id} not found, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
short_id = await db.upsert_short_video(candidate_id, status="RENDERING")
|
||||||
|
|
||||||
|
out_dir = Path(SHARED_MEDIA_ROOT) / "shorts"
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path = str(out_dir / f"{candidate_id}.mp4")
|
||||||
|
|
||||||
|
try:
|
||||||
|
transcript = candidate["transcript_json"] or {}
|
||||||
|
if isinstance(transcript, str):
|
||||||
|
transcript = json.loads(transcript)
|
||||||
|
|
||||||
|
await asyncio.to_thread(
|
||||||
|
_render_sync,
|
||||||
|
candidate["file_path"],
|
||||||
|
candidate["start_sec"],
|
||||||
|
candidate["end_sec"],
|
||||||
|
transcript,
|
||||||
|
out_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.update_short_video(short_id, status="READY", file_path=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])
|
||||||
|
|
||||||
|
|
||||||
|
def start_video_render_worker() -> Worker:
|
||||||
|
return Worker(
|
||||||
|
QUEUE_NAMES["VIDEO_RENDER"],
|
||||||
|
process_video_render,
|
||||||
|
{"connection": REDIS_URL, "concurrency": VIDEO_RENDER_CONCURRENCY},
|
||||||
|
)
|
||||||
+5
-2
@@ -33,7 +33,7 @@ services:
|
|||||||
DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper
|
DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper
|
||||||
SHARED_MEDIA_ROOT: /shared-media
|
SHARED_MEDIA_ROOT: /shared-media
|
||||||
POLL_INTERVAL_MS: ${POLL_INTERVAL_MS:-60000}
|
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}
|
API_DAEMON_PORT: ${API_DAEMON_PORT:-4001}
|
||||||
FORCE_LIVE_URL: ${FORCE_LIVE_URL:-}
|
FORCE_LIVE_URL: ${FORCE_LIVE_URL:-}
|
||||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||||
@@ -42,6 +42,7 @@ services:
|
|||||||
MAX_CONCURRENT_CAPTURES: ${MAX_CONCURRENT_CAPTURES:-10}
|
MAX_CONCURRENT_CAPTURES: ${MAX_CONCURRENT_CAPTURES:-10}
|
||||||
PROXY_URL: ${PROXY_URL:-}
|
PROXY_URL: ${PROXY_URL:-}
|
||||||
RAW_SEGMENT_TTL_HOURS: ${RAW_SEGMENT_TTL_HOURS:-24}
|
RAW_SEGMENT_TTL_HOURS: ${RAW_SEGMENT_TTL_HOURS:-24}
|
||||||
|
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-}
|
||||||
volumes:
|
volumes:
|
||||||
- shared-media:/shared-media
|
- shared-media:/shared-media
|
||||||
ports:
|
ports:
|
||||||
@@ -60,11 +61,12 @@ services:
|
|||||||
REDIS_URL: redis://sc_redis:6379
|
REDIS_URL: redis://sc_redis:6379
|
||||||
DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper
|
DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper
|
||||||
SHARED_MEDIA_ROOT: /shared-media
|
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:-}
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||||
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||||
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-5}
|
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-5}
|
||||||
|
VIDEO_RENDER_CONCURRENCY: ${VIDEO_RENDER_CONCURRENCY:-2}
|
||||||
volumes:
|
volumes:
|
||||||
- shared-media:/shared-media
|
- shared-media:/shared-media
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -81,6 +83,7 @@ services:
|
|||||||
API_DAEMON_URL: http://sc_api_daemon:4001
|
API_DAEMON_URL: http://sc_api_daemon:4001
|
||||||
SHARED_MEDIA_ROOT: /shared-media
|
SHARED_MEDIA_ROOT: /shared-media
|
||||||
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
||||||
|
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-}
|
||||||
volumes:
|
volumes:
|
||||||
- shared-media:/shared-media
|
- shared-media:/shared-media
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ShortVideoStatus" AS ENUM ('PENDING', 'RENDERING', 'READY', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "short_videos" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"candidate_id" TEXT NOT NULL,
|
||||||
|
"file_path" TEXT,
|
||||||
|
"status" "ShortVideoStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"error_message" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "short_videos_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "short_videos_candidate_id_key" ON "short_videos"("candidate_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "short_videos" ADD CONSTRAINT "short_videos_candidate_id_fkey" FOREIGN KEY ("candidate_id") REFERENCES "candidate_segments"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "channels" ADD COLUMN "segment_time_sec" INTEGER;
|
||||||
@@ -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;
|
||||||
@@ -11,6 +11,10 @@ enum RawSegmentStatus {
|
|||||||
PENDING
|
PENDING
|
||||||
PROCESSED
|
PROCESSED
|
||||||
DISCARDED
|
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 {
|
enum CandidateSegmentStatus {
|
||||||
@@ -19,6 +23,13 @@ enum CandidateSegmentStatus {
|
|||||||
FAILED
|
FAILED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ShortVideoStatus {
|
||||||
|
PENDING
|
||||||
|
RENDERING
|
||||||
|
READY
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
model Channel {
|
model Channel {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
@@ -27,6 +38,8 @@ model Channel {
|
|||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
lastCheckedAt DateTime? @map("last_checked_at")
|
lastCheckedAt DateTime? @map("last_checked_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
// null = global varsayılanı (SEGMENT_TIME_SEC env var) kullan
|
||||||
|
segmentTimeSec Int? @map("segment_time_sec")
|
||||||
|
|
||||||
sessions StreamSession[]
|
sessions StreamSession[]
|
||||||
|
|
||||||
@@ -51,7 +64,7 @@ model RawSegment {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
sessionId String @map("session_id")
|
sessionId String @map("session_id")
|
||||||
session StreamSession @relation(fields: [sessionId], references: [id])
|
session StreamSession @relation(fields: [sessionId], references: [id])
|
||||||
filePath String @map("file_path")
|
filePath String? @map("file_path")
|
||||||
duration Int
|
duration Int
|
||||||
startedAt DateTime @map("started_at")
|
startedAt DateTime @map("started_at")
|
||||||
status RawSegmentStatus @default(PENDING)
|
status RawSegmentStatus @default(PENDING)
|
||||||
@@ -74,9 +87,39 @@ model CandidateSegment {
|
|||||||
status CandidateSegmentStatus @default(PENDING_STT)
|
status CandidateSegmentStatus @default(PENDING_STT)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
short ShortVideo?
|
||||||
|
|
||||||
@@map("candidate_segments")
|
@@map("candidate_segments")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ShortVideo {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
candidateId String @unique @map("candidate_id")
|
||||||
|
candidate CandidateSegment @relation(fields: [candidateId], references: [id])
|
||||||
|
filePath String? @map("file_path")
|
||||||
|
status ShortVideoStatus @default(PENDING)
|
||||||
|
errorMessage String? @map("error_message")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@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 {
|
model AppSetting {
|
||||||
key String @id
|
key String @id
|
||||||
value String
|
value String
|
||||||
|
|||||||
Generated
+2080
-2
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user