Compare commits

...
2 Commits
Author SHA1 Message Date
ayrisdevandClaude Sonnet 5 19fe430549 frontend: kanal detay sayfası (canlı durum + poll hatası + yayın geçmişi)
/channels/[id] rotası eklendi: dashboard'daki kanal adı artık oraya
link veriyor. Sayfa api-daemon'un /status'undan canlı durum ve son
poll hatasını (lastPollError, DB'de değil sadece bellekte tutuluyordu)
çekiyor, Prisma'dan da o kanala ait tüm StreamSession/RawSegment/
CandidateSegment geçmişini transkript önizlemesiyle listeliyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 17:34:15 +03:00
ayrisdevandClaude Sonnet 5 b62d6a7e5f fix: canlı-tespitini yt-dlp'den düz HTTP fetch'e taşı
PO-token duvarı polling'de hiç gerekli değildi — sadece stream byte'ı
indirirken (capture) lazım. yt-dlp --simulate bile format çözümlemeye
çalıştığı için PO-token'a takılıyordu. Artık /channel/<id>/live
sayfasını düz fetch ile çekip ytInitialData içindeki isLive/videoId'yi
regex ile okuyoruz — bot-check/cookie/PO-token'a hiç maruz kalmıyor.
yt-dlp + cookie + pot-provider sadece gerçek capture adımında kalıyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 17:31:29 +03:00
5 changed files with 206 additions and 37 deletions
+25 -27
View File
@@ -1,47 +1,45 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { prisma } from "@streamclipper/db";
import { env } from "./env";
import { streamIngestQueue } from "./queues";
import { sendTelegramMessage } from "./telegram";
import { ytdlpAntiBotArgs } from "./ytdlpCookies";
const execFileAsync = promisify(execFile);
const BROWSER_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36";
/**
* yt-dlp exits non-zero both when a channel isn't live and when the check
* itself fails (network block, bot detection, extractor breakage). Those two
* cases must not be indistinguishable, so the last failure per channel is
* kept here and surfaced via GET /status for debugging.
* Live detection failures are kept per channel and surfaced via GET /status
* for debugging.
*/
export const lastPollErrors = new Map<string, string>();
/**
* Live detection goes through yt-dlp itself instead of the YouTube Data API.
* `search.list?eventType=live` costs 100 quota units per call against a
* 10,000/day free quota — polling one channel every 60s alone would need
* ~144,000 units/day, well over quota. yt-dlp's `/live` redirect check is
* free and needs no API key.
* Live detection is a plain page fetch, not a yt-dlp invocation. Resolving
* downloadable formats (what yt-dlp does even in --simulate mode) is what
* triggers YouTube's bot-check and proof-of-origin token requirements — but
* we don't need stream bytes here, just whether the channel is live, which
* is embedded directly in the channel's /live page HTML (`isLive`/`videoId`
* in ytInitialData). This avoids cookies/PO-token entirely for polling;
* those are only needed later, for the actual stream-ingest capture.
*/
async function findLiveVideoId(channelId: string): Promise<string | null> {
const liveUrl = `https://www.youtube.com/channel/${channelId}/live`;
try {
const { stdout } = await execFileAsync(
"yt-dlp",
[
"--simulate",
"--no-warnings",
...ytdlpAntiBotArgs(),
"-f", "bestvideo+bestaudio/best",
"--print", "%(id)s",
liveUrl,
],
{ timeout: 20_000 },
);
const res = await fetch(liveUrl, { headers: { "User-Agent": BROWSER_USER_AGENT } });
if (!res.ok) {
lastPollErrors.set(channelId, `HTTP ${res.status} fetching ${liveUrl}`);
return null;
}
const html = await res.text();
lastPollErrors.delete(channelId);
const videoId = stdout.trim().split("\n")[0];
return videoId || null;
if (!/"isLive":true/.test(html)) {
return null;
}
const match = html.match(/"videoId":"([^"]+)"/);
return match ? match[1] : null;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
lastPollErrors.set(channelId, message.slice(0, 500));
+127
View File
@@ -0,0 +1,127 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { prisma } from "@streamclipper/db";
import { fetchChannelStatuses } from "../../../lib/apiDaemon";
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 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" } } },
},
},
},
},
}),
fetchChannelStatuses(),
]);
if (!channel) notFound();
const status = statuses.get(channel.id);
return (
<>
<p><Link href="/">&larr; Kanal Durumu</Link></p>
<h1>{channel.name}</h1>
<p className="mono">{channel.youtubeHandle} · {channel.channelId}</p>
<div className="card">
<div className="card-head">
<span>Canlı Durum</span>
<span className={`badge ${status?.recording ? "warn" : "muted"}`}>
{status?.recording ? "🔴 Kayıtta" : "Kayıtta değil"}
</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&apos;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>
)}
</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>
{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>
)}
</div>
))}
</div>
))
)}
</div>
))
)}
</>
);
}
+24 -10
View File
@@ -1,19 +1,24 @@
import Link from "next/link";
import { prisma } from "@streamclipper/db";
import { addChannel } from "./actions";
import { fetchChannelStatuses } from "../lib/apiDaemon";
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,
const [channels, statuses] = await Promise.all([
prisma.channel.findMany({
orderBy: { name: "asc" },
include: {
sessions: {
where: { endedAt: null },
orderBy: { startedAt: "desc" },
take: 1,
},
},
},
});
}),
fetchChannelStatuses(),
]);
return (
<>
@@ -41,9 +46,13 @@ export default async function DashboardPage() {
<tbody>
{channels.map((c) => {
const recording = c.sessions.length > 0;
const status = statuses.get(c.id);
return (
<tr key={c.id}>
<td>{c.name} <span className="mono">{c.youtubeHandle}</span></td>
<td>
<Link href={`/channels/${c.id}`}>{c.name}</Link>{" "}
<span className="mono">{c.youtubeHandle}</span>
</td>
<td>
<span className={`badge ${c.isActive ? "ok" : "muted"}`}>
{c.isActive ? "Aktif" : "Pasif"}
@@ -53,6 +62,11 @@ export default async function DashboardPage() {
<span className={`badge ${recording ? "warn" : "muted"}`}>
{recording ? "🔴 Kayıtta" : "—"}
</span>
{status?.lastPollError && (
<span className="badge err" style={{ marginLeft: "0.4rem" }}>
hata
</span>
)}
</td>
<td className="mono">
{c.lastCheckedAt ? new Date(c.lastCheckedAt).toLocaleString("tr-TR") : "—"}
+28
View File
@@ -0,0 +1,28 @@
export interface ChannelStatus {
id: string;
name: string;
isActive: boolean;
lastCheckedAt: string | null;
recording: boolean;
activeSessionId: string | null;
lastPollError: string | null;
}
const API_DAEMON_URL = process.env.API_DAEMON_URL ?? "http://localhost:4001";
/**
* lastPollError only exists in api-daemon's in-memory map (not persisted to
* the DB), so the panel fetches it live over HTTP rather than via Prisma.
* Failures here (api-daemon down, network hiccup) shouldn't break the page —
* they just mean live-status fields fall back to unknown.
*/
export async function fetchChannelStatuses(): Promise<Map<string, ChannelStatus>> {
try {
const res = await fetch(`${API_DAEMON_URL}/status`, { cache: "no-store" });
if (!res.ok) return new Map();
const data = (await res.json()) as { channels: ChannelStatus[] };
return new Map(data.channels.map((c) => [c.id, c]));
} catch {
return new Map();
}
}
+2
View File
@@ -75,10 +75,12 @@ services:
restart: unless-stopped
environment:
DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper
API_DAEMON_URL: http://sc_api_daemon:4001
ports:
- "3000:3000"
depends_on:
- sc_postgres
- sc_api_daemon
volumes:
redis-data: