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 ? ( +
+ +