This commit is contained in:
eric
2026-05-19 06:47:07 +00:00
parent e1d7251545
commit 78e1308063
63 changed files with 2527 additions and 186 deletions

View File

@@ -525,13 +525,21 @@ async def android_logcat_recent(serial: str, lines: int = 200) -> str:
def _strip_to_png(data: bytes) -> bytes:
if not data:
return data
stripped = data.lstrip().replace(b"\r\n", b"\n")
sig = b"\x89PNG\r\n\x1a\n"
# Some adb paths may prepend shell noise. Do not normalize CRLF inside the
# PNG body: replacing bytes globally corrupts the PNG signature and chunks.
stripped = data.lstrip(b"\x00\t\r\n ")
idx = stripped.find(sig)
if idx > 0:
stripped = stripped[idx:]
if stripped.startswith(b"\n\x89PNG"):
stripped = stripped[1:]
if idx >= 0:
return stripped[idx:]
# Defensive repair for already line-ending-normalized signatures. Only the
# 8-byte signature is repaired; the compressed image body is left untouched.
alt_sig = b"\x89PNG\n\x1a\n"
idx_alt = stripped.find(alt_sig)
if idx_alt >= 0:
candidate = stripped[idx_alt:]
return sig + candidate[len(alt_sig):]
return stripped
@@ -565,8 +573,9 @@ async def android_screenshot(serial: str, *, exec_timeout: float = 14.0) -> byte
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")
normalized_pulled = _strip_to_png(pulled)
if _is_png(normalized_pulled):
return normalized_pulled
last_err = "Pulled file is not a valid PNG"
finally:
tmp.unlink(missing_ok=True)

View File

