This commit is contained in:
eric
2026-03-28 13:27:53 -05:00
parent d34ecab19c
commit 8053c4cb6d
48 changed files with 3527 additions and 1433 deletions

View File

@@ -4,7 +4,9 @@ import asyncio
import json
import re
import shutil
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Optional
from urllib.parse import quote
@@ -293,13 +295,42 @@ async def android_ui_nodes(serial: str, limit: int = 80) -> list[dict]:
return nodes[: max(1, min(limit, 200))]
def _is_png(data: bytes) -> bool:
return bool(data and len(data) > 8 and data.startswith(b"\x89PNG\r\n\x1a\n"))
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")
if code == 0 and data:
normalized = data.replace(b"\r\n", b"\n")
if _is_png(data) or normalized.startswith(b"\x89PNG"):
return normalized
last_err = err or "exec-out screencap failed"
for remote in ("/data/local/tmp/live-cap.png", "/sdcard/live-cap.png"):
tmp = Path(tempfile.mkstemp(suffix=".png")[1])
try:
sh_code, _, sh_err = await _adb_text(serial, "shell", f"screencap -p {remote}", timeout=25.0)
if sh_code != 0:
last_err = sh_err or f"screencap to {remote} failed"
continue
pull_code, _, pull_err = await _run_cmd(
_adb_prefix(serial) + ["pull", remote, str(tmp)],
timeout=35.0,
)
if pull_code != 0:
last_err = pull_err or "adb pull failed"
continue
pulled = tmp.read_bytes()
if _is_png(pulled) or pulled.startswith(b"\x89PNG"):
return pulled.replace(b"\r\n", b"\n")
last_err = "Pulled file is not a valid PNG"
finally:
tmp.unlink(missing_ok=True)
raise RuntimeError(last_err)
def _normalize_text(text: str) -> str:
@@ -420,4 +451,12 @@ async def android_action(action: str, serial: str, payload: Optional[dict] = Non
output = await _shell(serial, f"pm clear '{package}'", timeout=25.0)
return {"message": f"Cleared app data: {package}", "output": output}
if action == "install_apk":
apk_path = str(payload.get("path", "")).strip()
if not apk_path or any(ch in apk_path for ch in ";$`&|()<>\n\r"):
raise ValueError("Invalid apk path on device (use adb push to /sdcard/ first)")
apk_path = apk_path.replace("'", "'\\''")
output = await _shell(serial, f"pm install -r -t '{apk_path}'", timeout=180.0)
return {"message": "APK install finished", "output": output}
raise ValueError(f"Unsupported Android action: {action}")

118
src/hub_routes.py Normal file
View File

@@ -0,0 +1,118 @@
"""Live Hub 统一 API仪表盘、配置中心业务写操作经 API 落盘,前端不直接访问文件。"""
from __future__ import annotations
import json
from typing import Any, Optional
from fastapi import APIRouter
from pydantic import BaseModel
from src.android_control import adb_available, list_android_devices
from src.control_plane import query_services, stack_summary, system_snapshot
from src.live_config import business_config, load_hub_config_snapshot, services_config_list
from src.web_process_backend import WebProcessBackend
router = APIRouter(prefix="/hub", tags=["Live Hub"])
_process_backend: WebProcessBackend | None = None
_monitor_entries: list[dict[str, Any]] = []
def configure_hub(process_backend: WebProcessBackend, monitor_entries: list[dict[str, Any]]) -> None:
global _process_backend, _monitor_entries
_process_backend = process_backend
_monitor_entries = list(monitor_entries)
class BusinessDouyinPatch(BaseModel):
streams: Optional[dict[str, str]] = None
ffmpeg: Optional[dict[str, Any]] = None
@router.get("/dashboard")
async def hub_dashboard() -> dict[str, Any]:
snap = system_snapshot()
services = await query_services()
if adb_available():
devices = await list_android_devices()
else:
devices = []
stack = stack_summary()
live_status: dict[str, Any] = {
"processes": [],
"backend_mode": "unknown",
"hint": "PM2 or local registry",
}
pb = _process_backend
if pb is not None:
await pb.ensure_mode()
live_status["backend_mode"] = pb.mode
for entry in _monitor_entries:
name = str(entry.get("pm2", "")).strip()
if not name:
continue
try:
info = await pb.parse_pm2_describe(name)
except Exception as exc: # noqa: BLE001
info = {"process_status": "error", "message": str(exc)}
live_status["processes"].append(
{
"pm2": name,
"label": entry.get("label", name),
"script": entry.get("script", ""),
**info,
}
)
return {
"snapshot": snap,
"stack": stack,
"services": services,
"android": {
"adb_available": adb_available(),
"devices": devices,
},
"live": live_status,
"netdata": snap.get("netdata"),
}
@router.get("/config")
async def hub_config_read() -> dict[str, Any]:
return load_hub_config_snapshot()
@router.get("/services/declared")
async def hub_services_declared() -> dict[str, Any]:
return {"services": services_config_list()}
@router.get("/business/douyinyoutube")
async def hub_business_douyin_read() -> dict[str, Any]:
return business_config("douyinyoutube")
@router.post("/business/douyinyoutube")
async def hub_business_douyin_patch(body: BusinessDouyinPatch) -> dict[str, str]:
from src.live_config.loader import get_config_root
root = get_config_root()
path = root / "business" / "douyinyoutube.json"
path.parent.mkdir(parents=True, exist_ok=True)
current: dict = {}
if path.is_file():
try:
current = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
pass
if body.streams is not None:
cur_streams = current.get("streams") if isinstance(current.get("streams"), dict) else {}
cur_streams.update({k: v for k, v in body.streams.items() if v is not None})
current["streams"] = cur_streams
if body.ffmpeg is not None:
cur_ff = current.get("ffmpeg") if isinstance(current.get("ffmpeg"), dict) else {}
cur_ff.update(body.ffmpeg)
current["ffmpeg"] = cur_ff
path.write_text(json.dumps(current, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return {"message": "business/douyinyoutube.json updated"}

View File

@@ -0,0 +1,8 @@
from .loader import business_config, get_config_root, load_hub_config_snapshot, services_config_list
__all__ = [
"get_config_root",
"load_hub_config_snapshot",
"services_config_list",
"business_config",
]

56
src/live_config/loader.py Normal file
View File

@@ -0,0 +1,56 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
def get_config_root() -> Path:
env = os.environ.get("LIVE_CONFIG_ROOT", "").strip()
if env:
return Path(env)
if Path("/opt/live/config/system.json").is_file():
return Path("/opt/live/config")
# 仓库内开发默认值
base = Path(__file__).resolve().parent.parent.parent
return base / "live-platform" / "config" / "defaults"
def _read_json(path: Path) -> dict[str, Any]:
if not path.is_file():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {}
def services_config_list() -> list[dict[str, Any]]:
root = get_config_root() / "services"
if not root.is_dir():
return []
out: list[dict[str, Any]] = []
for f in sorted(root.glob("*.json")):
data = _read_json(f)
if data:
data.setdefault("id", f.stem)
out.append(data)
return out
def business_config(module_id: str) -> dict[str, Any]:
return _read_json(get_config_root() / "business" / f"{module_id}.json")
def load_hub_config_snapshot() -> dict[str, Any]:
root = get_config_root()
return {
"config_root": str(root),
"system": _read_json(root / "system.json"),
"network": _read_json(root / "network.json"),
"services": services_config_list(),
"business": {
"douyinyoutube": business_config("douyinyoutube"),
},
}