diff --git a/d2ypp2/README.md b/d2ypp2/README.md
index 703bedb..f8bf8a8 100644
--- a/d2ypp2/README.md
+++ b/d2ypp2/README.md
@@ -1,6 +1,17 @@

-## Linux Super Hub Profiles
+## 无人直播系统(Linux 一键部署,ARM / x86)
+
+本项目当前定位不是单纯录播工具,而是面向 Linux 的无人直播一键部署栈。默认入口为 `http://live.local`,通过 Web 控制面板统一查看和操作 YouTube 无人转播、TikTok HDMI 硬件链路、SRS、Neko 浏览器、安卓与系统服务。
+
+### 核心能力
+
+- 抖音直播间 → YouTube Live:支持单频道 / 多频道同时推流,每个任务独立 `youtube.ini`、`URL_config` 与 PM2 进程
+- TikTok 直播:支持视频流转发到硬件 HDMI,也可配合 SRS / OBS 接流后再转发
+- Neko 浏览器:默认保留管理员会话、开发者工具与 Tampermonkey 持久化
+- 兼容架构:Linux `x86_64` / `arm64`,使用一键脚本部署,控制面板默认域名 `live.local`
+
+## Linux Unmanned Live Profiles
This fork now includes a modular Linux deployment layout for ARM and X86 nodes.
@@ -9,7 +20,7 @@ This fork now includes a modular Linux deployment layout for ARM and X86 nodes.
- **配置中心**:`/opt/live/config`(模板见 `live-platform/config/defaults/`,开发时回退到仓库内同路径)
- **一键脚本**:`./install-core.sh`(系统 + 基础服务,与业务解耦)、`./install-live.sh`(仅无人直播业务)、`./doctor.sh`(体检)
- **统一 API**:`GET /hub/dashboard`、`GET /hub/config` 等(见 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md))
-- **产品级 Web**:根路径 `/` 为 Live Hub 控制中心;`/console` 为高级全功能页
+- **产品级 Web**:根路径 `/` 为无人直播系统控制台;`/console` 为高级全功能页
- `install-hub.sh`: reusable Linux super hub with `live.local`, ShellCrash, WebTTY, Cockpit, File Browser, Homepage and Netdata
- `install-business.sh`: Douyin / YouTube / HDMI business module with ffmpeg, mpv, ADB, Chromium, web-scrcpy and SRS
diff --git a/d2ypp2/config/system-stack.env.example b/d2ypp2/config/system-stack.env.example
index c0a1a00..1b7a743 100644
--- a/d2ypp2/config/system-stack.env.example
+++ b/d2ypp2/config/system-stack.env.example
@@ -55,7 +55,7 @@ DISABLE_IPV6=1
# ANDROID_PANEL_GIT_URL=https://github.com/baixin1228/web-scrcpy.git
# 可选:Docker Neko 浏览器桌面(m1k1o/neko),与 SRS 8080 错开端口
-# 策略/启动参数对齐 x86 参考机:油猴强制安装、扩展白名单、用户数据持久化目录见 NEKO_PROFILE_HOST_DIR
+# 默认策略:管理员口令可用、开发者工具开启、油猴强制安装并持久化;用户数据目录见 NEKO_PROFILE_HOST_DIR
# WebRTC 画面:NEKO_WEBRTC_NAT1TO1 必须为本机在局域网中的 IP(如 192.168.x.x),与参考机填网关 IP 同理
# 设为 1 才会安装/启动 Neko 容器;不需要浏览器桌面可改回 0
ENABLE_NEKO=1
@@ -66,10 +66,10 @@ NEKO_HTTP_PORT_3=9202
NEKO_START_URL=https://accounts.google.com/ServiceLogin?continue=https://www.youtube.com/
NEKO_IMAGE_X86=ghcr.io/m1k1o/neko/google-chrome:3.0.0
NEKO_IMAGE_ARM64=ghcr.io/m1k1o/neko/chromium:3.0.0
-# Neko 浏览器登录:用户名为 live / admin,口令与全站策略一致(默认 12345678)
+# Neko 浏览器登录:显示昵称可填 live / admin;密码与全站策略一致(默认 12345678)
NEKO_USER_PASS=12345678
NEKO_ADMIN_PASS=12345678
-# 持久化 Chromium 配置(油猴、YouTube 登录、脚本);可从参考机 rsync 整个目录覆盖以迁移
+# 持久化 Chromium 配置(油猴、YouTube 登录、脚本、开发者数据);可从参考机 rsync 整个目录覆盖以迁移
NEKO_PROFILE_HOST_DIR=
NEKO_SHM_SIZE=2gb
NEKO_DESKTOP_SCREEN=1920x1080@30
diff --git a/d2ypp2/readme2.txt b/d2ypp2/readme2.txt
new file mode 100644
index 0000000..df5efc2
--- /dev/null
+++ b/d2ypp2/readme2.txt
@@ -0,0 +1,41 @@
+项目修复记录(2026-05-17)
+
+1. 本次实际修复的 bug
+- 单频道 YouTube / 抖音转 YouTube:
+ 旧逻辑只要检测到任意一个托管配置文件存在,就会把 `youtube.ini` 和 `URL_config.ini` 整对切到托管路径;当只生成了一半托管文件时,会把另一半旧配置绕掉,导致推流 key 或房间 URL 丢失。现已改为“按文件粒度回退”。
+- 多频道 Pro:
+ 前端从服务端拉取频道列表时,空列表会被误判成“拉取失败”,随后回退到浏览器本地缓存,导致页面继续显示旧频道。现已把“成功拉到空列表”和“请求失败”区分开。
+- 进程重启:
+ `web.py` 的重启接口原先在 `restart()` 之后再 `force_kill_ffmpeg()`,会把刚拉起的新 ffmpeg 一并杀掉。现已改为先清理旧 ffmpeg,再执行重启。
+- PM2 状态污染:
+ PM2 `start/stop/restart/resurrect` 失败时,旧代码仍会把进程标记成期望运行并保存状态,控制台会显示假在线。现已改为只有返回码为 0 才更新 desired 状态。
+- PM2 自恢复卡死:
+ `src/web_process_backend.py` 把一些很小的本地文件读写放进 `asyncio.to_thread()`;在当前环境里这条路径会卡住,导致 PM2 进程表恢复逻辑不返回。现已改为直接同步执行这些轻量操作。
+- Neko 浏览器:
+ `DeveloperToolsAvailability` 原来是 `2`,会直接禁用开发者工具;同时启动参数带 `--bwsi`,会破坏扩展持久化和 Tampermonkey 行为。现已改为允许 DevTools,并移除 `--bwsi`。
+
+2. 本次改动的关键文件
+- 后端:
+ `src/youtube_config_paths.py`
+ `youtube.py`
+ `src/web_process_backend.py`
+ `web.py`
+- 前端:
+ `web-console/lib/youtubeProChannels.ts`
+ `web-console/components/live-product/LiveYoutubeUnmannedView.tsx`
+ `web-console/out/*`(已重新构建静态产物)
+- Neko:
+ `services/neko/policies/policies.json`
+ `services/neko/google-chrome.conf`
+ `services/neko/chromium.conf`
+ `services/neko/browser-launch.sh`
+
+3. 验证结果
+- 已通过:
+ `python -m compileall -q src web.py youtube.py douyin_youtube_ffplay.py tests`
+ `python -m unittest -q tests.test_web_process_backend tests.test_web_dynamic_processes tests.test_youtube_config_paths tests.test_neko_config tests.test_youtube_push_config tests.test_youtube_pro_runtime`
+- 前端已使用 Node 20 重新执行 `web-console` 构建,新的 `web-console/out` 已生成。
+
+4. 部署注意
+- `live.local` 实际运行目录不是当前仓库 `/home/eric/sh/d2ypp2`,而是 `/home/eric/d2ypp`。
+- 如果只改当前目录而不把修复同步到 `/home/eric/d2ypp`,`live.local` 不会生效。
diff --git a/d2ypp2/services/neko/browser-launch.sh b/d2ypp2/services/neko/browser-launch.sh
index 57d519b..b78b712 100644
--- a/d2ypp2/services/neko/browser-launch.sh
+++ b/d2ypp2/services/neko/browser-launch.sh
@@ -32,13 +32,11 @@ fi
render_mode="${NEKO_BROWSER_RENDER_MODE:-auto}"
render_flags="--disable-gpu --use-gl=swiftshader --enable-unsafe-swiftshader"
-session_flags=""
case "$render_mode" in
auto)
case "$arch:$family" in
x86_64:google-chrome|amd64:google-chrome)
render_flags="--disable-gpu --disable-software-rasterizer"
- session_flags="--bwsi"
;;
esac
;;
@@ -77,7 +75,6 @@ exec "$browser_bin" \
--user-data-dir="$profile_dir" \
--password-store=basic \
--no-first-run \
- ${session_flags} \
--start-maximized \
--force-dark-mode \
--disable-file-system \
diff --git a/d2ypp2/services/neko/chromium.conf b/d2ypp2/services/neko/chromium.conf
index ce092b9..a3f24a4 100644
--- a/d2ypp2/services/neko/chromium.conf
+++ b/d2ypp2/services/neko/chromium.conf
@@ -6,7 +6,6 @@ command=/usr/bin/chromium
--user-data-dir=/home/neko/.config/chromium
--no-first-run
--start-maximized
- --bwsi
--force-dark-mode
--disable-file-system
--disable-gpu
diff --git a/d2ypp2/services/neko/google-chrome.conf b/d2ypp2/services/neko/google-chrome.conf
index cbccad8..3cf1c61 100644
--- a/d2ypp2/services/neko/google-chrome.conf
+++ b/d2ypp2/services/neko/google-chrome.conf
@@ -6,7 +6,6 @@ command=/usr/bin/google-chrome
--user-data-dir=/home/neko/.config/google-chrome
--no-first-run
--start-maximized
- --bwsi
--force-dark-mode
--disable-file-system
--disable-gpu
diff --git a/d2ypp2/services/neko/policies/policies.json b/d2ypp2/services/neko/policies/policies.json
index 6d98afb..9ab0815 100644
--- a/d2ypp2/services/neko/policies/policies.json
+++ b/d2ypp2/services/neko/policies/policies.json
@@ -16,6 +16,6 @@
"AudioCaptureAllowed": true,
"VideoCaptureAllowedUrls": ["https://*.google.com/*", "https://*.youtube.com/*"],
"AudioCaptureAllowedUrls": ["https://*.google.com/*", "https://*.youtube.com/*"],
- "DeveloperToolsAvailability": 2,
+ "DeveloperToolsAvailability": 1,
"DefaultGeolocationSetting": 1
}
diff --git a/d2ypp2/src/control_plane.py b/d2ypp2/src/control_plane.py
index 8555781..6cac087 100644
--- a/d2ypp2/src/control_plane.py
+++ b/d2ypp2/src/control_plane.py
@@ -719,6 +719,7 @@ def stack_summary() -> dict:
"note_zh": (
"Neko 为 multiuser 模式:「显示昵称」可任意填写(如 live);「密码」必须与 NEKO_USER_PASS / NEKO_ADMIN_PASS "
"完全一致(默认 12345678)。不要把 live/12345678 填进同一格。密码错误时会一直卡在连接中。"
+ " 默认保留 admin 管理会话,开发者工具开启,Tampermonkey 强制安装并持久化。"
" WebRTC 需 NEKO_WEBRTC_NAT1TO1 为板子局域网 IP,并放行 UDP 端口段。"
" x86 默认拉起 Chrome 镜像,ARM 默认拉起 Chromium 镜像。"
),
diff --git a/d2ypp2/src/hub_routes.py b/d2ypp2/src/hub_routes.py
index d8addcc..a74d8ed 100644
--- a/d2ypp2/src/hub_routes.py
+++ b/d2ypp2/src/hub_routes.py
@@ -1,6 +1,7 @@
-"""Live Hub 统一 API:仪表盘、配置中心;业务写操作经 API 落盘,前端不直接访问文件。"""
+"""无人直播系统统一 API:仪表盘、配置中心;业务写操作经 API 落盘,前端不直接访问文件。"""
from __future__ import annotations
+import asyncio
import json
import time
from pathlib import Path
@@ -27,7 +28,7 @@ from src.redroid_control import redroid_runtime_summary
from src.hdmi_control import hdmi_capture_summary
from src.youtube_pro_runtime import normalize_youtube_pro_channels
-router = APIRouter(prefix="/hub", tags=["Live Hub"])
+router = APIRouter(prefix="/hub", tags=["无人直播系统"])
_process_backend: WebProcessBackend | None = None
_monitor_entries: list[dict[str, Any]] = []
@@ -163,34 +164,58 @@ async def hub_dashboard() -> dict[str, Any]:
if pb is not None:
await pb.ensure_mode()
live_status["backend_mode"] = pb.mode
- for entry in hub_monitor_entries():
+ entries = [entry for entry in hub_monitor_entries() if str(entry.get("pm2", "")).strip()]
+ info_map = await pb.process_infos(str(entry.get("pm2", "")).strip() for entry in entries)
+
+ async def build_live_row(entry: dict[str, Any]) -> dict[str, Any]:
name = str(entry.get("pm2", "")).strip()
- if not name:
- continue
- try:
- info = await pb.parse_pm2_describe(name)
- except Exception as exc: # noqa: BLE001
- info = {"process_status": "error", "message": str(exc)}
+ info = dict(info_map.get(name) or {})
+ if not info:
+ info = {"process_status": "not_found", "out_path": None, "err_path": None, "pid": 0}
info = _coerce_hub_process_info(pb, entry, info)
- recent_log = _tail_file_text(info.get("out_path"), 80)
- recent_error = _tail_file_text(info.get("err_path"), 80)
+ status = str(info.get("process_status") or "").lower()
+ online = status in {"online", "running", "launching"}
+ if online:
+ recent_log, recent_error = await asyncio.gather(
+ asyncio.to_thread(_tail_file_text, info.get("out_path"), 48),
+ asyncio.to_thread(_tail_file_text, info.get("err_path"), 32),
+ )
+ ffmpeg_pids = await pb.live_ffmpeg_pids(name, info)
+ else:
+ recent_log = ""
+ recent_error = ""
+ ffmpeg_pids = []
activity = summarize_process_activity(
name,
str(info.get("process_status") or ""),
recent_log,
recent_error,
log_age_seconds=_newest_log_age_seconds(info.get("out_path"), info.get("err_path")),
- ffmpeg_pids=await pb.live_ffmpeg_pids(name),
- )
- live_status["processes"].append(
- {
- "pm2": name,
- "label": entry.get("label", name),
- "script": entry.get("script", ""),
- **info,
- **activity,
- }
+ ffmpeg_pids=ffmpeg_pids,
)
+ return {
+ "pm2": name,
+ "label": entry.get("label", name),
+ "script": entry.get("script", ""),
+ **info,
+ **activity,
+ }
+
+ results = await asyncio.gather(*(build_live_row(entry) for entry in entries), return_exceptions=True)
+ for entry, result in zip(entries, results):
+ if isinstance(result, Exception):
+ live_status["processes"].append(
+ {
+ "pm2": str(entry.get("pm2", "")).strip(),
+ "label": entry.get("label", entry.get("pm2", "")),
+ "script": entry.get("script", ""),
+ "process_status": "error",
+ "business_status": "error",
+ "business_note": str(result),
+ }
+ )
+ else:
+ live_status["processes"].append(result)
pm2_tail = recent_pm2_log_lines(16)
if not pm2_tail:
diff --git a/d2ypp2/src/web_process_backend.py b/d2ypp2/src/web_process_backend.py
index b1d75b9..660a4cd 100644
--- a/d2ypp2/src/web_process_backend.py
+++ b/d2ypp2/src/web_process_backend.py
@@ -70,31 +70,113 @@ async def pm2_available() -> bool:
return code == 0
-async def run_pm2(cmd: str, timeout: float = 15.0) -> str:
+async def run_pm2_result(cmd: str, timeout: float = 15.0) -> tuple[int, str]:
pm2_bin = resolve_pm2_bin()
if not pm2_bin:
- return "ERROR: pm2 is not available"
- _, text = await _shell_out(f"{shlex.quote(pm2_bin)} {cmd}", timeout=timeout)
+ return 127, "ERROR: pm2 is not available"
+ return await _shell_out(f"{shlex.quote(pm2_bin)} {cmd}", timeout=timeout)
+
+
+async def run_pm2(cmd: str, timeout: float = 15.0) -> str:
+ _, text = await run_pm2_result(cmd, timeout=timeout)
return text
def _ffmpeg_cmdline_markers(process_name: str, project_root: Path | None) -> list[str]:
- markers: list[str] = [
- process_name,
- "a.rtmp.youtube.com",
- "rtmps://a.rtmp.youtube.com",
- "live.douyin.com",
- "pull-flv",
- "douyincdn.com",
- ]
- if project_root is not None:
- try:
- markers.append(str(project_root.resolve()))
- except OSError:
- pass
+ safe = re.sub(r"[^a-zA-Z0-9_.-]", "_", process_name).strip()
+ markers = {str(process_name or "").strip(), safe}
+ if project_root is not None and safe:
+ config_dir = project_root / "config"
+ markers.add(f"youtube.{safe}.ini")
+ markers.add(f"URL_config.{safe}.ini")
+ markers.add(str(config_dir / f"youtube.{safe}.ini"))
+ markers.add(str(config_dir / f"URL_config.{safe}.ini"))
return [m for m in markers if m]
+def _read_proc_cmdline(pid: int) -> str:
+ try:
+ raw = (Path("/proc") / str(pid) / "cmdline").read_bytes()
+ except OSError:
+ return ""
+ if not raw:
+ return ""
+ return raw.replace(b"\0", b" ").decode("utf-8", "replace")
+
+
+def _read_proc_env_value(pid: int, key: str) -> str:
+ try:
+ raw = (Path("/proc") / str(pid) / "environ").read_bytes()
+ except OSError:
+ return ""
+ prefix = f"{key}=".encode("utf-8")
+ for chunk in raw.split(b"\0"):
+ if chunk.startswith(prefix):
+ return chunk[len(prefix) :].decode("utf-8", "replace").strip()
+ return ""
+
+
+def _proc_parent_and_group(pid: int) -> tuple[int | None, int | None]:
+ try:
+ raw = (Path("/proc") / str(pid) / "stat").read_text(encoding="utf-8", errors="ignore")
+ except OSError:
+ return None, None
+ end = raw.rfind(")")
+ if end < 0:
+ return None, None
+ tail = raw[end + 2 :].split()
+ if len(tail) < 3:
+ return None, None
+ try:
+ return int(tail[1]), int(tail[2])
+ except ValueError:
+ return None, None
+
+
+def _linux_ffmpeg_pids_for_process_tree(root_pid: int, *, root_pgid: int | None = None) -> list[int]:
+ if root_pid <= 0:
+ return []
+ proc_root = Path("/proc")
+ if not proc_root.is_dir():
+ return []
+
+ children_map: dict[int, list[int]] = {}
+ group_map: dict[int, int] = {}
+ ffmpeg_pids: set[int] = set()
+ for entry in proc_root.iterdir():
+ if not entry.name.isdigit():
+ continue
+ try:
+ pid = int(entry.name)
+ except ValueError:
+ continue
+ parent_pid, process_group = _proc_parent_and_group(pid)
+ if parent_pid is not None:
+ children_map.setdefault(parent_pid, []).append(pid)
+ if process_group is not None:
+ group_map[pid] = process_group
+ if "ffmpeg" in _read_proc_cmdline(pid).lower():
+ ffmpeg_pids.add(pid)
+
+ descendants = {root_pid}
+ queue = [root_pid]
+ while queue:
+ current = queue.pop()
+ children = [pid for pid in children_map.get(current, []) if pid not in descendants]
+ if not children:
+ continue
+ descendants.update(children)
+ queue.extend(children)
+
+ group = root_pgid if root_pgid and root_pgid > 0 else group_map.get(root_pid)
+ matched = {
+ pid
+ for pid in ffmpeg_pids
+ if pid in descendants or (group is not None and group > 0 and group_map.get(pid) == group)
+ }
+ return sorted(matched)
+
+
def _linux_ffmpeg_pids_for_stack(process_name: str, project_root: Path | None) -> list[int]:
"""仅匹配与本项目直播栈相关的 ffmpeg(命令行含 YouTube 推流、抖音拉流或项目路径),避免 killall 误伤整机其它 ffmpeg。"""
markers = _ffmpeg_cmdline_markers(process_name, project_root)
@@ -130,28 +212,36 @@ def _signal_pids(pids: Iterable[int], sig: int) -> None:
pass
-async def force_kill_ffmpeg(process_name: str, project_root: Path | None = None) -> None:
+async def force_kill_ffmpeg(
+ process_name: str,
+ project_root: Path | None = None,
+ *,
+ root_pid: int | None = None,
+ root_pgid: int | None = None,
+) -> None:
"""结束与本业务相关的 ffmpeg:按 /proc 命令行匹配 + 可选 pkill。"""
- def collect() -> list[int]:
- return _linux_ffmpeg_pids_for_stack(process_name, project_root)
-
- pids = await asyncio.to_thread(collect)
+ pids = _linux_ffmpeg_pids_for_process_tree(int(root_pid or 0), root_pgid=root_pgid)
+ if not pids:
+ pids = _linux_ffmpeg_pids_for_stack(process_name, project_root)
if pids:
- await asyncio.to_thread(_signal_pids, pids, signal.SIGTERM)
+ _signal_pids(pids, signal.SIGTERM)
await asyncio.sleep(0.6)
- remaining = await asyncio.to_thread(collect)
+ remaining = _linux_ffmpeg_pids_for_process_tree(int(root_pid or 0), root_pgid=root_pgid)
+ if not remaining:
+ remaining = _linux_ffmpeg_pids_for_stack(process_name, project_root)
if remaining:
- await asyncio.to_thread(_signal_pids, remaining, signal.SIGKILL)
+ _signal_pids(remaining, signal.SIGKILL)
await asyncio.sleep(0.2)
safe = re.sub(r"[^a-zA-Z0-9_.-]", "", process_name) or "x"
- proc = await asyncio.create_subprocess_shell(
- f"pkill -f 'ffmpeg.*{safe}' || true",
- stdout=asyncio.subprocess.DEVNULL,
- stderr=asyncio.subprocess.DEVNULL,
- )
- await proc.wait()
+ if safe != process_name:
+ proc = await asyncio.create_subprocess_shell(
+ f"pkill -f 'ffmpeg.*{safe}' || true",
+ stdout=asyncio.subprocess.DEVNULL,
+ stderr=asyncio.subprocess.DEVNULL,
+ )
+ await proc.wait()
class LocalProcessRegistry:
@@ -369,6 +459,10 @@ def build_pm2_start_command(base_dir: Path, process: str, script: str) -> Option
return " ".join(env_bits + cmd_bits)
+def _pm2_log_slug(name: str) -> str:
+ return re.sub(r"[^A-Za-z0-9]", "-", str(name or "").strip()) or "process"
+
+
class WebProcessBackend:
def __init__(self, base_dir: Path, default_log_dir: Path):
self.base_dir = base_dir
@@ -380,9 +474,21 @@ class WebProcessBackend:
self._pm2_last_daemon_pid: Optional[int] = None
self._pm2_last_resurrect_attempt: float = 0.0
self._pm2_last_start_attempt: dict[str, float] = {}
+ self._pm2_jlist_cache_ttl: float = 0.35
+ self._pm2_jlist_stale_grace: float = 2.0
+ self._pm2_jlist_cached_at: float = 0.0
+ self._pm2_jlist_cache: list[dict[str, Any]] | None = None
+ self._pm2_dump_cache_ttl: float = 5.0
+ self._pm2_dump_cached_at: float = 0.0
+ self._pm2_dump_names_cache: set[str] | None = None
+ self._proc_runtime_cache_ttl: float = 0.35
+ self._proc_runtime_cached_at: float = 0.0
+ self._proc_runtime_cache: dict[str, dict[str, Any]] | None = None
async def refresh_mode(self) -> None:
self._use_pm2 = await pm2_available()
+ if self._use_pm2:
+ self.invalidate_pm2_cache()
@property
def mode(self) -> str:
@@ -393,9 +499,20 @@ class WebProcessBackend:
async def ensure_mode(self) -> None:
if self._use_pm2 is None or (not self._use_pm2 and resolve_pm2_bin()):
await self.refresh_mode()
+
+ async def ensure_pm2_runtime(self) -> None:
+ await self.ensure_mode()
if self._use_pm2:
await self._ensure_pm2_process_table()
+ def invalidate_pm2_cache(self) -> None:
+ self._pm2_jlist_cache = None
+ self._pm2_jlist_cached_at = 0.0
+ self._pm2_dump_names_cache = None
+ self._pm2_dump_cached_at = 0.0
+ self._proc_runtime_cache = None
+ self._proc_runtime_cached_at = 0.0
+
def _pm2_home_dir(self) -> Path:
raw = (os.environ.get("PM2_HOME") or "").strip()
if raw:
@@ -428,6 +545,88 @@ class WebProcessBackend:
return False
return any(isinstance(item, dict) and str(item.get("name") or "").strip() for item in payload)
+ def _pm2_dump_registered_names(self) -> set[str]:
+ now = time.monotonic()
+ if (
+ self._pm2_dump_names_cache is not None
+ and now - self._pm2_dump_cached_at <= self._pm2_dump_cache_ttl
+ ):
+ return set(self._pm2_dump_names_cache)
+
+ path = self._pm2_home_dir() / "dump.pm2"
+ names: set[str] = set()
+ if path.is_file():
+ try:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ payload = []
+ if isinstance(payload, list):
+ for item in payload:
+ if not isinstance(item, dict):
+ continue
+ name = str(item.get("name") or "").strip()
+ if name:
+ names.add(name)
+ self._pm2_dump_names_cache = names
+ self._pm2_dump_cached_at = now
+ return set(names)
+
+ def _pm2_default_log_paths(self, process: str) -> tuple[str, str]:
+ log_dir = self._pm2_home_dir() / "logs"
+ slug = _pm2_log_slug(process)
+ return str(log_dir / f"{slug}-out.log"), str(log_dir / f"{slug}-error.log")
+
+ async def _proc_runtime_items(self) -> dict[str, dict[str, Any]]:
+ now = time.monotonic()
+ if (
+ self._proc_runtime_cache is not None
+ and now - self._proc_runtime_cached_at <= self._proc_runtime_cache_ttl
+ ):
+ return {name: dict(info) for name, info in self._proc_runtime_cache.items()}
+
+ proc_root = Path("/proc")
+ if not proc_root.is_dir():
+ self._proc_runtime_cache = {}
+ self._proc_runtime_cached_at = now
+ return {}
+
+ chosen: dict[str, tuple[int, dict[str, Any]]] = {}
+ for entry in proc_root.iterdir():
+ if not entry.name.isdigit():
+ continue
+ try:
+ pid = int(entry.name)
+ except ValueError:
+ continue
+ process_name = _read_proc_env_value(pid, "LIVE_PM2_PROCESS_NAME")
+ if not process_name:
+ continue
+ cmdline = _read_proc_cmdline(pid)
+ if not cmdline or "ffmpeg" in cmdline.lower():
+ continue
+ parent_pid, process_group = _proc_parent_and_group(pid)
+ score = 0
+ if process_group and process_group == pid:
+ score += 100
+ if ".py" in cmdline or "python" in cmdline or "bash" in cmdline or " sh " in f" {cmdline} ":
+ score += 10
+ if parent_pid == 1:
+ score += 5
+ info = {
+ "pid": pid,
+ "ppid": parent_pid or 0,
+ "pgid": process_group or 0,
+ "cmdline": cmdline,
+ }
+ current = chosen.get(process_name)
+ if current is None or score > current[0] or (score == current[0] and pid < int(current[1].get("pid") or 0)):
+ chosen[process_name] = (score, info)
+
+ snapshot = {name: dict(info) for name, (_, info) in chosen.items()}
+ self._proc_runtime_cache = snapshot
+ self._proc_runtime_cached_at = now
+ return {name: dict(info) for name, info in snapshot.items()}
+
def _read_pm2_desired_state(self) -> dict[str, bool]:
path = self._pm2_desired_state_path
if not path.is_file():
@@ -458,39 +657,108 @@ class WebProcessBackend:
os.replace(str(tmp), str(path))
async def _set_pm2_desired_running(self, process: str, should_run: bool) -> None:
- def mutate() -> None:
- desired = self._read_pm2_desired_state()
- desired[str(process)] = bool(should_run)
- self._write_pm2_desired_state(desired)
+ desired = self._read_pm2_desired_state()
+ desired[str(process)] = bool(should_run)
+ self._write_pm2_desired_state(desired)
- await asyncio.to_thread(mutate)
+ async def _pm2_jlist_items(self, *, force_refresh: bool = False) -> list[dict[str, Any]]:
+ now = time.monotonic()
+ if (
+ not force_refresh
+ and self._pm2_jlist_cache is not None
+ and now - self._pm2_jlist_cached_at <= self._pm2_jlist_cache_ttl
+ ):
+ return list(self._pm2_jlist_cache)
- async def _pm2_jlist_items(self) -> list[dict[str, Any]]:
pm2_bin = resolve_pm2_bin()
if not pm2_bin:
+ self.invalidate_pm2_cache()
return []
code, text = await _shell_out(f"{shlex.quote(pm2_bin)} jlist", timeout=20.0)
if code != 0 or not text.strip():
+ if (
+ self._pm2_jlist_cache is not None
+ and now - self._pm2_jlist_cached_at <= self._pm2_jlist_stale_grace
+ ):
+ return list(self._pm2_jlist_cache)
+ self._pm2_jlist_cache = []
+ self._pm2_jlist_cached_at = now
return []
try:
items = json.loads(text)
except json.JSONDecodeError:
+ if (
+ self._pm2_jlist_cache is not None
+ and now - self._pm2_jlist_cached_at <= self._pm2_jlist_stale_grace
+ ):
+ return list(self._pm2_jlist_cache)
+ self._pm2_jlist_cache = []
+ self._pm2_jlist_cached_at = now
return []
if not isinstance(items, list):
+ self._pm2_jlist_cache = []
+ self._pm2_jlist_cached_at = now
return []
- return [item for item in items if isinstance(item, dict)]
+ filtered = [item for item in items if isinstance(item, dict)]
+ self._pm2_jlist_cache = filtered
+ self._pm2_jlist_cached_at = now
+ return list(filtered)
+
+ async def _pm2_jlist_index(self, *, force_refresh: bool = False) -> dict[str, dict[str, Any]]:
+ items = await self._pm2_jlist_items(force_refresh=force_refresh)
+ out: dict[str, dict[str, Any]] = {}
+ for item in items:
+ name = str(item.get("name") or "").strip()
+ if name:
+ out[name] = item
+ return out
+
+ def _pm2_info_from_item(self, process: str, item: dict[str, Any] | None) -> dict[str, Any]:
+ if not item:
+ return {
+ "process_status": "not_found",
+ "out_path": None,
+ "err_path": None,
+ "pid": 0,
+ }
+
+ env = item.get("pm2_env") if isinstance(item.get("pm2_env"), dict) else {}
+ raw_pid = item.get("pid")
+ try:
+ pid = int(raw_pid or 0)
+ except (TypeError, ValueError):
+ pid = 0
+
+ status = str(env.get("status") or item.get("status") or "").strip().lower()
+ if not status:
+ status = "online" if pid > 0 else "unknown"
+
+ out_path = env.get("pm_out_log_path") or item.get("pm_out_log_path")
+ err_path = env.get("pm_err_log_path") or item.get("pm_err_log_path")
+ if not out_path:
+ out_path = str(self.default_log_dir / f"{process}-out.log")
+ if not err_path:
+ err_path = str(self.default_log_dir / f"{process}-error.log")
+
+ return {
+ "process_status": status,
+ "out_path": out_path,
+ "err_path": err_path,
+ "pid": pid if pid > 0 else 0,
+ }
async def _ensure_pm2_process_table(self) -> None:
- daemon_pid = await asyncio.to_thread(self._pm2_pid_value)
+ daemon_pid = self._pm2_pid_value()
if daemon_pid != self._pm2_last_daemon_pid:
self._pm2_last_daemon_pid = daemon_pid
self._pm2_last_resurrect_attempt = 0.0
+ self.invalidate_pm2_cache()
items = await self._pm2_jlist_items()
if items:
return
- if not await asyncio.to_thread(self._pm2_dump_has_entries):
+ if not self._pm2_dump_has_entries():
return
now = time.monotonic()
@@ -498,14 +766,15 @@ class WebProcessBackend:
return
async with self._pm2_resurrect_lock:
- daemon_pid = await asyncio.to_thread(self._pm2_pid_value)
+ daemon_pid = self._pm2_pid_value()
if daemon_pid != self._pm2_last_daemon_pid:
self._pm2_last_daemon_pid = daemon_pid
self._pm2_last_resurrect_attempt = 0.0
+ self.invalidate_pm2_cache()
if await self._pm2_jlist_items():
return
- if not await asyncio.to_thread(self._pm2_dump_has_entries):
+ if not self._pm2_dump_has_entries():
return
now = time.monotonic()
@@ -514,12 +783,14 @@ class WebProcessBackend:
self._pm2_last_resurrect_attempt = now
with contextlib.suppress(Exception):
- await run_pm2("resurrect", timeout=45.0)
- await asyncio.sleep(0.2)
+ code, _ = await run_pm2_result("resurrect", timeout=45.0)
+ if code == 0:
+ self.invalidate_pm2_cache()
+ await asyncio.sleep(0.2)
await self._ensure_desired_pm2_processes()
async def _ensure_desired_pm2_processes(self) -> None:
- desired = await asyncio.to_thread(self._read_pm2_desired_state)
+ desired = self._read_pm2_desired_state()
if not desired:
return
items = await self._pm2_jlist_items()
@@ -540,8 +811,10 @@ class WebProcessBackend:
continue
self._pm2_last_start_attempt[name] = now
with contextlib.suppress(Exception):
- await run_pm2(f"start {name}", timeout=45.0)
- await asyncio.sleep(0.2)
+ code, _ = await run_pm2_result(f"start {name}", timeout=45.0)
+ if code == 0:
+ self.invalidate_pm2_cache()
+ await asyncio.sleep(0.2)
async def online_process_names(self) -> list[str]:
"""当前在线的 PM2 / 本地注册进程名(用于 TikTok·OBS 等 HDMI 链路互斥)。"""
@@ -557,25 +830,17 @@ class WebProcessBackend:
if self._local._pid_alive(pid):
names.append(str(name))
return sorted(names)
- items = await self._pm2_jlist_items()
- out: list[str] = []
- for it in items:
- 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
+ runtime = await self._proc_runtime_items()
+ return sorted(runtime)
async def _pm2_registered_names(self) -> set[str]:
- items = await self._pm2_jlist_items()
- return {
- str(it.get("name"))
- for it in items
- if str(it.get("name") or "").strip()
+ desired = {
+ str(name).strip()
+ for name in self._read_pm2_desired_state().keys()
+ if str(name).strip()
}
+ runtime = set(await self.online_process_names())
+ return self._pm2_dump_registered_names() | desired | runtime
async def clear_process_logs(self, process: str) -> None:
await self.ensure_mode()
@@ -591,12 +856,89 @@ class WebProcessBackend:
value = info.get(key)
if value:
candidates.add(str(value))
- await asyncio.to_thread(
- lambda: [_truncate_text_log(path) for path in sorted(candidates) if not _looks_like_null_device(path)]
- )
+ for path in sorted(candidates):
+ if not _looks_like_null_device(path):
+ _truncate_text_log(path)
- async def live_ffmpeg_pids(self, process: str) -> list[int]:
- return await asyncio.to_thread(_linux_ffmpeg_pids_for_stack, process, self.base_dir)
+ async def process_infos(self, processes: Iterable[str]) -> dict[str, dict[str, Any]]:
+ await self.ensure_mode()
+ names: list[str] = []
+ seen: set[str] = set()
+ for raw_name in processes:
+ name = str(raw_name or "").strip()
+ if not name or name in seen:
+ continue
+ seen.add(name)
+ names.append(name)
+
+ if not self._use_pm2:
+ return {name: self._local.describe_status(name) for name in names}
+
+ runtime = await self._proc_runtime_items()
+ known = self._pm2_dump_registered_names() | {
+ str(name).strip()
+ for name in self._read_pm2_desired_state().keys()
+ if str(name).strip()
+ }
+
+ out: dict[str, dict[str, Any]] = {}
+ for name in names:
+ process_info = runtime.get(name)
+ out_path, err_path = self._pm2_default_log_paths(name)
+ if process_info is not None:
+ out[name] = {
+ "process_status": "online",
+ "out_path": out_path,
+ "err_path": err_path,
+ "pid": int(process_info.get("pid") or 0),
+ }
+ elif name in known:
+ out[name] = {
+ "process_status": "stopped",
+ "out_path": out_path,
+ "err_path": err_path,
+ "pid": 0,
+ }
+ else:
+ out[name] = {
+ "process_status": "not_found",
+ "out_path": None,
+ "err_path": None,
+ "pid": 0,
+ }
+ return out
+
+ async def process_pid(self, process: str) -> int:
+ await self.ensure_mode()
+ if not self._use_pm2:
+ info = self._local.describe_status(process)
+ try:
+ pid = int(info.get("pid") or 0)
+ except (TypeError, ValueError):
+ return 0
+ return pid if self._local._pid_alive(pid) else 0
+
+ info = (await self.process_infos([process])).get(process) or {}
+ try:
+ pid = int(info.get("pid") or 0)
+ except (TypeError, ValueError):
+ return 0
+ return pid if pid > 0 else 0
+
+ async def live_ffmpeg_pids(self, process: str, info: Optional[dict[str, Any]] = None) -> list[int]:
+ if info is None:
+ info = (await self.process_infos([process])).get(process) or {}
+ try:
+ pid = int(info.get("pid") or 0)
+ except (TypeError, ValueError):
+ pid = 0
+ if pid <= 0:
+ return []
+ try:
+ pgid = os.getpgid(pid)
+ except OSError:
+ pgid = None
+ return _linux_ffmpeg_pids_for_process_tree(pid, root_pgid=pgid)
async def _pm2_save(self) -> None:
if not self._use_pm2:
@@ -605,46 +947,54 @@ class WebProcessBackend:
await run_pm2("save", timeout=20.0)
async def start(self, process: str, script: str) -> str:
- await self.ensure_mode()
+ await self.ensure_pm2_runtime()
if self._use_pm2:
registered = await self._pm2_registered_names()
if process in registered:
info = await self.parse_pm2_describe(process)
if str(info.get("process_status") or "").lower() not in ("online", "running"):
await self.clear_process_logs(process)
- text = await run_pm2(f"start {process}")
- await self._set_pm2_desired_running(process, True)
- await self._pm2_save()
+ code, text = await run_pm2_result(f"start {process}")
+ if code == 0:
+ await self._set_pm2_desired_running(process, True)
+ await self._pm2_save()
+ self.invalidate_pm2_cache()
return text
await self.clear_process_logs(process)
cmd = build_pm2_start_command(self.base_dir, process, script)
if not cmd:
return f"ERROR: script not found: {script}"
- _, text = await _shell_out(cmd, timeout=45.0)
- await self._set_pm2_desired_running(process, True)
- await self._pm2_save()
+ code, text = await _shell_out(cmd, timeout=45.0)
+ if code == 0:
+ await self._set_pm2_desired_running(process, True)
+ await self._pm2_save()
+ self.invalidate_pm2_cache()
return text
return await self._local.start(process, script)
async def stop(self, process: str) -> str:
- await self.ensure_mode()
+ await self.ensure_pm2_runtime()
if self._use_pm2:
- text = await run_pm2(f"stop {process}")
- await self._set_pm2_desired_running(process, False)
- await self._pm2_save()
+ code, text = await run_pm2_result(f"stop {process}")
+ if code == 0:
+ await self._set_pm2_desired_running(process, False)
+ await self._pm2_save()
+ self.invalidate_pm2_cache()
return text
return await self._local.stop(process)
async def restart(self, process: str, script: str) -> str:
- await self.ensure_mode()
+ await self.ensure_pm2_runtime()
if self._use_pm2:
registered = await self._pm2_registered_names()
if process not in registered:
return await self.start(process, script)
await self.clear_process_logs(process)
- text = await run_pm2(f"restart {process}")
- await self._set_pm2_desired_running(process, True)
- await self._pm2_save()
+ code, text = await run_pm2_result(f"restart {process}")
+ if code == 0:
+ await self._set_pm2_desired_running(process, True)
+ await self._pm2_save()
+ self.invalidate_pm2_cache()
return text
return await self._local.restart(process, script)
@@ -660,66 +1010,34 @@ class WebProcessBackend:
async def full_status_raw(self) -> str:
await self.ensure_mode()
if self._use_pm2:
- return await run_pm2("status", timeout=8.0)
+ known = sorted(await self._pm2_registered_names())
+ if not known:
+ return "PM2 process list is empty"
+ info_map = await self.process_infos(known)
+ rows: list[tuple[str, str, str]] = []
+ for name in known:
+ info = info_map.get(name) or {}
+ rows.append((name, str(info.get("process_status") or "unknown"), str(info.get("pid") or 0)))
+ if not rows:
+ return "PM2 process list is empty"
+ name_w = max(len("name"), *(len(name) for name, _, _ in rows))
+ status_w = max(len("status"), *(len(status) for _, status, _ in rows))
+ pid_w = max(len("pid"), *(len(pid) for _, _, pid in rows))
+ lines = [
+ f"{'name':<{name_w}} {'status':<{status_w}} {'pid':<{pid_w}}",
+ f"{'-' * name_w} {'-' * status_w} {'-' * pid_w}",
+ ]
+ for name, status, pid in rows:
+ lines.append(f"{name:<{name_w}} {status:<{status_w}} {pid:<{pid_w}}")
+ lines.append("(PM2 snapshot from jlist)")
+ return "\n".join(lines)
return self._local.status_table_text()
async def parse_pm2_describe(self, process: str) -> dict[str, Any]:
- await self.ensure_mode()
- if not self._use_pm2:
- d = self._local.describe_status(process)
- return {
- "process_status": d["process_status"],
- "out_path": d["out_path"],
- "err_path": d["err_path"],
- }
-
- registered = await self._pm2_registered_names()
- describe_raw = await run_pm2(f"describe {process}", timeout=10.0)
- describe_clean = strip_ansi_codes(describe_raw)
-
- if any(
- x in describe_clean.lower()
- for x in ["no such process", "unknown process", "not found"]
- ) or not describe_clean.strip():
- return {
- "process_status": "not_found",
- "out_path": None,
- "err_path": None,
- }
-
- status = "unknown"
- out_path = None
- err_path = None
-
- for line in describe_raw.splitlines():
- line_clean = strip_ansi_codes(line)
- if line_clean.count("│") < 2:
- continue
- parts = [p.strip() for p in line_clean.split("│") if p.strip()]
- if len(parts) >= 2:
- key = parts[0].lower().replace(" ", "_")
- value = parts[1]
- if key == "status":
- status = value.lower()
- elif key == "out_log_path":
- out_path = value
- elif key in {"err_log_path", "error_log_path"}:
- err_path = value
-
- if status == "unknown" and out_path is None and err_path is None and process not in registered:
- return {
- "process_status": "not_found",
- "out_path": None,
- "err_path": None,
- }
-
- if not out_path:
- out_path = str(self.default_log_dir / f"{process}-out.log")
- if not err_path:
- err_path = str(self.default_log_dir / f"{process}-error.log")
-
+ info = (await self.process_infos([process])).get(process) or {}
return {
- "process_status": status,
- "out_path": out_path,
- "err_path": err_path,
+ "process_status": str(info.get("process_status") or "not_found"),
+ "out_path": info.get("out_path"),
+ "err_path": info.get("err_path"),
+ "pid": int(info.get("pid") or 0) if str(info.get("pid") or "0").isdigit() else 0,
}
diff --git a/d2ypp2/src/youtube_config_paths.py b/d2ypp2/src/youtube_config_paths.py
new file mode 100644
index 0000000..8b75c69
--- /dev/null
+++ b/d2ypp2/src/youtube_config_paths.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+_SAFE_PM2_SEGMENT = re.compile(r"[^a-zA-Z0-9_.-]+")
+
+
+def sanitize_pm2_segment(value: str) -> str:
+ return _SAFE_PM2_SEGMENT.sub("_", (value or "").strip())
+
+
+def is_youtube_like_pm2(pm2: str | None) -> bool:
+ low = (pm2 or "").strip().lower()
+ return low.startswith("youtube") or low.startswith("douyin_youtube")
+
+
+def resolve_youtube_style_config_paths(base_dir: str | Path, pm2: str | None = None) -> tuple[str, str]:
+ root = Path(base_dir)
+ legacy_youtube = root / "config" / "youtube.ini"
+ legacy_url = root / "config" / "URL_config.ini"
+
+ if not is_youtube_like_pm2(pm2):
+ return str(legacy_youtube), str(legacy_url)
+
+ safe = sanitize_pm2_segment(pm2 or "")
+ managed_youtube = root / "config" / f"youtube.{safe}.ini"
+ managed_url = root / "config" / f"URL_config.{safe}.ini"
+
+ managed_youtube_exists = managed_youtube.is_file()
+ managed_url_exists = managed_url.is_file()
+ legacy_youtube_exists = legacy_youtube.is_file()
+ legacy_url_exists = legacy_url.is_file()
+
+ if managed_youtube_exists or managed_url_exists:
+ youtube_path = managed_youtube if managed_youtube_exists else legacy_youtube if legacy_youtube_exists else managed_youtube
+ url_path = managed_url if managed_url_exists else legacy_url if legacy_url_exists else managed_url
+ return str(youtube_path), str(url_path)
+
+ if legacy_youtube_exists or legacy_url_exists:
+ return str(legacy_youtube), str(legacy_url)
+
+ return str(managed_youtube), str(managed_url)
diff --git a/d2ypp2/src/youtube_pro_runtime.py b/d2ypp2/src/youtube_pro_runtime.py
index 90a2458..ebb7da8 100644
--- a/d2ypp2/src/youtube_pro_runtime.py
+++ b/d2ypp2/src/youtube_pro_runtime.py
@@ -4,6 +4,8 @@ import re
from pathlib import Path
from typing import Any, Iterable, Optional
+from src.youtube_config_paths import resolve_youtube_style_config_paths
+
_SAFE_PM2_SEGMENT = re.compile(r"[^a-zA-Z0-9_.-]+")
_YOUTUBE_HEADER = re.compile(r"^\s*\[youtube\]\s*$", re.IGNORECASE)
_ANY_HEADER = re.compile(r"^\s*\[[^\]]+\]\s*$")
@@ -228,3 +230,25 @@ def ensure_youtube_pro_channel_runtime_files(
except OSError as exc:
return True, [f"{pm2}: failed to write config ({exc})"]
return True, []
+
+
+def validate_youtube_runtime_files(
+ base_dir: Path,
+ config_dir: Path,
+ process: str,
+ channels: Iterable[dict[str, Any]],
+) -> list[str]:
+ handled, errors = ensure_youtube_pro_channel_runtime_files(config_dir, process, channels)
+ if handled:
+ return errors
+
+ youtube_path_raw, url_path_raw = resolve_youtube_style_config_paths(base_dir, process)
+ youtube_text = _read_text_if_file(Path(youtube_path_raw)) or ""
+ url_text = _read_text_if_file(Path(url_path_raw)) or ""
+
+ fallback_errors: list[str] = []
+ if not _extract_stream_key(youtube_text):
+ fallback_errors.append(f"{process}: stream key missing")
+ if not _has_noncomment_url(url_text):
+ fallback_errors.append(f"{process}: source URL missing")
+ return fallback_errors
diff --git a/d2ypp2/start.sh b/d2ypp2/start.sh
old mode 100644
new mode 100755
diff --git a/d2ypp2/tests/test_hub_routes.py b/d2ypp2/tests/test_hub_routes.py
index 88acba0..d3d64e8 100644
--- a/d2ypp2/tests/test_hub_routes.py
+++ b/d2ypp2/tests/test_hub_routes.py
@@ -33,6 +33,9 @@ class HubRoutesTests(unittest.TestCase):
backend = type("StubBackend", (), {})()
backend.mode = "local"
backend.ensure_mode = AsyncMock()
+ backend.process_infos = AsyncMock(
+ return_value={"youtube": {"process_status": "not_found", "out_path": None, "err_path": None, "pid": 0}}
+ )
backend.parse_pm2_describe = AsyncMock(return_value={"process_status": "not_found", "out_path": None, "err_path": None})
backend.live_ffmpeg_pids = AsyncMock(return_value=[])
configure_hub(backend, [{"pm2": "youtube", "label": "YouTube", "script": "youtube.py"}])
diff --git a/d2ypp2/tests/test_neko_config.py b/d2ypp2/tests/test_neko_config.py
index 684c7af..96c2166 100644
--- a/d2ypp2/tests/test_neko_config.py
+++ b/d2ypp2/tests/test_neko_config.py
@@ -14,7 +14,6 @@ class NekoConfigTests(unittest.TestCase):
self.assertIn("arch=\"$(uname -m", script)
self.assertIn("--disable-software-rasterizer", script)
self.assertIn("--use-gl=swiftshader", script)
- self.assertIn("--bwsi", script)
self.assertIn("NEKO_BROWSER_RENDER_MODE", script)
self.assertIn('prune_path "$profile_dir/BrowserMetrics"', script)
self.assertIn('prune_path "$profile_dir/Crash Reports"', script)
@@ -27,12 +26,14 @@ class NekoConfigTests(unittest.TestCase):
self.assertEqual(policies["DefaultGeolocationSetting"], 1)
self.assertTrue(policies["VideoCaptureAllowed"])
self.assertTrue(policies["AudioCaptureAllowed"])
+ self.assertEqual(policies["DeveloperToolsAvailability"], 1)
def test_google_chrome_supervisor_uses_direct_stable_command(self) -> None:
conf = (ROOT / "services" / "neko" / "google-chrome.conf").read_text(encoding="utf-8")
self.assertIn("command=/usr/bin/google-chrome", conf)
self.assertNotIn("browser-launch.sh", conf)
self.assertIn("--user-data-dir=/home/neko/.config/google-chrome", conf)
+ self.assertNotIn("--bwsi", conf)
self.assertIn("--disable-software-rasterizer", conf)
self.assertIn("autorestart=true", conf)
self.assertIn("[program:openbox]", conf)
@@ -43,6 +44,7 @@ class NekoConfigTests(unittest.TestCase):
self.assertIn("command=/usr/bin/chromium", conf)
self.assertNotIn("browser-launch.sh", conf)
self.assertIn("--user-data-dir=/home/neko/.config/chromium", conf)
+ self.assertNotIn("--bwsi", conf)
self.assertIn("--disable-software-rasterizer", conf)
self.assertIn("autorestart=true", conf)
self.assertIn("[program:openbox]", conf)
diff --git a/d2ypp2/tests/test_web_dynamic_processes.py b/d2ypp2/tests/test_web_dynamic_processes.py
index 63409eb..43e6544 100644
--- a/d2ypp2/tests/test_web_dynamic_processes.py
+++ b/d2ypp2/tests/test_web_dynamic_processes.py
@@ -33,13 +33,13 @@ class WebDynamicProcessesTests(unittest.TestCase):
self.assertEqual(resolved, "youtube.py")
- def test_process_monitor_entries_overlay_static_youtube_label_for_lane_one(self) -> None:
+ def test_process_monitor_entries_keep_static_youtube_label_for_lane_one(self) -> None:
channels = [{"id": "lane1", "name": "Lane 1", "pm2Name": "youtube"}]
with patch("web.get_youtube_pro_channels", return_value=channels):
entries = web.process_monitor_entries()
youtube_row = next(row for row in entries if row["pm2"] == "youtube")
- self.assertEqual(youtube_row["label"], "YouTube Pro \u00b7 Lane 1")
+ self.assertEqual(youtube_row["label"], "YouTube")
def test_process_monitor_entries_do_not_duplicate_static_pm2_rows(self) -> None:
channels = [
@@ -85,10 +85,13 @@ class WebDynamicProcessesTests(unittest.TestCase):
web.process_backend._use_pm2 = False
try:
with patch.object(web.process_backend, "ensure_mode", AsyncMock()):
- with patch("web.get_process_info", AsyncMock(return_value={"process_status": "not_found", "out_path": None, "err_path": None})):
+ with patch.object(
+ web.process_backend,
+ "process_infos",
+ AsyncMock(return_value={"youtube": {"process_status": "not_found", "out_path": None, "err_path": None}}),
+ ):
with patch("web.read_log_path_normalized", AsyncMock(return_value="")):
- with patch.object(web.process_backend, "live_ffmpeg_pids", AsyncMock(return_value=[])):
- payload = asyncio.run(web.process_monitor())
+ payload = asyncio.run(web.process_monitor())
finally:
web.process_backend._use_pm2 = original_use_pm2
@@ -97,6 +100,59 @@ class WebDynamicProcessesTests(unittest.TestCase):
self.assertEqual(youtube_row["business_status"], "ready")
self.assertFalse(youtube_row["is_pushing"])
+ def test_process_monitor_family_filter_returns_youtube_rows_only(self) -> None:
+ original_use_pm2 = web.process_backend._use_pm2
+ web.process_backend._use_pm2 = False
+ try:
+ with patch("web.process_monitor_entries", return_value=(
+ {"pm2": "youtube", "script": "youtube.py", "label": "YouTube"},
+ {"pm2": "youtube2__lane1", "script": "youtube.py", "label": "Lane 1"},
+ {"pm2": "tiktok", "script": "tiktok.py", "label": "TikTok"},
+ )):
+ with patch.object(web.process_backend, "ensure_mode", AsyncMock()):
+ with patch.object(
+ web.process_backend,
+ "process_infos",
+ AsyncMock(
+ return_value={
+ "youtube": {"process_status": "stopped", "out_path": None, "err_path": None},
+ "youtube2__lane1": {"process_status": "online", "out_path": None, "err_path": None},
+ }
+ ),
+ ):
+ with patch("web.read_log_path_normalized", AsyncMock(return_value="")):
+ with patch.object(web.process_backend, "live_ffmpeg_pids", AsyncMock(return_value=[])):
+ payload = asyncio.run(web.process_monitor(family="youtube", light=True))
+ finally:
+ web.process_backend._use_pm2 = original_use_pm2
+
+ pm2_names = {row["pm2"] for row in payload["entries"]}
+ self.assertIn("youtube", pm2_names)
+ self.assertIn("youtube2__lane1", pm2_names)
+ self.assertTrue(all(name.startswith("youtube") or name.startswith("douyin_youtube") for name in pm2_names))
+
+ def test_restart_kills_old_ffmpeg_before_backend_restart(self) -> None:
+ calls: list[str] = []
+
+ async def fake_kill(process: str, base_dir, **_kwargs) -> None: # noqa: ANN001
+ calls.append(f"kill:{process}")
+
+ async def fake_restart(process: str, script: str) -> str:
+ calls.append(f"restart:{process}:{script}")
+ return "ok"
+
+ with patch("web.is_valid_control_process", return_value=True):
+ with patch("web.script_for_pm2_or_pro", return_value="youtube.py"):
+ with patch("web._validate_youtube_runtime_files", return_value=[]):
+ with patch("web._stop_other_hdmi_sink_processes", new=AsyncMock(return_value=[])):
+ with patch("web.force_kill_ffmpeg", new=AsyncMock(side_effect=fake_kill)):
+ with patch.object(web.process_backend, "restart", new=AsyncMock(side_effect=fake_restart)):
+ response = asyncio.run(web.restart("youtube"))
+
+ payload = json.loads(response.body)
+ self.assertEqual(payload["output"], "ok")
+ self.assertEqual(calls, ["kill:youtube", "restart:youtube:youtube.py"])
+
if __name__ == "__main__":
unittest.main()
diff --git a/d2ypp2/tests/test_web_process_backend.py b/d2ypp2/tests/test_web_process_backend.py
index e6aa440..58492d6 100644
--- a/d2ypp2/tests/test_web_process_backend.py
+++ b/d2ypp2/tests/test_web_process_backend.py
@@ -29,41 +29,56 @@ class WebProcessBackendTests(unittest.TestCase):
self.assertIn(str(script), cmd)
self.assertIn(str(venv_python), cmd)
- def test_parse_pm2_describe_marks_unknown_unregistered_process_as_not_found(self) -> None:
+ def test_parse_pm2_describe_marks_missing_jlist_process_as_not_found(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
base_dir = Path(tmp)
backend = WebProcessBackend(base_dir, base_dir / ".pm2" / "logs")
- backend._use_pm2 = True
- backend.ensure_mode = AsyncMock()
- backend._pm2_registered_names = AsyncMock(return_value=set())
- warn = "[PM2][WARN] Current process list is not synchronized with saved list. App youtube differs."
- with patch("src.web_process_backend.run_pm2", new=AsyncMock(return_value=warn)):
- info = asyncio.run(backend.parse_pm2_describe("youtube"))
+ backend.process_infos = AsyncMock(return_value={"youtube": {"process_status": "not_found", "out_path": None, "err_path": None, "pid": 0}}) # type: ignore[method-assign]
+
+ info = asyncio.run(backend.parse_pm2_describe("youtube"))
+
self.assertEqual(info["process_status"], "not_found")
self.assertIsNone(info["out_path"])
self.assertIsNone(info["err_path"])
+ self.assertEqual(info["pid"], 0)
- def test_parse_pm2_describe_supports_spaced_log_path_keys(self) -> None:
+ def test_parse_pm2_describe_reads_status_and_log_paths_from_jlist_item(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
base_dir = Path(tmp)
backend = WebProcessBackend(base_dir, base_dir / ".pm2" / "logs")
- backend._use_pm2 = True
- backend.ensure_mode = AsyncMock()
- backend._pm2_registered_names = AsyncMock(return_value={"youtube"})
- describe = "\n".join(
- [
- "│ status │ online │",
- "│ out log path │ /home/live/.pm2/logs/youtube-out.log │",
- "│ error log path │ /home/live/.pm2/logs/youtube-error.log │",
- ]
+ backend.process_infos = AsyncMock( # type: ignore[method-assign]
+ return_value={
+ "youtube": {
+ "process_status": "online",
+ "out_path": "/home/live/.pm2/logs/youtube-out.log",
+ "err_path": "/home/live/.pm2/logs/youtube-error.log",
+ "pid": 4567,
+ }
+ }
)
- with patch("src.web_process_backend.run_pm2", new=AsyncMock(return_value=describe)):
- info = asyncio.run(backend.parse_pm2_describe("youtube"))
+
+ info = asyncio.run(backend.parse_pm2_describe("youtube"))
+
self.assertEqual(info["process_status"], "online")
self.assertEqual(info["out_path"], "/home/live/.pm2/logs/youtube-out.log")
self.assertEqual(info["err_path"], "/home/live/.pm2/logs/youtube-error.log")
+ self.assertEqual(info["pid"], 4567)
- def test_ensure_mode_resurrects_pm2_when_dump_exists_and_jlist_is_empty(self) -> None:
+ def test_pm2_jlist_items_uses_short_ttl_cache(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ base_dir = Path(tmp)
+ backend = WebProcessBackend(base_dir, base_dir / ".pm2" / "logs")
+ shell_out = AsyncMock(return_value=(0, '[{"name":"youtube","pm2_env":{"status":"online"}}]'))
+ with patch("src.web_process_backend.resolve_pm2_bin", return_value="/usr/bin/pm2"):
+ with patch("src.web_process_backend._shell_out", new=shell_out):
+ first = asyncio.run(backend._pm2_jlist_items())
+ second = asyncio.run(backend._pm2_jlist_items())
+
+ self.assertEqual(first[0]["name"], "youtube")
+ self.assertEqual(second[0]["name"], "youtube")
+ shell_out.assert_awaited_once()
+
+ def test_ensure_pm2_runtime_resurrects_pm2_when_dump_exists_and_jlist_is_empty(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
base_dir = Path(tmp)
pm2_home = base_dir / ".pm2-home"
@@ -75,16 +90,16 @@ class WebProcessBackendTests(unittest.TestCase):
backend._use_pm2 = True
shell_out = AsyncMock(return_value=(0, "[]"))
- resurrect = AsyncMock(return_value="ok")
+ resurrect = AsyncMock(return_value=(0, "ok"))
with patch.dict("os.environ", {"PM2_HOME": str(pm2_home)}, clear=False):
with patch("src.web_process_backend.resolve_pm2_bin", return_value="/usr/bin/pm2"):
with patch("src.web_process_backend._shell_out", new=shell_out):
- with patch("src.web_process_backend.run_pm2", new=resurrect):
- asyncio.run(backend.ensure_mode())
+ with patch("src.web_process_backend.run_pm2_result", new=resurrect):
+ asyncio.run(backend.ensure_pm2_runtime())
resurrect.assert_awaited_once_with("resurrect", timeout=45.0)
- def test_ensure_mode_skips_resurrect_without_saved_dump(self) -> None:
+ def test_ensure_pm2_runtime_skips_resurrect_without_saved_dump(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
base_dir = Path(tmp)
pm2_home = base_dir / ".pm2-home"
@@ -95,15 +110,52 @@ class WebProcessBackendTests(unittest.TestCase):
backend._use_pm2 = True
shell_out = AsyncMock(return_value=(0, "[]"))
- resurrect = AsyncMock(return_value="ok")
+ resurrect = AsyncMock(return_value=(0, "ok"))
with patch.dict("os.environ", {"PM2_HOME": str(pm2_home)}, clear=False):
with patch("src.web_process_backend.resolve_pm2_bin", return_value="/usr/bin/pm2"):
with patch("src.web_process_backend._shell_out", new=shell_out):
- with patch("src.web_process_backend.run_pm2", new=resurrect):
- asyncio.run(backend.ensure_mode())
+ with patch("src.web_process_backend.run_pm2_result", new=resurrect):
+ asyncio.run(backend.ensure_pm2_runtime())
resurrect.assert_not_awaited()
+ def test_start_does_not_mark_desired_running_when_shell_command_fails(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ base_dir = Path(tmp)
+ script = base_dir / "youtube.py"
+ script.write_text("print('ok')\n", encoding="utf-8")
+
+ backend = WebProcessBackend(base_dir, base_dir / ".pm2" / "logs")
+ backend._use_pm2 = True
+
+ with patch.object(backend, "ensure_mode", AsyncMock()):
+ with patch.object(backend, "_pm2_registered_names", AsyncMock(return_value=set())):
+ with patch.object(backend, "clear_process_logs", AsyncMock()):
+ with patch("src.web_process_backend.build_pm2_start_command", return_value="pm2 start ..."):
+ with patch("src.web_process_backend._shell_out", new=AsyncMock(return_value=(1, "boom"))):
+ with patch.object(backend, "_set_pm2_desired_running", AsyncMock()) as set_desired:
+ with patch.object(backend, "_pm2_save", AsyncMock()):
+ text = asyncio.run(backend.start("youtube", "youtube.py"))
+
+ self.assertEqual(text, "boom")
+ set_desired.assert_not_awaited()
+
+ def test_live_ffmpeg_pids_uses_process_tree_of_target_process(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ base_dir = Path(tmp)
+ backend = WebProcessBackend(base_dir, base_dir / ".pm2" / "logs")
+ backend.ensure_mode = AsyncMock()
+ backend.process_infos = AsyncMock( # type: ignore[method-assign]
+ return_value={"youtube": {"pid": 4321, "process_status": "online"}}
+ )
+
+ with patch("src.web_process_backend.os.getpgid", return_value=6789):
+ with patch("src.web_process_backend._linux_ffmpeg_pids_for_process_tree", return_value=[9876]) as finder:
+ pids = asyncio.run(backend.live_ffmpeg_pids("youtube"))
+
+ self.assertEqual(pids, [9876])
+ finder.assert_called_once_with(4321, root_pgid=6789)
+
if __name__ == "__main__":
unittest.main()
diff --git a/d2ypp2/tests/test_youtube_config_paths.py b/d2ypp2/tests/test_youtube_config_paths.py
new file mode 100644
index 0000000..08c458b
--- /dev/null
+++ b/d2ypp2/tests/test_youtube_config_paths.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+import tempfile
+import unittest
+from pathlib import Path
+
+from src.youtube_config_paths import resolve_youtube_style_config_paths
+
+
+class YoutubeConfigPathTests(unittest.TestCase):
+ def test_non_youtube_process_uses_legacy_paths(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ cfg = root / "config"
+ cfg.mkdir(parents=True, exist_ok=True)
+ yt, url = resolve_youtube_style_config_paths(root, "tiktok")
+ self.assertEqual(yt, str(cfg / "youtube.ini"))
+ self.assertEqual(url, str(cfg / "URL_config.ini"))
+
+ def test_youtube_process_uses_legacy_pair_when_only_legacy_exists(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ cfg = root / "config"
+ cfg.mkdir(parents=True, exist_ok=True)
+ (cfg / "youtube.ini").write_text("[youtube]\nkey = legacy\n", encoding="utf-8")
+ (cfg / "URL_config.ini").write_text("https://live.douyin.com/1\n", encoding="utf-8")
+
+ yt, url = resolve_youtube_style_config_paths(root, "youtube")
+
+ self.assertEqual(yt, str(cfg / "youtube.ini"))
+ self.assertEqual(url, str(cfg / "URL_config.ini"))
+
+ def test_youtube_process_falls_back_per_file_when_only_managed_url_exists(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ cfg = root / "config"
+ cfg.mkdir(parents=True, exist_ok=True)
+ (cfg / "youtube.ini").write_text("[youtube]\nkey = legacy\n", encoding="utf-8")
+ managed_url = cfg / "URL_config.youtube.ini"
+ managed_url.write_text("https://live.douyin.com/2\n", encoding="utf-8")
+
+ yt, url = resolve_youtube_style_config_paths(root, "youtube")
+
+ self.assertEqual(yt, str(cfg / "youtube.ini"))
+ self.assertEqual(url, str(managed_url))
+
+ def test_youtube_process_falls_back_per_file_when_only_managed_youtube_ini_exists(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ cfg = root / "config"
+ cfg.mkdir(parents=True, exist_ok=True)
+ managed_yt = cfg / "youtube.youtube.ini"
+ managed_yt.write_text("[youtube]\nkey = managed\n", encoding="utf-8")
+ (cfg / "URL_config.ini").write_text("https://live.douyin.com/3\n", encoding="utf-8")
+
+ yt, url = resolve_youtube_style_config_paths(root, "youtube")
+
+ self.assertEqual(yt, str(managed_yt))
+ self.assertEqual(url, str(cfg / "URL_config.ini"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/d2ypp2/tests/test_youtube_pro_runtime.py b/d2ypp2/tests/test_youtube_pro_runtime.py
index 856b088..18e085d 100644
--- a/d2ypp2/tests/test_youtube_pro_runtime.py
+++ b/d2ypp2/tests/test_youtube_pro_runtime.py
@@ -9,6 +9,7 @@ from src.youtube_pro_runtime import (
managed_url_config_relpath_for_pm2,
managed_youtube_ini_relpath_for_pm2,
normalize_youtube_pro_channels,
+ validate_youtube_runtime_files,
)
@@ -76,6 +77,28 @@ class YoutubeProRuntimeTests(unittest.TestCase):
self.assertIn("legacy-key", youtube_ini)
self.assertIn("https://live.douyin.com/88888888", url_ini)
+ def test_validate_runtime_files_reports_missing_single_lane_inputs(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ config_dir = root / "config"
+ config_dir.mkdir(parents=True, exist_ok=True)
+
+ errors = validate_youtube_runtime_files(root, config_dir, "youtube", [])
+
+ self.assertEqual(errors, ["youtube: stream key missing", "youtube: source URL missing"])
+
+ def test_validate_runtime_files_accepts_legacy_single_lane_pair(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ config_dir = root / "config"
+ config_dir.mkdir(parents=True, exist_ok=True)
+ (config_dir / "youtube.ini").write_text("[youtube]\nkey = legacy-key\nrtmps = 是\n", encoding="utf-8")
+ (config_dir / "URL_config.ini").write_text("https://live.douyin.com/123456\n", encoding="utf-8")
+
+ errors = validate_youtube_runtime_files(root, config_dir, "youtube", [])
+
+ self.assertEqual(errors, [])
+
if __name__ == "__main__":
unittest.main()
diff --git a/d2ypp2/web-console/app/globals.css b/d2ypp2/web-console/app/globals.css
index cf14acd..50c1891 100644
--- a/d2ypp2/web-console/app/globals.css
+++ b/d2ypp2/web-console/app/globals.css
@@ -293,7 +293,7 @@ html[data-theme="light"] .chart-surface {
background-size: 28px 28px;
}
-/* Live Hub 产品壳:噪点与光晕 */
+/* 无人直播系统产品壳:噪点与光晕 */
.lp-noise {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
}
diff --git a/d2ypp2/web-console/app/layout.tsx b/d2ypp2/web-console/app/layout.tsx
index 88a72d1..ac23f34 100644
--- a/d2ypp2/web-console/app/layout.tsx
+++ b/d2ypp2/web-console/app/layout.tsx
@@ -15,8 +15,8 @@ const geistMono = localFont({
});
export const metadata: Metadata = {
- title: "Live Hub · 超级业务中心",
- description: "ARM/x86 Linux 统一控制中心:服务、无人直播、安卓、网络与配置",
+ title: "无人直播系统 · live.local",
+ description: "Linux ARM/x86 一键部署的无人直播控制台:YouTube 转播、TikTok HDMI、SRS、Neko 与系统服务",
};
export default function RootLayout({
diff --git a/d2ypp2/web-console/components/live-product/LiveAndroidStreamConsole.tsx b/d2ypp2/web-console/components/live-product/LiveAndroidStreamConsole.tsx
index c373aa7..cbcf2af 100644
--- a/d2ypp2/web-console/components/live-product/LiveAndroidStreamConsole.tsx
+++ b/d2ypp2/web-console/components/live-product/LiveAndroidStreamConsole.tsx
@@ -702,7 +702,7 @@ export function LiveAndroidStreamConsole({
- {t("Live Hub · ADB 云控台", "Live Hub · ADB farm console")}
+ {t("无人直播系统 · ADB 云控台", "Unmanned Live System · ADB farm console")}
{t(
diff --git a/d2ypp2/web-console/components/live-product/LiveControlApp.tsx b/d2ypp2/web-console/components/live-product/LiveControlApp.tsx
index dbdaed6..6d84977 100644
--- a/d2ypp2/web-console/components/live-product/LiveControlApp.tsx
+++ b/d2ypp2/web-console/components/live-product/LiveControlApp.tsx
@@ -24,7 +24,7 @@ import {
Wifi,
X,
} from "lucide-react";
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { LiveStudioScene3D } from "@/components/LiveStudioScene3D";
import OverviewCharts from "@/components/OverviewCharts";
@@ -148,6 +148,12 @@ type HubDashboard = {
type SrsProbe = NonNullable["srs"];
type ProcessEntry = { pm2: string; script: string; label: string };
+type ProcessMonitorRow = ProcessEntry & {
+ process_status?: string;
+ business_status?: string;
+ business_note?: string;
+ is_pushing?: boolean;
+};
function formatUptime(sec?: number) {
if (sec == null || !Number.isFinite(sec)) return "—";
@@ -208,6 +214,7 @@ export default function LiveControlApp() {
const [toast, setToast] = useState(null);
const [entries, setEntries] = useState([]);
+ const [processRows, setProcessRows] = useState([]);
const [currentProc, setCurrentProc] = useState("");
const [urlConfig, setUrlConfig] = useState("");
const [apkPath, setApkPath] = useState("/sdcard/app.apk");
@@ -215,6 +222,8 @@ export default function LiveControlApp() {
const [hubCfgSnapshot, setHubCfgSnapshot] = useState | null>(null);
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true);
const [showStreamProbes, setShowStreamProbes] = useState(true);
+ const dashboardInFlightRef = useRef | null>(null);
+ const processMonitorInFlightRef = useRef | null>(null);
const t = useCallback((zh: string, en: string) => tr(lang, zh, en), [lang]);
@@ -231,22 +240,58 @@ export default function LiveControlApp() {
);
const refreshDashboard = useCallback(async () => {
- try {
- const d = await fetchJson("/hub/dashboard");
- setDash(d);
- setDashErr(null);
- const devs = d.android?.devices || [];
- if (devs.length && !androidSerial) {
- const ready = devs.find((x) => (x.state || "").toLowerCase() === "device");
- setAndroidSerial((ready ?? devs[0]).serial);
+ if (dashboardInFlightRef.current) return dashboardInFlightRef.current;
+ const task = (async () => {
+ try {
+ const d = await fetchJson("/hub/dashboard");
+ setDash(d);
+ setDashErr(null);
+ const devs = d.android?.devices || [];
+ if (devs.length && !androidSerial) {
+ const ready = devs.find((x) => (x.state || "").toLowerCase() === "device");
+ setAndroidSerial((ready ?? devs[0]).serial);
+ }
+ } catch (e) {
+ setDashErr((e as Error).message);
+ } finally {
+ setLoading(false);
+ dashboardInFlightRef.current = null;
}
- } catch (e) {
- setDashErr((e as Error).message);
- } finally {
- setLoading(false);
- }
+ })();
+ dashboardInFlightRef.current = task;
+ return task;
}, [androidSerial, fetchJson]);
+ const processMonitorPath = useMemo(() => {
+ const params = new URLSearchParams({ light: "1" });
+ if (view === "live_youtube") params.set("family", "youtube");
+ else if (view === "live_tiktok") params.set("family", "tiktok");
+ return `/process_monitor?${params.toString()}`;
+ }, [view]);
+
+ const refreshProcessMonitor = useCallback(async () => {
+ if (processMonitorInFlightRef.current) return processMonitorInFlightRef.current;
+ const task = (async () => {
+ try {
+ const data = await fetchJson<{ entries?: ProcessMonitorRow[] }>(processMonitorPath);
+ const list = data.entries || [];
+ setProcessRows(list);
+ setEntries(list.map((row) => ({ pm2: row.pm2, script: row.script, label: row.label })));
+ setCurrentProc((prev) => {
+ if (!list.length) return prev;
+ if (prev && list.some((row) => row.pm2 === prev)) return prev;
+ return list[0].pm2;
+ });
+ } catch {
+ /* ignore */
+ } finally {
+ processMonitorInFlightRef.current = null;
+ }
+ })();
+ processMonitorInFlightRef.current = task;
+ return task;
+ }, [fetchJson, processMonitorPath]);
+
useEffect(() => {
const s = window.localStorage.getItem("live-hub-lang");
if (s === "zh" || s === "en") setLang(s);
@@ -278,23 +323,27 @@ export default function LiveControlApp() {
useEffect(() => {
void refreshDashboard();
- if (!autoRefreshEnabled) return;
- const id = window.setInterval(() => void refreshDashboard(), 10000);
- return () => clearInterval(id);
- }, [refreshDashboard, autoRefreshEnabled]);
+ }, [refreshDashboard]);
useEffect(() => {
- void (async () => {
- try {
- const data = await fetchJson<{ entries?: ProcessEntry[] }>("/process_monitor");
- const list = data.entries || [];
- setEntries(list);
- if (!currentProc && list[0]) setCurrentProc(list[0].pm2);
- } catch {
- /* ignore */
- }
- })();
- }, [currentProc, fetchJson]);
+ void refreshProcessMonitor();
+ }, [refreshProcessMonitor]);
+
+ useEffect(() => {
+ if (!autoRefreshEnabled || view === "live_youtube" || view === "live_tiktok") return;
+ const id = window.setInterval(() => {
+ void refreshDashboard();
+ }, 10000);
+ return () => clearInterval(id);
+ }, [autoRefreshEnabled, refreshDashboard, view]);
+
+ useEffect(() => {
+ if (!autoRefreshEnabled) return;
+ const id = window.setInterval(() => {
+ void refreshProcessMonitor();
+ }, view === "live_youtube" || view === "live_tiktok" ? 700 : 4000);
+ return () => clearInterval(id);
+ }, [autoRefreshEnabled, refreshProcessMonitor, view]);
useEffect(() => {
if (!currentProc || view !== "live_youtube") return;
@@ -305,7 +354,7 @@ export default function LiveControlApp() {
);
setUrlConfig(data.content || "");
} catch {
- setUrlConfig("");
+ /* 保留当前草稿,避免临时读取失败把编辑器清空 */
}
})();
}, [currentProc, fetchJson, view]);
@@ -368,7 +417,12 @@ export default function LiveControlApp() {
body: JSON.stringify({ process: proc }),
});
notify(String(data.output || data.message || "OK"));
- await refreshDashboard();
+ await refreshProcessMonitor();
+ if (view === "live_youtube" || view === "live_tiktok") {
+ void refreshDashboard();
+ } else {
+ await refreshDashboard();
+ }
} catch (e) {
notify((e as Error).message);
} finally {
@@ -376,12 +430,13 @@ export default function LiveControlApp() {
}
};
- const saveUrlConfig = async (overrideContent?: string) => {
- if (!currentProc) return;
+ const saveUrlConfig = async (overrideContent?: string, processOverride?: string) => {
+ const proc = (processOverride ?? currentProc).trim();
+ if (!proc) return;
const content = overrideContent ?? urlConfig;
setBusy("save:url");
try {
- const params = new URLSearchParams({ process: currentProc });
+ const params = new URLSearchParams({ process: proc });
await fetchJson(`/save_url_config?${params}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -485,10 +540,10 @@ export default function LiveControlApp() {
- Live Hub
+ live.local
- {t("超级业务中心", "Control Center")}
+ {t("无人直播系统", "Unmanned Live System")}
@@ -606,7 +661,7 @@ export default function LiveControlApp() {