Files
screenclipper/apps/frontend/app/channels/[id]/page.tsx
T
ayrisdevandClaude Sonnet 5 cbb16697ca fix: poll hatasına zaman damgası ekle + STT'yi panelden kapatılabilir yap
- lastPollErrors artık {message, at} tutuyor — panelde hangi hatanın ne
  zaman oluştuğu görünmüyordu, eski/güncel ayrımı yapılamıyordu.
- "This live event will begin in N minutes" gibi zararsız mesajlar da
  hata rozetine düşüyordu, "is not currently live" ile aynı gruba alındı.
- Yeni stt_enabled ayarı (Ayarlar sayfası): kapatılınca stt_scoring.py
  OpenAI Whisper çağrısı yapmadan adayı PENDING_STT'de bırakıp çıkıyor —
  konuşmasız/ambiyans yayınlarda (bkz. NASA testi) boşa API maliyeti
  önleniyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 14:52:15 +03:00

281 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { ConfirmButton } from "../../components/ConfirmButton";
import { LiveLogViewer } from "../../components/LiveLogViewer";
import { SessionTimer } from "../../components/SessionTimer";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge, type badgeVariants } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { VariantProps } from "class-variance-authority";
export const dynamic = "force-dynamic";
const STATUS_BADGE: Record<string, VariantProps<typeof badgeVariants>["variant"]> = {
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 (
<div className="flex flex-col gap-6">
<div>
<Link href="/" className="text-sm text-muted-foreground hover:text-foreground">
Kanal Durumu
</Link>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{channel.name}</h1>
<p className="font-mono-num text-xs text-muted-foreground">
{channel.youtubeHandle} · {channel.channelId}
</p>
</div>
<form action={deleteChannel.bind(null, channel.id)}>
<ConfirmButton
confirmText="Bu kanalı ve tüm kayıt geçmişini silmek istediğine emin misin? Geri alınamaz."
label="Kanalı Sil"
/>
</form>
</div>
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="text-sm font-medium text-muted-foreground">Canlı Durum</CardTitle>
<div className="flex items-center gap-2">
<Badge variant={status?.recording ? "live" : "muted"}>
{status?.recording ? "🔴 Kayıtta" : "Kayıtta değil"}
{status?.recording && activeSession && (
<>
{" · "}
<SessionTimer startedAt={activeSession.startedAt.toISOString()} />
</>
)}
</Badge>
{status?.recording && activeSession && (
<form action={stopSession.bind(null, activeSession.id)}>
<ConfirmButton confirmText="Bu kaydı şimdi durdurmak istediğine emin misin?" label="Durdur" />
</form>
)}
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="font-mono-num text-xs text-muted-foreground">
Aktif: {channel.isActive ? "evet" : "hayır"} · Son kontrol:{" "}
{channel.lastCheckedAt ? new Date(channel.lastCheckedAt).toLocaleString("tr-TR") : "—"}
</p>
{status === undefined && (
<p className="text-sm text-muted-foreground">
api-daemon&apos;dan canlı durum alınamadı (servis erişilemez olabilir).
</p>
)}
{status?.lastPollError && (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<Badge variant="err" className="w-fit">
son poll hatası
</Badge>
<span className="font-mono-num text-xs text-muted-foreground">
{new Date(status.lastPollError.at).toLocaleString("tr-TR")}
</span>
</div>
<pre className="font-mono-num whitespace-pre-wrap rounded-md border border-border bg-background/60 p-3 text-[0.7rem] text-muted-foreground">
{status.lastPollError.message}
</pre>
</div>
)}
{status?.recording && activeSession?.liveVideoId && activeSession.liveVideoId !== "forced" && (
<iframe
src={`https://www.youtube.com/embed/${activeSession.liveVideoId}`}
title="Canlı yayın önizleme"
className="aspect-video w-full max-w-md rounded-md border border-border"
allow="autoplay; encrypted-media"
/>
)}
{status?.recording && activeSession && <LiveLogViewer sessionId={activeSession.id} />}
<details className="mt-1 text-sm">
<summary className="cursor-pointer font-mono-num text-xs text-muted-foreground">
Manuel URL ile kayıt başlat
</summary>
<form action={forceStartCapture.bind(null, channel.id)} className="mt-2 flex gap-2">
<Input name="url" placeholder="https://www.youtube.com/watch?v=..." required className="flex-1" />
<Button type="submit">Başlat</Button>
</form>
</details>
</CardContent>
</Card>
<div>
<h2 className="mb-3 text-lg font-semibold tracking-tight">Yayın Geçmişi</h2>
{channel.sessions.length === 0 ? (
<p className="text-sm text-muted-foreground">Bu kanal için henüz bir kayıt oturumu yok.</p>
) : (
<div className="flex flex-col gap-4">
{channel.sessions.map((session) => (
<Card key={session.id}>
<CardHeader className="flex-row items-center justify-between">
<span className="font-mono-num text-xs text-muted-foreground">
{new Date(session.startedAt).toLocaleString("tr-TR")}
{session.endedAt ? ` ${new Date(session.endedAt).toLocaleString("tr-TR")}` : " (devam ediyor)"}
</span>
<Badge variant="muted">{session.totalSegments} segment</Badge>
</CardHeader>
{session.segments.length === 0 ? (
<CardContent>
<p className="text-sm text-muted-foreground">Henüz segment yok.</p>
</CardContent>
) : (
<CardContent className="flex flex-col gap-4">
{session.segments.map((segment) => (
<div key={segment.id} className="border-l-2 border-border pl-4">
<div className="flex items-center justify-between gap-2">
<span className="font-mono-num text-xs text-muted-foreground">{segment.filePath}</span>
<Badge variant={STATUS_BADGE[segment.status] ?? "muted"}>{segment.status}</Badge>
</div>
<video controls preload="metadata" className="mt-2 w-full max-w-md rounded-md border border-border">
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
</video>
<div className="mt-2 flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<a href={`/api/segments/${segment.id}/video?download=1`}>İndir</a>
</Button>
<form action={deleteRawSegment.bind(null, segment.id)}>
<ConfirmButton
confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz."
label="Sil"
/>
</form>
</div>
{segment.candidates.map((c) => (
<div key={c.id} className="mt-3 border-l-2 border-border pl-4">
<div className="flex items-center justify-between gap-2">
<span className="font-mono-num text-xs text-muted-foreground">
{c.startSec}s {c.endSec}s
{c.audioPeakScore != null && ` · peak ${c.audioPeakScore.toFixed(1)}dB`}
</span>
<Badge variant={STATUS_BADGE[c.status] ?? "muted"}>{c.status}</Badge>
</div>
{transcriptPreview(c.transcriptJson) && (
<p className="mt-1.5 text-sm leading-relaxed">{transcriptPreview(c.transcriptJson)}</p>
)}
{c.short && (
<div className="mt-2 flex flex-col items-start gap-2">
<Badge variant={STATUS_BADGE[c.short.status] ?? "muted"}>9:16: {c.short.status}</Badge>
{c.short.status === "READY" && (
<>
<video controls preload="metadata" className="w-[220px] rounded-md border border-border">
<source src={`/api/shorts/${c.short.id}/video`} type="video/mp4" />
</video>
<Button asChild variant="outline" size="sm">
<a href={`/api/shorts/${c.short.id}/video?download=1`}>İndir</a>
</Button>
</>
)}
{c.short.status === "FAILED" && (
<>
{c.short.errorMessage && (
<p className="font-mono-num text-xs text-muted-foreground">{c.short.errorMessage}</p>
)}
<form action={retryRender.bind(null, c.id)}>
<Button type="submit" size="sm">
Tekrar Dene
</Button>
</form>
</>
)}
{c.short.status === "RENDERING" && (
<form action={cancelRender.bind(null, c.id)}>
<ConfirmButton
confirmText="Render'ı başarısız say ve kilidi aç? (Çalışan process'i öldürmez, sadece durumu sıfırlar)"
label="Takıldıysa İptal Et"
variant="outline"
/>
</form>
)}
</div>
)}
{!c.short && c.status === "TRANSCRIBED" && (
<form action={retryRender.bind(null, c.id)} className="mt-2">
<Button type="submit" size="sm">
9:16 Render Et
</Button>
</form>
)}
<form action={deleteCandidateSegment.bind(null, c.id)} className="mt-2">
<ConfirmButton confirmText="Bu aday klibi silmek istediğine emin misin?" label="Sil" />
</form>
</div>
))}
</div>
))}
</CardContent>
)}
</Card>
))}
</div>
)}
</div>
</div>
);
}