330 lines
14 KiB
TypeScript
330 lines
14 KiB
TypeScript
"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: <T,>(path: string, init?: RequestInit) => Promise<T>;
|
||
/** 用于源站房间线索(与 Electron 一致) */
|
||
urlListText: string;
|
||
/** YouTube 密钥尾码;非 YouTube 进程可传空并关 showKeyRow */
|
||
keySuffixUi: string;
|
||
showKeyRow?: boolean;
|
||
pollMs?: 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,
|
||
}: Props) {
|
||
const [processStatus, setProcessStatus] = useState<string>("");
|
||
const [recentLog, setRecentLog] = useState("");
|
||
const [recentError, setRecentError] = useState("");
|
||
const [businessStatus, setBusinessStatus] = useState("");
|
||
const [businessNote, setBusinessNote] = useState("");
|
||
const [ffmpegPids, setFfmpegPids] = useState<number[]>([]);
|
||
const [logAgeSeconds, setLogAgeSeconds] = useState<number | null>(null);
|
||
const [paused, setPaused] = useState(false);
|
||
const [liveSince, setLiveSince] = useState<number | null>(null);
|
||
const [nowTick, setNowTick] = useState(0);
|
||
const outRef = useRef<HTMLPreElement>(null);
|
||
const errRef = useRef<HTMLPreElement>(null);
|
||
const prevOnlineRef = useRef(false);
|
||
const pullInFlightRef = useRef(false);
|
||
const pullQueuedRef = useRef(false);
|
||
|
||
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 () => {
|
||
if (!currentProc) return;
|
||
if (pullInFlightRef.current) {
|
||
pullQueuedRef.current = true;
|
||
return;
|
||
}
|
||
pullInFlightRef.current = true;
|
||
try {
|
||
const data = await fetchJson<StatusPayload>(
|
||
`/status?process=${encodeURIComponent(currentProc)}`,
|
||
);
|
||
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 {
|
||
setProcessStatus("error");
|
||
setBusinessStatus("error");
|
||
setBusinessNote("");
|
||
setFfmpegPids([]);
|
||
setLogAgeSeconds(null);
|
||
} finally {
|
||
pullInFlightRef.current = false;
|
||
if (pullQueuedRef.current) {
|
||
pullQueuedRef.current = false;
|
||
window.setTimeout(() => void pull(), 0);
|
||
}
|
||
}
|
||
}, [currentProc, fetchJson]);
|
||
|
||
useEffect(() => {
|
||
void pull();
|
||
}, [pull]);
|
||
|
||
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 (
|
||
<section className="rounded-2xl border border-emerald-500/20 bg-gradient-to-b from-emerald-500/[0.07] to-zinc-950/80 p-6 ring-1 ring-white/[0.04]">
|
||
<span className="sr-only" aria-hidden>
|
||
{nowTick}
|
||
</span>
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="flex items-center gap-2">
|
||
<h3 className="text-sm font-semibold text-white">{t("当前直播日志", "Live process log")}</h3>
|
||
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-200/90 ring-1 ring-emerald-500/25">
|
||
{paused ? t("已暂停刷新", "Paused") : t("每秒刷新", "~1s refresh")}
|
||
</span>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => void pull()}
|
||
className="inline-flex items-center gap-1 rounded-lg border border-white/10 bg-white/[0.05] px-3 py-1.5 text-xs text-zinc-300 hover:bg-white/[0.08]"
|
||
>
|
||
<RefreshCw className="h-3.5 w-3.5" />
|
||
{t("立即拉取", "Refresh")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPaused((p) => !p)}
|
||
className={`inline-flex items-center gap-1 rounded-lg px-3 py-1.5 text-xs ring-1 ${
|
||
paused
|
||
? "bg-amber-500/20 text-amber-100 ring-amber-500/35"
|
||
: "border border-white/10 bg-black/30 text-zinc-300"
|
||
}`}
|
||
>
|
||
{paused ? <Play className="h-3.5 w-3.5" /> : <Pause className="h-3.5 w-3.5" />}
|
||
{paused ? t("继续自动刷新", "Resume") : t("暂停自动刷新", "Pause")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 space-y-3 rounded-xl border border-white/[0.06] bg-black/25 p-4">
|
||
<div className="grid gap-2 text-xs sm:grid-cols-2">
|
||
<div className="flex flex-wrap gap-2">
|
||
<span className="text-zinc-500">{t("进程状态", "Status")}</span>
|
||
<span className="font-medium text-zinc-200">{statusLine(t, processStatus)}</span>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<span className="text-zinc-500">{t("Business state", "Business state")}</span>
|
||
<span className="font-medium text-cyan-200/90">{businessLine(t, businessStatus, businessNote)}</span>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<span className="text-zinc-500">{t("本次在线时长(估算)", "Online duration (est.)")}</span>
|
||
<span className="font-mono text-cyan-200/90">{durationLabel}</span>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<span className="text-zinc-500">{t("FFmpeg workers", "FFmpeg workers")}</span>
|
||
<span className="font-mono text-zinc-300">
|
||
{ffmpegPids.length ? ffmpegPids.join(", ") : "none"}
|
||
{logAgeSeconds != null ? ` · log ${fmtDuration(Math.max(0, Math.floor(logAgeSeconds)))}` : ""}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{showKeyRow ? (
|
||
<div className="flex flex-wrap gap-2 text-xs">
|
||
<span className="text-zinc-500">{t("YouTube key 尾码(参考)", "YT key suffix")}</span>
|
||
<span className="font-mono text-zinc-300">{keySuffixUi || "—"}</span>
|
||
</div>
|
||
) : null}
|
||
{primaryUrl ? (
|
||
<div className="rounded-lg border border-violet-500/20 bg-violet-500/[0.06] px-3 py-2.5">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<span className="text-[11px] font-medium text-zinc-400">
|
||
{roomFromLog
|
||
? t("当前直播源站(日志)", "Current source (from log)")
|
||
: onlineNow
|
||
? t("配置中的源站地址", "Configured source URL")
|
||
: t("源站快捷入口", "Source quick link")}
|
||
</span>
|
||
<a
|
||
href={primaryUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="inline-flex items-center gap-1.5 rounded-lg bg-violet-500/20 px-3 py-1.5 text-xs font-medium text-violet-100 ring-1 ring-violet-500/35 transition hover:bg-violet-500/30"
|
||
>
|
||
<ExternalLink className="h-3.5 w-3.5 shrink-0 opacity-90" />
|
||
{primaryRoomId || t("打开抖音直播页", "Open Douyin live")}
|
||
</a>
|
||
</div>
|
||
{douyinHints.length > 1 && roomFromLog ? (
|
||
<p className="mt-2 text-[10px] text-zinc-600">
|
||
{t("配置中还有", "Also in config:")}{" "}
|
||
{douyinHints
|
||
.filter((id) => id !== primaryRoomId)
|
||
.slice(0, 3)
|
||
.map((id) => (
|
||
<a
|
||
key={id}
|
||
href={`https://live.douyin.com/${id}`}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="ml-1 text-violet-400/90 underline-offset-2 hover:underline"
|
||
>
|
||
{id}
|
||
</a>
|
||
))}
|
||
{douyinHints.length > 4 ? "…" : null}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
) : (
|
||
<p className="text-[11px] text-zinc-600">
|
||
{t("(日志与地址列表中暂无抖音房间)", "(No Douyin room in log or URL list)")}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{processStatus !== "not_found" && processStatus !== "invalid" ? (
|
||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||
<div>
|
||
<div className="text-[11px] font-semibold uppercase tracking-wider text-emerald-400/90">
|
||
{t("进程输出(stdout / 合并日志)", "Process output")}
|
||
</div>
|
||
<pre
|
||
ref={outRef}
|
||
className="log-live-scroll mt-2 max-h-[min(22rem,42vh)] min-h-[12rem] overflow-auto whitespace-pre-wrap rounded-xl border border-emerald-500/20 bg-black/55 p-3 font-mono text-[10px] leading-relaxed text-emerald-100/95"
|
||
>
|
||
{recentLog.trim() || t("(暂无)", "(empty)")}
|
||
</pre>
|
||
</div>
|
||
<div>
|
||
<div className="text-[11px] font-semibold uppercase tracking-wider text-rose-400/90">
|
||
{t("异常信息(stderr)", "Stderr")}
|
||
</div>
|
||
<pre
|
||
ref={errRef}
|
||
className="log-live-scroll mt-2 max-h-[min(22rem,42vh)] min-h-[12rem] overflow-auto whitespace-pre-wrap rounded-xl border border-rose-500/20 bg-black/55 p-3 font-mono text-[10px] leading-relaxed text-rose-100/95"
|
||
>
|
||
{recentError.trim() || t("(暂无)", "(empty)")}
|
||
</pre>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<p className="mt-4 text-xs text-zinc-500">
|
||
{t("进程未在 PM2 / 本地注册表中运行,日志区域已隐藏。", "Process not running — log panes hidden.")}
|
||
</p>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|