Files
screenclipper/apps/frontend/app/channels/[id]/page.tsx
T
ayrisdevandClaude Sonnet 5 3e01116729 feat: kanal başına segment süresini panelden ayarlama
Channel.segmentTimeSec (nullable, null = global SEGMENT_TIME_SEC env
var'ı kullan) eklendi. Kanal detay sayfasından dakika cinsinden
girilebiliyor, StreamIngestJob'a taşınıp streamIngest.ts'te ffmpeg'in
-segment_time argümanına yansıyor. Sadece o kanal için bundan sonra
başlayacak yeni kayıt oturumlarını etkiler — halihazırda çalışan bir
ffmpeg process'inin segment süresi zaten sabitlenmiş durumda.

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

311 lines
14 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,
updateChannelSegmentTime,
} from "../../actions";
import { ConfirmButton } from "../../components/ConfirmButton";
import { LiveLogViewer } from "../../components/LiveLogViewer";
import { SessionTimer } from "../../components/SessionTimer";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge, type badgeVariants } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import 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>
<form
action={updateChannelSegmentTime.bind(null, channel.id)}
className="flex flex-wrap items-end gap-2"
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="segmentTimeMin" className="text-xs text-muted-foreground">
Bu kanal için segment süresi (dakika)
</Label>
<Input
id="segmentTimeMin"
name="segmentTimeMin"
type="number"
min={1}
max={60}
step={0.5}
placeholder="varsayılan (15)"
defaultValue={channel.segmentTimeSec ? channel.segmentTimeSec / 60 : ""}
className="w-40"
/>
</div>
<Button type="submit" variant="outline" size="sm">
Kaydet
</Button>
<span className="text-xs text-muted-foreground">
Boş bırakılırsa global varsayılan kullanılır. Sadece bundan sonra başlayacak kayıtları etkiler.
</span>
</form>
{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>
);
}