's'
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
197
src/scrcpy_stream.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user