This commit is contained in:
root
2026-05-18 06:48:30 +00:00
parent f1145e3a36
commit e638a4656c
14 changed files with 172 additions and 11 deletions

View File

@@ -4,7 +4,7 @@
# —— ARMRK3588 等常见h264_rkmpp需 /dev/mpp_service 与 ffmpeg 含 rkmpp # —— ARMRK3588 等常见h264_rkmpp需 /dev/mpp_service 与 ffmpeg 含 rkmpp
# —— ARM 通用 V4L2h264_v4l2m2m # —— ARM 通用 V4L2h264_v4l2m2m
# —— x86_64 Intel/AMD 核显VA-APIh264_vaapi需 /dev/dri render 节点) # —— x86_64 Intel/AMD 核显VA-APIh264_vaapi需 /dev/dri render 节点)
# —— x86_64 Intel Quick Synch264_qsv需 ffmpeg 编译带 libmfx/oneVPL # —— x86_64 Intel Quick Sync本项目默认不自动启用 h264_qsvLinux 上优先走更稳的 h264_vaapi
# —— NVIDIA一般无需写此处推流脚本会自行检测 nvidia-smi + h264_nvenc # —— NVIDIA一般无需写此处推流脚本会自行检测 nvidia-smi + h264_nvenc
# #
# LIVE_VIDEO_ENCODER=h264_v4l2m2m # LIVE_VIDEO_ENCODER=h264_v4l2m2m

View File

@@ -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-APIx86 核显)→ QSVIntel x86 优先级RKMPP常见 ARM SoC→ V4L2M2M通用 ARM/部分板)→ VA-APIx86 核显)。
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 ""

View File

@@ -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="直播控制台一键启动")

View File

@@ -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-edgehttp://live.local:80 → 本机控制平面 PORT默认 8001 # 与 install-hub 一致:装 Caddy live-edgehttp://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"

View File

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

View File

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

View File

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

View File

@@ -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(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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