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:
@@ -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();
|
||||
@@ -59,6 +60,85 @@ 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 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) {
|
||||
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 { prisma } from "@streamclipper/db";
|
||||
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 { ConfirmButton } from "../../components/ConfirmButton";
|
||||
import { LiveLogViewer } from "../../components/LiveLogViewer";
|
||||
import { SessionTimer } from "../../components/SessionTimer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -48,6 +59,7 @@ 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 (
|
||||
<>
|
||||
@@ -64,8 +76,21 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<span>Canlı Durum</span>
|
||||
<span className={`badge ${status?.recording ? "warn" : "muted"}`}>
|
||||
{status?.recording ? "🔴 Kayıtta" : "Kayıtta değil"}
|
||||
<span style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
|
||||
<span className={`badge ${status?.recording ? "warn" : "muted"}`}>
|
||||
{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>
|
||||
</div>
|
||||
<div className="mono">
|
||||
@@ -85,6 +110,27 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
|
||||
</pre>
|
||||
</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>
|
||||
|
||||
<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 && (
|
||||
<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>
|
||||
)}
|
||||
{!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" }}>
|
||||
<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">
|
||||
<Link href="/">Kanal Durumu</Link>
|
||||
<Link href="/segments">Segment & Aday Kütüphanesi</Link>
|
||||
<Link href="/stats">İstatistik</Link>
|
||||
<Link href="/settings">Ayarlar</Link>
|
||||
<Link href="/users">Kullanıcılar</Link>
|
||||
<span style={{ marginLeft: "auto", display: "flex", gap: "1rem", alignItems: "center" }}>
|
||||
|
||||
+69
-42
@@ -1,6 +1,6 @@
|
||||
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";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -34,48 +34,75 @@ export default async function DashboardPage() {
|
||||
{channels.length === 0 ? (
|
||||
<p className="empty">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>
|
||||
{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"}`}>
|
||||
{c.isActive ? "Aktif" : "Pasif"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${recording ? "warn" : "muted"}`}>
|
||||
{recording ? "🔴 Kayıtta" : "—"}
|
||||
</span>
|
||||
{status?.lastPollError && (
|
||||
<span className="badge err" style={{ marginLeft: "0.4rem" }}>
|
||||
hata
|
||||
<>
|
||||
<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>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kanal</th>
|
||||
<th>Durum</th>
|
||||
<th>Kayıt</th>
|
||||
<th>Son Kontrol</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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"}`}>
|
||||
{c.isActive ? "Aktif" : "Pasif"}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="mono">
|
||||
{c.lastCheckedAt ? new Date(c.lastCheckedAt).toLocaleString("tr-TR") : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${recording ? "warn" : "muted"}`}>
|
||||
{recording ? "🔴 Kayıtta" : "—"}
|
||||
</span>
|
||||
{status?.lastPollError && (
|
||||
<span className="badge err" style={{ marginLeft: "0.4rem" }}>
|
||||
hata
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="mono">
|
||||
{c.lastCheckedAt ? new Date(c.lastCheckedAt).toLocaleString("tr-TR") : "—"}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prisma } from "@streamclipper/db";
|
||||
import { deleteRawSegment, deleteCandidateSegment } from "../actions";
|
||||
import { deleteRawSegment, deleteCandidateSegment, retryRender, cancelRender } from "../actions";
|
||||
import { DeleteButton } from "../components/DeleteButton";
|
||||
import { ConfirmButton } from "../components/ConfirmButton";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -92,8 +93,31 @@ export default async function SegmentsPage() {
|
||||
{c.short.status === "FAILED" && c.short.errorMessage && (
|
||||
<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>
|
||||
)}
|
||||
{!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" }}>
|
||||
<DeleteButton confirmText="Bu aday klibi silmek istediğine emin misin?" />
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import { prisma } from "@streamclipper/db";
|
||||
import { updateYtdlpCookies } from "../actions";
|
||||
import { updateYtdlpCookies, updateSystemSettings, updateNotificationPrefs } from "../actions";
|
||||
|
||||
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] = 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 (
|
||||
<>
|
||||
@@ -17,6 +32,14 @@ export default async function SettingsPage() {
|
||||
{setting ? `son güncelleme: ${setting.updatedAt.toLocaleString("tr-TR")}` : "hiç ayarlanmadı"}
|
||||
</span>
|
||||
</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" }}>
|
||||
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
|
||||
@@ -59,6 +82,79 @@ export default async function SettingsPage() {
|
||||
</button>
|
||||
</form>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user