StreamClipper AI Faz 1: ingestion + sinyal analizi + STT + altyapı iskeleti

yt-dlp headless capture (15dk segmentleme), ses peak + chat velocity sinyal
tespiti, OpenAI Whisper STT (kelime zaman damgalı), BullMQ/Redis/Postgres
altyapısı ve kanal durumu + transkript kütüphanesi gösteren Next.js panel.
LLM virality skorlama, render/crop/altyazı ve multi-platform dağıtım bu
fazın kapsamı dışında.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 14:36:50 +03:00
co-authored by Claude Sonnet 5
commit eccc74166a
44 changed files with 3897 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
:root {
color-scheme: light dark;
--bg: #0b0d12;
--panel: #141822;
--border: #262c3a;
--text: #e6e9ef;
--muted: #8b93a7;
--accent: #4f8cff;
--ok: #37c976;
--warn: #f2b84b;
--err: #ef5b5b;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.nav {
display: flex;
gap: 1.5rem;
padding: 1rem 2rem;
border-bottom: 1px solid var(--border);
}
.nav a {
color: var(--muted);
text-decoration: none;
font-weight: 600;
}
.nav a.active {
color: var(--text);
}
main {
max-width: 960px;
margin: 0 auto;
padding: 2rem;
}
h1 {
font-size: 1.4rem;
margin-bottom: 1.5rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 0.6rem 0.8rem;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
}
th {
color: var(--muted);
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.03em;
}
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
}
.badge.ok { background: rgba(55,201,118,0.15); color: var(--ok); }
.badge.muted { background: rgba(139,147,167,0.15); color: var(--muted); }
.badge.warn { background: rgba(242,184,75,0.15); color: var(--warn); }
.badge.err { background: rgba(239,91,91,0.15); color: var(--err); }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
.card-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8rem;
color: var(--muted);
}
.transcript-preview {
margin-top: 0.5rem;
font-size: 0.85rem;
color: var(--text);
line-height: 1.4;
}
.empty {
color: var(--muted);
font-size: 0.9rem;
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import Link from "next/link";
import "./globals.css";
export const metadata: Metadata = {
title: "StreamClipper AI — Panel",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="tr">
<body>
<nav className="nav">
<Link href="/">Kanal Durumu</Link>
<Link href="/segments">Segment & Aday Kütüphanesi</Link>
</nav>
<main>{children}</main>
</body>
</html>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { prisma } from "@streamclipper/db";
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
const channels = await prisma.channel.findMany({
orderBy: { name: "asc" },
include: {
sessions: {
where: { endedAt: null },
orderBy: { startedAt: "desc" },
take: 1,
},
},
});
return (
<>
<h1>Kanal Durumu</h1>
{channels.length === 0 ? (
<p className="empty">
Henüz kanal eklenmedi. `channels` tablosuna bir kayıt ekleyin (Prisma Studio veya seed script).
</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;
return (
<tr key={c.id}>
<td>{c.name} <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>
</td>
<td className="mono">
{c.lastCheckedAt ? new Date(c.lastCheckedAt).toLocaleString("tr-TR") : "—"}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { prisma } from "@streamclipper/db";
export const dynamic = "force-dynamic";
const STATUS_BADGE: Record<string, string> = {
PENDING: "muted",
PROCESSED: "ok",
DISCARDED: "err",
PENDING_STT: "warn",
TRANSCRIBED: "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 SegmentsPage() {
const segments = await prisma.rawSegment.findMany({
orderBy: { createdAt: "desc" },
take: 50,
include: { candidates: { orderBy: { createdAt: "desc" } } },
});
return (
<>
<h1>Segment & Aday Klip Kütüphanesi</h1>
{segments.length === 0 ? (
<p className="empty">Henüz işlenmiş segment yok.</p>
) : (
segments.map((segment) => (
<div className="card" key={segment.id}>
<div className="card-head">
<span className="mono">{segment.filePath}</span>
<span className={`badge ${STATUS_BADGE[segment.status] ?? "muted"}`}>{segment.status}</span>
</div>
<div className="mono">
{segment.duration}sn · {new Date(segment.startedAt).toLocaleString("tr-TR")}
</div>
{segment.candidates.length === 0 ? (
<p className="empty" style={{ marginTop: "0.5rem" }}>
Bu segmentte aday klip bulunamadı.
</p>
) : (
segment.candidates.map((c) => (
<div key={c.id} style={{ marginTop: "0.75rem", 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`}
{c.chatVelocityScore != null && " · chat spike"}
</span>
<span className={`badge ${STATUS_BADGE[c.status] ?? "muted"}`}>{c.status}</span>
</div>
{transcriptPreview(c.transcriptJson) && (
<p className="transcript-preview">{transcriptPreview(c.transcriptJson)}</p>
)}
</div>
))
)}
</div>
))
)}
</>
);
}