"use client"; import { Ban, Loader2, MonitorPlay, Plus, Radio, RefreshCw, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { buildYoutubeIniFromFields, extractDouyinLiveUrls, parseYoutubeIniFields, parseYoutubeStreamKeySuffix, type YoutubeIniFields, } from "@/lib/youtubeConfigUtils"; import { LiveProcessLiveLogCard } from "@/components/live-product/LiveProcessLiveLogCard"; import { defaultPm2NameForChannel, loadYoutubeProChannels, newChannelId, saveYoutubeProChannels, type YoutubeProChannel, } from "@/lib/youtubeProChannels"; 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); } const MODE_KEY = "live-hub-youtube-mode-v1"; type LiveProcRow = { pm2: string; process_status?: string }; function pm2StatusOnline(st?: string) { return /^(online|running)$/i.test((st || "").trim()); } 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, targetPm2, busy, online, label, className, baseLock, onRun, notify, t, compact, }: { kind: "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 loading = !!(forThisProc && pb.action === kind); const siblingBusy = !!(forThisProc && pb.action !== kind); 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) => void | Promise; fetchJson: (path: string, init?: RequestInit) => Promise; liveProcesses: LiveProcRow[]; notify: (msg: string) => void; }; export function LiveYoutubeUnmannedView({ t, busy, entries, currentProc, setCurrentProc, urlConfig, setUrlConfig, onRunProcess, onSaveUrlConfig, fetchJson, liveProcesses, notify, }: Props) { const ytEntries = useMemo(() => entries.filter(isYoutubeProcess), [entries]); const processOptions = ytEntries.length ? ytEntries : entries; 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 urlListDirtyRef = useRef(false); useEffect(() => { urlListDirtyRef.current = false; }, [currentProc]); const pullUrlConfigFromServer = useCallback(async () => { if (!currentProc) return; try { const data = await fetchJson<{ content?: string }>( `/get_url_config?process=${encodeURIComponent(currentProc)}`, ); const c = data.content ?? ""; if (mode === "single") setUrlConfig(c); else setProUrlDraft(c); } catch { /* ignore */ } }, [currentProc, fetchJson, mode, setUrlConfig]); 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 () => { if (!currentProc) return; setUrlReloading(true); setUrlFetchNote(null); urlListDirtyRef.current = false; try { const data = await fetchJson<{ content?: string }>( `/get_url_config?process=${encodeURIComponent(currentProc)}`, ); const c = data.content ?? ""; if (mode === "single") setUrlConfig(c); else setProUrlDraft(c); setUrlFetchNote(t("已从服务器读取 URL_config", "Reloaded URL_config from server")); } catch (e) { setUrlFetchNote((e as Error).message); } finally { setUrlReloading(false); } }; useEffect(() => { try { const m = window.localStorage.getItem(MODE_KEY); if (m === "pro" || m === "single") setMode(m); } catch { /* ignore */ } }, []); const setModePersist = (m: "single" | "pro") => { setMode(m); if (m === "pro") { const ch = channels.find((c) => c.id === activeCh) ?? channels[0]; if (ch) { const u = (ch.urlLines ?? "").trim(); setProUrlDraft(u || urlConfig); } } try { window.localStorage.setItem(MODE_KEY, m); } catch { /* ignore */ } }; useEffect(() => { 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); }, []); useEffect(() => { if (channels.length && !activeCh) setActiveCh(channels[0].id); }, [channels, activeCh]); const persistChannels = useCallback((next: YoutubeProChannel[]) => { setChannels(next); saveYoutubeProChannels(next); }, []); const active = channels.find((c) => c.id === activeCh) || channels[0]; const proLanesWithKey = useMemo( () => channels.filter((ch) => (ch.streamKey ?? "").trim().length > 0), [channels], ); const selectProChannel = useCallback( (c: YoutubeProChannel) => { if (active && mode === "pro") { persistChannels(channels.map((x) => (x.id === active.id ? { ...x, urlLines: proUrlDraft } : x))); } const u = (c.urlLines ?? "").trim(); setProUrlDraft(u || urlConfig); setActiveCh(c.id); }, [active, mode, channels, proUrlDraft, urlConfig, persistChannels], ); useEffect(() => { if (mode !== "pro" || !activeCh) return; const ch = channels.find((x) => x.id === activeCh); if (!ch) return; const want = (ch.pm2Name ?? "").trim() || defaultPm2NameForChannel(ch.id); if (processOptions.some((e) => e.pm2 === want)) setCurrentProc(want); }, [mode, activeCh, channels, processOptions, setCurrentProc]); const loadYoutubeIni = useCallback(async () => { if (!currentProc) return; setYtIniLoading(true); setYtIniStatus(null); try { const data = await fetchJson<{ content?: string }>( `/get_config?process=${encodeURIComponent(currentProc)}`, ); const text = data.content ?? ""; setYoutubeIniText(text); setYtFields(parseYoutubeIniFields(text)); } catch (e) { setYtIniStatus((e as Error).message); } finally { setYtIniLoading(false); } }, [currentProc, fetchJson]); useEffect(() => { if (!currentProc) return; void loadYoutubeIni(); }, [currentProc, loadYoutubeIni]); const updateActive = (patch: Partial) => { if (!active) return; persistChannels(channels.map((c) => (c.id === active.id ? { ...c, ...patch } : c))); }; const saveYoutubeIniToServer = async (content: string) => { if (!currentProc) { setYtIniStatus(t("请选择进程", "Pick a process")); return; } setYtIniLoading(true); setYtIniStatus(null); try { const params = new URLSearchParams({ process: currentProc }); 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: "youtube.ini", 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 = active?.streamKey ?? ""; return parseYoutubeStreamKeySuffix(`key = ${k}`); }, [mode, ytFields.key, active?.streamKey]); const saveProUrlAndServer = async () => { if (!active) return; updateActive({ urlLines: proUrlDraft }); setUrlConfig(proUrlDraft); try { await Promise.resolve(onSaveUrlConfig(proUrlDraft)); urlListDirtyRef.current = false; } catch { /* parent 已 toast */ } }; const saveSingleUrlAndClearDirty = async () => { try { await Promise.resolve(onSaveUrlConfig()); urlListDirtyRef.current = false; } catch { /* parent 已 toast */ } }; const dedupeUrlDraft = async () => { const source = mode === "single" ? urlConfig : proUrlDraft; 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: "URL_config.ini", content: source, action: "dedupe_urls", }), }); const next = data.content ?? source; if (mode === "single") setUrlConfig(next); else { setProUrlDraft(next); if (active) updateActive({ urlLines: 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) return; const base = parseYoutubeIniFields(youtubeIniText || buildYoutubeIniFromFields(ytFields)); const f: YoutubeIniFields = { key: (active.streamKey ?? "").trim(), rtmps: base.rtmps, bitrate: base.bitrate, fastAudio: base.fastAudio, }; const body = buildYoutubeIniFromFields(f); await saveYoutubeIniToServer(body); updateActive({ streamKey: f.key }); }; const lock = !!busy || ytIniLoading || urlReloading || urlOptimizing; const statusForPm2 = useCallback( (pm2: string) => liveProcesses.find((p) => p.pm2 === pm2)?.process_status, [liveProcesses], ); const singleOnline = pm2StatusOnline(statusForPm2(currentProc)); const urlToolbar = (
); return (

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

{t( "单路一条流;多路时每条线路一把密钥、一个进程名,填好密钥和直播地址后保存,再点「开始」。无需 Google 登录。", "Single lane or multi-lane: one key and one process per lane — fill key & URLs, save, then Start. No Google login.", )}

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

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

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

) : ( <>

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

{t("对齐桌面端工作台:每路密钥、源站、进程状态一览;点行可切换编辑。", "Like the desktop workspace: per-lane key, source, PM2 status.")}

{channels.map((c) => { const pm = (c.pm2Name ?? "").trim() || defaultPm2NameForChannel(c.id); const st = statusForPm2(pm); const on = pm2StatusOnline(st); const dUrl = extractDouyinLiveUrls((c.urlLines ?? "").trim())[0]; const suf = parseYoutubeStreamKeySuffix(`key = ${c.streamKey ?? ""}`); const sel = activeCh === c.id; return (
{on ? t("运行中", "Live") : st || t("未运行", "Idle")} {dUrl ? ( e.stopPropagation()} title={dUrl} > {dUrl.replace(/^https?:\/\//, "")} ) : ( )}
); })}

{t("点选一行即可编辑该线路;下方列表为已保存密钥的线路控制。", "Pick a row above to edit; the list below is lanes with a saved key.")}

{proLanesWithKey.length === 0 ? (

{channels.length > 0 ? t("请先在下方填写串流密钥并点「保存」。", "Enter a stream key below and tap Save.") : t("请先新增线路。", "Add a lane first.")}

) : (
{proLanesWithKey.map((c) => { const pm = (c.pm2Name ?? "").trim() || defaultPm2NameForChannel(c.id); const suffix = parseYoutubeStreamKeySuffix(`key = ${c.streamKey ?? ""}`); const sel = activeCh === c.id; const rowOnline = pm2StatusOnline(statusForPm2(pm)); return (
selectProChannel(c)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); selectProChannel(c); } }} className={`rounded-xl border p-3 text-left transition ${ sel ? "border-violet-500/45 bg-violet-500/[0.08]" : "border-white/10 bg-black/30 hover:border-white/20" }`} >
{suffix || "—"} {c.name}
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} > onRunProcess("start", pm)} notify={notify} t={t} compact /> onRunProcess("stop", pm)} notify={notify} t={t} compact /> onRunProcess("restart", pm)} notify={notify} t={t} compact />
); })}
)}

{t("当前线路", "Current lane")}: {active?.name ?? "—"} · key {keySuffixUi || "—"}

)}
{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")}