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:
@@ -10,9 +10,18 @@ const execFileAsync = promisify(execFile);
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Live detection failures are kept per channel and surfaced via GET /status
|
* 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:
|
* A few approaches were tried before landing here:
|
||||||
@@ -57,11 +66,10 @@ async function findLiveVideoId(channelId: string): Promise<string | null> {
|
|||||||
return videoId || null;
|
return videoId || null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(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,
|
// yt-dlp reports "not live" / "starts in N minutes" as a non-zero exit
|
||||||
// expected outcome on every check where the channel isn't broadcasting,
|
// too — normal, expected outcomes, not failures worth a red badge.
|
||||||
// not a failure worth surfacing as an error in the panel.
|
if (!BENIGN_POLL_MESSAGE.test(message)) {
|
||||||
if (!/is not currently live/.test(message)) {
|
lastPollErrors.set(channelId, { message: message.slice(0, 500), at: new Date().toISOString() });
|
||||||
lastPollErrors.set(channelId, message.slice(0, 500));
|
|
||||||
} else {
|
} else {
|
||||||
lastPollErrors.delete(channelId);
|
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. */
|
/** 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<{
|
export async function checkChannel(channel: { id: string; name: string; channelId: string }): Promise<{
|
||||||
liveVideoId: string | null;
|
liveVideoId: string | null;
|
||||||
error: string | null;
|
error: PollError | null;
|
||||||
}> {
|
}> {
|
||||||
const liveVideoId = await findLiveVideoId(channel.channelId);
|
const liveVideoId = await findLiveVideoId(channel.channelId);
|
||||||
await prisma.channel.update({
|
await prisma.channel.update({
|
||||||
|
|||||||
@@ -136,6 +136,16 @@ export async function updateSystemSettings(formData: FormData) {
|
|||||||
revalidatePath("/settings");
|
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) {
|
export async function updateNotificationPrefs(formData: FormData) {
|
||||||
const streamStart = formData.get("notifyStreamStart") === "on";
|
const streamStart = formData.get("notifyStreamStart") === "on";
|
||||||
const renderDone = formData.get("notifyRenderDone") === "on";
|
const renderDone = formData.get("notifyRenderDone") === "on";
|
||||||
|
|||||||
@@ -122,11 +122,16 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
|
|||||||
|
|
||||||
{status?.lastPollError && (
|
{status?.lastPollError && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Badge variant="err" className="w-fit">
|
<div className="flex items-center gap-2">
|
||||||
son poll hatası
|
<Badge variant="err" className="w-fit">
|
||||||
</Badge>
|
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">
|
<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>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -91,7 +91,14 @@ export default async function DashboardPage() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Badge variant={recording ? "live" : "muted"}>{recording ? "🔴 Kayıtta" : "—"}</Badge>
|
<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>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono-num text-xs text-muted-foreground">
|
<TableCell className="font-mono-num text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { prisma } from "@streamclipper/db";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -13,12 +13,13 @@ export const dynamic = "force-dynamic";
|
|||||||
const STALE_COOKIE_HOURS = 4;
|
const STALE_COOKIE_HOURS = 4;
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
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: "ytdlp_cookies" } }),
|
||||||
prisma.appSetting.findUnique({ where: { key: "poll_interval_ms" } }),
|
prisma.appSetting.findUnique({ where: { key: "poll_interval_ms" } }),
|
||||||
prisma.appSetting.findUnique({ where: { key: "raw_segment_ttl_hours" } }),
|
prisma.appSetting.findUnique({ where: { key: "raw_segment_ttl_hours" } }),
|
||||||
prisma.appSetting.findUnique({ where: { key: "notify_stream_start" } }),
|
prisma.appSetting.findUnique({ where: { key: "notify_stream_start" } }),
|
||||||
prisma.appSetting.findUnique({ where: { key: "notify_render_done" } }),
|
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;
|
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 ttlHours = ttlSetting ? Number(ttlSetting.value) : 24;
|
||||||
const notifyStreamStart = notifyStart?.value !== "false";
|
const notifyStreamStart = notifyStart?.value !== "false";
|
||||||
const notifyRenderDone = notifyRender?.value !== "false";
|
const notifyRenderDone = notifyRender?.value !== "false";
|
||||||
|
const sttEnabled = sttSetting?.value !== "false";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -95,6 +97,28 @@ export default async function SettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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'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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Bildirimler</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Bildirimler</CardTitle>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export interface ChannelStatus {
|
|||||||
lastCheckedAt: string | null;
|
lastCheckedAt: string | null;
|
||||||
recording: boolean;
|
recording: boolean;
|
||||||
activeSessionId: string | null;
|
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";
|
export const API_DAEMON_URL = process.env.API_DAEMON_URL ?? "http://localhost:4001";
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ async def process_stt_scoring(job, job_token=None):
|
|||||||
print(f"[stt-scoring] candidate {candidate_id} not found, skipping")
|
print(f"[stt-scoring] candidate {candidate_id} not found, skipping")
|
||||||
return
|
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:
|
with tempfile.NamedTemporaryFile(suffix=".mp3") as clip:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user