This commit is contained in:
root
2026-05-17 04:31:59 +00:00
parent 75a0ca4e31
commit 07aa02bca6
51 changed files with 1322 additions and 435 deletions

View File

@@ -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 镜像。"
),

View File

@@ -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:

View File

@@ -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,
}

View 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)

View File

@@ -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