This commit is contained in:
eric
2026-03-22 23:29:04 -05:00
parent a4b7edd153
commit cc552d49f0
2 changed files with 151 additions and 42 deletions

View File

@@ -84,16 +84,36 @@ button.loading::after{content:" ⏳";animation:spin 1s linear infinite}
<pre id="pm2Log">正在加载状态与日志...</pre> <pre id="pm2Log">正在加载状态与日志...</pre>
</div> </div>
<script> <script>
/** /** 进程列表仅来自 GET /process_monitorweb2.py 启动时扫描项目根目录自动识别PM2 名 = 脚本主文件名(无扩展名)。 */
* 监控目标:本分支用 *2.py / web2 / obs2 等与「无 2」分支区分常与 Ubuntu+PM2 配套。 let PROCESS_MONITOR = [];
* 与 web2.py 中 PROCESS_MONITOR / VALID_PROCESSES 一致PM2 应用名script 为入口脚本文件名。
* 若改了 PM2 进程名,请同步改 pm2 字段与 web2.py。 function pm2NameFromScript(script) {
*/ const base = String(script).split(/[/\\]/).pop() || '';
const PROCESS_MONITOR = [ const i = base.lastIndexOf('.');
{ pm2: 'tiktok', script: 'tiktok2.py', label: 'TikTok' }, return i > 0 ? base.slice(0, i) : base;
{ pm2: 'youtube', script: 'youtube2.py', label: 'YouTube' }, }
{ pm2: 'obs', script: 'obs2.sh', label: 'OBS' },
]; function isYoutubeScript(script) {
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('youtube');
}
function isTiktokScript(script) {
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('tiktok');
}
function isObsScript(script) {
return pm2NameFromScript(script).startsWith('obs');
}
async function loadProcessMonitor() {
const res = await fetch('/process_monitor');
if (!res.ok) throw new Error(String(res.status));
const data = await res.json();
const entries = data.entries || [];
PROCESS_MONITOR = entries.map((e) => ({
script: e.script,
label: e.label || e.script,
pm2: e.pm2 || pm2NameFromScript(e.script),
}));
}
function getEntryByPm2(name) { function getEntryByPm2(name) {
return PROCESS_MONITOR.find((e) => e.pm2 === name) || null; return PROCESS_MONITOR.find((e) => e.pm2 === name) || null;
@@ -112,8 +132,7 @@ function fillProcessSelect() {
const logEl = document.getElementById('pm2Log'); const logEl = document.getElementById('pm2Log');
const statusIndicator = document.getElementById('statusIndicator'); const statusIndicator = document.getElementById('statusIndicator');
const processSelect = document.getElementById('processSelect'); const processSelect = document.getElementById('processSelect');
fillProcessSelect(); let currentProcess = '';
let currentProcess = PROCESS_MONITOR[0].pm2;
// 各专属区域 // 各专属区域
const youtubeOnlySection = document.getElementById('youtubeOnlySection'); const youtubeOnlySection = document.getElementById('youtubeOnlySection');
@@ -147,9 +166,10 @@ processSelect.addEventListener('change', () => {
function updateSectionsVisibility() { function updateSectionsVisibility() {
const ent = getEntryByPm2(currentProcess); const ent = getEntryByPm2(currentProcess);
const isYoutube = ent && ent.script === 'youtube2.py'; const s = ent ? ent.script : '';
const isTiktok = ent && ent.script === 'tiktok2.py'; const isYoutube = ent && isYoutubeScript(s);
const isObs = ent && ent.script === 'obs2.sh'; const isTiktok = ent && isTiktokScript(s);
const isObs = ent && isObsScript(s);
youtubeOnlySection.style.display = isYoutube ? 'block' : 'none'; youtubeOnlySection.style.display = isYoutube ? 'block' : 'none';
urlConfigSection.style.display = (isYoutube || isTiktok) ? 'block' : 'none'; urlConfigSection.style.display = (isYoutube || isTiktok) ? 'block' : 'none';
@@ -291,11 +311,26 @@ document.getElementById('pm2Buttons').addEventListener('click', e=>{
if(action && action !== 'status') pm2Action(action); if(action && action !== 'status') pm2Action(action);
}); });
// 自动刷新 + 初始 (async function boot() {
setInterval(fetchStatus, 1000); try {
refreshProcessScriptNote(); await loadProcessMonitor();
fetchStatus(); } catch (e) {
updateSectionsVisibility(); logEl.textContent = '无法加载 /process_monitor请用本服务打开的页面访问或检查 web2 是否已启动)\n' + (e && e.message ? e.message : '');
statusIndicator.textContent = '❌ 未连接';
return;
}
if (!PROCESS_MONITOR.length) {
logEl.textContent = '未发现任何入口脚本(项目根应有 tiktok*.py / youtube*.py / obs*.sh且 Web 为当前 web*.py';
return;
}
fillProcessSelect();
currentProcess = PROCESS_MONITOR[0].pm2;
processSelect.value = currentProcess;
refreshProcessScriptNote();
setInterval(fetchStatus, 1000);
fetchStatus();
updateSectionsVisibility();
})();
</script> </script>
</body> </body>
</html> </html>

118
web2.py
View File

@@ -3,6 +3,7 @@ from fastapi.responses import FileResponse, JSONResponse
from pathlib import Path from pathlib import Path
import asyncio import asyncio
import re import re
from typing import Optional
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="多进程录制控制台") app = FastAPI(title="多进程录制控制台")
@@ -14,21 +15,80 @@ DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs"
MAX_LOG_LINES = 300 MAX_LOG_LINES = 300
# 本分支用文件名带「2」与另一套无后缀分支区分典型部署为 Ubuntu + PM2。
# 与 index.html 中 PROCESS_MONITOR 保持一致pm2=PM2 应用名script=入口脚本 def _label_short(prefix: str) -> str:
PROCESS_MONITOR = ( return {"tiktok": "TikTok", "youtube": "YouTube", "obs": "OBS", "web": "Web 控制台"}.get(prefix, prefix)
{"pm2": "tiktok", "script": "tiktok2.py", "label": "TikTok"},
{"pm2": "youtube", "script": "youtube2.py", "label": "YouTube"},
{"pm2": "obs", "script": "obs2.sh", "label": "OBS"}, def discover_script_entries() -> tuple:
) """扫描项目根目录自动识别入口tiktok*.py、youtube*.py、obs*.sh以及当前本文件Web 控制台。PM2 名 = 主文件名(无扩展名)。"""
base = BASE_DIR
web_path = Path(__file__).resolve()
entries: list[dict] = []
for path in sorted(base.glob("tiktok*.py")):
if path.is_file() and path.resolve() != web_path:
entries.append({"script": path.name, "label": _label_short("tiktok")})
for path in sorted(base.glob("youtube*.py")):
if path.is_file() and path.resolve() != web_path:
entries.append({"script": path.name, "label": _label_short("youtube")})
for path in sorted(base.glob("obs*.sh")):
if path.is_file():
entries.append({"script": path.name, "label": _label_short("obs")})
entries.append({"script": web_path.name, "label": _label_short("web")})
return tuple(entries)
SCRIPT_ENTRIES = discover_script_entries()
def pm2_name_from_script(script: str) -> str:
return Path(script).stem
def _stem(script: str) -> str:
return Path(script).stem
def is_youtube_script(script: str) -> bool:
return script.endswith(".py") and _stem(script).startswith("youtube")
def is_tiktok_script(script: str) -> bool:
return script.endswith(".py") and _stem(script).startswith("tiktok")
def _build_process_monitor():
return tuple(
{
"pm2": pm2_name_from_script(e["script"]),
"script": e["script"],
"label": e["label"],
}
for e in SCRIPT_ENTRIES
)
PROCESS_MONITOR = _build_process_monitor()
VALID_PROCESSES = frozenset(p["pm2"] for p in PROCESS_MONITOR) VALID_PROCESSES = frozenset(p["pm2"] for p in PROCESS_MONITOR)
# youtube.ini 仅对与 youtube2.py 绑定的 PM2 名开放
YOUTUBE_PM2 = next(p["pm2"] for p in PROCESS_MONITOR if p["script"] == "youtube2.py")
# URL_config.initiktok2 / youtube2 两条入口共用
URL_CONFIG_PM2 = frozenset( URL_CONFIG_PM2 = frozenset(
p["pm2"] for p in PROCESS_MONITOR if p["script"] in ("tiktok2.py", "youtube2.py") pm2_name_from_script(e["script"])
for e in SCRIPT_ENTRIES
if is_tiktok_script(e["script"]) or is_youtube_script(e["script"])
) )
def script_for_pm2(pm2: str) -> Optional[str]:
for p in PROCESS_MONITOR:
if p["pm2"] == pm2:
return p["script"]
return None
# CORS # CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -137,8 +197,9 @@ async def get_process_info(process: str) -> dict:
# ---------------- 配置路由youtube.ini仅 youtube ---------------- # ---------------- 配置路由youtube.ini仅 youtube ----------------
@app.get("/get_config") @app.get("/get_config")
async def get_config(process: str = Query(..., description="进程名")): async def get_config(process: str = Query(..., description="进程名")):
if process != YOUTUBE_PM2: sc = script_for_pm2(process)
return JSONResponse({"error": "仅 YouTube 入口youtube2.py对应进程支持此配置编辑"}, status_code=400) if not sc or not is_youtube_script(sc):
return JSONResponse({"error": "仅 youtube*.py 对应进程支持 youtube.ini 编辑"}, status_code=400)
config_file = CONFIG_DIR / "youtube.ini" config_file = CONFIG_DIR / "youtube.ini"
if not config_file.exists(): if not config_file.exists():
@@ -151,8 +212,9 @@ async def get_config(process: str = Query(..., description="进程名")):
@app.post("/save_config") @app.post("/save_config")
async def save_config(request: Request, process: str = Query(..., description="进程名")): async def save_config(request: Request, process: str = Query(..., description="进程名")):
if process != YOUTUBE_PM2: sc = script_for_pm2(process)
return JSONResponse({"error": "仅 YouTube 入口youtube2.py对应进程支持此配置编辑"}, status_code=400) if not sc or not is_youtube_script(sc):
return JSONResponse({"error": "仅 youtube*.py 对应进程支持 youtube.ini 编辑"}, status_code=400)
try: try:
data = await request.json() data = await request.json()
@@ -172,7 +234,7 @@ async def save_config(request: Request, process: str = Query(..., description="
@app.get("/get_url_config") @app.get("/get_url_config")
async def get_url_config(process: str = Query(..., description="进程名")): async def get_url_config(process: str = Query(..., description="进程名")):
if process not in URL_CONFIG_PM2: if process not in URL_CONFIG_PM2:
return JSONResponse({"error": "仅 tiktok2.py / youtube2.py 对应进程支持 URL 配置编辑"}, status_code=400) return JSONResponse({"error": "仅 tiktok*.py / youtube*.py 对应进程支持 URL 配置编辑"}, status_code=400)
config_file = CONFIG_DIR / "URL_config.ini" config_file = CONFIG_DIR / "URL_config.ini"
if not config_file.exists(): if not config_file.exists():
@@ -186,7 +248,7 @@ async def get_url_config(process: str = Query(..., description="进程名")):
@app.post("/save_url_config") @app.post("/save_url_config")
async def save_url_config(request: Request, process: str = Query(..., description="进程名")): async def save_url_config(request: Request, process: str = Query(..., description="进程名")):
if process not in URL_CONFIG_PM2: if process not in URL_CONFIG_PM2:
return JSONResponse({"error": "仅 tiktok2.py / youtube2.py 对应进程支持 URL 配置编辑"}, status_code=400) return JSONResponse({"error": "仅 tiktok*.py / youtube*.py 对应进程支持 URL 配置编辑"}, status_code=400)
try: try:
data = await request.json() data = await request.json()
@@ -222,14 +284,15 @@ async def stop(process: str = Query(..., description="进程名")):
# 第二步:等待 PM2 信号传播(通常 1~3 秒) # 第二步:等待 PM2 信号传播(通常 1~3 秒)
await asyncio.sleep(2.0) await asyncio.sleep(2.0)
# 第三步:主动查找并杀死 ffmpeg # 第三步:录制类进程才强杀 ffmpegweb* 控制台不杀 ffmpeg
await force_kill_ffmpeg(process) if not process.startswith("web"):
await force_kill_ffmpeg(process)
# 第四步:再等一小会儿,确认 # 第四步:再等一小会儿,确认
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
return JSONResponse({ return JSONResponse({
"message": "已执行 stop + 强杀 ffmpeg", "message": "已执行 stop" + ("" if process.startswith("web") else " + 强杀 ffmpeg"),
"pm2_output": pm2_output, "pm2_output": pm2_output,
"note": "如果仍有残留,可多次点击停止或检查 pm2 logs" "note": "如果仍有残留,可多次点击停止或检查 pm2 logs"
}) })
@@ -240,8 +303,8 @@ async def restart(process: str = Query(..., description="进程名")):
if process not in VALID_PROCESSES: if process not in VALID_PROCESSES:
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400) return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
output = await run_pm2(f"restart {process}") output = await run_pm2(f"restart {process}")
# 重启后也建议清理一次残留(视情况可选) if not process.startswith("web"):
await force_kill_ffmpeg(process) await force_kill_ffmpeg(process)
return JSONResponse({"output": output}) return JSONResponse({"output": output})
@@ -273,6 +336,17 @@ async def status(process: str = Query(..., description="进程名")):
}) })
# ---------------- 进程列表(供前端同步,由 discover_script_entries 自动扫描) ----------------
@app.get("/process_monitor")
async def process_monitor():
return {
"entries": [
{"pm2": p["pm2"], "script": p["script"], "label": p["label"]}
for p in PROCESS_MONITOR
]
}
# ---------------- 首页 ---------------- # ---------------- 首页 ----------------
@app.get("/") @app.get("/")
async def index(): async def index():