"use client"; import { Ban, Loader2, MonitorPlay, Plus, Radio, RefreshCw, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { buildYoutubeIniFromFields, extractDouyinLiveUrls, managedUrlConfigRelPathForPm2, managedYoutubeIniRelPathForPm2, parseYoutubeIniFields, parseYoutubeStreamKeySuffix, type YoutubeIniFields, } from "@/lib/youtubeConfigUtils"; import type { PortalNavTarget } from "@/components/live-product/LivePipelineStrip"; import { LivePipelineStrip } from "@/components/live-product/LivePipelineStrip"; import { LiveProcessLiveLogCard } from "@/components/live-product/LiveProcessLiveLogCard"; import { defaultPm2NameForChannel, fetchYoutubeProChannelsRemote, loadYoutubeProChannels, newChannelId, pushYoutubeProChannelsRemote, resolveYoutubeProChannelPm2, saveYoutubeProChannels, sanitizeYoutubePm2Name, type YoutubeProChannel, } from "@/lib/youtubeProChannels"; import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls"; type ProcessEntry = { pm2: string; script: string; label: string }; type TFn = (zh: string, en: string) => string; function isYoutubeProcess(e: ProcessEntry) { return /youtube/i.test(e.script) || /youtube/i.test(e.pm2); } function scriptStem(script: string): string { const base = script.split("/").pop() || script; return base.replace(/\.[^.]+$/, ""); } function isStaticYoutubeControlEntry(entry: ProcessEntry): boolean { return isYoutubeProcess(entry) && entry.pm2.trim() === scriptStem(entry.script).trim(); } function singleModeProcessLabel(entry: ProcessEntry, t: TFn): string { const stem = scriptStem(entry.script).toLowerCase(); if (stem.startsWith("douyin_youtube")) return t("抖音→YouTube", "Douyin → YouTube"); if (stem.startsWith("youtube")) return t("YouTube", "YouTube"); return entry.label; } function preferredSingleProcessPm2(entries: ProcessEntry[]): string { const exact = entries.find((entry) => entry.pm2.trim().toLowerCase() === "youtube"); if (exact) return exact.pm2; const byScript = entries.find((entry) => scriptStem(entry.script).trim().toLowerCase() === "youtube"); if (byScript) return byScript.pm2; return entries[0]?.pm2 ?? ""; } function isYoutubeRow(row: LiveProcRow | undefined): boolean { if (!row) return false; return /youtube/i.test(row.pm2) || /youtube/i.test(row.script ?? ""); } function singleProcessPriority(entry: ProcessEntry, row: LiveProcRow | undefined): number { const business = (row?.business_status || "").trim().toLowerCase(); if (row?.is_pushing || business === "streaming") return 0; if (pm2StatusOnline(row?.process_status)) return 1; if (entry.pm2.trim().toLowerCase() === "youtube") return 2; if (scriptStem(entry.script).trim().toLowerCase() === "youtube") return 3; return 10; } function buildSingleProcessOptions(entries: ProcessEntry[], liveRows: LiveProcRow[]): ProcessEntry[] { const youtubeEntries = entries.filter(isYoutubeProcess); const staticYoutubeEntries = youtubeEntries.filter((entry) => isStaticYoutubeControlEntry(entry)); const base = staticYoutubeEntries.length ? [...staticYoutubeEntries] : youtubeEntries.length ? [...youtubeEntries] : [...entries]; const seen = new Set(base.map((entry) => entry.pm2.trim())); for (const row of liveRows.filter(isYoutubeRow)) { const pm2 = row.pm2.trim(); if (!pm2 || seen.has(pm2)) continue; const business = (row.business_status || "").trim().toLowerCase(); if (!row.is_pushing && business !== "streaming" && !pm2StatusOnline(row.process_status)) continue; const matched = youtubeEntries.find((entry) => entry.pm2.trim() === pm2); base.push( matched ?? { pm2, script: row.script ?? youtubeEntries[0]?.script ?? "youtube.py", label: row.label ?? pm2, }, ); seen.add(pm2); } return base; } function preferredSingleProcessPm2FromRuntime(entries: ProcessEntry[], liveRows: LiveProcRow[]): string { if (!entries.length) return ""; const rowMap = new Map( liveRows .filter(isYoutubeRow) .map((row) => [row.pm2.trim(), row] as const), ); let best = entries[0]; let bestScore = singleProcessPriority(best, rowMap.get(best.pm2.trim())); for (const entry of entries.slice(1)) { const score = singleProcessPriority(entry, rowMap.get(entry.pm2.trim())); if (score < bestScore) { best = entry; bestScore = score; } } return best.pm2; } function resolveChannelPm2(channel: YoutubeProChannel): string { return resolveYoutubeProChannelPm2(channel); } const MODE_KEY = "live-hub-youtube-mode-v1"; const PRO_PM2_RESERVED = new Set(["tiktok", "obs", "web"]); /** UI 暂时隐藏「多频道 Pro」切换(DOM 与逻辑保留,改 false 即可恢复) */ const HIDE_YOUTUBE_MULTI_PRO_TOGGLE = false; type QuickLinkMap = Record; type ProLaneDraft = { name: string; streamKey: string; pm2Name: string; notes: string; }; function laneDraftFromChannel(channel: YoutubeProChannel): ProLaneDraft { return { name: channel.name, streamKey: channel.streamKey ?? "", pm2Name: channel.pm2Name ?? defaultPm2NameForChannel(channel.id), notes: channel.notes ?? "", }; } type LiveProcRow = { pm2: string; script?: string; label?: string; process_status?: string; business_status?: string; business_note?: string; is_pushing?: boolean; }; function pm2StatusOnline(st?: string) { return /^(online|running)$/i.test((st || "").trim()); } function businessStatusLabel(row: LiveProcRow | undefined, t: TFn): string { const business = (row?.business_status || "").trim().toLowerCase(); if (business === "ready") return t("已配置", "Ready"); if (business === "streaming") return t("推流中", "Pushing"); if (business === "waiting_source") return t("等待上游", "Waiting source"); if (business === "source_live") return t("源已开播,转推未起", "Source live, relay down"); if (business === "monitoring") return t("监控中", "Monitoring"); if (business === "misconfigured") return t("密钥异常", "Key invalid"); if (business === "stale") return t("心跳过期", "Stale"); if (business === "error") return t("异常", "Error"); if (pm2StatusOnline(row?.process_status)) return t("运行中", "Running"); return (row?.process_status || "").trim() || t("未运行", "Idle"); } function businessStatusBadgeClass(row: LiveProcRow | undefined): string { const business = (row?.business_status || "").trim().toLowerCase(); if (business === "ready") return "bg-sky-500/15 text-sky-100 ring-sky-500/25"; if (business === "streaming") return "bg-emerald-500/15 text-emerald-200 ring-emerald-500/30"; if (business === "waiting_source" || business === "monitoring") { return "bg-amber-500/15 text-amber-100 ring-amber-500/25"; } if (business === "source_live") return "bg-sky-500/15 text-sky-100 ring-sky-500/25"; if (business === "misconfigured" || business === "error" || business === "stale") { return "bg-rose-500/15 text-rose-100 ring-rose-500/25"; } if (pm2StatusOnline(row?.process_status)) return "bg-emerald-500/15 text-emerald-200 ring-emerald-500/30"; return "bg-zinc-600/20 text-zinc-400 ring-zinc-500/25"; } function nonCommentUrlLinesOf(text: string): string[] { return text .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line && !line.startsWith("#")); } function shouldPreserveLocalUrlDraft(localText: string, remoteText: string): boolean { return nonCommentUrlLinesOf(localText).length > 0 && nonCommentUrlLinesOf(remoteText).length === 0; } function parseProcBusy(busy: string | null): { action: string; pm2: string } | null { if (!busy?.startsWith("proc:")) return null; const rest = busy.slice(5); const idx = rest.indexOf(":"); if (idx <= 0) return null; const action = rest.slice(0, idx); const pm2 = rest.slice(idx + 1); if (!pm2 || !["start", "stop", "restart"].includes(action)) return null; return { action, pm2 }; } function LiveStreamActionBtn({ kind, busyKind, targetPm2, busy, online, label, className, baseLock, onRun, notify, t, compact, }: { kind: "start" | "stop" | "restart"; busyKind?: "start" | "stop" | "restart"; targetPm2: string; busy: string | null; online: boolean; label: string; className: string; baseLock: boolean; onRun: () => void; notify: (s: string) => void; t: TFn; compact?: boolean; }) { const pb = parseProcBusy(busy); const forThisProc = pb && pb.pm2 === targetPm2; const actionForBusy = busyKind ?? kind; const loading = !!(forThisProc && pb.action === actionForBusy); const siblingBusy = !!(forThisProc && pb.action !== actionForBusy); const blockStart = kind === "start" && online; const blockStop = kind === "stop" && !online; const blockRestart = kind === "restart" && !online; const blocked = blockStart || blockStop || blockRestart; const hardLock = baseLock || loading || siblingBusy; const onClick = () => { if (loading) return; if (siblingBusy) { notify(t("请等待当前操作完成", "Wait for the current action to finish")); return; } if (blockStart) { notify(t("已在直播中,无需重复开始", "Already live")); return; } if (blockStop) { notify(t("当前未在直播,无法停止", "Not live — cannot stop")); return; } if (blockRestart) { notify(t("进程未运行时请使用「开始直播」", "Use Start when the process is not running")); return; } if (baseLock) return; onRun(); }; const sizeCls = compact ? "min-h-[2rem] px-3 py-1.5 text-xs font-medium" : "min-h-[2.75rem] px-4 py-3 text-sm font-medium"; return ( ); } type Props = { t: TFn; busy: string | null; entries: ProcessEntry[]; currentProc: string; setCurrentProc: (v: string) => void; urlConfig: string; setUrlConfig: (v: string) => void; onRunProcess: (a: "start" | "stop" | "restart", processPm2?: string) => void; onSaveUrlConfig: (content?: string, processPm2?: string) => void | Promise; fetchJson: (path: string, init?: RequestInit) => Promise; liveProcesses: LiveProcRow[]; notify: (msg: string) => void; onNavigate?: (target: PortalNavTarget) => void; quickLinks?: QuickLinkMap; }; export function LiveYoutubeUnmannedView({ t, busy, entries, currentProc, setCurrentProc, urlConfig, setUrlConfig, onRunProcess, onSaveUrlConfig, fetchJson, liveProcesses, notify, onNavigate, quickLinks, }: Props) { const ytEntries = useMemo(() => entries.filter(isYoutubeProcess), [entries]); const staticYoutubeEntries = useMemo(() => ytEntries.filter((entry) => isStaticYoutubeControlEntry(entry)), [ytEntries]); const singleProcessOptions = useMemo( () => buildSingleProcessOptions(ytEntries.length ? ytEntries : entries, liveProcesses), [entries, liveProcesses, ytEntries], ); const preferredSingleProc = useMemo( () => preferredSingleProcessPm2FromRuntime(singleProcessOptions, liveProcesses) || preferredSingleProcessPm2(singleProcessOptions), [liveProcesses, singleProcessOptions], ); const [mode, setMode] = useState<"single" | "pro">("single"); const [channels, setChannels] = useState([]); const [activeCh, setActiveCh] = useState(""); const [youtubeIniText, setYoutubeIniText] = useState(""); const [ytFields, setYtFields] = useState({ key: "", rtmps: true, bitrate: "", fastAudio: false, }); const [ytIniStatus, setYtIniStatus] = useState(null); const [ytIniLoading, setYtIniLoading] = useState(false); const [proUrlDraft, setProUrlDraft] = useState(""); const [urlAutoRefresh, setUrlAutoRefresh] = useState(true); const [urlReloading, setUrlReloading] = useState(false); const [urlOptimizing, setUrlOptimizing] = useState(false); const [urlFetchNote, setUrlFetchNote] = useState(null); const [laneDrafts, setLaneDrafts] = useState>({}); const [laneSaving, setLaneSaving] = useState(false); const [laneStatus, setLaneStatus] = useState(null); const [activeViewRefreshToken, setActiveViewRefreshToken] = useState(0); const urlListDirtyRef = useRef(false); const singleUrlDraftRef = useRef(urlConfig); const proUrlDraftRef = useRef(proUrlDraft); const currentProcRef = useRef(currentProc); const urlRequestSeqRef = useRef(0); const ytIniRequestSeqRef = useRef(0); const applySingleUrlDraft = useCallback( (next: string) => { singleUrlDraftRef.current = next; setUrlConfig(next); }, [setUrlConfig], ); const applyProUrlDraft = useCallback((next: string) => { proUrlDraftRef.current = next; setProUrlDraft(next); }, []); useEffect(() => { urlListDirtyRef.current = false; }, [currentProc]); useEffect(() => { currentProcRef.current = currentProc; }, [currentProc]); useEffect(() => { singleUrlDraftRef.current = urlConfig; }, [urlConfig]); useEffect(() => { proUrlDraftRef.current = proUrlDraft; }, [proUrlDraft]); const pullUrlConfigFromServer = useCallback(async () => { const targetProc = currentProcRef.current.trim(); if (!targetProc) return; const requestId = ++urlRequestSeqRef.current; try { const params = new URLSearchParams({ process: targetProc, _ts: String(Date.now()), }); const data = await fetchJson<{ content?: string }>(`/get_url_config?${params}`); if (requestId !== urlRequestSeqRef.current || targetProc !== currentProcRef.current.trim()) return; const c = data.content ?? ""; if (urlListDirtyRef.current) return; if (mode === "single") applySingleUrlDraft(c); else { const localDraft = channels.find((row) => row.id === activeCh)?.urlLines ?? ""; if (shouldPreserveLocalUrlDraft(localDraft, c)) { applyProUrlDraft(localDraft); return; } applyProUrlDraft(c); if (activeCh) { setChannels((prev) => { const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x)); saveYoutubeProChannels(next); return next; }); } } } catch { /* ignore */ } }, [activeCh, applyProUrlDraft, applySingleUrlDraft, channels, fetchJson, mode]); useEffect(() => { if (!currentProc || !urlAutoRefresh) return; const id = window.setInterval(() => { if (urlListDirtyRef.current) return; void pullUrlConfigFromServer(); }, 8000); return () => clearInterval(id); }, [currentProc, urlAutoRefresh, pullUrlConfigFromServer]); const reloadUrlManual = async () => { const targetProc = currentProcRef.current.trim(); if (!targetProc) return; setUrlReloading(true); setUrlFetchNote(null); urlListDirtyRef.current = false; const requestId = ++urlRequestSeqRef.current; try { const params = new URLSearchParams({ process: targetProc, _ts: String(Date.now()), }); const data = await fetchJson<{ content?: string }>(`/get_url_config?${params}`); if (requestId !== urlRequestSeqRef.current || targetProc !== currentProcRef.current.trim()) return; const c = data.content ?? ""; if (urlListDirtyRef.current) { setUrlFetchNote(t("检测到本地未保存修改,已保留当前草稿", "Kept the local unsaved draft")); return; } if (mode === "single") applySingleUrlDraft(c); else { const localDraft = channels.find((row) => row.id === activeCh)?.urlLines ?? ""; if (shouldPreserveLocalUrlDraft(localDraft, c)) { applyProUrlDraft(localDraft); setUrlFetchNote(t("服务器暂无已保存 URL,已保留本地草稿", "Server has no saved URLs, kept the local draft")); return; } applyProUrlDraft(c); if (activeCh) { setChannels((prev) => { const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x)); saveYoutubeProChannels(next); return next; }); } } setUrlFetchNote(t("已从服务器读取 URL_config", "Reloaded URL_config from server")); } catch (e) { if (requestId !== urlRequestSeqRef.current || targetProc !== currentProcRef.current.trim()) return; setUrlFetchNote((e as Error).message); } finally { if (requestId === urlRequestSeqRef.current && targetProc === currentProcRef.current.trim()) { setUrlReloading(false); } } }; useEffect(() => { try { if (HIDE_YOUTUBE_MULTI_PRO_TOGGLE) { setMode("single"); return; } const m = window.localStorage.getItem(MODE_KEY); if (m === "pro" || m === "single") setMode(m); } catch { /* ignore */ } }, []); useEffect(() => { let cancelled = false; void (async () => { try { const remote = await fetchYoutubeProChannelsRemote(fetchJson); if (remote.fetched && remote.channels.length && !cancelled) { setChannels(remote.channels); saveYoutubeProChannels(remote.channels); return; } } catch { /* 使用本机缓存 */ } if (cancelled) return; const list = loadYoutubeProChannels(); const initial = list.length > 0 ? list : (() => { const id = newChannelId(); return [ { id, name: "线路 1", streamKey: "", urlLines: "", pm2Name: defaultPm2NameForChannel(id), notes: "", }, ]; })(); setChannels(initial); if (!list.length) saveYoutubeProChannels(initial); })(); return () => { cancelled = true; }; }, [fetchJson]); useEffect(() => { if (!channels.length) { if (activeCh) setActiveCh(""); return; } if (!activeCh || !channels.some((row) => row.id === activeCh)) { setActiveCh(channels[0].id); } }, [channels, activeCh]); const rowForPm2 = useCallback((pm2: string) => liveProcesses.find((p) => p.pm2 === pm2), [liveProcesses]); const statusForPm2 = useCallback( (pm2: string) => rowForPm2(pm2)?.process_status, [rowForPm2], ); const currentProcRow = rowForPm2(currentProc); const currentProcLooksYoutube = useMemo( () => !!currentProc && (/youtube/i.test(currentProc) || /youtube/i.test(currentProcRow?.script ?? "") || /youtube/i.test(currentProcRow?.label ?? "")), [currentProc, currentProcRow], ); useEffect(() => { if (mode !== "single" || !preferredSingleProc) return; if (!currentProc || !currentProcLooksYoutube) { setCurrentProc(preferredSingleProc); } }, [currentProc, currentProcLooksYoutube, mode, preferredSingleProc, setCurrentProc]); const applyChannelsLocal = useCallback((next: YoutubeProChannel[]) => { setChannels(next); saveYoutubeProChannels(next); }, []); const syncChannelsRemote = useCallback( async (next: YoutubeProChannel[]) => { await pushYoutubeProChannelsRemote(fetchJson, next); }, [fetchJson], ); const persistChannels = useCallback( (next: YoutubeProChannel[]) => { applyChannelsLocal(next); void syncChannelsRemote(next).catch((e) => { notify( `${t("多频道列表未同步到服务器", "Channel list not saved on server")}: ${(e as Error).message}`, ); }); }, [applyChannelsLocal, notify, syncChannelsRemote, t], ); const active = channels.find((c) => c.id === activeCh) || channels[0]; const activeLaneDraft = useMemo( () => (active ? laneDrafts[active.id] ?? laneDraftFromChannel(active) : null), [active, laneDrafts], ); useEffect(() => { if (!active) return; setLaneDrafts((prev) => { if (prev[active.id]) return prev; return { ...prev, [active.id]: laneDraftFromChannel(active) }; }); }, [active]); useEffect(() => { setLaneStatus(null); }, [activeCh]); const setLaneDraftField = useCallback( (key: keyof ProLaneDraft, value: string) => { if (!active) return; setLaneDrafts((prev) => ({ ...prev, [active.id]: { ...(prev[active.id] ?? laneDraftFromChannel(active)), [key]: value, }, })); setLaneStatus(null); }, [active], ); const activeSavedPm2 = active ? resolveChannelPm2(active) : ""; const activeDraftPm2 = active && activeLaneDraft ? resolveYoutubeProChannelPm2({ id: active.id, pm2Name: activeLaneDraft.pm2Name }) : ""; const activePm2PreviewChanged = !!active && !!activeLaneDraft && activeDraftPm2 !== activeSavedPm2; const activeLaneValidation = useMemo(() => { if (!active || !activeLaneDraft) { return { ok: false, message: t("请选择线路", "Pick a lane"), normalizedName: "", normalizedPm2: "", normalizedStreamKey: "", normalizedNotes: "", }; } const normalizedName = activeLaneDraft.name.trim() || `${t("线路", "Ch")} ${channels.findIndex((row) => row.id === active.id) + 1}`; const normalizedPm2 = resolveYoutubeProChannelPm2({ id: active.id, pm2Name: activeLaneDraft.pm2Name }); if (!normalizedPm2) { return { ok: false, message: t("进程名不能为空", "PM2 name is required"), normalizedName, normalizedPm2, normalizedStreamKey: activeLaneDraft.streamKey.trim(), normalizedNotes: activeLaneDraft.notes.trim(), }; } if (PRO_PM2_RESERVED.has(normalizedPm2)) { return { ok: false, message: t("该进程名保留给系统模块,请换一个", "This PM2 name is reserved"), normalizedName, normalizedPm2, normalizedStreamKey: activeLaneDraft.streamKey.trim(), normalizedNotes: activeLaneDraft.notes.trim(), }; } const duplicate = channels.find((row) => row.id !== active.id && resolveChannelPm2(row) === normalizedPm2); if (duplicate) { return { ok: false, message: t("该进程名已被其它线路占用", "Another lane already uses this PM2 name"), normalizedName, normalizedPm2, normalizedStreamKey: activeLaneDraft.streamKey.trim(), normalizedNotes: activeLaneDraft.notes.trim(), }; } return { ok: true, message: "", normalizedName, normalizedPm2, normalizedStreamKey: activeLaneDraft.streamKey.trim(), normalizedNotes: activeLaneDraft.notes.trim(), }; }, [active, activeLaneDraft, channels, t]); const activeLaneDirty = useMemo(() => { if (!active || !activeLaneDraft) return false; const savedDraft = laneDraftFromChannel(active); return ( activeLaneDraft.name !== savedDraft.name || activeLaneDraft.streamKey !== savedDraft.streamKey || sanitizeYoutubePm2Name(activeLaneDraft.pm2Name) !== sanitizeYoutubePm2Name(savedDraft.pm2Name) || activeLaneDraft.notes !== savedDraft.notes ); }, [active, activeLaneDraft]); const setModePersist = useCallback( (m: "single" | "pro") => { if (m === "single" && mode === "pro" && active) { const latestProUrlDraft = proUrlDraftRef.current; if (latestProUrlDraft !== (active.urlLines ?? "").trim()) { persistChannels(channels.map((x) => (x.id === active.id ? { ...x, urlLines: latestProUrlDraft } : x))); } } if (m === "pro" && channels.length) { const first = channels[0]; const latestSingleUrlDraft = singleUrlDraftRef.current.trim(); const youtubePm2Ok = staticYoutubeEntries.some((e) => e.pm2 === currentProc); const pm2Name = youtubePm2Ok && currentProc.trim() ? currentProc.trim() : resolveChannelPm2(first); const next: YoutubeProChannel[] = [ { ...first, streamKey: ytFields.key.trim() || first.streamKey || "", urlLines: latestSingleUrlDraft || first.urlLines || "", pm2Name, }, ...channels.slice(1), ]; persistChannels(next); const ch = next.find((c) => c.id === activeCh) ?? next[0]; applyProUrlDraft((ch?.urlLines ?? "").trim() || latestSingleUrlDraft); } setMode(m); try { window.localStorage.setItem(MODE_KEY, m); } catch { /* ignore */ } }, [ mode, active, channels, applyProUrlDraft, ytFields.key, currentProc, staticYoutubeEntries, activeCh, persistChannels, ], ); const selectProChannel = useCallback( (c: YoutubeProChannel) => { const latestProUrlDraft = proUrlDraftRef.current; if (active && mode === "pro" && latestProUrlDraft !== (active.urlLines ?? "").trim()) { persistChannels(channels.map((x) => (x.id === active.id ? { ...x, urlLines: latestProUrlDraft } : x))); } const u = (c.urlLines ?? "").trim(); applyProUrlDraft(u); setActiveCh(c.id); setCurrentProc(resolveChannelPm2(c)); }, [active, applyProUrlDraft, mode, channels, setCurrentProc, persistChannels], ); useEffect(() => { if (mode !== "pro" || !activeCh) return; const ch = channels.find((x) => x.id === activeCh); if (!ch) return; const want = resolveChannelPm2(ch); setCurrentProc(want); }, [mode, activeCh, channels, setCurrentProc]); const loadYoutubeIni = useCallback(async () => { const targetProc = currentProcRef.current.trim(); if (!targetProc) return; const requestId = ++ytIniRequestSeqRef.current; setYtIniLoading(true); setYtIniStatus(null); try { const params = new URLSearchParams({ process: targetProc, _ts: String(Date.now()), }); const data = await fetchJson<{ content?: string }>(`/get_config?${params}`); if (requestId !== ytIniRequestSeqRef.current || targetProc !== currentProcRef.current.trim()) return; const text = data.content ?? ""; setYoutubeIniText(text); setYtFields(parseYoutubeIniFields(text)); } catch (e) { if (requestId !== ytIniRequestSeqRef.current || targetProc !== currentProcRef.current.trim()) return; setYtIniStatus((e as Error).message); } finally { if (requestId === ytIniRequestSeqRef.current && targetProc === currentProcRef.current.trim()) { setYtIniLoading(false); } } }, [fetchJson]); useEffect(() => { if (!currentProc) return; void pullUrlConfigFromServer(); void loadYoutubeIni(); setActiveViewRefreshToken((prev) => prev + 1); }, [activeCh, currentProc, loadYoutubeIni, mode, pullUrlConfigFromServer]); const persistActiveLaneDraft = useCallback( async () => { if (!active || !activeLaneDraft) { throw new Error(t("请选择线路", "Pick a lane")); } if (!activeLaneValidation.ok) { throw new Error(activeLaneValidation.message); } const latestProUrlDraft = proUrlDraftRef.current; const next = channels.map((row) => row.id === active.id ? { ...row, name: activeLaneValidation.normalizedName, streamKey: activeLaneValidation.normalizedStreamKey, pm2Name: activeLaneValidation.normalizedPm2, notes: activeLaneValidation.normalizedNotes, urlLines: latestProUrlDraft, } : row, ); applyChannelsLocal(next); await syncChannelsRemote(next); setLaneDrafts((prev) => ({ ...prev, [active.id]: { name: activeLaneValidation.normalizedName, streamKey: activeLaneValidation.normalizedStreamKey, pm2Name: activeLaneValidation.normalizedPm2, notes: activeLaneValidation.normalizedNotes, }, })); return { next, savedLane: next.find((row) => row.id === active.id) ?? ({ ...active, name: activeLaneValidation.normalizedName, streamKey: activeLaneValidation.normalizedStreamKey, pm2Name: activeLaneValidation.normalizedPm2, notes: activeLaneValidation.normalizedNotes, urlLines: latestProUrlDraft, } satisfies YoutubeProChannel), pm2: activeLaneValidation.normalizedPm2, }; }, [ active, activeLaneDraft, activeLaneValidation, applyChannelsLocal, channels, syncChannelsRemote, t, ], ); const saveProLaneSettings = useCallback(async () => { if (!active) return; setLaneSaving(true); setLaneStatus(null); try { const saved = await persistActiveLaneDraft(); setCurrentProc(saved.pm2); setLaneStatus(t("线路设置已保存", "Lane settings saved")); } catch (e) { const message = (e as Error).message; setLaneStatus(message); notify(message); } finally { setLaneSaving(false); } }, [active, notify, persistActiveLaneDraft, setCurrentProc, t]); const saveYoutubeIniToServer = async (content: string, processPm2 = currentProcRef.current) => { const targetPm2 = processPm2.trim(); if (!targetPm2) { setYtIniStatus(t("请选择进程", "Pick a process")); return; } setYtIniLoading(true); setYtIniStatus(null); try { const params = new URLSearchParams({ process: targetPm2 }); await fetchJson(`/save_config?${params}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content }), }); setYoutubeIniText(content); setYtIniStatus(t("youtube.ini 已保存", "youtube.ini saved")); } catch (e) { setYtIniStatus((e as Error).message); } finally { setYtIniLoading(false); } }; const applyFieldsAndSave = async () => { const body = buildYoutubeIniFromFields(ytFields); await saveYoutubeIniToServer(body); }; const applyYoutubeBalancedPreset = async () => { setYtIniLoading(true); setYtIniStatus(null); try { const source = youtubeIniText || buildYoutubeIniFromFields(ytFields); const data = await fetchJson<{ message?: string; content?: string }>("/config_optimize", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ root: "app-config", path: managedYoutubeIniRelPathForPm2(currentProcRef.current), content: source, action: "youtube_balanced_preset", }), }); const next = data.content ?? source; setYoutubeIniText(next); setYtFields(parseYoutubeIniFields(next)); setYtIniStatus(data.message || t("已应用 YouTube 预设", "Applied YouTube preset")); } catch (e) { setYtIniStatus((e as Error).message); } finally { setYtIniLoading(false); } }; const syncFieldsFromText = () => { setYtFields(parseYoutubeIniFields(youtubeIniText)); setYtIniStatus(t("已从下方原文同步到表单", "Synced from raw")); }; const keySuffixUi = useMemo(() => { if (mode === "single") return parseYoutubeStreamKeySuffix(`key = ${ytFields.key}`); const k = activeLaneDraft?.streamKey ?? active?.streamKey ?? ""; return parseYoutubeStreamKeySuffix(`key = ${k}`); }, [mode, ytFields.key, active?.streamKey, activeLaneDraft?.streamKey]); const saveProUrlAndServer = async () => { if (!active) return; try { setLaneSaving(true); setLaneStatus(null); urlRequestSeqRef.current += 1; const latestProUrlDraft = proUrlDraftRef.current; const saved = await persistActiveLaneDraft(); setCurrentProc(saved.pm2); await Promise.resolve(onSaveUrlConfig(latestProUrlDraft, saved.pm2)); urlListDirtyRef.current = false; setLaneStatus(t("线路与地址已保存", "Lane settings and URLs saved")); } catch (e) { const message = e instanceof Error ? e.message : String(e); setLaneStatus(message); notify(message); } finally { setLaneSaving(false); } }; const saveSingleUrlAndClearDirty = async () => { const targetPm2 = currentProcRef.current.trim(); if (!targetPm2) { notify(t("请选择进程", "Pick a process")); return; } try { urlRequestSeqRef.current += 1; await Promise.resolve(onSaveUrlConfig(singleUrlDraftRef.current, targetPm2)); urlListDirtyRef.current = false; } catch { /* parent 已 toast */ } }; const dedupeUrlDraft = async () => { const source = mode === "single" ? singleUrlDraftRef.current : proUrlDraftRef.current; setUrlOptimizing(true); setUrlFetchNote(null); try { const data = await fetchJson<{ message?: string; content?: string }>("/config_optimize", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ root: "app-config", path: managedUrlConfigRelPathForPm2(currentProcRef.current), content: source, action: "dedupe_urls", }), }); const next = data.content ?? source; if (mode === "single") applySingleUrlDraft(next); else { applyProUrlDraft(next); } urlListDirtyRef.current = true; setUrlFetchNote(data.message || t("已去重当前 URL 列表", "Deduplicated current URL list")); } catch (e) { setUrlFetchNote((e as Error).message); } finally { setUrlOptimizing(false); } }; const pushProStreamKeyToServer = async () => { if (!active || !activeLaneDraft) return; const base = parseYoutubeIniFields(youtubeIniText || buildYoutubeIniFromFields(ytFields)); const f: YoutubeIniFields = { key: activeLaneDraft.streamKey.trim(), rtmps: base.rtmps, bitrate: base.bitrate, fastAudio: base.fastAudio, }; const body = buildYoutubeIniFromFields(f); setLaneSaving(true); setLaneStatus(null); try { const saved = await persistActiveLaneDraft(); setCurrentProc(saved.pm2); await saveYoutubeIniToServer(body, saved.pm2); setLaneStatus(t("线路设置与 youtube.ini 已保存", "Lane settings and youtube.ini saved")); } catch (e) { const message = (e as Error).message; setLaneStatus(message); notify(message); } finally { setLaneSaving(false); } }; const currentUrlDraft = mode === "single" ? urlConfig : proUrlDraft; const currentUrlCount = useMemo(() => nonCommentUrlLinesOf(currentUrlDraft).length, [currentUrlDraft]); const currentDouyinCount = useMemo(() => extractDouyinLiveUrls(currentUrlDraft).length, [currentUrlDraft]); const laneQuickLinks = useMemo( () => [ quickLinks?.neko_browser, quickLinks?.chromium_remote_debug, quickLinks?.srs_api, ].filter((value): value is string => !!value), [quickLinks], ); const flushProChannelsBeforeRun = useCallback( async (targetPm2: string) => { const next = channels; const activePm2 = active ? resolveChannelPm2(active) : ""; if (active && targetPm2 === activePm2) { const saved = await persistActiveLaneDraft(); return saved; } applyChannelsLocal(next); await syncChannelsRemote(next); return { next, savedLane: active ?? null, pm2: targetPm2, }; }, [active, applyChannelsLocal, channels, persistActiveLaneDraft, syncChannelsRemote], ); const runProProcess = useCallback( async (action: "start" | "stop" | "restart", targetPm2: string) => { const targetMatchesActiveLane = !!active && resolveChannelPm2(active) === targetPm2; if (action !== "stop") { let next = channels; let effectivePm2 = targetPm2; try { const saved = await flushProChannelsBeforeRun(targetPm2); next = saved.next; effectivePm2 = saved.pm2; } catch (e) { notify( `${t("当前线路保存失败", "Failed to save the active lane")}: ${(e as Error).message}`, ); return; } const target = next.find( (row) => resolveChannelPm2(row) === effectivePm2, ); const urlText = target?.urlLines ?? ""; if (!((target?.streamKey ?? "").trim())) { notify(t("请先填写当前线路的推流密钥", "Fill the stream key before starting")); return; } if (!nonCommentUrlLinesOf(urlText).length) { notify(t("请先填写当前线路的直播地址", "Fill the source URLs before starting")); return; } targetPm2 = effectivePm2; } if (targetMatchesActiveLane) { setCurrentProc(targetPm2); } onRunProcess(action, targetPm2); }, [active, channels, flushProChannelsBeforeRun, notify, onRunProcess, setCurrentProc, t], ); const lock = !!busy || ytIniLoading || urlReloading || urlOptimizing || laneSaving; const singleRow = currentProcRow; const singleOnline = pm2StatusOnline(statusForPm2(currentProc)); const urlToolbar = (
); return (
{onNavigate ? ( ) : null}

{t("YouTube 无人直播", "YouTube unmanned live")}

{t( "单路一条流;多路时每条线路独立保存自己的密钥、URL 列表和 PM2 任务。填好后保存,再点「开始」。Neko 浏览器仅作辅助工作室,不再阻塞推流。", "Single lane or multi-lane: every lane keeps its own key, URL list, and PM2 task. Save, then Start. Neko is optional browser tooling and no longer blocks streaming.", )}

{laneQuickLinks.length ? (
{quickLinks?.neko_browser ? ( {t("打开 Neko 工作室", "Open Neko studio")} ) : null} {quickLinks?.chromium_remote_debug ? ( {t("打开 Chromium 调试", "Open Chromium debug")} ) : null} {quickLinks?.srs_api ? ( {t("打开 SRS API", "Open SRS API")} ) : null}
) : null}

{t("直播开关", "Live control")}

{mode === "single" ? ( <>
onRunProcess("start", currentProc)} notify={notify} t={t} /> onRunProcess("stop", currentProc)} notify={notify} t={t} /> onRunProcess("restart", currentProc)} notify={notify} t={t} />