@@ -29,6 +29,7 @@ BASH_BIN = "/bin/bash" if Path("/bin/bash").is_file() else (shutil.which("bash")
_PRO_URL_CONFIG_INI = re.compile(r"^URL_config\.[a-zA-Z0-9_.-]+\.ini$")
_PRO_YOUTUBE_INI = re.compile(r"^youtube\.[a-zA-Z0-9_.-]+\.ini$")
_NETWORK_RATE_CACHE: dict[str, Any] = {"timestamp": 0.0, "interfaces": {}}
@dataclass(frozen=True)
@@ -497,12 +498,10 @@ def _env_int(env: dict[str, str], key: str, default: int) -> int:
def _neko_http_ports(env: dict[str, str]) -> list[int]:
count = max(1, min(3, _env_int(env, "NEKO_INSTANCE_COUNT", 2)))
return [
_env_int(env, "NEKO_HTTP_PORT_1", _env_int(env, "NEKO_HTTP_PORT", 9200)),
_env_int(env, "NEKO_HTTP_PORT_2", 9201),
_env_int(env, "NEKO_HTTP_PORT_3", 9202),
][:count]
max_count = max(2, min(64, _env_int(env, "NEKO_MAX_INSTANCE_COUNT", 64)))
count = max(2, min(max_count, _env_int(env, "NEKO_INSTANCE_COUNT", 2)))
base = _env_int(env, "NEKO_HTTP_PORT", 9200)
return [_env_int(env, f"NEKO_HTTP_PORT_{idx}", base + idx - 1) for idx in range(1, count + 1)]
def _neko_instances(mdns_host: str, env: dict[str, str]) -> list[dict[str, Any]]:
@@ -790,16 +789,24 @@ def _iface_operstate(name: str) -> str:
def network_iface_stats() -> dict[str, Any]:
"""Linux /proc/net/dev cumulative counters plus current link bandwidth hints."""
"""Linux /proc/net/dev counters with per-refresh bandwidth rates."""
dev = Path("/proc/net/dev")
interfaces: list[dict[str, Any]] = []
if not dev.is_file():
return {"interfaces": interfaces}
return {"interfaces": interfaces, "total_rx_bytes": 0, "total_tx_bytes": 0, "total_rx_bps": 0.0, "total_tx_bps": 0.0}
skip_prefixes = ("lo", "docker", "veth", "br-", "virbr", "tun", "tap")
try:
lines = dev.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return {"interfaces": interfaces}
return {"interfaces": interfaces, "total_rx_bytes": 0, "total_tx_bytes": 0, "total_rx_bps": 0.0, "total_tx_bps": 0.0}
now = time.monotonic()
previous_ts = float(_NETWORK_RATE_CACHE.get("timestamp") or 0.0)
previous = _NETWORK_RATE_CACHE.get("interfaces") or {}
next_cache: dict[str, dict[str, int]] = {}
total_rx = 0
total_tx = 0
total_rx_bps = 0.0
total_tx_bps = 0.0
for line in lines[2:]:
if ":" not in line:
continue
@@ -811,20 +818,50 @@ def network_iface_stats() -> dict[str, Any]:
if len(parts) < 16:
continue
try:
rx_bytes = int(parts[0])
rx_packets = int(parts[1])
tx_bytes = int(parts[8])
tx_packets = int(parts[9])
total_rx += rx_bytes
total_tx += tx_bytes
prev = previous.get(name) if isinstance(previous, dict) else None
elapsed = max(0.0, now - previous_ts) if previous_ts else 0.0
rx_bps = 0.0
tx_bps = 0.0
if isinstance(prev, dict) and elapsed > 0:
prev_rx = int(prev.get("rx_bytes", rx_bytes))
prev_tx = int(prev.get("tx_bytes", tx_bytes))
rx_bps = max(0.0, (rx_bytes - prev_rx) / elapsed)
tx_bps = max(0.0, (tx_bytes - prev_tx) / elapsed)
total_rx_bps += rx_bps
total_tx_bps += tx_bps
next_cache[name] = {"rx_bytes": rx_bytes, "tx_bytes": tx_bytes}
interfaces.append(
{
"name": name,
"rx_bytes": int(parts[0]),
"rx_packets": int(parts[1]),
"tx_bytes": int(parts[8]),
"tx_packets": int(parts[9]),
"rx_bytes": rx_bytes,
"rx_packets": rx_packets,
"tx_bytes": tx_bytes,
"tx_packets": tx_packets,
"rx_bps": round(rx_bps, 2),
"tx_bps": round(tx_bps, 2),
"sample_interval_seconds": round(elapsed, 3),
"speed_mbps": _iface_speed_mbps(name),
"operstate": _iface_operstate(name),
}
)
except (ValueError, IndexError):
continue
return {"interfaces": interfaces}
_NETWORK_RATE_CACHE["timestamp"] = now
_NETWORK_RATE_CACHE["interfaces"] = next_cache
return {
"interfaces": interfaces,
"total_rx_bytes": total_rx,
"total_tx_bytes": total_tx,
"total_rx_bps": round(total_rx_bps, 2),
"total_tx_bps": round(total_tx_bps, 2),
"sample_interval_seconds": round(max(0.0, now - previous_ts) if previous_ts else 0.0, 3),
}
def recent_pm2_log_lines(max_lines: int = 14) -> list[str]:
@@ -878,7 +915,7 @@ async def stack_stream_probes() -> dict[str, Any]:
http = _probe_http_get(f"http://127.0.0.1:{port}/", timeout=2.5)
instances.append({"id": idx, "port": port, "tcp": tcp, "http": http})
primary = instances[0] if instances else {"port": 0, "tcp": {}, "http": {}}
required_count = max(1, min(3, _env_int(env, "NEKO_INSTANCE_COUNT", 2)))
required_count = len(neko_ports)
return {
"port": primary["port"],
"tcp": primary["tcp"],

View File

@@ -26,6 +26,7 @@ from src.web_process_backend import WebProcessBackend
from src.youtube_ini_sync import write_youtube_ini_stream_key
from src.redroid_control import redroid_runtime_summary
from src.hdmi_control import hdmi_capture_summary
from src.neko_capacity import ensure_neko_capacity_for_youtube_channels
from src.youtube_pro_runtime import normalize_youtube_pro_channels
router = APIRouter(prefix="/hub", tags=["无人直播系统"])
@@ -268,14 +269,15 @@ async def hub_youtube_pro_channels_read() -> dict[str, Any]:
@router.post("/youtube_pro_channels")
async def hub_youtube_pro_channels_write(body: YoutubeProChannelsBody) -> dict[str, str]:
async def hub_youtube_pro_channels_write(body: YoutubeProChannelsBody) -> dict[str, Any]:
from src.hub_kv_store import set_youtube_pro_channels
try:
set_youtube_pro_channels(body.channels)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"message": "ok"}
capacity = await asyncio.to_thread(ensure_neko_capacity_for_youtube_channels, _repo_root, body.channels)
return {"message": "ok", "neko": capacity}
@router.get("/services/declared")

112
d2ypp2/src/neko_capacity.py Normal file
View File

@@ -0,0 +1,112 @@
from __future__ import annotations
import subprocess
from pathlib import Path
from typing import Any, Iterable
from src.youtube_pro_runtime import normalize_youtube_pro_channels
MIN_NEKO_INSTANCES = 2
MAX_MANAGED_NEKO_INSTANCES = 64
def desired_neko_count_for_youtube_channels(channels: Iterable[Any]) -> int:
normalized = normalize_youtube_pro_channels(channels)
return max(MIN_NEKO_INSTANCES, min(MAX_MANAGED_NEKO_INSTANCES, len(normalized) or MIN_NEKO_INSTANCES))
def _env_int(value: str | None) -> int | None:
if value is None:
return None
try:
return int(value.strip())
except (TypeError, ValueError):
return None
def _read_env_value(path: Path, key: str) -> str | None:
if not path.is_file():
return None
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return None
prefix = f"{key}="
for raw in lines:
line = raw.strip()
if line.startswith(prefix):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return None
def upsert_env_value(path: Path, key: str, value: str) -> bool:
path.parent.mkdir(parents=True, exist_ok=True)
lines: list[str]
if path.is_file():
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
else:
lines = []
prefix = f"{key}="
next_line = f"{key}={value}"
changed = False
for idx, raw in enumerate(lines):
if raw.strip().startswith(prefix):
if raw != next_line:
lines[idx] = next_line
changed = True
break
else:
if lines and lines[-1].strip():
lines.append("")
lines.append(next_line)
changed = True
if changed:
path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
return changed
def ensure_neko_capacity_for_youtube_channels(
repo_root: Path | None,
channels: Iterable[Any],
*,
restart: bool = True,
) -> dict[str, Any]:
desired = desired_neko_count_for_youtube_channels(channels)
if repo_root is None:
return {"desired": desired, "changed": False, "restarted": False, "reason": "repo_root_missing"}
root = Path(repo_root)
env_path = root / "config" / "system-stack.env"
current_raw = _read_env_value(env_path, "NEKO_INSTANCE_COUNT")
current = _env_int(current_raw)
if current is not None and current >= desired:
return {"desired": desired, "current": current, "changed": False, "restarted": False}
changed = upsert_env_value(env_path, "NEKO_INSTANCE_COUNT", str(desired))
restarted = False
reason = ""
service_ctl = root / "scripts" / "linux" / "service_ctl.sh"
if restart and service_ctl.is_file():
log_dir = root / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / "neko-capacity.log"
try:
with log_file.open("ab") as stream:
subprocess.Popen(
["bash", str(service_ctl), "neko", "restart"],
cwd=str(root),
stdout=stream,
stderr=subprocess.STDOUT,
start_new_session=True,
)
restarted = True
except OSError as exc:
reason = f"restart_spawn_failed: {exc}"
elif restart:
reason = "service_ctl_missing"
return {
"desired": desired,
"current": current_raw,
"changed": changed,
"restarted": restarted,
"reason": reason,
}