Compare commits
25
Commits
775633bfd0
...
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 | ||
|
|
5fef7460cf | ||
|
|
783125b8f2 |
+17
-1
@@ -12,7 +12,7 @@ TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
|
||||
POLL_INTERVAL_MS=60000
|
||||
SEGMENT_TIME_SEC=900
|
||||
SEGMENT_TIME_SEC=180
|
||||
API_DAEMON_PORT=4001
|
||||
|
||||
# Signs the panel's login session cookies (frontend). Generate with:
|
||||
@@ -45,3 +45,19 @@ YTDLP_POT_PROVIDER_URL=
|
||||
# ISP proxy product specifically (e.g. Oxylabs "ISP Proxies"), not a plain
|
||||
# datacenter proxy — the latter has the same problem as the server itself.
|
||||
PROXY_URL=
|
||||
|
||||
# Processed raw recordings (raw_segments) older than this get auto-deleted
|
||||
# (DB row + file) — PRD's auto-purge lifecycle. Only segments already fully
|
||||
# analyzed (no candidate still waiting on STT) are eligible.
|
||||
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
|
||||
headless capture + 15dk segmentleme), sinyal analizi (ses peak + chat
|
||||
velocity), Whisper STT (OpenAI API, kelime zaman damgalı), BullMQ/Redis/
|
||||
Postgres altyapısı ve durum/transkript gösteren bir Next.js panel. LLM
|
||||
virality skorlama, render/crop/altyazı ve multi-platform dağıtım bu fazda
|
||||
**yok** — bkz. `prd.md` Modül 3 (skorlama kısmı), 4 ve 5.
|
||||
`prd.md` içindeki tam kapsamlı otonom pipeline'ın Faz 1+2'si: ingestion
|
||||
(yt-dlp headless capture + 15dk segmentleme), sinyal analizi (ses peak +
|
||||
chat velocity), Whisper STT (OpenAI API, kelime zaman damgalı), BullMQ/
|
||||
Redis/Postgres altyapısı, auth korumalı bir Next.js panel — ve Faz 2 olarak
|
||||
transkribe edilen her aday klibi otomatik yüz-takipli 9:16'ya crop'layıp
|
||||
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ı
|
||||
|
||||
@@ -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
|
||||
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ı
|
||||
oradan oluşturursun. Sonraki kullanıcılar için ayrı bir "kullanıcı ekle"
|
||||
arayüzü henüz yok (Faz 2).
|
||||
oradan oluşturursun. `/users` sayfasından ek kullanıcı ekleyip silebilirsin
|
||||
(kendi hesabını veya son kalan tek kullanıcıyı silmek engellenir).
|
||||
|
||||
`SESSION_SECRET` production'da zorunlu (`openssl rand -base64 32` ile
|
||||
ü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
|
||||
`candidate_segments` üretir.
|
||||
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
|
||||
bildirim gider.
|
||||
5. Panelde (`/` ve `/segments`) kanal durumu ve üretilen transkriptleri
|
||||
kontrol et.
|
||||
sonuç `candidate_segments.transcript_json` alanına yazılır, Telegram'a
|
||||
bildirim gider ve `video-render` kuyruğuna otomatik iş eklenir.
|
||||
5. `video-render` (Python, `video_render.py`) yüz tespiti + yumuşatılmış
|
||||
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ışı)
|
||||
|
||||
- LLM virality skorlama, metadata üretimi, telif/görüntü filtresi yok.
|
||||
- 9:16 crop/altyazı render motoru yok.
|
||||
- LLM virality skorlama, metadata üretimi, telif/görüntü filtresi 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
|
||||
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ı;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { mkdir, readFile, unlink } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Worker, type Job } from "bullmq";
|
||||
import { prisma } from "@streamclipper/db";
|
||||
@@ -7,6 +7,7 @@ import { env } from "../env";
|
||||
import { QUEUE_NAMES, signalDetectionQueue, type StreamIngestJob } from "../queues";
|
||||
import { redisConnection } from "../redis";
|
||||
import { ytdlpAntiBotArgs } from "../ytdlpCookies";
|
||||
import { sendTelegramVideo } from "../telegram";
|
||||
|
||||
const SEGMENT_LIST_POLL_MS = 5_000;
|
||||
|
||||
@@ -17,6 +18,24 @@ const SEGMENT_LIST_POLL_MS = 5_000;
|
||||
*/
|
||||
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) {
|
||||
const lines = lastCaptureDebug.get(sessionId) ?? [];
|
||||
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(
|
||||
entries: SegmentListEntry[],
|
||||
fromIndex: number,
|
||||
outDir: string,
|
||||
sessionId: string,
|
||||
channelName: string,
|
||||
): Promise<number> {
|
||||
let processed = fromIndex;
|
||||
const sendRawToTelegram = await sendRawSegmentsToTelegramEnabled();
|
||||
|
||||
for (let i = fromIndex; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
@@ -64,13 +90,29 @@ async function processCompletedSegments(
|
||||
},
|
||||
});
|
||||
|
||||
if (sendRawToTelegram) {
|
||||
// Kullanıcı transkript/9:16 zincirini hiç istemiyor — ham segmenti
|
||||
// analiz kuyruğuna hiç sokmadan doğrudan gönderiyoruz. Kuyruğa
|
||||
// eklemiyoruz çünkü aşağıda dosyayı silmek, o segmenti daha sonra
|
||||
// okumaya çalışacak sinyal analizini bozardı.
|
||||
const caption = `🎬 *${channelName}* — ${duration}sn ham kayıt`;
|
||||
const sent = await sendTelegramVideo(filePath, caption);
|
||||
if (sent) {
|
||||
await unlink(filePath).catch(() => {});
|
||||
await prisma.rawSegment.update({ where: { id: segment.id }, data: { status: "SENT_TO_TELEGRAM", filePath: null } });
|
||||
console.log(`[stream-ingest] segment ${entry.fileName} sent to Telegram and removed from disk`);
|
||||
} else {
|
||||
console.warn(`[stream-ingest] segment ${entry.fileName} failed to send to Telegram, kept on disk`);
|
||||
}
|
||||
} else {
|
||||
await signalDetectionQueue.add("analyze-segment", {
|
||||
rawSegmentId: segment.id,
|
||||
filePath,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
console.log(`[stream-ingest] segment ready: ${entry.fileName} -> raw_segment ${segment.id}`);
|
||||
}
|
||||
|
||||
processed = i + 1;
|
||||
}
|
||||
|
||||
@@ -78,7 +120,9 @@ async function processCompletedSegments(
|
||||
}
|
||||
|
||||
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);
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
@@ -90,11 +134,12 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
|
||||
"yt-dlp",
|
||||
[
|
||||
"--js-runtimes", "node",
|
||||
...(await ytdlpAntiBotArgs()),
|
||||
...(await ytdlpAntiBotArgs(channelDbId, { includeAndroidClient: false })),
|
||||
"-f", "bestvideo+bestaudio/best", "-o", "-", youtubeUrl,
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
activeCaptures.set(sessionId, ytdlp);
|
||||
|
||||
const ffmpeg = spawn(
|
||||
"ffmpeg",
|
||||
@@ -102,7 +147,7 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
|
||||
"-i", "pipe:0",
|
||||
"-c", "copy",
|
||||
"-f", "segment",
|
||||
"-segment_time", String(env.segmentTimeSec),
|
||||
"-segment_time", String(segmentTimeSec ?? env.segmentTimeSec),
|
||||
"-reset_timestamps", "1",
|
||||
"-segment_list", segmentListPath,
|
||||
"-segment_list_type", "csv",
|
||||
@@ -131,7 +176,7 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
|
||||
if (!raw) return;
|
||||
const entries = parseSegmentListLines(raw);
|
||||
if (entries.length > lastProcessedIndex) {
|
||||
lastProcessedIndex = await processCompletedSegments(entries, lastProcessedIndex, outDir, sessionId);
|
||||
lastProcessedIndex = await processCompletedSegments(entries, lastProcessedIndex, outDir, sessionId, channelName);
|
||||
await prisma.streamSession.update({
|
||||
where: { id: sessionId },
|
||||
data: { totalSegments: lastProcessedIndex },
|
||||
@@ -153,12 +198,13 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
|
||||
});
|
||||
|
||||
clearInterval(pollTimer);
|
||||
activeCaptures.delete(sessionId);
|
||||
|
||||
// final sweep in case segments closed between the last poll and process exit
|
||||
const raw = await readFile(segmentListPath, "utf8").catch(() => "");
|
||||
const entries = parseSegmentListLines(raw);
|
||||
if (entries.length > lastProcessedIndex) {
|
||||
lastProcessedIndex = await processCompletedSegments(entries, lastProcessedIndex, outDir, sessionId);
|
||||
lastProcessedIndex = await processCompletedSegments(entries, lastProcessedIndex, outDir, sessionId, channelName);
|
||||
}
|
||||
|
||||
await prisma.streamSession.update({
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { unlink } from "node:fs/promises";
|
||||
import { prisma } from "@streamclipper/db";
|
||||
import { env } from "./env";
|
||||
|
||||
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
|
||||
* capture. Only segments that are done being analyzed (PROCESSED/DISCARDED)
|
||||
* and have no candidate still waiting on STT are eligible — deleting a raw
|
||||
* file mid-analysis or mid-transcription would break the pipeline for it.
|
||||
*/
|
||||
async function cleanupOnce(): Promise<void> {
|
||||
const ttlHours = await getTtlHours();
|
||||
const cutoff = new Date(Date.now() - ttlHours * 60 * 60 * 1000);
|
||||
|
||||
const staleSegments = await prisma.rawSegment.findMany({
|
||||
where: {
|
||||
createdAt: { lt: cutoff },
|
||||
status: { in: ["PROCESSED", "DISCARDED"] },
|
||||
candidates: { none: { status: "PENDING_STT" } },
|
||||
},
|
||||
});
|
||||
|
||||
for (const segment of staleSegments) {
|
||||
await prisma.candidateSegment.deleteMany({ where: { rawSegmentId: segment.id } });
|
||||
await prisma.rawSegment.delete({ where: { id: segment.id } });
|
||||
if (segment.filePath) await unlink(segment.filePath).catch(() => {});
|
||||
}
|
||||
|
||||
if (staleSegments.length > 0) {
|
||||
console.log(`[cleanup] removed ${staleSegments.length} raw segment(s) older than ${ttlHours}h`);
|
||||
}
|
||||
}
|
||||
|
||||
export function startCleanupLoop(): NodeJS.Timeout {
|
||||
console.log(`[cleanup] checking every ${CHECK_INTERVAL_MS}ms, default TTL ${env.rawSegmentTtlHours}h`);
|
||||
cleanupOnce().catch((err) => console.error("[cleanup] initial run failed:", err));
|
||||
return setInterval(() => {
|
||||
cleanupOnce().catch((err) => console.error("[cleanup] run failed:", err));
|
||||
}, CHECK_INTERVAL_MS);
|
||||
}
|
||||
@@ -4,10 +4,12 @@ export const env = {
|
||||
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",
|
||||
sharedMediaRoot: process.env.SHARED_MEDIA_ROOT ?? "./shared-media",
|
||||
pollIntervalMs: Number(process.env.POLL_INTERVAL_MS ?? 60_000),
|
||||
segmentTimeSec: Number(process.env.SEGMENT_TIME_SEC ?? 900),
|
||||
segmentTimeSec: Number(process.env.SEGMENT_TIME_SEC ?? 180),
|
||||
port: Number(process.env.API_DAEMON_PORT ?? 4001),
|
||||
forceLiveUrl: process.env.FORCE_LIVE_URL ?? "",
|
||||
ytdlpPotProviderUrl: process.env.YTDLP_POT_PROVIDER_URL ?? "",
|
||||
proxyUrl: process.env.PROXY_URL ?? "",
|
||||
maxConcurrentCaptures: Number(process.env.MAX_CONCURRENT_CAPTURES ?? 10),
|
||||
rawSegmentTtlHours: Number(process.env.RAW_SEGMENT_TTL_HOURS ?? 24),
|
||||
internalApiToken: process.env.INTERNAL_API_TOKEN ?? "",
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { startPollingLoop } from "./youtubePolling";
|
||||
import { startStreamIngestWorker } from "./capture/streamIngest";
|
||||
import { streamIngestQueue } from "./queues";
|
||||
import { startServer } from "./server";
|
||||
import { startCleanupLoop } from "./cleanup";
|
||||
|
||||
async function forceLiveBypass(youtubeUrl: string) {
|
||||
console.log(`[index] FORCE_LIVE_URL set — bypassing polling and capturing directly: ${youtubeUrl}`);
|
||||
@@ -54,6 +55,7 @@ async function main() {
|
||||
await closeOrphanedSessions();
|
||||
startServer();
|
||||
startStreamIngestWorker();
|
||||
startCleanupLoop();
|
||||
|
||||
if (env.forceLiveUrl) {
|
||||
await forceLiveBypass(env.forceLiveUrl);
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface StreamIngestJob {
|
||||
sessionId: string;
|
||||
channelDbId: string;
|
||||
youtubeUrl: string;
|
||||
/** Kanala özel segment süresi (sn) — verilmezse env.segmentTimeSec kullanılır. */
|
||||
segmentTimeSec?: number;
|
||||
}
|
||||
|
||||
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, {
|
||||
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 { prisma } from "@streamclipper/db";
|
||||
import { env } from "./env";
|
||||
import { lastPollErrors } from "./youtubePolling";
|
||||
import { lastCaptureDebug } from "./capture/streamIngest";
|
||||
import { lastPollErrors, checkChannel, startCaptureSession } from "./youtubePolling";
|
||||
import { lastCaptureDebug, stopCapture } from "./capture/streamIngest";
|
||||
import { videoRenderQueue } from "./queues";
|
||||
|
||||
export function startServer() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.get("/health", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
@@ -37,6 +39,70 @@ export function startServer() {
|
||||
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, () => {
|
||||
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 CHAT_ID = process.env.TELEGRAM_CHAT_ID ?? "";
|
||||
|
||||
// Telegram's bot-upload limit (no local Bot API server).
|
||||
const MAX_VIDEO_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
export async function sendTelegramMessage(text: string): Promise<void> {
|
||||
if (!BOT_TOKEN || !CHAT_ID) {
|
||||
console.warn("[telegram] TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID not set, skipping notification:", text);
|
||||
@@ -18,3 +24,40 @@ export async function sendTelegramMessage(text: string): Promise<void> {
|
||||
console.error("[telegram] failed to send message:", res.status, await res.text());
|
||||
}
|
||||
}
|
||||
|
||||
/** Uploads a video file to Telegram. Returns false (never throws) on any failure — the caller decides what that means for the local file. */
|
||||
export async function sendTelegramVideo(filePath: string, caption: string): Promise<boolean> {
|
||||
if (!BOT_TOKEN || !CHAT_ID) {
|
||||
console.warn("[telegram] TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID not set, skipping video:", caption);
|
||||
return false;
|
||||
}
|
||||
|
||||
const { size } = await stat(filePath);
|
||||
if (size > MAX_VIDEO_BYTES) {
|
||||
console.warn(`[telegram] video ${filePath} is ${size} bytes, over Telegram's bot-upload limit — skipping`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await readFile(filePath);
|
||||
const form = new FormData();
|
||||
form.set("chat_id", CHAT_ID);
|
||||
form.set("caption", caption);
|
||||
form.set("parse_mode", "Markdown");
|
||||
form.set("video", new Blob([buffer], { type: "video/mp4" }), path.basename(filePath));
|
||||
|
||||
const res = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendVideo`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error("[telegram] failed to send video:", res.status, await res.text());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("[telegram] failed to upload video:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,18 @@ const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* 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:
|
||||
@@ -30,27 +39,74 @@ export const lastPollErrors = new Map<string, string>();
|
||||
* residential/ISP proxy (the Coolify server's own IP got flagged from a
|
||||
* day of heavy testing). No `-f` format selector here — resolving
|
||||
* 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`;
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"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 },
|
||||
);
|
||||
// Only worth a "recovered" message if it was actually failing before —
|
||||
// this runs on every successful poll, most of which were never broken.
|
||||
if (lastPollErrors.has(channelId)) {
|
||||
lastPollErrors.delete(channelId);
|
||||
await notifyIfEnabled("notify_poll_error", channelDbId, `✅ *${channelName}* canlı-tespiti tekrar çalışıyor.`);
|
||||
}
|
||||
const videoId = stdout.trim().split("\n")[0];
|
||||
return videoId || null;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// yt-dlp reports "not live" as a non-zero exit too — that's a normal,
|
||||
// expected outcome on every check where the channel isn't broadcasting,
|
||||
// not a failure worth surfacing as an error in the panel.
|
||||
if (!/is not currently live/.test(message)) {
|
||||
lastPollErrors.set(channelId, message.slice(0, 500));
|
||||
// yt-dlp reports "not live" / "starts in N minutes" as a non-zero exit
|
||||
// too — normal, expected outcomes, not failures worth a red badge.
|
||||
if (!BENIGN_POLL_MESSAGE.test(message)) {
|
||||
// Alert only on the transition into failing — not every 60s poll
|
||||
// while it stays broken, or this would spam constantly.
|
||||
const wasAlreadyFailing = lastPollErrors.has(channelId);
|
||||
lastPollErrors.set(channelId, { message: message.slice(0, 500), at: new Date().toISOString() });
|
||||
if (!wasAlreadyFailing) {
|
||||
await notifyIfEnabled(
|
||||
"notify_poll_error",
|
||||
channelDbId,
|
||||
`⚠️ *${channelName}* canlı-tespiti başarısız oluyor:\n\`${message.slice(0, 300)}\``,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
lastPollErrors.delete(channelId);
|
||||
}
|
||||
@@ -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({
|
||||
where: { channelId: channel.id, endedAt: null },
|
||||
});
|
||||
if (existing) return;
|
||||
if (existing) return { started: false };
|
||||
|
||||
const session = await prisma.streamSession.create({
|
||||
data: { channelId: channel.id, liveVideoId },
|
||||
@@ -71,37 +138,95 @@ async function startSession(channel: { id: string; name: string }, liveVideoId:
|
||||
await streamIngestQueue.add("start-capture", {
|
||||
sessionId: session.id,
|
||||
channelDbId: channel.id,
|
||||
youtubeUrl: `https://www.youtube.com/watch?v=${liveVideoId}`,
|
||||
youtubeUrl,
|
||||
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}`);
|
||||
return { started: true };
|
||||
}
|
||||
|
||||
export async function pollOnce(): Promise<void> {
|
||||
const channels = await prisma.channel.findMany({ where: { isActive: true } });
|
||||
|
||||
for (const channel of channels) {
|
||||
try {
|
||||
const liveVideoId = await findLiveVideoId(channel.channelId);
|
||||
/** 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 startSession(channel, 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> {
|
||||
const channels = await prisma.channel.findMany({ where: { isActive: true } });
|
||||
|
||||
const openSessions = await prisma.streamSession.findMany({
|
||||
where: { endedAt: null, channelId: { in: channels.map((c) => c.id) } },
|
||||
select: { channelId: true },
|
||||
});
|
||||
const recordingChannelIds = new Set(openSessions.map((s) => s.channelId));
|
||||
|
||||
for (const channel of channels) {
|
||||
// Already capturing this channel — we already know it's live, so skip
|
||||
// the bot-check-prone /live lookup entirely. With several channels
|
||||
// sharing one cookie session + one proxy IP, re-checking channels that
|
||||
// don't need it multiplies request volume through that single shared
|
||||
// identity for no reason — a likely contributor to multiple channels
|
||||
// failing detection at once once a few are live simultaneously.
|
||||
if (recordingChannelIds.has(channel.id)) continue;
|
||||
|
||||
try {
|
||||
await checkChannel(channel);
|
||||
} catch (err) {
|
||||
console.error(`[youtube-polling] error polling channel ${channel.name}:`, err);
|
||||
}
|
||||
|
||||
// Stagger checks instead of firing them back-to-back — a tight burst of
|
||||
// near-simultaneous requests through the same cookie/IP looks more
|
||||
// automated than the same requests spread out.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
export function startPollingLoop(): NodeJS.Timeout {
|
||||
console.log(`[youtube-polling] polling every ${env.pollIntervalMs}ms`);
|
||||
pollOnce().catch((err) => console.error("[youtube-polling] initial poll failed:", err));
|
||||
return setInterval(() => {
|
||||
pollOnce().catch((err) => console.error("[youtube-polling] poll failed:", err));
|
||||
}, env.pollIntervalMs);
|
||||
const POLL_INTERVAL_SETTING_KEY = "poll_interval_ms";
|
||||
|
||||
async function getPollIntervalMs(): Promise<number> {
|
||||
const setting = await prisma.appSetting.findUnique({ where: { key: POLL_INTERVAL_SETTING_KEY } });
|
||||
const parsed = setting ? Number(setting.value) : NaN;
|
||||
return Number.isFinite(parsed) && parsed >= 5_000 ? parsed : env.pollIntervalMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-rescheduling instead of setInterval so a panel-side change to the
|
||||
* poll_interval_ms AppSetting (read fresh on every iteration, same pattern
|
||||
* as ytdlpAntiBotArgs()'s cookies) takes effect on the very next cycle
|
||||
* without a redeploy.
|
||||
*/
|
||||
export function startPollingLoop(): void {
|
||||
let stopped = false;
|
||||
|
||||
async function tick() {
|
||||
if (stopped) return;
|
||||
await pollOnce().catch((err) => console.error("[youtube-polling] poll failed:", err));
|
||||
const intervalMs = await getPollIntervalMs().catch(() => env.pollIntervalMs);
|
||||
if (!stopped) setTimeout(tick, intervalMs);
|
||||
}
|
||||
|
||||
getPollIntervalMs()
|
||||
.then((ms) => console.log(`[youtube-polling] polling every ${ms}ms`))
|
||||
.catch(() => {});
|
||||
tick();
|
||||
}
|
||||
|
||||
@@ -2,8 +2,70 @@ import { writeFileSync } from "node:fs";
|
||||
import { prisma } from "@streamclipper/db";
|
||||
import { env } from "./env";
|
||||
|
||||
const COOKIES_PATH = "/tmp/yt-cookies.txt";
|
||||
const COOKIES_SETTING_KEY = "ytdlp_cookies";
|
||||
const LEGACY_COOKIES_PATH = "/tmp/yt-cookies.txt";
|
||||
const LEGACY_COOKIES_SETTING_KEY = "ytdlp_cookies";
|
||||
|
||||
// Oxylabs' static ISP pool: one dedicated (non-rotating) IP per port,
|
||||
// confirmed against the dashboard's Proxy list (isp.oxylabs.io:8001..8010 ->
|
||||
// 10 distinct IPs). PROXY_URL's own port is the base; funneling every
|
||||
// channel through that single port meant 7-8 simultaneous channels' worth
|
||||
// of YouTube-facing traffic all looked like it came from one IP.
|
||||
const PROXY_PORT_POOL_SIZE = 10;
|
||||
|
||||
function simpleHash(input: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash = (hash * 31 + input.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/** Deterministically pins a channel to one of the pool's ports — same channel always gets the same IP (so it isn't mid-session IP-mismatched against a live URL signed for a different one), different channels spread across the pool. */
|
||||
function proxyUrlForChannel(baseProxyUrl: string, channelKey: string): string {
|
||||
const match = baseProxyUrl.match(/^(.*:)(\d+)$/);
|
||||
if (!match) return baseProxyUrl;
|
||||
const [, prefix, basePortStr] = match;
|
||||
const basePort = Number(basePortStr);
|
||||
const offset = simpleHash(channelKey) % PROXY_PORT_POOL_SIZE;
|
||||
return `${prefix}${basePort + offset}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks which cookie profile a channel uses (same deterministic-hash
|
||||
* pinning as proxyUrlForChannel — same channel always gets the same
|
||||
* account, different channels spread across the pool) and writes it to a
|
||||
* profile-specific temp file. Falls back to the old single global
|
||||
* ytdlp_cookies AppSetting if no profiles have been added yet, so nothing
|
||||
* breaks before the panel's Settings page is used to add accounts.
|
||||
*
|
||||
* A single shared cookie file was a real bottleneck once several channels
|
||||
* were active at once: it isn't just proxy IP reputation that YouTube can
|
||||
* flag, it's the *account* — one Google session making requests that look
|
||||
* like it's scanning many unrelated channels trips its own abuse signal,
|
||||
* independent of which IP each request came from. Splitting across
|
||||
* separate throwaway accounts dilutes that per-account signal the same way
|
||||
* the proxy pool dilutes per-IP signal.
|
||||
*
|
||||
* Profile-specific file paths (not one shared /tmp/yt-cookies.txt) matter
|
||||
* once multiple channels can be using *different* profiles concurrently —
|
||||
* a shared path would let one channel's capture process read a file another
|
||||
* channel's poll check just overwrote with a different account's cookies.
|
||||
*/
|
||||
async function cookiesFilePathForChannel(channelKey: string): Promise<string | null> {
|
||||
const profiles = await prisma.cookieProfile.findMany({ orderBy: { createdAt: "asc" } });
|
||||
|
||||
if (profiles.length === 0) {
|
||||
const legacy = await prisma.appSetting.findUnique({ where: { key: LEGACY_COOKIES_SETTING_KEY } });
|
||||
if (!legacy?.value) return null;
|
||||
writeFileSync(LEGACY_COOKIES_PATH, legacy.value, { mode: 0o600 });
|
||||
return LEGACY_COOKIES_PATH;
|
||||
}
|
||||
|
||||
const profile = profiles[simpleHash(channelKey) % profiles.length];
|
||||
const path = `/tmp/yt-cookies-${profile.id}.txt`;
|
||||
writeFileSync(path, profile.value, { mode: 0o600 });
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* YouTube's anti-bot layer against yt-dlp has three parts, all needed
|
||||
@@ -21,21 +83,38 @@ const COOKIES_SETTING_KEY = "ytdlp_cookies";
|
||||
* current value and rewrites the temp file, so a panel update takes effect
|
||||
* on the very next yt-dlp invocation without a redeploy.
|
||||
*/
|
||||
export async function ytdlpAntiBotArgs(): Promise<string[]> {
|
||||
export async function ytdlpAntiBotArgs(
|
||||
channelKey: string,
|
||||
{ includeAndroidClient = true }: { includeAndroidClient?: boolean } = {},
|
||||
): Promise<string[]> {
|
||||
const args: string[] = [];
|
||||
|
||||
const setting = await prisma.appSetting.findUnique({ where: { key: COOKIES_SETTING_KEY } });
|
||||
if (setting?.value) {
|
||||
writeFileSync(COOKIES_PATH, setting.value, { mode: 0o600 });
|
||||
args.push("--cookies", COOKIES_PATH);
|
||||
const cookiesPath = await cookiesFilePathForChannel(channelKey);
|
||||
if (cookiesPath) {
|
||||
args.push("--cookies", cookiesPath);
|
||||
}
|
||||
|
||||
if (env.ytdlpPotProviderUrl) {
|
||||
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) {
|
||||
args.push("--proxy", env.proxyUrl);
|
||||
args.push("--proxy", proxyUrlForChannel(env.proxyUrl, channelKey));
|
||||
}
|
||||
|
||||
return args;
|
||||
|
||||
@@ -7,4 +7,12 @@ COPY . .
|
||||
RUN pnpm install
|
||||
RUN pnpm --filter @streamclipper/db exec prisma generate
|
||||
|
||||
CMD ["pnpm", "--filter", "@streamclipper/frontend", "dev"]
|
||||
# DATABASE_URL isn't needed for real at build time (all pages are
|
||||
# force-dynamic — nothing queries the DB during `next build`), but
|
||||
# Prisma's client validates the connection string format at import, so a
|
||||
# well-formed placeholder keeps the build step from failing. Coolify's
|
||||
# actual DATABASE_URL is supplied as a runtime env var and overrides this.
|
||||
ENV DATABASE_URL="postgresql://build:build@localhost:5432/build"
|
||||
RUN pnpm --filter @streamclipper/frontend build
|
||||
|
||||
CMD ["pnpm", "--filter", "@streamclipper/frontend", "start"]
|
||||
|
||||
@@ -4,6 +4,7 @@ import { unlink } from "node:fs/promises";
|
||||
import { prisma } from "@streamclipper/db";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { postToApiDaemon } from "../lib/apiDaemon";
|
||||
|
||||
export async function addChannel(formData: FormData) {
|
||||
const name = String(formData.get("name") ?? "").trim();
|
||||
@@ -21,19 +22,32 @@ export async function addChannel(formData: FormData) {
|
||||
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) {
|
||||
const segment = await prisma.rawSegment.findUnique({ where: { id: segmentId } });
|
||||
const segment = await prisma.rawSegment.findUnique({
|
||||
where: { id: segmentId },
|
||||
include: { candidates: true },
|
||||
});
|
||||
if (!segment) return;
|
||||
|
||||
await deleteShortsForCandidates(segment.candidates.map((c) => c.id));
|
||||
await prisma.candidateSegment.deleteMany({ where: { rawSegmentId: segmentId } });
|
||||
await prisma.rawSegment.delete({ where: { id: segmentId } });
|
||||
await unlink(segment.filePath).catch(() => {});
|
||||
if (segment.filePath) await unlink(segment.filePath).catch(() => {});
|
||||
|
||||
revalidatePath("/segments");
|
||||
revalidatePath("/", "layout");
|
||||
}
|
||||
|
||||
export async function deleteCandidateSegment(candidateId: string) {
|
||||
await deleteShortsForCandidates([candidateId]);
|
||||
await prisma.candidateSegment.delete({ where: { id: candidateId } });
|
||||
|
||||
revalidatePath("/segments");
|
||||
@@ -43,11 +57,13 @@ export async function deleteCandidateSegment(candidateId: string) {
|
||||
export async function deleteChannel(channelId: string) {
|
||||
const sessions = await prisma.streamSession.findMany({
|
||||
where: { channelId },
|
||||
include: { segments: true },
|
||||
include: { segments: { include: { candidates: true } } },
|
||||
});
|
||||
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.rawSegment.deleteMany({ where: { sessionId: { in: sessions.map((s) => s.id) } } });
|
||||
await prisma.streamSession.deleteMany({ where: { channelId } });
|
||||
@@ -59,6 +75,140 @@ export async function deleteChannel(channelId: string) {
|
||||
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) {
|
||||
const value = String(formData.get("cookies") ?? "").trim();
|
||||
|
||||
@@ -74,3 +224,30 @@ export async function updateYtdlpCookies(formData: FormData) {
|
||||
|
||||
revalidatePath("/settings");
|
||||
}
|
||||
|
||||
export async function addCookieProfile(formData: FormData) {
|
||||
const label = String(formData.get("label") ?? "").trim();
|
||||
const value = String(formData.get("value") ?? "").trim();
|
||||
|
||||
if (!label || !value) {
|
||||
throw new Error("Hesap adı ve cookie içeriği zorunlu.");
|
||||
}
|
||||
|
||||
await prisma.cookieProfile.create({ data: { label, value } });
|
||||
revalidatePath("/settings");
|
||||
}
|
||||
|
||||
export async function updateCookieProfile(profileId: string, formData: FormData) {
|
||||
const value = String(formData.get("value") ?? "").trim();
|
||||
if (!value) {
|
||||
throw new Error("Cookie içeriği boş olamaz.");
|
||||
}
|
||||
|
||||
await prisma.cookieProfile.update({ where: { id: profileId }, data: { value } });
|
||||
revalidatePath("/settings");
|
||||
}
|
||||
|
||||
export async function deleteCookieProfile(profileId: string) {
|
||||
await prisma.cookieProfile.delete({ where: { id: profileId } });
|
||||
revalidatePath("/settings");
|
||||
}
|
||||
|
||||
@@ -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 { 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 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;
|
||||
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) },
|
||||
});
|
||||
return streamVideoFile(req, segment.filePath, `${id}.mp4`);
|
||||
}
|
||||
|
||||
@@ -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 { prisma } from "@streamclipper/db";
|
||||
import { fetchChannelStatuses } from "../../../lib/apiDaemon";
|
||||
import { deleteRawSegment, deleteCandidateSegment, deleteChannel } from "../../actions";
|
||||
import { DeleteButton } from "../../components/DeleteButton";
|
||||
import { formatTr } from "../../../lib/formatDate";
|
||||
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";
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
const STATUS_BADGE: Record<string, VariantProps<typeof badgeVariants>["variant"]> = {
|
||||
PENDING: "muted",
|
||||
PROCESSED: "ok",
|
||||
DISCARDED: "err",
|
||||
SENT_TO_TELEGRAM: "ok",
|
||||
PENDING_STT: "warn",
|
||||
TRANSCRIBED: "ok",
|
||||
RENDERING: "warn",
|
||||
READY: "ok",
|
||||
FAILED: "err",
|
||||
};
|
||||
|
||||
@@ -34,7 +56,7 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
|
||||
include: {
|
||||
segments: {
|
||||
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();
|
||||
|
||||
const status = statuses.get(channel.id);
|
||||
const activeSession = channel.sessions.find((s) => s.endedAt === null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p><Link href="/">← Kanal Durumu</Link></p>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<Link href="/" className="text-sm text-muted-foreground hover:text-foreground">
|
||||
← Kanal Durumu
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="card-head">
|
||||
<h1>{channel.name}</h1>
|
||||
<div className="flex items-center justify-between">
|
||||
<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)}>
|
||||
<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>
|
||||
</div>
|
||||
<p className="mono">{channel.youtubeHandle} · {channel.channelId}</p>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<span>Canlı Durum</span>
|
||||
<span className={`badge ${status?.recording ? "warn" : "muted"}`}>
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Canlı Durum</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={status?.recording ? "live" : "muted"}>
|
||||
{status?.recording ? "🔴 Kayıtta" : "Kayıtta değil"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mono">
|
||||
Aktif: {channel.isActive ? "evet" : "hayır"} ·{" "}
|
||||
Son kontrol: {channel.lastCheckedAt ? new Date(channel.lastCheckedAt).toLocaleString("tr-TR") : "—"}
|
||||
{status?.recording && activeSession && (
|
||||
<>
|
||||
{" · "}
|
||||
<SessionTimer startedAt={activeSession.startedAt.toISOString()} />
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
{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>
|
||||
|
||||
{status === undefined && (
|
||||
<p className="empty" style={{ marginTop: "0.5rem" }}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
api-daemon'dan canlı durum alınamadı (servis erişilemez olabilir).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status?.lastPollError && (
|
||||
<div style={{ marginTop: "0.5rem" }}>
|
||||
<span className="badge err">son poll hatası</span>
|
||||
<pre className="mono" style={{ whiteSpace: "pre-wrap", marginTop: "0.4rem" }}>
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
{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 ? (
|
||||
<p className="empty" style={{ marginTop: "0.5rem" }}>Henüz segment yok.</p>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Henüz segment yok.</p>
|
||||
</CardContent>
|
||||
) : (
|
||||
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>
|
||||
<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>
|
||||
|
||||
<video controls preload="metadata" style={{ width: "100%", maxWidth: "480px", marginTop: "0.5rem", borderRadius: "6px" }}>
|
||||
{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 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>
|
||||
<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)}>
|
||||
<DeleteButton confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz." />
|
||||
<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} style={{ marginTop: "0.5rem", paddingLeft: "0.75rem", borderLeft: "2px solid var(--border)" }}>
|
||||
<div className="card-head">
|
||||
<span className="mono">
|
||||
<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>
|
||||
<span className={`badge ${STATUS_BADGE[c.status] ?? "muted"}`}>{c.status}</span>
|
||||
<Badge variant={STATUS_BADGE[c.status] ?? "muted"}>{c.status}</Badge>
|
||||
</div>
|
||||
{transcriptPreview(c.transcriptJson) && (
|
||||
<p className="transcript-preview">{transcriptPreview(c.transcriptJson)}</p>
|
||||
<p className="mt-1.5 text-sm leading-relaxed">{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?" />
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
color-scheme: light dark;
|
||||
--bg: #0b0d12;
|
||||
--panel: #141822;
|
||||
--border: #262c3a;
|
||||
--text: #e6e9ef;
|
||||
--muted: #8b93a7;
|
||||
--accent: #4f8cff;
|
||||
--radius: 0.625rem;
|
||||
|
||||
--background: #0a0e15;
|
||||
--foreground: #e8ecf2;
|
||||
|
||||
--card: #121722;
|
||||
--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;
|
||||
--warn: #f2b84b;
|
||||
--err: #ef5b5b;
|
||||
--live: #ff5c5c;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@theme inline {
|
||||
--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 {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem 2rem;
|
||||
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;
|
||||
@layer utilities {
|
||||
.font-mono-num {
|
||||
font-family: var(--font-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Hanken_Grotesk, JetBrains_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { getSession } from "../lib/auth";
|
||||
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 = {
|
||||
title: "StreamClipper AI — Panel",
|
||||
@@ -12,27 +26,29 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
const session = await getSession();
|
||||
|
||||
return (
|
||||
<html lang="tr">
|
||||
<html lang="tr" className={`${hankenGrotesk.variable} ${jetbrainsMono.variable}`}>
|
||||
<body>
|
||||
{session && (
|
||||
<nav className="nav">
|
||||
<Link href="/">Kanal Durumu</Link>
|
||||
<Link href="/segments">Segment & Aday Kütüphanesi</Link>
|
||||
<Link href="/settings">Ayarlar</Link>
|
||||
<span style={{ marginLeft: "auto", display: "flex", gap: "1rem", alignItems: "center" }}>
|
||||
<span className="mono">{session.username}</span>
|
||||
<nav className="flex items-center gap-6 border-b border-border px-6 py-4">
|
||||
<span className="mr-2 text-sm font-semibold tracking-tight text-foreground">
|
||||
StreamClipper<span className="text-primary">.</span>
|
||||
</span>
|
||||
<NavLink href="/">Kanal Durumu</NavLink>
|
||||
<NavLink href="/segments">Segment & Aday Kütüphanesi</NavLink>
|
||||
<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}>
|
||||
<button
|
||||
type="submit"
|
||||
style={{ background: "none", border: "none", color: "var(--muted)", cursor: "pointer", font: "inherit" }}
|
||||
>
|
||||
<Button type="submit" variant="ghost" size="sm" className="text-muted-foreground">
|
||||
Çıkış Yap
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</span>
|
||||
</nav>
|
||||
)}
|
||||
<main>{children}</main>
|
||||
<main className="mx-auto max-w-4xl px-6 py-10">{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,71 +1,34 @@
|
||||
import { prisma } from "@streamclipper/db";
|
||||
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";
|
||||
|
||||
const fieldStyle = {
|
||||
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 }>;
|
||||
}) {
|
||||
export default async function LoginPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
|
||||
const { error } = await searchParams;
|
||||
const userCount = await prisma.user.count();
|
||||
const isBootstrap = userCount === 0;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: "360px", margin: "4rem auto" }}>
|
||||
<h1>{isBootstrap ? "İlk Yönetici Hesabını Oluştur" : "Giriş Yap"}</h1>
|
||||
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">{isBootstrap ? "İlk Yönetici Hesabını Oluştur" : "Giriş Yap"}</CardTitle>
|
||||
{isBootstrap && (
|
||||
<p className="empty" style={{ marginBottom: "1rem" }}>
|
||||
Henüz kullanıcı yok — panele erişecek ilk admin hesabını burada oluştur.
|
||||
</p>
|
||||
<CardDescription>Henüz kullanıcı yok — panele erişecek ilk admin hesabını burada oluştur.</CardDescription>
|
||||
)}
|
||||
|
||||
{!isBootstrap && error && (
|
||||
<p style={{ color: "var(--err)", fontSize: "0.9rem", marginBottom: "1rem" }}>
|
||||
Kullanıcı adı veya şifre hatalı.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form
|
||||
action={isBootstrap ? bootstrapAdmin : login}
|
||||
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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!isBootstrap && error && <p className="mb-3 text-sm text-destructive">Kullanıcı adı veya şifre hatalı.</p>}
|
||||
<form action={isBootstrap ? bootstrapAdmin : login} className="flex flex-col gap-3">
|
||||
<Input name="username" placeholder="Kullanıcı adı" required />
|
||||
<Input name="password" type="password" placeholder="Şifre" required minLength={isBootstrap ? 8 : undefined} />
|
||||
<Button type="submit">{isBootstrap ? "Hesabı Oluştur" : "Giriş Yap"}</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+87
-42
@@ -1,7 +1,13 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@streamclipper/db";
|
||||
import { addChannel } from "./actions";
|
||||
import { addChannel, toggleChannelActive, toggleAllChannels, checkChannelNow } from "./actions";
|
||||
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";
|
||||
|
||||
@@ -21,62 +27,101 @@ export default async function DashboardPage() {
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Kanal Durumu</h1>
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Kanal Durumu</h1>
|
||||
|
||||
<form action={addChannel} className="card add-channel-form">
|
||||
<input name="name" placeholder="Kanal adı" required />
|
||||
<input name="youtubeHandle" placeholder="@handle" required />
|
||||
<input name="channelId" placeholder="channel_id (UC...)" required />
|
||||
<button type="submit">Kanal Ekle</button>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Yeni Kanal Ekle</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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 ? (
|
||||
<p className="empty">Henüz kanal eklenmedi.</p>
|
||||
<p className="text-sm text-muted-foreground">Henüz kanal eklenmedi.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kanal</th>
|
||||
<th>Durum</th>
|
||||
<th>Kayıt</th>
|
||||
<th>Son Kontrol</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">{channels.length} kanal</span>
|
||||
<div className="flex gap-2">
|
||||
<form action={toggleAllChannels.bind(null, true)}>
|
||||
<Button type="submit" variant="outline" size="sm">
|
||||
Tümünü Devam Ettir
|
||||
</Button>
|
||||
</form>
|
||||
<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) => {
|
||||
const recording = c.sessions.length > 0;
|
||||
const status = statuses.get(c.id);
|
||||
return (
|
||||
<tr key={c.id}>
|
||||
<td>
|
||||
<Link href={`/channels/${c.id}`}>{c.name}</Link>{" "}
|
||||
<span className="mono">{c.youtubeHandle}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${c.isActive ? "ok" : "muted"}`}>
|
||||
<Card
|
||||
key={c.id}
|
||||
className={cn("gap-4 py-5", recording && "border-live/40")}
|
||||
>
|
||||
<CardHeader className="gap-1 px-5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<Link href={`/channels/${c.id}`} className="font-semibold leading-tight hover:text-primary hover:underline">
|
||||
{c.name}
|
||||
</Link>
|
||||
<Badge variant={c.isActive ? "ok" : "muted"} className="shrink-0">
|
||||
{c.isActive ? "Aktif" : "Pasif"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${recording ? "warn" : "muted"}`}>
|
||||
{recording ? "🔴 Kayıtta" : "—"}
|
||||
</span>
|
||||
</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 && (
|
||||
<span className="badge err" style={{ marginLeft: "0.4rem" }}>
|
||||
<Badge
|
||||
variant="err"
|
||||
title={`${formatTr(status.lastPollError.at)} — ${status.lastPollError.message}`}
|
||||
>
|
||||
hata
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="mono">
|
||||
{c.lastCheckedAt ? new Date(c.lastCheckedAt).toLocaleString("tr-TR") : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
<span className="font-mono-num text-xs text-muted-foreground">
|
||||
Son kontrol: {c.lastCheckedAt ? formatTr(c.lastCheckedAt) : "—"}
|
||||
</span>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="gap-2 px-5">
|
||||
<form action={toggleChannelActive.bind(null, c.id, !c.isActive)}>
|
||||
<Button type="submit" variant="outline" size="sm">
|
||||
{c.isActive ? "Duraklat" : "Devam Ettir"}
|
||||
</Button>
|
||||
</form>
|
||||
<form action={checkChannelNow.bind(null, c.id)}>
|
||||
<Button type="submit" variant="ghost" size="sm">
|
||||
Şimdi Kontrol Et
|
||||
</Button>
|
||||
</form>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { prisma } from "@streamclipper/db";
|
||||
import { deleteRawSegment, deleteCandidateSegment } from "../actions";
|
||||
import { DeleteButton } from "../components/DeleteButton";
|
||||
import { deleteRawSegment, deleteCandidateSegment, retryRender, cancelRender } from "../actions";
|
||||
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";
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
const STATUS_BADGE: Record<string, VariantProps<typeof badgeVariants>["variant"]> = {
|
||||
PENDING: "muted",
|
||||
PROCESSED: "ok",
|
||||
DISCARDED: "err",
|
||||
SENT_TO_TELEGRAM: "ok",
|
||||
PENDING_STT: "warn",
|
||||
TRANSCRIBED: "ok",
|
||||
RENDERING: "warn",
|
||||
READY: "ok",
|
||||
FAILED: "err",
|
||||
};
|
||||
|
||||
@@ -23,63 +31,131 @@ export default async function SegmentsPage() {
|
||||
const segments = await prisma.rawSegment.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
include: { candidates: { orderBy: { createdAt: "desc" } } },
|
||||
include: { candidates: { orderBy: { createdAt: "desc" }, include: { short: true } } },
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Segment & Aday Klip Kütüphanesi</h1>
|
||||
{segments.length === 0 ? (
|
||||
<p className="empty">Henüz işlenmiş segment yok.</p>
|
||||
) : (
|
||||
segments.map((segment) => (
|
||||
<div className="card" key={segment.id}>
|
||||
<div className="card-head">
|
||||
<span className="mono">{segment.filePath}</span>
|
||||
<span className={`badge ${STATUS_BADGE[segment.status] ?? "muted"}`}>{segment.status}</span>
|
||||
</div>
|
||||
<div className="mono">
|
||||
{segment.duration}sn · {new Date(segment.startedAt).toLocaleString("tr-TR")}
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Segment & Aday Klip Kütüphanesi</h1>
|
||||
|
||||
<video controls preload="metadata" style={{ width: "100%", maxWidth: "480px", marginTop: "0.5rem", borderRadius: "6px" }}>
|
||||
{segments.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Henüz işlenmiş segment yok.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{segments.map((segment) => (
|
||||
<Card key={segment.id}>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<div>
|
||||
<span className="font-mono-num text-xs text-muted-foreground">{segment.filePath}</span>
|
||||
<p className="font-mono-num text-xs text-muted-foreground">
|
||||
{segment.duration}sn · {formatTr(segment.startedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={STATUS_BADGE[segment.status] ?? "muted"}>{segment.status}</Badge>
|
||||
</CardHeader>
|
||||
<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 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>
|
||||
<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)}>
|
||||
<DeleteButton confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz." />
|
||||
<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.candidates.length === 0 ? (
|
||||
<p className="empty" style={{ marginTop: "0.5rem" }}>
|
||||
Bu segmentte aday klip bulunamadı.
|
||||
{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} style={{ marginTop: "0.75rem", paddingLeft: "0.75rem", borderLeft: "2px solid var(--border)" }}>
|
||||
<div className="card-head">
|
||||
<span className="mono">
|
||||
<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>
|
||||
<span className={`badge ${STATUS_BADGE[c.status] ?? "muted"}`}>{c.status}</span>
|
||||
<Badge variant={STATUS_BADGE[c.status] ?? "muted"}>{c.status}</Badge>
|
||||
</div>
|
||||
{transcriptPreview(c.transcriptJson) && (
|
||||
<p className="transcript-preview">{transcriptPreview(c.transcriptJson)}</p>
|
||||
<p className="mt-1.5 text-sm leading-relaxed">{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?" />
|
||||
|
||||
{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 { 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";
|
||||
|
||||
const STALE_COOKIE_HOURS = 4;
|
||||
|
||||
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 (
|
||||
<>
|
||||
<h1>Ayarlar</h1>
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Ayarlar</h1>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<span>YouTube Cookie'leri</span>
|
||||
<span className="mono">
|
||||
{setting ? `son güncelleme: ${setting.updatedAt.toLocaleString("tr-TR")}` : "hiç ayarlanmadı"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="empty" style={{ marginBottom: "0.5rem" }}>
|
||||
YouTube canlı yayın kontrolü ve kayıt için kullanılıyor. Google, oturum çerezlerinin bir
|
||||
kısmını birkaç saatte bir yeniliyor — burada eskidiğini fark edersen (kanal detay
|
||||
sayfasında "son poll hatası" olarak görürsün) taze bir cookies.txt ile
|
||||
güncelle. Ana hesabın yerine ayrı, önemsiz bir Google hesabı kullanman önerilir.
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Cookie Hesapları</CardTitle>
|
||||
<CardDescription>
|
||||
Her kanal, aşağıdaki hesaplardan birine sabit olarak atanır (aynı kanal hep aynı hesabı kullanır, kanallar
|
||||
havuza dağılır). Tek hesap çok kanalı taramaya çalışınca YouTube hesabın kendisini şüpheli bulup
|
||||
"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>
|
||||
<form action={updateYtdlpCookies}>
|
||||
<textarea
|
||||
) : (
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{cookieStale && cookieProfiles.length === 0 && (
|
||||
<Badge variant="warn" className="w-fit">
|
||||
⚠️ {Math.round(cookieAgeHours!)} saattir güncellenmedi — büyük/popüler kanallarda bot-check hatası
|
||||
görülebilir
|
||||
</Badge>
|
||||
)}
|
||||
<CardDescription>
|
||||
{cookieProfiles.length > 0
|
||||
? "Yukarıda en az bir hesap eklendiği için bu ayar artık kullanılmıyor — sadece geriye dönük referans için duruyor."
|
||||
: "Yukarıda hiç hesap eklenmediği sürece tüm kanallar bu tek cookie'yi paylaşır."}
|
||||
</CardDescription>
|
||||
<form action={updateYtdlpCookies} className="flex flex-col gap-3">
|
||||
<Textarea
|
||||
name="cookies"
|
||||
required
|
||||
placeholder="Netscape formatlı cookies.txt içeriğini buraya yapıştır"
|
||||
rows={10}
|
||||
style={{
|
||||
width: "100%",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "6px",
|
||||
padding: "0.6rem 0.7rem",
|
||||
color: "var(--text)",
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||
fontSize: "0.75rem",
|
||||
resize: "vertical",
|
||||
}}
|
||||
rows={8}
|
||||
className="font-mono-num text-xs"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
marginTop: "0.6rem",
|
||||
background: "var(--accent)",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
padding: "0.5rem 1rem",
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9rem",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Button type="submit" variant="outline" className="self-start">
|
||||
Kaydet
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Sistem Parametreleri</CardTitle>
|
||||
<CardDescription>Bir sonraki döngüden itibaren geçerli olur, redeploy gerekmez.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={updateSystemSettings} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="pollIntervalSec">Poll aralığı (saniye)</Label>
|
||||
<Input id="pollIntervalSec" name="pollIntervalSec" type="number" min={5} defaultValue={pollIntervalSec} className="max-w-40" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use server";
|
||||
|
||||
import bcrypt from "bcryptjs";
|
||||
import { prisma } from "@streamclipper/db";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { getSession } from "../../lib/auth";
|
||||
|
||||
export async function addUser(formData: FormData) {
|
||||
const username = String(formData.get("username") ?? "").trim();
|
||||
const password = String(formData.get("password") ?? "");
|
||||
|
||||
if (!username || password.length < 8) {
|
||||
throw new Error("Kullanıcı adı gerekli, şifre en az 8 karakter olmalı.");
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
await prisma.user.create({ data: { username, passwordHash } });
|
||||
|
||||
revalidatePath("/users");
|
||||
}
|
||||
|
||||
export async function deleteUser(userId: string) {
|
||||
const session = await getSession();
|
||||
if (session?.sub === userId) {
|
||||
throw new Error("Kendi hesabını silemezsin.");
|
||||
}
|
||||
|
||||
const totalUsers = await prisma.user.count();
|
||||
if (totalUsers <= 1) {
|
||||
throw new Error("Son kalan kullanıcı silinemez.");
|
||||
}
|
||||
|
||||
await prisma.user.delete({ where: { id: userId } });
|
||||
revalidatePath("/users");
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { prisma } from "@streamclipper/db";
|
||||
import { getSession } from "../../lib/auth";
|
||||
import { addUser, deleteUser } from "./actions";
|
||||
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 default async function UsersPage() {
|
||||
const [users, session] = await Promise.all([prisma.user.findMany({ orderBy: { createdAt: "asc" } }), getSession()]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Kullanıcılar</h1>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<form action={addUser} className="flex flex-wrap gap-2">
|
||||
<Input name="username" placeholder="Kullanıcı adı" required className="flex-1 min-w-[140px]" />
|
||||
<Input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Şifre (en az 8 karakter)"
|
||||
required
|
||||
minLength={8}
|
||||
className="flex-1 min-w-[160px]"
|
||||
/>
|
||||
<Button type="submit">Kullanıcı Ekle</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Kullanıcı Adı</TableHead>
|
||||
<TableHead>Oluşturulma</TableHead>
|
||||
<TableHead className="text-right"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell>
|
||||
{u.username}
|
||||
{u.id === session?.sub && (
|
||||
<Badge variant="muted" className="ml-2">
|
||||
sen
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono-num text-xs text-muted-foreground">
|
||||
{formatTr(u.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{u.id !== session?.sub && users.length > 1 && (
|
||||
<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;
|
||||
recording: boolean;
|
||||
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
|
||||
|
||||
@@ -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/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -9,11 +9,19 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@streamclipper/db": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jose": "^5.9.6",
|
||||
"lucide-react": "^1.38.0",
|
||||
"next": "^15.1.0",
|
||||
"radix-ui": "^1.6.7",
|
||||
"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": {
|
||||
"@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
|
||||
|
||||
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/*
|
||||
|
||||
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
|
||||
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
|
||||
CMD ["python", "-m", "worker.main"]
|
||||
|
||||
@@ -6,3 +6,5 @@ openai>=1.50.0
|
||||
httpx>=0.27.0
|
||||
chat-downloader>=0.2.7
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
pool = await get_pool()
|
||||
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]:
|
||||
pool = await get_pool()
|
||||
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 .signal_detection import start_signal_detection_worker
|
||||
from .stt_scoring import start_stt_scoring_worker
|
||||
from .video_render import start_video_render_worker
|
||||
|
||||
SESSION_WATCH_INTERVAL_SEC = 10
|
||||
|
||||
@@ -22,13 +23,15 @@ async def watch_sessions_for_chat_capture():
|
||||
async def main():
|
||||
signal_worker = start_signal_detection_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:
|
||||
await watch_sessions_for_chat_capture()
|
||||
finally:
|
||||
await signal_worker.close()
|
||||
await stt_worker.close()
|
||||
await render_worker.close()
|
||||
|
||||
|
||||
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
|
||||
# strictly one at a time and build up a backlog.
|
||||
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
|
||||
CANDIDATE_PAD_SEC = 45
|
||||
CHAT_SPIKE_MULTIPLIER = 3
|
||||
SEGMENT_TIME_SEC = int(os.environ.get("SEGMENT_TIME_SEC", "900"))
|
||||
SEGMENT_TIME_SEC = int(os.environ.get("SEGMENT_TIME_SEC", "180"))
|
||||
|
||||
stt_scoring_queue = Queue(QUEUE_NAMES["STT_SCORING"], {"connection": REDIS_URL})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from bullmq import Worker
|
||||
from bullmq import Queue, Worker
|
||||
from openai import OpenAI
|
||||
|
||||
from . import db
|
||||
@@ -11,6 +11,7 @@ from .queues import QUEUE_NAMES, REDIS_URL, WORKER_CONCURRENCY
|
||||
from .telegram import send_telegram_message
|
||||
|
||||
_openai_client: OpenAI | None = None
|
||||
video_render_queue = Queue(QUEUE_NAMES["VIDEO_RENDER"], {"connection": REDIS_URL})
|
||||
|
||||
|
||||
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")
|
||||
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:
|
||||
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}_"
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@ import httpx
|
||||
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
|
||||
|
||||
# Telegram's bot-upload limit (no local Bot API server) — checked before
|
||||
# attempting an upload so an oversized file fails fast instead of burning
|
||||
# time/bandwidth on a request Telegram will reject anyway.
|
||||
MAX_VIDEO_BYTES = 50 * 1024 * 1024
|
||||
|
||||
|
||||
async def send_telegram_message(text: str) -> None:
|
||||
if not BOT_TOKEN or not CHAT_ID:
|
||||
@@ -18,3 +23,35 @@ async def send_telegram_message(text: str) -> None:
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
print(f"[telegram] failed to send message: {resp.status_code} {resp.text}")
|
||||
|
||||
|
||||
async def send_telegram_video(video_path: str, caption: str) -> bool:
|
||||
"""Uploads the actual rendered clip to Telegram. Returns False (without
|
||||
raising) on any failure — the caller decides what that means for the
|
||||
local file (e.g. keep it if delivery didn't succeed)."""
|
||||
if not BOT_TOKEN or not CHAT_ID:
|
||||
print(f"[telegram] not configured, skipping video: {caption}")
|
||||
return False
|
||||
|
||||
size = os.path.getsize(video_path)
|
||||
if size > MAX_VIDEO_BYTES:
|
||||
print(f"[telegram] video {video_path} is {size} bytes, over Telegram's bot-upload limit — skipping")
|
||||
return False
|
||||
|
||||
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendVideo"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
with open(video_path, "rb") as f:
|
||||
resp = await client.post(
|
||||
url,
|
||||
data={"chat_id": CHAT_ID, "caption": caption, "parse_mode": "Markdown"},
|
||||
files={"video": (os.path.basename(video_path), f, "video/mp4")},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
print(f"[telegram] failed to upload video: {exc}")
|
||||
return False
|
||||
|
||||
if resp.status_code >= 400:
|
||||
print(f"[telegram] failed to send video: {resp.status_code} {resp.text}")
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
+6
-2
@@ -33,7 +33,7 @@ services:
|
||||
DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper
|
||||
SHARED_MEDIA_ROOT: /shared-media
|
||||
POLL_INTERVAL_MS: ${POLL_INTERVAL_MS:-60000}
|
||||
SEGMENT_TIME_SEC: ${SEGMENT_TIME_SEC:-900}
|
||||
SEGMENT_TIME_SEC: ${SEGMENT_TIME_SEC:-180}
|
||||
API_DAEMON_PORT: ${API_DAEMON_PORT:-4001}
|
||||
FORCE_LIVE_URL: ${FORCE_LIVE_URL:-}
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||
@@ -41,6 +41,8 @@ services:
|
||||
YTDLP_POT_PROVIDER_URL: http://sc_pot_provider:4416
|
||||
MAX_CONCURRENT_CAPTURES: ${MAX_CONCURRENT_CAPTURES:-10}
|
||||
PROXY_URL: ${PROXY_URL:-}
|
||||
RAW_SEGMENT_TTL_HOURS: ${RAW_SEGMENT_TTL_HOURS:-24}
|
||||
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-}
|
||||
volumes:
|
||||
- shared-media:/shared-media
|
||||
ports:
|
||||
@@ -59,11 +61,12 @@ services:
|
||||
REDIS_URL: redis://sc_redis:6379
|
||||
DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper
|
||||
SHARED_MEDIA_ROOT: /shared-media
|
||||
SEGMENT_TIME_SEC: ${SEGMENT_TIME_SEC:-900}
|
||||
SEGMENT_TIME_SEC: ${SEGMENT_TIME_SEC:-180}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-5}
|
||||
VIDEO_RENDER_CONCURRENCY: ${VIDEO_RENDER_CONCURRENCY:-2}
|
||||
volumes:
|
||||
- shared-media:/shared-media
|
||||
depends_on:
|
||||
@@ -80,6 +83,7 @@ services:
|
||||
API_DAEMON_URL: http://sc_api_daemon:4001
|
||||
SHARED_MEDIA_ROOT: /shared-media
|
||||
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
||||
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-}
|
||||
volumes:
|
||||
- shared-media:/shared-media
|
||||
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
|
||||
PROCESSED
|
||||
DISCARDED
|
||||
// Analiz zincirine (sinyal analizi/STT/9:16) hiç girmeden ham haliyle
|
||||
// doğrudan Telegram'a gönderildi — file_path bu durumda null (dosya
|
||||
// gönderim sonrası diskten silindi).
|
||||
SENT_TO_TELEGRAM
|
||||
}
|
||||
|
||||
enum CandidateSegmentStatus {
|
||||
@@ -19,6 +23,13 @@ enum CandidateSegmentStatus {
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum ShortVideoStatus {
|
||||
PENDING
|
||||
RENDERING
|
||||
READY
|
||||
FAILED
|
||||
}
|
||||
|
||||
model Channel {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
@@ -27,6 +38,8 @@ model Channel {
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
lastCheckedAt DateTime? @map("last_checked_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[]
|
||||
|
||||
@@ -51,7 +64,7 @@ model RawSegment {
|
||||
id String @id @default(cuid())
|
||||
sessionId String @map("session_id")
|
||||
session StreamSession @relation(fields: [sessionId], references: [id])
|
||||
filePath String @map("file_path")
|
||||
filePath String? @map("file_path")
|
||||
duration Int
|
||||
startedAt DateTime @map("started_at")
|
||||
status RawSegmentStatus @default(PENDING)
|
||||
@@ -74,9 +87,39 @@ model CandidateSegment {
|
||||
status CandidateSegmentStatus @default(PENDING_STT)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
short ShortVideo?
|
||||
|
||||
@@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 {
|
||||
key String @id
|
||||
value String
|
||||
|
||||
Generated
+2080
-2
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user