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

@@ -33,6 +33,9 @@ class HubRoutesTests(unittest.TestCase):
backend = type("StubBackend", (), {})()
backend.mode = "local"
backend.ensure_mode = AsyncMock()
backend.process_infos = AsyncMock(
return_value={"youtube": {"process_status": "not_found", "out_path": None, "err_path": None, "pid": 0}}
)
backend.parse_pm2_describe = AsyncMock(return_value={"process_status": "not_found", "out_path": None, "err_path": None})
backend.live_ffmpeg_pids = AsyncMock(return_value=[])
configure_hub(backend, [{"pm2": "youtube", "label": "YouTube", "script": "youtube.py"}])

View File

@@ -14,7 +14,6 @@ class NekoConfigTests(unittest.TestCase):
self.assertIn("arch=\"$(uname -m", script)
self.assertIn("--disable-software-rasterizer", script)
self.assertIn("--use-gl=swiftshader", script)
self.assertIn("--bwsi", script)
self.assertIn("NEKO_BROWSER_RENDER_MODE", script)
self.assertIn('prune_path "$profile_dir/BrowserMetrics"', script)
self.assertIn('prune_path "$profile_dir/Crash Reports"', script)
@@ -27,12 +26,14 @@ class NekoConfigTests(unittest.TestCase):
self.assertEqual(policies["DefaultGeolocationSetting"], 1)
self.assertTrue(policies["VideoCaptureAllowed"])
self.assertTrue(policies["AudioCaptureAllowed"])
self.assertEqual(policies["DeveloperToolsAvailability"], 1)
def test_google_chrome_supervisor_uses_direct_stable_command(self) -> None:
conf = (ROOT / "services" / "neko" / "google-chrome.conf").read_text(encoding="utf-8")
self.assertIn("command=/usr/bin/google-chrome", conf)
self.assertNotIn("browser-launch.sh", conf)
self.assertIn("--user-data-dir=/home/neko/.config/google-chrome", conf)
self.assertNotIn("--bwsi", conf)
self.assertIn("--disable-software-rasterizer", conf)
self.assertIn("autorestart=true", conf)
self.assertIn("[program:openbox]", conf)
@@ -43,6 +44,7 @@ class NekoConfigTests(unittest.TestCase):
self.assertIn("command=/usr/bin/chromium", conf)
self.assertNotIn("browser-launch.sh", conf)
self.assertIn("--user-data-dir=/home/neko/.config/chromium", conf)
self.assertNotIn("--bwsi", conf)
self.assertIn("--disable-software-rasterizer", conf)
self.assertIn("autorestart=true", conf)
self.assertIn("[program:openbox]", conf)

View File

