diff --git a/d2ypp2/config/hardware.env.example b/d2ypp2/config/hardware.env.example index e38d73e..a7dd794 100644 --- a/d2ypp2/config/hardware.env.example +++ b/d2ypp2/config/hardware.env.example @@ -4,7 +4,7 @@ # —— ARM(RK3588 等)常见:h264_rkmpp(需 /dev/mpp_service 与 ffmpeg 含 rkmpp) # —— ARM 通用 V4L2:h264_v4l2m2m # —— 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 # # LIVE_VIDEO_ENCODER=h264_v4l2m2m diff --git a/d2ypp2/scripts/hardware_probe.py b/d2ypp2/scripts/hardware_probe.py index be9e6ae..0dd94f2 100644 --- a/d2ypp2/scripts/hardware_probe.py +++ b/d2ypp2/scripts/hardware_probe.py @@ -269,8 +269,9 @@ def build_profile() -> dict: def suggest_video_encoder(profile: dict) -> str: """在 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,此处不重复写入。 + QSV 在部分 Linux 发行版上需要额外的设备/滤镜链路,本项目默认不自动启用,以优先保证跨机兼容性。 """ if platform.system().lower() != "linux": return "" @@ -291,8 +292,6 @@ def suggest_video_encoder(profile: dict) -> str: return "" if "h264_vaapi" in text and render_ok and "vaapi" in hwaccels: return "h264_vaapi" - if machine in ("x86_64", "amd64") and "h264_qsv" in text: - return "h264_qsv" return "" diff --git a/d2ypp2/scripts/launch.py b/d2ypp2/scripts/launch.py index 866f678..91c29b9 100644 --- a/d2ypp2/scripts/launch.py +++ b/d2ypp2/scripts/launch.py @@ -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(SCRIPTS_DIR)) 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: @@ -495,6 +495,15 @@ def run_prod(host: str, port: str, skip_build: bool, reload: bool) -> None: def main() -> None: load_runtime_env() 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") ap = argparse.ArgumentParser(description="直播控制台一键启动") diff --git a/d2ypp2/scripts/linux/install_business.sh b/d2ypp2/scripts/linux/install_business.sh index bd3bbe5..deeb6ed 100644 --- a/d2ypp2/scripts/linux/install_business.sh +++ b/d2ypp2/scripts/linux/install_business.sh @@ -16,6 +16,7 @@ ENABLE_NETDATA=0 ensure_live_user normalize_repo_root +ensure_runtime_dirs_owned_by_live install_base_packages install_media_packages configure_adb_udev @@ -40,4 +41,5 @@ prepare_srs run_hardware_probe # 与 install-hub 一致:装 Caddy live-edge,http://live.local:80 → 本机控制平面 PORT(默认 8001) install_live_edge_proxy +ensure_runtime_dirs_owned_by_live print_summary "business" diff --git a/d2ypp2/scripts/linux/install_hub.sh b/d2ypp2/scripts/linux/install_hub.sh index 7d477cd..eaa2687 100644 --- a/d2ypp2/scripts/linux/install_hub.sh +++ b/d2ypp2/scripts/linux/install_hub.sh @@ -13,6 +13,7 @@ ENABLE_SRS=0 ensure_live_user normalize_repo_root +ensure_runtime_dirs_owned_by_live install_base_packages install_media_packages configure_adb_udev @@ -42,4 +43,5 @@ prepare_homepage prepare_filebrowser run_hardware_probe install_live_edge_proxy +ensure_runtime_dirs_owned_by_live print_summary "hub" diff --git a/d2ypp2/scripts/linux/lib/common.sh b/d2ypp2/scripts/linux/lib/common.sh index e9531c7..216171a 100644 --- a/d2ypp2/scripts/linux/lib/common.sh +++ b/d2ypp2/scripts/linux/lib/common.sh @@ -460,6 +460,24 @@ normalize_repo_root() { 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() { log "Installing base packages" export DEBIAN_FRONTEND=noninteractive diff --git a/d2ypp2/scripts/linux/upgrade_live_console.sh b/d2ypp2/scripts/linux/upgrade_live_console.sh index 9c022cb..6972b90 100644 --- a/d2ypp2/scripts/linux/upgrade_live_console.sh +++ b/d2ypp2/scripts/linux/upgrade_live_console.sh @@ -12,6 +12,7 @@ load_env # git pull 若以 root 执行,避免 live 用户无法写 venv / node_modules chown -R live:live "$ROOT_DIR" || true +ensure_runtime_dirs_owned_by_live log "Refreshing Python venv + requirements" prepare_python_runtime @@ -30,6 +31,8 @@ else log "ENABLE_LIVE_EDGE_PROXY is not 1 — skipping live-edge (direct :${PORT})" fi +ensure_runtime_dirs_owned_by_live + ok=0 for _ in 1 2 3 4 5 6; do if curl -sf -m 6 "http://127.0.0.1:${PORT}/health" >/dev/null; then diff --git a/d2ypp2/src/config_governance.py b/d2ypp2/src/config_governance.py index b72195f..080e33e 100644 --- a/d2ypp2/src/config_governance.py +++ b/d2ypp2/src/config_governance.py @@ -29,6 +29,13 @@ def _read_text(path: Path) -> str: 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: name = path.name.lower() 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(): return None 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" backup_name = f"{_now_stamp()}__{reason}{suffix}" backup_path = hist_dir / backup_name try: shutil.copy2(target, backup_path) except OSError as exc: + if exc.errno in {errno.EACCES, errno.EPERM}: + _raise_write_permission_error(backup_path, exc) if exc.errno != errno.ENOSPC: raise _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) shutil.copy2(target, backup_path) 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: return None raise @@ -674,10 +690,17 @@ def save_managed_file(root_id: str, relative_path: str, content: str) -> dict[st "backup": None, } 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: 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) if exc.errno != errno.ENOSPC: raise _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(): raise FileNotFoundError(relative_path) 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 { "root_id": root.root_id, "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") content = _read_text(source) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(content, encoding="utf-8") + try: + 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 { "root_id": root.root_id, "path": rel_path.as_posix(), diff --git a/d2ypp2/src/runtime_bootstrap.py b/d2ypp2/src/runtime_bootstrap.py index 0b37604..706aeab 100644 --- a/d2ypp2/src/runtime_bootstrap.py +++ b/d2ypp2/src/runtime_bootstrap.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import sys from pathlib import Path from typing import Any, Callable @@ -10,6 +11,16 @@ except ModuleNotFoundError: # pragma: no cover - Windows fallback for local tes resource = None 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: @@ -31,6 +42,21 @@ def _noop_msg_push(reason: str) -> MsgPushFn: 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: target = max(int(soft), int(minimum)) if resource is not None and hard == resource.RLIM_INFINITY: diff --git a/d2ypp2/src/youtube_runtime.py b/d2ypp2/src/youtube_runtime.py index 72cd04e..77ef1f9 100644 --- a/d2ypp2/src/youtube_runtime.py +++ b/d2ypp2/src/youtube_runtime.py @@ -61,6 +61,10 @@ def resolve_live_video_encoder( return arm_candidates[0], "" 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: return "", "LIVE_VIDEO_ENCODER=h264_vaapi requires /dev/dri/renderD*; fallback to libx264" if requested and requested.startswith("h264_") and has_encoder(requested): diff --git a/d2ypp2/tests/test_config_governance.py b/d2ypp2/tests/test_config_governance.py index b43cc2f..6b50854 100644 --- a/d2ypp2/tests/test_config_governance.py +++ b/d2ypp2/tests/test_config_governance.py @@ -132,6 +132,34 @@ class ConfigGovernanceTests(unittest.TestCase): self.assertGreaterEqual(calls["n"], 2) 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__": unittest.main() diff --git a/d2ypp2/tests/test_runtime_bootstrap.py b/d2ypp2/tests/test_runtime_bootstrap.py index 8a2c4f1..3c3131f 100644 --- a/d2ypp2/tests/test_runtime_bootstrap.py +++ b/d2ypp2/tests/test_runtime_bootstrap.py @@ -1,9 +1,11 @@ from __future__ import annotations +import tempfile import unittest +from pathlib import Path 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): @@ -18,6 +20,20 @@ class RuntimeBootstrapTests(unittest.TestCase): result = funcs[0]("demo") 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__": unittest.main() diff --git a/d2ypp2/tests/test_youtube_runtime.py b/d2ypp2/tests/test_youtube_runtime.py index cd53b2b..ff01fd2 100644 --- a/d2ypp2/tests/test_youtube_runtime.py +++ b/d2ypp2/tests/test_youtube_runtime.py @@ -67,6 +67,17 @@ class YoutubeRuntimeTests(unittest.TestCase): self.assertEqual(enc, "") 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: enc, note = resolve_live_video_encoder( "h264_v4l2m2m", diff --git a/d2ypp2/web.py b/d2ypp2/web.py index f9caa2a..f943ce6 100644 --- a/d2ypp2/web.py +++ b/d2ypp2/web.py @@ -88,6 +88,12 @@ process_backend = WebProcessBackend(BASE_DIR, DEFAULT_LOG_DIR) 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: s = re.sub(r"[^a-zA-Z0-9._-]+", "_", (name or "").strip()) return (s or "default")[:120] @@ -496,6 +502,10 @@ def script_for_pm2(pm2: str) -> Optional[str]: @asynccontextmanager 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: await process_backend.ensure_mode() except Exception: