diff --git a/web-console/.eslintrc.json b/web-console/.eslintrc.json new file mode 100644 index 0000000..3722418 --- /dev/null +++ b/web-console/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"] +} diff --git a/web-console/.gitignore b/web-console/.gitignore new file mode 100644 index 0000000..fd3dbb5 --- /dev/null +++ b/web-console/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/web-console/README.md b/web-console/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/web-console/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/web-console/app/favicon.ico b/web-console/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/web-console/app/favicon.ico differ diff --git a/web-console/app/fonts/GeistMonoVF.woff b/web-console/app/fonts/GeistMonoVF.woff new file mode 100644 index 0000000..f2ae185 Binary files /dev/null and b/web-console/app/fonts/GeistMonoVF.woff differ diff --git a/web-console/app/fonts/GeistVF.woff b/web-console/app/fonts/GeistVF.woff new file mode 100644 index 0000000..1b62daa Binary files /dev/null and b/web-console/app/fonts/GeistVF.woff differ diff --git a/web-console/app/globals.css b/web-console/app/globals.css new file mode 100644 index 0000000..7b936e1 --- /dev/null +++ b/web-console/app/globals.css @@ -0,0 +1,85 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --bg-deep: #060912; + --bg-card: rgba(16, 22, 36, 0.78); + --accent: #22d3ee; + --accent2: #a78bfa; + --danger: #f43f5e; + --ok: #34d399; + --text: #e8edf7; + --muted: #94a3b8; +} + +body { + color: var(--text); + background: var(--bg-deep); + min-height: 100vh; + font-family: var(--font-geist-sans), system-ui, sans-serif; +} + +::selection { + background: rgba(34, 211, 238, 0.25); +} + +@layer utilities { + .glass { + @apply border border-white/[0.08] shadow-2xl shadow-black/40; + background: var(--bg-card); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + } + + .glow-text { + text-shadow: 0 0 32px rgba(34, 211, 238, 0.28); + } + + .btn-focus { + @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/50 focus-visible:ring-offset-2 focus-visible:ring-offset-[#060912]; + } +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translate(-50%, 12px); + } + to { + opacity: 1; + transform: translate(-50%, 0); + } +} + +.animate-toast-in { + animation: toast-in 0.35s ease-out both; +} + +@keyframes pulse-soft { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.65; + } +} + +.animate-pulse-soft { + animation: pulse-soft 2s ease-in-out infinite; +} + +/* 自定义滚动条,日志区更易读 */ +.log-scroll { + scrollbar-width: thin; + scrollbar-color: rgba(148, 163, 184, 0.35) transparent; +} +.log-scroll::-webkit-scrollbar { + width: 6px; + height: 6px; +} +.log-scroll::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.35); + border-radius: 6px; +} diff --git a/web-console/app/layout.tsx b/web-console/app/layout.tsx new file mode 100644 index 0000000..eca5a89 --- /dev/null +++ b/web-console/app/layout.tsx @@ -0,0 +1,42 @@ +import type { Metadata } from "next"; +import localFont from "next/font/local"; +import "./globals.css"; + +const geistSans = localFont({ + src: "./fonts/GeistVF.woff", + variable: "--font-geist-sans", + weight: "100 900", +}); +const geistMono = localFont({ + src: "./fonts/GeistMonoVF.woff", + variable: "--font-geist-mono", + weight: "100 900", +}); + +export const metadata: Metadata = { + title: "直播推流控制台", + description: "YouTube / TikTok / HDMI 推流进程与配置管理", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + +
+ {children} + + + ); +} diff --git a/web-console/app/page.tsx b/web-console/app/page.tsx new file mode 100644 index 0000000..0ef26fd --- /dev/null +++ b/web-console/app/page.tsx @@ -0,0 +1,791 @@ +"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 +

+

打开本页

+

+ 服务器上先执行 web.sh{" "} + (Windows 用 web.bat + )。浏览器地址栏要和启动时提示的端口一致。 +

+
+
+

+ 2 +

+

选进程、改配置

+

+ 左侧下拉框选「抖音→YouTube」或「TikTok」等。需要填密钥、直播间地址时,在右侧编辑后点「保存」。 +

+
+
+

+ 3 +

+

启动并看日志

+

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

+
+
+

+ HDMI / OBS: + 需本机已装 SRS,且显示器已接好。OBS 里选「自定义」推流,服务器填下面给出的 SRT 地址,密钥留空。 +

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

+

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

+