{t("当前状态", "Current state")}: {businessStatusLabel(singleRow, t)}

key {t("尾码", "suffix")}: {keySuffixUi || "—"}

) : ( <>

{t("Pro 转播实况", "Pro relay status")}

{t("对齐桌面端:每路独立 URL / youtube 配置文件;点左侧信息切换编辑,右侧控制进程。", "Per-lane config files like desktop; click row info to edit, use right-side controls for PM2.")}

{channels.map((c) => { const pm = resolveChannelPm2(c); const row = rowForPm2(pm); const st = statusForPm2(pm); const laneUrls = nonCommentUrlLinesOf((c.urlLines ?? "").trim()); const douyinUrls = extractDouyinLiveUrls((c.urlLines ?? "").trim()); const dUrl = douyinUrls[0]; const suf = parseYoutubeStreamKeySuffix(`key = ${c.streamKey ?? ""}`); const sel = activeCh === c.id; const rowOnline = pm2StatusOnline(st); return (
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} > void runProProcess("start", pm)} notify={notify} t={t} compact /> void runProProcess("stop", pm)} notify={notify} t={t} compact /> void runProProcess("restart", pm)} notify={notify} t={t} compact />
); })}
)}
{mode === "single" ? (

{t("YouTube 相关设置", "YouTube settings")}

{t("串流参数写进 youtube.ini,无需 Google 登录。", "Stream settings go to youtube.ini — no Google login.")}

setYtFields((f) => ({ ...f, key: e.target.value }))} placeholder="xxxx-xxxx-xxxx-xxxx-xxxx" />
{t("高级:youtube.ini 原文", "Raw youtube.ini")}