s
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
# —— ARM(RK3588 等)常见:h264_rkmpp(需 /dev/mpp_service 与 ffmpeg 含 rkmpp)
|
# —— ARM(RK3588 等)常见:h264_rkmpp(需 /dev/mpp_service 与 ffmpeg 含 rkmpp)
|
||||||
# —— ARM 通用 V4L2:h264_v4l2m2m
|
# —— ARM 通用 V4L2:h264_v4l2m2m
|
||||||
# —— x86_64 Intel/AMD 核显(VA-API):h264_vaapi(需 /dev/dri render 节点)
|
# —— x86_64 Intel/AMD 核显(VA-API):h264_vaapi(需 /dev/dri render 节点)
|
||||||
# —— x86_64 Intel Quick Sync:h264_qsv(需 ffmpeg 编译带 libmfx/oneVPL)
|
# —— x86_64 Intel Quick Sync:本项目默认不自动启用 h264_qsv;Linux 上优先走更稳的 h264_vaapi
|
||||||
# —— NVIDIA:一般无需写此处,推流脚本会自行检测 nvidia-smi + h264_nvenc
|
# —— NVIDIA:一般无需写此处,推流脚本会自行检测 nvidia-smi + h264_nvenc
|
||||||
#
|
#
|
||||||
# LIVE_VIDEO_ENCODER=h264_v4l2m2m
|
# LIVE_VIDEO_ENCODER=h264_v4l2m2m
|
||||||
|
|||||||
@@ -269,8 +269,9 @@ def build_profile() -> dict:
|
|||||||
def suggest_video_encoder(profile: dict) -> str:
|
def suggest_video_encoder(profile: dict) -> str:
|
||||||
"""在 Linux 上根据 ffmpeg 能力与探测结果给出可选硬件编码器名(供 douyin→YouTube 读取 LIVE_VIDEO_ENCODER)。
|
"""在 Linux 上根据 ffmpeg 能力与探测结果给出可选硬件编码器名(供 douyin→YouTube 读取 LIVE_VIDEO_ENCODER)。
|
||||||
|
|
||||||
优先级:RKMPP(常见 ARM SoC)→ V4L2M2M(通用 ARM/部分板)→ VA-API(x86 核显)→ QSV(Intel x86)。
|
优先级:RKMPP(常见 ARM SoC)→ V4L2M2M(通用 ARM/部分板)→ VA-API(x86 核显)。
|
||||||
NVIDIA 由 douyin_youtube_ffplay 内单独检测 h264_nvenc,此处不重复写入。
|
NVIDIA 由 douyin_youtube_ffplay 内单独检测 h264_nvenc,此处不重复写入。
|
||||||
|
QSV 在部分 Linux 发行版上需要额外的设备/滤镜链路,本项目默认不自动启用,以优先保证跨机兼容性。
|
||||||
"""
|
"""
|
||||||
if platform.system().lower() != "linux":
|
if platform.system().lower() != "linux":
|
||||||
return ""
|
return ""
|
||||||
@@ -291,8 +292,6 @@ def suggest_video_encoder(profile: dict) -> str:
|
|||||||
return ""
|
return ""
|
||||||
if "h264_vaapi" in text and render_ok and "vaapi" in hwaccels:
|
if "h264_vaapi" in text and render_ok and "vaapi" in hwaccels:
|
||||||
return "h264_vaapi"
|
return "h264_vaapi"
|
||||||
if machine in ("x86_64", "amd64") and "h264_qsv" in text:
|
|
||||||
return "h264_qsv"
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ DOCKER_NODE_IMAGE = os.environ.get("WEB_CONSOLE_NODE_IMAGE", "node:20-bookworm-s
|
|||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||||
import env_check # noqa: E402
|
import env_check # noqa: E402
|
||||||
from src.runtime_bootstrap import raise_nofile_limit # noqa: E402
|
from src.runtime_bootstrap import ensure_runtime_layout, raise_nofile_limit # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def load_runtime_env() -> None:
|
def load_runtime_env() -> None:
|
||||||
@@ -495,6 +495,15 @@ def run_prod(host: str, port: str, skip_build: bool, reload: bool) -> None:
|
|||||||
def main() -> None:
|
def main() -> None:
|
||||||
load_runtime_env()
|
load_runtime_env()
|
||||||
raise_nofile_limit()
|
raise_nofile_limit()
|
||||||
|
runtime_issues = ensure_runtime_layout(ROOT)
|
||||||
|
if runtime_issues:
|
||||||
|
print("[launch] warning: some runtime directories are not writable by the current user:", file=sys.stderr)
|
||||||
|
for issue in runtime_issues:
|
||||||
|
print(f"[launch] - {issue}", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
"[launch] hint: fix deployment ownership for runtime dirs such as config/logs/backup_config/.pm2/.process_runner/downloads/backup",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
default_port = os.environ.get("PORT", "8001")
|
default_port = os.environ.get("PORT", "8001")
|
||||||
ap = argparse.ArgumentParser(description="直播控制台一键启动")
|
ap = argparse.ArgumentParser(description="直播控制台一键启动")
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ ENABLE_NETDATA=0
|
|||||||
|
|
||||||
ensure_live_user
|
ensure_live_user
|
||||||
normalize_repo_root
|
normalize_repo_root
|
||||||
|
ensure_runtime_dirs_owned_by_live
|
||||||
install_base_packages
|
install_base_packages
|
||||||
install_media_packages
|
install_media_packages
|
||||||
configure_adb_udev
|
configure_adb_udev
|
||||||
@@ -40,4 +41,5 @@ prepare_srs
|
|||||||
run_hardware_probe
|
run_hardware_probe
|
||||||
# 与 install-hub 一致:装 Caddy live-edge,http://live.local:80 → 本机控制平面 PORT(默认 8001)
|
# 与 install-hub 一致:装 Caddy live-edge,http://live.local:80 → 本机控制平面 PORT(默认 8001)
|
||||||
install_live_edge_proxy
|
install_live_edge_proxy
|
||||||
|
ensure_runtime_dirs_owned_by_live
|
||||||
print_summary "business"
|
print_summary "business"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ ENABLE_SRS=0
|
|||||||
|
|
||||||
ensure_live_user
|
ensure_live_user
|
||||||
normalize_repo_root
|
normalize_repo_root
|
||||||
|
ensure_runtime_dirs_owned_by_live
|
||||||
install_base_packages
|
install_base_packages
|
||||||
install_media_packages
|
install_media_packages
|
||||||
configure_adb_udev
|
configure_adb_udev
|
||||||
@@ -42,4 +43,5 @@ prepare_homepage
|
|||||||
prepare_filebrowser
|
prepare_filebrowser
|
||||||
run_hardware_probe
|
run_hardware_probe
|
||||||
install_live_edge_proxy
|
install_live_edge_proxy
|
||||||
|
ensure_runtime_dirs_owned_by_live
|
||||||
print_summary "hub"
|
print_summary "hub"
|
||||||
|
|||||||
@@ -460,6 +460,24 @@ normalize_repo_root() {
|
|||||||
WEBTTY_SHELL="$ROOT_DIR/scripts/linux/webtty_shell.sh"
|
WEBTTY_SHELL="$ROOT_DIR/scripts/linux/webtty_shell.sh"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_runtime_dirs_owned_by_live() {
|
||||||
|
local rel path
|
||||||
|
for rel in \
|
||||||
|
config \
|
||||||
|
logs \
|
||||||
|
downloads \
|
||||||
|
backup \
|
||||||
|
backup_config \
|
||||||
|
.pm2 \
|
||||||
|
.process_runner \
|
||||||
|
services/neko/data
|
||||||
|
do
|
||||||
|
path="$ROOT_DIR/$rel"
|
||||||
|
mkdir -p "$path"
|
||||||
|
chown -R live:live "$path" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
install_base_packages() {
|
install_base_packages() {
|
||||||
log "Installing base packages"
|
log "Installing base packages"
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ load_env
|
|||||||
|
|
||||||
# git pull 若以 root 执行,避免 live 用户无法写 venv / node_modules
|
# git pull 若以 root 执行,避免 live 用户无法写 venv / node_modules
|
||||||
chown -R live:live "$ROOT_DIR" || true
|
chown -R live:live "$ROOT_DIR" || true
|
||||||
|
ensure_runtime_dirs_owned_by_live
|
||||||
|
|
||||||
log "Refreshing Python venv + requirements"
|
log "Refreshing Python venv + requirements"
|
||||||
prepare_python_runtime
|
prepare_python_runtime
|
||||||
@@ -30,6 +31,8 @@ else
|
|||||||
log "ENABLE_LIVE_EDGE_PROXY is not 1 — skipping live-edge (direct :${PORT})"
|
log "ENABLE_LIVE_EDGE_PROXY is not 1 — skipping live-edge (direct :${PORT})"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
ensure_runtime_dirs_owned_by_live
|
||||||
|
|
||||||
ok=0
|
ok=0
|
||||||
for _ in 1 2 3 4 5 6; do
|
for _ in 1 2 3 4 5 6; do
|
||||||
if curl -sf -m 6 "http://127.0.0.1:${PORT}/health" >/dev/null; then
|
if curl -sf -m 6 "http://127.0.0.1:${PORT}/health" >/dev/null; then
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ def _read_text(path: Path) -> str:
|
|||||||
return path.read_text(encoding="utf-8", errors="replace")
|
return path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_write_permission_error(path: Path, exc: OSError) -> None:
|
||||||
|
raise PermissionError(
|
||||||
|
f"Permission denied: {path} is not writable by the current web service user. "
|
||||||
|
"Fix deployment ownership for runtime dirs such as config/logs/backup_config/.pm2/.process_runner/downloads/backup."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
def _file_kind(path: Path) -> str:
|
def _file_kind(path: Path) -> str:
|
||||||
name = path.name.lower()
|
name = path.name.lower()
|
||||||
if name == "url_config.ini" or (name.startswith("url_config.") and name.endswith(".ini")):
|
if name == "url_config.ini" or (name.startswith("url_config.") and name.endswith(".ini")):
|
||||||
@@ -158,13 +165,20 @@ def backup_managed_file(root_id: str, relative_path: str, *, reason: str) -> Opt
|
|||||||
if not target.is_file():
|
if not target.is_file():
|
||||||
return None
|
return None
|
||||||
hist_dir = _history_dir(root_id, rel_path)
|
hist_dir = _history_dir(root_id, rel_path)
|
||||||
hist_dir.mkdir(parents=True, exist_ok=True)
|
try:
|
||||||
|
hist_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno in {errno.EACCES, errno.EPERM}:
|
||||||
|
_raise_write_permission_error(hist_dir, exc)
|
||||||
|
raise
|
||||||
suffix = target.suffix or ".bak"
|
suffix = target.suffix or ".bak"
|
||||||
backup_name = f"{_now_stamp()}__{reason}{suffix}"
|
backup_name = f"{_now_stamp()}__{reason}{suffix}"
|
||||||
backup_path = hist_dir / backup_name
|
backup_path = hist_dir / backup_name
|
||||||
try:
|
try:
|
||||||
shutil.copy2(target, backup_path)
|
shutil.copy2(target, backup_path)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
|
if exc.errno in {errno.EACCES, errno.EPERM}:
|
||||||
|
_raise_write_permission_error(backup_path, exc)
|
||||||
if exc.errno != errno.ENOSPC:
|
if exc.errno != errno.ENOSPC:
|
||||||
raise
|
raise
|
||||||
_reclaim_history_space(root_id, rel_path, keep=0)
|
_reclaim_history_space(root_id, rel_path, keep=0)
|
||||||
@@ -172,6 +186,8 @@ def backup_managed_file(root_id: str, relative_path: str, *, reason: str) -> Opt
|
|||||||
hist_dir.mkdir(parents=True, exist_ok=True)
|
hist_dir.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copy2(target, backup_path)
|
shutil.copy2(target, backup_path)
|
||||||
except OSError as retry_exc:
|
except OSError as retry_exc:
|
||||||
|
if retry_exc.errno in {errno.EACCES, errno.EPERM}:
|
||||||
|
_raise_write_permission_error(backup_path, retry_exc)
|
||||||
if retry_exc.errno == errno.ENOSPC:
|
if retry_exc.errno == errno.ENOSPC:
|
||||||
return None
|
return None
|
||||||
raise
|
raise
|
||||||
@@ -674,10 +690,17 @@ def save_managed_file(root_id: str, relative_path: str, content: str) -> dict[st
|
|||||||
"backup": None,
|
"backup": None,
|
||||||
}
|
}
|
||||||
backup = backup_managed_file(root_id, rel_path.as_posix(), reason="save")
|
backup = backup_managed_file(root_id, rel_path.as_posix(), reason="save")
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
try:
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno in {errno.EACCES, errno.EPERM}:
|
||||||
|
_raise_write_permission_error(target.parent, exc)
|
||||||
|
raise
|
||||||
try:
|
try:
|
||||||
target.write_text(content, encoding="utf-8")
|
target.write_text(content, encoding="utf-8")
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
|
if exc.errno in {errno.EACCES, errno.EPERM}:
|
||||||
|
_raise_write_permission_error(target, exc)
|
||||||
if exc.errno != errno.ENOSPC:
|
if exc.errno != errno.ENOSPC:
|
||||||
raise
|
raise
|
||||||
_reclaim_history_space(root_id, rel_path, keep=0)
|
_reclaim_history_space(root_id, rel_path, keep=0)
|
||||||
@@ -697,7 +720,12 @@ def delete_managed_file(root_id: str, relative_path: str) -> dict[str, Any]:
|
|||||||
if not target.exists():
|
if not target.exists():
|
||||||
raise FileNotFoundError(relative_path)
|
raise FileNotFoundError(relative_path)
|
||||||
backup = backup_managed_file(root_id, rel_path.as_posix(), reason="delete")
|
backup = backup_managed_file(root_id, rel_path.as_posix(), reason="delete")
|
||||||
target.unlink()
|
try:
|
||||||
|
target.unlink()
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno in {errno.EACCES, errno.EPERM}:
|
||||||
|
_raise_write_permission_error(target, exc)
|
||||||
|
raise
|
||||||
return {
|
return {
|
||||||
"root_id": root.root_id,
|
"root_id": root.root_id,
|
||||||
"path": rel_path.as_posix(),
|
"path": rel_path.as_posix(),
|
||||||
@@ -725,8 +753,13 @@ def restore_backup(root_id: str, relative_path: str, backup_id: str) -> dict[str
|
|||||||
|
|
||||||
backup = backup_managed_file(root_id, rel_path.as_posix(), reason="restore")
|
backup = backup_managed_file(root_id, rel_path.as_posix(), reason="restore")
|
||||||
content = _read_text(source)
|
content = _read_text(source)
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
try:
|
||||||
target.write_text(content, encoding="utf-8")
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_text(content, encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno in {errno.EACCES, errno.EPERM}:
|
||||||
|
_raise_write_permission_error(target, exc)
|
||||||
|
raise
|
||||||
return {
|
return {
|
||||||
"root_id": root.root_id,
|
"root_id": root.root_id,
|
||||||
"path": rel_path.as_posix(),
|
"path": rel_path.as_posix(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
@@ -10,6 +11,16 @@ except ModuleNotFoundError: # pragma: no cover - Windows fallback for local tes
|
|||||||
resource = None
|
resource = None
|
||||||
|
|
||||||
MsgPushFn = Callable[..., Any]
|
MsgPushFn = Callable[..., Any]
|
||||||
|
RUNTIME_DIRS = (
|
||||||
|
"config",
|
||||||
|
"logs",
|
||||||
|
"downloads",
|
||||||
|
"backup",
|
||||||
|
"backup_config",
|
||||||
|
".pm2",
|
||||||
|
".process_runner",
|
||||||
|
"services/neko/data",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def ensure_repo_root(entry_file: str | Path) -> Path:
|
def ensure_repo_root(entry_file: str | Path) -> Path:
|
||||||
@@ -31,6 +42,21 @@ def _noop_msg_push(reason: str) -> MsgPushFn:
|
|||||||
return _push
|
return _push
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_runtime_layout(base_dir: str | Path) -> list[str]:
|
||||||
|
root = Path(base_dir).resolve()
|
||||||
|
issues: list[str] = []
|
||||||
|
for rel in RUNTIME_DIRS:
|
||||||
|
path = root / rel
|
||||||
|
try:
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
issues.append(f"{path}: mkdir failed: {exc}")
|
||||||
|
continue
|
||||||
|
if not os.access(path, os.W_OK | os.X_OK):
|
||||||
|
issues.append(f"{path}: directory is not writable by uid={os.geteuid()}")
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
def compute_nofile_target(soft: int, hard: int, minimum: int = 65536) -> int:
|
def compute_nofile_target(soft: int, hard: int, minimum: int = 65536) -> int:
|
||||||
target = max(int(soft), int(minimum))
|
target = max(int(soft), int(minimum))
|
||||||
if resource is not None and hard == resource.RLIM_INFINITY:
|
if resource is not None and hard == resource.RLIM_INFINITY:
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ def resolve_live_video_encoder(
|
|||||||
return arm_candidates[0], ""
|
return arm_candidates[0], ""
|
||||||
return "", ""
|
return "", ""
|
||||||
|
|
||||||
|
if requested == "h264_qsv":
|
||||||
|
if has_encoder("h264_vaapi") and has_render_node:
|
||||||
|
return "h264_vaapi", "LIVE_VIDEO_ENCODER=h264_qsv is not enabled in this Linux stack yet, fallback to h264_vaapi"
|
||||||
|
return "", "LIVE_VIDEO_ENCODER=h264_qsv is not enabled in this Linux stack yet, fallback to libx264"
|
||||||
if requested == "h264_vaapi" and not has_render_node:
|
if requested == "h264_vaapi" and not has_render_node:
|
||||||
return "", "LIVE_VIDEO_ENCODER=h264_vaapi requires /dev/dri/renderD*; fallback to libx264"
|
return "", "LIVE_VIDEO_ENCODER=h264_vaapi requires /dev/dri/renderD*; fallback to libx264"
|
||||||
if requested and requested.startswith("h264_") and has_encoder(requested):
|
if requested and requested.startswith("h264_") and has_encoder(requested):
|
||||||
|
|||||||
@@ -132,6 +132,34 @@ class ConfigGovernanceTests(unittest.TestCase):
|
|||||||
self.assertGreaterEqual(calls["n"], 2)
|
self.assertGreaterEqual(calls["n"], 2)
|
||||||
self.assertFalse(hist_dir.exists())
|
self.assertFalse(hist_dir.exists())
|
||||||
|
|
||||||
|
def test_save_managed_file_surfaces_permission_hint(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
tmp_path = Path(tmp)
|
||||||
|
root_path = tmp_path / "config"
|
||||||
|
root_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = root_path / "youtube.demo.ini"
|
||||||
|
target.write_text("[youtube]\nkey = old\n", encoding="utf-8")
|
||||||
|
root = ManagedRoot(
|
||||||
|
root_id="app-config",
|
||||||
|
label="App Config",
|
||||||
|
description="test",
|
||||||
|
path=root_path,
|
||||||
|
explicit_names=("youtube.demo.ini",),
|
||||||
|
)
|
||||||
|
real_write_text = Path.write_text
|
||||||
|
|
||||||
|
def deny_write(self, *args, **kwargs):
|
||||||
|
if self == target:
|
||||||
|
raise OSError(errno.EACCES, "Permission denied")
|
||||||
|
return real_write_text(self, *args, **kwargs)
|
||||||
|
|
||||||
|
with mock.patch("src.config_governance.resolve_managed_file", return_value=(root, Path("youtube.demo.ini"), target)):
|
||||||
|
with mock.patch("pathlib.Path.write_text", new=deny_write):
|
||||||
|
with self.assertRaises(PermissionError) as ctx:
|
||||||
|
save_managed_file("app-config", "youtube.demo.ini", "[youtube]\nkey = new\n")
|
||||||
|
|
||||||
|
self.assertIn("backup_config", str(ctx.exception))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from src.runtime_bootstrap import compute_nofile_target, load_msg_push_exports
|
from src.runtime_bootstrap import compute_nofile_target, ensure_runtime_layout, load_msg_push_exports
|
||||||
|
|
||||||
|
|
||||||
class RuntimeBootstrapTests(unittest.TestCase):
|
class RuntimeBootstrapTests(unittest.TestCase):
|
||||||
@@ -18,6 +20,20 @@ class RuntimeBootstrapTests(unittest.TestCase):
|
|||||||
result = funcs[0]("demo")
|
result = funcs[0]("demo")
|
||||||
self.assertTrue(result["disabled"])
|
self.assertTrue(result["disabled"])
|
||||||
|
|
||||||
|
def test_ensure_runtime_layout_creates_expected_directories(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
issues = ensure_runtime_layout(Path(tmp))
|
||||||
|
self.assertEqual(issues, [])
|
||||||
|
self.assertTrue((Path(tmp) / "backup_config").is_dir())
|
||||||
|
self.assertTrue((Path(tmp) / ".process_runner").is_dir())
|
||||||
|
|
||||||
|
def test_ensure_runtime_layout_reports_unwritable_directory(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
with patch("src.runtime_bootstrap.os.access", side_effect=lambda p, mode: str(p) != str(root / "logs")):
|
||||||
|
issues = ensure_runtime_layout(root)
|
||||||
|
self.assertTrue(any("logs" in issue for issue in issues))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -67,6 +67,17 @@ class YoutubeRuntimeTests(unittest.TestCase):
|
|||||||
self.assertEqual(enc, "")
|
self.assertEqual(enc, "")
|
||||||
self.assertIn("renderD", note)
|
self.assertIn("renderD", note)
|
||||||
|
|
||||||
|
def test_x86_qsv_falls_back_to_vaapi_for_safe_linux_stack(self) -> None:
|
||||||
|
enc, note = resolve_live_video_encoder(
|
||||||
|
"h264_qsv",
|
||||||
|
"h264_qsv\nh264_vaapi\n",
|
||||||
|
"x86_64",
|
||||||
|
has_mpp_service=False,
|
||||||
|
has_render_node=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(enc, "h264_vaapi")
|
||||||
|
self.assertIn("fallback", note)
|
||||||
|
|
||||||
def test_arm_rejects_v4l2m2m_without_video_nodes(self) -> None:
|
def test_arm_rejects_v4l2m2m_without_video_nodes(self) -> None:
|
||||||
enc, note = resolve_live_video_encoder(
|
enc, note = resolve_live_video_encoder(
|
||||||
"h264_v4l2m2m",
|
"h264_v4l2m2m",
|
||||||
|
|||||||
@@ -88,6 +88,12 @@ process_backend = WebProcessBackend(BASE_DIR, DEFAULT_LOG_DIR)
|
|||||||
raise_nofile_limit()
|
raise_nofile_limit()
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_layout_warnings() -> list[str]:
|
||||||
|
from src.runtime_bootstrap import ensure_runtime_layout
|
||||||
|
|
||||||
|
return ensure_runtime_layout(BASE_DIR)
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_pm2_dir_fragment(name: str) -> str:
|
def _sanitize_pm2_dir_fragment(name: str) -> str:
|
||||||
s = re.sub(r"[^a-zA-Z0-9._-]+", "_", (name or "").strip())
|
s = re.sub(r"[^a-zA-Z0-9._-]+", "_", (name or "").strip())
|
||||||
return (s or "default")[:120]
|
return (s or "default")[:120]
|
||||||
@@ -496,6 +502,10 @@ def script_for_pm2(pm2: str) -> Optional[str]:
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
runtime_issues = _runtime_layout_warnings()
|
||||||
|
if runtime_issues:
|
||||||
|
for issue in runtime_issues:
|
||||||
|
print(f"[web] runtime-dir warning: {issue}", file=sys.stderr)
|
||||||
try:
|
try:
|
||||||
await process_backend.ensure_mode()
|
await process_backend.ensure_mode()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
Reference in New Issue
Block a user