ss
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
112
d2ypp2/src/neko_capacity.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user