1344 lines
57 KiB
Go
1344 lines
57 KiB
Go
// web UI 相关
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"crypto/subtle"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net"
|
||
"net/http"
|
||
"os"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// 储存日志
|
||
var logString struct {
|
||
sync.Mutex
|
||
str strings.Builder
|
||
total int64 // 从启动到现在写入的总字节数,用于按偏移量增量读取日志
|
||
}
|
||
|
||
const webuiHTML = `<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>AcFun 直播录制</title>
|
||
<style>
|
||
:root{--line:#e8eaed;--soft:#f0f1f3;--sub:#8a8f98;--red:#fd4c5d}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;background:#f6f7f8;color:#222;font:14px/1.5 system-ui,-apple-system,"Microsoft YaHei",sans-serif;-webkit-text-size-adjust:100%}
|
||
.wrap{max-width:1000px;margin:0 auto;padding:14px 12px 28px}
|
||
h1{font-size:16px;font-weight:600;margin:0 0 2px}
|
||
.head-sub{font-size:12px;color:var(--sub);margin-bottom:12px}
|
||
.card{background:#fff;border:1px solid var(--line);border-radius:6px;padding:12px 14px;margin-bottom:10px}
|
||
.card h2{font-size:13px;font-weight:600;margin:0 0 10px;color:#333}
|
||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:10px 14px}
|
||
.f label{display:block;font-size:12px;color:var(--sub);margin-bottom:4px}
|
||
input[type=text],select,textarea{width:100%;border:1px solid #dcdfe4;border-radius:4px;padding:7px 9px;font:inherit;background:#fff;color:#222}
|
||
input[type=text]:focus,select:focus,textarea:focus{outline:none;border-color:var(--red)}
|
||
textarea{resize:vertical;line-height:1.5;font-size:13px}
|
||
select[multiple]{padding:4px}
|
||
#mrgInfo{white-space:pre-wrap;margin-top:8px}
|
||
#mrgInfo:empty{display:none}
|
||
input.br{width:88px;text-align:center}
|
||
.chk{display:inline-flex;align-items:center;gap:5px;margin:0 14px 0 0;font-size:13px;color:#333;cursor:pointer}
|
||
button{border:none;border-radius:4px;padding:8px 16px;font:inherit;cursor:pointer;background:var(--red);color:#fff}
|
||
button.ghost,a.ghost{background:#f1f2f4;color:#555;padding:6px 10px;font-size:12px;text-decoration:none;display:inline-block}
|
||
button:active,a.ghost:active{opacity:.8}
|
||
button:disabled{opacity:.5}
|
||
.hint{font-size:12px;color:var(--sub);margin-top:6px;line-height:1.6}
|
||
.hr{height:1px;background:var(--soft);margin:12px -14px}
|
||
.row{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;padding:10px 0;border-top:1px solid var(--soft)}
|
||
.row:first-child{border-top:none;padding-top:2px}
|
||
.uid{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}
|
||
.name{font-weight:600;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.title{flex:1 1 200px;min-width:0;font-size:12px;color:var(--sub);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.ops{margin-left:auto;display:flex;gap:6px;flex-wrap:wrap}
|
||
.badge{display:inline-block;padding:1px 7px;border-radius:3px;font-size:12px;margin-right:4px}
|
||
.on{background:#ffe8e8;color:#d63046}
|
||
.rec{background:#e6f4ec;color:#1d7a46}
|
||
.off{font-size:12px;color:var(--sub)}
|
||
pre{background:#1e1e1e;color:#d6d6d6;border-radius:6px;padding:10px;font-size:12px;max-height:300px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0}
|
||
#msg{font-size:12px;margin-left:8px}
|
||
input[readonly]{background:#f6f7f8;color:#555}
|
||
.mask{position:fixed;left:0;top:0;right:0;bottom:0;background:rgba(20,22,26,.55);display:none;align-items:center;justify-content:center;z-index:9;padding:16px}
|
||
.mask.on{display:flex}
|
||
.mask .box{background:#fff;border-radius:8px;padding:18px;width:100%;max-width:380px}
|
||
.mask h3{margin:0 0 6px;font-size:15px}
|
||
.mask p{margin:0 0 12px;font-size:12px;color:var(--sub);line-height:1.6}
|
||
#authMsg{font-size:12px;color:#d63046;min-height:17px;margin-top:8px}
|
||
@media (max-width:600px){
|
||
.name{max-width:none}
|
||
.title{flex:1 1 100%;order:8}
|
||
.ops{width:100%;margin-left:0;order:9}
|
||
.ops button,.ops a{flex:1 1 0;padding:8px 4px;text-align:center}
|
||
button.ghost,.mask button{padding:8px 10px;font-size:13px}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<h1>AcFun 直播录制</h1>
|
||
<div class="head-sub">弹幕 / 视频录制,直播结束后自动合并</div>
|
||
<div class="card">
|
||
<h2>设置</h2>
|
||
<div class="f"><label>文件保存目录(留空 = 程序所在目录)</label><input type="text" id="directory" placeholder="例如 D:\record"></div>
|
||
<div class="hr"></div>
|
||
<div>
|
||
<label class="chk"><input type="checkbox" id="autoMerge">直播结束后自动合并</label>
|
||
<label class="chk"><input type="checkbox" id="mergeDelVideo">合并后删除源视频</label>
|
||
<label class="chk"><input type="checkbox" id="mergeDelDanmu">合并后删除弹幕</label>
|
||
</div>
|
||
<div class="hint">三项互不影响,可以同时勾选;需该主播同时开启弹幕和录播且都录到直播结束,手动停止或重新开始不会触发合并</div>
|
||
<div class="grid" style="margin-top:10px">
|
||
<div class="f"><label>成片移动到(留空 = 保存目录下的成片文件夹)</label><input type="text" id="mergeOutputDir" placeholder="例如 D:\merged"></div>
|
||
<div class="f"><label>合成码率(Kbps,0 = 默认质量)</label><input type="text" id="mergeBitrate" inputmode="numeric" placeholder="例如 1500"></div>
|
||
<div class="f"><label>默认下载码率上限(Kbps,0 = 最高档)</label><input type="text" id="defaultBitrate" inputmode="numeric" placeholder="例如 2000"></div>
|
||
<div class="f"><label>录播/弹幕子文件夹</label><input type="text" id="recordSubdir" placeholder="record"></div>
|
||
<div class="f"><label>成片子文件夹</label><input type="text" id="mergeSubdir" placeholder="merged"></div>
|
||
<div class="f"><label>ffmpeg 路径(留空自动查找)</label><input type="text" id="ffmpegPath" placeholder="例如 D:\ffmpeg\bin\ffmpeg.exe"></div>
|
||
<div class="f"><label>录播文件格式</label><select id="recordFormat">
|
||
<option value="mp4">mp4(推荐,最通用)</option>
|
||
<option value="ts">ts(强杀后还能播已录部分)</option>
|
||
<option value="flv">flv(兼容性一般)</option>
|
||
<option value="mkv">mkv(可装多音轨)</option>
|
||
</select></div>
|
||
</div>
|
||
<div class="hint">录播和弹幕合并都要用 ffmpeg:留空时先查 PATH,再查程序所在目录及其 bin、ffmpeg 子目录</div>
|
||
<div class="hint">录制是 -c copy 直接封装,不转码,四种格式画质完全一样,只是容器不同。正常停止(停止按钮、Ctrl+C)都没问题;如果进程被强杀(任务管理器结束进程、断电),mp4 / flv / mkv 都会整个报废,实测只有 ts 还能播出已经录到的部分。合并出来的成片始终是 mp4。</div>
|
||
<div class="hint">同一主播下播后 20 秒内又上播会当成网络波动,接着录同一场,最终只合成一个成片。</div>
|
||
<div class="hr"></div>
|
||
<div class="grid">
|
||
<div class="f"><label>通知推送地址(留空 = 不推送,POST JSON)</label><input type="text" id="notifyUrl" placeholder="例如 http://127.0.0.1:8080/acfunlive/notify"></div>
|
||
<div class="f"><label>推送口令(留空 = 不带,作为 Bearer 发送)</label><input type="text" id="notifyToken" placeholder="可选"></div>
|
||
<div class="f"><label>通知群号(随推送下发,机器人用它决定发到哪)</label><input type="text" id="notifyGroup" inputmode="numeric" placeholder="例如 777163254,留空则用机器人自己的默认群"></div>
|
||
</div>
|
||
<div style="margin-top:8px">
|
||
<label class="chk"><input type="checkbox" id="notifyLiveOn">开播</label>
|
||
<label class="chk"><input type="checkbox" id="notifyLiveOff">下播</label>
|
||
<label class="chk"><input type="checkbox" id="notifyRecordStart">开始录制</label>
|
||
<label class="chk"><input type="checkbox" id="notifyMergeDone">合并完成</label>
|
||
</div>
|
||
<div class="hint">勾选的事件会以 JSON 推送到上面的地址(字段 event / uid / name / title / url / file / group / text / time,text 可以直接发给群);推送失败只记警告,不影响录制</div>
|
||
<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px;align-items:center">
|
||
<button class="ghost" onclick="testNotify(this)">发送测试通知</button>
|
||
<span class="hint" style="margin:0">用上面填写的地址直接测试,不需要先保存</span>
|
||
</div>
|
||
<div style="margin-top:10px">
|
||
<label class="chk"><input type="checkbox" id="webExternal">允许外网访问(关闭时只有本机能打开本页)</label>
|
||
</div>
|
||
<div class="hint">开启后监听所有网卡,同一局域网的设备可用本机 IP 访问;修改后 web 服务会自动重启,页面会自动重连</div>
|
||
<div class="hr"></div>
|
||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:8px">
|
||
<button onclick="saveSettings(this)">保存设置</button><span id="msg"></span>
|
||
</div>
|
||
<div class="hint">勾选类设置修改后立即保存,文本框内容点「保存设置」生效</div>
|
||
<div class="hr"></div>
|
||
<div class="f"><label>管理口令(打开本页和管理录制都需要,建议复制保存到别处)</label>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||
<input type="text" id="authTokenView" readonly style="flex:1 1 200px">
|
||
<button class="ghost" onclick="copyToken()">复制</button>
|
||
</div>
|
||
</div>
|
||
<div class="f" style="margin-top:8px"><label>更换管理口令(至少 8 个字符)</label>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||
<input type="text" id="authTokenNew" autocomplete="off" placeholder="输入新的管理口令" style="flex:1 1 200px">
|
||
<button class="ghost" onclick="changeToken()">更换</button>
|
||
</div>
|
||
</div>
|
||
<div class="hint">忘记口令时可以打开程序所在目录的 config.json,查看 authToken 字段</div>
|
||
<div class="hr"></div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
|
||
<input type="text" id="newuid" inputmode="numeric" placeholder="新增主播 uid,例如 23682490" style="max-width:230px">
|
||
<button class="ghost" onclick="addUid()">添加(弹幕+录播)</button>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h2>主播列表(勾选 = 开启对应录制,修改立即生效)</h2>
|
||
<div class="hint" style="margin:0 0 8px">下载码率上限 = 挑选码率不超过该值的直播源档位(2000 约超清档),0 = 最高档;正在录制时修改会自动换档重新开始</div>
|
||
<div id="st"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h2>文件管理</h2>
|
||
<div class="hint" id="diskInfo" style="margin:0 0 8px">点击下面的按钮读取文件和磁盘信息</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
|
||
<button class="ghost" onclick="loadFiles(this)">刷新文件列表</button>
|
||
<span class="hint" style="margin:0" id="filesInfo"></span>
|
||
</div>
|
||
<div id="files" style="margin-top:6px"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h2>手动合并</h2>
|
||
<div class="hint" style="margin:0 0 8px">自动合并只在录制自然结束时触发(手动停止过、弹幕和视频对不上号、或者把文件挪过位置的都不会自动合并),这些可以在这里自己挑文件合成。视频一行一个,填多个会按文件名里的录制时间顺序拼成一个再合成。合并进度见下方运行日志。</div>
|
||
<div class="grid">
|
||
<div class="f">
|
||
<label>视频文件(一行一个,也可填绝对路径)</label>
|
||
<textarea id="mrgVideos" rows="3" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.mp4"></textarea>
|
||
</div>
|
||
<div class="f">
|
||
<label>弹幕文件(.jsonl 直接用,.ass 会先还原成 .jsonl)</label>
|
||
<input type="text" id="mrgDanmu" list="mrgDanmuList" autocomplete="off" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.jsonl">
|
||
<datalist id="mrgDanmuList"></datalist>
|
||
</div>
|
||
</div>
|
||
<div class="grid" style="margin-top:10px">
|
||
<div class="f">
|
||
<label>从文件列表选视频(可多选,按 Ctrl 点选)</label>
|
||
<select id="mrgVideoPick" multiple size="4" onchange="pickVideos()"></select>
|
||
</div>
|
||
<div class="f">
|
||
<label>偏移(秒,留空 = 自动推算)</label>
|
||
<input type="text" id="mrgOffset" inputmode="decimal" autocomplete="off" placeholder="留空自动">
|
||
<label style="margin-top:8px">输出文件名(留空 = 自动命名,不覆盖已有成片)</label>
|
||
<input type="text" id="mrgOut" autocomplete="off" placeholder="留空自动">
|
||
</div>
|
||
</div>
|
||
<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px;align-items:center">
|
||
<button class="ghost" id="mrgCheck" onclick="checkMerge()">检查</button>
|
||
<button id="mrgGo" onclick="startMerge()">开始合并</button>
|
||
<span class="hint" style="margin:0" id="mrgState"></span>
|
||
</div>
|
||
<div class="hint" id="mrgInfo"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h2>运行日志</h2>
|
||
<pre id="log"></pre>
|
||
</div>
|
||
</div>
|
||
<div class="mask" id="auth">
|
||
<div class="box">
|
||
<h3>请输入管理口令</h3>
|
||
<p>管理口令在程序首次启动时自动生成并打印在运行日志里,也可以在程序所在目录的 config.json 的 authToken 字段查看。</p>
|
||
<input type="text" id="tokenInput" autocomplete="off" placeholder="管理口令">
|
||
<div style="margin-top:10px"><button onclick="login()">进入</button></div>
|
||
<div id="authMsg"></div>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
var esc = function(s){ return (s||"").replace(/[&<>"]/g, function(c){ return {"&":"&","<":"<",">":">","\"":"""}[c]; }); };
|
||
var saving = false; // 正在保存设置时暂停定时刷新对表单的覆写
|
||
var stSig = ""; // 主播列表的数据摘要,没变化就不重建 DOM
|
||
var logOffset = "0"; // 已经读取到的日志偏移量
|
||
var token = localStorage.getItem("acfunlive_token") || ""; // 管理口令,存在浏览器本地
|
||
var authed = false; // 是否已通过口令校验,未通过时停止轮询
|
||
var tokenLoaded = false;
|
||
function setChk(id, v){ var el = document.getElementById(id); if(!saving && !el.matches(":focus")) el.checked = !!v; }
|
||
function setVal(id, v){ var el = document.getElementById(id); if(!saving && document.activeElement !== el) el.value = v; }
|
||
// 管理接口都要带上口令
|
||
function api(url, opt){
|
||
opt = opt || {};
|
||
var h = {};
|
||
var src = opt.headers || {};
|
||
for(var k in src){ h[k] = src[k]; }
|
||
h["X-Auth-Token"] = token;
|
||
opt.headers = h;
|
||
return fetch(url, opt);
|
||
}
|
||
function showLogin(text){
|
||
authed = false;
|
||
document.getElementById("auth").classList.add("on");
|
||
document.getElementById("authMsg").textContent = text || "";
|
||
var ti = document.getElementById("tokenInput");
|
||
if(!ti.value) ti.value = token;
|
||
}
|
||
function hideLogin(){
|
||
authed = true;
|
||
document.getElementById("auth").classList.remove("on");
|
||
document.getElementById("authMsg").textContent = "";
|
||
}
|
||
async function login(){
|
||
var m = document.getElementById("authMsg");
|
||
var t = document.getElementById("tokenInput").value.trim();
|
||
if(!t){ m.textContent = "请输入管理口令"; return; }
|
||
token = t;
|
||
authed = true;
|
||
var r = null;
|
||
try{ r = await api("/api/auth"); }catch(e){}
|
||
if(r && r.ok){
|
||
localStorage.setItem("acfunlive_token", token);
|
||
hideLogin();
|
||
tokenLoaded = false;
|
||
refresh();
|
||
}else{
|
||
authed = false;
|
||
m.textContent = r ? "管理口令不正确" : "无法连接到服务器";
|
||
}
|
||
}
|
||
function copyToken(){
|
||
var el = document.getElementById("authTokenView");
|
||
if(!el.value) return;
|
||
el.removeAttribute("readonly");
|
||
el.select();
|
||
el.setSelectionRange(0, 999);
|
||
var ok = false;
|
||
try{ ok = document.execCommand("copy"); }catch(e){}
|
||
el.setAttribute("readonly", "readonly");
|
||
// 局域网 http 下 navigator.clipboard 不可用,上面的 execCommand 才是主要手段
|
||
if(window.isSecureContext && navigator.clipboard){
|
||
try{ navigator.clipboard.writeText(el.value); ok = true; }catch(e){}
|
||
}
|
||
showMsg(ok, ok ? "已复制管理口令" : "自动复制失败,请手动选中复制");
|
||
}
|
||
async function changeToken(){
|
||
var t = document.getElementById("authTokenNew").value.trim();
|
||
if(t.length < 8){ showMsg(false, "管理口令至少 8 个字符"); return; }
|
||
var r = null;
|
||
try{
|
||
r = await api("/api/auth/token", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({token: t})});
|
||
}catch(e){}
|
||
if(!r || !r.ok){ showMsg(false, r ? await r.text() : "连接失败"); return; }
|
||
token = t;
|
||
localStorage.setItem("acfunlive_token", token);
|
||
document.getElementById("authTokenView").value = t;
|
||
document.getElementById("authTokenNew").value = "";
|
||
hideLogin();
|
||
showMsg(true, "管理口令已更换,本机已自动更新");
|
||
refresh();
|
||
}
|
||
async function refresh(){
|
||
if(!authed) return;
|
||
try{
|
||
var r = await api("/api/status");
|
||
if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
|
||
if(!r.ok) return;
|
||
var st = await r.json();
|
||
hideLogin();
|
||
// 口令只在登录后拉取一次,填进设置区的只读框
|
||
if(!tokenLoaded){
|
||
tokenLoaded = true;
|
||
api("/api/auth/token").then(function(x){ return x.ok ? x.json() : null; }).then(function(d){
|
||
if(d && d.token) document.getElementById("authTokenView").value = d.token;
|
||
}).catch(function(){});
|
||
// 顺便读一次文件列表,不用等用户点刷新
|
||
loadFiles();
|
||
}
|
||
setVal("directory", st.directory||"");
|
||
setVal("mergeOutputDir", st.mergeOutputDir||"");
|
||
setVal("mergeBitrate", st.mergeBitrate||0);
|
||
setVal("defaultBitrate", st.defaultBitrate||0);
|
||
setVal("recordSubdir", st.recordSubdir||"record");
|
||
setVal("mergeSubdir", st.mergeSubdir||"merged");
|
||
setVal("ffmpegPath", st.ffmpegPath||"");
|
||
setVal("recordFormat", st.recordFormat||"mp4");
|
||
setVal("notifyUrl", st.notifyUrl||"");
|
||
setVal("notifyToken", st.notifyToken||"");
|
||
setVal("notifyGroup", st.notifyGroup||"");
|
||
setChk("notifyLiveOn", st.notifyLiveOn);
|
||
setChk("notifyLiveOff", st.notifyLiveOff);
|
||
setChk("notifyRecordStart", st.notifyRecordStart);
|
||
setChk("notifyMergeDone", st.notifyMergeDone);
|
||
setChk("webExternal", st.webExternal);
|
||
setChk("autoMerge", st.autoMerge);
|
||
setChk("mergeDelVideo", st.mergeDelVideo);
|
||
setChk("mergeDelDanmu", st.mergeDelDanmu);
|
||
syncMergeState(st);
|
||
var rows = (st.streamers||[]).map(function(s){
|
||
var badges = "";
|
||
if(s.recordActive) badges += '<span class="badge on">录播中</span>';
|
||
if(s.danmuActive) badges += '<span class="badge rec">弹幕中</span>';
|
||
if(!badges) badges = s.live ? '<span class="badge on">直播中</span>' : '<span class="off">离线</span>';
|
||
var title = esc(s.title);
|
||
return '<div class="row">'
|
||
+ '<span class="uid">uid ' + s.uid + '</span>'
|
||
+ '<span class="name">' + (esc(s.name) || "uid " + s.uid) + '</span>'
|
||
+ '<label class="chk"><input type="checkbox" data-uid="' + s.uid + '" data-k="danmu"' + (s.danmu?" checked":"") + '>弹幕</label>'
|
||
+ '<label class="chk"><input type="checkbox" data-uid="' + s.uid + '" data-k="record"' + (s.record?" checked":"") + '>录播</label>'
|
||
+ '<input type="text" class="br" inputmode="numeric" data-uid="' + s.uid + '" value="' + (s.bitrate||0) + '" placeholder="0=最高" title="下载码率上限(Kbps),0 = 最高档">'
|
||
+ '<span class="state">' + badges + '</span>'
|
||
+ '<span class="title"' + (title?' title="' + title + '"':'') + '>' + title + '</span>'
|
||
+ '<span class="ops">'
|
||
+ '<button class="ghost" onclick="ctl(' + s.uid + ',\'stop\')">停止</button>'
|
||
+ '<button class="ghost" onclick="ctl(' + s.uid + ',\'restart\')">重新开始</button>'
|
||
+ '<button class="ghost" onclick="delUid(' + s.uid + ')">删除</button></span></div>';
|
||
}).join("");
|
||
var list = document.getElementById("st");
|
||
var sig = JSON.stringify(st.streamers);
|
||
// 数据没变化不重建;正在列表里输入时也跳过,避免把没提交的输入清掉
|
||
if(sig !== stSig && !list.contains(document.activeElement)){
|
||
stSig = sig;
|
||
list.innerHTML = rows || '<div class="off">列表为空,请在上方添加 uid</div>';
|
||
}
|
||
var lr = await api("/api/log?since=" + logOffset);
|
||
if(lr.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
|
||
var off = lr.headers.get("X-Log-Offset");
|
||
var chunk = await lr.text();
|
||
if(off) logOffset = off;
|
||
if(chunk){
|
||
var p = document.getElementById("log");
|
||
var stick = p.scrollHeight - p.scrollTop - p.clientHeight < 40;
|
||
p.textContent += chunk;
|
||
// 页面里保留的日志太长会拖慢浏览器,只留末尾一段
|
||
if(p.textContent.length > 300000) p.textContent = p.textContent.slice(-150000);
|
||
if(stick) p.scrollTop = p.scrollHeight;
|
||
}
|
||
}catch(e){}
|
||
}
|
||
function showMsg(ok, text){
|
||
var m = document.getElementById("msg");
|
||
m.textContent = text; m.style.color = ok ? "#1d7a46" : "#d63046";
|
||
setTimeout(function(){ m.textContent = ""; }, 3000);
|
||
}
|
||
async function post(url, body){
|
||
saving = true;
|
||
try{
|
||
var r = await api(url, {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)});
|
||
if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return false; }
|
||
if(!r.ok) showMsg(false, await r.text());
|
||
return r.ok;
|
||
}finally{
|
||
saving = false;
|
||
refresh();
|
||
// 保存目录、子文件名可能刚被改过,顺手刷新一遍文件列表
|
||
loadFiles();
|
||
}
|
||
}
|
||
async function saveSettings(b){
|
||
if(b) b.disabled = true;
|
||
var ok = await post("/api/config", {
|
||
directory: document.getElementById("directory").value.trim(),
|
||
autoMerge: document.getElementById("autoMerge").checked,
|
||
mergeDelVideo: document.getElementById("mergeDelVideo").checked,
|
||
mergeDelDanmu: document.getElementById("mergeDelDanmu").checked,
|
||
mergeOutputDir: document.getElementById("mergeOutputDir").value.trim(),
|
||
mergeBitrate: parseInt(document.getElementById("mergeBitrate").value, 10) || 0,
|
||
defaultBitrate: parseInt(document.getElementById("defaultBitrate").value, 10) || 0,
|
||
recordSubdir: document.getElementById("recordSubdir").value.trim(),
|
||
mergeSubdir: document.getElementById("mergeSubdir").value.trim(),
|
||
ffmpegPath: document.getElementById("ffmpegPath").value.trim(),
|
||
recordFormat: document.getElementById("recordFormat").value,
|
||
notifyUrl: document.getElementById("notifyUrl").value.trim(),
|
||
notifyToken: document.getElementById("notifyToken").value.trim(),
|
||
notifyGroup: document.getElementById("notifyGroup").value.trim(),
|
||
notifyLiveOn: document.getElementById("notifyLiveOn").checked,
|
||
notifyLiveOff: document.getElementById("notifyLiveOff").checked,
|
||
notifyRecordStart: document.getElementById("notifyRecordStart").checked,
|
||
notifyMergeDone: document.getElementById("notifyMergeDone").checked,
|
||
webExternal: document.getElementById("webExternal").checked
|
||
});
|
||
if(ok) showMsg(true, "已保存");
|
||
if(b) b.disabled = false;
|
||
}
|
||
// 勾选类设置修改后立即保存,避免被定时刷新还原
|
||
["autoMerge","mergeDelVideo","mergeDelDanmu","webExternal","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone"].forEach(function(id){
|
||
document.getElementById(id).addEventListener("change", function(){ saveSettings(null); });
|
||
});
|
||
// 下拉框改完也立即保存
|
||
document.getElementById("recordFormat").addEventListener("change", function(){ saveSettings(null); });
|
||
var fileList = [];
|
||
function fmtSize(n){
|
||
if(!n) return "0 B";
|
||
if(n >= 1073741824) return (n/1073741824).toFixed(2) + " GB";
|
||
if(n >= 1048576) return (n/1048576).toFixed(1) + " MB";
|
||
if(n >= 1024) return (n/1024).toFixed(0) + " KB";
|
||
return n + " B";
|
||
}
|
||
function fmtTime(ts){
|
||
var d = new Date(ts*1000);
|
||
function p(x){ return (x<10?"0":"")+x; }
|
||
return d.getFullYear()+"-"+p(d.getMonth()+1)+"-"+p(d.getDate())+" "+p(d.getHours())+":"+p(d.getMinutes());
|
||
}
|
||
function fileUrl(f){
|
||
return "/api/file?dir=" + encodeURIComponent(f.dir) + "&name=" + encodeURIComponent(f.name) + "&token=" + encodeURIComponent(token);
|
||
}
|
||
async function loadFiles(b){
|
||
if(b) b.disabled = true;
|
||
var r = null;
|
||
try{ r = await api("/api/files"); }catch(e){}
|
||
if(b) b.disabled = false;
|
||
if(!r){ showMsg(false, "读取文件列表失败"); return; }
|
||
if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
|
||
if(!r.ok){ showMsg(false, await r.text()); return; }
|
||
var d = await r.json();
|
||
fileList = d.files || [];
|
||
fillMergePickers();
|
||
document.getElementById("diskInfo").textContent =
|
||
"磁盘剩余 " + fmtSize(d.free) + " / 共 " + fmtSize(d.total) +
|
||
" 录播:" + d.recordDir + " 成片:" + d.mergeDir;
|
||
var used = 0;
|
||
fileList.forEach(function(f){ used += f.size; });
|
||
document.getElementById("filesInfo").textContent =
|
||
fileList.length ? ("共 " + fileList.length + " 个文件,占用 " + fmtSize(used)) : "没有文件";
|
||
document.getElementById("files").innerHTML = fileList.length ? fileList.map(function(f, i){
|
||
var canPlay = /\.(mp4|m4v|webm|ogv)$/i.test(f.name);
|
||
var u = esc(fileUrl(f));
|
||
return '<div class="row">'
|
||
+ '<span class="name" title="' + esc(f.name) + '">' + esc(f.name) + '</span>'
|
||
+ '<span class="uid">' + (f.dir === "merge" ? "成片" : "录播") + '</span>'
|
||
+ '<span class="uid">' + fmtSize(f.size) + '</span>'
|
||
+ '<span class="uid">' + fmtTime(f.modTime) + '</span>'
|
||
+ (f.inUse ? ' <span class="badge on">写入中</span>' : '')
|
||
+ '<span class="ops">'
|
||
+ (canPlay ? '<a class="ghost" href="' + u + '&inline=1" target="_blank" rel="noopener">播放</a>' : '')
|
||
+ '<a class="ghost" href="' + u + '">下载</a>'
|
||
+ (f.inUse ? '' : '<button class="ghost" onclick="delFile(' + i + ')">删除</button>')
|
||
+ '</span></div>';
|
||
}).join("") : '<div class="off">没有文件</div>';
|
||
}
|
||
async function delFile(i){
|
||
var f = fileList[i];
|
||
if(!f) return;
|
||
if(!confirm("确定删除 " + f.name + " ?删除后无法恢复。")) return;
|
||
var r = null;
|
||
try{
|
||
r = await api("/api/file/remove", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({dir: f.dir, name: f.name})});
|
||
}catch(e){}
|
||
if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
|
||
showMsg(!!(r && r.ok), (r && r.ok) ? "已删除" : ("删除失败:" + (r ? await r.text() : "连接失败")));
|
||
loadFiles();
|
||
}
|
||
// ========== 手动合并 ==========
|
||
var mrgBusy = false; // 是否正在合并,跟着 /api/status 一起刷新
|
||
function mrgPayload(){
|
||
var videos = document.getElementById("mrgVideos").value.split("\n").map(function(s){ return s.trim(); }).filter(function(s){ return s; });
|
||
var off = document.getElementById("mrgOffset").value.trim();
|
||
var body = {
|
||
videos: videos,
|
||
danmu: document.getElementById("mrgDanmu").value.trim(),
|
||
outName: document.getElementById("mrgOut").value.trim()
|
||
};
|
||
if(off !== ""){
|
||
var v = parseFloat(off);
|
||
if(isNaN(v)){ showMsg(false, "偏移必须是数字"); return null; }
|
||
body.offset = v;
|
||
}
|
||
return body;
|
||
}
|
||
// 用文件列表里的文件填下拉框和输入建议
|
||
function fillMergePickers(){
|
||
var vids = [], dms = [];
|
||
fileList.forEach(function(f){
|
||
if(/\.(mp4|m4v|flv|ts|mkv|mov|webm)$/i.test(f.name)) vids.push(f);
|
||
else if(/\.(jsonl|ass)$/i.test(f.name)) dms.push(f);
|
||
});
|
||
// 文件名以录制时间开头,升序就是录制顺序
|
||
vids.sort(function(a,b){ return a.name < b.name ? -1 : (a.name > b.name ? 1 : 0); });
|
||
dms.sort(function(a,b){ return a.name < b.name ? -1 : (a.name > b.name ? 1 : 0); });
|
||
var sel = document.getElementById("mrgVideoPick");
|
||
var keep = {};
|
||
Array.prototype.forEach.call(sel.options, function(o){ if(o.selected) keep[o.value] = 1; });
|
||
sel.innerHTML = vids.map(function(f){
|
||
return '<option value="' + esc(f.name) + '">' + esc(f.name) + ' ' + fmtSize(f.size) + '</option>';
|
||
}).join("");
|
||
// 刷新列表时不要把已经选好的分段弄丢
|
||
Array.prototype.forEach.call(sel.options, function(o){ if(keep[o.value]) o.selected = true; });
|
||
document.getElementById("mrgDanmuList").innerHTML = dms.map(function(f){
|
||
return '<option value="' + esc(f.name) + '"></option>';
|
||
}).join("");
|
||
}
|
||
function pickVideos(){
|
||
var names = [];
|
||
Array.prototype.forEach.call(document.getElementById("mrgVideoPick").options, function(o){ if(o.selected) names.push(o.value); });
|
||
names.sort(); // 按文件名里的录制时间排,点选顺序无所谓
|
||
if(names.length) document.getElementById("mrgVideos").value = names.join("\n");
|
||
}
|
||
function setMergeState(){
|
||
var go = document.getElementById("mrgGo");
|
||
go.disabled = mrgBusy;
|
||
go.textContent = mrgBusy ? "合并中…" : "开始合并";
|
||
document.getElementById("mrgCheck").disabled = mrgBusy;
|
||
document.getElementById("mrgState").textContent = mrgBusy ? "正在合并,进度见下方运行日志" : "";
|
||
}
|
||
async function checkMerge(){
|
||
var body = mrgPayload();
|
||
if(!body) return;
|
||
if(!body.videos.length || !body.danmu){ showMsg(false, "请先填写视频和弹幕文件"); return; }
|
||
var b = document.getElementById("mrgCheck");
|
||
b.disabled = true;
|
||
var r = null;
|
||
try{ r = await api("/api/merge/info", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)}); }catch(e){}
|
||
b.disabled = mrgBusy;
|
||
if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
|
||
if(!r || !r.ok){ showMsg(false, r ? await r.text() : "连接失败"); return; }
|
||
var d = await r.json();
|
||
var lines = [];
|
||
(d.videos||[]).forEach(function(v){
|
||
lines.push("视频 " + v.name + " " + fmtSize(v.size) + " 时长 " + v.duration.toFixed(1) + " 秒" + (v.inUse ? "(正在写入)" : ""));
|
||
});
|
||
lines.push("视频总时长 " + d.duration.toFixed(1) + " 秒");
|
||
lines.push("弹幕 " + d.danmuName + " " + d.danmuCount + " 条 时间 " + d.danmuFirst.toFixed(1) + " ~ " + d.danmuLast.toFixed(1) + " 秒(" + d.source + ")");
|
||
lines.push("偏移 " + d.offset.toFixed(3) + " 秒(" + d.offsetNote + ")");
|
||
lines.push("输出 " + d.outName);
|
||
(d.warnings||[]).forEach(function(w){ lines.push("注意:" + w); });
|
||
document.getElementById("mrgInfo").textContent = lines.join("\n");
|
||
}
|
||
async function startMerge(){
|
||
var body = mrgPayload();
|
||
if(!body) return;
|
||
if(!body.videos.length || !body.danmu){ showMsg(false, "请先填写视频和弹幕文件"); return; }
|
||
if(!confirm("开始合并?合成期间可以在下方运行日志里看进度。")) return;
|
||
var b = document.getElementById("mrgGo");
|
||
b.disabled = true;
|
||
var r = null;
|
||
try{ r = await api("/api/merge", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)}); }catch(e){}
|
||
if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); b.disabled = false; return; }
|
||
if(!r || !r.ok){
|
||
showMsg(false, r ? await r.text() : "连接失败");
|
||
b.disabled = false;
|
||
return;
|
||
}
|
||
var d = await r.json();
|
||
mrgBusy = true;
|
||
setMergeState();
|
||
showMsg(true, "已开始合并:" + d.outName);
|
||
}
|
||
// 合并状态跟着 /api/status 一起刷新,只在「进行中 → 结束」这一刻提示一次
|
||
function syncMergeState(st){
|
||
var busy = !!st.mergeBusy;
|
||
if(busy === mrgBusy) return;
|
||
mrgBusy = busy;
|
||
setMergeState();
|
||
if(busy) return;
|
||
loadFiles();
|
||
if(st.mergeErr) showMsg(false, "合并失败:" + st.mergeErr);
|
||
else showMsg(true, "手动合并结束,结果见运行日志");
|
||
}
|
||
async function testNotify(b){
|
||
if(b) b.disabled = true;
|
||
var r = null;
|
||
try{
|
||
r = await api("/api/notify/test", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({
|
||
url: document.getElementById("notifyUrl").value.trim(),
|
||
token: document.getElementById("notifyToken").value.trim(),
|
||
group: document.getElementById("notifyGroup").value.trim()
|
||
})});
|
||
}catch(e){}
|
||
if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); if(b) b.disabled = false; return; }
|
||
if(r && r.ok) showMsg(true, "测试通知已发送,请查看是否收到");
|
||
else showMsg(false, "发送失败:" + (r ? await r.text() : "连接失败"));
|
||
if(b) b.disabled = false;
|
||
}
|
||
async function addUid(){
|
||
var uid = parseInt(document.getElementById("newuid").value.trim(), 10);
|
||
if(!(uid > 0)){ showMsg(false, "uid 不合法"); return; }
|
||
if(await post("/api/streamer", {uid: uid, danmu: true, record: true})) document.getElementById("newuid").value = "";
|
||
}
|
||
async function ctl(uid, action){
|
||
var name = action === "stop" ? "停止" : "重新开始";
|
||
if(action === "stop" && !confirm("确定停止 uid " + uid + " 当前的录制?停止后需点重新开始才会继续录。")) return;
|
||
await post("/api/streamer/control", {uid: uid, target: "all", action: action});
|
||
showMsg(true, "已" + name + "录制");
|
||
}
|
||
async function delUid(uid){
|
||
if(!confirm("确定删除 uid " + uid + " 并停止其录制?")) return;
|
||
post("/api/streamer/remove", {uid: uid});
|
||
}
|
||
document.addEventListener("change", function(e){
|
||
var t = e.target;
|
||
if(t.matches('input[type=checkbox][data-uid]')){
|
||
var uid = parseInt(t.dataset.uid, 10);
|
||
var row = t.closest(".row");
|
||
var danmu = row.querySelector('input[data-k=danmu]').checked;
|
||
var record = row.querySelector('input[data-k=record]').checked;
|
||
var bitrate = parseInt(row.querySelector('input.br').value, 10) || 0;
|
||
post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate});
|
||
}
|
||
if(t.matches('input.br')){
|
||
var uid = parseInt(t.dataset.uid, 10);
|
||
var row = t.closest(".row");
|
||
var v = parseInt(t.value.trim(), 10);
|
||
if(isNaN(v) || v < 0){ showMsg(false, "码率必须是大于等于 0 的数字"); refresh(); return; }
|
||
var danmu = row.querySelector('input[data-k=danmu]').checked;
|
||
var record = row.querySelector('input[data-k=record]').checked;
|
||
post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: v});
|
||
}
|
||
});
|
||
// 先试一次(用本地保存的口令),失败会自动弹出输入框并停止轮询
|
||
authed = true;
|
||
refresh();
|
||
setInterval(refresh, 3000);
|
||
document.getElementById("tokenInput").addEventListener("keydown", function(e){ if(e.key === "Enter") login(); });
|
||
</script>
|
||
</body>
|
||
</html>`
|
||
|
||
// web UI 的主播状态
|
||
type webStreamer struct {
|
||
UID int `json:"uid"`
|
||
Name string `json:"name"`
|
||
Live bool `json:"live"`
|
||
Danmu bool `json:"danmu"` // 弹幕录制开关
|
||
Record bool `json:"record"` // 视频录制开关
|
||
Bitrate int `json:"bitrate"` // 录制的直播源最高码率(Kbps),0 表示码率最高的直播源
|
||
DanmuActive bool `json:"danmuActive"` // 是否正在录制弹幕
|
||
RecordActive bool `json:"recordActive"` // 是否正在录制视频
|
||
Title string `json:"title"`
|
||
URL string `json:"url"`
|
||
}
|
||
|
||
// web UI 的状态数据
|
||
type webStatus struct {
|
||
UIDs []uidSetting `json:"uids"`
|
||
Directory string `json:"directory"`
|
||
AutoMerge bool `json:"autoMerge"`
|
||
MergeDelVideo bool `json:"mergeDelVideo"`
|
||
MergeDelDanmu bool `json:"mergeDelDanmu"`
|
||
MergeOutputDir string `json:"mergeOutputDir"`
|
||
MergeBitrate int `json:"mergeBitrate"`
|
||
DefaultBitrate int `json:"defaultBitrate"`
|
||
WebExternal bool `json:"webExternal"`
|
||
RecordSubdir string `json:"recordSubdir"`
|
||
MergeSubdir string `json:"mergeSubdir"`
|
||
FFmpegPath string `json:"ffmpegPath"`
|
||
RecordFormat string `json:"recordFormat"`
|
||
NotifyURL string `json:"notifyUrl"`
|
||
NotifyToken string `json:"notifyToken"`
|
||
NotifyGroup string `json:"notifyGroup"`
|
||
NotifyLiveOn bool `json:"notifyLiveOn"`
|
||
NotifyLiveOff bool `json:"notifyLiveOff"`
|
||
NotifyRecordStart bool `json:"notifyRecordStart"`
|
||
NotifyMergeDone bool `json:"notifyMergeDone"`
|
||
MergeBusy bool `json:"mergeBusy"` // 手动合并是否正在进行
|
||
MergeOutName string `json:"mergeOutName"` // 正在进行的手动合并的输出文件名
|
||
MergeErr string `json:"mergeErr"` // 最近一次手动合并的错误
|
||
MergeDoneAt int64 `json:"mergeDoneAt"` // 最近一次手动合并结束的时刻
|
||
Streamers []webStreamer `json:"streamers"`
|
||
}
|
||
|
||
// 打印日志到 web UI
|
||
func lPrintln(msg ...any) {
|
||
logger.Println(msg...)
|
||
logString.Lock()
|
||
defer logString.Unlock()
|
||
// 防止内存泄漏
|
||
if logString.str.Len() > 1000000 {
|
||
logString.str.Reset()
|
||
}
|
||
n, _ := fmt.Fprint(&logString.str, getLogTime()+" ")
|
||
n2, _ := fmt.Fprintln(&logString.str, msg...)
|
||
logString.total += int64(n + n2)
|
||
}
|
||
|
||
// 显示 web UI 页面
|
||
func webuiHandler(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
fmt.Fprint(w, webuiHTML)
|
||
}
|
||
|
||
// 显示状态
|
||
func statusHandler(w http.ResponseWriter, r *http.Request) {
|
||
// 手动合并的状态在另一把锁上,先取了再用,不要在持 streamers 锁时去拿
|
||
mergeBusy, mergeOut, mergeErr, mergeDoneAt := mergeRunSnapshot()
|
||
streamers.RLock()
|
||
status := webStatus{
|
||
UIDs: append(uidList{}, config.UIDs...),
|
||
Directory: config.Directory,
|
||
AutoMerge: config.AutoMerge,
|
||
MergeDelVideo: config.MergeDelVideo,
|
||
MergeDelDanmu: config.MergeDelDanmu,
|
||
MergeOutputDir: config.MergeOutputDir,
|
||
MergeBitrate: config.MergeBitrate,
|
||
WebExternal: config.WebExternal,
|
||
RecordSubdir: config.RecordSubdir,
|
||
MergeSubdir: config.MergeSubdir,
|
||
FFmpegPath: config.FFmpegPath,
|
||
RecordFormat: normalizeRecordFormat(config.RecordFormat),
|
||
DefaultBitrate: config.DefaultBitrate,
|
||
NotifyURL: config.NotifyURL,
|
||
NotifyToken: config.NotifyToken,
|
||
NotifyGroup: config.NotifyGroup,
|
||
NotifyLiveOn: config.NotifyLiveOn,
|
||
NotifyLiveOff: config.NotifyLiveOff,
|
||
NotifyRecordStart: config.NotifyRecordStart,
|
||
NotifyMergeDone: config.NotifyMergeDone,
|
||
MergeBusy: mergeBusy,
|
||
MergeOutName: mergeOut,
|
||
MergeErr: mergeErr,
|
||
MergeDoneAt: mergeDoneAt,
|
||
Streamers: make([]webStreamer, 0, len(streamers.crt)),
|
||
}
|
||
for _, s := range streamers.crt {
|
||
live := s.isLiveOn()
|
||
var title string
|
||
if live {
|
||
title = s.getTitle()
|
||
}
|
||
ws := webStreamer{UID: s.UID, Name: s.Name, Live: live, Title: title, URL: s.getURL(), Danmu: s.Danmu, Record: s.Record, Bitrate: s.Bitrate}
|
||
if infoList, ok := getLiveInfoByUID(s.UID); ok {
|
||
for _, info := range infoList {
|
||
if info.isDanmu {
|
||
ws.DanmuActive = true
|
||
}
|
||
if info.isRecording {
|
||
ws.RecordActive = true
|
||
}
|
||
}
|
||
}
|
||
status.Streamers = append(status.Streamers, ws)
|
||
}
|
||
streamers.RUnlock()
|
||
|
||
sort.Slice(status.Streamers, func(i, j int) bool {
|
||
return status.Streamers[i].UID < status.Streamers[j].UID
|
||
})
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
err := json.NewEncoder(w).Encode(status)
|
||
checkErr(err)
|
||
}
|
||
|
||
// 检查配置里的文件夹字段,返回错误信息,空字符串表示通过
|
||
func checkWebDir(path, name string) string {
|
||
if path == "" {
|
||
return ""
|
||
}
|
||
info, err := os.Stat(path)
|
||
if err != nil || !info.IsDir() {
|
||
return name + " 必须是存在的文件夹:" + path
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// 修改设置(保存目录和自动合并相关)
|
||
func webConfigHandler(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
Directory string `json:"directory"`
|
||
AutoMerge bool `json:"autoMerge"`
|
||
MergeDelVideo bool `json:"mergeDelVideo"`
|
||
MergeDelDanmu bool `json:"mergeDelDanmu"`
|
||
MergeOutputDir string `json:"mergeOutputDir"`
|
||
MergeBitrate int `json:"mergeBitrate"`
|
||
DefaultBitrate int `json:"defaultBitrate"`
|
||
WebExternal bool `json:"webExternal"`
|
||
RecordSubdir string `json:"recordSubdir"`
|
||
MergeSubdir string `json:"mergeSubdir"`
|
||
FFmpegPath string `json:"ffmpegPath"`
|
||
RecordFormat string `json:"recordFormat"`
|
||
NotifyURL string `json:"notifyUrl"`
|
||
NotifyToken string `json:"notifyToken"`
|
||
NotifyGroup string `json:"notifyGroup"`
|
||
NotifyLiveOn bool `json:"notifyLiveOn"`
|
||
NotifyLiveOff bool `json:"notifyLiveOff"`
|
||
NotifyRecordStart bool `json:"notifyRecordStart"`
|
||
NotifyMergeDone bool `json:"notifyMergeDone"`
|
||
}
|
||
err := json.NewDecoder(r.Body).Decode(&req)
|
||
if err != nil {
|
||
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
if msg := checkWebDir(req.Directory, "directory"); msg != "" {
|
||
http.Error(w, msg, http.StatusBadRequest)
|
||
return
|
||
}
|
||
if msg := checkWebDir(req.MergeOutputDir, "mergeOutputDir"); msg != "" {
|
||
http.Error(w, msg, http.StatusBadRequest)
|
||
return
|
||
}
|
||
if req.MergeBitrate < 0 {
|
||
http.Error(w, "mergeBitrate 不能小于 0", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if req.DefaultBitrate < 0 {
|
||
http.Error(w, "defaultBitrate 不能小于 0", http.StatusBadRequest)
|
||
return
|
||
}
|
||
recordSubdir := strings.TrimSpace(req.RecordSubdir)
|
||
mergeSubdir := strings.TrimSpace(req.MergeSubdir)
|
||
if recordSubdir == "" {
|
||
recordSubdir = defaultRecordSubdir
|
||
}
|
||
if mergeSubdir == "" {
|
||
mergeSubdir = defaultMergeSubdir
|
||
}
|
||
if isBadSubdir(recordSubdir) {
|
||
http.Error(w, "录播子文件夹名不能包含路径分隔符或非法字符:"+recordSubdir, http.StatusBadRequest)
|
||
return
|
||
}
|
||
if isBadSubdir(mergeSubdir) {
|
||
http.Error(w, "成片子文件夹名不能包含路径分隔符或非法字符:"+mergeSubdir, http.StatusBadRequest)
|
||
return
|
||
}
|
||
if recordSubdir == mergeSubdir && req.MergeOutputDir == "" {
|
||
http.Error(w, "录播子文件夹和成片子文件夹不能相同", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if msg := checkFFmpegPath(req.FFmpegPath); msg != "" {
|
||
http.Error(w, msg, http.StatusBadRequest)
|
||
return
|
||
}
|
||
recordFormat := strings.ToLower(strings.TrimSpace(req.RecordFormat))
|
||
if recordFormat == "" {
|
||
recordFormat = defaultRecordFormat
|
||
}
|
||
if _, ok := recordFormats[recordFormat]; !ok {
|
||
http.Error(w, "录播格式只能是 mp4、flv、ts 或 mkv:"+recordFormat, http.StatusBadRequest)
|
||
return
|
||
}
|
||
notifyURL := strings.TrimSpace(req.NotifyURL)
|
||
if msg := checkNotifyURL(notifyURL); msg != "" {
|
||
http.Error(w, msg, http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
streamers.Lock()
|
||
config.Directory = req.Directory
|
||
config.AutoMerge = req.AutoMerge
|
||
config.MergeDelVideo = req.MergeDelVideo
|
||
config.MergeDelDanmu = req.MergeDelDanmu
|
||
config.MergeOutputDir = req.MergeOutputDir
|
||
config.MergeBitrate = req.MergeBitrate
|
||
config.DefaultBitrate = req.DefaultBitrate
|
||
restartWeb := config.WebExternal != req.WebExternal
|
||
config.WebExternal = req.WebExternal
|
||
config.RecordSubdir = recordSubdir
|
||
config.MergeSubdir = mergeSubdir
|
||
config.FFmpegPath = strings.TrimSpace(req.FFmpegPath)
|
||
config.RecordFormat = recordFormat
|
||
config.NotifyURL = notifyURL
|
||
config.NotifyToken = strings.TrimSpace(req.NotifyToken)
|
||
config.NotifyGroup = strings.TrimSpace(req.NotifyGroup)
|
||
config.NotifyLiveOn = req.NotifyLiveOn
|
||
config.NotifyLiveOff = req.NotifyLiveOff
|
||
config.NotifyRecordStart = req.NotifyRecordStart
|
||
config.NotifyMergeDone = req.NotifyMergeDone
|
||
// 修改了子文件夹名后确保新文件夹存在
|
||
if err := os.MkdirAll(recordingDir(), 0755); err != nil {
|
||
lPrintWarnf("创建文件夹 %s 失败:%v", recordingDir(), err)
|
||
}
|
||
if err := os.MkdirAll(recordSaveDir(), 0755); err != nil {
|
||
lPrintWarnf("创建文件夹 %s 失败:%v", recordSaveDir(), err)
|
||
}
|
||
if err := os.MkdirAll(mergeSaveDir(), 0755); err != nil {
|
||
lPrintWarnf("创建文件夹 %s 失败:%v", mergeSaveDir(), err)
|
||
}
|
||
streamers.Unlock()
|
||
|
||
saveConfig()
|
||
|
||
if restartWeb {
|
||
lPrintln("外网访问设置已改变,重启 web UI 服务器")
|
||
restartWebUI()
|
||
}
|
||
lPrintf("通过 web UI 修改了设置:目录 %q、自动合并 %v、合并删视频 %v、合并删弹幕 %v、成片目录 %q、合并码率 %d、默认下载码率 %d、外网访问 %v、录播子文件夹 %q、成片子文件夹 %q、ffmpeg 路径 %q、录播格式 %q",
|
||
req.Directory, req.AutoMerge, req.MergeDelVideo, req.MergeDelDanmu, req.MergeOutputDir,
|
||
req.MergeBitrate, req.DefaultBitrate, req.WebExternal, recordSubdir, mergeSubdir, config.FFmpegPath, recordFormat)
|
||
lPrintf("推送设置:地址 %q、群号 %q、开播 %v、下播 %v、开始录制 %v、合并完成 %v",
|
||
notifyURL, config.NotifyGroup, req.NotifyLiveOn, req.NotifyLiveOff, req.NotifyRecordStart, req.NotifyMergeDone)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte("true"))
|
||
}
|
||
|
||
// 新增主播或修改主播的弹幕/录播开关和码率
|
||
func webStreamerHandler(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
UID int `json:"uid"`
|
||
Danmu bool `json:"danmu"`
|
||
Record bool `json:"record"`
|
||
Bitrate *int `json:"bitrate"` // 不传时使用默认下载码率上限
|
||
}
|
||
err := json.NewDecoder(r.Body).Decode(&req)
|
||
if err != nil {
|
||
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
if req.UID <= 0 {
|
||
http.Error(w, "uid 必须大于 0", http.StatusBadRequest)
|
||
return
|
||
}
|
||
bitrate := config.DefaultBitrate
|
||
if req.Bitrate != nil {
|
||
bitrate = *req.Bitrate
|
||
}
|
||
if bitrate < 0 {
|
||
http.Error(w, "bitrate 不能小于 0", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// 码率变化时先停掉正在进行的录播,setStreamerFlags 会用新码率重启
|
||
if cur, ok := getStreamer(req.UID); ok && cur.Bitrate != bitrate && cur.Record && req.Record {
|
||
stopRecord(req.UID)
|
||
}
|
||
if !setStreamerFlags(req.UID, req.Danmu, req.Record) {
|
||
http.Error(w, "不存在 uid 为"+itoa(req.UID)+"的用户", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if cur, ok := getStreamer(req.UID); ok && cur.Bitrate != bitrate {
|
||
setBitrate(req.UID, bitrate)
|
||
}
|
||
lPrintf("通过 web UI 设置 uid %d:弹幕 %v、录播 %v、码率上限 %d", req.UID, req.Danmu, req.Record, bitrate)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte("true"))
|
||
}
|
||
|
||
// 停止或重新开始某个主播的录制
|
||
func webStreamerControlHandler(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
UID int `json:"uid"`
|
||
Target string `json:"target"` // danmu、record 或 all
|
||
Action string `json:"action"` // stop 或 restart
|
||
}
|
||
err := json.NewDecoder(r.Body).Decode(&req)
|
||
if err != nil {
|
||
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
if req.UID <= 0 {
|
||
http.Error(w, "uid 必须大于 0", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if err := controlStreamer(req.UID, req.Target, req.Action); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
actionName := "停止"
|
||
if req.Action == "restart" {
|
||
actionName = "重新开始"
|
||
}
|
||
lPrintf("通过 web UI %s uid %d 的录制(%s)", actionName, req.UID, req.Target)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte("true"))
|
||
}
|
||
|
||
// 删除主播
|
||
func webStreamerRemoveHandler(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
UID int `json:"uid"`
|
||
}
|
||
err := json.NewDecoder(r.Body).Decode(&req)
|
||
if err != nil {
|
||
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
removeUID(req.UID)
|
||
lPrintf("通过 web UI 删除了 uid %d", req.UID)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte("true"))
|
||
}
|
||
|
||
// 发送一条测试通知,验证推送地址、群号和口令是否配置正确
|
||
func notifyTestHandler(w http.ResponseWriter, r *http.Request) {
|
||
// 允许直接用设置区里还没保存的地址测试,没传就用已经保存的配置
|
||
var req struct {
|
||
URL string `json:"url"`
|
||
Token string `json:"token"`
|
||
Group string `json:"group"`
|
||
}
|
||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||
|
||
addr := strings.TrimSpace(req.URL)
|
||
token := strings.TrimSpace(req.Token)
|
||
group := strings.TrimSpace(req.Group)
|
||
if addr == "" {
|
||
n := currentNotifySetting()
|
||
addr, token = n.url, n.token
|
||
}
|
||
if group == "" {
|
||
group = currentNotifySetting().group
|
||
}
|
||
if addr == "" {
|
||
http.Error(w, "还没有填写通知推送地址", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if msg := checkNotifyURL(addr); msg != "" {
|
||
http.Error(w, msg, http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
p := notifyPayload{
|
||
Event: "test",
|
||
Name: "AcFun 直播录制",
|
||
Group: group,
|
||
Text: "这是一条测试通知,收到就说明推送地址和口令都配置正确",
|
||
Time: getLogTime(),
|
||
}
|
||
if err := postNotify(addr, token, p); err != nil {
|
||
lPrintWarnf("推送测试通知失败:%v", err)
|
||
http.Error(w, "推送失败:"+err.Error(), http.StatusBadGateway)
|
||
return
|
||
}
|
||
lPrintln("测试通知推送成功:" + addr)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte("true"))
|
||
}
|
||
|
||
// 显示日志,支持 ?since=<偏移量> 只返回新增的部分,避免每 3 秒全量传输
|
||
func logHandler(w http.ResponseWriter, r *http.Request) {
|
||
since, err := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)
|
||
if err != nil {
|
||
since = 0
|
||
}
|
||
|
||
logString.Lock()
|
||
text := logString.str.String()
|
||
start := logString.total - int64(len(text)) // 缓冲区里现有内容对应的起始偏移
|
||
data := text
|
||
if since > start && since <= logString.total {
|
||
data = text[since-start:]
|
||
}
|
||
total := logString.total
|
||
logString.Unlock()
|
||
|
||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||
// 用响应头返回新的偏移量:前端拿到的是解码后的字符串,长度不等于字节数
|
||
w.Header().Set("X-Log-Offset", strconv.FormatInt(total, 10))
|
||
fmt.Fprint(w, data)
|
||
}
|
||
|
||
// 启动 web UI 服务器
|
||
func startWebUI(ctx context.Context) {
|
||
defer func() {
|
||
if err := recover(); err != nil {
|
||
lPrintErr("Recovering from panic in startWebUI(), the error is:", err)
|
||
lPrintErr("web UI 服务器发生错误,尝试重启 web UI 服务器")
|
||
time.Sleep(2 * time.Second)
|
||
go startWebUI(ctx)
|
||
}
|
||
}()
|
||
|
||
addr := webUIAddr()
|
||
if config.WebExternal {
|
||
lPrintln("启动 web UI 服务器,监听 " + addr + ",局域网或外网设备可以通过本机 IP 访问 web UI 界面")
|
||
} else {
|
||
lPrintln("启动 web UI 服务器,只监听本机 " + addr + ",现在可以通过 http://localhost:" + itoa(config.WebPort) + " 访问 web UI 界面")
|
||
}
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("GET /{$}", webuiHandler)
|
||
mux.HandleFunc("GET /api/auth", authCheckHandler)
|
||
mux.HandleFunc("GET /api/auth/token", authTokenHandler)
|
||
mux.HandleFunc("POST /api/auth/token", authTokenSetHandler)
|
||
mux.HandleFunc("GET /api/status", statusHandler)
|
||
mux.HandleFunc("POST /api/config", webConfigHandler)
|
||
mux.HandleFunc("POST /api/streamer", webStreamerHandler)
|
||
mux.HandleFunc("POST /api/streamer/control", webStreamerControlHandler)
|
||
mux.HandleFunc("POST /api/streamer/remove", webStreamerRemoveHandler)
|
||
mux.HandleFunc("GET /api/log", logHandler)
|
||
mux.HandleFunc("POST /api/notify/test", notifyTestHandler)
|
||
mux.HandleFunc("GET /api/files", filesHandler)
|
||
mux.HandleFunc("GET /api/file", fileDownloadHandler)
|
||
mux.HandleFunc("POST /api/file/remove", fileRemoveHandler)
|
||
mux.HandleFunc("POST /api/merge/info", mergeInfoHandler)
|
||
mux.HandleFunc("POST /api/merge", mergeHandler)
|
||
|
||
srvCtx, srvCancel := context.WithCancel(ctx)
|
||
webSrvMu.Lock()
|
||
webSrvCancel = srvCancel
|
||
webSrvMu.Unlock()
|
||
|
||
srv := &http.Server{
|
||
Addr: addr,
|
||
IdleTimeout: 60 * time.Second,
|
||
// 没有这几个超时的话,慢速连接可以一直占着不放
|
||
ReadHeaderTimeout: 10 * time.Second,
|
||
ReadTimeout: 30 * time.Second,
|
||
Handler: webMiddleware(webAuth(mux)),
|
||
}
|
||
|
||
// 退出程序或主动重启时关闭 web UI 服务器
|
||
go func() {
|
||
<-srvCtx.Done()
|
||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||
lPrintErr("web UI 服务器关闭错误:", err)
|
||
}
|
||
}()
|
||
|
||
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
||
// 主动重启导致的关闭不算错误
|
||
if srvCtx.Err() != nil {
|
||
lPrintln("停止 web UI 服务器")
|
||
return
|
||
}
|
||
lPrintErr("web UI 服务器出现错误:", err)
|
||
panic(err)
|
||
}
|
||
lPrintln("停止 web UI 服务器")
|
||
}
|
||
|
||
// web UI 监听地址,允许外网访问时监听所有网卡,否则只监听本机
|
||
func webUIAddr() string {
|
||
if config.WebExternal {
|
||
return ":" + itoa(config.WebPort)
|
||
}
|
||
return "127.0.0.1:" + itoa(config.WebPort)
|
||
}
|
||
|
||
// 从请求里取管理口令:优先 Authorization: Bearer,其次 X-Auth-Token,最后 ?token=
|
||
func authTokenFromRequest(r *http.Request) string {
|
||
if h := strings.TrimSpace(r.Header.Get("Authorization")); h != "" {
|
||
if len(h) > 7 && strings.EqualFold(h[:7], "Bearer ") {
|
||
return strings.TrimSpace(h[7:])
|
||
}
|
||
}
|
||
if t := strings.TrimSpace(r.Header.Get("X-Auth-Token")); t != "" {
|
||
return t
|
||
}
|
||
return strings.TrimSpace(r.URL.Query().Get("token"))
|
||
}
|
||
|
||
// 校验请求里的管理口令,用固定时间比较避免通过响应时间猜口令
|
||
func checkAuthToken(r *http.Request) bool {
|
||
want := currentAuthToken()
|
||
if want == "" {
|
||
return false
|
||
}
|
||
got := authTokenFromRequest(r)
|
||
if got == "" {
|
||
return false
|
||
}
|
||
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
|
||
}
|
||
|
||
// 口令错误次数的记录,给反复尝试的请求加一点延迟
|
||
var authFail struct {
|
||
sync.Mutex
|
||
m map[string]authFailEntry
|
||
}
|
||
|
||
type authFailEntry struct {
|
||
count int
|
||
last time.Time
|
||
}
|
||
|
||
// 口令错误时最多延迟这么久,避免拖垮服务器
|
||
const authMaxDelay = 2 * time.Second
|
||
|
||
// 取请求的来源 IP
|
||
func clientIP(r *http.Request) string {
|
||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||
return host
|
||
}
|
||
return r.RemoteAddr
|
||
}
|
||
|
||
// 记录一次口令错误并返回应该延迟的时长;口令正确时调用 authFailClear 清掉记录
|
||
func authFailDelay(ip string) time.Duration {
|
||
authFail.Lock()
|
||
defer authFail.Unlock()
|
||
if authFail.m == nil {
|
||
authFail.m = make(map[string]authFailEntry)
|
||
}
|
||
// 记录太多时清掉过期的,避免大量不同来源的请求把内存撑满
|
||
if len(authFail.m) > 1000 {
|
||
for k, v := range authFail.m {
|
||
if time.Since(v.last) > 10*time.Minute {
|
||
delete(authFail.m, k)
|
||
}
|
||
}
|
||
}
|
||
e := authFail.m[ip]
|
||
e.count++
|
||
e.last = time.Now()
|
||
authFail.m[ip] = e
|
||
d := time.Duration(e.count) * 100 * time.Millisecond
|
||
if d > authMaxDelay {
|
||
d = authMaxDelay
|
||
}
|
||
return d
|
||
}
|
||
|
||
// 口令正确时清掉错误记录
|
||
func authFailClear(ip string) {
|
||
authFail.Lock()
|
||
defer authFail.Unlock()
|
||
delete(authFail.m, ip)
|
||
}
|
||
|
||
// 管理接口的口令校验:页面本身可以匿名打开(前端拿到 401 后再提示输入口令),
|
||
// 所有 /api/ 接口都必须带上正确的口令
|
||
func webAuth(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if !strings.HasPrefix(r.URL.Path, "/api/") {
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
ip := clientIP(r)
|
||
if checkAuthToken(r) {
|
||
authFailClear(ip)
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
if d := authFailDelay(ip); d > 0 {
|
||
time.Sleep(d)
|
||
}
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.WriteHeader(http.StatusUnauthorized)
|
||
_, _ = w.Write([]byte(`{"error":"管理口令不正确,请在页面上重新输入"}`))
|
||
})
|
||
}
|
||
|
||
// 口令校验通过后才会走到这里,直接返回成功供前端登录使用
|
||
func authCheckHandler(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte("true"))
|
||
}
|
||
|
||
// 返回当前的管理口令,方便用户在设置区查看和复制
|
||
func authTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(map[string]string{"token": currentAuthToken()})
|
||
}
|
||
|
||
// 更换管理口令
|
||
func authTokenSetHandler(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
Token string `json:"token"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
token := strings.TrimSpace(req.Token)
|
||
if len([]rune(token)) < 8 {
|
||
http.Error(w, "管理口令至少需要 8 个字符", http.StatusBadRequest)
|
||
return
|
||
}
|
||
streamers.Lock()
|
||
config.AuthToken = token
|
||
streamers.Unlock()
|
||
saveConfig()
|
||
lPrintln("通过 web UI 更换了管理口令(authToken)")
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte("true"))
|
||
}
|
||
|
||
// 统一的响应头和请求体限制
|
||
func webMiddleware(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
h := w.Header()
|
||
h.Set("X-Content-Type-Options", "nosniff")
|
||
h.Set("X-Frame-Options", "DENY")
|
||
h.Set("Referrer-Policy", "no-referrer")
|
||
// 页面用的是内联脚本和样式,所以只放行内联
|
||
h.Set("Content-Security-Policy",
|
||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self'")
|
||
// 写接口的请求体都很小,限制大小避免被慢速大请求拖着
|
||
if r.Method == http.MethodPost {
|
||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
var (
|
||
webSrvMu sync.Mutex
|
||
webSrvCancel context.CancelFunc
|
||
)
|
||
|
||
// 切换外网访问后监听地址会变,需要重启 web UI 服务器
|
||
func restartWebUI() {
|
||
webSrvMu.Lock()
|
||
cancel := webSrvCancel
|
||
webSrvMu.Unlock()
|
||
if cancel != nil {
|
||
cancel()
|
||
}
|
||
go func() {
|
||
time.Sleep(500 * time.Millisecond)
|
||
startWebUI(mainCtx)
|
||
}()
|
||
}
|