feat: interaktif panel kontrolleri (Faz 3)
Yayın durdurma, kanal duraklat/devam (tekli+toplu), şimdi kontrol et, manuel URL ile zorla kayıt başlatma, canlı player+log önizleme, oturum süre sayacı, başarısız/takılı render için tekrar dene ve iptal, poll aralığı + ham segment TTL'yi panelden canlı değiştirme, cookie bayatlık uyarısı, Telegram bildirim aç/kapa, ve kullanım/maliyet özeti (/stats). api-daemon'ın yeni state-değiştiren endpoint'leri (stop/force-start/ render/cancel) INTERNAL_API_TOKEN ile korunuyor — bu daemon'ın portu Coolify'de public'e açık olduğu için korumasız bırakmak güvenlik açığı olurdu. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -55,3 +55,9 @@ RAW_SEGMENT_TTL_HOURS=24
|
|||||||
# işinden çok daha ağır — düşük tutulur ki aynı anda birden fazla render
|
# işinden çok daha ağır — düşük tutulur ki aynı anda birden fazla render
|
||||||
# worker container'ını boğmasın.
|
# worker container'ını boğmasın.
|
||||||
VIDEO_RENDER_CONCURRENCY=2
|
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,4 +1,4 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import { mkdir, readFile } from "node:fs/promises";
|
import { mkdir, readFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { Worker, type Job } from "bullmq";
|
import { Worker, type Job } from "bullmq";
|
||||||
@@ -17,6 +17,24 @@ const SEGMENT_LIST_POLL_MS = 5_000;
|
|||||||
*/
|
*/
|
||||||
export const lastCaptureDebug = new Map<string, string[]>();
|
export const lastCaptureDebug = new Map<string, string[]>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live yt-dlp processes, keyed by sessionId — lets a panel "stop" action
|
||||||
|
* reach a specific in-progress capture. Only yt-dlp is tracked/killed:
|
||||||
|
* ffmpeg is downstream of it in the pipe, so ending yt-dlp closes its stdin
|
||||||
|
* with EOF and ffmpeg finishes the same way it does on a natural stream end
|
||||||
|
* (flushes the last partial segment, exits, existing finalize code runs
|
||||||
|
* unchanged).
|
||||||
|
*/
|
||||||
|
const activeCaptures = new Map<string, ChildProcess>();
|
||||||
|
|
||||||
|
/** Returns false if the session has no tracked live process (already ended, or this daemon didn't start it — e.g. after a restart). */
|
||||||
|
export function stopCapture(sessionId: string): boolean {
|
||||||
|
const ytdlp = activeCaptures.get(sessionId);
|
||||||
|
if (!ytdlp) return false;
|
||||||
|
ytdlp.kill("SIGTERM");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function appendCaptureDebug(sessionId: string, line: string) {
|
function appendCaptureDebug(sessionId: string, line: string) {
|
||||||
const lines = lastCaptureDebug.get(sessionId) ?? [];
|
const lines = lastCaptureDebug.get(sessionId) ?? [];
|
||||||
lines.push(line);
|
lines.push(line);
|
||||||
@@ -95,6 +113,7 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
|
|||||||
],
|
],
|
||||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||||
);
|
);
|
||||||
|
activeCaptures.set(sessionId, ytdlp);
|
||||||
|
|
||||||
const ffmpeg = spawn(
|
const ffmpeg = spawn(
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
@@ -153,6 +172,7 @@ async function runCapture(job: Job<StreamIngestJob>): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
clearInterval(pollTimer);
|
clearInterval(pollTimer);
|
||||||
|
activeCaptures.delete(sessionId);
|
||||||
|
|
||||||
// final sweep in case segments closed between the last poll and process exit
|
// final sweep in case segments closed between the last poll and process exit
|
||||||
const raw = await readFile(segmentListPath, "utf8").catch(() => "");
|
const raw = await readFile(segmentListPath, "utf8").catch(() => "");
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { prisma } from "@streamclipper/db";
|
|||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
|
|
||||||
const CHECK_INTERVAL_MS = 60 * 60 * 1000; // hourly
|
const CHECK_INTERVAL_MS = 60 * 60 * 1000; // hourly
|
||||||
|
const TTL_SETTING_KEY = "raw_segment_ttl_hours";
|
||||||
|
|
||||||
|
async function getTtlHours(): Promise<number> {
|
||||||
|
const setting = await prisma.appSetting.findUnique({ where: { key: TTL_SETTING_KEY } });
|
||||||
|
const parsed = setting ? Number(setting.value) : NaN;
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : env.rawSegmentTtlHours;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PRD's auto-purge lifecycle: processed raw recordings get deleted 24h after
|
* PRD's auto-purge lifecycle: processed raw recordings get deleted 24h after
|
||||||
@@ -11,7 +18,8 @@ const CHECK_INTERVAL_MS = 60 * 60 * 1000; // hourly
|
|||||||
* file mid-analysis or mid-transcription would break the pipeline for it.
|
* file mid-analysis or mid-transcription would break the pipeline for it.
|
||||||
*/
|
*/
|
||||||
async function cleanupOnce(): Promise<void> {
|
async function cleanupOnce(): Promise<void> {
|
||||||
const cutoff = new Date(Date.now() - env.rawSegmentTtlHours * 60 * 60 * 1000);
|
const ttlHours = await getTtlHours();
|
||||||
|
const cutoff = new Date(Date.now() - ttlHours * 60 * 60 * 1000);
|
||||||
|
|
||||||
const staleSegments = await prisma.rawSegment.findMany({
|
const staleSegments = await prisma.rawSegment.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -28,12 +36,12 @@ async function cleanupOnce(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (staleSegments.length > 0) {
|
if (staleSegments.length > 0) {
|
||||||
console.log(`[cleanup] removed ${staleSegments.length} raw segment(s) older than ${env.rawSegmentTtlHours}h`);
|
console.log(`[cleanup] removed ${staleSegments.length} raw segment(s) older than ${ttlHours}h`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startCleanupLoop(): NodeJS.Timeout {
|
export function startCleanupLoop(): NodeJS.Timeout {
|
||||||
console.log(`[cleanup] checking every ${CHECK_INTERVAL_MS}ms, TTL ${env.rawSegmentTtlHours}h`);
|
console.log(`[cleanup] checking every ${CHECK_INTERVAL_MS}ms, default TTL ${env.rawSegmentTtlHours}h`);
|
||||||
cleanupOnce().catch((err) => console.error("[cleanup] initial run failed:", err));
|
cleanupOnce().catch((err) => console.error("[cleanup] initial run failed:", err));
|
||||||
return setInterval(() => {
|
return setInterval(() => {
|
||||||
cleanupOnce().catch((err) => console.error("[cleanup] run failed:", err));
|
cleanupOnce().catch((err) => console.error("[cleanup] run failed:", err));
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ export const env = {
|
|||||||
proxyUrl: process.env.PROXY_URL ?? "",
|
proxyUrl: process.env.PROXY_URL ?? "",
|
||||||
maxConcurrentCaptures: Number(process.env.MAX_CONCURRENT_CAPTURES ?? 10),
|
maxConcurrentCaptures: Number(process.env.MAX_CONCURRENT_CAPTURES ?? 10),
|
||||||
rawSegmentTtlHours: Number(process.env.RAW_SEGMENT_TTL_HOURS ?? 24),
|
rawSegmentTtlHours: Number(process.env.RAW_SEGMENT_TTL_HOURS ?? 24),
|
||||||
|
internalApiToken: process.env.INTERNAL_API_TOKEN ?? "",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,3 +34,13 @@ export const streamIngestQueue = new Queue<StreamIngestJob>(QUEUE_NAMES.STREAM_I
|
|||||||
export const signalDetectionQueue = new Queue<SignalDetectionJob>(QUEUE_NAMES.SIGNAL_DETECTION, {
|
export const signalDetectionQueue = new Queue<SignalDetectionJob>(QUEUE_NAMES.SIGNAL_DETECTION, {
|
||||||
connection: redisConnection,
|
connection: redisConnection,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export interface VideoRenderJob {
|
||||||
|
candidateSegmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consumed by the Python worker (apps/worker/worker/video_render.py) — job
|
||||||
|
// name/payload shape must match what stt_scoring.py already enqueues.
|
||||||
|
export const videoRenderQueue = new Queue<VideoRenderJob>(QUEUE_NAMES.VIDEO_RENDER, {
|
||||||
|
connection: redisConnection,
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import { prisma } from "@streamclipper/db";
|
import { prisma } from "@streamclipper/db";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
import { lastPollErrors } from "./youtubePolling";
|
import { lastPollErrors, checkChannel, startCaptureSession } from "./youtubePolling";
|
||||||
import { lastCaptureDebug } from "./capture/streamIngest";
|
import { lastCaptureDebug, stopCapture } from "./capture/streamIngest";
|
||||||
|
import { videoRenderQueue } from "./queues";
|
||||||
|
|
||||||
export function startServer() {
|
export function startServer() {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
app.get("/health", (_req, res) => res.json({ ok: true }));
|
app.get("/health", (_req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
@@ -37,6 +39,70 @@ export function startServer() {
|
|||||||
res.json({ lines: lastCaptureDebug.get(req.params.sessionId) ?? [] });
|
res.json({ lines: lastCaptureDebug.get(req.params.sessionId) ?? [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These endpoints change state (stop a capture, start one against an
|
||||||
|
* arbitrary URL, re-run a render) — unlike /health and /status they must
|
||||||
|
* not be reachable by anyone who finds this daemon's public sslip.io URL.
|
||||||
|
* If INTERNAL_API_TOKEN isn't set (local dev), they stay open — matches
|
||||||
|
* SESSION_SECRET's existing "insecure default, fine for local" pattern.
|
||||||
|
*/
|
||||||
|
function requireInternalToken(req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||||
|
if (!env.internalApiToken) return next();
|
||||||
|
const header = req.header("authorization") ?? "";
|
||||||
|
if (header === `Bearer ${env.internalApiToken}`) return next();
|
||||||
|
res.status(401).json({ error: "unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post("/sessions/:sessionId/stop", requireInternalToken, (req, res) => {
|
||||||
|
const stopped = stopCapture(req.params.sessionId);
|
||||||
|
res.json({ stopped });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/channels/:channelId/check-now", requireInternalToken, async (req, res) => {
|
||||||
|
const channel = await prisma.channel.findUnique({ where: { id: req.params.channelId } });
|
||||||
|
if (!channel) return res.status(404).json({ error: "channel not found" });
|
||||||
|
const result = await checkChannel(channel);
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/channels/:channelId/force-start", requireInternalToken, async (req, res) => {
|
||||||
|
const channel = await prisma.channel.findUnique({ where: { id: req.params.channelId } });
|
||||||
|
if (!channel) return res.status(404).json({ error: "channel not found" });
|
||||||
|
const youtubeUrl = String(req.body?.url ?? "").trim();
|
||||||
|
if (!youtubeUrl) return res.status(400).json({ error: "url is required" });
|
||||||
|
const result = await startCaptureSession(channel, youtubeUrl);
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/shorts/:candidateId/render", requireInternalToken, async (req, res) => {
|
||||||
|
const candidateId = req.params.candidateId;
|
||||||
|
const candidate = await prisma.candidateSegment.findUnique({ where: { id: candidateId } });
|
||||||
|
if (!candidate) return res.status(404).json({ error: "candidate not found" });
|
||||||
|
|
||||||
|
await prisma.shortVideo.upsert({
|
||||||
|
where: { candidateId },
|
||||||
|
create: { candidateId, status: "PENDING" },
|
||||||
|
update: { status: "PENDING", errorMessage: null },
|
||||||
|
});
|
||||||
|
await videoRenderQueue.add("render-short", { candidateSegmentId: candidateId });
|
||||||
|
res.json({ queued: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/shorts/:candidateId/cancel", requireInternalToken, async (req, res) => {
|
||||||
|
const candidateId = req.params.candidateId;
|
||||||
|
const short = await prisma.shortVideo.findUnique({ where: { candidateId } });
|
||||||
|
if (!short) return res.status(404).json({ error: "short not found" });
|
||||||
|
|
||||||
|
// Doesn't kill the in-flight Python render process — just clears the
|
||||||
|
// stuck DB state so the panel can offer a retry instead of showing
|
||||||
|
// "RENDERING" forever if a worker died mid-job.
|
||||||
|
await prisma.shortVideo.update({
|
||||||
|
where: { candidateId },
|
||||||
|
data: { status: "FAILED", errorMessage: "Manuel olarak iptal edildi." },
|
||||||
|
});
|
||||||
|
res.json({ cancelled: true });
|
||||||
|
});
|
||||||
|
|
||||||
app.listen(env.port, () => {
|
app.listen(env.port, () => {
|
||||||
console.log(`[server] api-daemon listening on :${env.port}`);
|
console.log(`[server] api-daemon listening on :${env.port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,11 +69,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 },
|
||||||
|
youtubeUrl: string,
|
||||||
|
liveVideoId: string | null = null,
|
||||||
|
): Promise<{ started: boolean }> {
|
||||||
const existing = await prisma.streamSession.findFirst({
|
const existing = await prisma.streamSession.findFirst({
|
||||||
where: { channelId: channel.id, endedAt: null },
|
where: { channelId: channel.id, endedAt: null },
|
||||||
});
|
});
|
||||||
if (existing) return;
|
if (existing) return { started: false };
|
||||||
|
|
||||||
const session = await prisma.streamSession.create({
|
const session = await prisma.streamSession.create({
|
||||||
data: { channelId: channel.id, liveVideoId },
|
data: { channelId: channel.id, liveVideoId },
|
||||||
@@ -82,18 +93,22 @@ async function startSession(channel: { id: string; name: string }, liveVideoId:
|
|||||||
await streamIngestQueue.add("start-capture", {
|
await streamIngestQueue.add("start-capture", {
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
channelDbId: channel.id,
|
channelDbId: channel.id,
|
||||||
youtubeUrl: `https://www.youtube.com/watch?v=${liveVideoId}`,
|
youtubeUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const notifySetting = await prisma.appSetting.findUnique({ where: { key: "notify_stream_start" } });
|
||||||
|
if (notifySetting?.value !== "false") {
|
||||||
await sendTelegramMessage(`🔴 *${channel.name}* canlıya geçti, kayıt başlatılıyor.`);
|
await sendTelegramMessage(`🔴 *${channel.name}* canlıya geçti, kayıt başlatılıyor.`);
|
||||||
|
}
|
||||||
console.log(`[youtube-polling] started session ${session.id} for channel ${channel.name}`);
|
console.log(`[youtube-polling] started session ${session.id} for channel ${channel.name}`);
|
||||||
|
return { started: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pollOnce(): Promise<void> {
|
/** 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. */
|
||||||
const channels = await prisma.channel.findMany({ where: { isActive: true } });
|
export async function checkChannel(channel: { id: string; name: string; channelId: string }): Promise<{
|
||||||
|
liveVideoId: string | null;
|
||||||
for (const channel of channels) {
|
error: string | null;
|
||||||
try {
|
}> {
|
||||||
const liveVideoId = await findLiveVideoId(channel.channelId);
|
const liveVideoId = await findLiveVideoId(channel.channelId);
|
||||||
await prisma.channel.update({
|
await prisma.channel.update({
|
||||||
where: { id: channel.id },
|
where: { id: channel.id },
|
||||||
@@ -101,18 +116,50 @@ export async function pollOnce(): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (liveVideoId) {
|
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 } });
|
||||||
|
|
||||||
|
for (const channel of channels) {
|
||||||
|
try {
|
||||||
|
await checkChannel(channel);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[youtube-polling] error polling channel ${channel.name}:`, err);
|
console.error(`[youtube-polling] error polling channel ${channel.name}:`, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startPollingLoop(): NodeJS.Timeout {
|
const POLL_INTERVAL_SETTING_KEY = "poll_interval_ms";
|
||||||
console.log(`[youtube-polling] polling every ${env.pollIntervalMs}ms`);
|
|
||||||
pollOnce().catch((err) => console.error("[youtube-polling] initial poll failed:", err));
|
async function getPollIntervalMs(): Promise<number> {
|
||||||
return setInterval(() => {
|
const setting = await prisma.appSetting.findUnique({ where: { key: POLL_INTERVAL_SETTING_KEY } });
|
||||||
pollOnce().catch((err) => console.error("[youtube-polling] poll failed:", err));
|
const parsed = setting ? Number(setting.value) : NaN;
|
||||||
}, env.pollIntervalMs);
|
return Number.isFinite(parsed) && parsed >= 5_000 ? parsed : env.pollIntervalMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-rescheduling instead of setInterval so a panel-side change to the
|
||||||
|
* poll_interval_ms AppSetting (read fresh on every iteration, same pattern
|
||||||
|
* as ytdlpAntiBotArgs()'s cookies) takes effect on the very next cycle
|
||||||
|
* without a redeploy.
|
||||||
|
*/
|
||||||
|
export function startPollingLoop(): void {
|
||||||
|
let stopped = false;
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
if (stopped) return;
|
||||||
|
await pollOnce().catch((err) => console.error("[youtube-polling] poll failed:", err));
|
||||||
|
const intervalMs = await getPollIntervalMs().catch(() => env.pollIntervalMs);
|
||||||
|
if (!stopped) setTimeout(tick, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPollIntervalMs()
|
||||||
|
.then((ms) => console.log(`[youtube-polling] polling every ${ms}ms`))
|
||||||
|
.catch(() => {});
|
||||||
|
tick();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { unlink } from "node:fs/promises";
|
|||||||
import { prisma } from "@streamclipper/db";
|
import { prisma } from "@streamclipper/db";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { postToApiDaemon } from "../lib/apiDaemon";
|
||||||
|
|
||||||
export async function addChannel(formData: FormData) {
|
export async function addChannel(formData: FormData) {
|
||||||
const name = String(formData.get("name") ?? "").trim();
|
const name = String(formData.get("name") ?? "").trim();
|
||||||
@@ -59,6 +60,85 @@ export async function deleteChannel(channelId: string) {
|
|||||||
redirect("/");
|
redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function toggleChannelActive(channelId: string, nextActive: boolean) {
|
||||||
|
await prisma.channel.update({ where: { id: channelId }, data: { isActive: nextActive } });
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function 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 updateNotificationPrefs(formData: FormData) {
|
||||||
|
const streamStart = formData.get("notifyStreamStart") === "on";
|
||||||
|
const renderDone = formData.get("notifyRenderDone") === "on";
|
||||||
|
|
||||||
|
await prisma.appSetting.upsert({
|
||||||
|
where: { key: "notify_stream_start" },
|
||||||
|
create: { key: "notify_stream_start", value: String(streamStart) },
|
||||||
|
update: { value: String(streamStart) },
|
||||||
|
});
|
||||||
|
await prisma.appSetting.upsert({
|
||||||
|
where: { key: "notify_render_done" },
|
||||||
|
create: { key: "notify_render_done", value: String(renderDone) },
|
||||||
|
update: { value: String(renderDone) },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/settings");
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateYtdlpCookies(formData: FormData) {
|
export async function updateYtdlpCookies(formData: FormData) {
|
||||||
const value = String(formData.get("cookies") ?? "").trim();
|
const value = String(formData.get("cookies") ?? "").trim();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -2,8 +2,19 @@ import { notFound } from "next/navigation";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { prisma } from "@streamclipper/db";
|
import { prisma } from "@streamclipper/db";
|
||||||
import { fetchChannelStatuses } from "../../../lib/apiDaemon";
|
import { fetchChannelStatuses } from "../../../lib/apiDaemon";
|
||||||
import { deleteRawSegment, deleteCandidateSegment, deleteChannel } from "../../actions";
|
import {
|
||||||
|
deleteRawSegment,
|
||||||
|
deleteCandidateSegment,
|
||||||
|
deleteChannel,
|
||||||
|
stopSession,
|
||||||
|
forceStartCapture,
|
||||||
|
retryRender,
|
||||||
|
cancelRender,
|
||||||
|
} from "../../actions";
|
||||||
import { DeleteButton } from "../../components/DeleteButton";
|
import { DeleteButton } from "../../components/DeleteButton";
|
||||||
|
import { ConfirmButton } from "../../components/ConfirmButton";
|
||||||
|
import { LiveLogViewer } from "../../components/LiveLogViewer";
|
||||||
|
import { SessionTimer } from "../../components/SessionTimer";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -48,6 +59,7 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
|
|||||||
if (!channel) notFound();
|
if (!channel) notFound();
|
||||||
|
|
||||||
const status = statuses.get(channel.id);
|
const status = statuses.get(channel.id);
|
||||||
|
const activeSession = channel.sessions.find((s) => s.endedAt === null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -64,8 +76,21 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<span>Canlı Durum</span>
|
<span>Canlı Durum</span>
|
||||||
|
<span style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
|
||||||
<span className={`badge ${status?.recording ? "warn" : "muted"}`}>
|
<span className={`badge ${status?.recording ? "warn" : "muted"}`}>
|
||||||
{status?.recording ? "🔴 Kayıtta" : "Kayıtta değil"}
|
{status?.recording ? "🔴 Kayıtta" : "Kayıtta değil"}
|
||||||
|
{status?.recording && activeSession && (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
<SessionTimer startedAt={activeSession.startedAt.toISOString()} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{status?.recording && activeSession && (
|
||||||
|
<form action={stopSession.bind(null, activeSession.id)}>
|
||||||
|
<ConfirmButton confirmText="Bu kaydı şimdi durdurmak istediğine emin misin?" label="Durdur" />
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mono">
|
<div className="mono">
|
||||||
@@ -85,6 +110,27 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
|
|||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{status?.recording && activeSession?.liveVideoId && activeSession.liveVideoId !== "forced" && (
|
||||||
|
<iframe
|
||||||
|
src={`https://www.youtube.com/embed/${activeSession.liveVideoId}`}
|
||||||
|
title="Canlı yayın önizleme"
|
||||||
|
style={{ width: "100%", maxWidth: "480px", aspectRatio: "16/9", marginTop: "0.6rem", border: "none", borderRadius: "6px" }}
|
||||||
|
allow="autoplay; encrypted-media"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{status?.recording && activeSession && <LiveLogViewer sessionId={activeSession.id} />}
|
||||||
|
|
||||||
|
<details style={{ marginTop: "0.75rem" }}>
|
||||||
|
<summary className="mono" style={{ cursor: "pointer", fontSize: "0.8rem" }}>
|
||||||
|
Manuel URL ile kayıt başlat
|
||||||
|
</summary>
|
||||||
|
<form action={forceStartCapture.bind(null, channel.id)} style={{ display: "flex", gap: "0.4rem", marginTop: "0.5rem" }}>
|
||||||
|
<input name="url" placeholder="https://www.youtube.com/watch?v=..." required style={{ flex: 1 }} />
|
||||||
|
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}>
|
||||||
|
Başlat
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 style={{ marginTop: "2rem" }}>Yayın Geçmişi</h1>
|
<h1 style={{ marginTop: "2rem" }}>Yayın Geçmişi</h1>
|
||||||
@@ -153,8 +199,31 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
|
|||||||
{c.short.status === "FAILED" && c.short.errorMessage && (
|
{c.short.status === "FAILED" && c.short.errorMessage && (
|
||||||
<p className="mono" style={{ fontSize: "0.75rem", marginTop: "0.3rem" }}>{c.short.errorMessage}</p>
|
<p className="mono" style={{ fontSize: "0.75rem", marginTop: "0.3rem" }}>{c.short.errorMessage}</p>
|
||||||
)}
|
)}
|
||||||
|
{c.short.status === "FAILED" && (
|
||||||
|
<form action={retryRender.bind(null, c.id)} style={{ marginTop: "0.3rem" }}>
|
||||||
|
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}>
|
||||||
|
Tekrar Dene
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{c.short.status === "RENDERING" && (
|
||||||
|
<form action={cancelRender.bind(null, c.id)} style={{ marginTop: "0.3rem" }}>
|
||||||
|
<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="muted"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!c.short && c.status === "TRANSCRIBED" && (
|
||||||
|
<form action={retryRender.bind(null, c.id)} style={{ marginTop: "0.5rem" }}>
|
||||||
|
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}>
|
||||||
|
9:16 Render Et
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
<form action={deleteCandidateSegment.bind(null, c.id)} style={{ marginTop: "0.35rem" }}>
|
<form action={deleteCandidateSegment.bind(null, c.id)} style={{ marginTop: "0.35rem" }}>
|
||||||
<DeleteButton confirmText="Bu aday klibi silmek istediğine emin misin?" />
|
<DeleteButton confirmText="Bu aday klibi silmek istediğine emin misin?" />
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
export function ConfirmButton({
|
||||||
|
confirmText,
|
||||||
|
label,
|
||||||
|
variant = "err",
|
||||||
|
}: {
|
||||||
|
confirmText: string;
|
||||||
|
label: string;
|
||||||
|
variant?: "err" | "warn" | "muted" | "ok";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`badge ${variant}`}
|
||||||
|
style={{ border: "none", cursor: "pointer" }}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!confirm(confirmText)) e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"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="mono"
|
||||||
|
style={{
|
||||||
|
maxHeight: "220px",
|
||||||
|
overflowY: "auto",
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "0.6rem 0.7rem",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lines.length > 0 ? lines.join("\n") : "Log bekleniyor…"}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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="mono">{formatElapsed(now - new Date(startedAt).getTime())}</span>;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
<nav className="nav">
|
<nav className="nav">
|
||||||
<Link href="/">Kanal Durumu</Link>
|
<Link href="/">Kanal Durumu</Link>
|
||||||
<Link href="/segments">Segment & Aday Kütüphanesi</Link>
|
<Link href="/segments">Segment & Aday Kütüphanesi</Link>
|
||||||
|
<Link href="/stats">İstatistik</Link>
|
||||||
<Link href="/settings">Ayarlar</Link>
|
<Link href="/settings">Ayarlar</Link>
|
||||||
<Link href="/users">Kullanıcılar</Link>
|
<Link href="/users">Kullanıcılar</Link>
|
||||||
<span style={{ marginLeft: "auto", display: "flex", gap: "1rem", alignItems: "center" }}>
|
<span style={{ marginLeft: "auto", display: "flex", gap: "1rem", alignItems: "center" }}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { prisma } from "@streamclipper/db";
|
import { prisma } from "@streamclipper/db";
|
||||||
import { addChannel } from "./actions";
|
import { addChannel, toggleChannelActive, toggleAllChannels, checkChannelNow } from "./actions";
|
||||||
import { fetchChannelStatuses } from "../lib/apiDaemon";
|
import { fetchChannelStatuses } from "../lib/apiDaemon";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -34,6 +34,19 @@ export default async function DashboardPage() {
|
|||||||
{channels.length === 0 ? (
|
{channels.length === 0 ? (
|
||||||
<p className="empty">Henüz kanal eklenmedi.</p>
|
<p className="empty">Henüz kanal eklenmedi.</p>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.6rem" }}>
|
||||||
|
<form action={toggleAllChannels.bind(null, true)}>
|
||||||
|
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}>
|
||||||
|
Tümünü Devam Ettir
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form action={toggleAllChannels.bind(null, false)}>
|
||||||
|
<button type="submit" className="badge muted" style={{ border: "none", cursor: "pointer" }}>
|
||||||
|
Tümünü Duraklat
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -41,6 +54,7 @@ export default async function DashboardPage() {
|
|||||||
<th>Durum</th>
|
<th>Durum</th>
|
||||||
<th>Kayıt</th>
|
<th>Kayıt</th>
|
||||||
<th>Son Kontrol</th>
|
<th>Son Kontrol</th>
|
||||||
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -71,11 +85,24 @@ export default async function DashboardPage() {
|
|||||||
<td className="mono">
|
<td className="mono">
|
||||||
{c.lastCheckedAt ? new Date(c.lastCheckedAt).toLocaleString("tr-TR") : "—"}
|
{c.lastCheckedAt ? new Date(c.lastCheckedAt).toLocaleString("tr-TR") : "—"}
|
||||||
</td>
|
</td>
|
||||||
|
<td style={{ display: "flex", gap: "0.35rem" }}>
|
||||||
|
<form action={toggleChannelActive.bind(null, c.id, !c.isActive)}>
|
||||||
|
<button type="submit" className="badge muted" style={{ border: "none", cursor: "pointer" }}>
|
||||||
|
{c.isActive ? "Duraklat" : "Devam Ettir"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form action={checkChannelNow.bind(null, c.id)}>
|
||||||
|
<button type="submit" className="badge muted" style={{ border: "none", cursor: "pointer" }}>
|
||||||
|
Şimdi Kontrol Et
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { prisma } from "@streamclipper/db";
|
import { prisma } from "@streamclipper/db";
|
||||||
import { deleteRawSegment, deleteCandidateSegment } from "../actions";
|
import { deleteRawSegment, deleteCandidateSegment, retryRender, cancelRender } from "../actions";
|
||||||
import { DeleteButton } from "../components/DeleteButton";
|
import { DeleteButton } from "../components/DeleteButton";
|
||||||
|
import { ConfirmButton } from "../components/ConfirmButton";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -92,8 +93,31 @@ export default async function SegmentsPage() {
|
|||||||
{c.short.status === "FAILED" && c.short.errorMessage && (
|
{c.short.status === "FAILED" && c.short.errorMessage && (
|
||||||
<p className="mono" style={{ fontSize: "0.75rem", marginTop: "0.3rem" }}>{c.short.errorMessage}</p>
|
<p className="mono" style={{ fontSize: "0.75rem", marginTop: "0.3rem" }}>{c.short.errorMessage}</p>
|
||||||
)}
|
)}
|
||||||
|
{c.short.status === "FAILED" && (
|
||||||
|
<form action={retryRender.bind(null, c.id)} style={{ marginTop: "0.3rem" }}>
|
||||||
|
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}>
|
||||||
|
Tekrar Dene
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{c.short.status === "RENDERING" && (
|
||||||
|
<form action={cancelRender.bind(null, c.id)} style={{ marginTop: "0.3rem" }}>
|
||||||
|
<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="muted"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!c.short && c.status === "TRANSCRIBED" && (
|
||||||
|
<form action={retryRender.bind(null, c.id)} style={{ marginTop: "0.5rem" }}>
|
||||||
|
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}>
|
||||||
|
9:16 Render Et
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
<form action={deleteCandidateSegment.bind(null, c.id)} style={{ marginTop: "0.35rem" }}>
|
<form action={deleteCandidateSegment.bind(null, c.id)} style={{ marginTop: "0.35rem" }}>
|
||||||
<DeleteButton confirmText="Bu aday klibi silmek istediğine emin misin?" />
|
<DeleteButton confirmText="Bu aday klibi silmek istediğine emin misin?" />
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
import { prisma } from "@streamclipper/db";
|
import { prisma } from "@streamclipper/db";
|
||||||
import { updateYtdlpCookies } from "../actions";
|
import { updateYtdlpCookies, updateSystemSettings, updateNotificationPrefs } from "../actions";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const STALE_COOKIE_HOURS = 4;
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const setting = await prisma.appSetting.findUnique({ where: { key: "ytdlp_cookies" } });
|
const [setting, pollSetting, ttlSetting, notifyStart, notifyRender] = 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" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -17,6 +32,14 @@ export default async function SettingsPage() {
|
|||||||
{setting ? `son güncelleme: ${setting.updatedAt.toLocaleString("tr-TR")}` : "hiç ayarlanmadı"}
|
{setting ? `son güncelleme: ${setting.updatedAt.toLocaleString("tr-TR")}` : "hiç ayarlanmadı"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{cookieStale && (
|
||||||
|
<p style={{ marginBottom: "0.5rem" }}>
|
||||||
|
<span className="badge warn">
|
||||||
|
⚠️ {Math.round(cookieAgeHours!)} saattir güncellenmedi — büyük/popüler kanallarda bot-check
|
||||||
|
hatası görülebilir, taze bir cookies.txt ile güncelle
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="empty" style={{ marginBottom: "0.5rem" }}>
|
<p className="empty" style={{ marginBottom: "0.5rem" }}>
|
||||||
YouTube canlı yayın kontrolü ve kayıt için kullanılıyor. Google, oturum çerezlerinin bir
|
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
|
kısmını birkaç saatte bir yeniliyor — burada eskidiğini fark edersen (kanal detay
|
||||||
@@ -59,6 +82,79 @@ export default async function SettingsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-head">
|
||||||
|
<span>Sistem Parametreleri</span>
|
||||||
|
</div>
|
||||||
|
<p className="empty" style={{ marginBottom: "0.5rem" }}>
|
||||||
|
Bir sonraki döngüden itibaren geçerli olur, redeploy gerekmez.
|
||||||
|
</p>
|
||||||
|
<form action={updateSystemSettings} style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}>
|
||||||
|
<label className="mono" style={{ fontSize: "0.8rem" }}>
|
||||||
|
Poll aralığı (saniye)
|
||||||
|
<input name="pollIntervalSec" type="number" min={5} defaultValue={pollIntervalSec} style={{ display: "block", marginTop: "0.25rem" }} />
|
||||||
|
</label>
|
||||||
|
<label className="mono" style={{ fontSize: "0.8rem" }}>
|
||||||
|
Ham segment TTL (saat)
|
||||||
|
<input name="ttlHours" type="number" min={1} defaultValue={ttlHours} style={{ display: "block", marginTop: "0.25rem" }} />
|
||||||
|
</label>
|
||||||
|
<p className="empty" style={{ fontSize: "0.75rem", margin: 0 }}>
|
||||||
|
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 `MAX_CONCURRENT_CAPTURES` /
|
||||||
|
`VIDEO_RENDER_CONCURRENCY` env var'larını güncelleyip redeploy etmek gerekiyor.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={{
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
background: "var(--accent)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Kaydet
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-head">
|
||||||
|
<span>Bildirimler</span>
|
||||||
|
</div>
|
||||||
|
<form action={updateNotificationPrefs} style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||||
|
<label className="mono" style={{ fontSize: "0.85rem", display: "flex", alignItems: "center", gap: "0.4rem" }}>
|
||||||
|
<input type="checkbox" name="notifyStreamStart" defaultChecked={notifyStreamStart} />
|
||||||
|
Yayın başladığında Telegram bildirimi
|
||||||
|
</label>
|
||||||
|
<label className="mono" style={{ fontSize: "0.85rem", display: "flex", alignItems: "center", gap: "0.4rem" }}>
|
||||||
|
<input type="checkbox" name="notifyRenderDone" defaultChecked={notifyRenderDone} />
|
||||||
|
9:16 render tamamlandığında Telegram bildirimi
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={{
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
marginTop: "0.3rem",
|
||||||
|
background: "var(--accent)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Kaydet
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { prisma } from "@streamclipper/db";
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<h1>Kullanım & Maliyet</h1>
|
||||||
|
<p className="empty" style={{ marginBottom: "1rem" }}>
|
||||||
|
Şimdiye kadarki toplam kullanım — tüm zamanlar. STT maliyeti ve disk kullanımı tahmini
|
||||||
|
(bkz. maliyet analizi), gerçek faturayla küçük farklar olabilir.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="card" style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: "1rem" }}>
|
||||||
|
<div>
|
||||||
|
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>TOPLAM KAYIT SÜRESİ</div>
|
||||||
|
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{fmt(captureHours)} sa</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>TOPLAM STT SÜRESİ</div>
|
||||||
|
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{fmt(sttMinutes)} dk</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>TAHMİNİ WHISPER MALİYETİ</div>
|
||||||
|
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>${fmt(sttCost, 2)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>TAHMİNİ DİSK KULLANIMI</div>
|
||||||
|
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{fmt(estimatedGb)} GB</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>ÜRETİLEN 9:16 KLİP</div>
|
||||||
|
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{readyShorts}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>BAŞARISIZ RENDER</div>
|
||||||
|
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{failedShorts}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>TOPLAM ADAY KLİP</div>
|
||||||
|
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{totalCandidates}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,25 @@ export interface ChannelStatus {
|
|||||||
lastPollError: string | null;
|
lastPollError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_DAEMON_URL = process.env.API_DAEMON_URL ?? "http://localhost:4001";
|
export const API_DAEMON_URL = process.env.API_DAEMON_URL ?? "http://localhost:4001";
|
||||||
|
|
||||||
|
/** POSTs to a state-changing api-daemon endpoint with the shared internal auth header (see INTERNAL_API_TOKEN in .env.example). */
|
||||||
|
export async function postToApiDaemon<T = unknown>(path: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${API_DAEMON_URL}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
...(process.env.INTERNAL_API_TOKEN ? { authorization: `Bearer ${process.env.INTERNAL_API_TOKEN}` } : {}),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`api-daemon ${path} failed (${res.status}): ${text.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* lastPollError only exists in api-daemon's in-memory map (not persisted to
|
* lastPollError only exists in api-daemon's in-memory map (not persisted to
|
||||||
|
|||||||
Vendored
+1
@@ -1,5 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ async def get_pool() -> asyncpg.Pool:
|
|||||||
return _pool
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
|
async def get_setting(key: str, default: str | None = None) -> str | None:
|
||||||
|
"""Same app_settings table the Node side reads for cookies/poll-interval — settings authored from the panel are shared across both languages."""
|
||||||
|
pool = await get_pool()
|
||||||
|
row = await pool.fetchrow("SELECT value FROM app_settings WHERE key = $1", key)
|
||||||
|
return row["value"] if row else default
|
||||||
|
|
||||||
|
|
||||||
async def get_raw_segment(raw_segment_id: str) -> asyncpg.Record | None:
|
async def get_raw_segment(raw_segment_id: str) -> asyncpg.Record | None:
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
return await pool.fetchrow(
|
return await pool.fetchrow(
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ async def process_video_render(job, job_token=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
await db.update_short_video(short_id, status="READY", file_path=out_path)
|
await db.update_short_video(short_id, status="READY", file_path=out_path)
|
||||||
|
if await db.get_setting("notify_render_done", "true") != "false":
|
||||||
await send_telegram_message(f"🎬 *{candidate['channel_name']}* için 9:16 kısa video hazır!")
|
await send_telegram_message(f"🎬 *{candidate['channel_name']}* için 9:16 kısa video hazır!")
|
||||||
print(f"[video-render] short {short_id} ready: {out_path}")
|
print(f"[video-render] short {short_id} ready: {out_path}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ services:
|
|||||||
MAX_CONCURRENT_CAPTURES: ${MAX_CONCURRENT_CAPTURES:-10}
|
MAX_CONCURRENT_CAPTURES: ${MAX_CONCURRENT_CAPTURES:-10}
|
||||||
PROXY_URL: ${PROXY_URL:-}
|
PROXY_URL: ${PROXY_URL:-}
|
||||||
RAW_SEGMENT_TTL_HOURS: ${RAW_SEGMENT_TTL_HOURS:-24}
|
RAW_SEGMENT_TTL_HOURS: ${RAW_SEGMENT_TTL_HOURS:-24}
|
||||||
|
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-}
|
||||||
volumes:
|
volumes:
|
||||||
- shared-media:/shared-media
|
- shared-media:/shared-media
|
||||||
ports:
|
ports:
|
||||||
@@ -82,6 +83,7 @@ services:
|
|||||||
API_DAEMON_URL: http://sc_api_daemon:4001
|
API_DAEMON_URL: http://sc_api_daemon:4001
|
||||||
SHARED_MEDIA_ROOT: /shared-media
|
SHARED_MEDIA_ROOT: /shared-media
|
||||||
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
||||||
|
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-}
|
||||||
volumes:
|
volumes:
|
||||||
- shared-media:/shared-media
|
- shared-media:/shared-media
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
Reference in New Issue
Block a user