This commit is contained in:
eric
2026-03-29 02:57:08 -05:00
parent ef87c43b33
commit e3ac026710
15 changed files with 799 additions and 222 deletions

View File

@@ -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 StudioENABLE_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,
}

View File

@@ -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")

197
src/scrcpy_stream.py Normal file
View File

@@ -0,0 +1,197 @@
"""在宿主上拉起官方 scrcpy-serverraw_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_processtunnel_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)