From ffdc887b8aa88183b4aea46022bf079d5978dbf3 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 17 May 2026 12:09:14 +0000 Subject: [PATCH] s --- d2ypp2/config/URL_config.youtube.ini | 2 + ...URL_config.youtube2__ch_mp9mm36r_2wn0r.ini | 3 + ...URL_config.youtube2__ch_mp9n7yu2_83vci.ini | 1 + d2ypp2/config/youtube.ini | 4 +- d2ypp2/config/youtube.youtube.ini | 133 +++++++++++++++++ .../youtube.youtube2__ch_mp9mm36r_2wn0r.ini | 12 ++ .../youtube.youtube2__ch_mp9mm9lz_a6jfx.ini | 12 ++ .../youtube.youtube2__ch_mp9n7yu2_83vci.ini | 133 +++++++++++++++++ d2ypp2/src/web_process_backend.py | 135 ++++++++++++++---- d2ypp2/tests/test_web_dynamic_processes.py | 67 +++++++++ d2ypp2/tests/test_web_process_backend.py | 78 +++++++++- .../live-product/LiveControlApp.tsx | 12 +- .../live-product/LiveTiktokHdmiView.tsx | 16 ++- .../live-product/LiveYoutubeUnmannedView.tsx | 110 ++++++++------ d2ypp2/web-console/lib/fetchJson.ts | 2 +- d2ypp2/web-console/out/404.html | 2 +- .../_buildManifest.js | 0 .../_ssgManifest.js | 0 ...8420dae1d39.js => 246-a5b3d260d1721b03.js} | 2 +- .../chunks/app/page-0c50020a1b5ffe1d.js | 1 - .../chunks/app/page-5e18bf638b82a736.js | 1 + d2ypp2/web-console/out/android.html | 2 +- d2ypp2/web-console/out/android.txt | 4 +- d2ypp2/web-console/out/console.html | 2 +- d2ypp2/web-console/out/console.txt | 4 +- d2ypp2/web-console/out/index.html | 2 +- d2ypp2/web-console/out/index.txt | 4 +- d2ypp2/web-console/out/shellcrash.html | 2 +- d2ypp2/web-console/out/shellcrash.txt | 4 +- d2ypp2/web.py | 89 +++++++++--- 30 files changed, 728 insertions(+), 111 deletions(-) create mode 100644 d2ypp2/config/URL_config.youtube.ini create mode 100644 d2ypp2/config/URL_config.youtube2__ch_mp9mm36r_2wn0r.ini create mode 100644 d2ypp2/config/URL_config.youtube2__ch_mp9n7yu2_83vci.ini create mode 100644 d2ypp2/config/youtube.youtube.ini create mode 100644 d2ypp2/config/youtube.youtube2__ch_mp9mm36r_2wn0r.ini create mode 100644 d2ypp2/config/youtube.youtube2__ch_mp9mm9lz_a6jfx.ini create mode 100644 d2ypp2/config/youtube.youtube2__ch_mp9n7yu2_83vci.ini rename d2ypp2/web-console/out/_next/static/{X6fD94e2JgU55NfU1XPEM => 4paJoVoY-KFg9DRarNRLx}/_buildManifest.js (100%) rename d2ypp2/web-console/out/_next/static/{X6fD94e2JgU55NfU1XPEM => 4paJoVoY-KFg9DRarNRLx}/_ssgManifest.js (100%) rename d2ypp2/web-console/out/_next/static/chunks/{246-fafe28420dae1d39.js => 246-a5b3d260d1721b03.js} (98%) delete mode 100644 d2ypp2/web-console/out/_next/static/chunks/app/page-0c50020a1b5ffe1d.js create mode 100644 d2ypp2/web-console/out/_next/static/chunks/app/page-5e18bf638b82a736.js diff --git a/d2ypp2/config/URL_config.youtube.ini b/d2ypp2/config/URL_config.youtube.ini new file mode 100644 index 0000000..1f0f900 --- /dev/null +++ b/d2ypp2/config/URL_config.youtube.ini @@ -0,0 +1,2 @@ +https://live.douyin.com/866853161480,主播: 小小咖啡老吴 +https://live.douyin.com/866853161482 diff --git a/d2ypp2/config/URL_config.youtube2__ch_mp9mm36r_2wn0r.ini b/d2ypp2/config/URL_config.youtube2__ch_mp9mm36r_2wn0r.ini new file mode 100644 index 0000000..6fab07e --- /dev/null +++ b/d2ypp2/config/URL_config.youtube2__ch_mp9mm36r_2wn0r.ini @@ -0,0 +1,3 @@ +https://live.douyin.com/866853161480 +https://live.douyin.com/866853161482 +https://live.douyin.com/866853161483 \ No newline at end of file diff --git a/d2ypp2/config/URL_config.youtube2__ch_mp9n7yu2_83vci.ini b/d2ypp2/config/URL_config.youtube2__ch_mp9n7yu2_83vci.ini new file mode 100644 index 0000000..cbacf08 --- /dev/null +++ b/d2ypp2/config/URL_config.youtube2__ch_mp9n7yu2_83vci.ini @@ -0,0 +1 @@ +https://live.douyin.com/866853111 diff --git a/d2ypp2/config/youtube.ini b/d2ypp2/config/youtube.ini index 1952b99..0b9c309 100644 --- a/d2ypp2/config/youtube.ini +++ b/d2ypp2/config/youtube.ini @@ -5,8 +5,8 @@ key = # 可选:是否使用 RTMPS 加密推流,填 否 则用 RTMP(默认 RTMPS) ; rtmps = 否 -# 可选:视频比特率 kbps,YouTube 推荐 2500,可填 3000-4500 提高画质 -; bitrate = 2500 +# 可选:视频比特率 kbps,YouTube 推荐 4000-6000,默认 4500 +; bitrate = 4500 # 可选:软编 speed 长期小于 1 时开启——轻量音频链(无 arnndn/长 EQ),减轻 CPU。填 1 或 是 ; fast_audio = 1 diff --git a/d2ypp2/config/youtube.youtube.ini b/d2ypp2/config/youtube.youtube.ini new file mode 100644 index 0000000..c0c9e1f --- /dev/null +++ b/d2ypp2/config/youtube.youtube.ini @@ -0,0 +1,133 @@ +[youtube] +key = x04z-564w-aks7-embw-30y4 +rtmps = 是 +fast_audio = 1 + +[录制设置] +language(zh_cn/en) = zh_cn +是否跳过代理检测(是/否) = 否 +直播保存路径(不填则默认) = +保存文件夹是否以作者区分 = 是 +保存文件夹是否以时间区分 = 否 +保存文件夹是否以标题区分 = 否 +保存文件名是否包含标题 = 否 +是否去除名称中的表情符号 = 是 +视频保存格式ts|mkv|flv|mp4|mp3音频|m4a音频 = ts +原画|超清|高清|标清|流畅 = 原画 +是否使用代理ip(是/否) = 是 +代理地址 = +同一时间访问网络的线程数 = 3 +循环时间(秒) = 120 +排队读取网址时间(秒) = 0 +是否显示循环秒数 = 否 +是否显示直播源地址 = 否 +分段录制是否开启 = 否 +是否强制启用https录制 = 否 +录制空间剩余阈值(gb) = 1.0 +视频分段时间(秒) = 1800 +录制完成后自动转为mp4格式 = 否 +mp4格式重新编码为h264 = 否 +追加格式后删除原文件 = 否 +生成时间字幕文件 = 否 +是否录制完成后执行自定义脚本 = 否 +使用代理录制的平台(逗号分隔) = tiktok, soop, pandalive, winktv, flextv, popkontv, twitch, liveme, showroom, chzzk, shopee, shp, youtu, faceit +额外使用代理录制的平台(逗号分隔) = + +[推送配置] +直播状态推送渠道 = +钉钉推送接口链接 = +微信推送接口链接 = +bark推送接口链接 = +bark推送中断级别 = active +bark推送铃声 = bell +钉钉通知@对象(填手机号) = +钉钉通知@全体(是/否) = 否 +tgapi令牌 = +tg聊天id(个人或者群组id) = +smtp邮件服务器 = +是否使用smtp服务ssl加密(是/否) = 是 +smtp邮件服务器端口 = +邮箱登录账号 = +发件人密码(授权码) = +发件人邮箱 = +发件人显示昵称 = +收件人邮箱 = +ntfy推送地址 = +ntfy推送标签 = tada +ntfy推送邮箱 = +pushplus推送token = +自定义推送标题 = 直播间状态更新通知 +自定义开播推送内容 = +自定义关播推送内容 = +只推送通知不录制(是/否) = 否 +直播推送检测频率(秒) = 1800 +开播推送开启(是/否) = 是 +关播推送开启(是/否) = 否 + +[Cookie] +抖音cookie = +快手cookie = +tiktok_cookie = +虎牙cookie = +斗鱼cookie = +yy_cookie = +b站cookie = +小红书cookie = +bigo_cookie = +blued_cookie = +sooplive_cookie = +netease_cookie = +千度热播_cookie = +pandatv_cookie = +猫耳fm_cookie = +winktv_cookie = +flextv_cookie = +look_cookie = +twitcasting_cookie = +baidu_cookie = +weibo_cookie = +kugou_cookie = +twitch_cookie = +liveme_cookie = +huajiao_cookie = +liuxing_cookie = +showroom_cookie = +acfun_cookie = +changliao_cookie = +yinbo_cookie = +yingke_cookie = +zhihu_cookie = +chzzk_cookie = +haixiu_cookie = +vvxqiu_cookie = +17live_cookie = +langlive_cookie = +pplive_cookie = +6room_cookie = +lehaitv_cookie = +huamao_cookie = +shopee_cookie = +youtube_cookie = +taobao_cookie = +jd_cookie = +faceit_cookie = +migu_cookie = +lianjie_cookie = +laixiu_cookie = +picarto_cookie = + +[Authorization] +popkontv_token = + +[账号密码] +sooplive账号 = +sooplive密码 = +flextv账号 = +flextv密码 = +popkontv账号 = +partner_code = P-00001 +popkontv密码 = +twitcasting账号类型 = normal +twitcasting账号 = +twitcasting密码 = + diff --git a/d2ypp2/config/youtube.youtube2__ch_mp9mm36r_2wn0r.ini b/d2ypp2/config/youtube.youtube2__ch_mp9mm36r_2wn0r.ini new file mode 100644 index 0000000..50b44c8 --- /dev/null +++ b/d2ypp2/config/youtube.youtube2__ch_mp9mm36r_2wn0r.ini @@ -0,0 +1,12 @@ +[youtube] +# YouTube 推流 key(工作室 → 直播 → 串流密钥) +key = x04z-564w-aks7-embw-30y4 + +# 使用 RTMPS;填 否 则 RTMP +rtmps = 是 + +# 视频比特率 kbps(可选) +# bitrate = 4000 + +# 轻量音频链,减轻 CPU:1 / 是 +fast_audio = 1 diff --git a/d2ypp2/config/youtube.youtube2__ch_mp9mm9lz_a6jfx.ini b/d2ypp2/config/youtube.youtube2__ch_mp9mm9lz_a6jfx.ini new file mode 100644 index 0000000..22f96ac --- /dev/null +++ b/d2ypp2/config/youtube.youtube2__ch_mp9mm9lz_a6jfx.ini @@ -0,0 +1,12 @@ +[youtube] +# YouTube 推流 key(工作室 → 直播 → 串流密钥) +key = 23122112 + +# 使用 RTMPS;填 否 则 RTMP +rtmps = 是 + +# 视频比特率 kbps(可选) +# bitrate = 4000 + +# 轻量音频链,减轻 CPU:1 / 是 +fast_audio = 0 diff --git a/d2ypp2/config/youtube.youtube2__ch_mp9n7yu2_83vci.ini b/d2ypp2/config/youtube.youtube2__ch_mp9n7yu2_83vci.ini new file mode 100644 index 0000000..76e725c --- /dev/null +++ b/d2ypp2/config/youtube.youtube2__ch_mp9n7yu2_83vci.ini @@ -0,0 +1,133 @@ +[youtube] +key = ue78-1c3e-mr9g-14mz-9r4z +rtmps = 是 +fast_audio = 1 + +[录制设置] +language(zh_cn/en) = zh_cn +是否跳过代理检测(是/否) = 否 +直播保存路径(不填则默认) = +保存文件夹是否以作者区分 = 是 +保存文件夹是否以时间区分 = 否 +保存文件夹是否以标题区分 = 否 +保存文件名是否包含标题 = 否 +是否去除名称中的表情符号 = 是 +视频保存格式ts|mkv|flv|mp4|mp3音频|m4a音频 = ts +原画|超清|高清|标清|流畅 = 原画 +是否使用代理ip(是/否) = 是 +代理地址 = +同一时间访问网络的线程数 = 3 +循环时间(秒) = 120 +排队读取网址时间(秒) = 0 +是否显示循环秒数 = 否 +是否显示直播源地址 = 否 +分段录制是否开启 = 否 +是否强制启用https录制 = 否 +录制空间剩余阈值(gb) = 1.0 +视频分段时间(秒) = 1800 +录制完成后自动转为mp4格式 = 否 +mp4格式重新编码为h264 = 否 +追加格式后删除原文件 = 否 +生成时间字幕文件 = 否 +是否录制完成后执行自定义脚本 = 否 +使用代理录制的平台(逗号分隔) = tiktok, soop, pandalive, winktv, flextv, popkontv, twitch, liveme, showroom, chzzk, shopee, shp, youtu, faceit +额外使用代理录制的平台(逗号分隔) = + +[推送配置] +直播状态推送渠道 = +钉钉推送接口链接 = +微信推送接口链接 = +bark推送接口链接 = +bark推送中断级别 = active +bark推送铃声 = bell +钉钉通知@对象(填手机号) = +钉钉通知@全体(是/否) = 否 +tgapi令牌 = +tg聊天id(个人或者群组id) = +smtp邮件服务器 = +是否使用smtp服务ssl加密(是/否) = 是 +smtp邮件服务器端口 = +邮箱登录账号 = +发件人密码(授权码) = +发件人邮箱 = +发件人显示昵称 = +收件人邮箱 = +ntfy推送地址 = +ntfy推送标签 = tada +ntfy推送邮箱 = +pushplus推送token = +自定义推送标题 = 直播间状态更新通知 +自定义开播推送内容 = +自定义关播推送内容 = +只推送通知不录制(是/否) = 否 +直播推送检测频率(秒) = 1800 +开播推送开启(是/否) = 是 +关播推送开启(是/否) = 否 + +[Cookie] +抖音cookie = +快手cookie = +tiktok_cookie = +虎牙cookie = +斗鱼cookie = +yy_cookie = +b站cookie = +小红书cookie = +bigo_cookie = +blued_cookie = +sooplive_cookie = +netease_cookie = +千度热播_cookie = +pandatv_cookie = +猫耳fm_cookie = +winktv_cookie = +flextv_cookie = +look_cookie = +twitcasting_cookie = +baidu_cookie = +weibo_cookie = +kugou_cookie = +twitch_cookie = +liveme_cookie = +huajiao_cookie = +liuxing_cookie = +showroom_cookie = +acfun_cookie = +changliao_cookie = +yinbo_cookie = +yingke_cookie = +zhihu_cookie = +chzzk_cookie = +haixiu_cookie = +vvxqiu_cookie = +17live_cookie = +langlive_cookie = +pplive_cookie = +6room_cookie = +lehaitv_cookie = +huamao_cookie = +shopee_cookie = +youtube_cookie = +taobao_cookie = +jd_cookie = +faceit_cookie = +migu_cookie = +lianjie_cookie = +laixiu_cookie = +picarto_cookie = + +[Authorization] +popkontv_token = + +[账号密码] +sooplive账号 = +sooplive密码 = +flextv账号 = +flextv密码 = +popkontv账号 = +partner_code = P-00001 +popkontv密码 = +twitcasting账号类型 = normal +twitcasting账号 = +twitcasting密码 = + diff --git a/d2ypp2/src/web_process_backend.py b/d2ypp2/src/web_process_backend.py index 660a4cd..7c0d50f 100644 --- a/d2ypp2/src/web_process_backend.py +++ b/d2ypp2/src/web_process_backend.py @@ -84,13 +84,17 @@ async def run_pm2(cmd: str, timeout: float = 15.0) -> str: def _ffmpeg_cmdline_markers(process_name: str, project_root: Path | None) -> list[str]: safe = re.sub(r"[^a-zA-Z0-9_.-]", "_", process_name).strip() - markers = {str(process_name or "").strip(), safe} + markers: set[str] = set() 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")) + markers.update( + { + f"youtube.{safe}.ini", + f"URL_config.{safe}.ini", + str(config_dir / f"youtube.{safe}.ini"), + str(config_dir / f"URL_config.{safe}.ini"), + } + ) return [m for m in markers if m] @@ -116,6 +120,16 @@ def _read_proc_env_value(pid: int, key: str) -> str: return "" +def _ffmpeg_pid_matches_process(pid: int, process_name: str) -> bool: + target = (process_name or "").strip() + if not target: + return False + for key in ("LIVE_PM2_PROCESS_NAME", "PM2_SERVICE_NAME"): + if _read_proc_env_value(pid, key) == target: + return True + return False + + 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") @@ -178,7 +192,7 @@ def _linux_ffmpeg_pids_for_process_tree(root_pid: int, *, root_pgid: int | None def _linux_ffmpeg_pids_for_stack(process_name: str, project_root: Path | None) -> list[int]: - """仅匹配与本项目直播栈相关的 ffmpeg(命令行含 YouTube 推流、抖音拉流或项目路径),避免 killall 误伤整机其它 ffmpeg。""" + """仅匹配继承了目标进程环境变量的 ffmpeg,避免按关键词误杀其它线路。""" markers = _ffmpeg_cmdline_markers(process_name, project_root) seen: set[int] = set() proc_root = Path("/proc") @@ -198,6 +212,9 @@ def _linux_ffmpeg_pids_for_stack(process_name: str, project_root: Path | None) - continue if not raw or b"ffmpeg" not in raw.lower(): continue + if _ffmpeg_pid_matches_process(pid, process_name): + seen.add(pid) + continue cmd = raw.replace(b"\0", b" ").decode("utf-8", "replace") if any(m in cmd for m in markers): seen.add(pid) @@ -420,9 +437,64 @@ def resolve_pm2_bin() -> Optional[str]: def preferred_python_interpreter(base_dir: Path) -> str: venv_python = base_dir / ".venv" / "bin" / "python" - if venv_python.is_file(): - return str(venv_python) - return sys.executable or "python3" + candidates = [ + str(venv_python), + shutil_which("python3"), + shutil_which("python"), + sys.executable, + ] + for candidate in candidates: + if not candidate: + continue + path = Path(candidate).expanduser() + if path.is_file() and os.access(str(path), os.X_OK): + return str(path) + return "python3" + + +def _resolved_pm2_item_path(item: dict[str, Any], *keys: str) -> Optional[Path]: + env = item.get("pm2_env") if isinstance(item.get("pm2_env"), dict) else {} + for key in keys: + raw = env.get(key) + if raw in (None, ""): + raw = item.get(key) + value = str(raw or "").strip() + if value: + return Path(value).expanduser() + return None + + +def _samefile_or_resolved_path(left: Path, right: Path) -> bool: + try: + return left.resolve() == right.resolve() + except OSError: + return False + + +def pm2_process_entry_needs_recreate(base_dir: Path, process: str, script: str, item: dict[str, Any] | None) -> bool: + if not item: + return False + + script_path = (base_dir / script).resolve() + configured_script = _resolved_pm2_item_path(item, "pm_exec_path") + if configured_script is None or not _samefile_or_resolved_path(configured_script, script_path): + return True + + configured_cwd = _resolved_pm2_item_path(item, "pm_cwd", "cwd") + if configured_cwd is None or not _samefile_or_resolved_path(configured_cwd, base_dir.resolve()): + return True + + suffix = script_path.suffix.lower() + if suffix == ".py": + interpreter = _resolved_pm2_item_path(item, "exec_interpreter") + if interpreter is None or not interpreter.is_file() or not os.access(str(interpreter), os.X_OK): + return True + elif suffix == ".sh": + interpreter = _resolved_pm2_item_path(item, "exec_interpreter") + if interpreter is not None and (not interpreter.is_file() or not os.access(str(interpreter), os.X_OK)): + return True + + return False def build_pm2_start_command(base_dir: Path, process: str, script: str) -> Optional[str]: @@ -946,12 +1018,33 @@ class WebProcessBackend: with contextlib.suppress(Exception): await run_pm2("save", timeout=20.0) + async def _pm2_start_fresh(self, process: str, script: str) -> str: + 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}" + 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 + + async def _pm2_delete_existing_process(self, process: str) -> None: + with contextlib.suppress(Exception): + await run_pm2_result(f"delete {process}", timeout=20.0) + self.invalidate_pm2_cache() + await asyncio.sleep(0.2) + async def start(self, process: str, script: str) -> str: 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) + item = (await self._pm2_jlist_index(force_refresh=True)).get(process) + if pm2_process_entry_needs_recreate(self.base_dir, process, script, item): + await self._pm2_delete_existing_process(process) + item = None + if item is not None: + info = self._pm2_info_from_item(process, item) if str(info.get("process_status") or "").lower() not in ("online", "running"): await self.clear_process_logs(process) code, text = await run_pm2_result(f"start {process}") @@ -960,16 +1053,7 @@ class WebProcessBackend: 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}" - 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._pm2_start_fresh(process, script) return await self._local.start(process, script) async def stop(self, process: str) -> str: @@ -986,9 +1070,12 @@ class WebProcessBackend: async def restart(self, process: str, script: str) -> str: await self.ensure_pm2_runtime() if self._use_pm2: - registered = await self._pm2_registered_names() - if process not in registered: + item = (await self._pm2_jlist_index(force_refresh=True)).get(process) + if item is None: return await self.start(process, script) + if pm2_process_entry_needs_recreate(self.base_dir, process, script, item): + await self._pm2_delete_existing_process(process) + return await self._pm2_start_fresh(process, script) await self.clear_process_logs(process) code, text = await run_pm2_result(f"restart {process}") if code == 0: diff --git a/d2ypp2/tests/test_web_dynamic_processes.py b/d2ypp2/tests/test_web_dynamic_processes.py index 43e6544..b165773 100644 --- a/d2ypp2/tests/test_web_dynamic_processes.py +++ b/d2ypp2/tests/test_web_dynamic_processes.py @@ -3,12 +3,26 @@ from __future__ import annotations import ast import asyncio import json +import tempfile import unittest +from pathlib import Path from unittest.mock import AsyncMock, patch import web +class _JsonRequest: + def __init__(self, payload: dict[str, object]): + self._payload = payload + + async def json(self) -> dict[str, object]: + return self._payload + + +def _json_body(resp) -> dict[str, object]: # noqa: ANN001 + return json.loads(resp.body) + + class WebDynamicProcessesTests(unittest.TestCase): def test_process_monitor_entries_include_saved_youtube_pro_channels(self) -> None: channels = [{"id": "lane1", "name": "Lane 1", "pm2Name": "youtube2__lane1"}] @@ -153,6 +167,59 @@ class WebDynamicProcessesTests(unittest.TestCase): self.assertEqual(payload["output"], "ok") self.assertEqual(calls, ["kill:youtube", "restart:youtube:youtube.py"]) + def test_get_url_config_does_not_resync_existing_managed_file(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + config_dir = Path(tmp) + rel = "URL_config.youtube.ini" + (config_dir / rel).write_text("https://live.douyin.com/new\n", encoding="utf-8") + + with patch("web.CONFIG_DIR", config_dir): + with patch("web.allows_url_config", return_value=True): + with patch("web.managed_url_config_relpath", return_value=rel): + with patch("web._sync_youtube_pro_runtime_files", side_effect=AssertionError("unexpected resync")): + payload = asyncio.run(web.get_url_config("youtube")) + + self.assertEqual(_json_body(payload)["content"], "https://live.douyin.com/new\n") + self.assertEqual(payload.headers.get("cache-control"), "no-store, max-age=0, must-revalidate") + + def test_save_url_config_updates_matching_youtube_pro_channel_store(self) -> None: + captured: list[list[dict[str, object]]] = [] + channels = [{"id": "lane1", "name": "Lane 1", "pm2Name": "youtube", "urlLines": "old\n"}] + + with patch("web.allows_url_config", return_value=True): + with patch("web.managed_url_config_relpath", return_value="URL_config.youtube.ini"): + with patch("web.governed_save_managed_file"): + with patch("web.get_youtube_pro_channels", return_value=channels): + with patch("web.set_youtube_pro_channels", side_effect=lambda rows: captured.append(rows)): + payload = asyncio.run( + web.save_url_config( + _JsonRequest({"content": "https://live.douyin.com/fresh\n"}), + process="youtube", + ) + ) + + self.assertEqual(_json_body(payload)["message"], "配置保存成功") + self.assertEqual(captured[-1][0]["urlLines"], "https://live.douyin.com/fresh\n") + + def test_save_config_updates_matching_youtube_pro_channel_stream_key(self) -> None: + captured: list[list[dict[str, object]]] = [] + channels = [{"id": "lane1", "name": "Lane 1", "pm2Name": "youtube", "streamKey": "old-key"}] + content = "[youtube]\nkey = new-key-123\nrtmps = 是\n" + + with patch("web.managed_youtube_ini_relpath", return_value="youtube.youtube.ini"): + with patch("web.governed_save_managed_file"): + with patch("web.get_youtube_pro_channels", return_value=channels): + with patch("web.set_youtube_pro_channels", side_effect=lambda rows: captured.append(rows)): + payload = asyncio.run( + web.save_config( + _JsonRequest({"content": content}), + process="youtube", + ) + ) + + self.assertEqual(_json_body(payload)["message"], "配置保存成功") + self.assertEqual(captured[-1][0]["streamKey"], "new-key-123") + if __name__ == "__main__": unittest.main() diff --git a/d2ypp2/tests/test_web_process_backend.py b/d2ypp2/tests/test_web_process_backend.py index 58492d6..1301e79 100644 --- a/d2ypp2/tests/test_web_process_backend.py +++ b/d2ypp2/tests/test_web_process_backend.py @@ -6,7 +6,13 @@ import unittest from pathlib import Path from unittest.mock import AsyncMock, patch -from src.web_process_backend import WebProcessBackend, build_pm2_start_command +from src.web_process_backend import ( + WebProcessBackend, + _ffmpeg_pid_matches_process, + build_pm2_start_command, + pm2_process_entry_needs_recreate, + preferred_python_interpreter, +) class WebProcessBackendTests(unittest.TestCase): @@ -17,6 +23,7 @@ class WebProcessBackendTests(unittest.TestCase): venv_python = base_dir / ".venv" / "bin" / "python" venv_python.parent.mkdir(parents=True, exist_ok=True) venv_python.write_text("#!/usr/bin/env python3\n", encoding="utf-8") + venv_python.chmod(0o755) script.write_text("print('ok')\n", encoding="utf-8") cmd = build_pm2_start_command(base_dir, "youtube2__lane1", "youtube.py") @@ -29,6 +36,62 @@ class WebProcessBackendTests(unittest.TestCase): self.assertIn(str(script), cmd) self.assertIn(str(venv_python), cmd) + def test_preferred_python_interpreter_falls_back_to_system_python_when_venv_is_missing(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + base_dir = Path(tmp) + with patch("src.web_process_backend.shutil_which", side_effect=lambda name: f"/usr/bin/{name}" if name == "python3" else None): + with patch("src.web_process_backend.sys.executable", "/definitely/missing/python"): + chosen = preferred_python_interpreter(base_dir) + + self.assertEqual(chosen, "/usr/bin/python3") + + def test_pm2_process_entry_needs_recreate_when_python_interpreter_is_missing(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") + item = { + "pm_exec_path": str(script), + "pm_cwd": str(base_dir), + "pm2_env": { + "exec_interpreter": str(base_dir / ".venv" / "bin" / "python"), + }, + } + + self.assertTrue(pm2_process_entry_needs_recreate(base_dir, "youtube", "youtube.py", item)) + + def test_start_recreates_stale_pm2_entry_instead_of_reusing_broken_interpreter(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 + stale_item = { + "pm_exec_path": str(script), + "pm_cwd": str(base_dir), + "pm2_env": { + "exec_interpreter": str(base_dir / ".venv" / "bin" / "python"), + }, + } + + with patch.object(backend, "ensure_pm2_runtime", AsyncMock()): + with patch.object(backend, "_pm2_jlist_index", AsyncMock(return_value={"youtube": stale_item})): + with patch.object(backend, "clear_process_logs", AsyncMock()): + with patch("src.web_process_backend.build_pm2_start_command", return_value="pm2 start fresh") as build_cmd: + with patch("src.web_process_backend.run_pm2_result", new=AsyncMock(return_value=(0, "deleted"))) as run_pm2_result: + with patch("src.web_process_backend._shell_out", new=AsyncMock(return_value=(0, "created"))) as shell_out: + 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, "created") + build_cmd.assert_called_once_with(base_dir, "youtube", "youtube.py") + run_pm2_result.assert_awaited_once_with("delete youtube", timeout=20.0) + shell_out.assert_awaited_once_with("pm2 start fresh", timeout=45.0) + set_desired.assert_awaited_once_with("youtube", True) + def test_parse_pm2_describe_marks_missing_jlist_process_as_not_found(self) -> None: with tempfile.TemporaryDirectory() as tmp: base_dir = Path(tmp) @@ -156,6 +219,19 @@ class WebProcessBackendTests(unittest.TestCase): self.assertEqual(pids, [9876]) finder.assert_called_once_with(4321, root_pgid=6789) + def test_ffmpeg_pid_matches_process_uses_exact_inherited_env_name(self) -> None: + env_map = { + "LIVE_PM2_PROCESS_NAME": "youtube2__lane1", + "PM2_SERVICE_NAME": "", + } + + def fake_env(pid: int, key: str) -> str: + return env_map.get(key, "") + + with patch("src.web_process_backend._read_proc_env_value", side_effect=fake_env): + self.assertTrue(_ffmpeg_pid_matches_process(1234, "youtube2__lane1")) + self.assertFalse(_ffmpeg_pid_matches_process(1234, "youtube")) + if __name__ == "__main__": unittest.main() diff --git a/d2ypp2/web-console/components/live-product/LiveControlApp.tsx b/d2ypp2/web-console/components/live-product/LiveControlApp.tsx index 10d26de..19b1779 100644 --- a/d2ypp2/web-console/components/live-product/LiveControlApp.tsx +++ b/d2ypp2/web-console/components/live-product/LiveControlApp.tsx @@ -357,9 +357,11 @@ export default function LiveControlApp() { const targetProc = currentProcRef.current.trim(); const requestId = ++urlConfigRequestSeqRef.current; try { - const data = await fetchJson<{ content?: string }>( - `/get_url_config?process=${encodeURIComponent(targetProc)}`, - ); + const params = new URLSearchParams({ + process: targetProc, + _ts: String(Date.now()), + }); + const data = await fetchJson<{ content?: string }>(`/get_url_config?${params}`); if (requestId !== urlConfigRequestSeqRef.current || targetProc !== currentProcRef.current.trim()) return; setUrlConfig(data.content || ""); } catch { @@ -443,6 +445,7 @@ export default function LiveControlApp() { const proc = (processOverride ?? currentProc).trim(); if (!proc) return; const content = overrideContent ?? urlConfig; + const requestId = ++urlConfigRequestSeqRef.current; setBusy("save:url"); try { const params = new URLSearchParams({ process: proc }); @@ -451,6 +454,9 @@ export default function LiveControlApp() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content }), }); + if (requestId === urlConfigRequestSeqRef.current && proc === currentProcRef.current.trim()) { + setUrlConfig(content); + } notify(t("URL 配置已保存", "URL config saved")); } catch (e) { notify((e as Error).message); diff --git a/d2ypp2/web-console/components/live-product/LiveTiktokHdmiView.tsx b/d2ypp2/web-console/components/live-product/LiveTiktokHdmiView.tsx index 152ec3f..7c21005 100644 --- a/d2ypp2/web-console/components/live-product/LiveTiktokHdmiView.tsx +++ b/d2ypp2/web-console/components/live-product/LiveTiktokHdmiView.tsx @@ -167,9 +167,11 @@ export function LiveTiktokHdmiView({ const pullUrlConfigFromServer = useCallback(async () => { if (!currentProc) return; try { - const data = await fetchJson<{ content?: string }>( - `/get_url_config?process=${encodeURIComponent(currentProc)}`, - ); + const params = new URLSearchParams({ + process: currentProc, + _ts: String(Date.now()), + }); + const data = await fetchJson<{ content?: string }>(`/get_url_config?${params}`); applyUrlDraft(data.content ?? ""); } catch { /* ignore */ @@ -249,9 +251,11 @@ export function LiveTiktokHdmiView({ setUrlFetchNote(null); urlListDirtyRef.current = false; try { - const data = await fetchJson<{ content?: string }>( - `/get_url_config?process=${encodeURIComponent(currentProc)}`, - ); + const params = new URLSearchParams({ + process: currentProc, + _ts: String(Date.now()), + }); + const data = await fetchJson<{ content?: string }>(`/get_url_config?${params}`); applyUrlDraft(data.content ?? ""); setUrlFetchNote(t("已从服务器读取 URL_config", "Reloaded URL_config from server")); } catch (e) { diff --git a/d2ypp2/web-console/components/live-product/LiveYoutubeUnmannedView.tsx b/d2ypp2/web-console/components/live-product/LiveYoutubeUnmannedView.tsx index b67902e..43f2e22 100644 --- a/d2ypp2/web-console/components/live-product/LiveYoutubeUnmannedView.tsx +++ b/d2ypp2/web-console/components/live-product/LiveYoutubeUnmannedView.tsx @@ -404,9 +404,11 @@ export function LiveYoutubeUnmannedView({ if (!targetProc) return; const requestId = ++urlRequestSeqRef.current; try { - const data = await fetchJson<{ content?: string }>( - `/get_url_config?process=${encodeURIComponent(targetProc)}`, - ); + const params = new URLSearchParams({ + process: targetProc, + _ts: String(Date.now()), + }); + const data = await fetchJson<{ content?: string }>(`/get_url_config?${params}`); if (requestId !== urlRequestSeqRef.current || targetProc !== currentProcRef.current.trim()) return; const c = data.content ?? ""; if (urlListDirtyRef.current) return; @@ -448,9 +450,11 @@ export function LiveYoutubeUnmannedView({ urlListDirtyRef.current = false; const requestId = ++urlRequestSeqRef.current; try { - const data = await fetchJson<{ content?: string }>( - `/get_url_config?process=${encodeURIComponent(targetProc)}`, - ); + const params = new URLSearchParams({ + process: targetProc, + _ts: String(Date.now()), + }); + const data = await fetchJson<{ content?: string }>(`/get_url_config?${params}`); if (requestId !== urlRequestSeqRef.current || targetProc !== currentProcRef.current.trim()) return; const c = data.content ?? ""; if (urlListDirtyRef.current) { @@ -503,7 +507,7 @@ export function LiveYoutubeUnmannedView({ void (async () => { try { const remote = await fetchYoutubeProChannelsRemote(fetchJson); - if (remote.fetched && !cancelled) { + if (remote.fetched && remote.channels.length && !cancelled) { setChannels(remote.channels); saveYoutubeProChannels(remote.channels); return; @@ -538,7 +542,13 @@ export function LiveYoutubeUnmannedView({ }, [fetchJson]); useEffect(() => { - if (channels.length && !activeCh) setActiveCh(channels[0].id); + if (!channels.length) { + if (activeCh) setActiveCh(""); + return; + } + if (!activeCh || !channels.some((row) => row.id === activeCh)) { + setActiveCh(channels[0].id); + } }, [channels, activeCh]); const rowForPm2 = useCallback((pm2: string) => liveProcesses.find((p) => p.pm2 === pm2), [liveProcesses]); @@ -546,26 +556,18 @@ export function LiveYoutubeUnmannedView({ (pm2: string) => rowForPm2(pm2)?.process_status, [rowForPm2], ); + const currentProcRow = rowForPm2(currentProc); + const currentProcLooksYoutube = useMemo( + () => !!currentProc && (/youtube/i.test(currentProc) || /youtube/i.test(currentProcRow?.script ?? "") || /youtube/i.test(currentProcRow?.label ?? "")), + [currentProc, currentProcRow], + ); useEffect(() => { if (mode !== "single" || !preferredSingleProc) return; - if (!singleProcessOptions.some((entry) => entry.pm2 === currentProc)) { - setCurrentProc(preferredSingleProc); - return; - } - const currentEntry = singleProcessOptions.find((entry) => entry.pm2 === currentProc); - const currentRow = rowForPm2(currentProc); - const preferredEntry = singleProcessOptions.find((entry) => entry.pm2 === preferredSingleProc); - const preferredRow = rowForPm2(preferredSingleProc); - if ( - currentProc !== preferredSingleProc && - currentEntry && - preferredEntry && - singleProcessPriority(preferredEntry, preferredRow) < singleProcessPriority(currentEntry, currentRow) - ) { + if (!currentProc || !currentProcLooksYoutube) { setCurrentProc(preferredSingleProc); } - }, [currentProc, mode, preferredSingleProc, rowForPm2, setCurrentProc, singleProcessOptions]); + }, [currentProc, currentProcLooksYoutube, mode, preferredSingleProc, setCurrentProc]); const applyChannelsLocal = useCallback((next: YoutubeProChannel[]) => { setChannels(next); @@ -773,9 +775,11 @@ export function LiveYoutubeUnmannedView({ setYtIniLoading(true); setYtIniStatus(null); try { - const data = await fetchJson<{ content?: string }>( - `/get_config?process=${encodeURIComponent(targetProc)}`, - ); + const params = new URLSearchParams({ + process: targetProc, + _ts: String(Date.now()), + }); + const data = await fetchJson<{ content?: string }>(`/get_config?${params}`); if (requestId !== ytIniRequestSeqRef.current || targetProc !== currentProcRef.current.trim()) return; const text = data.content ?? ""; setYoutubeIniText(text); @@ -872,7 +876,7 @@ export function LiveYoutubeUnmannedView({ } }, [active, notify, persistActiveLaneDraft, setCurrentProc, t]); - const saveYoutubeIniToServer = async (content: string, processPm2 = currentProc) => { + const saveYoutubeIniToServer = async (content: string, processPm2 = currentProcRef.current) => { const targetPm2 = processPm2.trim(); if (!targetPm2) { setYtIniStatus(t("请选择进程", "Pick a process")); @@ -911,7 +915,7 @@ export function LiveYoutubeUnmannedView({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ root: "app-config", - path: managedYoutubeIniRelPathForPm2(currentProc), + path: managedYoutubeIniRelPathForPm2(currentProcRef.current), content: source, action: "youtube_balanced_preset", }), @@ -943,6 +947,7 @@ export function LiveYoutubeUnmannedView({ try { setLaneSaving(true); setLaneStatus(null); + urlRequestSeqRef.current += 1; const latestProUrlDraft = proUrlDraftRef.current; const saved = await persistActiveLaneDraft(); setCurrentProc(saved.pm2); @@ -959,12 +964,13 @@ export function LiveYoutubeUnmannedView({ }; const saveSingleUrlAndClearDirty = async () => { - const targetPm2 = currentProc.trim(); + const targetPm2 = currentProcRef.current.trim(); if (!targetPm2) { notify(t("请选择进程", "Pick a process")); return; } try { + urlRequestSeqRef.current += 1; await Promise.resolve(onSaveUrlConfig(singleUrlDraftRef.current, targetPm2)); urlListDirtyRef.current = false; } catch { @@ -982,7 +988,7 @@ export function LiveYoutubeUnmannedView({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ root: "app-config", - path: managedUrlConfigRelPathForPm2(currentProc), + path: managedUrlConfigRelPathForPm2(currentProcRef.current), content: source, action: "dedupe_urls", }), @@ -1099,7 +1105,7 @@ export function LiveYoutubeUnmannedView({ const lock = !!busy || ytIniLoading || urlReloading || urlOptimizing || laneSaving; - const singleRow = rowForPm2(currentProc); + const singleRow = currentProcRow; const singleOnline = pm2StatusOnline(statusForPm2(currentProc)); const urlToolbar = ( @@ -1260,8 +1266,18 @@ export function LiveYoutubeUnmannedView({