"use client"; import { ExternalLink, Pause, Play, RefreshCw } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { extractDouyinCurrentRoomFromLog, extractDouyinLiveUrls, extractDouyinRoomHints, } from "@/lib/youtubeConfigUtils"; type TFn = (zh: string, en: string) => string; type StatusPayload = { process_status?: string; recent_log?: string; recent_error?: string; business_status?: string; business_note?: string; ffmpeg_pids?: number[]; log_age_seconds?: number | null; is_pushing?: boolean; }; function fmtDuration(sec: number): string { if (sec < 60) return `${sec}s`; const m = Math.floor(sec / 60); const s = sec % 60; if (m < 60) return `${m}m ${s}s`; const h = Math.floor(m / 60); const mm = m % 60; return `${h}h ${mm}m`; } function statusLine(t: TFn, raw: string | undefined): string { const s = (raw || "unknown").toLowerCase(); if (s === "online" || s === "running") return t("运行中(PM2/进程在线)", "Running (process online)"); if (s === "stopped" || s === "stopping") return t("已停止", "Stopped"); if (s === "not_found" || s === "errored") return t("未运行或未找到", "Not running / not found"); if (s === "invalid") return t("无效进程名", "Invalid process"); return raw || "—"; } type Props = { t: TFn; currentProc: string; fetchJson: (path: string, init?: RequestInit) => Promise; /** 用于源站房间线索(与 Electron 一致) */ urlListText: string; /** YouTube 密钥尾码;非 YouTube 进程可传空并关 showKeyRow */ keySuffixUi: string; showKeyRow?: boolean; pollMs?: number; refreshToken?: number; }; function businessLine(t: TFn, raw: string | undefined, note: string | undefined): string { const s = (raw || "").toLowerCase(); if (s === "streaming") return t("Pushing to YouTube", "Pushing to YouTube"); if (s === "waiting_source") return t("Waiting for the upstream live source", "Waiting for the upstream live source"); if (s === "source_live") return t("Source live detected, but no active FFmpeg relay worker", "Source live detected, but no active FFmpeg relay worker"); if (s === "monitoring") return t("Process online and monitoring the source", "Process online and monitoring the source"); if (s === "misconfigured") return t("YouTube key missing or invalid", "YouTube key missing or invalid"); if (s === "stale") return t("Process online but the log heartbeat is stale", "Process online but the log heartbeat is stale"); if (s === "error") return t("Recent process error detected", "Recent process error detected"); if (note?.trim()) return note.trim(); return t("No business-state hint yet", "No business-state hint yet"); } export function LiveProcessLiveLogCard({ t, currentProc, fetchJson, urlListText, keySuffixUi, showKeyRow = true, pollMs = 1000, refreshToken = 0, }: Props) { const [processStatus, setProcessStatus] = useState(""); const [recentLog, setRecentLog] = useState(""); const [recentError, setRecentError] = useState(""); const [businessStatus, setBusinessStatus] = useState(""); const [businessNote, setBusinessNote] = useState(""); const [ffmpegPids, setFfmpegPids] = useState([]); const [logAgeSeconds, setLogAgeSeconds] = useState(null); const [paused, setPaused] = useState(false); const [liveSince, setLiveSince] = useState(null); const [nowTick, setNowTick] = useState(0); const outRef = useRef(null); const errRef = useRef(null); const prevOnlineRef = useRef(false); const pullInFlightRef = useRef(false); const pullQueuedRef = useRef(false); const currentProcRef = useRef(currentProc); const requestSeqRef = useRef(0); const douyinHints = extractDouyinRoomHints(urlListText); const onlineNow = /^(online|running)$/i.test(processStatus); const roomFromLog = extractDouyinCurrentRoomFromLog(recentLog); const fallbackUrl = extractDouyinLiveUrls(urlListText)[0] ?? ""; const primaryRoomId = roomFromLog || (fallbackUrl.match(/live\.douyin\.com\/([a-zA-Z0-9_-]+)/i)?.[1] ?? null); const primaryUrl = primaryRoomId ? `https://live.douyin.com/${primaryRoomId}` : fallbackUrl || null; const pull = useCallback(async () => { const targetProc = currentProcRef.current.trim(); if (!targetProc) return; if (pullInFlightRef.current) { pullQueuedRef.current = true; return; } pullInFlightRef.current = true; const requestId = ++requestSeqRef.current; try { const data = await fetchJson( `/status?process=${encodeURIComponent(targetProc)}`, ); if (requestId !== requestSeqRef.current || targetProc !== currentProcRef.current.trim()) return; const ps = data.process_status || "unknown"; setProcessStatus(ps); setRecentLog(data.recent_log || ""); setRecentError(data.recent_error || ""); setBusinessStatus(data.business_status || ""); setBusinessNote(data.business_note || ""); setFfmpegPids(Array.isArray(data.ffmpeg_pids) ? data.ffmpeg_pids : []); setLogAgeSeconds(typeof data.log_age_seconds === "number" ? data.log_age_seconds : null); const online = /^(online|running)$/i.test(ps); if (online && !prevOnlineRef.current) { setLiveSince(Date.now()); } if (!online) { setLiveSince(null); } prevOnlineRef.current = online; } catch { if (requestId !== requestSeqRef.current || targetProc !== currentProcRef.current.trim()) return; setProcessStatus("error"); setBusinessStatus("error"); setBusinessNote(""); setFfmpegPids([]); setLogAgeSeconds(null); } finally { pullInFlightRef.current = false; if (pullQueuedRef.current) { pullQueuedRef.current = false; window.setTimeout(() => void pull(), 0); } } }, [fetchJson]); useEffect(() => { currentProcRef.current = currentProc; requestSeqRef.current += 1; pullQueuedRef.current = false; if (!currentProc) { setProcessStatus(""); setRecentLog(""); setRecentError(""); setBusinessStatus(""); setBusinessNote(""); setFfmpegPids([]); setLogAgeSeconds(null); setLiveSince(null); prevOnlineRef.current = false; return; } setProcessStatus(""); setRecentLog(""); setRecentError(""); setBusinessStatus(""); setBusinessNote(""); setFfmpegPids([]); setLogAgeSeconds(null); setLiveSince(null); prevOnlineRef.current = false; void pull(); }, [currentProc, pull]); useEffect(() => { if (!currentProc) return; void pull(); }, [currentProc, pull, refreshToken]); useEffect(() => { if (paused || !currentProc) return; const id = window.setInterval(() => void pull(), pollMs); return () => clearInterval(id); }, [paused, currentProc, pull, pollMs]); useEffect(() => { const onVis = () => { if (document.visibilityState === "visible") void pull(); }; document.addEventListener("visibilitychange", onVis); return () => document.removeEventListener("visibilitychange", onVis); }, [pull]); useEffect(() => { if (!liveSince) return; const id = window.setInterval(() => setNowTick((n) => n + 1), 1000); return () => clearInterval(id); }, [liveSince]); useEffect(() => { if (paused) return; const el = outRef.current; if (el) el.scrollTop = el.scrollHeight; }, [recentLog, paused]); useEffect(() => { if (paused) return; const el = errRef.current; if (el) el.scrollTop = el.scrollHeight; }, [recentError, paused]); const durationLabel = liveSince == null ? t("—(未检测到在线)", "— (not online)") : fmtDuration(Math.max(0, Math.floor((Date.now() - liveSince) / 1000))); return (
{nowTick}

{t("当前直播日志", "Live process log")}

{currentProc || "—"} {paused ? t("已暂停刷新", "Paused") : t("每秒刷新", "~1s refresh")}
{t("进程状态", "Status")} {statusLine(t, processStatus)}
{t("Business state", "Business state")} {businessLine(t, businessStatus, businessNote)}
{t("本次在线时长(估算)", "Online duration (est.)")} {durationLabel}
{t("FFmpeg workers", "FFmpeg workers")} {ffmpegPids.length ? ffmpegPids.join(", ") : "none"} {logAgeSeconds != null ? ` · log ${fmtDuration(Math.max(0, Math.floor(logAgeSeconds)))}` : ""}
{showKeyRow ? (
{t("YouTube key 尾码(参考)", "YT key suffix")} {keySuffixUi || "—"}
) : null} {primaryUrl ? (
{roomFromLog ? t("当前直播源站(日志)", "Current source (from log)") : onlineNow ? t("配置中的源站地址", "Configured source URL") : t("源站快捷入口", "Source quick link")} {primaryRoomId || t("打开抖音直播页", "Open Douyin live")}
{douyinHints.length > 1 && roomFromLog ? (

{t("配置中还有", "Also in config:")}{" "} {douyinHints .filter((id) => id !== primaryRoomId) .slice(0, 3) .map((id) => ( {id} ))} {douyinHints.length > 4 ? "…" : null}

) : null}
) : (

{t("(日志与地址列表中暂无抖音房间)", "(No Douyin room in log or URL list)")}

)}
{processStatus !== "not_found" && processStatus !== "invalid" ? (
{t("进程输出(stdout / 合并日志)", "Process output")}
              {recentLog.trim() || t("(暂无)", "(empty)")}
            
{t("异常信息(stderr)", "Stderr")}
              {recentError.trim() || t("(暂无)", "(empty)")}
            
) : (

{t("进程未在 PM2 / 本地注册表中运行,日志区域已隐藏。", "Process not running — log panes hidden.")}

)}
); }