Compare commits

..
3 Commits
Author SHA1 Message Date
ayrisdevandClaude Sonnet 5 b5c761afbf fix: frontend production'da next dev yerine build+start çalışsın
Dockerfile'ın CMD'si next dev kullanıyordu — Coolify'de canlı sistemde
dev-mode overlay/bildirimleri (Next.js sürüm uyarısı, hata detayları) ve
optimize edilmemiş, JIT-compile edilen bir build çalışıyordu. Artık image
build sırasında next build çalışıyor, CMD sadece next start.

DATABASE_URL build adımında gerçek bir DB'ye ihtiyaç duymuyor (tüm
sayfalar force-dynamic, build sırasında sorgu atılmıyor) ama Prisma
client import'ta connection string formatını doğruluyor — yer tutucu bir
değer verildi, Coolify'nin gerçek DATABASE_URL'i runtime'da onun üzerine
yazıyor zaten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 22:59:53 +03:00
ayrisdevandClaude Sonnet 5 dea207996e fix: kanal detay sayfasında iç içe girinti (segment/aday klip) netleştirildi
3 seviyeli border-left girinti (oturum > segment > aday klip) yerine
gerçek kutu (soft-card) blokları — her segment kendi arka planıyla
ayrışıyor, adaylar onun içinde daha da net bir alt kutuda. Segment
başlığı artık ham dosya yolu değil ("15sn segment · 02.09.2026 20:14"
gibi) insan-okur bir etiket — dosya yolu küçük/soluk referans olarak
altta kaldı. "Canlı Durum" kartındaki karışık bölümler de (hata/canlı
önizleme/log/segment ayarı/manuel başlat) Separator ile ayrıştırıldı.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 22:58:06 +03:00
ayrisdevandClaude Sonnet 5 58ef40edf8 feat: segment süresi varsayılanını 15 dk -> 5 dk düşür + dashboard'u kart-grid'e çevir
Global SEGMENT_TIME_SEC varsayılanı 900->300 (docker-compose, .env.example,
env.ts, signal_detection.py fallback'i) — kanal bazlı override zaten
panelden ayarlanabiliyordu, bu sadece varsayılanı değiştiriyor.

Dashboard'daki kanal listesi tablo yerine kart-grid: her kanal kendi
kartında, durum rozetleri üstte belirgin, aksiyonlar altta — önceki
tablo düzeni (özellikle aksiyon sütunundaki sıkışık butonlar) okunması
zor bulunuyordu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 22:55:12 +03:00
7 changed files with 195 additions and 180 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
POLL_INTERVAL_MS=60000
SEGMENT_TIME_SEC=900
SEGMENT_TIME_SEC=300
API_DAEMON_PORT=4001
# Signs the panel's login session cookies (frontend). Generate with:
+1 -1
View File
@@ -4,7 +4,7 @@ export const env = {
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",
sharedMediaRoot: process.env.SHARED_MEDIA_ROOT ?? "./shared-media",
pollIntervalMs: Number(process.env.POLL_INTERVAL_MS ?? 60_000),
segmentTimeSec: Number(process.env.SEGMENT_TIME_SEC ?? 900),
segmentTimeSec: Number(process.env.SEGMENT_TIME_SEC ?? 300),
port: Number(process.env.API_DAEMON_PORT ?? 4001),
forceLiveUrl: process.env.FORCE_LIVE_URL ?? "",
ytdlpPotProviderUrl: process.env.YTDLP_POT_PROVIDER_URL ?? "",
+9 -1
View File
@@ -7,4 +7,12 @@ COPY . .
RUN pnpm install
RUN pnpm --filter @streamclipper/db exec prisma generate
CMD ["pnpm", "--filter", "@streamclipper/frontend", "dev"]
# DATABASE_URL isn't needed for real at build time (all pages are
# force-dynamic — nothing queries the DB during `next build`), but
# Prisma's client validates the connection string format at import, so a
# well-formed placeholder keeps the build step from failing. Coolify's
# actual DATABASE_URL is supplied as a runtime env var and overrides this.
ENV DATABASE_URL="postgresql://build:build@localhost:5432/build"
RUN pnpm --filter @streamclipper/frontend build
CMD ["pnpm", "--filter", "@streamclipper/frontend", "start"]
+57 -45
View File
@@ -21,6 +21,7 @@ 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 { Separator } from "@/components/ui/separator";
import type { VariantProps } from "class-variance-authority";
export const dynamic = "force-dynamic";
@@ -111,12 +112,51 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
)}
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<CardContent className="flex flex-col gap-4">
<p className="font-mono-num text-xs text-muted-foreground">
Aktif: {channel.isActive ? "evet" : "hayır"} · Son kontrol:{" "}
{channel.lastCheckedAt ? formatTr(channel.lastCheckedAt) : "—"}
</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 rounded-lg border border-err/30 bg-err/5 p-3">
<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">
{formatTr(status.lastPollError.at)}
</span>
</div>
<pre className="font-mono-num whitespace-pre-wrap text-[0.7rem] text-muted-foreground">
{status.lastPollError.message}
</pre>
</div>
)}
{status?.recording && (
<div className="flex flex-col gap-3">
{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"
/>
)}
{activeSession && <LiveLogViewer sessionId={activeSession.id} />}
</div>
)}
<Separator />
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<form
action={updateChannelSegmentTime.bind(null, channel.id)}
className="flex flex-wrap items-end gap-2"
@@ -132,61 +172,26 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
min={1}
max={60}
step={0.5}
placeholder="varsayılan (15)"
placeholder="varsayılan"
defaultValue={channel.segmentTimeSec ? channel.segmentTimeSec / 60 : ""}
className="w-40"
className="w-32"
/>
</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">
{formatTr(status.lastPollError.at)}
</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">
<details className="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" />
<Input name="url" placeholder="https://www.youtube.com/watch?v=..." required className="w-64" />
<Button type="submit">Başlat</Button>
</form>
</details>
</div>
</CardContent>
</Card>
@@ -213,11 +218,14 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
) : (
<CardContent className="flex flex-col gap-4">
{session.segments.map((segment) => (
<div key={segment.id} className="border-l-2 border-border pl-4">
<div key={segment.id} className="rounded-lg border border-border bg-muted/20 p-4">
<div className="flex items-center justify-between gap-2">
<span className="font-mono-num text-xs text-muted-foreground">{segment.filePath}</span>
<span className="text-sm font-medium">
{segment.duration}sn segment · {formatTr(segment.startedAt)}
</span>
<Badge variant={STATUS_BADGE[segment.status] ?? "muted"}>{segment.status}</Badge>
</div>
<span className="font-mono-num text-[0.7rem] text-muted-foreground">{segment.filePath}</span>
<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" />
@@ -236,13 +244,15 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
</div>
{segment.candidates.length === 0 && segment.status !== "PENDING" && (
<p className="mt-2 text-xs text-muted-foreground">
<p className="mt-3 text-xs text-muted-foreground">
Bu segmentte aday klip bulunamadı (sinyal analizi ilginç bir an tespit etmedi).
</p>
)}
{segment.candidates.length > 0 && (
<div className="mt-3 flex flex-col gap-2">
{segment.candidates.map((c) => (
<div key={c.id} className="mt-3 border-l-2 border-border pl-4">
<div key={c.id} className="rounded-md border border-border bg-background/60 p-3">
<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
@@ -304,6 +314,8 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
</div>
))}
</div>
)}
</div>
))}
</CardContent>
)}
+37 -42
View File
@@ -6,8 +6,8 @@ import { formatTr } from "../lib/formatDate";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
import { cn } from "@/lib/utils";
export const dynamic = "force-dynamic";
@@ -47,9 +47,9 @@ export default async function DashboardPage() {
{channels.length === 0 ? (
<p className="text-sm text-muted-foreground">Henüz kanal eklenmedi.</p>
) : (
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="text-sm font-medium text-muted-foreground">{channels.length} kanal</CardTitle>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">{channels.length} kanal</span>
<div className="flex gap-2">
<form action={toggleAllChannels.bind(null, true)}>
<Button type="submit" variant="outline" size="sm">
@@ -62,36 +62,34 @@ export default async function DashboardPage() {
</Button>
</form>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Kanal</TableHead>
<TableHead>Durum</TableHead>
<TableHead>Kayıt</TableHead>
<TableHead>Son Kontrol</TableHead>
<TableHead className="text-right">Aksiyon</TableHead>
</TableRow>
</TableHeader>
<TableBody>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{channels.map((c) => {
const recording = c.sessions.length > 0;
const status = statuses.get(c.id);
return (
<TableRow key={c.id}>
<TableCell>
<Link href={`/channels/${c.id}`} className="font-medium hover:text-primary hover:underline">
<Card
key={c.id}
className={cn("gap-4 py-5", recording && "border-live/40")}
>
<CardHeader className="gap-1 px-5">
<div className="flex items-start justify-between gap-2">
<Link href={`/channels/${c.id}`} className="font-semibold leading-tight hover:text-primary hover:underline">
{c.name}
</Link>{" "}
</Link>
<Badge variant={c.isActive ? "ok" : "muted"} className="shrink-0">
{c.isActive ? "Aktif" : "Pasif"}
</Badge>
</div>
<span className="font-mono-num text-xs text-muted-foreground">{c.youtubeHandle}</span>
</TableCell>
<TableCell>
<Badge variant={c.isActive ? "ok" : "muted"}>{c.isActive ? "Aktif" : "Pasif"}</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
<Badge variant={recording ? "live" : "muted"}>{recording ? "🔴 Kayıtta" : "—"}</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-2 px-5">
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant={recording ? "live" : "muted"}>
{recording ? "🔴 Kayıtta" : "Kayıtta değil"}
</Badge>
{status?.lastPollError && (
<Badge
variant="err"
@@ -101,12 +99,12 @@ export default async function DashboardPage() {
</Badge>
)}
</div>
</TableCell>
<TableCell className="font-mono-num text-xs text-muted-foreground">
{c.lastCheckedAt ? formatTr(c.lastCheckedAt) : "—"}
</TableCell>
<TableCell>
<div className="flex justify-end gap-1.5">
<span className="font-mono-num text-xs text-muted-foreground">
Son kontrol: {c.lastCheckedAt ? formatTr(c.lastCheckedAt) : "—"}
</span>
</CardContent>
<CardFooter className="gap-2 px-5">
<form action={toggleChannelActive.bind(null, c.id, !c.isActive)}>
<Button type="submit" variant="outline" size="sm">
{c.isActive ? "Duraklat" : "Devam Ettir"}
@@ -117,15 +115,12 @@ export default async function DashboardPage() {
Şimdi Kontrol Et
</Button>
</form>
</div>
</TableCell>
</TableRow>
</CardFooter>
</Card>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</div>
)}
</div>
);
+1 -1
View File
@@ -17,7 +17,7 @@ WINDOW_SEC = 1.0
PEAK_STD_MULTIPLIER = 1.5
CANDIDATE_PAD_SEC = 45
CHAT_SPIKE_MULTIPLIER = 3
SEGMENT_TIME_SEC = int(os.environ.get("SEGMENT_TIME_SEC", "900"))
SEGMENT_TIME_SEC = int(os.environ.get("SEGMENT_TIME_SEC", "300"))
stt_scoring_queue = Queue(QUEUE_NAMES["STT_SCORING"], {"connection": REDIS_URL})
+2 -2
View File
@@ -33,7 +33,7 @@ services:
DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper
SHARED_MEDIA_ROOT: /shared-media
POLL_INTERVAL_MS: ${POLL_INTERVAL_MS:-60000}
SEGMENT_TIME_SEC: ${SEGMENT_TIME_SEC:-900}
SEGMENT_TIME_SEC: ${SEGMENT_TIME_SEC:-300}
API_DAEMON_PORT: ${API_DAEMON_PORT:-4001}
FORCE_LIVE_URL: ${FORCE_LIVE_URL:-}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
@@ -61,7 +61,7 @@ services:
REDIS_URL: redis://sc_redis:6379
DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper
SHARED_MEDIA_ROOT: /shared-media
SEGMENT_TIME_SEC: ${SEGMENT_TIME_SEC:-900}
SEGMENT_TIME_SEC: ${SEGMENT_TIME_SEC:-300}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}