s
This commit is contained in:
@@ -719,6 +719,7 @@ def stack_summary() -> dict:
|
||||
"note_zh": (
|
||||
"Neko 为 multiuser 模式:「显示昵称」可任意填写(如 live);「密码」必须与 NEKO_USER_PASS / NEKO_ADMIN_PASS "
|
||||
"完全一致(默认 12345678)。不要把 live/12345678 填进同一格。密码错误时会一直卡在连接中。"
|
||||
" 默认保留 admin 管理会话,开发者工具开启,Tampermonkey 强制安装并持久化。"
|
||||
" WebRTC 需 NEKO_WEBRTC_NAT1TO1 为板子局域网 IP,并放行 UDP 端口段。"
|
||||
" x86 默认拉起 Chrome 镜像,ARM 默认拉起 Chromium 镜像。"
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Live Hub 统一 API:仪表盘、配置中心;业务写操作经 API 落盘,前端不直接访问文件。"""
|
||||
"""无人直播系统统一 API:仪表盘、配置中心;业务写操作经 API 落盘,前端不直接访问文件。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -27,7 +28,7 @@ from src.redroid_control import redroid_runtime_summary
|
||||
from src.hdmi_control import hdmi_capture_summary
|
||||
from src.youtube_pro_runtime import normalize_youtube_pro_channels
|
||||
|
||||
router = APIRouter(prefix="/hub", tags=["Live Hub"])
|
||||
router = APIRouter(prefix="/hub", tags=["无人直播系统"])
|
||||
|
||||
_process_backend: WebProcessBackend | None = None
|
||||
_monitor_entries: list[dict[str, Any]] = []
|
||||
@@ -163,34 +164,58 @@ async def hub_dashboard() -> dict[str, Any]:
|
||||
if pb is not None:
|
||||
await pb.ensure_mode()
|
||||
live_status["backend_mode"] = pb.mode
|
||||
for entry in hub_monitor_entries():
|
||||
entries = [entry for entry in hub_monitor_entries() if str(entry.get("pm2", "")).strip()]
|
||||
info_map = await pb.process_infos(str(entry.get("pm2", "")).strip() for entry in entries)
|
||||
|
||||
async def build_live_row(entry: dict[str, Any]) -> dict[str, Any]:
|
||||
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)}
|
||||
info = dict(info_map.get(name) or {})
|
||||
if not info:
|
||||
info = {"process_status": "not_found", "out_path": None, "err_path": None, "pid": 0}
|
||||
info = _coerce_hub_process_info(pb, entry, info)
|
||||
recent_log = _tail_file_text(info.get("out_path"), 80)
|
||||
recent_error = _tail_file_text(info.get("err_path"), 80)
|
||||
status = str(info.get("process_status") or "").lower()
|
||||
online = status in {"online", "running", "launching"}
|
||||
if online:
|
||||
recent_log, recent_error = await asyncio.gather(
|
||||
asyncio.to_thread(_tail_file_text, info.get("out_path"), 48),
|
||||
asyncio.to_thread(_tail_file_text, info.get("err_path"), 32),
|
||||
)
|
||||
ffmpeg_pids = await pb.live_ffmpeg_pids(name, info)
|
||||
else:
|
||||
recent_log = ""
|
||||
recent_error = ""
|
||||
ffmpeg_pids = []
|
||||
activity = summarize_process_activity(
|
||||
name,
|
||||
str(info.get("process_status") or ""),
|
||||
recent_log,
|
||||
recent_error,
|
||||
log_age_seconds=_newest_log_age_seconds(info.get("out_path"), info.get("err_path")),
|
||||
ffmpeg_pids=await pb.live_ffmpeg_pids(name),
|
||||
)
|
||||
live_status["processes"].append(
|
||||
{
|
||||
"pm2": name,
|
||||
"label": entry.get("label", name),
|
||||
"script": entry.get("script", ""),
|
||||
**info,
|
||||
**activity,
|
||||
}
|
||||
ffmpeg_pids=ffmpeg_pids,
|
||||
)
|
||||
return {
|
||||
"pm2": name,
|
||||
"label": entry.get("label", name),
|
||||
"script": entry.get("script", ""),
|
||||
**info,
|
||||
**activity,
|
||||
}
|
||||
|
||||
results = await asyncio.gather(*(build_live_row(entry) for entry in entries), return_exceptions=True)
|
||||
for entry, result in zip(entries, results):
|
||||
if isinstance(result, Exception):
|
||||
live_status["processes"].append(
|
||||
{
|
||||
"pm2": str(entry.get("pm2", "")).strip(),
|
||||
"label": entry.get("label", entry.get("pm2", "")),
|
||||
"script": entry.get("script", ""),
|
||||
"process_status": "error",
|
||||
"business_status": "error",
|
||||
"business_note": str(result),
|
||||
}
|
||||
)
|
||||
else:
|
||||
live_status["processes"].append(result)
|
||||
|
||||
pm2_tail = recent_pm2_log_lines(16)
|
||||
if not pm2_tail:
|
||||
|
||||
@@ -70,31 +70,113 @@ async def pm2_available() -> bool:
|
||||
return code == 0
|
||||
|
||||
|
||||
async def run_pm2(cmd: str, timeout: float = 15.0) -> str:
|
||||
async def run_pm2_result(cmd: str, timeout: float = 15.0) -> tuple[int, str]:
|
||||
pm2_bin = resolve_pm2_bin()
|
||||
if not pm2_bin:
|
||||
return "ERROR: pm2 is not available"
|
||||
_, text = await _shell_out(f"{shlex.quote(pm2_bin)} {cmd}", timeout=timeout)
|
||||
return 127, "ERROR: pm2 is not available"
|
||||
return await _shell_out(f"{shlex.quote(pm2_bin)} {cmd}", timeout=timeout)
|
||||
|
||||
|
||||
async def run_pm2(cmd: str, timeout: float = 15.0) -> str:
|
||||
_, text = await run_pm2_result(cmd, timeout=timeout)
|
||||
return text
|
||||
|
||||
|
||||
def _ffmpeg_cmdline_markers(process_name: str, project_root: Path | None) -> list[str]:
|
||||
markers: list[str] = [
|
||||
process_name,
|
||||
"a.rtmp.youtube.com",
|
||||
"rtmps://a.rtmp.youtube.com",
|
||||
"live.douyin.com",
|
||||
"pull-flv",
|
||||
"douyincdn.com",
|
||||
]
|
||||
if project_root is not None:
|
||||
try:
|
||||
markers.append(str(project_root.resolve()))
|
||||
except OSError:
|
||||
pass
|
||||
safe = re.sub(r"[^a-zA-Z0-9_.-]", "_", process_name).strip()
|
||||
markers = {str(process_name or "").strip(), safe}
|
||||
if project_root is not None and safe:
|
||||
config_dir = project_root / "config"
|
||||
markers.add(f"youtube.{safe}.ini")
|
||||
markers.add(f"URL_config.{safe}.ini")
|
||||
markers.add(str(config_dir / f"youtube.{safe}.ini"))
|
||||
markers.add(str(config_dir / f"URL_config.{safe}.ini"))
|
||||
return [m for m in markers if m]
|
||||
|
||||
|
||||
def _read_proc_cmdline(pid: int) -> str:
|
||||
try:
|
||||
raw = (Path("/proc") / str(pid) / "cmdline").read_bytes()
|
||||
except OSError:
|
||||
return ""
|
||||
if not raw:
|
||||
return ""
|
||||
return raw.replace(b"\0", b" ").decode("utf-8", "replace")
|
||||
|
||||
|
||||
def _read_proc_env_value(pid: int, key: str) -> str:
|
||||
try:
|
||||
raw = (Path("/proc") / str(pid) / "environ").read_bytes()
|
||||
except OSError:
|
||||
return ""
|
||||
prefix = f"{key}=".encode("utf-8")
|
||||
for chunk in raw.split(b"\0"):
|
||||
if chunk.startswith(prefix):
|
||||
return chunk[len(prefix) :].decode("utf-8", "replace").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _proc_parent_and_group(pid: int) -> tuple[int | None, int | None]:
|
||||
try:
|
||||
raw = (Path("/proc") / str(pid) / "stat").read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError:
|
||||
return None, None
|
||||
end = raw.rfind(")")
|
||||
if end < 0:
|
||||
return None, None
|
||||
tail = raw[end + 2 :].split()
|
||||
if len(tail) < 3:
|
||||
return None, None
|
||||
try:
|
||||
return int(tail[1]), int(tail[2])
|
||||
except ValueError:
|
||||
return None, None
|
||||
|
||||
|
||||
def _linux_ffmpeg_pids_for_process_tree(root_pid: int, *, root_pgid: int | None = None) -> list[int]:
|
||||
if root_pid <= 0:
|
||||
return []
|
||||
proc_root = Path("/proc")
|
||||
if not proc_root.is_dir():
|
||||
return []
|
||||
|
||||
children_map: dict[int, list[int]] = {}
|
||||
group_map: dict[int, int] = {}
|
||||
ffmpeg_pids: set[int] = set()
|
||||
for entry in proc_root.iterdir():
|
||||
if not entry.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
pid = int(entry.name)
|
||||
except ValueError:
|
||||
continue
|
||||
parent_pid, process_group = _proc_parent_and_group(pid)
|
||||
if parent_pid is not None:
|
||||
children_map.setdefault(parent_pid, []).append(pid)
|
||||
if process_group is not None:
|
||||
group_map[pid] = process_group
|
||||
if "ffmpeg" in _read_proc_cmdline(pid).lower():
|
||||
ffmpeg_pids.add(pid)
|
||||
|
||||
descendants = {root_pid}
|
||||
queue = [root_pid]
|
||||
while queue:
|
||||
current = queue.pop()
|
||||
children = [pid for pid in children_map.get(current, []) if pid not in descendants]
|
||||
if not children:
|
||||
continue
|
||||
descendants.update(children)
|
||||
queue.extend(children)
|
||||
|
||||
group = root_pgid if root_pgid and root_pgid > 0 else group_map.get(root_pid)
|
||||
matched = {
|
||||
pid
|
||||
for pid in ffmpeg_pids
|
||||
if pid in descendants or (group is not None and group > 0 and group_map.get(pid) == group)
|
||||
}
|
||||
return sorted(matched)
|
||||
|
||||
|
||||
def _linux_ffmpeg_pids_for_stack(process_name: str, project_root: Path | None) -> list[int]:
|
||||
"""仅匹配与本项目直播栈相关的 ffmpeg(命令行含 YouTube 推流、抖音拉流或项目路径),避免 killall 误伤整机其它 ffmpeg。"""
|
||||
markers = _ffmpeg_cmdline_markers(process_name, project_root)
|
||||
@@ -130,28 +212,36 @@ def _signal_pids(pids: Iterable[int], sig: int) -> None:
|
||||
pass
|
||||
|
||||
|
||||
async def force_kill_ffmpeg(process_name: str, project_root: Path | None = None) -> None:
|
||||
async def force_kill_ffmpeg(
|
||||
process_name: str,
|
||||
project_root: Path | None = None,
|
||||
*,
|
||||
root_pid: int | None = None,
|
||||
root_pgid: int | None = None,
|
||||
) -> None:
|
||||
"""结束与本业务相关的 ffmpeg:按 /proc 命令行匹配 + 可选 pkill。"""
|
||||
|
||||
def collect() -> list[int]:
|
||||
return _linux_ffmpeg_pids_for_stack(process_name, project_root)
|
||||
|
||||
pids = await asyncio.to_thread(collect)
|
||||
pids = _linux_ffmpeg_pids_for_process_tree(int(root_pid or 0), root_pgid=root_pgid)
|
||||
if not pids:
|
||||
pids = _linux_ffmpeg_pids_for_stack(process_name, project_root)
|
||||
if pids:
|
||||
await asyncio.to_thread(_signal_pids, pids, signal.SIGTERM)
|
||||
_signal_pids(pids, signal.SIGTERM)
|
||||
await asyncio.sleep(0.6)
|
||||
remaining = await asyncio.to_thread(collect)
|
||||
remaining = _linux_ffmpeg_pids_for_process_tree(int(root_pid or 0), root_pgid=root_pgid)
|
||||
if not remaining:
|
||||
remaining = _linux_ffmpeg_pids_for_stack(process_name, project_root)
|
||||
if remaining:
|
||||
await asyncio.to_thread(_signal_pids, remaining, signal.SIGKILL)
|
||||
_signal_pids(remaining, signal.SIGKILL)
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
safe = re.sub(r"[^a-zA-Z0-9_.-]", "", process_name) or "x"
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
f"pkill -f 'ffmpeg.*{safe}' || true",
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await proc.wait()
|
||||
if safe != process_name:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
f"pkill -f 'ffmpeg.*{safe}' || true",
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await proc.wait()
|
||||
|
||||
|
||||
class LocalProcessRegistry:
|
||||
@@ -369,6 +459,10 @@ def build_pm2_start_command(base_dir: Path, process: str, script: str) -> Option
|
||||
return " ".join(env_bits + cmd_bits)
|
||||
|
||||
|
||||
def _pm2_log_slug(name: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9]", "-", str(name or "").strip()) or "process"
|
||||
|
||||
|
||||
class WebProcessBackend:
|
||||
def __init__(self, base_dir: Path, default_log_dir: Path):
|
||||
self.base_dir = base_dir
|
||||
@@ -380,9 +474,21 @@ class WebProcessBackend:
|
||||
self._pm2_last_daemon_pid: Optional[int] = None
|
||||
self._pm2_last_resurrect_attempt: float = 0.0
|
||||
self._pm2_last_start_attempt: dict[str, float] = {}
|
||||
self._pm2_jlist_cache_ttl: float = 0.35
|
||||
self._pm2_jlist_stale_grace: float = 2.0
|
||||
self._pm2_jlist_cached_at: float = 0.0
|
||||
self._pm2_jlist_cache: list[dict[str, Any]] | None = None
|
||||
self._pm2_dump_cache_ttl: float = 5.0
|
||||
self._pm2_dump_cached_at: float = 0.0
|
||||
self._pm2_dump_names_cache: set[str] | None = None
|
||||
self._proc_runtime_cache_ttl: float = 0.35
|
||||
self._proc_runtime_cached_at: float = 0.0
|
||||
self._proc_runtime_cache: dict[str, dict[str, Any]] | None = None
|
||||
|
||||
async def refresh_mode(self) -> None:
|
||||
self._use_pm2 = await pm2_available()
|
||||
if self._use_pm2:
|
||||
self.invalidate_pm2_cache()
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
@@ -393,9 +499,20 @@ class WebProcessBackend:
|
||||
async def ensure_mode(self) -> None:
|
||||
if self._use_pm2 is None or (not self._use_pm2 and resolve_pm2_bin()):
|
||||
await self.refresh_mode()
|
||||
|
||||
async def ensure_pm2_runtime(self) -> None:
|
||||
await self.ensure_mode()
|
||||
if self._use_pm2:
|
||||
await self._ensure_pm2_process_table()
|
||||
|
||||
def invalidate_pm2_cache(self) -> None:
|
||||
self._pm2_jlist_cache = None
|
||||
self._pm2_jlist_cached_at = 0.0
|
||||
self._pm2_dump_names_cache = None
|
||||
self._pm2_dump_cached_at = 0.0
|
||||
self._proc_runtime_cache = None
|
||||
self._proc_runtime_cached_at = 0.0
|
||||
|
||||
def _pm2_home_dir(self) -> Path:
|
||||
raw = (os.environ.get("PM2_HOME") or "").strip()
|
||||
if raw:
|
||||
@@ -428,6 +545,88 @@ class WebProcessBackend:
|
||||
return False
|
||||
return any(isinstance(item, dict) and str(item.get("name") or "").strip() for item in payload)
|
||||
|
||||
def _pm2_dump_registered_names(self) -> set[str]:
|
||||
now = time.monotonic()
|
||||
if (
|
||||
self._pm2_dump_names_cache is not None
|
||||
and now - self._pm2_dump_cached_at <= self._pm2_dump_cache_ttl
|
||||
):
|
||||
return set(self._pm2_dump_names_cache)
|
||||
|
||||
path = self._pm2_home_dir() / "dump.pm2"
|
||||
names: set[str] = set()
|
||||
if path.is_file():
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
payload = []
|
||||
if isinstance(payload, list):
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = str(item.get("name") or "").strip()
|
||||
if name:
|
||||
names.add(name)
|
||||
self._pm2_dump_names_cache = names
|
||||
self._pm2_dump_cached_at = now
|
||||
return set(names)
|
||||
|
||||
def _pm2_default_log_paths(self, process: str) -> tuple[str, str]:
|
||||
log_dir = self._pm2_home_dir() / "logs"
|
||||
slug = _pm2_log_slug(process)
|
||||
return str(log_dir / f"{slug}-out.log"), str(log_dir / f"{slug}-error.log")
|
||||
|
||||
async def _proc_runtime_items(self) -> dict[str, dict[str, Any]]:
|
||||
now = time.monotonic()
|
||||
if (
|
||||
self._proc_runtime_cache is not None
|
||||
and now - self._proc_runtime_cached_at <= self._proc_runtime_cache_ttl
|
||||
):
|
||||
return {name: dict(info) for name, info in self._proc_runtime_cache.items()}
|
||||
|
||||
proc_root = Path("/proc")
|
||||
if not proc_root.is_dir():
|
||||
self._proc_runtime_cache = {}
|
||||
self._proc_runtime_cached_at = now
|
||||
return {}
|
||||
|
||||
chosen: dict[str, tuple[int, dict[str, Any]]] = {}
|
||||
for entry in proc_root.iterdir():
|
||||
if not entry.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
pid = int(entry.name)
|
||||
except ValueError:
|
||||
continue
|
||||
process_name = _read_proc_env_value(pid, "LIVE_PM2_PROCESS_NAME")
|
||||
if not process_name:
|
||||
continue
|
||||
cmdline = _read_proc_cmdline(pid)
|
||||
if not cmdline or "ffmpeg" in cmdline.lower():
|
||||
continue
|
||||
parent_pid, process_group = _proc_parent_and_group(pid)
|
||||
score = 0
|
||||
if process_group and process_group == pid:
|
||||
score += 100
|
||||
if ".py" in cmdline or "python" in cmdline or "bash" in cmdline or " sh " in f" {cmdline} ":
|
||||
score += 10
|
||||
if parent_pid == 1:
|
||||
score += 5
|
||||
info = {
|
||||
"pid": pid,
|
||||
"ppid": parent_pid or 0,
|
||||
"pgid": process_group or 0,
|
||||
"cmdline": cmdline,
|
||||
}
|
||||
current = chosen.get(process_name)
|
||||
if current is None or score > current[0] or (score == current[0] and pid < int(current[1].get("pid") or 0)):
|
||||
chosen[process_name] = (score, info)
|
||||
|
||||
snapshot = {name: dict(info) for name, (_, info) in chosen.items()}
|
||||
self._proc_runtime_cache = snapshot
|
||||
self._proc_runtime_cached_at = now
|
||||
return {name: dict(info) for name, info in snapshot.items()}
|
||||
|
||||
def _read_pm2_desired_state(self) -> dict[str, bool]:
|
||||
path = self._pm2_desired_state_path
|
||||
if not path.is_file():
|
||||
@@ -458,39 +657,108 @@ class WebProcessBackend:
|
||||
os.replace(str(tmp), str(path))
|
||||
|
||||
async def _set_pm2_desired_running(self, process: str, should_run: bool) -> None:
|
||||
def mutate() -> None:
|
||||
desired = self._read_pm2_desired_state()
|
||||
desired[str(process)] = bool(should_run)
|
||||
self._write_pm2_desired_state(desired)
|
||||
desired = self._read_pm2_desired_state()
|
||||
desired[str(process)] = bool(should_run)
|
||||
self._write_pm2_desired_state(desired)
|
||||
|
||||
await asyncio.to_thread(mutate)
|
||||
async def _pm2_jlist_items(self, *, force_refresh: bool = False) -> list[dict[str, Any]]:
|
||||
now = time.monotonic()
|
||||
if (
|
||||
not force_refresh
|
||||
and self._pm2_jlist_cache is not None
|
||||
and now - self._pm2_jlist_cached_at <= self._pm2_jlist_cache_ttl
|
||||
):
|
||||
return list(self._pm2_jlist_cache)
|
||||
|
||||
async def _pm2_jlist_items(self) -> list[dict[str, Any]]:
|
||||
pm2_bin = resolve_pm2_bin()
|
||||
if not pm2_bin:
|
||||
self.invalidate_pm2_cache()
|
||||
return []
|
||||
code, text = await _shell_out(f"{shlex.quote(pm2_bin)} jlist", timeout=20.0)
|
||||
if code != 0 or not text.strip():
|
||||
if (
|
||||
self._pm2_jlist_cache is not None
|
||||
and now - self._pm2_jlist_cached_at <= self._pm2_jlist_stale_grace
|
||||
):
|
||||
return list(self._pm2_jlist_cache)
|
||||
self._pm2_jlist_cache = []
|
||||
self._pm2_jlist_cached_at = now
|
||||
return []
|
||||
try:
|
||||
items = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
if (
|
||||
self._pm2_jlist_cache is not None
|
||||
and now - self._pm2_jlist_cached_at <= self._pm2_jlist_stale_grace
|
||||
):
|
||||
return list(self._pm2_jlist_cache)
|
||||
self._pm2_jlist_cache = []
|
||||
self._pm2_jlist_cached_at = now
|
||||
return []
|
||||
if not isinstance(items, list):
|
||||
self._pm2_jlist_cache = []
|
||||
self._pm2_jlist_cached_at = now
|
||||
return []
|
||||
return [item for item in items if isinstance(item, dict)]
|
||||
filtered = [item for item in items if isinstance(item, dict)]
|
||||
self._pm2_jlist_cache = filtered
|
||||
self._pm2_jlist_cached_at = now
|
||||
return list(filtered)
|
||||
|
||||
async def _pm2_jlist_index(self, *, force_refresh: bool = False) -> dict[str, dict[str, Any]]:
|
||||
items = await self._pm2_jlist_items(force_refresh=force_refresh)
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for item in items:
|
||||
name = str(item.get("name") or "").strip()
|
||||
if name:
|
||||
out[name] = item
|
||||
return out
|
||||
|
||||
def _pm2_info_from_item(self, process: str, item: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not item:
|
||||
return {
|
||||
"process_status": "not_found",
|
||||
"out_path": None,
|
||||
"err_path": None,
|
||||
"pid": 0,
|
||||
}
|
||||
|
||||
env = item.get("pm2_env") if isinstance(item.get("pm2_env"), dict) else {}
|
||||
raw_pid = item.get("pid")
|
||||
try:
|
||||
pid = int(raw_pid or 0)
|
||||
except (TypeError, ValueError):
|
||||
pid = 0
|
||||
|
||||
status = str(env.get("status") or item.get("status") or "").strip().lower()
|
||||
if not status:
|
||||
status = "online" if pid > 0 else "unknown"
|
||||
|
||||
out_path = env.get("pm_out_log_path") or item.get("pm_out_log_path")
|
||||
err_path = env.get("pm_err_log_path") or item.get("pm_err_log_path")
|
||||
if not out_path:
|
||||
out_path = str(self.default_log_dir / f"{process}-out.log")
|
||||
if not err_path:
|
||||
err_path = str(self.default_log_dir / f"{process}-error.log")
|
||||
|
||||
return {
|
||||
"process_status": status,
|
||||
"out_path": out_path,
|
||||
"err_path": err_path,
|
||||
"pid": pid if pid > 0 else 0,
|
||||
}
|
||||
|
||||
async def _ensure_pm2_process_table(self) -> None:
|
||||
daemon_pid = await asyncio.to_thread(self._pm2_pid_value)
|
||||
daemon_pid = self._pm2_pid_value()
|
||||
if daemon_pid != self._pm2_last_daemon_pid:
|
||||
self._pm2_last_daemon_pid = daemon_pid
|
||||
self._pm2_last_resurrect_attempt = 0.0
|
||||
self.invalidate_pm2_cache()
|
||||
|
||||
items = await self._pm2_jlist_items()
|
||||
if items:
|
||||
return
|
||||
|
||||
if not await asyncio.to_thread(self._pm2_dump_has_entries):
|
||||
if not self._pm2_dump_has_entries():
|
||||
return
|
||||
|
||||
now = time.monotonic()
|
||||
@@ -498,14 +766,15 @@ class WebProcessBackend:
|
||||
return
|
||||
|
||||
async with self._pm2_resurrect_lock:
|
||||
daemon_pid = await asyncio.to_thread(self._pm2_pid_value)
|
||||
daemon_pid = self._pm2_pid_value()
|
||||
if daemon_pid != self._pm2_last_daemon_pid:
|
||||
self._pm2_last_daemon_pid = daemon_pid
|
||||
self._pm2_last_resurrect_attempt = 0.0
|
||||
self.invalidate_pm2_cache()
|
||||
|
||||
if await self._pm2_jlist_items():
|
||||
return
|
||||
if not await asyncio.to_thread(self._pm2_dump_has_entries):
|
||||
if not self._pm2_dump_has_entries():
|
||||
return
|
||||
|
||||
now = time.monotonic()
|
||||
@@ -514,12 +783,14 @@ class WebProcessBackend:
|
||||
|
||||
self._pm2_last_resurrect_attempt = now
|
||||
with contextlib.suppress(Exception):
|
||||
await run_pm2("resurrect", timeout=45.0)
|
||||
await asyncio.sleep(0.2)
|
||||
code, _ = await run_pm2_result("resurrect", timeout=45.0)
|
||||
if code == 0:
|
||||
self.invalidate_pm2_cache()
|
||||
await asyncio.sleep(0.2)
|
||||
await self._ensure_desired_pm2_processes()
|
||||
|
||||
async def _ensure_desired_pm2_processes(self) -> None:
|
||||
desired = await asyncio.to_thread(self._read_pm2_desired_state)
|
||||
desired = self._read_pm2_desired_state()
|
||||
if not desired:
|
||||
return
|
||||
items = await self._pm2_jlist_items()
|
||||
@@ -540,8 +811,10 @@ class WebProcessBackend:
|
||||
continue
|
||||
self._pm2_last_start_attempt[name] = now
|
||||
with contextlib.suppress(Exception):
|
||||
await run_pm2(f"start {name}", timeout=45.0)
|
||||
await asyncio.sleep(0.2)
|
||||
code, _ = await run_pm2_result(f"start {name}", timeout=45.0)
|
||||
if code == 0:
|
||||
self.invalidate_pm2_cache()
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
async def online_process_names(self) -> list[str]:
|
||||
"""当前在线的 PM2 / 本地注册进程名(用于 TikTok·OBS 等 HDMI 链路互斥)。"""
|
||||
@@ -557,25 +830,17 @@ class WebProcessBackend:
|
||||
if self._local._pid_alive(pid):
|
||||
names.append(str(name))
|
||||
return sorted(names)
|
||||
items = await self._pm2_jlist_items()
|
||||
out: list[str] = []
|
||||
for it in items:
|
||||
name = it.get("name")
|
||||
if not name:
|
||||
continue
|
||||
env = it.get("pm2_env") if isinstance(it.get("pm2_env"), dict) else {}
|
||||
status = str(env.get("status") or "").lower()
|
||||
if status in ("online", "launching"):
|
||||
out.append(str(name))
|
||||
return out
|
||||
runtime = await self._proc_runtime_items()
|
||||
return sorted(runtime)
|
||||
|
||||
async def _pm2_registered_names(self) -> set[str]:
|
||||
items = await self._pm2_jlist_items()
|
||||
return {
|
||||
str(it.get("name"))
|
||||
for it in items
|
||||
if str(it.get("name") or "").strip()
|
||||
desired = {
|
||||
str(name).strip()
|
||||
for name in self._read_pm2_desired_state().keys()
|
||||
if str(name).strip()
|
||||
}
|
||||
runtime = set(await self.online_process_names())
|
||||
return self._pm2_dump_registered_names() | desired | runtime
|
||||
|
||||
async def clear_process_logs(self, process: str) -> None:
|
||||
await self.ensure_mode()
|
||||
@@ -591,12 +856,89 @@ class WebProcessBackend:
|
||||
value = info.get(key)
|
||||
if value:
|
||||
candidates.add(str(value))
|
||||
await asyncio.to_thread(
|
||||
lambda: [_truncate_text_log(path) for path in sorted(candidates) if not _looks_like_null_device(path)]
|
||||
)
|
||||
for path in sorted(candidates):
|
||||
if not _looks_like_null_device(path):
|
||||
_truncate_text_log(path)
|
||||
|
||||
async def live_ffmpeg_pids(self, process: str) -> list[int]:
|
||||
return await asyncio.to_thread(_linux_ffmpeg_pids_for_stack, process, self.base_dir)
|
||||
async def process_infos(self, processes: Iterable[str]) -> dict[str, dict[str, Any]]:
|
||||
await self.ensure_mode()
|
||||
names: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw_name in processes:
|
||||
name = str(raw_name or "").strip()
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
names.append(name)
|
||||
|
||||
if not self._use_pm2:
|
||||
return {name: self._local.describe_status(name) for name in names}
|
||||
|
||||
runtime = await self._proc_runtime_items()
|
||||
known = self._pm2_dump_registered_names() | {
|
||||
str(name).strip()
|
||||
for name in self._read_pm2_desired_state().keys()
|
||||
if str(name).strip()
|
||||
}
|
||||
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for name in names:
|
||||
process_info = runtime.get(name)
|
||||
out_path, err_path = self._pm2_default_log_paths(name)
|
||||
if process_info is not None:
|
||||
out[name] = {
|
||||
"process_status": "online",
|
||||
"out_path": out_path,
|
||||
"err_path": err_path,
|
||||
"pid": int(process_info.get("pid") or 0),
|
||||
}
|
||||
elif name in known:
|
||||
out[name] = {
|
||||
"process_status": "stopped",
|
||||
"out_path": out_path,
|
||||
"err_path": err_path,
|
||||
"pid": 0,
|
||||
}
|
||||
else:
|
||||
out[name] = {
|
||||
"process_status": "not_found",
|
||||
"out_path": None,
|
||||
"err_path": None,
|
||||
"pid": 0,
|
||||
}
|
||||
return out
|
||||
|
||||
async def process_pid(self, process: str) -> int:
|
||||
await self.ensure_mode()
|
||||
if not self._use_pm2:
|
||||
info = self._local.describe_status(process)
|
||||
try:
|
||||
pid = int(info.get("pid") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return pid if self._local._pid_alive(pid) else 0
|
||||
|
||||
info = (await self.process_infos([process])).get(process) or {}
|
||||
try:
|
||||
pid = int(info.get("pid") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return pid if pid > 0 else 0
|
||||
|
||||
async def live_ffmpeg_pids(self, process: str, info: Optional[dict[str, Any]] = None) -> list[int]:
|
||||
if info is None:
|
||||
info = (await self.process_infos([process])).get(process) or {}
|
||||
try:
|
||||
pid = int(info.get("pid") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pid = 0
|
||||
if pid <= 0:
|
||||
return []
|
||||
try:
|
||||
pgid = os.getpgid(pid)
|
||||
except OSError:
|
||||
pgid = None
|
||||
return _linux_ffmpeg_pids_for_process_tree(pid, root_pgid=pgid)
|
||||
|
||||
async def _pm2_save(self) -> None:
|
||||
if not self._use_pm2:
|
||||
@@ -605,46 +947,54 @@ class WebProcessBackend:
|
||||
await run_pm2("save", timeout=20.0)
|
||||
|
||||
async def start(self, process: str, script: str) -> str:
|
||||
await self.ensure_mode()
|
||||
await self.ensure_pm2_runtime()
|
||||
if self._use_pm2:
|
||||
registered = await self._pm2_registered_names()
|
||||
if process in registered:
|
||||
info = await self.parse_pm2_describe(process)
|
||||
if str(info.get("process_status") or "").lower() not in ("online", "running"):
|
||||
await self.clear_process_logs(process)
|
||||
text = await run_pm2(f"start {process}")
|
||||
await self._set_pm2_desired_running(process, True)
|
||||
await self._pm2_save()
|
||||
code, text = await run_pm2_result(f"start {process}")
|
||||
if code == 0:
|
||||
await self._set_pm2_desired_running(process, True)
|
||||
await self._pm2_save()
|
||||
self.invalidate_pm2_cache()
|
||||
return text
|
||||
await self.clear_process_logs(process)
|
||||
cmd = build_pm2_start_command(self.base_dir, process, script)
|
||||
if not cmd:
|
||||
return f"ERROR: script not found: {script}"
|
||||
_, text = await _shell_out(cmd, timeout=45.0)
|
||||
await self._set_pm2_desired_running(process, True)
|
||||
await self._pm2_save()
|
||||
code, text = await _shell_out(cmd, timeout=45.0)
|
||||
if code == 0:
|
||||
await self._set_pm2_desired_running(process, True)
|
||||
await self._pm2_save()
|
||||
self.invalidate_pm2_cache()
|
||||
return text
|
||||
return await self._local.start(process, script)
|
||||
|
||||
async def stop(self, process: str) -> str:
|
||||
await self.ensure_mode()
|
||||
await self.ensure_pm2_runtime()
|
||||
if self._use_pm2:
|
||||
text = await run_pm2(f"stop {process}")
|
||||
await self._set_pm2_desired_running(process, False)
|
||||
await self._pm2_save()
|
||||
code, text = await run_pm2_result(f"stop {process}")
|
||||
if code == 0:
|
||||
await self._set_pm2_desired_running(process, False)
|
||||
await self._pm2_save()
|
||||
self.invalidate_pm2_cache()
|
||||
return text
|
||||
return await self._local.stop(process)
|
||||
|
||||
async def restart(self, process: str, script: str) -> str:
|
||||
await self.ensure_mode()
|
||||
await self.ensure_pm2_runtime()
|
||||
if self._use_pm2:
|
||||
registered = await self._pm2_registered_names()
|
||||
if process not in registered:
|
||||
return await self.start(process, script)
|
||||
await self.clear_process_logs(process)
|
||||
text = await run_pm2(f"restart {process}")
|
||||
await self._set_pm2_desired_running(process, True)
|
||||
await self._pm2_save()
|
||||
code, text = await run_pm2_result(f"restart {process}")
|
||||
if code == 0:
|
||||
await self._set_pm2_desired_running(process, True)
|
||||
await self._pm2_save()
|
||||
self.invalidate_pm2_cache()
|
||||
return text
|
||||
return await self._local.restart(process, script)
|
||||
|
||||
@@ -660,66 +1010,34 @@ class WebProcessBackend:
|
||||
async def full_status_raw(self) -> str:
|
||||
await self.ensure_mode()
|
||||
if self._use_pm2:
|
||||
return await run_pm2("status", timeout=8.0)
|
||||
known = sorted(await self._pm2_registered_names())
|
||||
if not known:
|
||||
return "PM2 process list is empty"
|
||||
info_map = await self.process_infos(known)
|
||||
rows: list[tuple[str, str, str]] = []
|
||||
for name in known:
|
||||
info = info_map.get(name) or {}
|
||||
rows.append((name, str(info.get("process_status") or "unknown"), str(info.get("pid") or 0)))
|
||||
if not rows:
|
||||
return "PM2 process list is empty"
|
||||
name_w = max(len("name"), *(len(name) for name, _, _ in rows))
|
||||
status_w = max(len("status"), *(len(status) for _, status, _ in rows))
|
||||
pid_w = max(len("pid"), *(len(pid) for _, _, pid in rows))
|
||||
lines = [
|
||||
f"{'name':<{name_w}} {'status':<{status_w}} {'pid':<{pid_w}}",
|
||||
f"{'-' * name_w} {'-' * status_w} {'-' * pid_w}",
|
||||
]
|
||||
for name, status, pid in rows:
|
||||
lines.append(f"{name:<{name_w}} {status:<{status_w}} {pid:<{pid_w}}")
|
||||
lines.append("(PM2 snapshot from jlist)")
|
||||
return "\n".join(lines)
|
||||
return self._local.status_table_text()
|
||||
|
||||
async def parse_pm2_describe(self, process: str) -> dict[str, Any]:
|
||||
await self.ensure_mode()
|
||||
if not self._use_pm2:
|
||||
d = self._local.describe_status(process)
|
||||
return {
|
||||
"process_status": d["process_status"],
|
||||
"out_path": d["out_path"],
|
||||
"err_path": d["err_path"],
|
||||
}
|
||||
|
||||
registered = await self._pm2_registered_names()
|
||||
describe_raw = await run_pm2(f"describe {process}", timeout=10.0)
|
||||
describe_clean = strip_ansi_codes(describe_raw)
|
||||
|
||||
if any(
|
||||
x in describe_clean.lower()
|
||||
for x in ["no such process", "unknown process", "not found"]
|
||||
) or not describe_clean.strip():
|
||||
return {
|
||||
"process_status": "not_found",
|
||||
"out_path": None,
|
||||
"err_path": None,
|
||||
}
|
||||
|
||||
status = "unknown"
|
||||
out_path = None
|
||||
err_path = None
|
||||
|
||||
for line in describe_raw.splitlines():
|
||||
line_clean = strip_ansi_codes(line)
|
||||
if line_clean.count("│") < 2:
|
||||
continue
|
||||
parts = [p.strip() for p in line_clean.split("│") if p.strip()]
|
||||
if len(parts) >= 2:
|
||||
key = parts[0].lower().replace(" ", "_")
|
||||
value = parts[1]
|
||||
if key == "status":
|
||||
status = value.lower()
|
||||
elif key == "out_log_path":
|
||||
out_path = value
|
||||
elif key in {"err_log_path", "error_log_path"}:
|
||||
err_path = value
|
||||
|
||||
if status == "unknown" and out_path is None and err_path is None and process not in registered:
|
||||
return {
|
||||
"process_status": "not_found",
|
||||
"out_path": None,
|
||||
"err_path": None,
|
||||
}
|
||||
|
||||
if not out_path:
|
||||
out_path = str(self.default_log_dir / f"{process}-out.log")
|
||||
if not err_path:
|
||||
err_path = str(self.default_log_dir / f"{process}-error.log")
|
||||
|
||||
info = (await self.process_infos([process])).get(process) or {}
|
||||
return {
|
||||
"process_status": status,
|
||||
"out_path": out_path,
|
||||
"err_path": err_path,
|
||||
"process_status": str(info.get("process_status") or "not_found"),
|
||||
"out_path": info.get("out_path"),
|
||||
"err_path": info.get("err_path"),
|
||||
"pid": int(info.get("pid") or 0) if str(info.get("pid") or "0").isdigit() else 0,
|
||||
}
|
||||
|
||||
43
d2ypp2/src/youtube_config_paths.py
Normal file
43
d2ypp2/src/youtube_config_paths.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_SAFE_PM2_SEGMENT = re.compile(r"[^a-zA-Z0-9_.-]+")
|
||||
|
||||
|
||||
def sanitize_pm2_segment(value: str) -> str:
|
||||
return _SAFE_PM2_SEGMENT.sub("_", (value or "").strip())
|
||||
|
||||
|
||||
def is_youtube_like_pm2(pm2: str | None) -> bool:
|
||||
low = (pm2 or "").strip().lower()
|
||||
return low.startswith("youtube") or low.startswith("douyin_youtube")
|
||||
|
||||
|
||||
def resolve_youtube_style_config_paths(base_dir: str | Path, pm2: str | None = None) -> tuple[str, str]:
|
||||
root = Path(base_dir)
|
||||
legacy_youtube = root / "config" / "youtube.ini"
|
||||
legacy_url = root / "config" / "URL_config.ini"
|
||||
|
||||
if not is_youtube_like_pm2(pm2):
|
||||
return str(legacy_youtube), str(legacy_url)
|
||||
|
||||
safe = sanitize_pm2_segment(pm2 or "")
|
||||
managed_youtube = root / "config" / f"youtube.{safe}.ini"
|
||||
managed_url = root / "config" / f"URL_config.{safe}.ini"
|
||||
|
||||
managed_youtube_exists = managed_youtube.is_file()
|
||||
managed_url_exists = managed_url.is_file()
|
||||
legacy_youtube_exists = legacy_youtube.is_file()
|
||||
legacy_url_exists = legacy_url.is_file()
|
||||
|
||||
if managed_youtube_exists or managed_url_exists:
|
||||
youtube_path = managed_youtube if managed_youtube_exists else legacy_youtube if legacy_youtube_exists else managed_youtube
|
||||
url_path = managed_url if managed_url_exists else legacy_url if legacy_url_exists else managed_url
|
||||
return str(youtube_path), str(url_path)
|
||||
|
||||
if legacy_youtube_exists or legacy_url_exists:
|
||||
return str(legacy_youtube), str(legacy_url)
|
||||
|
||||
return str(managed_youtube), str(managed_url)
|
||||
@@ -4,6 +4,8 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
from src.youtube_config_paths import resolve_youtube_style_config_paths
|
||||
|
||||
_SAFE_PM2_SEGMENT = re.compile(r"[^a-zA-Z0-9_.-]+")
|
||||
_YOUTUBE_HEADER = re.compile(r"^\s*\[youtube\]\s*$", re.IGNORECASE)
|
||||
_ANY_HEADER = re.compile(r"^\s*\[[^\]]+\]\s*$")
|
||||
@@ -228,3 +230,25 @@ def ensure_youtube_pro_channel_runtime_files(
|
||||
except OSError as exc:
|
||||
return True, [f"{pm2}: failed to write config ({exc})"]
|
||||
return True, []
|
||||
|
||||
|
||||
def validate_youtube_runtime_files(
|
||||
base_dir: Path,
|
||||
config_dir: Path,
|
||||
process: str,
|
||||
channels: Iterable[dict[str, Any]],
|
||||
) -> list[str]:
|
||||
handled, errors = ensure_youtube_pro_channel_runtime_files(config_dir, process, channels)
|
||||
if handled:
|
||||
return errors
|
||||
|
||||
youtube_path_raw, url_path_raw = resolve_youtube_style_config_paths(base_dir, process)
|
||||
youtube_text = _read_text_if_file(Path(youtube_path_raw)) or ""
|
||||
url_text = _read_text_if_file(Path(url_path_raw)) or ""
|
||||
|
||||
fallback_errors: list[str] = []
|
||||
if not _extract_stream_key(youtube_text):
|
||||
fallback_errors.append(f"{process}: stream key missing")
|
||||
if not _has_noncomment_url(url_text):
|
||||
fallback_errors.append(f"{process}: source URL missing")
|
||||
return fallback_errors
|
||||
|
||||
Reference in New Issue
Block a user