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 @@ ![video_spider](https://socialify.git.ci/ihmily/DouyinLiveRecorder/image?font=Inter&forks=1&language=1&owner=1&pattern=Circuit%20Board&stargazers=1&theme=Light) -## 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() {
正在连接控制台...
\ No newline at end of file +无人直播系统 · live.local
返回门户首页无人直播 · 本页需与 API 同域(推荐 live.local:8001)

控制平面 · ARM Linux 开发板

无人直播控制中心

ShellCrash 负责代理上网;本机 FFmpeg 处理推流或 HDMI;Neko(Docker Chromium)或本机 Chromium 负责浏览器侧;开发板通过 USB 双头线 ADB 控制 AOSP。各模块独立,可分别启停。

live.local · unknown · unknown
正在连接控制台...
\ No newline at end of file diff --git a/d2ypp2/web-console/out/android.txt b/d2ypp2/web-console/out/android.txt index a823df2..0093e3e 100644 --- a/d2ypp2/web-console/out/android.txt +++ b/d2ypp2/web-console/out/android.txt @@ -1,7 +1,7 @@ 2:I[9107,[],"ClientPageRoot"] -3:I[3946,["500","static/chunks/500-ea37cc8de90441f7.js","246","static/chunks/246-d358b7b7982962c5.js","357","static/chunks/app/android/page-f500a06b3bb80332.js"],"default",1] +3:I[3946,["500","static/chunks/500-ea37cc8de90441f7.js","246","static/chunks/246-fafe28420dae1d39.js","357","static/chunks/app/android/page-84550dabda18d658.js"],"default",1] 4:I[4707,[],""] 5:I[6423,[],""] -0:["7mXMtMWzc0X5_y9x4MIOK",[[["",{"children":["android",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["android",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","android","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/dc08974264f65939.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"zh-CN","data-theme":"dark","suppressHydrationWarning":true,"children":["$","body",null,{"className":"__variable_1e4310 __variable_c3aa02 font-sans antialiased","children":[["$","div",null,{"className":"app-bg-layer","aria-hidden":true}],["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]]}]}]],null],null],["$L6",null]]]] -6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Live Hub · 超级业务中心"}],["$","meta","3",{"name":"description","content":"ARM/x86 Linux 统一控制中心:服务、无人直播、安卓、网络与配置"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}]] +0:["DYGAc5F7lnL_yZfXw32eU",[[["",{"children":["android",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["android",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","android","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/dc08974264f65939.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"zh-CN","data-theme":"dark","suppressHydrationWarning":true,"children":["$","body",null,{"className":"__variable_1e4310 __variable_c3aa02 font-sans antialiased","children":[["$","div",null,{"className":"app-bg-layer","aria-hidden":true}],["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]]}]}]],null],null],["$L6",null]]]] +6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"无人直播系统 · live.local"}],["$","meta","3",{"name":"description","content":"Linux ARM/x86 一键部署的无人直播控制台:YouTube 转播、TikTok HDMI、SRS、Neko 与系统服务"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}],["$","meta","5",{"name":"next-size-adjust"}]] 1:null diff --git a/d2ypp2/web-console/out/console.html b/d2ypp2/web-console/out/console.html index f7cf257..ce4c736 100644 --- a/d2ypp2/web-console/out/console.html +++ b/d2ypp2/web-console/out/console.html @@ -1 +1 @@ -Live Hub · 超级业务中心
返回门户首页无人直播 · 本页需与 API 同域(推荐 live.local:8001)

控制平面 · ARM Linux 开发板

无人直播控制中心

ShellCrash 负责代理上网;本机 FFmpeg 处理推流或 HDMI;Neko(Docker Chromium)或本机 Chromium 负责浏览器侧;开发板通过 USB 双头线 ADB 控制 AOSP。各模块独立,可分别启停。

live.local · unknown · unknown
正在连接控制台...
\ No newline at end of file +无人直播系统 · live.local
返回门户首页无人直播 · 本页需与 API 同域(推荐 live.local:8001)

控制平面 · ARM Linux 开发板

无人直播控制中心

ShellCrash 负责代理上网;本机 FFmpeg 处理推流或 HDMI;Neko(Docker Chromium)或本机 Chromium 负责浏览器侧;开发板通过 USB 双头线 ADB 控制 AOSP。各模块独立,可分别启停。

live.local · unknown · unknown
正在连接控制台...
\ No newline at end of file diff --git a/d2ypp2/web-console/out/console.txt b/d2ypp2/web-console/out/console.txt index f88ba85..1d926c6 100644 --- a/d2ypp2/web-console/out/console.txt +++ b/d2ypp2/web-console/out/console.txt @@ -1,7 +1,7 @@ 2:I[9107,[],"ClientPageRoot"] -3:I[6367,["500","static/chunks/500-ea37cc8de90441f7.js","246","static/chunks/246-d358b7b7982962c5.js","443","static/chunks/app/console/page-6adee1daed16edd2.js"],"default",1] +3:I[6367,["500","static/chunks/500-ea37cc8de90441f7.js","246","static/chunks/246-fafe28420dae1d39.js","443","static/chunks/app/console/page-75d851a154677dbc.js"],"default",1] 4:I[4707,[],""] 5:I[6423,[],""] -0:["7mXMtMWzc0X5_y9x4MIOK",[[["",{"children":["console",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["console",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","console","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/dc08974264f65939.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"zh-CN","data-theme":"dark","suppressHydrationWarning":true,"children":["$","body",null,{"className":"__variable_1e4310 __variable_c3aa02 font-sans antialiased","children":[["$","div",null,{"className":"app-bg-layer","aria-hidden":true}],["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]]}]}]],null],null],["$L6",null]]]] -6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Live Hub · 超级业务中心"}],["$","meta","3",{"name":"description","content":"ARM/x86 Linux 统一控制中心:服务、无人直播、安卓、网络与配置"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}]] +0:["DYGAc5F7lnL_yZfXw32eU",[[["",{"children":["console",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["console",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","console","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/dc08974264f65939.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"zh-CN","data-theme":"dark","suppressHydrationWarning":true,"children":["$","body",null,{"className":"__variable_1e4310 __variable_c3aa02 font-sans antialiased","children":[["$","div",null,{"className":"app-bg-layer","aria-hidden":true}],["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]]}]}]],null],null],["$L6",null]]]] +6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"无人直播系统 · live.local"}],["$","meta","3",{"name":"description","content":"Linux ARM/x86 一键部署的无人直播控制台:YouTube 转播、TikTok HDMI、SRS、Neko 与系统服务"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}],["$","meta","5",{"name":"next-size-adjust"}]] 1:null diff --git a/d2ypp2/web-console/out/index.html b/d2ypp2/web-console/out/index.html index 6b8f6a0..2fcdbc2 100644 --- a/d2ypp2/web-console/out/index.html +++ b/d2ypp2/web-console/out/index.html @@ -1 +1 @@ -Live Hub · 超级业务中心

总览

live.local · 夜间 · 自动刷新

直播工作室 · 3D

CPU 1m

负载均值

内存

/ —

磁盘可用

/ —

服务在线

基础服务层

运行时间

自开机

直播进程

  • 暂无直播进程
\ No newline at end of file +无人直播系统 · live.local

总览

live.local · 夜间 · 自动刷新

直播工作室 · 3D

CPU 1m

负载均值

内存

/ —

磁盘可用

/ —

服务在线

基础服务层

运行时间

自开机

直播进程

  • 暂无直播进程
\ No newline at end of file diff --git a/d2ypp2/web-console/out/index.txt b/d2ypp2/web-console/out/index.txt index 3b4715c..44377f0 100644 --- a/d2ypp2/web-console/out/index.txt +++ b/d2ypp2/web-console/out/index.txt @@ -1,7 +1,7 @@ 2:I[9107,[],"ClientPageRoot"] -3:I[1499,["500","static/chunks/500-ea37cc8de90441f7.js","719","static/chunks/719-4dbe9dca4cff8afd.js","246","static/chunks/246-d358b7b7982962c5.js","931","static/chunks/app/page-590190652d0e661c.js"],"default",1] +3:I[1499,["500","static/chunks/500-ea37cc8de90441f7.js","719","static/chunks/719-4dbe9dca4cff8afd.js","246","static/chunks/246-fafe28420dae1d39.js","931","static/chunks/app/page-c6e5e4273c433d42.js"],"default",1] 4:I[4707,[],""] 5:I[6423,[],""] -0:["7mXMtMWzc0X5_y9x4MIOK",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/dc08974264f65939.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"zh-CN","data-theme":"dark","suppressHydrationWarning":true,"children":["$","body",null,{"className":"__variable_1e4310 __variable_c3aa02 font-sans antialiased","children":[["$","div",null,{"className":"app-bg-layer","aria-hidden":true}],["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]]}]}]],null],null],["$L6",null]]]] -6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Live Hub · 超级业务中心"}],["$","meta","3",{"name":"description","content":"ARM/x86 Linux 统一控制中心:服务、无人直播、安卓、网络与配置"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}]] +0:["DYGAc5F7lnL_yZfXw32eU",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/dc08974264f65939.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"zh-CN","data-theme":"dark","suppressHydrationWarning":true,"children":["$","body",null,{"className":"__variable_1e4310 __variable_c3aa02 font-sans antialiased","children":[["$","div",null,{"className":"app-bg-layer","aria-hidden":true}],["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]]}]}]],null],null],["$L6",null]]]] +6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"无人直播系统 · live.local"}],["$","meta","3",{"name":"description","content":"Linux ARM/x86 一键部署的无人直播控制台:YouTube 转播、TikTok HDMI、SRS、Neko 与系统服务"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}],["$","meta","5",{"name":"next-size-adjust"}]] 1:null diff --git a/d2ypp2/web-console/out/shellcrash.html b/d2ypp2/web-console/out/shellcrash.html index dc68db8..dfd555f 100644 --- a/d2ypp2/web-console/out/shellcrash.html +++ b/d2ypp2/web-console/out/shellcrash.html @@ -1 +1 @@ -Live Hub · 超级业务中心
返回门户首页无人直播 · 本页需与 API 同域(推荐 live.local:8001)

控制平面 · ARM Linux 开发板

无人直播控制中心

ShellCrash 负责代理上网;本机 FFmpeg 处理推流或 HDMI;Neko(Docker Chromium)或本机 Chromium 负责浏览器侧;开发板通过 USB 双头线 ADB 控制 AOSP。各模块独立,可分别启停。

live.local · unknown · unknown
正在连接控制台...
\ No newline at end of file +无人直播系统 · live.local
返回门户首页无人直播 · 本页需与 API 同域(推荐 live.local:8001)

控制平面 · ARM Linux 开发板

无人直播控制中心

ShellCrash 负责代理上网;本机 FFmpeg 处理推流或 HDMI;Neko(Docker Chromium)或本机 Chromium 负责浏览器侧;开发板通过 USB 双头线 ADB 控制 AOSP。各模块独立,可分别启停。

live.local · unknown · unknown
正在连接控制台...
\ No newline at end of file diff --git a/d2ypp2/web-console/out/shellcrash.txt b/d2ypp2/web-console/out/shellcrash.txt index eebb0f6..42e2846 100644 --- a/d2ypp2/web-console/out/shellcrash.txt +++ b/d2ypp2/web-console/out/shellcrash.txt @@ -1,7 +1,7 @@ 2:I[9107,[],"ClientPageRoot"] -3:I[3398,["500","static/chunks/500-ea37cc8de90441f7.js","246","static/chunks/246-d358b7b7982962c5.js","6","static/chunks/app/shellcrash/page-2d5da0b032fa5ffa.js"],"default",1] +3:I[3398,["500","static/chunks/500-ea37cc8de90441f7.js","246","static/chunks/246-fafe28420dae1d39.js","6","static/chunks/app/shellcrash/page-3d8615ae919b9119.js"],"default",1] 4:I[4707,[],""] 5:I[6423,[],""] -0:["7mXMtMWzc0X5_y9x4MIOK",[[["",{"children":["shellcrash",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["shellcrash",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","shellcrash","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/dc08974264f65939.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"zh-CN","data-theme":"dark","suppressHydrationWarning":true,"children":["$","body",null,{"className":"__variable_1e4310 __variable_c3aa02 font-sans antialiased","children":[["$","div",null,{"className":"app-bg-layer","aria-hidden":true}],["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]]}]}]],null],null],["$L6",null]]]] -6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Live Hub · 超级业务中心"}],["$","meta","3",{"name":"description","content":"ARM/x86 Linux 统一控制中心:服务、无人直播、安卓、网络与配置"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}]] +0:["DYGAc5F7lnL_yZfXw32eU",[[["",{"children":["shellcrash",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["shellcrash",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","shellcrash","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/dc08974264f65939.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"zh-CN","data-theme":"dark","suppressHydrationWarning":true,"children":["$","body",null,{"className":"__variable_1e4310 __variable_c3aa02 font-sans antialiased","children":[["$","div",null,{"className":"app-bg-layer","aria-hidden":true}],["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]]}]}]],null],null],["$L6",null]]]] +6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"无人直播系统 · live.local"}],["$","meta","3",{"name":"description","content":"Linux ARM/x86 一键部署的无人直播控制台:YouTube 转播、TikTok HDMI、SRS、Neko 与系统服务"}],["$","link","4",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}],["$","meta","5",{"name":"next-size-adjust"}]] 1:null diff --git a/d2ypp2/web.py b/d2ypp2/web.py index 0432a16..a8d3166 100644 --- a/d2ypp2/web.py +++ b/d2ypp2/web.py @@ -4,8 +4,9 @@ import asyncio import json import os import re +import signal import sys -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, suppress from pathlib import Path from typing import Any, Optional @@ -58,7 +59,7 @@ from src.config_governance import ( from src.hub_kv_store import get_youtube_pro_channels, set_youtube_pro_channels from src.hub_routes import YoutubeProChannelsBody, configure_hub, router as hub_router from src.process_runtime_status import summarize_process_activity, tail_log_text -from src.process_runtime_status import coerce_declared_process_status +from src.process_runtime_status import coerce_declared_process_status, process_family from src.runtime_bootstrap import raise_nofile_limit from src.web_process_backend import WebProcessBackend, force_kill_ffmpeg, strip_ansi_codes from src.youtube_pro_runtime import ( @@ -66,6 +67,7 @@ from src.youtube_pro_runtime import ( ensure_youtube_pro_channel_runtime_files, managed_url_config_relpath_for_pm2, managed_youtube_ini_relpath_for_pm2, + validate_youtube_runtime_files, ) # ---------------- 配置(根目录结构) ---------------- @@ -217,9 +219,17 @@ async def _stop_other_hdmi_sink_processes(keep_pm2: str) -> list[str]: st = (info.get("process_status") or "").lower() if st not in ("online", "running"): continue + try: + root_pid = await process_backend.process_pid(str(name)) + except Exception: + root_pid = 0 + try: + root_pgid = os.getpgid(root_pid) if root_pid > 0 else None + except OSError: + root_pgid = None await process_backend.stop(name) if not str(name).startswith("web"): - await force_kill_ffmpeg(str(name), BASE_DIR) + await force_kill_ffmpeg(str(name), BASE_DIR, root_pid=root_pid, root_pgid=root_pgid) stopped.append(str(name)) if stopped: await asyncio.sleep(0.85) @@ -278,19 +288,6 @@ def process_monitor_entries() -> tuple[dict[str, str], ...]: extras = list(youtube_pro_process_entries()) merged = [dict(row) for row in STATIC_PROCESS_MONITOR] index = {str(row["pm2"]).strip(): pos for pos, row in enumerate(merged)} - for channel in get_youtube_pro_channels(): - if not isinstance(channel, dict): - continue - cid = str(channel.get("id", "")).strip() - if not cid: - continue - pm2 = channel_pm2_name(channel) - if not pm2 or pm2 not in index: - continue - if not is_youtube_script(str(merged[index[pm2]].get("script", ""))): - continue - name = str(channel.get("name", "")).strip() or cid - merged[index[pm2]]["label"] = _youtube_pro_label(name) for row in extras: pm2 = str(row.get("pm2", "")).strip() if not pm2: @@ -305,6 +302,23 @@ def _sync_youtube_pro_runtime_files(process: str) -> list[str]: return errors +def _validate_youtube_runtime_files(process: str) -> list[str]: + return validate_youtube_runtime_files(BASE_DIR, CONFIG_DIR, process, get_youtube_pro_channels()) + + +async def _process_runtime_identity(process: str) -> tuple[int, int | None]: + try: + pid = await process_backend.process_pid(process) + except Exception: + pid = 0 + if pid <= 0: + return 0, None + try: + return pid, os.getpgid(pid) + except OSError: + return pid, None + + def _pm2_safe_segment(pm2: str) -> str: return re.sub(r"[^a-zA-Z0-9_.-]", "_", pm2) @@ -586,19 +600,19 @@ def _newest_log_age_seconds(*paths: Optional[str]) -> float | None: async def read_log_path_normalized(path: Optional[str], lines: int) -> str: if not path: - return "鏃犳棩蹇楄矾寰刓n" + return "无日志路径\n" norm = path.replace("\\", "/").lower() if "/dev/null" in norm or norm.endswith("/nul") or norm == "nul": - return "鏃ュ織杈撳嚭琚鐢紙PM2 閰嶇疆涓烘棤鏃ュ織锛塡n" + return "日志输出被禁用(PM2 配置为无日志)\n" log_file = Path(path) if not log_file.exists(): - return f"鏃ュ織鏂囦欢涓嶅瓨鍦? {path}\n" + return f"日志文件不存在: {path}\n" try: text = await asyncio.to_thread(_read_log_tail_bytes, log_file) tailed = tail_log_text(text, lines) - return tailed if tailed else "鏃犳棩蹇楀唴瀹筡n" + return tailed if tailed else "无日志内容\n" except OSError as e: - return f"璇诲彇鏃ュ織澶辫触 ({path}): {str(e)}\n" + return f"读取日志失败 ({path}): {str(e)}\n" async def get_process_info(process: str) -> dict: @@ -1224,16 +1238,40 @@ def _coerce_local_configured_process(process: str, info: dict) -> dict: async def _process_monitor_snapshot(entry: dict[str, str]) -> dict[str, Any]: name = str(entry.get("pm2") or "").strip() info = await get_process_info(name) + return await _process_monitor_snapshot_from_info(entry, info) + + +async def _process_monitor_snapshot_from_info( + entry: dict[str, str], + info: dict[str, Any], + *, + light: bool = False, +) -> dict[str, Any]: + name = str(entry.get("pm2") or "").strip() info = _coerce_local_configured_process(name, info) - recent_log = await read_log_path_normalized(info.get("out_path"), 40) - recent_error = await read_log_path_normalized(info.get("err_path"), 40) + status = str(info.get("process_status") or "").lower() + online = status in {"online", "running", "launching"} + read_logs = online or not light + log_lines = 12 if light else 40 + error_lines = 8 if light else 40 + + if read_logs: + recent_log, recent_error = await asyncio.gather( + read_log_path_normalized(info.get("out_path"), log_lines), + read_log_path_normalized(info.get("err_path"), error_lines), + ) + else: + recent_log = "" + recent_error = "" + + ffmpeg_pids = await process_backend.live_ffmpeg_pids(name, info) if online else [] 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 process_backend.live_ffmpeg_pids(name), + ffmpeg_pids=ffmpeg_pids, ) return { "pm2": name, @@ -1247,6 +1285,16 @@ async def _process_monitor_snapshot(entry: dict[str, str]) -> dict[str, Any]: } +def _process_monitor_family_matches(entry: dict[str, str], family: str | None) -> bool: + target = (family or "").strip().lower() + if not target or target == "all": + return True + script = str(entry.get("script") or "").strip() + if script: + return process_family(Path(script).stem) == target + return process_family(str(entry.get("pm2") or "").strip()) == target + + # ---------------- 进程控制 ---------------- @app.get("/start") async def start(process: str = Query(..., description="进程名")): @@ -1255,7 +1303,7 @@ async def start(process: str = Query(..., description="进程名")): sc = script_for_pm2_or_pro(process) if not sc: return JSONResponse({"output": "内部错误:找不到脚本映射"}, status_code=500) - sync_errors = _sync_youtube_pro_runtime_files(process) + sync_errors = _validate_youtube_runtime_files(process) if sync_errors: return JSONResponse({"output": "; ".join(sync_errors)}, status_code=400) stopped = await _stop_other_hdmi_sink_processes(process) @@ -1276,14 +1324,22 @@ async def stop(process: str = Query(..., description="进程名")): if not is_valid_control_process(process): return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400) + root_pid, root_pgid = await _process_runtime_identity(process) pm2_output = await process_backend.stop(process) - await asyncio.sleep(2.0) + await asyncio.sleep(0.25) if not process.startswith("web"): - await force_kill_ffmpeg(process, BASE_DIR) + await force_kill_ffmpeg(process, BASE_DIR, root_pid=root_pid, root_pgid=root_pgid) - await asyncio.sleep(1.0) + if root_pid > 0: + with suppress(ProcessLookupError, PermissionError, OSError): + os.kill(root_pid, signal.SIGTERM) + await asyncio.sleep(0.15) + post_info = await get_process_info(process) + if str(post_info.get("process_status") or "").lower() in {"online", "running"}: + with suppress(ProcessLookupError, PermissionError, OSError): + os.kill(root_pid, signal.SIGKILL) return JSONResponse( { @@ -1306,13 +1362,14 @@ async def restart(process: str = Query(..., description="进程名")): sc = script_for_pm2_or_pro(process) if not sc: return JSONResponse({"output": "内部错误:找不到脚本映射"}, status_code=500) - sync_errors = _sync_youtube_pro_runtime_files(process) + sync_errors = _validate_youtube_runtime_files(process) if sync_errors: return JSONResponse({"output": "; ".join(sync_errors)}, status_code=400) stopped = await _stop_other_hdmi_sink_processes(process) - output = await process_backend.restart(process, sc) + root_pid, root_pgid = await _process_runtime_identity(process) if not process.startswith("web"): - await force_kill_ffmpeg(process, BASE_DIR) + await force_kill_ffmpeg(process, BASE_DIR, root_pid=root_pid, root_pgid=root_pgid) + output = await process_backend.restart(process, sc) prefix = "" if stopped: prefix = "已停止其它占用 HDMI 链路的进程: " + "、".join(stopped) + "\n" @@ -1373,29 +1430,44 @@ async def status(process: str = Query(..., description="进程名")): @app.get("/process_monitor") -async def process_monitor(): +async def process_monitor( + family: str | None = Query(None, description="按进程族过滤:youtube/tiktok/obs/generic"), + light: bool = Query(False, description="轻量模式:减少日志读取,仅返回监控所需状态"), +): await process_backend.ensure_mode() - entries = [dict(p) for p in process_monitor_entries()] + family_value = family if isinstance(family, str) else None + light_value = bool(light) if isinstance(light, bool) else False + entries = [dict(p) for p in process_monitor_entries() if _process_monitor_family_matches(p, family_value)] + names = [str(entry.get("pm2") or "").strip() for entry in entries if str(entry.get("pm2") or "").strip()] + infos = await process_backend.process_infos(names) + + async def build_snapshot(entry: dict[str, str]) -> dict[str, Any]: + name = str(entry.get("pm2") or "").strip() + return await _process_monitor_snapshot_from_info(entry, infos.get(name) or {}, light=light_value) + + results = await asyncio.gather(*(build_snapshot(entry) for entry in entries), return_exceptions=True) snapshots: list[dict[str, Any]] = [] - for entry in entries: - try: - snapshots.append(await _process_monitor_snapshot(entry)) - except Exception: - snapshots.append( - { - "pm2": entry["pm2"], - "script": entry["script"], - "label": entry["label"], - "process_status": "error", - "business_status": "error", - "business_note": "Failed to inspect process state", - "ffmpeg_active": False, - "is_pushing": False, - } - ) + for entry, result in zip(entries, results): + if not isinstance(result, Exception): + snapshots.append(result) + continue + snapshots.append( + { + "pm2": entry["pm2"], + "script": entry["script"], + "label": entry["label"], + "process_status": "error", + "business_status": "error", + "business_note": "Failed to inspect process state", + "ffmpeg_active": False, + "is_pushing": False, + } + ) return { "entries": snapshots, "process_backend": process_backend.mode, + "family": (family_value or "all").strip().lower() or "all", + "light": light_value, } diff --git a/d2ypp2/web.sh b/d2ypp2/web.sh old mode 100644 new mode 100755 diff --git a/d2ypp2/youtube.py b/d2ypp2/youtube.py index 4c6ea7c..82d59fb 100644 --- a/d2ypp2/youtube.py +++ b/d2ypp2/youtube.py @@ -40,6 +40,7 @@ from src.async_thread_loop import run_async_coro from src import spider, stream from src.proxy import ProxyDetector from src.runtime_bootstrap import load_msg_push_exports +from src.youtube_config_paths import resolve_youtube_style_config_paths from src.youtube_runtime import normalize_youtube_video_save_type from src.utils import logger from src import utils @@ -88,22 +89,8 @@ def _live_pm2_name() -> str: def _youtube_pro_style_config_paths() -> tuple[str, str]: - """每 PM2 进程独立 youtube.* / URL_config.*(与 web.py managed_*_relpath 一致);无 PM2 名时回落旧版共享文件。""" - legacy_yt = f"{script_path}/config/youtube.ini" - legacy_url = f"{script_path}/config/URL_config.ini" - pm2 = _live_pm2_name() - if pm2: - low = pm2.lower() - if low.startswith("youtube") or low.startswith("douyin_youtube"): - safe = re.sub(r"[^a-zA-Z0-9_.-]", "_", pm2) - yp = f"{script_path}/config/youtube.{safe}.ini" - up = f"{script_path}/config/URL_config.{safe}.ini" - if os.path.isfile(yp) or os.path.isfile(up): - return yp, up - if os.path.isfile(legacy_yt) or os.path.isfile(legacy_url): - return legacy_yt, legacy_url - return yp, up - return legacy_yt, legacy_url + """按文件粒度回退,避免只生成一半托管文件时把 legacy 配置绕掉。""" + return resolve_youtube_style_config_paths(script_path, _live_pm2_name()) config_file, url_config_file = _youtube_pro_style_config_paths()