This commit is contained in:
Your Name
2026-01-24 04:24:41 +00:00
parent 50b61d75fc
commit 0806545014
3 changed files with 244 additions and 110 deletions

View File

@@ -2,112 +2,174 @@
<html lang="zh"> <html lang="zh">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>M3U8/FLV + PM2 控制</title> <title>HDMI 录制控制</title>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest/dist/hls.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script src="https://cdn.jsdelivr.net/npm/flv.js@1.6.2/dist/flv.min.js"></script>
<style> <style>
body { :root{--bg:#1a237e;--accent:#283593;--text:#fff;--card:#fff;--card-text:#000}
font-family: Arial, sans-serif; @media(prefers-color-scheme:dark){:root{--card:#1e1e1e;--card-text:#fff}}
background: linear-gradient(120deg, #1a237e 0%, #283593 50%, #4a148c 100%); body{margin:0;font-family:system-ui,-apple-system,sans-serif;background:linear-gradient(135deg,var(--bg),#283593,#4a148c);color:var(--text);min-height:100vh;display:flex;justify-content:center;padding:20px;box-sizing:border-box}
color: #fff; .container{max-width:720px;width:100%;background:var(--card);color:var(--card-text);border-radius:12px;padding:24px;box-shadow:0 8px 32px rgba(0,0,0,.4)}
display: flex; h1{margin:0 0 24px;font-size:1.6em;display:flex;align-items:center;gap:12px}
justify-content: center; #statusIndicator{font-size:1.2em;font-weight:600}
align-items: flex-start; h2{margin:24px 0 12px;font-size:1.4em}
flex-direction: column; input,button{width:100%;padding:12px;margin:8px 0;border-radius:8px;border:none;box-sizing:border-box;font-size:1em}
padding: 20px; input{background:#f5f5f5;color:#000;border:1px solid #ddd}
} button{background:var(--accent);color:#fff;cursor:pointer;font-weight:600;transition:.2s}
.container { max-width: 640px; width: 100%; background:#fff; color:#000; padding:20px; border-radius:10px; } button:hover{background:#1a237e}
#videoPlayer { width:100%; height:0; padding-bottom:56.25%; position:relative; background:#000; display:none; border-radius:5px; } button.loading{opacity:.7;pointer-events:none}
video { position:absolute; top:0; left:0; width:100%; height:100%; } button.loading::after{content:" ⏳";animation:spin 1s linear infinite}
input, button { width:100%; margin:5px 0; padding:8px; border-radius:5px; border:1px solid #ccc; box-sizing:border-box; } #pm2Buttons{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:8px;margin:12px 0}
button { background:#283593; color:white; border:none; cursor:pointer; font-weight:bold; } #videoPlayer{position:relative;width:100%;padding-bottom:56.25%;background:#000;border-radius:8px;overflow:hidden;margin:16px 0;display:none}
button:hover { background:#1a237e; } video{position:absolute;inset:0;width:100%;height:100%}
#pm2Buttons button { width:24%; margin-right:1%; } #loading{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;font-size:1.8em;pointer-events:none;background:rgba(0,0,0,.5);opacity:0;transition:opacity .3s;border-radius:8px}
#pm2Buttons button:last-child { margin-right:0; } #pm2Log{background:#000;color:#0f0;padding:12px;height:320px;overflow-y:auto;font-family:monospace;font-size:.9em;border-radius:8px;white-space:pre-wrap;word-break:break-all}
#pm2Log { background:#000;color:#0f0;padding:10px;height:200px;overflow:auto; white-space: pre-wrap; font-family: monospace; } @keyframes spin{to{transform:rotate(360deg)}}
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<h2>视频播放器</h2> <h1>进程: hdmi <span id="statusIndicator">⚪ 加载中...</span></h1>
<input type="text" id="videoUrlInput" placeholder="请输入 M3U8 或 FLV 链接">
<button id="playButton">播放视频</button> <h2>视频播放器M3U8 / FLV</h2>
<input type="text" id="videoUrlInput" placeholder="请输入视频链接">
<button id="playButton">播放</button>
<div id="videoPlayer"> <div id="videoPlayer">
<video controls></video> <video controls autoplay playsinline></video>
<div id="loading">加载中...</div>
</div> </div>
<h2>PM2 控制</h2> <h2>PM2 进程控制</h2>
<div id="pm2Buttons"> <div id="pm2Buttons">
<button onclick="pm2Action('start')">启动</button> <button data-action="start">启动</button>
<button onclick="pm2Action('stop')">停止</button> <button data-action="stop">停止</button>
<button onclick="pm2Action('restart')">重启</button> <button data-action="restart">重启</button>
<button onclick="pm2Action('status')">状态</button> <button data-action="status">手动刷新</button>
</div> </div>
<pre id="pm2Log">PM2 日志显示区</pre>
<h2>实时日志(自动每秒刷新)</h2>
<pre id="pm2Log">正在加载状态与日志...</pre>
</div> </div>
<script> <script>
const video = document.querySelector('#videoPlayer video'); const video = document.querySelector('video');
const videoPlayer = document.getElementById('videoPlayer'); const player = document.getElementById('videoPlayer');
const loading = document.getElementById('loading');
const logEl = document.getElementById('pm2Log');
const statusIndicator = document.getElementById('statusIndicator');
function httpToHttps(url) { function httpToHttps(url){return url.replace(/^http:/,"https:")}
return url.startsWith("http://") ? url.replace("http://", "https://") : url;
function scrollLog(){logEl.scrollTop = logEl.scrollHeight}
function updateStatus(process_status){
let emoji = '⚪', text = '未知';
if(process_status === 'online'){ emoji = '🟢'; text = '运行中'; }
else if(process_status === 'stopped'){ emoji = '🔴'; text = '已停止'; }
else if(process_status === 'errored'){ emoji = '🔥'; text = '错误'; }
else if(process_status === 'launching' || process_status === 'starting'){ emoji = '🟡'; text = '启动中'; }
else if(process_status === 'waiting restart'){ emoji = '🟡'; text = '等待重启'; }
else if(process_status === 'not_found'){ emoji = '❌'; text = '未注册到 PM2'; }
statusIndicator.innerHTML = `${emoji} ${text}`;
} }
function playVideo() { async function playVideo(){
let url = document.getElementById('videoUrlInput').value.trim(); let url = document.getElementById('videoUrlInput').value.trim();
if(!url){ alert('请输入视频链接'); return; } if(!url) return alert('请输入视频链接');
url = httpToHttps(url); url = httpToHttps(url);
player.style.display = 'block';
loading.style.opacity = 1;
video.pause(); video.src = '';
videoPlayer.style.display = 'block'; if(url.endsWith('.m3u8')){
if(url.includes('.m3u8')){
if(Hls.isSupported()){ if(Hls.isSupported()){
const hls = new Hls(); const hls = new Hls({enableWorker:true});
hls.loadSource(url);
hls.attachMedia(video); hls.attachMedia(video);
hls.on(Hls.Events.MEDIA_ATTACHED, ()=>{ hls.loadSource(url); }); hls.on(Hls.Events.MANIFEST_PARSED, ()=>loading.style.opacity=0);
} else if(video.canPlayType('application/vnd.apple.mpegurl')){ hls.on(Hls.Events.ERROR, (e,data)=>{alert('HLS 错误: '+data.type); loading.style.opacity=0});
video.src = url; }else if(video.canPlayType('application/vnd.apple.mpegurl')){
} else { alert('M3U8 不支持'); return; } video.src = url; video.play(); loading.style.opacity=0;
} else if(url.includes('.flv')){ }else{alert('浏览器不支持 M3U8'); return}
if(flvjs.isSupported()){ }else if(url.endsWith('.flv')){
const flvPlayer = flvjs.createPlayer({type:'flv',url:url}); if(typeof flvjs !== 'undefined' && flvjs.isSupported()){
const flvPlayer = flvjs.createPlayer({type:'flv',url,isLive:true});
flvPlayer.attachMediaElement(video); flvPlayer.attachMediaElement(video);
flvPlayer.load(); flvPlayer.play(); flvPlayer.load(); flvPlayer.play();
} else { alert('FLV 不支持'); return; } flvPlayer.on(flvjs.Events.LOADING_COMPLETE, ()=>loading.style.opacity=0);
} else { alert('不支持此格式'); } }else{
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/flv.js@1.6.2/dist/flv.min.js';
script.onload = playVideo;
document.head.appendChild(script);
return;
}
}else{alert('仅支持 .m3u8 或 .flv 格式'); return}
} }
document.getElementById('playButton').addEventListener('click', playVideo); document.getElementById('playButton').onclick = playVideo;
// ---------------- PM2 控制 ---------------- // PM2 控制 + 实时状态/日志
async function pm2Action(action){ async function pm2Action(action){
const pre = document.getElementById('pm2Log'); const btn = document.querySelector(`[data-action="${action}"]`);
try { if(btn) btn.classList.add('loading');
try{
const res = await fetch(`/${action}`); const res = await fetch(`/${action}`);
if(!res.ok) throw new Error('网络错误');
const data = await res.json(); const data = await res.json();
if(action === "status"){ if(action === 'status'){
pre.textContent = updateStatus(data.process_status);
`【PM2 状态】
${data.status}
【最近输出日志】 let logText = `【PM2 全状态表格】(${new Date().toLocaleTimeString()}\n${data.raw_status}\n\n`;
${data.recent_log}
【最近错误日志】 if(data.process_status === 'not_found'){
${data.recent_error}`; logText += `【提示】进程 "hdmi" 未注册到 PM2可能被 pm2 delete 删除)\n`;
} else { }else{
pre.textContent = data.output || data.error; logText += `【实时输出日志】`;
if(data.recent_log.includes('不存在') || data.recent_log.includes('无日志路径')){
logText += `\n${data.recent_log}`;
}else{
logText += `(最近 ${data.recent_log.trim().split('\n').length} 行)\n${data.recent_log || '无新输出'}`;
}
logText += `\n\n【实时错误日志】`;
if(data.recent_error.includes('不存在') || data.recent_error.includes('无日志路径')){
logText += `\n${data.recent_error}`;
}else{
logText += `(最近 ${data.recent_error.trim().split('\n').length} 行)\n${data.recent_error || '无错误'}`;
}
}
logEl.textContent = logText;
scrollLog();
}else{
logEl.textContent = `操作: ${action}\n${data.output || '成功'}\n\n正在刷新状态...`;
scrollLog();
setTimeout(fetchStatus, 1500);
} }
} catch(e){ }catch(err){
pre.textContent = `请求失败: ${e}`; logEl.textContent = `请求失败: ${err.message}`;
scrollLog();
updateStatus('unknown');
}finally{
if(btn) btn.classList.remove('loading');
} }
} }
// 可选:每 2 秒自动刷新状态 async function fetchStatus(){ pm2Action('status'); }
setInterval(()=>{ pm2Action('status'); }, 2000);
</script>
// 按钮事件委托
document.getElementById('pm2Buttons').addEventListener('click', e=>{
const btn = e.target.closest('button');
if(!btn) return;
const action = btn.dataset.action;
if(action && action !== 'status') pm2Action(action);
});
// 自动刷新(每 1 秒)
setInterval(fetchStatus, 1000);
fetchStatus();
</script>
</body> </body>
</html> </html>

149
web.py
View File

@@ -1,71 +1,142 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
import subprocess
from pathlib import Path from pathlib import Path
import asyncio
import re
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
app = FastAPI() app = FastAPI(title="HDMI 录制控制台")
# ---------------- 配置 ---------------- # ---------------- 配置 ----------------
BOT_NAME = "hdmi" # PM2 进程名 BOT_NAME = "hdmi"
BASE_DIR = Path(__file__).parent BASE_DIR = Path(__file__).parent
DEFAULT_LOG_DIR = BASE_DIR.parent / ".pm2" / "logs"
MAX_LOG_LINES = 300
# 允许前端跨域访问 # CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
allow_methods=["*"], allow_methods=["GET"],
allow_headers=["*"], allow_headers=["*"],
) )
# 静态文件 # ---------------- 工具 ----------------
app.mount("/static", StaticFiles(directory=BASE_DIR), name="static") def strip_ansi_codes(text: str) -> str:
return re.sub(r'\x1b\[[0-9;]*m', '', text)
# ---------------- PM2 控制 ---------------- async def run_pm2(cmd: str) -> str:
def pm2_cmd(cmd: str): process = await asyncio.create_subprocess_shell(
f"pm2 {cmd}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try: try:
out = subprocess.check_output(f"pm2 {cmd}", shell=True, stderr=subprocess.STDOUT, text=True) stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=15.0)
return out return (stdout or stderr).decode(errors="ignore").strip()
except subprocess.CalledProcessError as e: except asyncio.TimeoutError:
return e.output process.kill()
return "ERROR: PM2 命令超时"
def read_log(log_type="out", lines=50): async def read_log_path(path: str, lines: int) -> str:
log_file = BASE_DIR.parent / f".pm2/logs/{BOT_NAME}-{log_type}.log" if not path:
return "无日志路径\n"
if "/dev/null" in path:
return "日志输出被禁用PM2 配置为无日志)\n"
log_file = Path(path)
if not log_file.exists():
return f"日志文件不存在: {path}\n"
try: try:
with open(log_file, "r", encoding="utf-8") as f: with open(log_file, "r", encoding="utf-8", errors="ignore") as f:
content = f.readlines()[-lines:] content_lines = f.readlines()
return "".join(content) return "".join(content_lines[-lines:]) if content_lines else "无日志内容\n"
except FileNotFoundError: except Exception as e:
return f"未找到日志文件: {log_file}" return f"读取日志失败 ({path}): {str(e)}\n"
async def get_hdmi_info() -> dict:
describe_raw = await run_pm2(f"describe {BOT_NAME}")
describe_clean = strip_ansi_codes(describe_raw)
if ("No such process" in describe_clean or
"Unknown process" in describe_clean or
"not found" in describe_clean.lower() or
not describe_clean.strip()):
return {
"process_status": "not_found",
"out_path": None,
"err_path": None,
}
status = "unknown"
out_path = None
err_path = None
for line in describe_raw.splitlines():
line_clean = strip_ansi_codes(line)
if line_clean.count('') < 2:
continue
parts = [p.strip() for p in line_clean.split('') if p.strip()]
if len(parts) >= 2:
key = parts[0].lower()
value = parts[1]
if key == "status":
status = value.lower()
elif key == "out_log_path":
out_path = value
elif key == "err_log_path":
err_path = value
# 备用默认路径(仅当 describe 未提供时)
if not out_path:
out_path = str(DEFAULT_LOG_DIR / f"{BOT_NAME}-out.log")
if not err_path:
err_path = str(DEFAULT_LOG_DIR / f"{BOT_NAME}-error.log")
return {
"process_status": status,
"out_path": out_path,
"err_path": err_path,
}
# ---------------- 路由 ----------------
@app.get("/start") @app.get("/start")
def start(): async def start():
out = pm2_cmd(f"start {BOT_NAME}") output = await run_pm2(f"start {BOT_NAME}")
return JSONResponse({"action": "start", "output": out}) return JSONResponse({"output": output})
@app.get("/stop") @app.get("/stop")
def stop(): async def stop():
out = pm2_cmd(f"stop {BOT_NAME} --no-autorestart") output = await run_pm2(f"stop {BOT_NAME}")
return JSONResponse({"action": "stop", "output": out}) return JSONResponse({"output": output})
@app.get("/restart") @app.get("/restart")
def restart(): async def restart():
out = pm2_cmd(f"restart {BOT_NAME}") output = await run_pm2(f"restart {BOT_NAME}")
return JSONResponse({"action": "restart", "output": out}) return JSONResponse({"output": output})
@app.get("/status") @app.get("/status")
def status(): async def status():
status = pm2_cmd(f"status {BOT_NAME}") full_status_raw = await run_pm2("status")
log = read_log("out", 50) full_status = strip_ansi_codes(full_status_raw) # 去除颜色代码,前端显示更干净
error_log = read_log("error", 50)
info = await get_hdmi_info()
recent_log = await read_log_path(info["out_path"], MAX_LOG_LINES)
recent_error = await read_log_path(info["err_path"], MAX_LOG_LINES)
if info["process_status"] == "not_found":
recent_log = "进程未注册到 PM2可能被 pm2 delete 删除或从未保存)\n"
recent_error = ""
return JSONResponse({ return JSONResponse({
"status": status, "raw_status": full_status,
"recent_log": log, "process_status": info["process_status"],
"recent_error": error_log "recent_log": recent_log,
"recent_error": recent_error,
}) })
# ---------------- HTML ----------------
@app.get("/") @app.get("/")
def index(): async def index():
return FileResponse(BASE_DIR / "index.html") return FileResponse(BASE_DIR / "index.html")

1
web.sh Normal file
View File

@@ -0,0 +1 @@
uvicorn web:app --host 0.0.0.0 --port 8000 --reload