diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index f77ecdc..342f02d 100644 --- a/.gitignore +++ b/.gitignore @@ -177,3 +177,6 @@ web-console/.next/ # 本机端口/主机覆盖(从 config/runtime.env.example 复制) config/runtime.env +config/system-stack.env +config/hardware.env +config/hardware-profile.json diff --git a/README.md b/README.md index 8e0a31b..1fd9423 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,47 @@ ![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 + +This fork now includes a modular Linux deployment layout for ARM and X86 nodes. + +- `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 +- `install-all.sh`: installs both layers without tightly coupling them + +Detailed docs: + +- [`docs/install-profiles.md`](docs/install-profiles.md) +- [`docs/super-hub-architecture.md`](docs/super-hub-architecture.md) + +### Default LAN Entrypoints + +After `install-hub.sh` or `install-all.sh`, the default service map is: + +- `http://live.local:8001` for the main Chinese-first control plane +- `http://live.local:8001/shellcrash` for the dedicated ShellCrash web editor +- `http://live.local:8001/android` for the Android device center +- `http://live.local:7681` for WebTTY +- `http://live.local:8082` for File Browser with `live / 12345678` +- `http://live.local:19999` for Netdata + +### ShellCrash Web Control + +ShellCrash is no longer treated as a jump back to the terminal menu. The control plane now exposes: + +- direct service start, stop, and restart +- managed roots for `cfg`, `yaml`, `json`, `list`, and `env` files +- in-browser create, edit, delete, and import-or-replace flows for YAML and related config files + +### Android Device Center + +The Android panel now combines multiple layers instead of showing only raw `web-scrcpy`: + +- ADB device discovery and overview +- screenshot-based preview with tap, double tap, and long press +- package search and app launch / stop / clear actions +- UI node extraction with quick jump actions +- embedded raw `web-scrcpy` for full interactive fallback + ## 💡简介 [![Python Version](https://img.shields.io/badge/python-3.11.6-blue.svg)](https://www.python.org/downloads/release/python-3116/) [![Supported Platforms](https://img.shields.io/badge/platforms-Windows%20%7C%20Linux-blue.svg)](https://github.com/ihmily/DouyinLiveRecorder) diff --git a/codex-live-stack.tgz b/codex-live-stack.tgz new file mode 100644 index 0000000..c4775d0 Binary files /dev/null and b/codex-live-stack.tgz differ diff --git a/config/system-stack.env.example b/config/system-stack.env.example new file mode 100644 index 0000000..cf808c0 --- /dev/null +++ b/config/system-stack.env.example @@ -0,0 +1,81 @@ +# Copy to config/system-stack.env before first Linux deployment when you need custom values. +# Profile model: +# - install-hub.sh: base Linux super hub, web admin, ShellCrash, monitoring, beginner tools +# - install-business.sh: Douyin / YouTube / HDMI business module only +# - install-all.sh: hub first, then business module + +# LAN identity +HOSTNAME_ALIAS=live + +# Shared control plane +PORT=8001 + +# Hub web services +WEBTTY_PORT=7681 +HOMEPAGE_PORT=80 +FILEBROWSER_PORT=8082 +NETDATA_PORT=19999 + +# Business streaming services +SRS_HTTP_PORT=8080 +SRS_API_PORT=1985 +SRS_RTMP_PORT=1935 +SRS_SRT_PORT=10080 +SRS_WEBRTC_PORT=8088 +SRS_WEBRTC_UDP_PORT=8000 + +# ShellCrash +SHELLCRASH_DIR=/etc/ShellCrash + +# Browser / Android lab +BROWSER_PROFILE_DIR=/var/lib/live-browser/profile +BROWSER_START_URL=https://www.tiktok.com/live +BROWSER_REMOTE_DEBUG_PORT=9222 +BROWSER_LOCALE=zh-CN + +# WebTTY credentials +WEBTTY_USER=live +WEBTTY_PASS=12345678 + +# File Browser credentials +FILEBROWSER_USER=live +FILEBROWSER_PASS=12345678 + +# Wi-Fi onboarding +WIFI_CONNECTION_NAME=live-wifi +WIFI_SSID=live +WIFI_PSK=12345678 +DISABLE_IPV6=1 + +# Android gateway mode +ANDROID_GATEWAY_NAME=live-android-gateway +ANDROID_UPLINK_IF=eth0 +ANDROID_DOWNLINK_IF= +ANDROID_GATEWAY_CIDR=192.168.51.1/24 +ANDROID_PANEL_PORT=5000 +ANDROID_PANEL_DIR=/opt/live-modules/web-scrcpy +ANDROID_PANEL_VIDEO_BIT_RATE=1024000 + +# Optional modules +# Shared +ENABLE_SHELLCRASH=1 +ENABLE_WEBTTY=1 +ENABLE_BROWSER=1 +ENABLE_MDNS=1 +ENABLE_WIFI_PROFILE=1 +ENABLE_ANDROID_PANEL=1 +# Hub profile defaults +ENABLE_COCKPIT=1 +ENABLE_FILEBROWSER=1 +ENABLE_HOMEPAGE=1 +ENABLE_NETDATA=1 +# Business profile defaults +ENABLE_SRS=1 + +# Future modules (recommended next wave, not yet wired into the core installer) +# ENABLE_CADDY=0 +# ENABLE_WG_EASY=0 +# ENABLE_FRP=0 +# ENABLE_MATRIX=0 +# ENABLE_UPTIME_KUMA=0 +# ENABLE_N8N=0 diff --git a/dev.sh b/dev.sh index 2d76c98..202b578 100644 --- a/dev.sh +++ b/dev.sh @@ -3,6 +3,10 @@ set -euo pipefail cd "$(dirname "$0")" export PORT="${PORT:-8001}" -PY=python3 -command -v python3 >/dev/null 2>&1 || PY=python +if [[ -x ".venv/bin/python" ]]; then + PY=".venv/bin/python" +else + PY=python3 + command -v python3 >/dev/null 2>&1 || PY=python +fi exec "$PY" scripts/launch.py --dev --port "$PORT" diff --git a/docs/install-profiles.md b/docs/install-profiles.md new file mode 100644 index 0000000..54cdf6d --- /dev/null +++ b/docs/install-profiles.md @@ -0,0 +1,77 @@ +# Linux Install Profiles + +## Quick Start + +Use one of these entrypoints from the repo root on Debian, Ubuntu, or Armbian: + +```bash +sudo bash install-hub.sh +sudo bash install-business.sh +sudo bash install-all.sh +``` + +## What Each Script Does + +### `install-hub.sh` + +Use this when the Linux device should become the reusable super hub first. + +- Creates and refreshes the `live` account +- Installs the shared control plane runtime +- Enables `live.local` +- Creates the default Wi-Fi profile `live / 12345678` when `wlan0` exists +- Installs ShellCrash, WebTTY, Cockpit, File Browser, Homepage, and Netdata +- Installs the Android web panel based on `web-scrcpy` +- Builds the web console and enables `live-console.service` + +### `install-business.sh` + +Use this when the device should only run the Douyin or YouTube business stack. + +- Creates and refreshes the `live` account +- Installs the shared control plane runtime +- Installs ffmpeg, mpv, ADB, Chromium +- Installs the Android web panel based on `web-scrcpy` +- Starts SRS +- Builds the web console and enables `live-console.service` +- Generates the hardware profile for adaptive ARM or X86 defaults + +### `install-all.sh` + +Use this when the node should be both the super hub and the livestream business node. + +- Runs `install-hub.sh` +- Runs `install-business.sh` +- Reuses shared modules instead of replacing them + +## Shared Config + +All install profiles read the same shared file: + +- `config/system-stack.env` + +If the file does not exist, the installer copies: + +- `config/system-stack.env.example` + +This keeps host identity, ports, Wi-Fi, ShellCrash path, and module toggles centralized. + +## Default Access URLs + +With the default hostname and ports, a fresh hub exposes: + +- `http://live.local:8001` main control plane +- `http://live.local:8001/shellcrash` ShellCrash editor route +- `http://live.local:8001/android` Android device center route +- `http://live.local:7681` WebTTY +- `http://live.local:8082` File Browser, default login `live / 12345678` +- `http://live.local:19999` Netdata + +The ShellCrash and Android pages are separate routes on purpose, so they can be bookmarked directly instead of sharing the same tabbed root URL. + +## Separation Rules + +- The hub profile must keep working without SRS. +- The business profile must keep working without Cockpit, Homepage, File Browser, or Netdata. +- The control plane should show degraded modules as unavailable instead of crashing. +- Every service must be removable by systemd or compose boundaries. diff --git a/docs/super-hub-architecture.md b/docs/super-hub-architecture.md new file mode 100644 index 0000000..d0317a1 --- /dev/null +++ b/docs/super-hub-architecture.md @@ -0,0 +1,103 @@ +# Super Hub Architecture + +## Goal + +Build the ARM or X86 node as a reusable Linux super hub instead of a single-purpose +streaming box. The Douyin or YouTube workflow is one optional business module that can +be installed alone or layered on top of the hub later. + +## Install Profiles + +- `install-hub.sh` + - Base Linux super hub + - Web control plane + - mDNS `live.local` + - Wi-Fi onboarding profile + - ShellCrash + - WebTTY + - Cockpit + - File Browser + - Homepage + - Netdata + - Hardware probe + +- `install-business.sh` + - Shared control plane runtime + - ffmpeg, mpv, ADB, Chromium + - SRS + - Hardware probe and adaptive decode defaults + - Douyin or YouTube business orchestration + +- `install-all.sh` + - Runs the hub profile first + - Runs the business profile second + - Safe to rerun because the shared pieces are idempotent + +## Layers + +1. Base Node + - Debian / Ubuntu / Armbian + - `live` user + - mDNS with `live.local` + - Wi-Fi bootstrap + - hardware probe and graceful degradation + +2. Control Plane + - FastAPI backend + - Next.js panel + - managed config roots + - service registry and status polling + +3. Hub Modules + - ShellCrash + - WebTTY + - Cockpit + - File Browser + - Homepage + - Netdata + - Android Web Panel + +4. Business Modules + - Douyin / YouTube relay app + - SRS + - HDMI playback bridge + - Android / ADB integration + - Chromium persistent profile + - Browser-based device control via web-scrcpy + +5. Future Modules + - Caddy + - WireGuard + - FRP + - Matrix + - Uptime Kuma + - n8n + - AI and media workloads + +## Design Rules + +- `install-hub.sh` must never hard-depend on business services such as SRS. +- `install-business.sh` must work on a clean Linux system without requiring the hub profile first. +- If the hub profile already exists, the business profile must reuse it instead of replacing it. +- Each service stays in its own systemd unit or compose file so it can be removed without breaking unrelated modules. +- The control plane edits only managed roots and never exposes arbitrary filesystem write access. +- ARM and X86 share one control plane, then diverge only through the generated hardware profile. + +## Current Implementation + +- `scripts/linux/lib/common.sh` contains the shared install functions used by every profile. +- `scripts/linux/install_hub.sh` installs the reusable Linux super hub. +- `scripts/linux/install_business.sh` installs the Douyin or YouTube business module. +- `scripts/linux/install_stack.sh` remains as a compatibility entrypoint for `hub`, `business`, and `all`. +- `src/control_plane.py` exposes managed roots, hardware profile loading, and service actions. +- `scripts/hardware_probe.py` generates safe runtime defaults for ARM and X86. +- `services/` keeps dockerized modules isolated instead of merging them into one monolith. + +## Recommended Next Modules + +- Caddy for unified reverse proxy and TLS +- wg-easy for WireGuard mesh management +- FRP panel for tunnel management +- Uptime Kuma for alerting +- n8n for workflow automation +- Matrix plus Element for messaging and operations collaboration diff --git a/ffmpeg_install.py b/ffmpeg_install.py index eddec4b..7bc3a16 100644 --- a/ffmpeg_install.py +++ b/ffmpeg_install.py @@ -6,6 +6,8 @@ GitHub: https://github.com/ihmily Copyright (c) 2024 by Hmily, All Rights Reserved. """ +from __future__ import annotations + import os import re import subprocess diff --git a/install-all.sh b/install-all.sh new file mode 100644 index 0000000..8ebe911 --- /dev/null +++ b/install-all.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" +exec bash "$ROOT_DIR/scripts/linux/install_stack.sh" all "$@" diff --git a/install-business.sh b/install-business.sh new file mode 100644 index 0000000..7bde944 --- /dev/null +++ b/install-business.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" +exec bash "$ROOT_DIR/scripts/linux/install_business.sh" "$@" diff --git a/install-hub.sh b/install-hub.sh new file mode 100644 index 0000000..5031550 --- /dev/null +++ b/install-hub.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" +exec bash "$ROOT_DIR/scripts/linux/install_hub.sh" "$@" diff --git a/main.py b/main.py index 6f2da1f..b7cd0be 100644 --- a/main.py +++ b/main.py @@ -8,6 +8,9 @@ Update: 2025-10-23 19:48:05 Copyright (c) 2023-2025 by Hmily, All Rights Reserved. Function: Record live stream video. """ + +from __future__ import annotations + import asyncio import os import sys @@ -2201,4 +2204,4 @@ while True: t2.start() first_run = False - time.sleep(3) \ No newline at end of file + time.sleep(3) diff --git a/obs.sh b/obs.sh index 5ddc203..7d5e31c 100644 --- a/obs.sh +++ b/obs.sh @@ -1,30 +1,53 @@ #!/usr/bin/env bash -# 从本机 SRS 拉 SRT 流并用 ffplay 全屏输出到 HDMI(需已登录图形会话:X11 或 Wayland 下 XWayland) -# 注意:下方端口为 SRS 的 SRT 端口(常见 10080),与 Web 控制台端口(见 config/runtime.env)无关。 set -euo pipefail -# SRS SRT 播放地址(与 srs 里 app/stream 名一致时可只改此项) +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" +[ -f "$ROOT_DIR/config/system-stack.env" ] && set -a && . "$ROOT_DIR/config/system-stack.env" && set +a +[ -f "$ROOT_DIR/config/hardware.env" ] && set -a && . "$ROOT_DIR/config/hardware.env" && set +a + : "${SRS_SRT_URL:=srt://127.0.0.1:10080?streamid=#!::r=live/livestream,m=request&latency=10}" -# X11:常见为 :0(开发板/工控机本地桌面) -if [[ -z "${DISPLAY:-}" ]]; then +try_mpv_drm() { + command -v mpv >/dev/null 2>&1 || return 1 + [[ -e /dev/dri/card0 ]] || return 1 + exec mpv \ + --no-config \ + --profile=low-latency \ + --fullscreen \ + --osc=no \ + --osd-level=0 \ + --no-audio \ + --untimed \ + --cache=no \ + --vo=gpu \ + --gpu-context=drm \ + --hwdec=auto-safe \ + "$SRS_SRT_URL" +} + +try_ffplay_gui() { + command -v ffplay >/dev/null 2>&1 || return 1 export DISPLAY="${DISPLAY:-:0}" -fi + if [[ -n "${FFPLAY_SDL_DRIVER:-}" ]]; then + export SDL_VIDEODRIVER="${FFPLAY_SDL_DRIVER}" + fi + exec ffplay -fs -nostats -hide_banner \ + -fflags nobuffer -flags low_delay -framedrop \ + -probesize 32 -analyzeduration 0 -sync ext -infbuf \ + -vf "setpts=PTS-STARTPTS" \ + "$SRS_SRT_URL" +} -# 部分 ARM 板卡仅支持 OpenGL ES,可尝试:export FFPLAY_SDL_DRIVER=opengles2 -if [[ -n "${FFPLAY_SDL_DRIVER:-}" ]]; then - export SDL_VIDEODRIVER="${FFPLAY_SDL_DRIVER}" -fi +case "${LIVE_HDMI_PLAYER:-auto}" in + mpv-drm) + try_mpv_drm + ;; + ffplay-x11) + try_ffplay_gui + ;; +esac -# 纯 DRM/KMS 无 X 时不能用本脚本;请改用 ffmpeg 直送 drm 或 kmsgrab(见 README 说明) -if ! command -v ffplay >/dev/null 2>&1; then - echo "ffplay 未找到,请安装 ffmpeg(含 ffplay)。" >&2 - exit 1 -fi +try_mpv_drm || try_ffplay_gui -# 需要额外参数时可在本行自行追加,例如:-window_title hdmi -exec ffplay -nostats -hide_banner \ - -fflags nobuffer -flags low_delay -framedrop \ - -probesize 32 -analyzeduration 0 -sync ext -infbuf \ - -vf "setpts=PTS-STARTPTS" \ - "$SRS_SRT_URL" +echo "No compatible HDMI playback stack found. Install mpv for DRM/KMS or ffplay + X11." >&2 +exit 1 diff --git a/scripts/hardware_probe.py b/scripts/hardware_probe.py new file mode 100644 index 0000000..785577e --- /dev/null +++ b/scripts/hardware_probe.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import platform +import shutil +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CONFIG_DIR = ROOT / "config" +JSON_PATH = CONFIG_DIR / "hardware-profile.json" +ENV_PATH = CONFIG_DIR / "hardware.env" + + +def run_text(cmd: list[str]) -> str: + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="ignore", + timeout=8, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return "" + return (proc.stdout or proc.stderr or "").strip() + + +def detect_ffmpeg_hwaccels() -> list[str]: + if not shutil.which("ffmpeg"): + return [] + text = run_text(["ffmpeg", "-hide_banner", "-hwaccels"]) + accels: list[str] = [] + for line in text.splitlines(): + item = line.strip() + if not item or item.lower().startswith("hardware acceleration methods"): + continue + if item not in accels: + accels.append(item) + return accels + + +def detect_gpu() -> dict: + dri_dir = Path("/dev/dri") + render_nodes = sorted(str(path) for path in dri_dir.glob("renderD*")) if dri_dir.is_dir() else [] + cards = sorted(str(path) for path in dri_dir.glob("card*")) if dri_dir.is_dir() else [] + driver = "" + if cards: + card0 = Path(cards[0]) + driver_link = Path(f"/sys/class/drm/{card0.name}/device/driver") + if driver_link.exists(): + try: + driver = driver_link.resolve().name + except OSError: + driver = driver_link.name + return { + "present": bool(cards or render_nodes), + "cards": cards, + "render_nodes": render_nodes, + "driver": driver, + "vainfo": bool(shutil.which("vainfo")), + } + + +def detect_npu() -> dict: + hints = [] + for path in ( + Path("/dev/rknpu"), + Path("/dev/npu"), + Path("/dev/galcore"), + Path("/sys/class/misc/rknpu"), + Path("/sys/class/misc/verisilicon-npu"), + ): + if path.exists(): + hints.append(str(path)) + return { + "present": bool(hints), + "devices": hints, + } + + +def detect_kernel_features() -> dict: + features = { + "drm": Path("/dev/dri/card0").exists(), + "render_node": any(Path("/dev/dri").glob("renderD*")) if Path("/dev/dri").is_dir() else False, + "v4l2loopback": Path("/sys/module/v4l2loopback").exists(), + "kvm": Path("/dev/kvm").exists(), + "nvidia": shutil.which("nvidia-smi") is not None, + } + return features + + +def recommend(profile: dict) -> dict: + hwaccels = set(profile["ffmpeg_hwaccels"]) + gpu = profile["gpu"] + browser_hw = "auto" if gpu["present"] else "disabled" + decoder = "cpu" + if "vaapi" in hwaccels and gpu["render_nodes"]: + decoder = "vaapi" + elif "v4l2m2m" in hwaccels: + decoder = "v4l2m2m" + elif "cuda" in hwaccels or "nvdec" in hwaccels: + decoder = "cuda" + + hdmi_player = "cpu" + if shutil.which("mpv") and gpu["cards"]: + hdmi_player = "mpv-drm" + elif shutil.which("ffplay") and (os.environ.get("DISPLAY") or shutil.which("startx")): + hdmi_player = "ffplay-x11" + + risk = [] + if not gpu["present"]: + risk.append("No DRM/VA render node detected, browser and HDMI playback will stay on conservative settings") + if decoder == "cpu": + risk.append("No safe hardware decoder detected, ffmpeg workloads will remain on CPU") + if hdmi_player == "cpu": + risk.append("No direct HDMI playback stack detected, install mpv or X11+ffplay for full-screen output") + + return { + "video_decoder": decoder, + "browser_hwaccel": browser_hw, + "hdmi_player": hdmi_player, + "stability_mode": "safe" if risk else "balanced", + "degradation_reasons": risk, + } + + +def build_profile() -> dict: + profile = { + "status": "ok", + "arch": platform.machine(), + "kernel": platform.release(), + "platform": platform.platform(), + "ffmpeg_hwaccels": detect_ffmpeg_hwaccels(), + "gpu": detect_gpu(), + "npu": detect_npu(), + "kernel_features": detect_kernel_features(), + } + profile["recommendation"] = recommend(profile) + return profile + + +def write_outputs(profile: dict) -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + JSON_PATH.write_text(json.dumps(profile, indent=2, ensure_ascii=False), encoding="utf-8") + env_lines = [ + f"LIVE_HW_DECODER={profile['recommendation']['video_decoder']}", + f"LIVE_HDMI_PLAYER={profile['recommendation']['hdmi_player']}", + f"LIVE_BROWSER_HWACCEL={profile['recommendation']['browser_hwaccel']}", + f"LIVE_STABILITY_MODE={profile['recommendation']['stability_mode']}", + ] + ENV_PATH.write_text("\n".join(env_lines) + "\n", encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Probe Linux hardware capabilities for safe runtime defaults") + parser.add_argument("--write", action="store_true", help="Write JSON and env files into config/") + args = parser.parse_args() + + profile = build_profile() + if args.write: + write_outputs(profile) + print(json.dumps(profile, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/scripts/linux/install_business.sh b/scripts/linux/install_business.sh new file mode 100644 index 0000000..af97be6 --- /dev/null +++ b/scripts/linux/install_business.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +SELF_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_TAG="business" +. "$SELF_DIR/lib/common.sh" + +ensure_root "$@" +load_env + +# Keep the business profile focused on the livestream stack. +ENABLE_HOMEPAGE=0 +ENABLE_FILEBROWSER=0 +ENABLE_COCKPIT=0 +ENABLE_NETDATA=0 + +ensure_live_user +install_base_packages +install_media_packages +configure_adb_udev +install_nodejs +install_docker_stack +install_android_panel +prepare_python_runtime +prepare_web_console +install_live_console_service +configure_sudoers +configure_mdns +install_browser_stack +prepare_srs +run_hardware_probe +print_summary "business" diff --git a/scripts/linux/install_hub.sh b/scripts/linux/install_hub.sh new file mode 100644 index 0000000..2649f5f --- /dev/null +++ b/scripts/linux/install_hub.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +SELF_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_TAG="hub" +. "$SELF_DIR/lib/common.sh" + +ensure_root "$@" +load_env + +# Keep the hub profile independent from the livestream business stack. +ENABLE_SRS=0 + +ensure_live_user +install_base_packages +install_media_packages +configure_adb_udev +install_nodejs +install_docker_stack +install_android_panel +prepare_python_runtime +prepare_web_console +install_live_console_service +configure_sudoers +configure_mdns +configure_wifi_profile +install_shellcrash +install_browser_stack +install_webtty +install_cockpit +install_netdata +prepare_homepage +prepare_filebrowser +run_hardware_probe +print_summary "hub" diff --git a/scripts/linux/install_stack.sh b/scripts/linux/install_stack.sh new file mode 100644 index 0000000..923d846 --- /dev/null +++ b/scripts/linux/install_stack.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +PROFILE="${1:-all}" + +case "$PROFILE" in + hub|core|sh) + exec bash "$ROOT_DIR/scripts/linux/install_hub.sh" "${@:2}" + ;; + business|app|sh2) + exec bash "$ROOT_DIR/scripts/linux/install_business.sh" "${@:2}" + ;; + all|full) + bash "$ROOT_DIR/scripts/linux/install_hub.sh" "${@:2}" + bash "$ROOT_DIR/scripts/linux/install_business.sh" "${@:2}" + ;; + *) + cat </dev/null 2>&1 +} + +filtered_pgrep() { + local pattern="$1" + pgrep -fa "$pattern" 2>/dev/null | grep -v -F "$0" | grep -v -F "pgrep -fa" || true +} + +unit_exists() { + systemctl list-unit-files "$1" --no-legend 2>/dev/null | grep -q "^$1" +} + +unit_active() { + systemctl is-active --quiet "$1" +} + +compose_bin() { + if have docker && docker compose version >/dev/null 2>&1; then + echo "docker compose" + return 0 + fi + if have docker-compose; then + echo "docker-compose" + return 0 + fi + return 1 +} + +compose_run() { + local compose_file="$1" + shift + local cmd + cmd="$(compose_bin)" || return 127 + if [ "$cmd" = "docker compose" ]; then + docker compose -f "$compose_file" "$@" + else + docker-compose -f "$compose_file" "$@" + fi +} + +docker_container_status() { + local name="$1" + if ! have docker; then + return 1 + fi + docker ps -a --filter "name=^/${name}$" --format '{{.State}}|{{.Status}}' | head -n 1 +} + +service_mdns_status() { + local detail hostname ipv6 + hostname="$(hostname)" + ipv6="unknown" + if grep -Eq '^[[:space:]]*use-ipv6=no' /etc/avahi/avahi-daemon.conf 2>/dev/null; then + ipv6="disabled" + fi + detail="hostname=${hostname}; avahi_ipv6=${ipv6}" + kv available 1 + kv running "$(unit_active avahi-daemon && echo 1 || echo 0)" + kv status "$(unit_active avahi-daemon && echo online || echo stopped)" + kv host "${HOSTNAME_ALIAS}.local" + kv port 22 + kv url "ssh ${WEBTTY_USER}@${HOSTNAME_ALIAS}.local" + kv detail "$detail" +} + +service_mdns_start() { + hostnamectl set-hostname "$HOSTNAME_ALIAS" + systemctl restart avahi-daemon + kv message "mDNS hostname refreshed" +} + +service_mdns_stop() { + systemctl stop avahi-daemon + kv message "avahi-daemon stopped" +} + +service_wifi_status() { + if ! have nmcli || [ ! -d /sys/class/net/wlan0 ]; then + kv available 0 + kv running 0 + kv status unavailable + kv detail "NetworkManager or wlan0 is not available" + return 0 + fi + local active detail + active="$(nmcli -t -f NAME,DEVICE connection show --active 2>/dev/null | grep "^${WIFI_CONNECTION_NAME}:" || true)" + detail="$(nmcli -t -f NAME,UUID,TYPE,AUTOCONNECT connection show "$WIFI_CONNECTION_NAME" 2>/dev/null | paste -sd ';' - || true)" + kv available 1 + kv running "$([ -n "$active" ] && echo 1 || echo 0)" + kv status "$([ -n "$active" ] && echo online || echo configured)" + kv host "${HOSTNAME_ALIAS}.local" + kv port "" + kv url "" + kv detail "${detail:-ssid=${WIFI_SSID}}" +} + +service_wifi_start() { + if ! have nmcli || [ ! -d /sys/class/net/wlan0 ]; then + kv message "Wi-Fi interface is not available" + exit 1 + fi + if ! nmcli connection show "$WIFI_CONNECTION_NAME" >/dev/null 2>&1; then + nmcli connection add type wifi ifname wlan0 con-name "$WIFI_CONNECTION_NAME" ssid "$WIFI_SSID" \ + wifi-sec.key-mgmt wpa-psk wifi-sec.psk "${WIFI_PSK:-12345678}" ipv4.method auto connection.autoconnect yes + fi + nmcli connection up "$WIFI_CONNECTION_NAME" >/dev/null 2>&1 || true + kv message "Wi-Fi profile ensured" +} + +service_wifi_stop() { + nmcli connection down "$WIFI_CONNECTION_NAME" >/dev/null 2>&1 || true + kv message "Wi-Fi profile disconnected" +} + +service_shellcrash_status() { + local running detail + if [ ! -x "$SHELLCRASH_DIR/start.sh" ] && ! unit_exists shellcrash.service; then + kv available 0 + kv running 0 + kv status unavailable + kv detail "ShellCrash is not installed" + return 0 + fi + running=0 + detail="$(filtered_pgrep 'CrashCore|shellcrash' | tr '\n' ';' || true)" + if unit_active shellcrash.service || [ -n "$detail" ]; then + running=1 + fi + [ -z "$detail" ] && detail="dir=$SHELLCRASH_DIR" + kv available 1 + kv running "$running" + kv status "$([ "$running" = 1 ] && echo online || echo stopped)" + kv url "${CONSOLE_BASE_URL}/shellcrash" + kv host "${HOSTNAME_ALIAS}.local" + kv port "" + kv detail "$detail" +} + +service_shellcrash_start() { + if unit_exists shellcrash.service; then + systemctl enable --now shellcrash.service + elif [ -x "$SHELLCRASH_DIR/start.sh" ]; then + "$SHELLCRASH_DIR/start.sh" start + else + kv message "ShellCrash is not installed" + exit 1 + fi + kv message "ShellCrash started" +} + +service_shellcrash_stop() { + if unit_exists shellcrash.service; then + systemctl stop shellcrash.service + elif [ -x "$SHELLCRASH_DIR/start.sh" ]; then + "$SHELLCRASH_DIR/start.sh" stop + fi + kv message "ShellCrash stopped" +} + +service_srs_status() { + if ! have docker || [ ! -f "$SRS_COMPOSE" ]; then + kv available 0 + kv running 0 + kv status unavailable + kv detail "Docker or SRS compose file is missing" + return 0 + fi + local state + state="$(docker_container_status live-srs || true)" + kv available 1 + kv running "$([[ "$state" == running* ]] && echo 1 || echo 0)" + kv status "$([[ "$state" == running* ]] && echo online || echo stopped)" + kv host "${HOSTNAME_ALIAS}.local" + kv port "$SRS_HTTP_PORT" + kv url "http://${HOSTNAME_ALIAS}.local:${SRS_HTTP_PORT}" + kv detail "${state:-container=missing};rtmp=${SRS_RTMP_PORT};api=${SRS_API_PORT};srt=${SRS_SRT_PORT};rtc=${SRS_WEBRTC_PORT}" +} + +service_srs_start() { + compose_run "$SRS_COMPOSE" up -d + kv message "SRS started" +} + +service_srs_stop() { + compose_run "$SRS_COMPOSE" down + kv message "SRS stopped" +} + +service_webtty_status() { + if ! unit_exists live-webtty.service && ! have ttyd; then + kv available 0 + kv running 0 + kv status unavailable + kv detail "ttyd is not installed" + return 0 + fi + kv available 1 + kv running "$(unit_active live-webtty.service && echo 1 || echo 0)" + kv status "$(unit_active live-webtty.service && echo online || echo stopped)" + kv host "${HOSTNAME_ALIAS}.local" + kv port "$WEBTTY_PORT" + kv url "http://${HOSTNAME_ALIAS}.local:${WEBTTY_PORT}" + kv detail "web user=${WEBTTY_USER}" +} + +service_webtty_start() { + systemctl enable --now live-webtty.service + kv message "WebTTY started" +} + +service_webtty_stop() { + systemctl stop live-webtty.service + kv message "WebTTY stopped" +} + +service_cockpit_status() { + if ! unit_exists cockpit.socket; then + kv available 0 + kv running 0 + kv status unavailable + kv detail "Cockpit is not installed" + return 0 + fi + kv available 1 + kv running "$(unit_active cockpit.socket && echo 1 || echo 0)" + kv status "$(unit_active cockpit.socket && echo online || echo stopped)" + kv host "${HOSTNAME_ALIAS}.local" + kv port 9090 + kv url "https://${HOSTNAME_ALIAS}.local:9090" + kv detail "login with live or any enabled system account" +} + +service_cockpit_start() { + systemctl enable --now cockpit.socket + kv message "Cockpit started" +} + +service_cockpit_stop() { + systemctl stop cockpit.socket + kv message "Cockpit stopped" +} + +service_filebrowser_status() { + if ! have docker || [ ! -f "$FILEBROWSER_COMPOSE" ]; then + kv available 0 + kv running 0 + kv status unavailable + kv detail "Docker or File Browser compose file is missing" + return 0 + fi + local state + state="$(docker_container_status live-filebrowser || true)" + kv available 1 + kv running "$([[ "$state" == running* ]] && echo 1 || echo 0)" + kv status "$([[ "$state" == running* ]] && echo online || echo stopped)" + kv host "${HOSTNAME_ALIAS}.local" + kv port "$FILEBROWSER_PORT" + kv url "http://${HOSTNAME_ALIAS}.local:${FILEBROWSER_PORT}" + kv detail "${state:-container=missing};user=${FILEBROWSER_USER:-live}" +} + +service_filebrowser_start() { + compose_run "$FILEBROWSER_COMPOSE" up -d + kv message "File Browser started" +} + +service_filebrowser_stop() { + compose_run "$FILEBROWSER_COMPOSE" down + kv message "File Browser stopped" +} + +service_homepage_status() { + if ! have docker || [ ! -f "$HOMEPAGE_COMPOSE" ]; then + kv available 0 + kv running 0 + kv status unavailable + kv detail "Docker or Homepage compose file is missing" + return 0 + fi + local state + state="$(docker_container_status live-homepage || true)" + kv available 1 + kv running "$([[ "$state" == running* ]] && echo 1 || echo 0)" + kv status "$([[ "$state" == running* ]] && echo online || echo stopped)" + kv host "${HOSTNAME_ALIAS}.local" + kv port "$HOMEPAGE_PORT" + kv url "$([ "$HOMEPAGE_PORT" = 80 ] && echo "http://${HOSTNAME_ALIAS}.local" || echo "http://${HOSTNAME_ALIAS}.local:${HOMEPAGE_PORT}")" + kv detail "${state:-container=missing}" +} + +service_homepage_start() { + compose_run "$HOMEPAGE_COMPOSE" up -d + kv message "Homepage started" +} + +service_homepage_stop() { + compose_run "$HOMEPAGE_COMPOSE" down + kv message "Homepage stopped" +} + +service_netdata_status() { + if ! unit_exists netdata.service; then + kv available 0 + kv running 0 + kv status unavailable + kv detail "Netdata is not installed" + return 0 + fi + kv available 1 + kv running "$(unit_active netdata.service && echo 1 || echo 0)" + kv status "$(unit_active netdata.service && echo online || echo stopped)" + kv host "${HOSTNAME_ALIAS}.local" + kv port "$NETDATA_PORT" + kv url "http://${HOSTNAME_ALIAS}.local:${NETDATA_PORT}" + kv detail "metrics and alarms API available at /api/v1" +} + +service_netdata_start() { + systemctl enable --now netdata + kv message "Netdata started" +} + +service_netdata_stop() { + systemctl stop netdata + kv message "Netdata stopped" +} + +service_adb_status() { + if ! have adb; then + kv available 0 + kv running 0 + kv status unavailable + kv detail "adb is not installed" + return 0 + fi + local details + details="$(adb devices -l 2>/dev/null | tr '\n' ';' || true)" + kv available 1 + kv running "$(filtered_pgrep 'adb -L tcp:5037|[ /]adb([ ]|$)' >/dev/null 2>&1 && echo 1 || echo 0)" + kv status "$(filtered_pgrep 'adb -L tcp:5037|[ /]adb([ ]|$)' >/dev/null 2>&1 && echo online || echo idle)" + kv host "${HOSTNAME_ALIAS}.local" + kv port "" + kv url "" + kv detail "${details:-adb server ready}" +} + +service_adb_start() { + adb start-server >/dev/null + kv message "ADB server started" +} + +service_adb_stop() { + adb kill-server >/dev/null 2>&1 || true + kv message "ADB server stopped" +} + +service_android_panel_status() { + if ! unit_exists live-android-panel.service; then + kv available 0 + kv running 0 + kv status unavailable + kv detail "Android web panel is not installed" + return 0 + fi + kv available 1 + kv running "$(unit_active live-android-panel.service && echo 1 || echo 0)" + kv status "$(unit_active live-android-panel.service && echo online || echo stopped)" + kv host "${HOSTNAME_ALIAS}.local" + kv port "$ANDROID_PANEL_PORT" + kv url "${CONSOLE_BASE_URL}/android" + kv detail "android-center=${CONSOLE_BASE_URL}/android;raw-web-scrcpy=http://${HOSTNAME_ALIAS}.local:${ANDROID_PANEL_PORT}" +} + +service_android_panel_start() { + systemctl enable --now live-android-panel.service + kv message "Android web panel started" +} + +service_android_panel_stop() { + systemctl stop live-android-panel.service + kv message "Android web panel stopped" +} + +service_chromium_status() { + if ! have chromium && ! have chromium-browser && ! have google-chrome; then + kv available 0 + kv running 0 + kv status unavailable + kv detail "Chromium or Google Chrome is not installed" + return 0 + fi + local details + details="$(filtered_pgrep 'chromium|chrome' | tr '\n' ';' || true)" + kv available 1 + kv running "$([ -n "$details" ] && echo 1 || echo 0)" + kv status "$([ -n "$details" ] && echo online || echo stopped)" + kv host "${HOSTNAME_ALIAS}.local" + kv port "${BROWSER_REMOTE_DEBUG_PORT:-9222}" + kv url "" + kv detail "${details:-profile=${BROWSER_PROFILE_DIR:-$HOME/.local/share/live-browser/profile}}" +} + +service_chromium_start() { + nohup "$BROWSER_LAUNCHER" >/tmp/live-browser.log 2>&1 & + disown || true + kv message "Chromium launched" +} + +service_chromium_stop() { + pkill -f 'chromium|chrome' || true + kv message "Chromium stopped" +} + +service_android_gateway_status() { + if ! have nmcli || [ -z "$ANDROID_DOWNLINK_IF" ]; then + kv available 0 + kv running 0 + kv status unavailable + kv detail "Set ANDROID_DOWNLINK_IF in system-stack.env to enable gateway mode" + return 0 + fi + if [ ! -d "/sys/class/net/$ANDROID_DOWNLINK_IF" ]; then + kv available 0 + kv running 0 + kv status unavailable + kv detail "Downlink interface $ANDROID_DOWNLINK_IF is missing" + return 0 + fi + local active + active="$(nmcli -t -f NAME connection show --active 2>/dev/null | grep "^${ANDROID_GATEWAY_NAME}$" || true)" + kv available 1 + kv running "$([ -n "$active" ] && echo 1 || echo 0)" + kv status "$([ -n "$active" ] && echo online || echo configured)" + kv host "${HOSTNAME_ALIAS}.local" + kv port "" + kv url "" + kv detail "uplink=${ANDROID_UPLINK_IF};downlink=${ANDROID_DOWNLINK_IF};gateway=${ANDROID_GATEWAY_CIDR}" +} + +service_android_gateway_start() { + if [ -z "$ANDROID_DOWNLINK_IF" ]; then + kv message "ANDROID_DOWNLINK_IF is not configured" + exit 1 + fi + if ! nmcli connection show "$ANDROID_GATEWAY_NAME" >/dev/null 2>&1; then + nmcli connection add type ethernet ifname "$ANDROID_DOWNLINK_IF" con-name "$ANDROID_GATEWAY_NAME" \ + ipv4.method shared ipv4.addresses "$ANDROID_GATEWAY_CIDR" connection.autoconnect yes + fi + nmcli connection up "$ANDROID_GATEWAY_NAME" + kv message "Android gateway started" +} + +service_android_gateway_stop() { + nmcli connection down "$ANDROID_GATEWAY_NAME" >/dev/null 2>&1 || true + kv message "Android gateway stopped" +} + +dispatch() { + local fn="service_${SERVICE_ID}_${ACTION}" + if ! declare -F "$fn" >/dev/null 2>&1; then + echo "MESSAGE=Unsupported service/action: ${SERVICE_ID}/${ACTION}" + exit 1 + fi + "$fn" +} + +if [ -z "$SERVICE_ID" ]; then + echo "MESSAGE=Missing service id" + exit 1 +fi + +dispatch diff --git a/scripts/linux/webtty_shell.sh b/scripts/linux/webtty_shell.sh new file mode 100644 index 0000000..8f67d2a --- /dev/null +++ b/scripts/linux/webtty_shell.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(cd "$(dirname "$0")/../.." && pwd)" +exec bash -l diff --git a/services/filebrowser/config/settings.json b/services/filebrowser/config/settings.json new file mode 100644 index 0000000..34ea551 --- /dev/null +++ b/services/filebrowser/config/settings.json @@ -0,0 +1,6 @@ +{ + "address": "0.0.0.0", + "port": 80, + "baseURL": "", + "log": "stdout" +} diff --git a/services/filebrowser/docker-compose.yml b/services/filebrowser/docker-compose.yml new file mode 100644 index 0000000..c7eb97b --- /dev/null +++ b/services/filebrowser/docker-compose.yml @@ -0,0 +1,22 @@ +version: "3.3" + +services: + filebrowser: + image: filebrowser/filebrowser:latest + container_name: live-filebrowser + restart: unless-stopped + ports: + - "${FILEBROWSER_PORT:-8082}:80" + volumes: + - ./database:/database + - ./config:/config + - ../..:/srv/app + - "${SHELLCRASH_DIR:-/etc/ShellCrash}:/srv/shellcrash" + - /home/live:/srv/home-live + command: + - --database + - /database/filebrowser.db + - --config + - /config/settings.json + - --root + - /srv diff --git a/services/homepage/config/services.yaml b/services/homepage/config/services.yaml new file mode 100644 index 0000000..dfe7e0f --- /dev/null +++ b/services/homepage/config/services.yaml @@ -0,0 +1,22 @@ +- Infrastructure: + - Live Console: + href: http://__HOST__:8001 + description: Douyin / YouTube / HDMI orchestration + - WebTTY: + href: http://__HOST__:7681 + description: Browser terminal as live user + - Cockpit: + href: https://__HOST__:9090 + description: System administration + - File Browser: + href: http://__HOST__:8082 + description: Beginner-friendly file manager + - ShellCrash: + href: http://__HOST__:8001 + description: Rules and YAML editing from the main console + - Netdata: + href: http://__HOST__:19999 + description: Metrics and health monitoring + - SRS: + href: http://__HOST__:8080 + description: FLV and HLS preview endpoint diff --git a/services/homepage/config/settings.yaml b/services/homepage/config/settings.yaml new file mode 100644 index 0000000..2eeafdf --- /dev/null +++ b/services/homepage/config/settings.yaml @@ -0,0 +1,7 @@ +title: Live Control Hub +description: Unified LAN dashboard for streaming, networking, and device control +color: slate +layout: + Infrastructure: + style: row + columns: 3 diff --git a/services/homepage/config/widgets.yaml b/services/homepage/config/widgets.yaml new file mode 100644 index 0000000..22c862a --- /dev/null +++ b/services/homepage/config/widgets.yaml @@ -0,0 +1,4 @@ +- resources: + cpu: true + memory: true + disk: / diff --git a/services/homepage/docker-compose.yml b/services/homepage/docker-compose.yml new file mode 100644 index 0000000..61a8386 --- /dev/null +++ b/services/homepage/docker-compose.yml @@ -0,0 +1,14 @@ +version: "3.3" + +services: + homepage: + image: ghcr.io/gethomepage/homepage:latest + container_name: live-homepage + restart: unless-stopped + ports: + - "${HOMEPAGE_PORT:-80}:3000" + environment: + HOMEPAGE_ALLOWED_HOSTS: "*" + volumes: + - ./config:/app/config + - /var/run/docker.sock:/var/run/docker.sock:ro diff --git a/services/srs/conf/srs.conf b/services/srs/conf/srs.conf new file mode 100644 index 0000000..93ca501 --- /dev/null +++ b/services/srs/conf/srs.conf @@ -0,0 +1,41 @@ +listen 1935; +max_connections 1000; +daemon off; +srs_log_tank console; + +http_api { + enabled on; + listen 1985; +} + +http_server { + enabled on; + listen 8080; + dir ./objs/nginx/html; +} + +srt_server { + enabled on; + listen 10080; +} + +vhost __defaultVhost__ { + tcp_nodelay on; + min_latency on; + play { + gop_cache off; + queue_length 1; + } + + http_remux { + enabled on; + mount [vhost]/[app]/[stream].flv; + } + + hls { + enabled on; + hls_path ./objs/nginx/html; + hls_fragment 3; + hls_window 18; + } +} diff --git a/services/srs/docker-compose.yml b/services/srs/docker-compose.yml new file mode 100644 index 0000000..3602e94 --- /dev/null +++ b/services/srs/docker-compose.yml @@ -0,0 +1,16 @@ +version: "3.3" + +services: + srs: + image: ossrs/srs:5 + container_name: live-srs + restart: unless-stopped + ports: + - "${SRS_RTMP_PORT:-1935}:1935" + - "${SRS_API_PORT:-1985}:1985" + - "${SRS_HTTP_PORT:-8080}:8080" + - "${SRS_SRT_PORT:-10080}:10080/udp" + volumes: + - ./conf/srs.conf:/usr/local/srs/conf/live.conf:ro + - ./data:/usr/local/srs/objs/nginx/html + command: ["./objs/srs", "-c", "conf/live.conf"] diff --git a/src/ab_sign.py b/src/ab_sign.py index ad04d32..7c5f17f 100644 --- a/src/ab_sign.py +++ b/src/ab_sign.py @@ -1,4 +1,6 @@ # -*- encoding: utf-8 -*- +from __future__ import annotations + import math import time diff --git a/src/android_control.py b/src/android_control.py new file mode 100644 index 0000000..80a45a3 --- /dev/null +++ b/src/android_control.py @@ -0,0 +1,423 @@ +from __future__ import annotations + +import asyncio +import json +import re +import shutil +import xml.etree.ElementTree as ET +from typing import Optional +from urllib.parse import quote + + +def adb_available() -> bool: + return shutil.which("adb") is not None + + +async def _run_cmd( + command: list[str], + *, + timeout: float = 20.0, + capture_binary: bool = False, +) -> tuple[int, str | bytes, str]: + proc = await asyncio.create_subprocess_exec( + *command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + return 124, b"" if capture_binary else "", "Command timed out" + + if capture_binary: + return proc.returncode or 0, stdout or b"", (stderr or b"").decode("utf-8", "replace").strip() + return ( + proc.returncode or 0, + (stdout or b"").decode("utf-8", "replace").strip(), + (stderr or b"").decode("utf-8", "replace").strip(), + ) + + +def _adb_prefix(serial: Optional[str]) -> list[str]: + command = ["adb"] + if serial: + command.extend(["-s", serial]) + return command + + +async def _adb_text(serial: Optional[str], *args: str, timeout: float = 20.0) -> tuple[int, str, str]: + code, out, err = await _run_cmd(_adb_prefix(serial) + list(args), timeout=timeout) + assert isinstance(out, str) + return code, out, err + + +async def _adb_binary(serial: Optional[str], *args: str, timeout: float = 20.0) -> tuple[int, bytes, str]: + code, out, err = await _run_cmd( + _adb_prefix(serial) + list(args), + timeout=timeout, + capture_binary=True, + ) + assert isinstance(out, bytes) + return code, out, err + + +def _parse_device_line(line: str) -> Optional[dict]: + line = line.strip() + if not line or line.startswith("List of devices attached"): + return None + parts = line.split() + if len(parts) < 2: + return None + + serial = parts[0] + state = parts[1] + extras: dict[str, str] = {} + for token in parts[2:]: + if ":" not in token: + continue + key, value = token.split(":", 1) + extras[key] = value + + return { + "serial": serial, + "state": state, + "usb": extras.get("usb", ""), + "product": extras.get("product", ""), + "model": extras.get("model", ""), + "device": extras.get("device", ""), + "transport_id": extras.get("transport_id", ""), + "raw": line, + } + + +async def list_android_devices() -> list[dict]: + if not adb_available(): + return [] + code, out, _ = await _adb_text(None, "devices", "-l") + if code != 0: + return [] + devices: list[dict] = [] + for raw in out.splitlines(): + parsed = _parse_device_line(raw) + if parsed: + devices.append(parsed) + return devices + + +async def _shell(serial: str, command: str, timeout: float = 20.0) -> str: + code, out, err = await _adb_text(serial, "shell", command, timeout=timeout) + if code != 0: + raise RuntimeError(err or out or "ADB shell command failed") + return out or err + + +async def _get_display_size(serial: str) -> tuple[int, int]: + output = await _shell(serial, "wm size | tail -n 1") + match = re.search(r"(\d+)x(\d+)", output) + if not match: + return 1080, 2400 + return int(match.group(1)), int(match.group(2)) + + +async def android_device_overview(serial: str) -> dict: + if not serial: + raise ValueError("Missing Android device serial") + + props = { + "model": "getprop ro.product.model", + "brand": "getprop ro.product.brand", + "android_version": "getprop ro.build.version.release", + "sdk": "getprop ro.build.version.sdk", + } + + async def grab(name: str, cmd: str) -> tuple[str, str]: + try: + return name, (await _shell(serial, cmd, timeout=10.0)).strip() + except RuntimeError as exc: + return name, str(exc) + + results = dict(await asyncio.gather(*(grab(key, cmd) for key, cmd in props.items()))) + + battery = await _shell( + serial, + "dumpsys battery | grep -E 'level|status|powered' | sed 's/^[[:space:]]*//'", + timeout=10.0, + ) + focus = await _shell( + serial, + "(dumpsys window windows | grep -E 'mCurrentFocus' | tail -n 1) || " + "(dumpsys activity activities | grep -E 'mResumedActivity' | tail -n 1)", + timeout=10.0, + ) + resolution = await _shell(serial, "wm size | tail -n 1", timeout=10.0) + rotation = await _shell(serial, "settings get system user_rotation", timeout=10.0) + screen_on = await _shell(serial, "dumpsys power | grep -E 'Display Power|mHoldingDisplaySuspendBlocker'", timeout=10.0) + + return { + "serial": serial, + "model": results.get("model", ""), + "brand": results.get("brand", ""), + "android_version": results.get("android_version", ""), + "sdk": results.get("sdk", ""), + "battery": battery, + "focus": focus, + "resolution": resolution, + "rotation": rotation, + "screen_state": screen_on, + } + + +def _bounds_to_rect(bounds: str) -> Optional[tuple[int, int, int, int]]: + match = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds.strip()) + if not match: + return None + left, top, right, bottom = (int(match.group(i)) for i in range(1, 5)) + if right <= left or bottom <= top: + return None + return left, top, right, bottom + + +def _node_label(node: ET.Element) -> str: + text = (node.attrib.get("text") or "").strip() + desc = (node.attrib.get("content-desc") or "").strip() + resource_id = (node.attrib.get("resource-id") or "").strip() + class_name = (node.attrib.get("class") or "").strip() + if text: + return text + if desc: + return desc + if resource_id: + return resource_id.split("/")[-1] + return class_name.split(".")[-1] if class_name else "node" + + +async def android_packages(serial: str, query: str = "", limit: int = 60) -> list[dict]: + if not serial: + raise ValueError("Missing Android device serial") + + output = await _shell(serial, "pm list packages", timeout=25.0) + needle = query.strip().lower() + results: list[dict] = [] + for raw in output.splitlines(): + package = raw.strip() + if package.startswith("package:"): + package = package[len("package:") :] + if not package: + continue + if needle and needle not in package.lower(): + continue + results.append( + { + "package": package, + "label": package.split(".")[-1], + } + ) + if len(results) >= max(1, min(limit, 200)): + break + return results + + +async def android_ui_nodes(serial: str, limit: int = 80) -> list[dict]: + if not serial: + raise ValueError("Missing Android device serial") + + xml_text = "" + for dump_path in ("/data/local/tmp/live-ui.xml", "/sdcard/live-ui.xml"): + try: + xml_text = await _shell( + serial, + f"uiautomator dump {dump_path} >/dev/null 2>&1 && cat {dump_path}", + timeout=25.0, + ) + except RuntimeError: + xml_text = "" + if xml_text.strip().startswith(" tuple[int, int, int]: + return ( + 0 if item["clickable"] else 1, + 0 if item["text"] or item["content_desc"] else 1, + len(item["label"]), + ) + + nodes.sort(key=sort_key) + return nodes[: max(1, min(limit, 200))] + + +async def android_screenshot(serial: str) -> bytes: + if not serial: + raise ValueError("Missing Android device serial") + code, data, err = await _adb_binary(serial, "exec-out", "screencap", "-p", timeout=20.0) + if code != 0 or not data: + raise RuntimeError(err or "Failed to capture screenshot") + return data.replace(b"\r\n", b"\n") + + +def _normalize_text(text: str) -> str: + normalized = text.replace(" ", "%s") + normalized = normalized.replace("&", r"\&") + normalized = normalized.replace("(", r"\(") + normalized = normalized.replace(")", r"\)") + return normalized + + +async def android_action(action: str, serial: str, payload: Optional[dict] = None) -> dict: + if not serial: + raise ValueError("Missing Android device serial") + if not adb_available(): + raise RuntimeError("adb is not installed") + payload = payload or {} + + if action == "key": + keycode = str(payload.get("keycode", "")).strip() + if not keycode: + raise ValueError("Missing keycode") + await _shell(serial, f"input keyevent {keycode}") + return {"message": f"Sent keycode {keycode}"} + + if action == "text": + text = str(payload.get("text", "")) + if not text: + raise ValueError("Missing text") + await _shell(serial, f"input text '{_normalize_text(text)}'") + return {"message": "Text sent to device"} + + if action == "open_url": + url = str(payload.get("url", "")).strip() + if not url: + raise ValueError("Missing url") + await _shell( + serial, + f"am start -a android.intent.action.VIEW -d '{quote(url, safe=':/?&=%#.-_')}'", + ) + return {"message": f"Opened URL: {url}"} + + if action == "launch_package": + package = str(payload.get("package", "")).strip() + if not package: + raise ValueError("Missing package") + await _shell(serial, f"monkey -p '{package}' -c android.intent.category.LAUNCHER 1") + return {"message": f"Launched package: {package}"} + + if action == "shell": + command = str(payload.get("command", "")).strip() + if not command: + raise ValueError("Missing shell command") + output = await _shell(serial, command, timeout=30.0) + return {"message": "Shell command finished", "output": output} + + if action == "tap": + x = int(payload.get("x", 0)) + y = int(payload.get("y", 0)) + await _shell(serial, f"input tap {x} {y}") + return {"message": f"Tapped at {x}, {y}"} + + if action == "double_tap": + x = int(payload.get("x", 0)) + y = int(payload.get("y", 0)) + await _shell(serial, f"input tap {x} {y} && sleep 0.08 && input tap {x} {y}") + return {"message": f"Double tapped at {x}, {y}"} + + if action == "long_press": + x = int(payload.get("x", 0)) + y = int(payload.get("y", 0)) + duration = int(payload.get("duration", 700)) + await _shell(serial, f"input swipe {x} {y} {x} {y} {duration}") + return {"message": f"Long pressed at {x}, {y} for {duration}ms"} + + if action == "swipe": + direction = str(payload.get("direction", "")).strip().lower() + width, height = await _get_display_size(serial) + mid_x = max(width // 2, 1) + mid_y = max(height // 2, 1) + mapping = { + "up": (mid_x, int(height * 0.75), mid_x, int(height * 0.28)), + "down": (mid_x, int(height * 0.28), mid_x, int(height * 0.75)), + "left": (int(width * 0.75), mid_y, int(width * 0.25), mid_y), + "right": (int(width * 0.25), mid_y, int(width * 0.75), mid_y), + } + if direction not in mapping: + raise ValueError("Unsupported swipe direction") + start_x, start_y, end_x, end_y = mapping[direction] + await _shell(serial, f"input swipe {start_x} {start_y} {end_x} {end_y} 240") + return {"message": f"Swiped {direction}"} + + if action == "wake": + await _shell(serial, "input keyevent KEYCODE_WAKEUP") + return {"message": "Wake command sent"} + + if action == "rotate": + target = str(payload.get("rotation", "0")).strip() + if target not in {"0", "1", "2", "3"}: + raise ValueError("Rotation must be one of 0,1,2,3") + await _shell( + serial, + "settings put system accelerometer_rotation 0 && " + f"settings put system user_rotation {target}", + ) + return {"message": f"Rotation set to {target}"} + + if action == "force_stop": + package = str(payload.get("package", "")).strip() + if not package: + raise ValueError("Missing package") + await _shell(serial, f"am force-stop '{package}'") + return {"message": f"Force-stopped: {package}"} + + if action == "clear_app": + package = str(payload.get("package", "")).strip() + if not package: + raise ValueError("Missing package") + output = await _shell(serial, f"pm clear '{package}'", timeout=25.0) + return {"message": f"Cleared app data: {package}", "output": output} + + raise ValueError(f"Unsupported Android action: {action}") diff --git a/src/control_plane.py b/src/control_plane.py new file mode 100644 index 0000000..5772593 --- /dev/null +++ b/src/control_plane.py @@ -0,0 +1,533 @@ +from __future__ import annotations + +import asyncio +import json +import os +import platform +import shutil +import socket +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable +from urllib.request import urlopen + +BASE_DIR = Path(__file__).resolve().parent.parent +CONFIG_DIR = BASE_DIR / "config" +LINUX_DIR = BASE_DIR / "scripts" / "linux" +SERVICE_CTL = LINUX_DIR / "service_ctl.sh" +HW_PROBE = BASE_DIR / "scripts" / "hardware_probe.py" +HW_PROFILE_JSON = CONFIG_DIR / "hardware-profile.json" +STACK_ENV = CONFIG_DIR / "system-stack.env" +MAX_FILES_PER_ROOT = 256 + + +@dataclass(frozen=True) +class ManagedRoot: + root_id: str + label: str + description: str + path: Path + allow_create: bool = True + allow_delete: bool = True + recursive: bool = False + explicit_names: tuple[str, ...] = () + allowed_suffixes: tuple[str, ...] = () + default_file: str | None = None + + def exists(self) -> bool: + return self.path.is_dir() + + def allows(self, rel_path: Path) -> bool: + rel_posix = rel_path.as_posix() + if self.explicit_names: + return rel_posix in self.explicit_names + return rel_path.suffix.lower() in self.allowed_suffixes + + +@dataclass(frozen=True) +class ServiceSpec: + service_id: str + label: str + category: str + description: str + + +SERVICE_SPECS: tuple[ServiceSpec, ...] = ( + ServiceSpec("mdns", "mDNS / live.local", "system", "Hostname and mDNS discovery"), + ServiceSpec("wifi", "Wi-Fi Auto Connect", "system", "Default Wi-Fi onboarding profile"), + ServiceSpec("shellcrash", "ShellCrash", "network", "Transparent proxy and rule engine"), + ServiceSpec("srs", "SRS", "stream", "RTMP/SRT relay for HDMI and live workflows"), + ServiceSpec("webtty", "WebTTY", "access", "Browser terminal for live user"), + ServiceSpec("cockpit", "Cockpit", "admin", "System administration dashboard"), + ServiceSpec("filebrowser", "File Browser", "admin", "Beginner-friendly file manager"), + ServiceSpec("homepage", "Homepage", "admin", "Start page for all LAN services"), + ServiceSpec("netdata", "Netdata", "monitor", "System metrics and health monitoring"), + ServiceSpec("adb", "ADB", "android", "Android bridge and device inspection"), + ServiceSpec("android_panel", "Android Web Panel", "android", "Browser-based Android control with web-scrcpy"), + ServiceSpec("chromium", "Chromium", "browser", "Persistent browser profile launcher"), + ServiceSpec( + "android_gateway", + "Android Gateway", + "network", + "Transparent gateway mode for Android boards", + ), +) + + +def shellcrash_dir() -> Path: + value = os.environ.get("SHELLCRASH_DIR", "/etc/ShellCrash") + return Path(os.path.expanduser(os.path.expandvars(value))) + + +def managed_roots() -> dict[str, ManagedRoot]: + shell_dir = shellcrash_dir() + return { + "app-config": ManagedRoot( + root_id="app-config", + label="Business App Config", + description="Core livestream app configuration files", + path=CONFIG_DIR, + allow_create=True, + allow_delete=False, + explicit_names=( + "config.ini", + "youtube.ini", + "URL_config.ini", + "runtime.env", + "runtime.env.example", + ), + default_file="runtime.env", + ), + "stack-config": ManagedRoot( + root_id="stack-config", + label="System Stack Config", + description="Ports, feature toggles, hostname, Wi-Fi, and gateway settings", + path=CONFIG_DIR, + allow_create=True, + allow_delete=False, + explicit_names=("system-stack.env", "system-stack.env.example"), + default_file="system-stack.env", + ), + "shellcrash-configs": ManagedRoot( + root_id="shellcrash-configs", + label="ShellCrash Configs", + description="ShellCrash cfg/list/env style files", + path=shell_dir / "configs", + allow_create=True, + allow_delete=True, + allowed_suffixes=(".cfg", ".env", ".list", ".txt"), + default_file="ShellCrash.cfg", + ), + "shellcrash-yamls": ManagedRoot( + root_id="shellcrash-yamls", + label="ShellCrash YAML", + description="mihomo YAML fragments and config.yaml", + path=shell_dir / "yamls", + allow_create=True, + allow_delete=True, + allowed_suffixes=(".yaml", ".yml"), + default_file="config.yaml", + ), + "shellcrash-jsons": ManagedRoot( + root_id="shellcrash-jsons", + label="ShellCrash JSON", + description="sing-box JSON fragments", + path=shell_dir / "jsons", + allow_create=True, + allow_delete=True, + allowed_suffixes=(".json",), + default_file="config.json", + ), + "srs-config": ManagedRoot( + root_id="srs-config", + label="SRS Config", + description="SRS compose and conf files", + path=BASE_DIR / "services" / "srs", + allow_create=True, + allow_delete=True, + recursive=True, + allowed_suffixes=(".conf", ".json", ".env", ".yml", ".yaml"), + default_file="conf/srs.conf", + ), + "homepage-config": ManagedRoot( + root_id="homepage-config", + label="Homepage Config", + description="Dashboard links and widgets", + path=BASE_DIR / "services" / "homepage" / "config", + allow_create=True, + allow_delete=True, + recursive=True, + allowed_suffixes=(".yaml", ".yml", ".json"), + default_file="services.yaml", + ), + } + + +def _safe_rel_path(raw_path: str) -> Path: + rel = Path(raw_path.strip().replace("\\", "/")) + if rel.is_absolute(): + raise ValueError("Absolute paths are not allowed") + if any(part in {"", ".", ".."} for part in rel.parts): + raise ValueError("Path traversal is not allowed") + return rel + + +def _resolve_managed_file(root_id: str, relative_path: str) -> tuple[ManagedRoot, Path, Path]: + roots = managed_roots() + root = roots.get(root_id) + if not root: + raise KeyError(f"Unknown root: {root_id}") + rel = _safe_rel_path(relative_path) + if not root.allows(rel): + raise PermissionError(f"File type not allowed under {root_id}") + root.path.mkdir(parents=True, exist_ok=True) + target = (root.path / rel).resolve() + root_real = root.path.resolve() + if root_real not in (target, *target.parents): + raise PermissionError("Resolved path escapes managed root") + return root, rel, target + + +def list_root_summaries() -> list[dict]: + summaries: list[dict] = [] + for root in managed_roots().values(): + file_count = 0 + if root.exists(): + globber: Iterable[Path] + globber = root.path.rglob("*") if root.recursive else root.path.glob("*") + for file in globber: + if file.is_file(): + rel = file.relative_to(root.path) + if root.allows(rel): + file_count += 1 + summaries.append( + { + "root_id": root.root_id, + "label": root.label, + "description": root.description, + "path": str(root.path), + "exists": root.exists(), + "allow_create": root.allow_create, + "allow_delete": root.allow_delete, + "recursive": root.recursive, + "default_file": root.default_file, + "file_count": file_count, + } + ) + return summaries + + +def list_root_files(root_id: str) -> list[dict]: + root = managed_roots().get(root_id) + if not root: + raise KeyError(f"Unknown root: {root_id}") + if not root.exists(): + return [] + files: list[dict] = [] + iterator = root.path.rglob("*") if root.recursive else root.path.glob("*") + for file in iterator: + if not file.is_file(): + continue + rel = file.relative_to(root.path) + if not root.allows(rel): + continue + stat = file.stat() + files.append( + { + "path": rel.as_posix(), + "size": stat.st_size, + "mtime": int(stat.st_mtime), + } + ) + files.sort(key=lambda item: item["path"]) + return files[:MAX_FILES_PER_ROOT] + + +def read_root_file(root_id: str, relative_path: str) -> dict: + root, rel, target = _resolve_managed_file(root_id, relative_path) + if not target.is_file(): + raise FileNotFoundError(relative_path) + return { + "root_id": root.root_id, + "path": rel.as_posix(), + "content": target.read_text(encoding="utf-8"), + } + + +def write_root_file(root_id: str, relative_path: str, content: str) -> dict: + root, rel, target = _resolve_managed_file(root_id, relative_path) + if not root.allow_create and not target.exists(): + raise PermissionError(f"Creating files is disabled for {root_id}") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + return { + "root_id": root.root_id, + "path": rel.as_posix(), + "message": "Saved successfully", + } + + +def delete_root_file(root_id: str, relative_path: str) -> dict: + root, rel, target = _resolve_managed_file(root_id, relative_path) + if not root.allow_delete: + raise PermissionError(f"Deleting files is disabled for {root_id}") + if not target.exists(): + raise FileNotFoundError(relative_path) + target.unlink() + return { + "root_id": root.root_id, + "path": rel.as_posix(), + "message": "Deleted successfully", + } + + +def _parse_kv_output(text: str) -> dict[str, str]: + data: dict[str, str] = {} + for raw in text.splitlines(): + line = raw.strip() + if not line or "=" not in line: + continue + key, _, value = line.partition("=") + data[key.strip().lower()] = value.strip() + return data + + +async def _run_command(cmd: list[str], *, timeout: float = 20.0) -> tuple[int, str]: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=str(BASE_DIR), + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + return 124, "Command timed out" + out = (stdout or b"").decode("utf-8", "replace").strip() + err = (stderr or b"").decode("utf-8", "replace").strip() + merged = out if out else err + return proc.returncode or 0, merged + + +def _default_service_status(spec: ServiceSpec, detail: str) -> dict: + return { + "service_id": spec.service_id, + "label": spec.label, + "category": spec.category, + "description": spec.description, + "available": False, + "running": False, + "status": "unavailable", + "detail": detail, + "url": "", + } + + +async def query_service(spec: ServiceSpec) -> dict: + if sys.platform == "win32": + return _default_service_status(spec, "Linux-only stack integration") + if not SERVICE_CTL.is_file(): + return _default_service_status(spec, "Service controller script is missing") + code, output = await _run_command(["bash", str(SERVICE_CTL), spec.service_id, "status"]) + if code != 0: + return _default_service_status(spec, output or "Status command failed") + info = _parse_kv_output(output) + return { + "service_id": spec.service_id, + "label": spec.label, + "category": spec.category, + "description": spec.description, + "available": info.get("available", "0") == "1", + "running": info.get("running", "0") == "1", + "status": info.get("status", "unknown"), + "detail": info.get("detail", ""), + "url": info.get("url", ""), + "host": info.get("host", ""), + "port": info.get("port", ""), + } + + +async def query_services() -> list[dict]: + return list(await asyncio.gather(*(query_service(spec) for spec in SERVICE_SPECS))) + + +async def service_action(service_id: str, action: str) -> dict: + if action not in {"start", "stop", "restart"}: + raise ValueError(f"Unsupported action: {action}") + spec = next((item for item in SERVICE_SPECS if item.service_id == service_id), None) + if not spec: + raise KeyError(f"Unknown service: {service_id}") + if sys.platform == "win32": + return _default_service_status(spec, "Linux-only stack integration") + if not SERVICE_CTL.is_file(): + return _default_service_status(spec, "Service controller script is missing") + + command = ["bash", str(SERVICE_CTL), service_id, action] + if shutil.which("sudo"): + command = ["sudo", "-n", "bash", str(SERVICE_CTL), service_id, action] + code, output = await _run_command(command, timeout=45.0) + info = _parse_kv_output(output) + message = info.get("message", output) + if code != 0: + return { + "service_id": service_id, + "status": "error", + "message": message or "Action failed", + "detail": output, + } + status = await query_service(spec) + status["message"] = message or f"{action} completed" + return status + + +def local_ips() -> list[str]: + ips: list[str] = [] + if shutil.which("ip"): + try: + raw = subprocess.check_output( + ["ip", "-j", "-4", "addr", "show", "scope", "global"], + cwd=str(BASE_DIR), + text=True, + ) + ignored_prefixes = ( + "docker", + "br-", + "veth", + "virbr", + "tun", + "tap", + "zt", + "tailscale", + "wg", + "sit", + "dummy", + ) + for item in json.loads(raw): + ifname = str(item.get("ifname", "")) + if not ifname or ifname == "lo" or ifname.startswith(ignored_prefixes): + continue + for addr in item.get("addr_info", []): + ip = str(addr.get("local", "")).strip() + if ip and not ip.startswith("127.") and ip not in ips: + ips.append(ip) + if ips: + return ips + except (OSError, ValueError, subprocess.SubprocessError, json.JSONDecodeError): + pass + + hostname = socket.gethostname() + try: + for info in socket.getaddrinfo(hostname, None, family=socket.AF_INET): + ip = info[4][0] + if ip and not ip.startswith("127.") and ip not in ips: + ips.append(ip) + except OSError: + pass + return ips + + +def load_stack_env() -> dict[str, str]: + if not STACK_ENV.is_file(): + return {} + values: dict[str, str] = {} + for raw in STACK_ENV.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + values[key.strip()] = value.strip().strip('"').strip("'") + return values + + +def stack_summary() -> dict: + env = load_stack_env() + hostname = env.get("HOSTNAME_ALIAS") or env.get("HOSTNAME") or socket.gethostname() + web_port = env.get("PORT", os.environ.get("PORT", "8001")) + homepage_port = env.get("HOMEPAGE_PORT", "80") + mdns_host = f"{hostname}.local" + return { + "hostname": hostname, + "mdns_host": mdns_host, + "platform": sys.platform, + "machine": platform.machine(), + "kernel": platform.release(), + "python": platform.python_version(), + "ips": local_ips(), + "dashboard_url": f"http://{mdns_host}" if homepage_port in {"80", ""} else f"http://{mdns_host}:{homepage_port}", + "control_url": f"http://{mdns_host}:{web_port}", + "stack_env_present": STACK_ENV.is_file(), + } + + +def _read_meminfo() -> dict[str, int]: + info: dict[str, int] = {} + meminfo = Path("/proc/meminfo") + if not meminfo.is_file(): + return info + for raw in meminfo.read_text(encoding="utf-8").splitlines(): + if ":" not in raw: + continue + key, _, value = raw.partition(":") + parts = value.strip().split() + if not parts: + continue + try: + info[key] = int(parts[0]) * 1024 + except ValueError: + continue + return info + + +def system_snapshot() -> dict: + disk = shutil.disk_usage(BASE_DIR.anchor if sys.platform == "win32" else "/") + meminfo = _read_meminfo() + load = os.getloadavg() if hasattr(os, "getloadavg") else (0.0, 0.0, 0.0) + snapshot = { + "cpu_load": {"1m": load[0], "5m": load[1], "15m": load[2]}, + "disk": {"total": disk.total, "used": disk.used, "free": disk.free}, + "memory": { + "total": meminfo.get("MemTotal", 0), + "available": meminfo.get("MemAvailable", 0), + "free": meminfo.get("MemFree", 0), + }, + "netdata": {"available": False}, + } + if sys.platform != "win32": + try: + with urlopen("http://127.0.0.1:19999/api/v1/info", timeout=1.5) as response: + payload = json.loads(response.read().decode("utf-8", "replace")) + snapshot["netdata"] = { + "available": True, + "version": payload.get("version", ""), + "mirrored_hosts": payload.get("mirrored_hosts", []), + } + except Exception: + pass + return snapshot + + +async def load_hardware_profile(force_refresh: bool = False) -> dict: + if force_refresh and HW_PROBE.is_file() and shutil.which(sys.executable): + await _run_command([sys.executable, str(HW_PROBE), "--write"], timeout=30.0) + if HW_PROFILE_JSON.is_file(): + try: + return json.loads(HW_PROFILE_JSON.read_text(encoding="utf-8")) + except json.JSONDecodeError: + pass + if HW_PROBE.is_file(): + code, output = await _run_command([sys.executable, str(HW_PROBE)], timeout=30.0) + if code == 0 and output: + try: + return json.loads(output) + except json.JSONDecodeError: + return {"status": "error", "detail": output} + return { + "status": "unavailable", + "detail": "Hardware probe not available", + "arch": platform.machine(), + "kernel": platform.release(), + } diff --git a/src/http_clients/async_http.py b/src/http_clients/async_http.py index 2902373..7053c5b 100644 --- a/src/http_clients/async_http.py +++ b/src/http_clients/async_http.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +from __future__ import annotations + import httpx from typing import Dict, Any from .. import utils diff --git a/src/http_clients/sync_http.py b/src/http_clients/sync_http.py index 76d7c07..af9b9c4 100644 --- a/src/http_clients/sync_http.py +++ b/src/http_clients/sync_http.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +from __future__ import annotations + import gzip import urllib.parse import urllib.error diff --git a/src/initializer.py b/src/initializer.py index 2b339a1..63a9718 100644 --- a/src/initializer.py +++ b/src/initializer.py @@ -6,6 +6,8 @@ GitHub:https://github.com/ihmily Copyright (c) 2024 by Hmily, All Rights Reserved. """ +from __future__ import annotations + import os import subprocess import sys diff --git a/src/room.py b/src/room.py index 307187f..d636051 100644 --- a/src/room.py +++ b/src/room.py @@ -7,6 +7,9 @@ Date: 2023-07-17 23:52:05 Update: 2025-02-04 04:57:00 Copyright (c) 2023 by Hmily, All Rights Reserved. """ + +from __future__ import annotations + import re import urllib.parse import execjs diff --git a/src/spider.py b/src/spider.py index b0a9304..cf389f0 100644 --- a/src/spider.py +++ b/src/spider.py @@ -9,6 +9,8 @@ Copyright (c) 2023-2025 by Hmily, All Rights Reserved. Function: Get live stream data. """ +from __future__ import annotations + import hashlib import random import subprocess @@ -3392,4 +3394,4 @@ async def get_picarto_stream_url(url: str, proxy_addr: OptionalStr = None, cooki title = json_data['channel']['title'] m3u8_url = f"https://1-edge1-us-newyork.picarto.tv/stream/hls/golive+{anchor_name}/index.m3u8" result |= {'is_live': True, 'title': title, 'm3u8_url': m3u8_url, 'record_url': m3u8_url} - return result \ No newline at end of file + return result diff --git a/src/stream.py b/src/stream.py index ad5e641..94d0e12 100644 --- a/src/stream.py +++ b/src/stream.py @@ -8,6 +8,9 @@ Update: 2025-02-06 02:28:00 Copyright (c) 2023-2025 by Hmily, All Rights Reserved. Function: Get live stream data. """ + +from __future__ import annotations + import base64 import hashlib import json @@ -443,4 +446,4 @@ async def get_stream_url(json_data: dict, video_quality: str, url_type: str = 'm data |= {"flv_url": flv_url, "record_url": flv_url} data['title'] = json_data.get('title') data['quality'] = video_quality - return data \ No newline at end of file + return data diff --git a/src/utils.py b/src/utils.py index 414ed0a..b5368c7 100644 --- a/src/utils.py +++ b/src/utils.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +from __future__ import annotations + import json import os import random @@ -203,4 +205,4 @@ def get_query_params(url: str, param_name: OptionalStr) -> dict | list[str]: else: values = query_params.get(param_name, []) return values - \ No newline at end of file + diff --git a/start.sh b/start.sh index 73f7d9e..1532c7e 100644 --- a/start.sh +++ b/start.sh @@ -4,6 +4,10 @@ set -euo pipefail cd "$(dirname "$0")" export HOST="${HOST:-0.0.0.0}" export PORT="${PORT:-8001}" -PY=python3 -command -v python3 >/dev/null 2>&1 || PY=python +if [[ -x ".venv/bin/python" ]]; then + PY=".venv/bin/python" +else + PY=python3 + command -v python3 >/dev/null 2>&1 || PY=python +fi exec "$PY" scripts/launch.py --host "$HOST" --port "$PORT" diff --git a/tiktok.py b/tiktok.py index 6f2da1f..b7cd0be 100644 --- a/tiktok.py +++ b/tiktok.py @@ -8,6 +8,9 @@ Update: 2025-10-23 19:48:05 Copyright (c) 2023-2025 by Hmily, All Rights Reserved. Function: Record live stream video. """ + +from __future__ import annotations + import asyncio import os import sys @@ -2201,4 +2204,4 @@ while True: t2.start() first_run = False - time.sleep(3) \ No newline at end of file + time.sleep(3) diff --git a/web-console/app/android/page.tsx b/web-console/app/android/page.tsx new file mode 100644 index 0000000..bee8ff3 --- /dev/null +++ b/web-console/app/android/page.tsx @@ -0,0 +1 @@ +export { default } from "../page"; diff --git a/web-console/app/layout.tsx b/web-console/app/layout.tsx index eca5a89..e1ee693 100644 --- a/web-console/app/layout.tsx +++ b/web-console/app/layout.tsx @@ -7,6 +7,7 @@ const geistSans = localFont({ variable: "--font-geist-sans", weight: "100 900", }); + const geistMono = localFont({ src: "./fonts/GeistMonoVF.woff", variable: "--font-geist-mono", @@ -14,8 +15,8 @@ const geistMono = localFont({ }); export const metadata: Metadata = { - title: "直播推流控制台", - description: "YouTube / TikTok / HDMI 推流进程与配置管理", + title: "直播与系统控制台", + description: "业务进程、系统服务与配置文件的统一控制平面", }; export default function RootLayout({ @@ -25,9 +26,7 @@ export default function RootLayout({ }>) { return ( - +
(typeof process !== "undefined" && process.env.NEXT_PUBLIC_API_URL) || ""; -function stem(script: string) { - const base = script.split(/[/\\]/).pop() || script; - const i = base.lastIndexOf("."); - return i > 0 ? base.slice(0, i) : base; -} - -function isYoutubeScript(script: string) { - if (!/\.py$/i.test(script)) return false; - const s = stem(script).toLowerCase(); - return s.startsWith("youtube") || s.startsWith("douyin_youtube"); -} -function isTiktokScript(script: string) { - return /\.py$/i.test(script) && stem(script).toLowerCase().startsWith("tiktok"); -} -function isObsScript(script: string) { - return stem(script).toLowerCase().startsWith("obs"); -} -function isWebScript(script: string) { - return stem(script).toLowerCase() === "web"; -} - -function statusMeta(processStatus: string): { - dot: string; - text: string; - pillClass: string; -} { - switch (processStatus) { - case "online": - return { - dot: "bg-emerald-400 shadow-[0_0_12px_rgba(52,211,153,0.55)]", - text: "运行中", - pillClass: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200", - }; - case "stopped": - return { - dot: "bg-slate-500", - text: "已停止", - pillClass: "border-slate-500/30 bg-slate-500/10 text-slate-300", - }; - case "errored": - return { - dot: "bg-rose-500 shadow-[0_0_10px_rgba(244,63,94,0.45)]", - text: "异常", - pillClass: "border-rose-500/30 bg-rose-500/10 text-rose-200", - }; - case "launching": - case "starting": - return { - dot: "bg-amber-400 animate-pulse-soft", - text: "启动中", - pillClass: "border-amber-500/30 bg-amber-500/10 text-amber-200", - }; - case "waiting restart": - return { - dot: "bg-amber-400", - text: "等待重启", - pillClass: "border-amber-500/30 bg-amber-500/10 text-amber-200", - }; - case "not_found": - return { - dot: "bg-violet-400", - text: "未启动过", - pillClass: "border-violet-500/30 bg-violet-500/10 text-violet-200", - }; - case "invalid": - return { - dot: "bg-slate-600", - text: "选择无效", - pillClass: "border-slate-600/40 bg-slate-800/50 text-slate-400", - }; - default: - return { - dot: "bg-slate-500", - text: "检测中…", - pillClass: "border-slate-500/25 bg-slate-800/40 text-slate-400", - }; +function formatBytes(value?: number) { + if (!value) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let amount = value; + let index = 0; + while (amount >= 1024 && index < units.length - 1) { + amount /= 1024; + index += 1; } + return `${amount.toFixed(amount >= 10 || index === 0 ? 0 : 1)} ${units[index]}`; } -function hintForEntry(script: string): string { - if (isObsScript(script)) - return "用于从本机 SRS 拉流并在接好显示器的机器上全屏播放(HDMI)。请先保证 SRS 已启动,再点「启动」。"; - if (stem(script).toLowerCase().startsWith("douyin_youtube")) - return "抖音直播转推到 YouTube:先填好右侧 youtube.ini(密钥)和 URL_config(直播间地址),保存后再启动。"; - if (isYoutubeScript(script)) - return "YouTube 相关推流:检查 youtube.ini 与 URL_config,保存后启动。"; - if (isTiktokScript(script)) - return "多平台录制/监测:在 URL_config 里填写直播间地址,保存后启动。"; - if (isWebScript(script)) - return "这是网页控制台本身。一般不要用这里停掉自己;其它机器上可用 PM2 管理。"; - return "选中后使用左侧按钮启动或停止,右侧查看日志。"; +function statusTone(status: string) { + if (status === "online") return "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"; + if (status === "stopped") return "border-slate-500/30 bg-slate-500/10 text-slate-300"; + if (status === "configured") return "border-violet-500/30 bg-violet-500/10 text-violet-200"; + if (status === "unavailable") return "border-slate-700/30 bg-slate-800/50 text-slate-500"; + if (status === "error") return "border-rose-500/30 bg-rose-500/10 text-rose-200"; + return "border-cyan-500/30 bg-cyan-500/10 text-cyan-200"; } -const obsDefault = - "srt://127.0.0.1:10080?streamid=#!::r=live/livestream,m=request&latency=10"; +function isTabKey(value: string | null): value is TabKey { + return value !== null && (TABS as string[]).includes(value); +} + +function pathnameToTab(pathname: string): TabKey | null { + const normalized = pathname.replace(/\/+$/, "") || "/"; + if (normalized === "/shellcrash") return "shellcrash"; + if (normalized === "/android") return "android"; + return null; +} + +function tabPath(tab: TabKey) { + if (tab === "shellcrash") return "/shellcrash"; + if (tab === "android") return "/android"; + return "/"; +} + +function parseResolution(resolution?: string) { + const match = resolution?.match(/(\d+)x(\d+)/); + if (!match) return null; + return { width: Number(match[1]), height: Number(match[2]) }; +} + +function Button({ + children, + className, + disabled, + onClick, +}: { + children: React.ReactNode; + className?: string; + disabled?: boolean; + onClick?: () => void; +}) { + return ( + + ); +} export default function Home() { - const [entries, setEntries] = useState([]); - const [backend, setBackend] = useState(""); - const [platform, setPlatform] = useState(""); - const [current, setCurrent] = useState(""); - const [processStatus, setProcessStatus] = useState("unknown"); - const [rawStatus, setRawStatus] = useState(""); - const [recentLog, setRecentLog] = useState(""); - const [recentErr, setRecentErr] = useState(""); - const [busy, setBusy] = useState(null); - const [toast, setToast] = useState(null); - const [loadError, setLoadError] = useState(null); - const [bootLoading, setBootLoading] = useState(true); - const [autoRefresh, setAutoRefresh] = useState(true); - const [logTab, setLogTab] = useState<"out" | "err" | "split">("split"); - const [helpOpen, setHelpOpen] = useState(true); - const [confirmStop, setConfirmStop] = useState(false); - - const [youtubeIni, setYoutubeIni] = useState(""); - const [urlIni, setUrlIni] = useState(""); - const [cfgYoutubeStatus, setCfgYoutubeStatus] = useState("就绪"); - const [cfgUrlStatus, setCfgUrlStatus] = useState("就绪"); - const base = apiBase(); - const ent = useMemo( - () => entries.find((e) => e.pm2 === current) || null, - [entries, current], - ); - const script = ent?.script || ""; + const [lang, setLang] = useState("zh"); + const [tab, setTab] = useState("overview"); + const tr = useCallback((zh: string, en: string) => (lang === "zh" ? zh : en), [lang]); - const showYoutube = ent && isYoutubeScript(script); - const showUrl = ent && (isYoutubeScript(script) || isTiktokScript(script)); - const showObs = ent && isObsScript(script); + const [booting, setBooting] = useState(true); + const [error, setError] = useState(null); + const [toast, setToast] = useState(null); + const [busy, setBusy] = useState(null); - const showToast = useCallback((msg: string) => { - setToast(msg); - setTimeout(() => setToast(null), 3200); + const [entries, setEntries] = useState([]); + const [currentProcess, setCurrentProcess] = useState(""); + const [processStatus, setProcessStatus] = useState("unknown"); + const [recentLog, setRecentLog] = useState(""); + const [recentError, setRecentError] = useState(""); + + const [stack, setStack] = useState(null); + const [services, setServices] = useState([]); + const [snapshot, setSnapshot] = useState(null); + const [hardware, setHardware] = useState(null); + + const [roots, setRoots] = useState([]); + const [rootId, setRootId] = useState(""); + const [files, setFiles] = useState([]); + const [filePath, setFilePath] = useState(""); + const [fileContent, setFileContent] = useState(""); + + const [androidAvailable, setAndroidAvailable] = useState(false); + const [androidDevices, setAndroidDevices] = useState([]); + const [androidSerial, setAndroidSerial] = useState(""); + const [androidOverview, setAndroidOverview] = useState(null); + const [androidNodes, setAndroidNodes] = useState([]); + const [androidPackages, setAndroidPackages] = useState([]); + const [androidPackageQuery, setAndroidPackageQuery] = useState(""); + const [androidText, setAndroidText] = useState(""); + const [androidPackage, setAndroidPackage] = useState("com.zhiliaoapp.musically"); + const [androidUrl, setAndroidUrl] = useState("https://www.tiktok.com"); + const [androidShell, setAndroidShell] = useState(""); + const [androidOutput, setAndroidOutput] = useState(""); + const [shotNonce, setShotNonce] = useState(Date.now()); + const [previewMode, setPreviewMode] = useState("tap"); + const [previewPoint, setPreviewPoint] = useState<{ x: number; y: number } | null>(null); + const [fileImportKey, setFileImportKey] = useState(0); + + const currentRoot = roots.find((item) => item.root_id === rootId) ?? null; + const shellRoots = roots.filter((item) => SHELLCRASH_ROOT_IDS.has(item.root_id)); + const otherRoots = roots.filter((item) => !SHELLCRASH_ROOT_IDS.has(item.root_id)); + const shellcrashService = services.find((item) => item.service_id === "shellcrash") ?? null; + const androidService = services.find((item) => item.service_id === "android_panel") ?? null; + const rawAndroidPanelUrl = + androidService?.host && androidService.port + ? `http://${androidService.host}:${androidService.port}` + : stack?.mdns_host + ? `http://${stack.mdns_host}:5000` + : ""; + const androidResolution = parseResolution(androidOverview?.resolution); + + const notify = useCallback((message: string) => { + setToast(message); + window.setTimeout(() => setToast(null), 2500); }, []); const fetchJson = useCallback( - async (path: string) => { - const res = await fetch(`${base}${path}`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.json(); + async (path: string, init?: RequestInit): Promise => { + const response = await fetch(`${base}${path}`, init); + const data = (await response.json()) as T & { error?: string }; + if (!response.ok) { + throw new Error(data.error || `HTTP ${response.status}`); + } + return data; }, [base], ); - const refreshBackendInfo = useCallback(async () => { - try { - const info = await fetchJson("/server_info?refresh=true"); - setBackend(info.process_backend || ""); - setPlatform(info.platform || ""); - showToast( - info.process_backend === "pm2" - ? "已切换为 PM2 管理" - : "已切换为本地进程模式(未检测到 PM2)", - ); - } catch { - showToast("刷新后端信息失败"); + const syncUrl = useCallback((nextTab: TabKey, nextLang: Lang) => { + const url = new URL(window.location.href); + url.pathname = tabPath(nextTab); + if (url.pathname === "/") { + url.searchParams.set("tab", nextTab); + } else { + url.searchParams.delete("tab"); } - }, [fetchJson, showToast]); + url.searchParams.set("lang", nextLang); + window.history.replaceState(null, "", url.toString()); + window.localStorage.setItem("live-console-lang", nextLang); + }, []); - useEffect(() => { - (async () => { - setBootLoading(true); - setLoadError(null); - try { - const [mon, info] = await Promise.all([ - fetchJson("/process_monitor"), - fetchJson("/server_info"), - ]); - const list: Entry[] = (mon.entries || []).map((e: Entry) => ({ - script: e.script, - label: e.label || e.script, - pm2: e.pm2 || stem(e.script), - })); - setEntries(list); - setBackend(info.process_backend || ""); - setPlatform(info.platform || ""); - setCurrent((prev) => prev || list[0]?.pm2 || ""); - } catch { - setLoadError( - "连不上后端。请确认已运行 web.sh / web.bat,并用浏览器打开同一地址(开发时可先起 uvicorn 再 npm run dev)。", - ); - } finally { - setBootLoading(false); - } - })(); + const refreshProcess = useCallback(async () => { + if (!currentProcess) return; + const data = await fetchJson( + `/status?process=${encodeURIComponent(currentProcess)}`, + ); + setProcessStatus(data.process_status || "unknown"); + setRecentLog(data.recent_log || ""); + setRecentError(data.recent_error || ""); + }, [currentProcess, fetchJson]); + + const refreshServices = useCallback(async () => { + const [serviceData, snapshotData, hardwareData] = await Promise.all([ + fetchJson<{ services?: ServiceItem[] }>("/stack/services"), + fetchJson("/system_snapshot"), + fetchJson("/hardware_profile"), + ]); + setServices(serviceData.services || []); + setSnapshot(snapshotData); + setHardware(hardwareData); }, [fetchJson]); - const refreshStatus = useCallback(async () => { - if (!current) return; - try { - const data = await fetchJson(`/status?process=${encodeURIComponent(current)}`); - setProcessStatus(data.process_status || "unknown"); - setRawStatus(data.raw_status || ""); - setRecentLog(data.recent_log || ""); - setRecentErr(data.recent_error || ""); - } catch { - setProcessStatus("unknown"); - } - }, [current, fetchJson]); - - useEffect(() => { - if (!current || !autoRefresh) return; - const t = setInterval(refreshStatus, 1000); - refreshStatus(); - return () => clearInterval(t); - }, [current, autoRefresh, refreshStatus]); - - useEffect(() => { - if (current && !autoRefresh) void refreshStatus(); - }, [current, autoRefresh, refreshStatus]); - - const loadYoutube = useCallback(async () => { - if (!current) return; - setCfgYoutubeStatus("加载中…"); - try { - const data = await fetchJson(`/get_config?process=${encodeURIComponent(current)}`); - setYoutubeIni(data.content || ""); - setCfgYoutubeStatus("已从服务器加载"); - } catch (e) { - setCfgYoutubeStatus(`失败: ${(e as Error).message}`); - } - }, [current, fetchJson]); - - const saveYoutube = async () => { - if (!current) return; - setCfgYoutubeStatus("保存中…"); - try { - const res = await fetch( - `${base}/save_config?process=${encodeURIComponent(current)}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content: youtubeIni }), - }, + const refreshFiles = useCallback( + async (selectedRoot: string, preferredPath?: string) => { + if (!selectedRoot) return; + const data = await fetchJson<{ files?: ManagedFile[] }>( + `/managed_files?root=${encodeURIComponent(selectedRoot)}`, ); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || res.statusText); - setCfgYoutubeStatus(data.message || "已保存到服务器"); - showToast("youtube.ini 已保存"); - } catch (e) { - setCfgYoutubeStatus(`失败: ${(e as Error).message}`); - } - }; + const nextFiles = data.files || []; + setFiles(nextFiles); + const expected = preferredPath || filePath; + if (expected && nextFiles.some((item) => item.path === expected)) { + setFilePath(expected); + } else { + setFilePath(nextFiles[0]?.path || ""); + } + }, + [fetchJson, filePath], + ); - const loadUrl = useCallback(async () => { - if (!current) return; - setCfgUrlStatus("加载中…"); - try { - const data = await fetchJson(`/get_url_config?process=${encodeURIComponent(current)}`); - setUrlIni(data.content || ""); - setCfgUrlStatus("已从服务器加载"); - } catch (e) { - setCfgUrlStatus(`失败: ${(e as Error).message}`); - } - }, [current, fetchJson]); + const refreshAndroidDevices = useCallback(async () => { + const data = await fetchJson<{ available?: boolean; devices?: AndroidDevice[] }>("/android/devices"); + const nextDevices = data.devices || []; + const nextSerial = + androidSerial && nextDevices.some((item) => item.serial === androidSerial) + ? androidSerial + : nextDevices[0]?.serial || ""; + setAndroidAvailable(Boolean(data.available)); + setAndroidDevices(nextDevices); + setAndroidSerial(nextSerial); + return nextSerial; + }, [androidSerial, fetchJson]); - const saveUrl = async () => { - if (!current) return; - setCfgUrlStatus("保存中…"); - try { - const res = await fetch( - `${base}/save_url_config?process=${encodeURIComponent(current)}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content: urlIni }), - }, - ); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || res.statusText); - setCfgUrlStatus(data.message || "已保存到服务器"); - showToast("URL 配置已保存"); - } catch (e) { - setCfgUrlStatus(`失败: ${(e as Error).message}`); - } - }; - - useEffect(() => { - if (!current) return; - if (showYoutube) void loadYoutube(); - if (showUrl) void loadUrl(); - }, [current, showYoutube, showUrl, loadYoutube, loadUrl]); - - const runAction = async (action: string) => { - if (!current) return; - setBusy(action); - try { - const res = await fetch(`${base}/${action}?process=${encodeURIComponent(current)}`); - const data = await res.json(); - if (!res.ok) { - showToast(String(data.output || data.message || "操作失败")); + const refreshAndroidOverview = useCallback( + async (serial: string) => { + if (!serial) { + setAndroidOverview(null); return; } - if (action !== "status") { - const msg = - data.output ?? data.pm2_output ?? data.message ?? "已完成"; - showToast(typeof msg === "string" ? msg : JSON.stringify(msg)); - setTimeout(refreshStatus, 1200); - } else { - setProcessStatus(data.process_status || "unknown"); - setRawStatus(data.raw_status || ""); - setRecentLog(data.recent_log || ""); - setRecentErr(data.recent_error || ""); + const data = await fetchJson( + `/android/overview?serial=${encodeURIComponent(serial)}`, + ); + setAndroidOverview(data); + }, + [fetchJson], + ); + + const refreshAndroidPackages = useCallback( + async (serial: string, query = androidPackageQuery) => { + if (!serial) { + setAndroidPackages([]); + return; } - } catch (e) { - showToast((e as Error).message); + const data = await fetchJson<{ packages?: AndroidPackage[] }>( + `/android/packages?serial=${encodeURIComponent(serial)}&query=${encodeURIComponent(query)}&limit=60`, + ); + setAndroidPackages(data.packages || []); + }, + [androidPackageQuery, fetchJson], + ); + + const refreshAndroidUi = useCallback( + async (serial: string) => { + if (!serial) { + setAndroidNodes([]); + return; + } + const data = await fetchJson<{ nodes?: AndroidNode[] }>( + `/android/ui?serial=${encodeURIComponent(serial)}&limit=80`, + ); + setAndroidNodes(data.nodes || []); + }, + [fetchJson], + ); + + const openManagedFile = useCallback( + async (nextRoot: string, nextPath: string) => { + setRootId(nextRoot); + setFilePath(nextPath); + await refreshFiles(nextRoot, nextPath); + }, + [refreshFiles], + ); + + const boot = useCallback(async () => { + setBooting(true); + setError(null); + try { + const [monitor, info, managed] = await Promise.all([ + fetchJson<{ entries?: ProcessEntry[] }>("/process_monitor"), + fetchJson<{ stack?: StackSummary }>("/server_info"), + fetchJson<{ roots?: ManagedRoot[] }>("/managed_roots"), + ]); + setEntries(monitor.entries || []); + setCurrentProcess(monitor.entries?.[0]?.pm2 || ""); + setStack(info.stack || null); + setRoots(managed.roots || []); + setRootId(managed.roots?.[0]?.root_id || ""); + await Promise.all([refreshServices(), refreshAndroidDevices()]); + } catch (reason) { + setError((reason as Error).message); + } finally { + setBooting(false); + } + }, [fetchJson, refreshAndroidDevices, refreshServices]); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const pathTab = pathnameToTab(window.location.pathname); + const nextTab = params.get("tab"); + const nextLang = params.get("lang"); + const storedLang = window.localStorage.getItem("live-console-lang"); + if (pathTab) setTab(pathTab); + else if (isTabKey(nextTab)) setTab(nextTab); + if (nextLang === "zh" || nextLang === "en") setLang(nextLang); + else if (storedLang === "zh" || storedLang === "en") setLang(storedLang); + }, []); + + useEffect(() => { + syncUrl(tab, lang); + }, [lang, syncUrl, tab]); + + useEffect(() => { + void boot(); + }, [boot]); + + useEffect(() => { + if (!currentProcess) return; + void refreshProcess(); + const timer = window.setInterval(() => void refreshProcess(), 1500); + return () => window.clearInterval(timer); + }, [currentProcess, refreshProcess]); + + useEffect(() => { + const visibleRoots = tab === "shellcrash" ? shellRoots : tab === "config" ? otherRoots : roots; + if (!visibleRoots.length) return; + if (!visibleRoots.some((item) => item.root_id === rootId)) { + setRootId(visibleRoots[0].root_id); + setFilePath(""); + } + }, [otherRoots, rootId, roots, shellRoots, tab]); + + useEffect(() => { + if (!rootId) return; + void refreshFiles(rootId); + }, [refreshFiles, rootId]); + + useEffect(() => { + if (!rootId || !filePath) { + setFileContent(""); + return; + } + (async () => { + try { + const data = await fetchJson<{ content?: string }>( + `/managed_file?root=${encodeURIComponent(rootId)}&path=${encodeURIComponent(filePath)}`, + ); + setFileContent(data.content || ""); + } catch (reason) { + notify((reason as Error).message); + } + })(); + }, [fetchJson, filePath, notify, rootId]); + + useEffect(() => { + if (tab !== "android") return; + void (async () => { + const serial = await refreshAndroidDevices(); + if (serial) { + await Promise.all([ + refreshAndroidOverview(serial), + refreshAndroidUi(serial), + refreshAndroidPackages(serial), + ]); + setShotNonce(Date.now()); + } + })(); + }, [refreshAndroidDevices, refreshAndroidOverview, refreshAndroidPackages, refreshAndroidUi, tab]); + + useEffect(() => { + if (tab !== "android" || !androidSerial) return; + const timer = window.setInterval(() => { + void refreshAndroidOverview(androidSerial); + setShotNonce(Date.now()); + }, 5000); + return () => window.clearInterval(timer); + }, [androidSerial, refreshAndroidOverview, tab]); + + const processAction = async (action: "start" | "restart" | "stop") => { + if (!currentProcess) return; + setBusy(`process:${action}`); + try { + const data = await fetchJson( + `/${action}?process=${encodeURIComponent(currentProcess)}`, + ); + notify(String(data.output || data.pm2_output || data.message || "OK")); + await refreshProcess(); + } catch (reason) { + notify((reason as Error).message); } finally { setBusy(null); } }; - const onStopClick = () => { - if (isWebScript(script)) { - showToast("这是控制台进程,停止后网页会失效,请谨慎操作"); - } - setConfirmStop(true); - }; - - const confirmStopRun = async () => { - setConfirmStop(false); - await runAction("stop"); - }; - - const copyObs = async () => { + const serviceAction = async (serviceId: string, action: "start" | "restart" | "stop") => { + setBusy(`${serviceId}:${action}`); try { - await navigator.clipboard.writeText(obsDefault); - showToast("已复制到剪贴板"); - } catch { - showToast("复制失败,请手动选中文字复制"); + const data = await fetchJson( + `/service_action?service=${encodeURIComponent(serviceId)}&action=${encodeURIComponent(action)}`, + ); + notify(data.message || "OK"); + await refreshServices(); + } catch (reason) { + notify((reason as Error).message); + } finally { + setBusy(null); } }; - const st = statusMeta(processStatus); + const saveFile = async () => { + if (!rootId || !filePath) return; + setBusy("file:save"); + try { + await fetchJson("/managed_file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ root: rootId, path: filePath, content: fileContent }), + }); + notify(tr("文件已保存", "File saved")); + await refreshFiles(rootId, filePath); + } catch (reason) { + notify((reason as Error).message); + } finally { + setBusy(null); + } + }; + + const createFile = async () => { + if (!currentRoot?.allow_create) { + notify(tr("当前目录不允许新建文件", "The current root does not allow new files")); + return; + } + const nextPath = window.prompt(tr("请输入新的相对路径", "Enter a new relative path"), currentRoot.default_file || ""); + if (!nextPath) return; + setBusy("file:create"); + try { + await fetchJson("/managed_file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ root: rootId, path: nextPath, content: "" }), + }); + notify(tr("文件已创建", "File created")); + await refreshFiles(rootId, nextPath); + } catch (reason) { + notify((reason as Error).message); + } finally { + setBusy(null); + } + }; + + const deleteFile = async () => { + if (!rootId || !filePath) return; + if (!currentRoot?.allow_delete) { + notify(tr("当前目录不允许删除文件", "The current root does not allow deletion")); + return; + } + if (!window.confirm(tr("确定删除该文件?", "Delete this file?"))) return; + setBusy("file:delete"); + try { + await fetchJson( + `/managed_file?root=${encodeURIComponent(rootId)}&path=${encodeURIComponent(filePath)}`, + { method: "DELETE" }, + ); + notify(tr("文件已删除", "File deleted")); + await refreshFiles(rootId); + } catch (reason) { + notify((reason as Error).message); + } finally { + setBusy(null); + } + }; + + const importManagedFile = async (event: ChangeEvent) => { + const upload = event.target.files?.[0]; + event.target.value = ""; + setFileImportKey((value) => value + 1); + if (!upload || !rootId) return; + + setBusy("file:import"); + try { + const content = await upload.text(); + await fetchJson("/managed_file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ root: rootId, path: upload.name, content }), + }); + notify(tr("导入完成,已替换同名文件", "Import finished and replaced matching file")); + setFilePath(upload.name); + await refreshFiles(rootId, upload.name); + } catch (reason) { + notify((reason as Error).message); + } finally { + setBusy(null); + } + }; + + const sendAndroidAction = async (action: string, payload?: Record) => { + if (!androidSerial) { + notify(tr("当前没有可用的 ADB 设备", "No active ADB device")); + return; + } + setBusy(`android:${action}`); + try { + const data = await fetchJson<{ message?: string; output?: string }>("/android/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, serial: androidSerial, payload: payload || {} }), + }); + notify(data.message || "OK"); + if (data.output) setAndroidOutput(data.output); + setShotNonce(Date.now()); + await refreshAndroidOverview(androidSerial); + } catch (reason) { + notify((reason as Error).message); + } finally { + setBusy(null); + } + }; + + const previewActionLabel = + previewMode === "tap" + ? tr("点击", "Tap") + : previewMode === "double_tap" + ? tr("双击", "Double Tap") + : tr("长按", "Long Press"); + + const handlePreviewClick = async (event: MouseEvent) => { + if (!androidSerial) { + notify(tr("当前没有可用的 ADB 设备", "No active ADB device")); + return; + } + const rect = event.currentTarget.getBoundingClientRect(); + const sourceWidth = androidResolution?.width || event.currentTarget.naturalWidth || rect.width; + const sourceHeight = androidResolution?.height || event.currentTarget.naturalHeight || rect.height; + const x = Math.max( + 0, + Math.min(sourceWidth, Math.round((event.clientX - rect.left) * (sourceWidth / rect.width))), + ); + const y = Math.max( + 0, + Math.min(sourceHeight, Math.round((event.clientY - rect.top) * (sourceHeight / rect.height))), + ); + setPreviewPoint({ x, y }); + if (previewMode === "long_press") { + await sendAndroidAction("long_press", { x, y, duration: 750 }); + return; + } + await sendAndroidAction(previewMode, { x, y }); + }; + + const serviceHref = (item: ServiceItem) => { + if (item.service_id === "shellcrash" && stack?.control_url) { + return `${stack.control_url}/shellcrash?lang=${lang}`; + } + if (item.service_id === "android_panel" && stack?.control_url) { + return `${stack.control_url}/android?lang=${lang}`; + } + return item.url || ""; + }; + + const statusText = (status: string) => + ({ + online: tr("在线", "Online"), + stopped: tr("已停止", "Stopped"), + configured: tr("已配置", "Configured"), + unavailable: tr("未安装", "Unavailable"), + }[status] || status); + + const activeRoots = tab === "shellcrash" ? shellRoots : otherRoots; return ( -
- {toast && ( -
+
+ {toast ? ( +
{toast}
- )} + ) : null} - {confirmStop && ( -
-
-

- 确认停止该进程? -

-

- 停止后推流或播放会中断。若进程卡住,停止后系统还会尝试结束相关 ffmpeg。 +

+
+
+

+ {tr("超级业务中心 / 模块控制台", "Super Hub / Control Plane")}

- {ent && ( -

- {ent.label} · {ent.script} -

- )} -
- - +

+ {tr("Linux 超级业务中心", "Linux Super Business Center")} +

+

+ {tr( + "系统服务、业务模块、ShellCrash 配置与安卓控制保持解耦,一个模块异常不应拖垮整个平台。", + "Services, business modules, ShellCrash configs and Android control stay decoupled.", + )} +

+
+
+
+ {(stack?.mdns_host || "live.local") + + " | " + + (stack?.machine || "unknown") + + " | " + + (stack?.kernel || "unknown")}
+ + +
- )} -
-
-

- 直播推流 · 小白友好 -

-

- 控制台 -

-

- 选一个程序 → 需要时先改配置并保存 → 点「启动」。下面有图文说明。看不懂日志时,把「错误输出」截图发给技术支持。 -

-
-
-
- {platform && ( - - 系统 {platform} - - )} - {backend && ( - - {backend === "pm2" - ? "PM2 管理" - : backend === "local" - ? "本地进程(无 PM2)" - : backend} - - )} -
- -
+
-
- - {helpOpen && ( -
-
-
-

- 1 -

-

打开本页

-

- Windows / Ubuntu 共用同一套代码:git pull 后双击/运行{" "} - start.bat 或{" "} - ./start.sh(生产),开发双端用{" "} - dev.bat /{" "} - ./dev.sh。启动时会自动检测 Python、ffmpeg、npm 等。 - 端口与同机 SRS、代理冲突时,复制{" "} - config/runtime.env.example 为{" "} - config/runtime.env 修改{" "} - PORT。Docker 默认 8101。 -

-
-
-

- 2 -

-

选进程、改配置

-

- 左侧下拉框默认首选 YouTube / 抖音→YouTube - ,可切换到 TikTok、OBS 等。需要密钥、直播间地址时,在右侧编辑后点「保存到服务器」再启动。 -

-
-
-

- 3 -

-

启动并看日志

-

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

-
-
-

- HDMI / OBS: - 需本机已装 SRS(拉流端口多为 10080 等,与控制台 Web 端口不是同一个)。显示器接好后,OBS - 选「自定义」推流,服务器填下方 SRT 地址,密钥留空。 -

-

- 局域网访问: - 生产模式默认绑定 0.0.0.0,同一 WiFi/内网下用手机或电脑浏览器访问{" "} - http://这台机器的IP:端口/ 即可(注意防火墙放行端口)。 -

-
- )} -
- - {bootLoading && ( -
-
-

正在连接服务器…

+ {booting ? ( +
+ {tr("正在连接控制台...", "Connecting to the control plane...")}
- )} + ) : null} - {!bootLoading && loadError && ( -
-

无法连接

-

- {loadError} -

- + {!booting && error ? ( +
+ {error}
- )} + ) : null} - {!bootLoading && !loadError && !entries.length && ( -
-

没有找到可管理的脚本

-

- 项目根目录应有 tiktok*.py、youtube*.py、douyin_youtube*.py 或 obs 脚本(Linux 用 .sh,Windows 用 .bat)。 -

-
- )} + {!booting && !error ? ( +
+ {tab === "overview" ? ( + <> +
+ - {!bootLoading && !loadError && entries.length > 0 && ( -
-
-
- -
-
- - {st.text} - - {ent && ( - - {ent.pm2} - +
+

{tr("模块概况", "Module Summary")}

+
+ {services.map((item) => ( +
+
+
+

{item.label}

+

{item.description}

+
+ {statusText(item.status)} +
+

{item.detail || "-"}

+ {serviceHref(item) ? ( + {tr("打开", "Open")} + ) : null} +
+ ))} +
+
+ + ) : null} + + {tab === "business" ? ( +
+
+

{tr("业务运行时", "Business Runtime")}

+

{tr("这里只放业务脚本本身,Hub 基座可单独运行。", "Only business processes live here.")}

+ {entries.length > 0 ? ( + <> + +
+ {statusText(processStatus)} + +
+
+ + + +
+ + ) : ( +
+ {tr("未发现业务进程。若只部署了 Hub 基座,这是正常状态。", "No business process discovered.")} +
+ )} +
+
+

{tr("业务日志", "Process Logs")}

+
+
{recentLog || tr("暂无输出", "No stdout yet.")}
+
{recentError || tr("暂无报错", "No stderr yet.")}
+
+
+
+ ) : null} + + {tab === "modules" ? ( +
+
+
+
+

{tr("系统模块", "System Modules")}

+

{tr("每个模块都独立启停和降级,避免一个服务异常拖垮整套业务。", "Each module starts and degrades independently.")}

+
+ +
+
+ {services.map((item) => ( +
+
+
+

{item.label}

+

{item.description}

+
+ {statusText(item.status)} +
+

{item.detail || "-"}

+
+ + + + {serviceHref(item) ? {tr("打开", "Open")} : null} +
+
+ ))} +
+
+
+

{tr("硬件与系统", "Hardware and System")}

+
+

Arch: {hardware?.arch || "-"}

+

FFmpeg: {(hardware?.ffmpeg_hwaccels || []).join(", ") || "-"}

+

GPU: {hardware?.gpu?.present ? hardware.gpu.driver || "present" : "none"}

+

NPU: {hardware?.npu?.present ? (hardware.npu.devices || []).join(", ") : "none"}

+

Decoder: {hardware?.recommendation?.video_decoder || "cpu"}

+

HDMI: {hardware?.recommendation?.hdmi_player || "cpu"}

+

Mode: {hardware?.recommendation?.stability_mode || "safe"}

+
+
+
+ ) : null} + + {tab === "shellcrash" ? ( +
+
+

ShellCrash

+

{tr("这里不再依赖命令行菜单,直接做 YAML / JSON / CFG 编辑与服务控制。", "Manage ShellCrash from the web instead of the shell menu.")}

+ {shellcrashService ? ( +
+
+
+

{shellcrashService.label}

+

{shellcrashService.description}

+
+ {statusText(shellcrashService.status)} +
+

{shellcrashService.detail || "-"}

+
+ + + +
+
+ ) : null} +
+

{tr("快捷文件", "Quick Files")}

+
+ + + + +
+
+
+
+
+
+

{tr("ShellCrash 文件编辑器", "ShellCrash Editor")}

+

{tr("支持查看、修改、新建、删除,路径被限制在 ShellCrash 托管目录内。", "View, edit, create and delete files inside managed ShellCrash roots.")}

+
+
+ + +
+
+
+
+ +
+ + +
+
+ {files.map((item) => ( + + ))} +
+
+
+
{filePath ? `${rootId} / ${filePath}` : tr("请选择文件", "Pick a file")}
+