From e3ac0267106ea62c3ac8feff713467e6274fd0bc Mon Sep 17 00:00:00 2001 From: eric Date: Sun, 29 Mar 2026 02:57:08 -0500 Subject: [PATCH] 's' --- .../config/defaults/services/neko.json | 21 -- src/control_plane.py | 34 +- src/hub_routes.py | 17 +- src/scrcpy_stream.py | 197 +++++++++++ tools/deploy_live_paramiko.py | 3 + vendor/scrcpy-server.version | 1 + .../console/LiveConsoleWorkspace.tsx | 21 +- .../live-product/LiveAndroidStreamConsole.tsx | 309 ++++++++++++++++-- .../live-product/LiveControlApp.tsx | 84 +---- .../live-product/LiveTiktokHdmiView.tsx | 35 +- .../live-product/LiveYoutubeUnmannedView.tsx | 20 +- web-console/lib/androidScrcpyWs.ts | 28 ++ web-console/lib/scrcpyH264Decoder.ts | 169 ++++++++++ web-console/lib/youtubeProChannels.ts | 4 +- web.py | 78 ++++- 15 files changed, 799 insertions(+), 222 deletions(-) delete mode 100644 live-platform/config/defaults/services/neko.json create mode 100644 src/scrcpy_stream.py create mode 100644 vendor/scrcpy-server.version create mode 100644 web-console/lib/androidScrcpyWs.ts create mode 100644 web-console/lib/scrcpyH264Decoder.ts diff --git a/live-platform/config/defaults/services/neko.json b/live-platform/config/defaults/services/neko.json deleted file mode 100644 index 5a9b153..0000000 --- a/live-platform/config/defaults/services/neko.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "id": "neko", - "layer": "infra", - "enabled": true, - "manager": "docker", - "ports": { "http": 9200 }, - "env": { - "ENABLE_NEKO": "1", - "NEKO_HTTP_PORT": "9200", - "NEKO_USER_PASS": "12345678", - "NEKO_ADMIN_PASS": "12345678", - "NEKO_PROFILE_HOST_DIR": "", - "NEKO_SHM_SIZE": "2gb", - "NEKO_DESKTOP_SCREEN": "1920x1080@30", - "NEKO_WEBRTC_EPR": "52000-52100", - "NEKO_WEBRTC_UDP_RANGE": "52000-52100", - "NEKO_WEBRTC_ICELITE": "1", - "NEKO_WEBRTC_NAT1TO1": "", - "NEKO_PLUGINS_ENABLED": "true" - } -} diff --git a/src/control_plane.py b/src/control_plane.py index 131ea83..93d1892 100644 --- a/src/control_plane.py +++ b/src/control_plane.py @@ -89,12 +89,6 @@ SERVICE_SPECS: tuple[ServiceSpec, ...] = ( "Browser ADB control (ws-scrcpy stack; clone URL configurable in system-stack.env)", ), ServiceSpec("chromium", "Chromium 浏览器", "browser", "本机 Chromium + 持久化配置 + Remote DevTools(非 Docker)"), - ServiceSpec( - "neko", - "Neko 浏览器 (Docker)", - "browser", - "可选:Docker 内嵌 Chromium 桌面,适合远程开 YouTube Studio;ENABLE_NEKO=1 时安装", - ), ServiceSpec( "android_gateway", "Android Gateway", @@ -572,11 +566,10 @@ def recent_pm2_log_lines(max_lines: int = 14) -> list[str]: async def stack_stream_probes() -> dict[str, Any]: - """本机 SRS / Neko HTTP 端口探测(供 live.local 验收)。""" + """本机 SRS HTTP 端口探测(供 live.local 验收)。""" env = load_stack_env() srs_p = int(env.get("SRS_HTTP_PORT", "8080") or 8080) srs_api_p = int(env.get("SRS_API_PORT", "1985") or 1985) - neko_p = int(env.get("NEKO_HTTP_PORT", "9200") or 9200) loop = asyncio.get_event_loop() def run_srs() -> dict[str, Any]: @@ -596,16 +589,8 @@ async def stack_stream_probes() -> dict[str, Any]: "api_ok": api_ok, } - def run_neko() -> dict[str, Any]: - tcp = _probe_tcp_port("127.0.0.1", neko_p) - http = _probe_http_get(f"http://127.0.0.1:{neko_p}/", timeout=2.5) - return {"port": neko_p, "tcp": tcp, "http": http} - - srs_d, neko_d = await asyncio.gather( - loop.run_in_executor(None, run_srs), - loop.run_in_executor(None, run_neko), - ) - return {"srs": srs_d, "neko": neko_d} + srs_d = await loop.run_in_executor(None, run_srs) + return {"srs": srs_d} def stack_summary() -> dict: @@ -621,7 +606,6 @@ def stack_summary() -> dict: srs_api = env.get("SRS_API_PORT", "1985") android_panel = env.get("ANDROID_PANEL_PORT", "5000") chrome_dbg = env.get("BROWSER_REMOTE_DEBUG_PORT", "9222") - neko_port = env.get("NEKO_HTTP_PORT", "9200") ql: dict[str, str] = { "control_plane": f"http://{mdns_host}:{web_port}", "webtty": f"http://{mdns_host}:{webtty}", @@ -631,7 +615,6 @@ def stack_summary() -> dict: "srs_api": f"http://{mdns_host}:{srs_api}/api/v1/versions", "ws_scrcpy": f"http://{mdns_host}:{android_panel}", "chromium_remote_debug": f"http://{mdns_host}:{chrome_dbg}", - "neko_browser": f"http://{mdns_host}:{neko_port}", } return { "hostname": hostname, @@ -644,17 +627,6 @@ def stack_summary() -> dict: "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(), - "neko_login": { - "user_login": "live", - "user_password": env.get("NEKO_USER_PASS", "12345678"), - "admin_login": "admin", - "admin_password": env.get("NEKO_ADMIN_PASS", "12345678"), - "note_zh": ( - "Neko 为 multiuser 模式:「显示昵称」可任意填写(如 live);「密码」必须与 NEKO_USER_PASS / NEKO_ADMIN_PASS " - "完全一致(默认 12345678)。不要把 live/12345678 填进同一格。密码错误时会一直卡在连接中。" - " WebRTC 需 NEKO_WEBRTC_NAT1TO1 为板子局域网 IP,并放行 UDP 端口段。" - ), - }, "quick_links": ql, } diff --git a/src/hub_routes.py b/src/hub_routes.py index 021e1e5..b6e6035 100644 --- a/src/hub_routes.py +++ b/src/hub_routes.py @@ -49,19 +49,24 @@ class BusinessDouyinPatch(BaseModel): class YoutubeProChannelsBody(BaseModel): channels: list[dict[str, Any]] = Field(default_factory=list) - @field_validator("channels") + @field_validator("channels", mode="after") @classmethod - def _validate_channels(cls, v: list[Any]) -> list[Any]: + def _validate_channels(cls, v: list[Any]) -> list[dict[str, Any]]: if len(v) > 64: raise ValueError("at most 64 channels") + out: list[dict[str, Any]] = [] for item in v: if not isinstance(item, dict): raise ValueError("each channel must be an object") - if not str(item.get("id", "")).strip(): + cid = str(item.get("id", "")).strip() + if not cid: raise ValueError("channel.id required") - if not str(item.get("name", "")).strip(): - raise ValueError("channel.name required") - return v + row = dict(item) + row["id"] = cid + nm = str(row.get("name", "")).strip() + row["name"] = nm if nm else cid + out.append(row) + return out @router.get("/dashboard") diff --git a/src/scrcpy_stream.py b/src/scrcpy_stream.py new file mode 100644 index 0000000..42d7da5 --- /dev/null +++ b/src/scrcpy_stream.py @@ -0,0 +1,197 @@ +"""在宿主上拉起官方 scrcpy-server(raw_stream),经 ADB forward 读 H.264,再推到 WebSocket。 + +与桌面端 [escrcpy](https://github.com/viarotel-org/escrcpy) / [scrcpy](https://github.com/Genymobile/scrcpy) +使用同一套 scrcpy-server;版本号须与 jar 内 BuildConfig 完全一致(见官方 doc/develop.md)。 +""" + +from __future__ import annotations + +import asyncio +import json +import os +import random +import re +import shutil +import socket +from pathlib import Path +from typing import Any + +from src.android_control import _adb_prefix, _adb_text, _run_cmd, adb_available + +BASE_DIR = Path(__file__).resolve().parent.parent +REMOTE_JAR = "/data/local/tmp/scrcpy-web-hub.jar" +_VENDOR_JAR = BASE_DIR / "vendor" / "scrcpy-server.jar" +_VERSION_FILE = BASE_DIR / "vendor" / "scrcpy-server.version" + + +def default_server_version() -> str: + v = (os.environ.get("SCRCPY_SERVER_VERSION") or "").strip() + if v: + return v + try: + if _VERSION_FILE.is_file(): + line = _VERSION_FILE.read_text(encoding="utf-8").splitlines()[0].strip() + if line: + return line + except OSError: + pass + return "3.3.3" + + +def resolve_scrcpy_server_jar() -> Path | None: + env = (os.environ.get("SCRCPY_SERVER_JAR") or "").strip() + if env: + p = Path(env).expanduser() + if p.is_file(): + return p.resolve() + if _VENDOR_JAR.is_file(): + return _VENDOR_JAR.resolve() + exe = shutil.which("scrcpy") + if exe: + parent = Path(exe).resolve().parent + for name in ("scrcpy-server", "scrcpy-server.jar"): + cand = parent / name + if cand.is_file(): + return cand.resolve() + return None + + +def scrcpy_stream_info() -> dict[str, Any]: + jar = resolve_scrcpy_server_jar() + ver = default_server_version() + return { + "adb_ok": adb_available(), + "server_jar": jar is not None, + "server_path": str(jar) if jar else None, + "server_version": ver, + "remote_jar": REMOTE_JAR, + "hint_zh": "将官方 scrcpy 发行包中的 scrcpy-server(或 scrcpy-server.jar)放到项目 vendor/scrcpy-server.jar," + "或设置环境变量 SCRCPY_SERVER_JAR;版本字符串须与该 jar 完全一致(默认可配 vendor/scrcpy-server.version 或 SCRCPY_SERVER_VERSION)。", + "hint_en": "Place scrcpy-server from the official scrcpy release in vendor/scrcpy-server.jar, " + "or set SCRCPY_SERVER_JAR; the version string must match the jar (vendor/scrcpy-server.version or SCRCPY_SERVER_VERSION).", + } + + +_SERIAL_SAFE = re.compile(r"^[A-Za-z0-9._:\-]+$") + + +def _validate_serial(serial: str) -> str: + s = (serial or "").strip() + if not s or len(s) > 256 or not _SERIAL_SAFE.match(s): + raise ValueError("Invalid device serial") + return s + + +def _pick_local_port() -> int: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + s.bind(("127.0.0.1", 0)) + _host, port = s.getsockname() + return int(port) + finally: + s.close() + + +async def _adb_forward_add(serial: str, local_port: int, socket_suffix: str) -> None: + spec = f"tcp:{local_port}" + abstract = f"localabstract:scrcpy_{socket_suffix}" + code, _out, err = await _adb_text(serial, "forward", spec, abstract, timeout=25.0) + if code != 0: + raise RuntimeError(err or f"adb forward {spec} {abstract} failed") + + +async def _adb_forward_remove(local_port: int) -> None: + spec = f"tcp:{local_port}" + await _adb_text(None, "forward", "--remove", spec, timeout=15.0) + + +async def _push_server(serial: str, jar: Path) -> None: + code, _out, err = await _run_cmd( + _adb_prefix(serial) + ["push", str(jar), REMOTE_JAR], + timeout=120.0, + ) + if code != 0: + raise RuntimeError(err or "adb push scrcpy-server failed") + + +async def _start_server_process(serial: str, version: str, scid_hex: str, max_size: int) -> asyncio.subprocess.Process: + """在设备上后台启动 app_process;tunnel_forward 模式下等待宿主机连入 forward 端口。""" + # 与 DesktopConnection.getSocketName 一致:scrcpy_%08x + args_line = ( + f"{version} tunnel_forward=true audio=false control=false cleanup=false raw_stream=true " + f"max_size={max_size} scid={scid_hex}" + ) + # 使用 sh -c 与后台 &,避免阻塞 adb shell + inner = ( + f"CLASSPATH={REMOTE_JAR} /system/bin/app_process / com.genymobile.scrcpy.Server {args_line} " + ">/dev/null 2>&1 &" + ) + proc = await asyncio.create_subprocess_exec( + *_adb_prefix(serial), + "shell", + "sh", + "-c", + inner, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + _stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15.0) + rc = int(proc.returncode or 0) + if rc != 0: + msg = (stderr or b"").decode("utf-8", "replace").strip() + raise RuntimeError(msg or f"app_process start failed (code {rc})") + return proc + + +async def open_scrcpy_h264_stream( + serial: str, + *, + max_size: int = 1920, + connect_attempts: int = 12, + connect_delay: float = 0.22, +) -> tuple[int, str, asyncio.StreamReader, asyncio.StreamWriter]: + """建立 forward + 启动 server,并返回 (local_port, scid_hex, reader, writer)。""" + _validate_serial(serial) + if not adb_available(): + raise RuntimeError("adb is not installed") + + jar = resolve_scrcpy_server_jar() + if not jar: + raise RuntimeError( + "scrcpy-server not found; set SCRCPY_SERVER_JAR or add vendor/scrcpy-server.jar", + ) + + version = default_server_version() + scid = random.randint(0x1000_0000, 0x7FFF_FFFF) + scid_hex = f"{scid:08x}" + port = _pick_local_port() + + await _push_server(serial, jar) + await _adb_forward_add(serial, port, scid_hex) + await _start_server_process(serial, version, scid_hex, max(0, min(int(max_size), 8192))) + + last_err: str | None = None + for attempt in range(connect_attempts): + await asyncio.sleep(connect_delay) + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection("127.0.0.1", port), + timeout=4.0, + ) + return port, scid_hex, reader, writer + except (OSError, asyncio.TimeoutError) as exc: + last_err = str(exc) + continue + + await _adb_forward_remove(port) + raise RuntimeError(last_err or "Could not connect to scrcpy forward port") + + +async def cleanup_scrcpy_session(local_port: int, writer: asyncio.StreamWriter | None) -> None: + if writer is not None and not writer.is_closing(): + writer.close() + try: + await writer.wait_closed() + except OSError: + pass + await _adb_forward_remove(local_port) diff --git a/tools/deploy_live_paramiko.py b/tools/deploy_live_paramiko.py index 1654494..3f5dc79 100644 --- a/tools/deploy_live_paramiko.py +++ b/tools/deploy_live_paramiko.py @@ -44,6 +44,7 @@ FILES = [ "src/http_clients/async_http.py", "src/http_clients/sync_http.py", "src/android_control.py", + "src/scrcpy_stream.py", "src/async_thread_loop.py", "src/youtube_ini_sync.py", "src/control_plane.py", @@ -92,6 +93,8 @@ FILES = [ "web-console/components/live-product/LiveProcessLiveLogCard.tsx", "web-console/components/live-product/LiveTiktokHdmiView.tsx", "web-console/lib/panelUrls.ts", + "web-console/lib/androidScrcpyWs.ts", + "web-console/lib/scrcpyH264Decoder.ts", "web-console/lib/youtubeProChannels.ts", "web-console/lib/youtubeConfigUtils.ts", "web-console/components/console/LiveConsoleWorkspace.tsx", diff --git a/vendor/scrcpy-server.version b/vendor/scrcpy-server.version new file mode 100644 index 0000000..619b537 --- /dev/null +++ b/vendor/scrcpy-server.version @@ -0,0 +1 @@ +3.3.3 diff --git a/web-console/components/console/LiveConsoleWorkspace.tsx b/web-console/components/console/LiveConsoleWorkspace.tsx index 4031a42..b7cbc7b 100644 --- a/web-console/components/console/LiveConsoleWorkspace.tsx +++ b/web-console/components/console/LiveConsoleWorkspace.tsx @@ -870,8 +870,8 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:

{tr( - "ShellCrash 负责代理上网;本机 FFmpeg 处理推流或 HDMI 采集;Neko / Chromium 负责浏览器侧;开发板通过 USB 双头线 ADB 控制 AOSP,采集卡可走视频进 Linux。各模块独立,可分别启停。", - "ShellCrash for egress; FFmpeg for stream or HDMI; Neko/Chromium for browser; USB ADB to AOSP plus optional capture card. Modules are isolated.", + "ShellCrash 负责代理上网;本机 FFmpeg 处理推流或 HDMI;Chromium 负责浏览器侧;开发板通过 USB 双头线 ADB 控制 AOSP。各模块独立,可分别启停。", + "ShellCrash for egress; FFmpeg for stream or HDMI; Chromium for browser; USB ADB to AOSP. Modules are isolated.", )}

@@ -971,8 +971,8 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
  • {tr( - "「直播业务」里启停 PM2 脚本(抖音→YouTube、HDMI 等);「系统服务」看 ShellCrash、SRS、ws-scrcpy、Neko 等是否在跑。", - "Use Live ops for PM2 scripts; Services for ShellCrash, SRS, ws-scrcpy, Neko, etc.", + "「直播业务」里启停 PM2 脚本(抖音→YouTube、HDMI 等);「系统服务」看 ShellCrash、SRS、ws-scrcpy 等是否在跑。", + "Use Live ops for PM2 scripts; Services for ShellCrash, SRS, ws-scrcpy, etc.", )}
  • @@ -995,8 +995,8 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:

    {tr("🛰️ 流媒体与桌面入口", "Streaming & desktop")}

    {tr( - "ws-scrcpy:浏览器里控安卓;Chromium:本机+油猴+RemoteDebug;Neko:可选 Docker 远程桌面开 YouTube。", - "ws-scrcpy: browser ADB; Chromium: host+Tampermonkey+9222; Neko: optional Docker desktop.", + "ws-scrcpy:浏览器里控安卓;Chromium:本机+油猴+RemoteDebug。", + "ws-scrcpy: browser ADB; Chromium: host+Tampermonkey+9222.", )}

    @@ -1028,11 +1028,6 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }: 🧪 {tr("Chromium RemoteDebug (9222)", "Chromium RemoteDebug")} ) : null} - {qlNorm.neko_browser ? ( - - 🐱 Neko / {tr("Docker 浏览器桌面", "Docker browser desktop")} - - ) : null} {qlNorm.webtty ? ( ⌨️ WebTTY @@ -1501,8 +1496,8 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:

    {tr( - "内嵌若黑屏:多为 WebSocket 未连上或服务未启动。请先确认「系统服务」里 android_panel 在线,并尝试新标签打开;Neko 在 ENABLE_NEKO=1 且容器运行后一般为 9200 端口。", - "Black iframe: often WebSocket or service down. Check android_panel in Services, try opening in a new tab; Neko needs ENABLE_NEKO and Docker (often :9200).", + "内嵌若黑屏:多为 WebSocket 未连上或服务未启动。请先确认「系统服务」里 android_panel 在线,并尝试新标签打开。", + "Black iframe: often WebSocket or service down. Check android_panel in Services, try opening in a new tab.", )}