"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; type Entry = { pm2: string; script: string; label: string }; const apiBase = () => (typeof process !== "undefined" && process.env.NEXT_PUBLIC_API_URL) || ""; function stem(script: string) { const base = script.split(/[/\\]/).pop() || script; const i = base.lastIndexOf("."); return i > 0 ? base.slice(0, i) : base; } function isYoutubeScript(script: string) { if (!/\.py$/i.test(script)) return false; const s = stem(script).toLowerCase(); return s.startsWith("youtube") || s.startsWith("douyin_youtube"); } function isTiktokScript(script: string) { return /\.py$/i.test(script) && stem(script).toLowerCase().startsWith("tiktok"); } function isObsScript(script: string) { return stem(script).toLowerCase().startsWith("obs"); } function isWebScript(script: string) { return stem(script).toLowerCase() === "web"; } function statusMeta(processStatus: string): { dot: string; text: string; pillClass: string; } { switch (processStatus) { case "online": return { dot: "bg-emerald-400 shadow-[0_0_12px_rgba(52,211,153,0.55)]", text: "运行中", pillClass: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200", }; case "stopped": return { dot: "bg-slate-500", text: "已停止", pillClass: "border-slate-500/30 bg-slate-500/10 text-slate-300", }; case "errored": return { dot: "bg-rose-500 shadow-[0_0_10px_rgba(244,63,94,0.45)]", text: "异常", pillClass: "border-rose-500/30 bg-rose-500/10 text-rose-200", }; case "launching": case "starting": return { dot: "bg-amber-400 animate-pulse-soft", text: "启动中", pillClass: "border-amber-500/30 bg-amber-500/10 text-amber-200", }; case "waiting restart": return { dot: "bg-amber-400", text: "等待重启", pillClass: "border-amber-500/30 bg-amber-500/10 text-amber-200", }; case "not_found": return { dot: "bg-violet-400", text: "未启动过", pillClass: "border-violet-500/30 bg-violet-500/10 text-violet-200", }; case "invalid": return { dot: "bg-slate-600", text: "选择无效", pillClass: "border-slate-600/40 bg-slate-800/50 text-slate-400", }; default: return { dot: "bg-slate-500", text: "检测中…", pillClass: "border-slate-500/25 bg-slate-800/40 text-slate-400", }; } } function hintForEntry(script: string): string { if (isObsScript(script)) return "用于从本机 SRS 拉流并在接好显示器的机器上全屏播放(HDMI)。请先保证 SRS 已启动,再点「启动」。"; if (stem(script).toLowerCase().startsWith("douyin_youtube")) return "抖音直播转推到 YouTube:先填好右侧 youtube.ini(密钥)和 URL_config(直播间地址),保存后再启动。"; if (isYoutubeScript(script)) return "YouTube 相关推流:检查 youtube.ini 与 URL_config,保存后启动。"; if (isTiktokScript(script)) return "多平台录制/监测:在 URL_config 里填写直播间地址,保存后启动。"; if (isWebScript(script)) return "这是网页控制台本身。一般不要用这里停掉自己;其它机器上可用 PM2 管理。"; return "选中后使用左侧按钮启动或停止,右侧查看日志。"; } const obsDefault = "srt://127.0.0.1:10080?streamid=#!::r=live/livestream,m=request&latency=10"; export default function Home() { const [entries, setEntries] = useState([]); const [backend, setBackend] = useState(""); const [platform, setPlatform] = useState(""); const [current, setCurrent] = useState(""); const [processStatus, setProcessStatus] = useState("unknown"); const [rawStatus, setRawStatus] = useState(""); const [recentLog, setRecentLog] = useState(""); const [recentErr, setRecentErr] = useState(""); const [busy, setBusy] = useState(null); const [toast, setToast] = useState(null); const [loadError, setLoadError] = useState(null); const [bootLoading, setBootLoading] = useState(true); const [autoRefresh, setAutoRefresh] = useState(true); const [logTab, setLogTab] = useState<"out" | "err" | "split">("split"); const [helpOpen, setHelpOpen] = useState(true); const [confirmStop, setConfirmStop] = useState(false); const [youtubeIni, setYoutubeIni] = useState(""); const [urlIni, setUrlIni] = useState(""); const [cfgYoutubeStatus, setCfgYoutubeStatus] = useState("就绪"); const [cfgUrlStatus, setCfgUrlStatus] = useState("就绪"); const base = apiBase(); const ent = useMemo( () => entries.find((e) => e.pm2 === current) || null, [entries, current], ); const script = ent?.script || ""; const showYoutube = ent && isYoutubeScript(script); const showUrl = ent && (isYoutubeScript(script) || isTiktokScript(script)); const showObs = ent && isObsScript(script); const showToast = useCallback((msg: string) => { setToast(msg); setTimeout(() => setToast(null), 3200); }, []); const fetchJson = useCallback( async (path: string) => { const res = await fetch(`${base}${path}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }, [base], ); const refreshBackendInfo = useCallback(async () => { try { const info = await fetchJson("/server_info?refresh=true"); setBackend(info.process_backend || ""); setPlatform(info.platform || ""); showToast( info.process_backend === "pm2" ? "已切换为 PM2 管理" : "已切换为本地进程模式(未检测到 PM2)", ); } catch { showToast("刷新后端信息失败"); } }, [fetchJson, showToast]); useEffect(() => { (async () => { setBootLoading(true); setLoadError(null); try { const [mon, info] = await Promise.all([ fetchJson("/process_monitor"), fetchJson("/server_info"), ]); const list: Entry[] = (mon.entries || []).map((e: Entry) => ({ script: e.script, label: e.label || e.script, pm2: e.pm2 || stem(e.script), })); setEntries(list); setBackend(info.process_backend || ""); setPlatform(info.platform || ""); setCurrent((prev) => prev || list[0]?.pm2 || ""); } catch { setLoadError( "连不上后端。请确认已运行 web.sh / web.bat,并用浏览器打开同一地址(开发时可先起 uvicorn 再 npm run dev)。", ); } finally { setBootLoading(false); } })(); }, [fetchJson]); const refreshStatus = useCallback(async () => { if (!current) return; try { const data = await fetchJson(`/status?process=${encodeURIComponent(current)}`); setProcessStatus(data.process_status || "unknown"); setRawStatus(data.raw_status || ""); setRecentLog(data.recent_log || ""); setRecentErr(data.recent_error || ""); } catch { setProcessStatus("unknown"); } }, [current, fetchJson]); useEffect(() => { if (!current || !autoRefresh) return; const t = setInterval(refreshStatus, 1000); refreshStatus(); return () => clearInterval(t); }, [current, autoRefresh, refreshStatus]); useEffect(() => { if (current && !autoRefresh) void refreshStatus(); }, [current, autoRefresh, refreshStatus]); const loadYoutube = useCallback(async () => { if (!current) return; setCfgYoutubeStatus("加载中…"); try { const data = await fetchJson(`/get_config?process=${encodeURIComponent(current)}`); setYoutubeIni(data.content || ""); setCfgYoutubeStatus("已从服务器加载"); } catch (e) { setCfgYoutubeStatus(`失败: ${(e as Error).message}`); } }, [current, fetchJson]); const saveYoutube = async () => { if (!current) return; setCfgYoutubeStatus("保存中…"); try { const res = await fetch( `${base}/save_config?process=${encodeURIComponent(current)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: youtubeIni }), }, ); const data = await res.json(); if (!res.ok) throw new Error(data.error || res.statusText); setCfgYoutubeStatus(data.message || "已保存到服务器"); showToast("youtube.ini 已保存"); } catch (e) { setCfgYoutubeStatus(`失败: ${(e as Error).message}`); } }; const loadUrl = useCallback(async () => { if (!current) return; setCfgUrlStatus("加载中…"); try { const data = await fetchJson(`/get_url_config?process=${encodeURIComponent(current)}`); setUrlIni(data.content || ""); setCfgUrlStatus("已从服务器加载"); } catch (e) { setCfgUrlStatus(`失败: ${(e as Error).message}`); } }, [current, fetchJson]); const saveUrl = async () => { if (!current) return; setCfgUrlStatus("保存中…"); try { const res = await fetch( `${base}/save_url_config?process=${encodeURIComponent(current)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: urlIni }), }, ); const data = await res.json(); if (!res.ok) throw new Error(data.error || res.statusText); setCfgUrlStatus(data.message || "已保存到服务器"); showToast("URL 配置已保存"); } catch (e) { setCfgUrlStatus(`失败: ${(e as Error).message}`); } }; useEffect(() => { if (!current) return; if (showYoutube) void loadYoutube(); if (showUrl) void loadUrl(); }, [current, showYoutube, showUrl, loadYoutube, loadUrl]); const runAction = async (action: string) => { if (!current) return; setBusy(action); try { const res = await fetch(`${base}/${action}?process=${encodeURIComponent(current)}`); const data = await res.json(); if (!res.ok) { showToast(String(data.output || data.message || "操作失败")); return; } if (action !== "status") { const msg = data.output ?? data.pm2_output ?? data.message ?? "已完成"; showToast(typeof msg === "string" ? msg : JSON.stringify(msg)); setTimeout(refreshStatus, 1200); } else { setProcessStatus(data.process_status || "unknown"); setRawStatus(data.raw_status || ""); setRecentLog(data.recent_log || ""); setRecentErr(data.recent_error || ""); } } catch (e) { showToast((e as Error).message); } finally { setBusy(null); } }; const onStopClick = () => { if (isWebScript(script)) { showToast("这是控制台进程,停止后网页会失效,请谨慎操作"); } setConfirmStop(true); }; const confirmStopRun = async () => { setConfirmStop(false); await runAction("stop"); }; const copyObs = async () => { try { await navigator.clipboard.writeText(obsDefault); showToast("已复制到剪贴板"); } catch { showToast("复制失败,请手动选中文字复制"); } }; const st = statusMeta(processStatus); return (
{toast && (
{toast}
)} {confirmStop && (

确认停止该进程?

停止后推流或播放会中断。若进程卡住,停止后系统还会尝试结束相关 ffmpeg。

{ent && (

{ent.label} · {ent.script}

)}
)}

直播推流 · 小白友好

控制台

选一个程序 → 需要时先改配置并保存 → 点「启动」。下面有图文说明。看不懂日志时,把「错误输出」截图发给技术支持。

{platform && ( 系统 {platform} )} {backend && ( {backend === "pm2" ? "PM2 管理" : backend === "local" ? "本地进程(无 PM2)" : backend} )}
{helpOpen && (

1

打开本页

Windows / Ubuntu 共用同一套代码:git pull 后双击/运行{" "} start.bat 或{" "} ./start.sh(生产),开发双端用{" "} dev.bat /{" "} ./dev.sh。启动时会自动检测 Python、ffmpeg、npm 等。 端口与同机 SRS、代理冲突时,复制{" "} config/runtime.env.example 为{" "} config/runtime.env 修改{" "} PORT。Docker 默认 8101

2

选进程、改配置

左侧下拉框默认首选 YouTube / 抖音→YouTube ,可切换到 TikTok、OBS 等。需要密钥、直播间地址时,在右侧编辑后点「保存到服务器」再启动。

3

启动并看日志

点「启动」,看右侧绿色「运行日志」。红色「错误输出」有内容时,多半要改配置或检查网络。

HDMI / OBS: 需本机已装 SRS(拉流端口多为 10080 等,与控制台 Web 端口不是同一个)。显示器接好后,OBS 选「自定义」推流,服务器填下方 SRT 地址,密钥留空。

局域网访问: 生产模式默认绑定 0.0.0.0,同一 WiFi/内网下用手机或电脑浏览器访问{" "} http://这台机器的IP:端口/ 即可(注意防火墙放行端口)。

)}
{bootLoading && (

正在连接服务器…

)} {!bootLoading && loadError && (

无法连接

{loadError}

)} {!bootLoading && !loadError && !entries.length && (

没有找到可管理的脚本

项目根目录应有 tiktok*.py、youtube*.py、douyin_youtube*.py 或 obs 脚本(Linux 用 .sh,Windows 用 .bat)。

)} {!bootLoading && !loadError && entries.length > 0 && (

配置与日志

{( [ ["split", "双栏"], ["out", "仅运行"], ["err", "仅错误"], ] as const ).map(([id, lab]) => ( ))}
{showObs && (

OBS 推流地址(接到 HDMI 前在 OBS 里填)

OBS → 设置 → 推流 → 服务选「自定义」。下方整行就是「服务器」内容,「串流密钥」留空。若 SRS 不在本机,把{" "} 127.0.0.1 改成服务器 IP。

{obsDefault}
)} {showYoutube && (

YouTube 密钥(youtube.ini)

改完务必点「保存到服务器」,再回左侧点「启动」。