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>
This commit is contained in:
2026-09-02 14:52:15 +03:00
co-authored by Claude Sonnet 5
parent 7d504d1938
commit cbb16697ca
7 changed files with 74 additions and 16 deletions
+16 -8
View File
@@ -10,9 +10,18 @@ const execFileAsync = promisify(execFile);
/**
* Live detection failures are kept per channel and surfaced via GET /status
* for debugging.
* for debugging. `at` lets the panel show how stale an error is — a message
* on its own doesn't say whether it's from the last poll or from hours ago
* before the channel started passing again.
*/
export const lastPollErrors = new Map<string, string>();
export interface PollError {
message: string;
at: string;
}
export const lastPollErrors = new Map<string, PollError>();
/** Non-error outcomes yt-dlp still reports as a non-zero exit — not worth surfacing as a scary error badge in the panel. */
const BENIGN_POLL_MESSAGE = /is not currently live|will begin in/;
/**
* A few approaches were tried before landing here:
@@ -57,11 +66,10 @@ async function findLiveVideoId(channelId: string): Promise<string | null> {
return videoId || null;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// yt-dlp reports "not live" as a non-zero exit too — that's a normal,
// expected outcome on every check where the channel isn't broadcasting,
// not a failure worth surfacing as an error in the panel.
if (!/is not currently live/.test(message)) {
lastPollErrors.set(channelId, message.slice(0, 500));
// yt-dlp reports "not live" / "starts in N minutes" as a non-zero exit
// too — normal, expected outcomes, not failures worth a red badge.
if (!BENIGN_POLL_MESSAGE.test(message)) {
lastPollErrors.set(channelId, { message: message.slice(0, 500), at: new Date().toISOString() });
} else {
lastPollErrors.delete(channelId);
}
@@ -107,7 +115,7 @@ export async function startCaptureSession(
/** Runs the live-check for a single channel and applies its result — the unit both `pollOnce` and the panel's manual "şimdi kontrol et" action reuse. */
export async function checkChannel(channel: { id: string; name: string; channelId: string }): Promise<{
liveVideoId: string | null;
error: string | null;
error: PollError | null;
}> {
const liveVideoId = await findLiveVideoId(channel.channelId);
await prisma.channel.update({
+10
View File
@@ -136,6 +136,16 @@ export async function updateSystemSettings(formData: FormData) {
revalidatePath("/settings");
}
export async function updateSttEnabled(formData: FormData) {
const enabled = formData.get("sttEnabled") === "on";
await prisma.appSetting.upsert({
where: { key: "stt_enabled" },
create: { key: "stt_enabled", value: String(enabled) },
update: { value: String(enabled) },
});
revalidatePath("/settings");
}
export async function updateNotificationPrefs(formData: FormData) {
const streamStart = formData.get("notifyStreamStart") === "on";
const renderDone = formData.get("notifyRenderDone") === "on";
+9 -4
View File
@@ -122,11 +122,16 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
{status?.lastPollError && (
<div className="flex flex-col gap-1.5">
<Badge variant="err" className="w-fit">
son poll hatası
</Badge>
<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}
{status.lastPollError.message}
</pre>
</div>
)}
+8 -1
View File
@@ -91,7 +91,14 @@ export default async function DashboardPage() {
<TableCell>
<div className="flex items-center gap-1.5">
<Badge variant={recording ? "live" : "muted"}>{recording ? "🔴 Kayıtta" : "—"}</Badge>
{status?.lastPollError && <Badge variant="err">hata</Badge>}
{status?.lastPollError && (
<Badge
variant="err"
title={`${new Date(status.lastPollError.at).toLocaleString("tr-TR")}${status.lastPollError.message}`}
>
hata
</Badge>
)}
</div>
</TableCell>
<TableCell className="font-mono-num text-xs text-muted-foreground">
+26 -2
View File
@@ -1,5 +1,5 @@
import { prisma } from "@streamclipper/db";
import { updateYtdlpCookies, updateSystemSettings, updateNotificationPrefs } from "../actions";
import { updateYtdlpCookies, updateSystemSettings, updateNotificationPrefs, updateSttEnabled } from "../actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -13,12 +13,13 @@ export const dynamic = "force-dynamic";
const STALE_COOKIE_HOURS = 4;
export default async function SettingsPage() {
const [setting, pollSetting, ttlSetting, notifyStart, notifyRender] = await Promise.all([
const [setting, pollSetting, ttlSetting, notifyStart, notifyRender, sttSetting] = await Promise.all([
prisma.appSetting.findUnique({ where: { key: "ytdlp_cookies" } }),
prisma.appSetting.findUnique({ where: { key: "poll_interval_ms" } }),
prisma.appSetting.findUnique({ where: { key: "raw_segment_ttl_hours" } }),
prisma.appSetting.findUnique({ where: { key: "notify_stream_start" } }),
prisma.appSetting.findUnique({ where: { key: "notify_render_done" } }),
prisma.appSetting.findUnique({ where: { key: "stt_enabled" } }),
]);
const cookieAgeHours = setting ? (Date.now() - setting.updatedAt.getTime()) / 3_600_000 : null;
@@ -27,6 +28,7 @@ export default async function SettingsPage() {
const ttlHours = ttlSetting ? Number(ttlSetting.value) : 24;
const notifyStreamStart = notifyStart?.value !== "false";
const notifyRenderDone = notifyRender?.value !== "false";
const sttEnabled = sttSetting?.value !== "false";
return (
<div className="flex flex-col gap-6">
@@ -95,6 +97,28 @@ export default async function SettingsPage() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">STT (Transkripsiyon)</CardTitle>
<CardDescription>
Kapatırsan yeni aday klipler transkribe edilmez (PENDING_STT&apos;de bekler) OpenAI Whisper çağrısı
yapılmaz, render de tetiklenmez. Boş/konuşmasız yayınlarda (ör. sadece ambiyans müzik) gereksiz API
maliyetini önlemek için kapatabilirsin.
</CardDescription>
</CardHeader>
<form action={updateSttEnabled}>
<CardContent>
<Label className="flex items-center justify-between gap-4 text-sm font-normal">
Otomatik transkripsiyon aktif
<Switch name="sttEnabled" defaultChecked={sttEnabled} />
</Label>
</CardContent>
<CardFooter>
<Button type="submit">Kaydet</Button>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">Bildirimler</CardTitle>
+1 -1
View File
@@ -5,7 +5,7 @@ export interface ChannelStatus {
lastCheckedAt: string | null;
recording: boolean;
activeSessionId: string | null;
lastPollError: string | null;
lastPollError: { message: string; at: string } | null;
}
export const API_DAEMON_URL = process.env.API_DAEMON_URL ?? "http://localhost:4001";
+4
View File
@@ -39,6 +39,10 @@ async def process_stt_scoring(job, job_token=None):
print(f"[stt-scoring] candidate {candidate_id} not found, skipping")
return
if await db.get_setting("stt_enabled", "true") == "false":
print(f"[stt-scoring] STT panelden kapatılmış, {candidate_id} atlanıyor (PENDING_STT'de kalır)")
return
with tempfile.NamedTemporaryFile(suffix=".mp3") as clip:
subprocess.run(
[