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>
242 lines
11 KiB
TypeScript
242 lines
11 KiB
TypeScript
import { notFound } from "next/navigation";
|
||
import Link from "next/link";
|
||
import { prisma } from "@streamclipper/db";
|
||
import { fetchChannelStatuses } from "../../../lib/apiDaemon";
|
||
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";
|
||
|
||
const STATUS_BADGE: Record<string, string> = {
|
||
PENDING: "muted",
|
||
PROCESSED: "ok",
|
||
DISCARDED: "err",
|
||
PENDING_STT: "warn",
|
||
TRANSCRIBED: "ok",
|
||
RENDERING: "warn",
|
||
READY: "ok",
|
||
FAILED: "err",
|
||
};
|
||
|
||
function transcriptPreview(transcriptJson: unknown): string | null {
|
||
if (!transcriptJson || typeof transcriptJson !== "object") return null;
|
||
const text = (transcriptJson as { text?: string }).text;
|
||
return text ? text.slice(0, 240) : null;
|
||
}
|
||
|
||
export default async function ChannelDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||
const { id } = await params;
|
||
|
||
const [channel, statuses] = await Promise.all([
|
||
prisma.channel.findUnique({
|
||
where: { id },
|
||
include: {
|
||
sessions: {
|
||
orderBy: { startedAt: "desc" },
|
||
include: {
|
||
segments: {
|
||
orderBy: { createdAt: "desc" },
|
||
include: { candidates: { orderBy: { createdAt: "desc" }, include: { short: true } } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}),
|
||
fetchChannelStatuses(),
|
||
]);
|
||
|
||
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="card-head">
|
||
<h1>{channel.name}</h1>
|
||
<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" />
|
||
</form>
|
||
</div>
|
||
<p className="mono">{channel.youtubeHandle} · {channel.channelId}</p>
|
||
|
||
<div className="card">
|
||
<div className="card-head">
|
||
<span>Canlı Durum</span>
|
||
<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">
|
||
Aktif: {channel.isActive ? "evet" : "hayır"} ·{" "}
|
||
Son kontrol: {channel.lastCheckedAt ? new Date(channel.lastCheckedAt).toLocaleString("tr-TR") : "—"}
|
||
</div>
|
||
{status === undefined && (
|
||
<p className="empty" style={{ marginTop: "0.5rem" }}>
|
||
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}
|
||
</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>
|
||
{channel.sessions.length === 0 ? (
|
||
<p className="empty">Bu kanal için henüz bir kayıt oturumu yok.</p>
|
||
) : (
|
||
channel.sessions.map((session) => (
|
||
<div className="card" key={session.id}>
|
||
<div className="card-head">
|
||
<span className="mono">
|
||
{new Date(session.startedAt).toLocaleString("tr-TR")}
|
||
{session.endedAt ? ` – ${new Date(session.endedAt).toLocaleString("tr-TR")}` : " (devam ediyor)"}
|
||
</span>
|
||
<span className="badge muted">{session.totalSegments} segment</span>
|
||
</div>
|
||
|
||
{session.segments.length === 0 ? (
|
||
<p className="empty" style={{ marginTop: "0.5rem" }}>Henüz segment yok.</p>
|
||
) : (
|
||
session.segments.map((segment) => (
|
||
<div key={segment.id} style={{ marginTop: "0.75rem", paddingLeft: "0.75rem", borderLeft: "2px solid var(--border)" }}>
|
||
<div className="card-head">
|
||
<span className="mono">{segment.filePath}</span>
|
||
<span className={`badge ${STATUS_BADGE[segment.status] ?? "muted"}`}>{segment.status}</span>
|
||
</div>
|
||
|
||
<video controls preload="metadata" style={{ width: "100%", maxWidth: "480px", marginTop: "0.5rem", borderRadius: "6px" }}>
|
||
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
|
||
</video>
|
||
|
||
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.5rem" }}>
|
||
<a className="badge muted" href={`/api/segments/${segment.id}/video?download=1`}>İndir</a>
|
||
<form action={deleteRawSegment.bind(null, segment.id)}>
|
||
<DeleteButton confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz." />
|
||
</form>
|
||
</div>
|
||
|
||
{segment.candidates.map((c) => (
|
||
<div key={c.id} style={{ marginTop: "0.5rem", paddingLeft: "0.75rem", borderLeft: "2px solid var(--border)" }}>
|
||
<div className="card-head">
|
||
<span className="mono">
|
||
{c.startSec}s – {c.endSec}s
|
||
{c.audioPeakScore != null && ` · peak ${c.audioPeakScore.toFixed(1)}dB`}
|
||
</span>
|
||
<span className={`badge ${STATUS_BADGE[c.status] ?? "muted"}`}>{c.status}</span>
|
||
</div>
|
||
{transcriptPreview(c.transcriptJson) && (
|
||
<p className="transcript-preview">{transcriptPreview(c.transcriptJson)}</p>
|
||
)}
|
||
|
||
{c.short && (
|
||
<div style={{ marginTop: "0.5rem" }}>
|
||
<span className={`badge ${STATUS_BADGE[c.short.status] ?? "muted"}`}>
|
||
9:16: {c.short.status}
|
||
</span>
|
||
{c.short.status === "READY" && (
|
||
<>
|
||
<video controls preload="metadata" style={{ width: "220px", marginTop: "0.4rem", borderRadius: "6px", display: "block" }}>
|
||
<source src={`/api/shorts/${c.short.id}/video`} type="video/mp4" />
|
||
</video>
|
||
<a className="badge muted" style={{ marginTop: "0.3rem", display: "inline-block" }} href={`/api/shorts/${c.short.id}/video?download=1`}>
|
||
İndir
|
||
</a>
|
||
</>
|
||
)}
|
||
{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?" />
|
||
</form>
|
||
</div>
|
||
))}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
))
|
||
)}
|
||
</>
|
||
);
|
||
}
|