From 784a4468b87e5cb2803a2599992252e3d070aedb Mon Sep 17 00:00:00 2001 From: eric Date: Sun, 29 Mar 2026 01:34:23 -0500 Subject: [PATCH] 's' --- ecosystem.config.cjs | 10 +- src/config_governance.py | 4 +- src/control_plane.py | 12 +- src/web_process_backend.py | 3 + .../live-product/LiveYoutubeUnmannedView.tsx | 367 +++++++++--------- web-console/lib/youtubeConfigUtils.ts | 12 + web.py | 95 +++-- youtube.py | 23 +- 8 files changed, 308 insertions(+), 218 deletions(-) diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index bb1db73..d556cf6 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -3,8 +3,8 @@ * web 使用 scripts/launch.py:若缺少 web-console/out 会自动 npm build 再起 uvicorn * * 单频道:通常只启一个 youtube.py(或 tiktok.py)+ web。 - * 多频道 Pro:每路需要独立 config/youtube.ini + URL_config.ini(或独立工作目录), - * 请为每路复制一份目录或使用符号链接,再为每个 PM2 app 设置不同的 cwd,进程名与控制台 Pro 线路一致(如 youtube2__xxx)。 + * 多频道 Pro:同一项目根下按 PM2 名拆分文件:config/youtube..ini 与 config/URL_config..ini(控制台保存时自动创建)。 + * PM2 进程名须与控制台线路一致(如 youtube2__ch_xxx),并在 env 中设置 LIVE_PM2_PROCESS_NAME 与同名字符串,供 youtube.py 读取正确 ini。 * 音频处理链与 GitLab 分支 ffmpegAudioRMFoode 中 douyin_youtube_ffplay 对齐;Linux ARM/x86 编码由 config/hardware.env 与硬件探测辅助。 */ const path = require("path"); @@ -38,13 +38,13 @@ module.exports = { // interpreter: py, // env: { PYTHONUNBUFFERED: "1" }, // }, - // —— Pro 第二路示例:把 /opt/live/lane2 换成你独立配置目录(内含 config/youtube.ini、URL_config.ini)—— + // —— Pro 第二路示例:与 web 控制台「多频道 Pro」线路 PM2 名一致 —— // { // name: "youtube2__lane2", - // cwd: "/opt/live/lane2", + // cwd: root, // script: "youtube.py", // interpreter: py, - // env: { PYTHONUNBUFFERED: "1" }, + // env: { PYTHONUNBUFFERED: "1", LIVE_PM2_PROCESS_NAME: "youtube2__lane2" }, // }, // { name: "obs", cwd: root, script: "bash", args: `-lc ${JSON.stringify(path.join(root, "obs.sh"))}` }, ], diff --git a/src/config_governance.py b/src/config_governance.py index 1767461..513f73b 100644 --- a/src/config_governance.py +++ b/src/config_governance.py @@ -30,9 +30,9 @@ def _read_text(path: Path) -> str: def _file_kind(path: Path) -> str: name = path.name.lower() - if name == "url_config.ini": + if name == "url_config.ini" or (name.startswith("url_config.") and name.endswith(".ini")): return "url_config" - if name == "youtube.ini": + if name == "youtube.ini" or (name.startswith("youtube.") and name.endswith(".ini")): return "youtube_ini" if name == "config.ini": return "recorder_ini" diff --git a/src/control_plane.py b/src/control_plane.py index da8e100..95fc446 100644 --- a/src/control_plane.py +++ b/src/control_plane.py @@ -4,6 +4,7 @@ import asyncio import json import os import platform +import re import shutil import socket import subprocess @@ -24,6 +25,9 @@ HW_PROFILE_JSON = CONFIG_DIR / "hardware-profile.json" STACK_ENV = CONFIG_DIR / "system-stack.env" MAX_FILES_PER_ROOT = 256 +_PRO_URL_CONFIG_INI = re.compile(r"^URL_config\.[a-zA-Z0-9_.-]+\.ini$") +_PRO_YOUTUBE_INI = re.compile(r"^youtube\.[a-zA-Z0-9_.-]+\.ini$") + @dataclass(frozen=True) class ManagedRoot: @@ -44,7 +48,13 @@ class ManagedRoot: def allows(self, rel_path: Path) -> bool: rel_posix = rel_path.as_posix() if self.explicit_names: - return rel_posix in self.explicit_names + if rel_posix in self.explicit_names: + return True + if len(rel_path.parts) == 1: + n = rel_path.name + if _PRO_URL_CONFIG_INI.match(n) or _PRO_YOUTUBE_INI.match(n): + return True + return False return rel_path.suffix.lower() in self.allowed_suffixes diff --git a/src/web_process_backend.py b/src/web_process_backend.py index 1ba0318..e662dee 100644 --- a/src/web_process_backend.py +++ b/src/web_process_backend.py @@ -203,11 +203,14 @@ class LocalProcessRegistry: err_log = open(self.log_dir / f"{name}-error.log", "a", encoding="utf-8", errors="ignore") try: + child_env = os.environ.copy() + child_env["LIVE_PM2_PROCESS_NAME"] = name kw: dict[str, Any] = { "cwd": str(self.base_dir), "stdout": out_log, "stderr": err_log, "preexec_fn": os.setsid, + "env": child_env, } proc = await asyncio.create_subprocess_exec(*cmd, **kw) except Exception as e: diff --git a/web-console/components/live-product/LiveYoutubeUnmannedView.tsx b/web-console/components/live-product/LiveYoutubeUnmannedView.tsx index cb22078..2234da6 100644 --- a/web-console/components/live-product/LiveYoutubeUnmannedView.tsx +++ b/web-console/components/live-product/LiveYoutubeUnmannedView.tsx @@ -6,6 +6,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { buildYoutubeIniFromFields, extractDouyinLiveUrls, + managedUrlConfigRelPathForPm2, + managedYoutubeIniRelPathForPm2, parseYoutubeIniFields, parseYoutubeStreamKeySuffix, type YoutubeIniFields, @@ -191,11 +193,20 @@ export function LiveYoutubeUnmannedView({ ); const c = data.content ?? ""; if (mode === "single") setUrlConfig(c); - else setProUrlDraft(c); + else { + setProUrlDraft(c); + if (activeCh) { + setChannels((prev) => { + const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x)); + saveYoutubeProChannels(next); + return next; + }); + } + } } catch { /* ignore */ } - }, [currentProc, fetchJson, mode, setUrlConfig]); + }, [activeCh, currentProc, fetchJson, mode, setUrlConfig]); useEffect(() => { if (!currentProc || !urlAutoRefresh) return; @@ -217,7 +228,16 @@ export function LiveYoutubeUnmannedView({ ); const c = data.content ?? ""; if (mode === "single") setUrlConfig(c); - else setProUrlDraft(c); + else { + setProUrlDraft(c); + if (activeCh) { + setChannels((prev) => { + const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x)); + saveYoutubeProChannels(next); + return next; + }); + } + } setUrlFetchNote(t("已从服务器读取 URL_config", "Reloaded URL_config from server")); } catch (e) { setUrlFetchNote((e as Error).message); @@ -284,11 +304,6 @@ export function LiveYoutubeUnmannedView({ const active = channels.find((c) => c.id === activeCh) || channels[0]; - const proLanesWithKey = useMemo( - () => channels.filter((ch) => (ch.streamKey ?? "").trim().length > 0), - [channels], - ); - const selectProChannel = useCallback( (c: YoutubeProChannel) => { if (active && mode === "pro") { @@ -306,8 +321,34 @@ export function LiveYoutubeUnmannedView({ const ch = channels.find((x) => x.id === activeCh); if (!ch) return; const want = (ch.pm2Name ?? "").trim() || defaultPm2NameForChannel(ch.id); - if (processOptions.some((e) => e.pm2 === want)) setCurrentProc(want); - }, [mode, activeCh, channels, processOptions, setCurrentProc]); + setCurrentProc(want); + }, [mode, activeCh, channels, setCurrentProc]); + + useEffect(() => { + if (mode !== "pro" || !currentProc || !activeCh) return; + let cancelled = false; + void (async () => { + try { + const data = await fetchJson<{ content?: string }>( + `/get_url_config?process=${encodeURIComponent(currentProc)}`, + ); + if (cancelled) return; + const c = data.content ?? ""; + setProUrlDraft(c); + urlListDirtyRef.current = false; + setChannels((prev) => { + const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x)); + saveYoutubeProChannels(next); + return next; + }); + } catch { + if (!cancelled) setProUrlDraft(""); + } + })(); + return () => { + cancelled = true; + }; + }, [mode, currentProc, activeCh, fetchJson]); const loadYoutubeIni = useCallback(async () => { if (!currentProc) return; @@ -375,7 +416,7 @@ export function LiveYoutubeUnmannedView({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ root: "app-config", - path: "youtube.ini", + path: managedYoutubeIniRelPathForPm2(currentProc), content: source, action: "youtube_balanced_preset", }), @@ -433,7 +474,7 @@ export function LiveYoutubeUnmannedView({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ root: "app-config", - path: "URL_config.ini", + path: managedUrlConfigRelPathForPm2(currentProc), content: source, action: "dedupe_urls", }), @@ -507,7 +548,7 @@ export function LiveYoutubeUnmannedView({ ); return ( -
+
@@ -620,9 +661,9 @@ export function LiveYoutubeUnmannedView({ {t("Pro 转播实况", "Pro relay status")}

- {t("对齐桌面端工作台:每路密钥、源站、进程状态一览;点行可切换编辑。", "Like the desktop workspace: per-lane key, source, PM2 status.")} + {t("对齐桌面端:每路独立 URL / youtube 配置文件;点左侧信息切换编辑,右侧控制进程。", "Per-lane config files like desktop; click row info to edit, use right-side controls for PM2.")}

-
+
{channels.map((c) => { const pm = (c.pm2Name ?? "").trim() || defaultPm2NameForChannel(c.id); const st = statusForPm2(pm); @@ -630,10 +671,11 @@ export function LiveYoutubeUnmannedView({ const dUrl = extractDouyinLiveUrls((c.urlLines ?? "").trim())[0]; const suf = parseYoutubeStreamKeySuffix(`key = ${c.streamKey ?? ""}`); const sel = activeCh === c.id; + const rowOnline = pm2StatusOnline(statusForPm2(pm)); return (
@@ -642,131 +684,92 @@ export function LiveYoutubeUnmannedView({ className="min-w-0 flex-1 text-left" onClick={() => selectProChannel(c)} > - {c.name} - - key …{suf || "—"} - +
+ {c.name} + + {on ? t("运行中", "Live") : st || t("未运行", "Idle")} + +
+
+ + key …{suf || "—"} + + · + + {pm} + +
+ {dUrl ? ( + e.stopPropagation()} + title={dUrl} + > + {dUrl.replace(/^https?:\/\//, "")} + + ) : ( + + )} - e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} > - {on ? t("运行中", "Live") : st || t("未运行", "Idle")} - - {dUrl ? ( - e.stopPropagation()} - title={dUrl} - > - {dUrl.replace(/^https?:\/\//, "")} - - ) : ( - - )} -
- ); - })} -
-
- -

- {t("点选一行即可编辑该线路;下方列表为已保存密钥的线路控制。", "Pick a row above to edit; the list below is lanes with a saved key.")} -

- {proLanesWithKey.length === 0 ? ( -

- {channels.length > 0 - ? t("请先在下方填写串流密钥并点「保存」。", "Enter a stream key below and tap Save.") - : t("请先新增线路。", "Add a lane first.")} -

- ) : ( -
- {proLanesWithKey.map((c) => { - const pm = (c.pm2Name ?? "").trim() || defaultPm2NameForChannel(c.id); - const suffix = parseYoutubeStreamKeySuffix(`key = ${c.streamKey ?? ""}`); - const sel = activeCh === c.id; - const rowOnline = pm2StatusOnline(statusForPm2(pm)); - return ( -
selectProChannel(c)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - selectProChannel(c); - } - }} - className={`rounded-xl border p-3 text-left transition ${ - sel ? "border-violet-500/45 bg-violet-500/[0.08]" : "border-white/10 bg-black/30 hover:border-white/20" - }`} - > -
-
- {suffix || "—"} - {c.name} -
-
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - > - onRunProcess("start", pm)} - notify={notify} - t={t} - compact - /> - onRunProcess("stop", pm)} - notify={notify} - t={t} - compact - /> - onRunProcess("restart", pm)} - notify={notify} - t={t} - compact - /> -
+ onRunProcess("start", pm)} + notify={notify} + t={t} + compact + /> + onRunProcess("stop", pm)} + notify={notify} + t={t} + compact + /> + onRunProcess("restart", pm)} + notify={notify} + t={t} + compact + />
); })}
- )} -

- {t("当前线路", "Current lane")}: {active?.name ?? "—"} · key {keySuffixUi || "—"} -

+
)}
@@ -882,7 +885,19 @@ export function LiveYoutubeUnmannedView({
-

{t("当前线路", "Current lane")}

+
+

+ {t("当前线路", "Current lane")} + {active ? ( + · {active.name} + ) : null} +

+ {active ? ( +

+ PM2: {(active.pm2Name ?? "").trim() || defaultPm2NameForChannel(active.id)} · key {keySuffixUi || "—"} +

+ ) : null} +
- - {active ? (
@@ -982,7 +982,12 @@ export function LiveYoutubeUnmannedView({
-

{t("当前线路 · 直播地址列表", "URL list (this lane)")}

+

{t("直播地址列表", "URL list")}

+

+ {currentProc + ? managedUrlConfigRelPathForPm2(currentProc) + : "URL_config.ini"} +

{urlToolbar} {urlFetchNote ?

{urlFetchNote}

: null}