@@ -33,13 +33,13 @@ class WebDynamicProcessesTests(unittest.TestCase):
self.assertEqual(resolved, "youtube.py")
def test_process_monitor_entries_overlay_static_youtube_label_for_lane_one(self) -> None:
def test_process_monitor_entries_keep_static_youtube_label_for_lane_one(self) -> None:
channels = [{"id": "lane1", "name": "Lane 1", "pm2Name": "youtube"}]
with patch("web.get_youtube_pro_channels", return_value=channels):
entries = web.process_monitor_entries()
youtube_row = next(row for row in entries if row["pm2"] == "youtube")
self.assertEqual(youtube_row["label"], "YouTube Pro \u00b7 Lane 1")
self.assertEqual(youtube_row["label"], "YouTube")
def test_process_monitor_entries_do_not_duplicate_static_pm2_rows(self) -> None:
channels = [
@@ -85,10 +85,13 @@ class WebDynamicProcessesTests(unittest.TestCase):
web.process_backend._use_pm2 = False
try:
with patch.object(web.process_backend, "ensure_mode", AsyncMock()):
with patch("web.get_process_info", AsyncMock(return_value={"process_status": "not_found", "out_path": None, "err_path": None})):
with patch.object(
web.process_backend,
"process_infos",
AsyncMock(return_value={"youtube": {"process_status": "not_found", "out_path": None, "err_path": None}}),
):
with patch("web.read_log_path_normalized", AsyncMock(return_value="")):
with patch.object(web.process_backend, "live_ffmpeg_pids", AsyncMock(return_value=[])):
payload = asyncio.run(web.process_monitor())
payload = asyncio.run(web.process_monitor())
finally:
web.process_backend._use_pm2 = original_use_pm2
@@ -97,6 +100,59 @@ class WebDynamicProcessesTests(unittest.TestCase):
self.assertEqual(youtube_row["business_status"], "ready")
self.assertFalse(youtube_row["is_pushing"])
def test_process_monitor_family_filter_returns_youtube_rows_only(self) -> None:
original_use_pm2 = web.process_backend._use_pm2
web.process_backend._use_pm2 = False
try:
with patch("web.process_monitor_entries", return_value=(
{"pm2": "youtube", "script": "youtube.py", "label": "YouTube"},
{"pm2": "youtube2__lane1", "script": "youtube.py", "label": "Lane 1"},
{"pm2": "tiktok", "script": "tiktok.py", "label": "TikTok"},
)):
with patch.object(web.process_backend, "ensure_mode", AsyncMock()):
with patch.object(
web.process_backend,
"process_infos",
AsyncMock(
return_value={
"youtube": {"process_status": "stopped", "out_path": None, "err_path": None},
"youtube2__lane1": {"process_status": "online", "out_path": None, "err_path": None},
}
),
):
with patch("web.read_log_path_normalized", AsyncMock(return_value="")):
with patch.object(web.process_backend, "live_ffmpeg_pids", AsyncMock(return_value=[])):
payload = asyncio.run(web.process_monitor(family="youtube", light=True))
finally:
web.process_backend._use_pm2 = original_use_pm2
pm2_names = {row["pm2"] for row in payload["entries"]}
self.assertIn("youtube", pm2_names)
self.assertIn("youtube2__lane1", pm2_names)
self.assertTrue(all(name.startswith("youtube") or name.startswith("douyin_youtube") for name in pm2_names))
def test_restart_kills_old_ffmpeg_before_backend_restart(self) -> None:
calls: list[str] = []
async def fake_kill(process: str, base_dir, **_kwargs) -> None: # noqa: ANN001
calls.append(f"kill:{process}")
async def fake_restart(process: str, script: str) -> str:
calls.append(f"restart:{process}:{script}")
return "ok"
with patch("web.is_valid_control_process", return_value=True):
with patch("web.script_for_pm2_or_pro", return_value="youtube.py"):
with patch("web._validate_youtube_runtime_files", return_value=[]):
with patch("web._stop_other_hdmi_sink_processes", new=AsyncMock(return_value=[])):
with patch("web.force_kill_ffmpeg", new=AsyncMock(side_effect=fake_kill)):
with patch.object(web.process_backend, "restart", new=AsyncMock(side_effect=fake_restart)):
response = asyncio.run(web.restart("youtube"))
payload = json.loads(response.body)
self.assertEqual(payload["output"], "ok")
self.assertEqual(calls, ["kill:youtube", "restart:youtube:youtube.py"])
if __name__ == "__main__":
unittest.main()

View File

@@ -29,41 +29,56 @@ class WebProcessBackendTests(unittest.TestCase):
self.assertIn(str(script), cmd)
self.assertIn(str(venv_python), cmd)
def test_parse_pm2_describe_marks_unknown_unregistered_process_as_not_found(self) -> None:
def test_parse_pm2_describe_marks_missing_jlist_process_as_not_found(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
base_dir = Path(tmp)
backend = WebProcessBackend(base_dir, base_dir / ".pm2" / "logs")
backend._use_pm2 = True
backend.ensure_mode = AsyncMock()
backend._pm2_registered_names = AsyncMock(return_value=set())
warn = "[PM2][WARN] Current process list is not synchronized with saved list. App youtube differs."
with patch("src.web_process_backend.run_pm2", new=AsyncMock(return_value=warn)):
info = asyncio.run(backend.parse_pm2_describe("youtube"))
backend.process_infos = AsyncMock(return_value={"youtube": {"process_status": "not_found", "out_path": None, "err_path": None, "pid": 0}}) # type: ignore[method-assign]
info = asyncio.run(backend.parse_pm2_describe("youtube"))
self.assertEqual(info["process_status"], "not_found")
self.assertIsNone(info["out_path"])
self.assertIsNone(info["err_path"])
self.assertEqual(info["pid"], 0)
def test_parse_pm2_describe_supports_spaced_log_path_keys(self) -> None:
def test_parse_pm2_describe_reads_status_and_log_paths_from_jlist_item(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
base_dir = Path(tmp)
backend = WebProcessBackend(base_dir, base_dir / ".pm2" / "logs")
backend._use_pm2 = True
backend.ensure_mode = AsyncMock()
backend._pm2_registered_names = AsyncMock(return_value={"youtube"})
describe = "\n".join(
[
"│ status │ online │",
"│ out log path │ /home/live/.pm2/logs/youtube-out.log │",
"│ error log path │ /home/live/.pm2/logs/youtube-error.log │",
]
backend.process_infos = AsyncMock( # type: ignore[method-assign]
return_value={
"youtube": {
"process_status": "online",
"out_path": "/home/live/.pm2/logs/youtube-out.log",
"err_path": "/home/live/.pm2/logs/youtube-error.log",
"pid": 4567,
}
}
)
with patch("src.web_process_backend.run_pm2", new=AsyncMock(return_value=describe)):
info = asyncio.run(backend.parse_pm2_describe("youtube"))
info = asyncio.run(backend.parse_pm2_describe("youtube"))
self.assertEqual(info["process_status"], "online")
self.assertEqual(info["out_path"], "/home/live/.pm2/logs/youtube-out.log")
self.assertEqual(info["err_path"], "/home/live/.pm2/logs/youtube-error.log")
self.assertEqual(info["pid"], 4567)
def test_ensure_mode_resurrects_pm2_when_dump_exists_and_jlist_is_empty(self) -> None:
def test_pm2_jlist_items_uses_short_ttl_cache(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
base_dir = Path(tmp)
backend = WebProcessBackend(base_dir, base_dir / ".pm2" / "logs")
shell_out = AsyncMock(return_value=(0, '[{"name":"youtube","pm2_env":{"status":"online"}}]'))
with patch("src.web_process_backend.resolve_pm2_bin", return_value="/usr/bin/pm2"):
with patch("src.web_process_backend._shell_out", new=shell_out):
first = asyncio.run(backend._pm2_jlist_items())
second = asyncio.run(backend._pm2_jlist_items())
self.assertEqual(first[0]["name"], "youtube")
self.assertEqual(second[0]["name"], "youtube")
shell_out.assert_awaited_once()
def test_ensure_pm2_runtime_resurrects_pm2_when_dump_exists_and_jlist_is_empty(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
base_dir = Path(tmp)
pm2_home = base_dir / ".pm2-home"
@@ -75,16 +90,16 @@ class WebProcessBackendTests(unittest.TestCase):
backend._use_pm2 = True
shell_out = AsyncMock(return_value=(0, "[]"))
resurrect = AsyncMock(return_value="ok")
resurrect = AsyncMock(return_value=(0, "ok"))
with patch.dict("os.environ", {"PM2_HOME": str(pm2_home)}, clear=False):
with patch("src.web_process_backend.resolve_pm2_bin", return_value="/usr/bin/pm2"):
with patch("src.web_process_backend._shell_out", new=shell_out):
with patch("src.web_process_backend.run_pm2", new=resurrect):
asyncio.run(backend.ensure_mode())
with patch("src.web_process_backend.run_pm2_result", new=resurrect):
asyncio.run(backend.ensure_pm2_runtime())
resurrect.assert_awaited_once_with("resurrect", timeout=45.0)
def test_ensure_mode_skips_resurrect_without_saved_dump(self) -> None:
def test_ensure_pm2_runtime_skips_resurrect_without_saved_dump(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
base_dir = Path(tmp)
pm2_home = base_dir / ".pm2-home"
@@ -95,15 +110,52 @@ class WebProcessBackendTests(unittest.TestCase):
backend._use_pm2 = True
shell_out = AsyncMock(return_value=(0, "[]"))
resurrect = AsyncMock(return_value="ok")
resurrect = AsyncMock(return_value=(0, "ok"))
with patch.dict("os.environ", {"PM2_HOME": str(pm2_home)}, clear=False):
with patch("src.web_process_backend.resolve_pm2_bin", return_value="/usr/bin/pm2"):
with patch("src.web_process_backend._shell_out", new=shell_out):
with patch("src.web_process_backend.run_pm2", new=resurrect):
asyncio.run(backend.ensure_mode())
with patch("src.web_process_backend.run_pm2_result", new=resurrect):
asyncio.run(backend.ensure_pm2_runtime())
resurrect.assert_not_awaited()
def test_start_does_not_mark_desired_running_when_shell_command_fails(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
base_dir = Path(tmp)
script = base_dir / "youtube.py"
script.write_text("print('ok')\n", encoding="utf-8")
backend = WebProcessBackend(base_dir, base_dir / ".pm2" / "logs")
backend._use_pm2 = True
with patch.object(backend, "ensure_mode", AsyncMock()):
with patch.object(backend, "_pm2_registered_names", AsyncMock(return_value=set())):
with patch.object(backend, "clear_process_logs", AsyncMock()):
with patch("src.web_process_backend.build_pm2_start_command", return_value="pm2 start ..."):
with patch("src.web_process_backend._shell_out", new=AsyncMock(return_value=(1, "boom"))):
with patch.object(backend, "_set_pm2_desired_running", AsyncMock()) as set_desired:
with patch.object(backend, "_pm2_save", AsyncMock()):
text = asyncio.run(backend.start("youtube", "youtube.py"))
self.assertEqual(text, "boom")
set_desired.assert_not_awaited()
def test_live_ffmpeg_pids_uses_process_tree_of_target_process(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
base_dir = Path(tmp)
backend = WebProcessBackend(base_dir, base_dir / ".pm2" / "logs")
backend.ensure_mode = AsyncMock()
backend.process_infos = AsyncMock( # type: ignore[method-assign]
return_value={"youtube": {"pid": 4321, "process_status": "online"}}
)
with patch("src.web_process_backend.os.getpgid", return_value=6789):
with patch("src.web_process_backend._linux_ffmpeg_pids_for_process_tree", return_value=[9876]) as finder:
pids = asyncio.run(backend.live_ffmpeg_pids("youtube"))
self.assertEqual(pids, [9876])
finder.assert_called_once_with(4321, root_pgid=6789)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from src.youtube_config_paths import resolve_youtube_style_config_paths
class YoutubeConfigPathTests(unittest.TestCase):
def test_non_youtube_process_uses_legacy_paths(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
cfg = root / "config"
cfg.mkdir(parents=True, exist_ok=True)
yt, url = resolve_youtube_style_config_paths(root, "tiktok")
self.assertEqual(yt, str(cfg / "youtube.ini"))
self.assertEqual(url, str(cfg / "URL_config.ini"))
def test_youtube_process_uses_legacy_pair_when_only_legacy_exists(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
cfg = root / "config"
cfg.mkdir(parents=True, exist_ok=True)
(cfg / "youtube.ini").write_text("[youtube]\nkey = legacy\n", encoding="utf-8")
(cfg / "URL_config.ini").write_text("https://live.douyin.com/1\n", encoding="utf-8")
yt, url = resolve_youtube_style_config_paths(root, "youtube")
self.assertEqual(yt, str(cfg / "youtube.ini"))
self.assertEqual(url, str(cfg / "URL_config.ini"))
def test_youtube_process_falls_back_per_file_when_only_managed_url_exists(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
cfg = root / "config"
cfg.mkdir(parents=True, exist_ok=True)
(cfg / "youtube.ini").write_text("[youtube]\nkey = legacy\n", encoding="utf-8")
managed_url = cfg / "URL_config.youtube.ini"
managed_url.write_text("https://live.douyin.com/2\n", encoding="utf-8")
yt, url = resolve_youtube_style_config_paths(root, "youtube")
self.assertEqual(yt, str(cfg / "youtube.ini"))
self.assertEqual(url, str(managed_url))
def test_youtube_process_falls_back_per_file_when_only_managed_youtube_ini_exists(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
cfg = root / "config"
cfg.mkdir(parents=True, exist_ok=True)
managed_yt = cfg / "youtube.youtube.ini"
managed_yt.write_text("[youtube]\nkey = managed\n", encoding="utf-8")
(cfg / "URL_config.ini").write_text("https://live.douyin.com/3\n", encoding="utf-8")
yt, url = resolve_youtube_style_config_paths(root, "youtube")
self.assertEqual(yt, str(managed_yt))
self.assertEqual(url, str(cfg / "URL_config.ini"))
if __name__ == "__main__":
unittest.main()

View File

@@ -9,6 +9,7 @@ from src.youtube_pro_runtime import (
managed_url_config_relpath_for_pm2,
managed_youtube_ini_relpath_for_pm2,
normalize_youtube_pro_channels,
validate_youtube_runtime_files,
)
@@ -76,6 +77,28 @@ class YoutubeProRuntimeTests(unittest.TestCase):
self.assertIn("legacy-key", youtube_ini)
self.assertIn("https://live.douyin.com/88888888", url_ini)
def test_validate_runtime_files_reports_missing_single_lane_inputs(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
config_dir = root / "config"
config_dir.mkdir(parents=True, exist_ok=True)
errors = validate_youtube_runtime_files(root, config_dir, "youtube", [])
self.assertEqual(errors, ["youtube: stream key missing", "youtube: source URL missing"])
def test_validate_runtime_files_accepts_legacy_single_lane_pair(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
config_dir = root / "config"
config_dir.mkdir(parents=True, exist_ok=True)
(config_dir / "youtube.ini").write_text("[youtube]\nkey = legacy-key\nrtmps = 是\n", encoding="utf-8")
(config_dir / "URL_config.ini").write_text("https://live.douyin.com/123456\n", encoding="utf-8")
errors = validate_youtube_runtime_files(root, config_dir, "youtube", [])
self.assertEqual(errors, [])
if __name__ == "__main__":
unittest.main()