grok
This commit is contained in:
204
index.html
204
index.html
@@ -2,112 +2,174 @@
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>M3U8/FLV + PM2 控制</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest/dist/hls.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/flv.js@1.6.2/dist/flv.min.js"></script>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>HDMI 录制控制台</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background: linear-gradient(120deg, #1a237e 0%, #283593 50%, #4a148c 100%);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
}
|
||||
.container { max-width: 640px; width: 100%; background:#fff; color:#000; padding:20px; border-radius:10px; }
|
||||
#videoPlayer { width:100%; height:0; padding-bottom:56.25%; position:relative; background:#000; display:none; border-radius:5px; }
|
||||
video { position:absolute; top:0; left:0; width:100%; height:100%; }
|
||||
input, button { width:100%; margin:5px 0; padding:8px; border-radius:5px; border:1px solid #ccc; box-sizing:border-box; }
|
||||
button { background:#283593; color:white; border:none; cursor:pointer; font-weight:bold; }
|
||||
button:hover { background:#1a237e; }
|
||||
#pm2Buttons button { width:24%; margin-right:1%; }
|
||||
#pm2Buttons button:last-child { margin-right:0; }
|
||||
#pm2Log { background:#000;color:#0f0;padding:10px;height:200px;overflow:auto; white-space: pre-wrap; font-family: monospace; }
|
||||
:root{--bg:#1a237e;--accent:#283593;--text:#fff;--card:#fff;--card-text:#000}
|
||||
@media(prefers-color-scheme:dark){:root{--card:#1e1e1e;--card-text:#fff}}
|
||||
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}
|
||||
.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)}
|
||||
h1{margin:0 0 24px;font-size:1.6em;display:flex;align-items:center;gap:12px}
|
||||
#statusIndicator{font-size:1.2em;font-weight:600}
|
||||
h2{margin:24px 0 12px;font-size:1.4em}
|
||||
input,button{width:100%;padding:12px;margin:8px 0;border-radius:8px;border:none;box-sizing:border-box;font-size:1em}
|
||||
input{background:#f5f5f5;color:#000;border:1px solid #ddd}
|
||||
button{background:var(--accent);color:#fff;cursor:pointer;font-weight:600;transition:.2s}
|
||||
button:hover{background:#1a237e}
|
||||
button.loading{opacity:.7;pointer-events:none}
|
||||
button.loading::after{content:" ⏳";animation:spin 1s linear infinite}
|
||||
#pm2Buttons{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:8px;margin:12px 0}
|
||||
#videoPlayer{position:relative;width:100%;padding-bottom:56.25%;background:#000;border-radius:8px;overflow:hidden;margin:16px 0;display:none}
|
||||
video{position:absolute;inset:0;width:100%;height:100%}
|
||||
#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}
|
||||
#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}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<h2>视频播放器</h2>
|
||||
<input type="text" id="videoUrlInput" placeholder="请输入 M3U8 或 FLV 链接">
|
||||
<button id="playButton">播放视频</button>
|
||||
<h1>进程: hdmi <span id="statusIndicator">⚪ 加载中...</span></h1>
|
||||
|
||||
<h2>视频播放器(M3U8 / FLV)</h2>
|
||||
<input type="text" id="videoUrlInput" placeholder="请输入视频链接">
|
||||
<button id="playButton">播放</button>
|
||||
<div id="videoPlayer">
|
||||
<video controls></video>
|
||||
<video controls autoplay playsinline></video>
|
||||
<div id="loading">加载中...</div>
|
||||
</div>
|
||||
|
||||
<h2>PM2 控制</h2>
|
||||
<h2>PM2 进程控制</h2>
|
||||
<div id="pm2Buttons">
|
||||
<button onclick="pm2Action('start')">启动</button>
|
||||
<button onclick="pm2Action('stop')">停止</button>
|
||||
<button onclick="pm2Action('restart')">重启</button>
|
||||
<button onclick="pm2Action('status')">状态</button>
|
||||
<button data-action="start">启动</button>
|
||||
<button data-action="stop">停止</button>
|
||||
<button data-action="restart">重启</button>
|
||||
<button data-action="status">手动刷新</button>
|
||||
</div>
|
||||
<pre id="pm2Log">PM2 日志显示区</pre>
|
||||
|
||||
<h2>实时日志(自动每秒刷新)</h2>
|
||||
<pre id="pm2Log">正在加载状态与日志...</pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const video = document.querySelector('#videoPlayer video');
|
||||
const videoPlayer = document.getElementById('videoPlayer');
|
||||
const video = document.querySelector('video');
|
||||
const player = document.getElementById('videoPlayer');
|
||||
const loading = document.getElementById('loading');
|
||||
const logEl = document.getElementById('pm2Log');
|
||||
const statusIndicator = document.getElementById('statusIndicator');
|
||||
|
||||
function httpToHttps(url) {
|
||||
return url.startsWith("http://") ? url.replace("http://", "https://") : url;
|
||||
function httpToHttps(url){return url.replace(/^http:/,"https:")}
|
||||
|
||||
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();
|
||||
if(!url){ alert('请输入视频链接'); return; }
|
||||
if(!url) return alert('请输入视频链接');
|
||||
url = httpToHttps(url);
|
||||
player.style.display = 'block';
|
||||
loading.style.opacity = 1;
|
||||
video.pause(); video.src = '';
|
||||
|
||||
videoPlayer.style.display = 'block';
|
||||
if(url.includes('.m3u8')){
|
||||
if(url.endsWith('.m3u8')){
|
||||
if(Hls.isSupported()){
|
||||
const hls = new Hls();
|
||||
const hls = new Hls({enableWorker:true});
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, ()=>{ hls.loadSource(url); });
|
||||
} else if(video.canPlayType('application/vnd.apple.mpegurl')){
|
||||
video.src = url;
|
||||
} else { alert('M3U8 不支持'); return; }
|
||||
} else if(url.includes('.flv')){
|
||||
if(flvjs.isSupported()){
|
||||
const flvPlayer = flvjs.createPlayer({type:'flv',url:url});
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, ()=>loading.style.opacity=0);
|
||||
hls.on(Hls.Events.ERROR, (e,data)=>{alert('HLS 错误: '+data.type); loading.style.opacity=0});
|
||||
}else if(video.canPlayType('application/vnd.apple.mpegurl')){
|
||||
video.src = url; video.play(); loading.style.opacity=0;
|
||||
}else{alert('浏览器不支持 M3U8'); return}
|
||||
}else if(url.endsWith('.flv')){
|
||||
if(typeof flvjs !== 'undefined' && flvjs.isSupported()){
|
||||
const flvPlayer = flvjs.createPlayer({type:'flv',url,isLive:true});
|
||||
flvPlayer.attachMediaElement(video);
|
||||
flvPlayer.load(); flvPlayer.play();
|
||||
} else { alert('FLV 不支持'); return; }
|
||||
} else { alert('不支持此格式'); }
|
||||
flvPlayer.on(flvjs.Events.LOADING_COMPLETE, ()=>loading.style.opacity=0);
|
||||
}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){
|
||||
const pre = document.getElementById('pm2Log');
|
||||
try {
|
||||
const btn = document.querySelector(`[data-action="${action}"]`);
|
||||
if(btn) btn.classList.add('loading');
|
||||
|
||||
try{
|
||||
const res = await fetch(`/${action}`);
|
||||
if(!res.ok) throw new Error('网络错误');
|
||||
const data = await res.json();
|
||||
|
||||
if(action === "status"){
|
||||
pre.textContent =
|
||||
`【PM2 状态】
|
||||
${data.status}
|
||||
if(action === 'status'){
|
||||
updateStatus(data.process_status);
|
||||
|
||||
【最近输出日志】
|
||||
${data.recent_log}
|
||||
let logText = `【PM2 全状态表格】(${new Date().toLocaleTimeString()})\n${data.raw_status}\n\n`;
|
||||
|
||||
【最近错误日志】
|
||||
${data.recent_error}`;
|
||||
} else {
|
||||
pre.textContent = data.output || data.error;
|
||||
if(data.process_status === 'not_found'){
|
||||
logText += `【提示】进程 "hdmi" 未注册到 PM2(可能被 pm2 delete 删除)\n`;
|
||||
}else{
|
||||
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){
|
||||
pre.textContent = `请求失败: ${e}`;
|
||||
}catch(err){
|
||||
logEl.textContent = `请求失败: ${err.message}`;
|
||||
scrollLog();
|
||||
updateStatus('unknown');
|
||||
}finally{
|
||||
if(btn) btn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
// 可选:每 2 秒自动刷新状态
|
||||
setInterval(()=>{ pm2Action('status'); }, 2000);
|
||||
</script>
|
||||
async function fetchStatus(){ pm2Action('status'); }
|
||||
|
||||
// 按钮事件委托
|
||||
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>
|
||||
</html>
|
||||
|
||||
149
web.py
149
web.py
@@ -1,71 +1,142 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import asyncio
|
||||
import re
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
app = FastAPI()
|
||||
app = FastAPI(title="HDMI 录制控制台")
|
||||
|
||||
# ---------------- 配置 ----------------
|
||||
BOT_NAME = "hdmi" # PM2 进程名
|
||||
BOT_NAME = "hdmi"
|
||||
BASE_DIR = Path(__file__).parent
|
||||
DEFAULT_LOG_DIR = BASE_DIR.parent / ".pm2" / "logs"
|
||||
MAX_LOG_LINES = 300
|
||||
|
||||
# 允许前端跨域访问
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_methods=["GET"],
|
||||
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 控制 ----------------
|
||||
def pm2_cmd(cmd: str):
|
||||
async def run_pm2(cmd: str) -> str:
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
f"pm2 {cmd}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
out = subprocess.check_output(f"pm2 {cmd}", shell=True, stderr=subprocess.STDOUT, text=True)
|
||||
return out
|
||||
except subprocess.CalledProcessError as e:
|
||||
return e.output
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=15.0)
|
||||
return (stdout or stderr).decode(errors="ignore").strip()
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
return "ERROR: PM2 命令超时"
|
||||
|
||||
def read_log(log_type="out", lines=50):
|
||||
log_file = BASE_DIR.parent / f".pm2/logs/{BOT_NAME}-{log_type}.log"
|
||||
async def read_log_path(path: str, lines: int) -> str:
|
||||
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:
|
||||
with open(log_file, "r", encoding="utf-8") as f:
|
||||
content = f.readlines()[-lines:]
|
||||
return "".join(content)
|
||||
except FileNotFoundError:
|
||||
return f"未找到日志文件: {log_file}"
|
||||
with open(log_file, "r", encoding="utf-8", errors="ignore") as f:
|
||||
content_lines = f.readlines()
|
||||
return "".join(content_lines[-lines:]) if content_lines else "无日志内容\n"
|
||||
except Exception as e:
|
||||
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")
|
||||
def start():
|
||||
out = pm2_cmd(f"start {BOT_NAME}")
|
||||
return JSONResponse({"action": "start", "output": out})
|
||||
async def start():
|
||||
output = await run_pm2(f"start {BOT_NAME}")
|
||||
return JSONResponse({"output": output})
|
||||
|
||||
@app.get("/stop")
|
||||
def stop():
|
||||
out = pm2_cmd(f"stop {BOT_NAME} --no-autorestart")
|
||||
return JSONResponse({"action": "stop", "output": out})
|
||||
async def stop():
|
||||
output = await run_pm2(f"stop {BOT_NAME}")
|
||||
return JSONResponse({"output": output})
|
||||
|
||||
@app.get("/restart")
|
||||
def restart():
|
||||
out = pm2_cmd(f"restart {BOT_NAME}")
|
||||
return JSONResponse({"action": "restart", "output": out})
|
||||
async def restart():
|
||||
output = await run_pm2(f"restart {BOT_NAME}")
|
||||
return JSONResponse({"output": output})
|
||||
|
||||
@app.get("/status")
|
||||
def status():
|
||||
status = pm2_cmd(f"status {BOT_NAME}")
|
||||
log = read_log("out", 50)
|
||||
error_log = read_log("error", 50)
|
||||
async def status():
|
||||
full_status_raw = await run_pm2("status")
|
||||
full_status = strip_ansi_codes(full_status_raw) # 去除颜色代码,前端显示更干净
|
||||
|
||||
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({
|
||||
"status": status,
|
||||
"recent_log": log,
|
||||
"recent_error": error_log
|
||||
"raw_status": full_status,
|
||||
"process_status": info["process_status"],
|
||||
"recent_log": recent_log,
|
||||
"recent_error": recent_error,
|
||||
})
|
||||
|
||||
# ---------------- HTML ----------------
|
||||
@app.get("/")
|
||||
def index():
|
||||
async def index():
|
||||
return FileResponse(BASE_DIR / "index.html")
|
||||
|
||||
Reference in New Issue
Block a user