diff --git a/install-oneclick.sh b/install-oneclick.sh
new file mode 100644
index 0000000..f0602a1
--- /dev/null
+++ b/install-oneclick.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+# 一键安装入口:Ubuntu/Debian 系 + 常见 ARM/x86_64,再调用现有 install-all(hub + business)
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+log() {
+ printf '[oneclick] %s\n' "$*"
+}
+
+if [ ! -f /etc/os-release ]; then
+ log "未找到 /etc/os-release,仅支持 Debian/Ubuntu 系。"
+ exit 1
+fi
+
+# shellcheck source=/dev/null
+. /etc/os-release
+
+ID_LIKE="${ID_LIKE:-}"
+if ! echo "${ID:-} ${ID_LIKE}" | grep -qiE 'debian|ubuntu'; then
+ log "当前发行版: ${PRETTY_NAME:-unknown}"
+ log "脚本仅在 Debian/Ubuntu 及其衍生版上测试;如需其它系统请直接阅读 scripts/linux/*.sh 手工执行。"
+ exit 1
+fi
+
+ARCH="$(uname -m 2>/dev/null || true)"
+case "${ARCH}" in
+ x86_64 | amd64 | aarch64 | arm64 | armv7l | armv6l) ;;
+ *)
+ log "未识别的机器架构: ${ARCH:-?},将继续尝试安装(请自行确认依赖是否可用)。"
+ ;;
+esac
+
+log "ROOT_DIR=${ROOT_DIR}"
+log "架构=${ARCH} 系统=${PRETTY_NAME:-$ID}"
+
+if [ ! -x "${ROOT_DIR}/install-all.sh" ]; then
+ log "缺少可执行的 install-all.sh"
+ exit 1
+fi
+
+exec bash "${ROOT_DIR}/install-all.sh" "$@"
diff --git a/src/web_process_backend.py b/src/web_process_backend.py
index e662dee..0b4fbec 100644
--- a/src/web_process_backend.py
+++ b/src/web_process_backend.py
@@ -308,6 +308,42 @@ class WebProcessBackend:
if self._use_pm2 is None:
await self.refresh_mode()
+ async def online_process_names(self) -> list[str]:
+ """当前在线的 PM2 / 本地注册进程名(用于 TikTok·OBS 等 HDMI 链路互斥)。"""
+ await self.ensure_mode()
+ if not self._use_pm2:
+ data = self._local._read_state()
+ names: list[str] = []
+ for name, row in (data.get("processes") or {}).items():
+ try:
+ pid = int((row or {}).get("pid") or 0)
+ except (TypeError, ValueError):
+ continue
+ if self._local._pid_alive(pid):
+ names.append(str(name))
+ return sorted(names)
+ code, text = await _shell_out("pm2 jlist", timeout=20.0)
+ if code != 0 or not text.strip():
+ return []
+ try:
+ items = json.loads(text)
+ except json.JSONDecodeError:
+ return []
+ if not isinstance(items, list):
+ return []
+ out: list[str] = []
+ for it in items:
+ if not isinstance(it, dict):
+ continue
+ name = it.get("name")
+ if not name:
+ continue
+ env = it.get("pm2_env") if isinstance(it.get("pm2_env"), dict) else {}
+ status = str(env.get("status") or "").lower()
+ if status in ("online", "launching"):
+ out.append(str(name))
+ return out
+
async def start(self, process: str, script: str) -> str:
await self.ensure_mode()
if self._use_pm2:
diff --git a/web-console/components/live-product/LiveControlApp.tsx b/web-console/components/live-product/LiveControlApp.tsx
index ac1b830..5886a0a 100644
--- a/web-console/components/live-product/LiveControlApp.tsx
+++ b/web-console/components/live-product/LiveControlApp.tsx
@@ -8,6 +8,7 @@ import {
CirclePlay,
Clock,
Cpu,
+ FolderOpen,
HardDrive,
Languages,
Loader2,
@@ -30,6 +31,7 @@ import { LiveStudioScene3D } from "@/components/LiveStudioScene3D";
import OverviewCharts from "@/components/OverviewCharts";
import { LiveConsoleWorkspace } from "@/components/console/LiveConsoleWorkspace";
import { LiveAndroidWorkstation } from "@/components/live-product/LiveAndroidWorkstation";
+import { LiveFilebrowserPanel } from "@/components/live-product/LiveFilebrowserPanel";
import { LiveTiktokHdmiView } from "@/components/live-product/LiveTiktokHdmiView";
import type { PortalNavTarget } from "@/components/live-product/LivePipelineStrip";
import { LiveYoutubeUnmannedView } from "@/components/live-product/LiveYoutubeUnmannedView";
@@ -40,6 +42,7 @@ type PortalTheme = "dark" | "light";
type ViewKey =
| "dashboard"
| "services"
+ | "files"
| "live_youtube"
| "live_tiktok"
| "android"
@@ -400,6 +403,7 @@ export default function LiveControlApp() {
const navItems: { id: ViewKey; icon: typeof Activity; labelZh: string; labelEn: string }[] = [
{ id: "dashboard", icon: Activity, labelZh: "总览", labelEn: "Dashboard" },
{ id: "services", icon: Server, labelZh: "服务", labelEn: "Services" },
+ { id: "files", icon: FolderOpen, labelZh: "文件(File Browser)", labelEn: "Files" },
{ id: "live_youtube", icon: CirclePlay, labelZh: "YouTube 无人直播", labelEn: "YouTube unmanned" },
{ id: "live_tiktok", icon: MonitorPlay, labelZh: "TikTok 无人直播", labelEn: "TikTok unmanned" },
{ id: "android", icon: Smartphone, labelZh: "安卓", labelEn: "Android" },
@@ -617,6 +621,32 @@ export default function LiveControlApp() {
{dashErr}
) : null}
+
+
+
+
+
+
+
+
{[
{
@@ -667,23 +697,6 @@ export default function LiveControlApp() {
))}
-
-
-
-
-
-
-
{dash?.hardware && typeof dash.hardware === "object" ? (
@@ -1074,6 +1087,14 @@ export default function LiveControlApp() {
) : null}
+ {view === "files" ? (
+
+ ) : null}
+
{view === "services" ? (
{(dash?.services || []).map((s, i) => (
diff --git a/web-console/components/live-product/LiveFilebrowserPanel.tsx b/web-console/components/live-product/LiveFilebrowserPanel.tsx
new file mode 100644
index 0000000..5d3e543
--- /dev/null
+++ b/web-console/components/live-product/LiveFilebrowserPanel.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import { ExternalLink, FolderOpen } from "lucide-react";
+
+import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls";
+
+type TFn = (zh: string, en: string) => string;
+type PortalTheme = "dark" | "light";
+
+type Props = {
+ t: TFn;
+ portalTheme: PortalTheme;
+ filebrowserUrl: string;
+};
+
+export function LiveFilebrowserPanel({ t, portalTheme, filebrowserUrl }: Props) {
+ const url = filebrowserUrl ? normalizeBrowserReachableHttpUrl(filebrowserUrl) : "";
+
+ return (
+
+
+
+
+
+
+
+
+ {t("文件(File Browser)", "Files (File Browser)")}
+
+
+ {t(
+ "与 Docker 部署的 File Browser 分离:此处仅嵌入面板并说明 API,便于你自建「选本地视频 → 配合 TikTok HDMI 测试」等前端逻辑。",
+ "Separate from File Browser itself: embed + API notes so you can build “pick local video → TikTok HDMI test” flows.",
+ )}
+
+
+
+
+
+
+
+ {t("内置 UI(iframe)", "Built-in UI (iframe)")}
+
+
+ {t(
+ "若服务未启动或端口不通,请先在「服务」页启动 filebrowser 容器。",
+ "Start the filebrowser service from Services if the frame is blank.",
+ )}
+
+ {url ? (
+
+ ) : (
+
+ {t("未从总览获取到 filebrowser 链接;请确认 stack 配置与 hub 仪表盘。", "No filebrowser URL from dashboard.")}
+
+ )}
+
+
+
+
+ {t("REST API(自定义前端)", "REST API (custom UI)")}
+
+
+ -
+ {t(
+ "登录:POST /api/login,Body 为 JSON:username / password(与 File Browser 账户一致),返回 Set-Cookie 或 token(视版本而定)。",
+ "Login: POST /api/login with JSON username/password; follow cookie/token per your File Browser version.",
+ )}
+
+ -
+ {t(
+ "列目录:GET /api/resources(常需携带 Cookie)。具体字段以你部署的版本文档为准。",
+ "List: GET /api/resources (often cookie-authenticated). See your image version docs.",
+ )}
+
+ -
+ {t(
+ "与 TikTok HDMI 页配合:在自定义脚本里拉取文件路径后,可写独立 ffmpeg 推流;控制台会在启动 TikTok / obs*.sh 时自动互斥其它 HDMI 链路进程。",
+ "With TikTok HDMI: your script can feed ffmpeg paths; the hub stops other TikTok/OBS HDMI sinks when you start one.",
+ )}
+
+
+
+
+ );
+}
diff --git a/web-console/components/live-product/LivePipelineStrip.tsx b/web-console/components/live-product/LivePipelineStrip.tsx
index f7b4e59..40e40bf 100644
--- a/web-console/components/live-product/LivePipelineStrip.tsx
+++ b/web-console/components/live-product/LivePipelineStrip.tsx
@@ -2,7 +2,13 @@
import { ChevronRight } from "lucide-react";
-export type PortalNavTarget = "network" | "services" | "live_youtube" | "live_tiktok" | "android";
+export type PortalNavTarget =
+ | "network"
+ | "services"
+ | "live_youtube"
+ | "live_tiktok"
+ | "android"
+ | "files";
type TFn = (zh: string, en: string) => string;
diff --git a/web-console/components/live-product/LiveTiktokHdmiView.tsx b/web-console/components/live-product/LiveTiktokHdmiView.tsx
index fb4d71e..803ea00 100644
--- a/web-console/components/live-product/LiveTiktokHdmiView.tsx
+++ b/web-console/components/live-product/LiveTiktokHdmiView.tsx
@@ -199,6 +199,12 @@ export function LiveTiktokHdmiView({
gradient: "from-cyan-400 to-blue-500",
target: "live_tiktok",
},
+ {
+ label: "File Browser",
+ sub: t("本地文件", "Local files"),
+ gradient: "from-amber-400 to-orange-500",
+ target: "files",
+ },
{
label: "HDMI",
sub: t("板载输出", "SoC out"),
@@ -239,6 +245,15 @@ export function LiveTiktokHdmiView({
+
+
+ {t(
+ "HDMI 互斥:后端在「开始 / 重新开始」TikTok 或 obs*.sh(SRS 拉流上屏)时,会自动停止其它正在占用 HDMI 链路的同类进程;与 YouTube 推流互不干扰。",
+ "HDMI mutex: starting TikTok or obs*.sh stops other TikTok/OBS HDMI sinks; YouTube RTMP is unaffected.",
+ )}
+
+
+
diff --git a/web-console/components/live-product/LiveYoutubeUnmannedView.tsx b/web-console/components/live-product/LiveYoutubeUnmannedView.tsx
index 14ffb34..9e8caa6 100644
--- a/web-console/components/live-product/LiveYoutubeUnmannedView.tsx
+++ b/web-console/components/live-product/LiveYoutubeUnmannedView.tsx
@@ -259,22 +259,6 @@ export function LiveYoutubeUnmannedView({
}
}, []);
- 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 =
@@ -308,6 +292,52 @@ export function LiveYoutubeUnmannedView({
const active = channels.find((c) => c.id === activeCh) || channels[0];
+ const setModePersist = useCallback(
+ (m: "single" | "pro") => {
+ if (m === "single" && mode === "pro" && active) {
+ persistChannels(channels.map((x) => (x.id === active.id ? { ...x, urlLines: proUrlDraft } : x)));
+ }
+ if (m === "pro" && channels.length) {
+ const first = channels[0];
+ const youtubePm2Ok = ytEntries.some((e) => e.pm2 === currentProc);
+ const pm2Name =
+ youtubePm2Ok && currentProc.trim()
+ ? currentProc.trim()
+ : (first.pm2Name ?? "").trim() || defaultPm2NameForChannel(first.id);
+ const next: YoutubeProChannel[] = [
+ {
+ ...first,
+ streamKey: ytFields.key.trim() || first.streamKey || "",
+ urlLines: urlConfig.trim() || first.urlLines || "",
+ pm2Name,
+ },
+ ...channels.slice(1),
+ ];
+ persistChannels(next);
+ const ch = next.find((c) => c.id === activeCh) ?? next[0];
+ setProUrlDraft((ch?.urlLines ?? "").trim() || urlConfig.trim());
+ }
+ setMode(m);
+ try {
+ window.localStorage.setItem(MODE_KEY, m);
+ } catch {
+ /* ignore */
+ }
+ },
+ [
+ mode,
+ active,
+ channels,
+ proUrlDraft,
+ ytFields.key,
+ urlConfig,
+ currentProc,
+ ytEntries,
+ activeCh,
+ persistChannels,
+ ],
+ );
+
const selectProChannel = useCallback(
(c: YoutubeProChannel) => {
if (active && mode === "pro") {
diff --git a/web.py b/web.py
index 21a44e2..1e73de1 100644
--- a/web.py
+++ b/web.py
@@ -148,6 +148,43 @@ def is_tiktok_script(script: str) -> bool:
return script.endswith(".py") and _stem(script).startswith("tiktok")
+def _script_targets_hdmi_sink(script: str) -> bool:
+ """与采集卡/板端 HDMI 输出争用同一物理链路的进程(互斥;与 YouTube RTMP 推流无关)。"""
+ if is_tiktok_script(script):
+ return True
+ s = _stem(script).lower()
+ return script.endswith(".sh") and s.startswith("obs")
+
+
+async def _stop_other_hdmi_sink_processes(keep_pm2: str) -> list[str]:
+ sc_keep = script_for_pm2_or_pro(keep_pm2)
+ if not sc_keep or not _script_targets_hdmi_sink(sc_keep):
+ return []
+ await process_backend.ensure_mode()
+ stopped: list[str] = []
+ try:
+ online = await process_backend.online_process_names()
+ except Exception:
+ online = []
+ for name in online:
+ if name == keep_pm2:
+ continue
+ sc = script_for_pm2_or_pro(name)
+ if not sc or not _script_targets_hdmi_sink(sc):
+ continue
+ info = await get_process_info(name)
+ st = (info.get("process_status") or "").lower()
+ if st not in ("online", "running"):
+ continue
+ await process_backend.stop(name)
+ if not str(name).startswith("web"):
+ await force_kill_ffmpeg(str(name), BASE_DIR)
+ stopped.append(str(name))
+ if stopped:
+ await asyncio.sleep(0.85)
+ return stopped
+
+
def _build_process_monitor():
return tuple(
{
@@ -645,8 +682,12 @@ async def start(process: str = Query(..., description="进程名")):
sc = script_for_pm2_or_pro(process)
if not sc:
return JSONResponse({"output": "内部错误:找不到脚本映射"}, status_code=500)
+ stopped = await _stop_other_hdmi_sink_processes(process)
output = await process_backend.start(process, sc)
- return JSONResponse({"output": output})
+ prefix = ""
+ if stopped:
+ prefix = "已停止其它占用 HDMI 链路的进程: " + "、".join(stopped) + "\n"
+ return JSONResponse({"output": prefix + output})
@app.post("/start")
@@ -689,10 +730,14 @@ async def restart(process: str = Query(..., description="进程名")):
sc = script_for_pm2_or_pro(process)
if not sc:
return JSONResponse({"output": "内部错误:找不到脚本映射"}, status_code=500)
+ stopped = await _stop_other_hdmi_sink_processes(process)
output = await process_backend.restart(process, sc)
if not process.startswith("web"):
await force_kill_ffmpeg(process, BASE_DIR)
- return JSONResponse({"output": output})
+ prefix = ""
+ if stopped:
+ prefix = "已停止其它占用 HDMI 链路的进程: " + "、".join(stopped) + "\n"
+ return JSONResponse({"output": prefix + output})
@app.post("/restart")