Files
acfunlive-remake/webui.go
T
tamakyi aa47dd342d 修:选了「自定义」却看不到码率框(焦点还在下拉上,列表不重建)
页面为了让定时刷新不冲掉用户正在填的内容,列表在「焦点还在列表里」时不重建。
但原生 select 选完一项后焦点会留在下拉上,于是选了「自定义」以后列表一直不重建,
那个码率框要等用户点到别处才出现 —— 看起来就是「选了自定义根本没地方填」。

- 选到「自定义」时当场把码率框建出来并聚焦(不用等服务端回来和下一次重建),
  切走时当场收掉。
- 顺带把「不重建」的条件收紧成「只有正在列表里的文本框里打字时才跳过」:
  下拉和勾选都是改完立即提交的,之前它们也会一直卡住重建,
  连带「录播中/弹幕中」的徽标也要点到别处才刷新。
- 框里的初始值:填过自定义就用上次那个数(切走再切回来不会被改成当前档位),
  没填过才用现在实际在用的那一档。

验证方式也换了:回归脚本里改用「点开下拉 + 键盘选最后一项」这种真实操作
(Playwright 的 select_option 不保留焦点,正是它让这个 bug 漏了过去),
新增「选完立刻就有框」「焦点直接落在框里」「切走即收掉」「切回带着上次的值」四条断言;
verify_streamer_br.py 33 项、verify_qualities.py 全过,verify_settings.py /
verify_danmu_match.py 无回归,go test ./...、gofmt、go vet 干净。
2026-09-25 21:14:59 +08:00

2359 lines
109 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// web UI 相关
package main
import (
"context"
"crypto/subtle"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"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">
<meta name="color-scheme" content="light dark">
<title>AcFun 直播录制</title>
<style>
:root{
--bg:#f4f5f7;--card:#fff;--line:#e6e8ec;--soft:#eff1f4;--soft2:#f7f8fa;
--text:#1f2329;--sub:#8a9099;--accent:#fd4c5d;--accent-soft:#ffecee;
--ok:#1d7a46;--ok-soft:#e7f5ed;--bad:#d63046;
--topbar:rgba(255,255,255,.86);
--code-bg:#1c1f26;--code-fg:#c9ced8;
--radius:12px;--radius-s:8px;
--shadow:0 1px 2px rgba(16,20,28,.04),0 6px 16px -10px rgba(16,20,28,.18);
}
@media (prefers-color-scheme:dark){
:root{
--bg:#15171c;--card:#1e2127;--line:#2b2f37;--soft:#262a32;--soft2:#22262d;
--text:#e7e9ed;--sub:#959ca7;--accent:#fd4c5d;--accent-soft:#3a2126;
--ok:#59c98a;--ok-soft:#1e3328;--bad:#ff7183;
--topbar:rgba(30,33,39,.86);
--code-bg:#101216;--code-fg:#c2c7d0;
--shadow:0 1px 2px rgba(0,0,0,.3),0 8px 20px -12px rgba(0,0,0,.6);
}
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 system-ui,-apple-system,"Microsoft YaHei",sans-serif;-webkit-text-size-adjust:100%}
a{color:var(--accent)}
/* 顶栏 */
.top{position:sticky;top:0;z-index:5;background:var(--topbar);backdrop-filter:saturate(1.5) blur(12px);-webkit-backdrop-filter:saturate(1.5) blur(12px);border-bottom:1px solid var(--line)}
.top-in{max-width:1060px;margin:0 auto;padding:10px 14px 8px;display:flex;align-items:center;gap:12px}
.brand{display:flex;align-items:center;gap:10px;min-width:0;flex:1}
.logo{width:30px;height:30px;border-radius:9px;background:var(--accent);color:#fff;font-weight:700;font-size:15px;display:flex;align-items:center;justify-content:center;flex:none;box-shadow:0 2px 8px -2px rgba(253,76,93,.6)}
.bt{min-width:0}
.bt strong{display:block;font-size:14px;font-weight:600;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.head-sub{display:block;font-size:11.5px;color:var(--sub);line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.top-act{display:flex;align-items:center;gap:8px;flex:none}
.stat{font-size:12px;color:var(--sub);display:inline-flex;align-items:center;gap:5px;white-space:nowrap}
.dot{width:6px;height:6px;border-radius:50%;background:var(--sub);display:inline-block;flex:none}
.dot.rec{background:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.dot.live{background:var(--ok);box-shadow:0 0 0 3px var(--ok-soft)}
.dot.warn{background:var(--bad);box-shadow:0 0 0 3px var(--accent-soft)}
/* 有没提交的修改时把保存按钮标出来,手机上光看输入框看不出来 */
#saveBtn{position:relative}
#saveBtn.dirty{box-shadow:0 0 0 3px var(--accent-soft)}
#saveBtn.dirty::after{content:"";position:absolute;top:-4px;right:-4px;width:9px;height:9px;border-radius:50%;background:var(--bad);border:2px solid var(--card)}
/* 分区导航 */
.tabs{max-width:1060px;margin:0 auto;padding:0 14px 10px;display:flex;gap:4px;overflow-x:auto;scrollbar-width:none}
.tabs::-webkit-scrollbar{display:none}
.tab{flex:0 0 auto;border:none;background:transparent;color:var(--sub);font:inherit;font-size:13px;padding:6px 14px;border-radius:999px;cursor:pointer;transition:background .15s,color .15s}
.tab:hover{background:var(--soft);color:var(--text)}
.tab.on{background:var(--card);color:var(--text);font-weight:600;box-shadow:var(--shadow)}
.tab .n{font-size:11px;opacity:.7;margin-left:4px;font-variant-numeric:tabular-nums}
/* 内容 */
.wrap{max-width:1060px;margin:0 auto;padding:14px 14px 40px}
.pane{display:none}
.pane.on{display:block;animation:fade .18s ease-out}
@keyframes fade{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:16px;margin-bottom:12px;box-shadow:var(--shadow)}
.card-h{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;margin-bottom:12px}
.card-h h2{font-size:14px;font-weight:600;margin:0}
.card-h .sub{font-size:12px;color:var(--sub)}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px 16px}
.f label{display:block;font-size:12px;color:var(--sub);margin-bottom:5px}
input[type=text],select,textarea{width:100%;border:1px solid var(--line);border-radius:var(--radius-s);padding:8px 10px;font:inherit;background:var(--card);color:var(--text);transition:border-color .15s,box-shadow .15s}
input[type=text]:hover,select:hover,textarea:hover{border-color:var(--sub)}
input[type=text]:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
textarea{resize:vertical;line-height:1.5;font-size:13px}
select[multiple]{padding:5px}
input[readonly]{background:var(--soft2);color:var(--sub)}
#mrgInfo{white-space:pre-wrap;margin-top:10px;font-size:12.5px;line-height:1.7;color:var(--text);background:var(--soft2);border-radius:var(--radius-s);padding:10px 12px}
#mrgInfo:empty{display:none}
select.brsel{padding:6px;max-width:200px}
input.brnum{width:76px;text-align:center;padding:6px 8px}
button.mini{padding:4px 6px;font-size:11.5px}
/* 开关 */
.sws{display:flex;flex-wrap:wrap;gap:10px 22px}
.sw{display:inline-flex;align-items:center;gap:9px;cursor:pointer;font-size:13px;position:relative}
.sw input{position:absolute;width:1px;height:1px;opacity:0;margin:0;pointer-events:none}
.sw-i{width:36px;height:21px;border-radius:999px;background:#d3d7de;flex:none;position:relative;transition:background .16s}
.sw-i::after{content:"";position:absolute;top:2px;left:2px;width:17px;height:17px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.24);transition:transform .16s}
.sw input:checked+.sw-i{background:var(--accent)}
.sw input:checked+.sw-i::after{transform:translateX(15px)}
.sw input:focus-visible+.sw-i{box-shadow:0 0 0 3px var(--accent-soft)}
/* 小圆片勾选(主播列表) */
.pill{display:inline-flex;align-items:center;gap:6px;padding:4px 11px;border-radius:999px;background:var(--soft);font-size:12px;color:var(--sub);cursor:pointer;user-select:none;border:1px solid transparent;transition:.15s}
.pill input{appearance:none;-webkit-appearance:none;width:14px;height:14px;margin:0;border:1.5px solid #c3c9d2;border-radius:4px;background:var(--card);position:relative;flex:none;cursor:pointer}
.pill input:checked{background:var(--accent);border-color:var(--accent)}
.pill input:checked::after{content:"";position:absolute;left:3px;top:.5px;width:4px;height:8px;border:2px solid #fff;border-top:0;border-left:0;transform:rotate(45deg)}
.pill:has(input:checked){background:var(--accent-soft);color:var(--accent);border-color:rgba(253,76,93,.25)}
/* 按钮 */
button{border:none;border-radius:var(--radius-s);padding:8px 16px;font:inherit;font-size:13px;cursor:pointer;background:var(--accent);color:#fff;transition:filter .15s,background .15s;white-space:nowrap}
button:hover{filter:brightness(1.06)}
button:active{filter:brightness(.94)}
button:disabled{opacity:.5;cursor:default;filter:none}
button.ghost,a.ghost{background:var(--soft);color:var(--text);padding:7px 12px;font-size:12.5px;text-decoration:none;display:inline-block}
button.ghost:hover,a.ghost:hover{background:var(--soft2);filter:none}
.hint{font-size:12px;color:var(--sub);margin-top:8px;line-height:1.65}
.hint.bad{color:var(--bad)}
.bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:12px}
.bar .hint{margin:0}
.rowline{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.rowline input{max-width:240px}
/* 主播列表 / 文件列表 */
.row{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;padding:10px 10px;border-radius:var(--radius-s);transition:background .12s}
.row+.row{border-top:1px solid var(--line)}
.row:hover{background:var(--soft2)}
.uid{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}
.name{font-weight:600;max-width:200px;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}
/* 文件名是拿来认文件的关键信息,在文件列表里放开宽度不要截 */
#files .name{max-width:none;flex:1 1 240px;word-break:break-all;white-space:normal}
.badge{display:inline-block;padding:2px 9px;border-radius:999px;font-size:11.5px;margin-right:4px;background:var(--soft);color:var(--sub)}
.badge.on{background:var(--accent-soft);color:var(--accent)}
.badge.rec{background:var(--ok-soft);color:var(--ok)}
.off{font-size:12.5px;color:var(--sub);padding:8px 0}
pre{background:var(--code-bg);color:var(--code-fg);border-radius:var(--radius-s);padding:12px;font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,"Courier New",monospace;max-height:340px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0}
/* 提示条 */
#toast{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:20;display:flex;flex-direction:column;gap:8px;align-items:center;pointer-events:none;width:calc(100% - 24px);max-width:460px}
.toast{background:var(--card);color:var(--text);border:1px solid var(--line);border-left:3px solid var(--ok);border-radius:var(--radius-s);padding:10px 14px;font-size:12.5px;line-height:1.6;box-shadow:0 8px 28px -10px rgba(16,20,28,.35);animation:tin .18s ease-out;max-width:100%;word-break:break-word}
.toast.bad{border-left-color:var(--bad)}
.toast.out{opacity:0;transform:translateY(6px);transition:.2s}
@keyframes tin{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
/* 口令弹窗 */
.mask{position:fixed;left:0;top:0;right:0;bottom:0;background:rgba(16,18,22,.5);backdrop-filter:blur(3px);display:none;align-items:center;justify-content:center;z-index:30;padding:16px}
.mask.on{display:flex}
.mask .box{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px;width:100%;max-width:390px;box-shadow:0 20px 50px -20px rgba(0,0,0,.5)}
.mask h3{margin:0 0 6px;font-size:15px}
.mask p{margin:0 0 12px;font-size:12px;color:var(--sub);line-height:1.65}
#authMsg{font-size:12px;color:var(--bad);min-height:17px;margin-top:8px}
/* 路径输入框:手工填和点「浏览」并存 */
.pathf{display:flex;gap:8px;align-items:center}
.pathf input{flex:1 1 auto;min-width:0;max-width:none}
.pathf button{flex:none}
/* 路径选择弹窗 */
#pick .box{max-width:620px}
#pickPath{font-size:12.5px}
.roots{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}
.roots:empty{display:none}
.roots button{background:var(--soft);color:var(--text);font-size:12px;padding:4px 10px;border-radius:999px}
.roots button:hover{background:var(--soft2)}
.plist{max-height:44vh;overflow:auto;border:1px solid var(--line);border-radius:var(--radius-s);background:var(--soft2)}
.plist .off{padding:10px}
.pi{display:flex;align-items:center;gap:10px;padding:7px 10px;font-size:13px;cursor:pointer}
.pi+.pi{border-top:1px solid var(--line)}
.pi:hover{background:var(--card)}
.pi.on{background:var(--accent-soft);color:var(--accent)}
.pi-n{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pi-dir .pi-n{font-weight:600}
.pi-m{flex:none;font-size:11.5px;color:var(--sub);font-variant-numeric:tabular-nums}
/* 多选模式(选视频这种可以选多个的框):文件条目左边给个勾选框 */
.pi-c{display:none;flex:none;width:15px;height:15px;border:1px solid var(--line);border-radius:4px;background:var(--card)}
#pick.multi .pi:not(.pi-dir) .pi-c{display:block}
#pick.multi .pi.on .pi-c{border-color:var(--accent);background:var(--accent)}
#pick.multi .pi.on .pi-c::after{content:"";display:block;width:4px;height:8px;margin:1px 0 0 4px;border:solid #fff;border-width:0 2px 2px 0;transform:rotate(45deg)}
.lnk{color:var(--accent);cursor:pointer;text-decoration:underline}
.only-m{display:none}
@media (max-width:700px){
/* 手机上标题居中、两侧各留一列等宽(左右各只有一个按钮,视觉上才是正的居中) */
.top-in{padding:9px 12px 7px;gap:8px;display:grid;grid-template-columns:1fr auto 1fr}
.brand{grid-column:2;justify-content:center}
.top-act{grid-column:3;justify-content:flex-end}
.head-sub{display:none}
.stat{display:none}
.only-d{display:none}
.only-m{display:inline}
.wrap{padding:12px 12px 36px}
.card{padding:14px;border-radius:10px}
.grid{grid-template-columns:1fr}
.row{gap:6px 10px}
.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 12px;font-size:13px}
#toast{bottom:14px}
}
</style>
</head>
<body>
<header class="top">
<div class="top-in">
<div class="brand">
<span class="logo">A</span>
<div class="bt"><strong>AcFun 直播录制</strong><span class="head-sub">弹幕 / 视频录制,直播结束后自动合并</span></div>
</div>
<div class="top-act">
<span class="stat only-d" id="dirtyTip" style="display:none"><i class="dot warn"></i>有未保存的修改</span>
<span class="stat" id="topStat"></span>
<button id="saveBtn" onclick="saveSettings(this)"><span class="only-d">保存设置</span><span class="only-m">保存</span></button>
</div>
</div>
<nav class="tabs" id="tabs">
<button class="tab on" data-tab="mon">监控</button>
<button class="tab" data-tab="rec">录制</button>
<button class="tab" data-tab="mrg">合并</button>
<button class="tab" data-tab="ntf">通知</button>
<button class="tab" data-tab="file">文件</button>
<button class="tab" data-tab="sys">设置</button>
</nav>
</header>
<div class="wrap">
<!-- 监控 -->
<section class="pane on" data-pane="mon">
<div class="card">
<div class="card-h"><h2>添加主播</h2><span class="sub">填 uid 后默认同时开启弹幕和录播</span></div>
<div class="rowline">
<input type="text" id="newuid" inputmode="numeric" placeholder="主播 uid,例如 23682490">
<button class="ghost" onclick="addUid()">添加</button>
</div>
</div>
<div class="card">
<div class="card-h"><h2>主播列表</h2><span class="sub">勾选 = 开启对应录制,改完立即生效</span></div>
<div class="hint" style="margin:0 0 10px">码率这里选的是直播的<b>实际档位</b>(如蓝光 4M / 高清),开播后自动读取,也能点每行的「档位」按钮立刻读一次。默认「自动 · 第二高」= 按这次读到档位排序自上往下取第二高(不一定有蓝光 8M,所以只按实际档位排),也可以直接选最高、最低或中间某一档。正在录制时换档会自动重录。</div>
<div id="st"></div>
</div>
<div class="card">
<div class="card-h"><h2>运行日志</h2><span class="sub">手动合并的进度也在这里</span></div>
<pre id="log"></pre>
</div>
</section>
<!-- 录制 -->
<section class="pane" data-pane="rec">
<div class="card">
<div class="card-h"><h2>保存位置</h2><span class="sub">录播、弹幕和成片都放在这里</span></div>
<div class="grid">
<div class="f"><label>文件保存目录(留空 = 程序所在目录)</label>
<div class="pathf">
<input type="text" id="directory" placeholder="例如 D:\record">
<button class="ghost" onclick="openPick('directory','dir')">浏览…</button>
</div>
</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>
<div class="hint">文本框改完按回车或点右上角「保存设置」生效,开关和下拉框改完立即生效;没保存就离开页面或刷新会丢掉改动。</div>
</div>
<div class="card">
<div class="card-h"><h2>录制参数</h2></div>
<div class="grid">
<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 class="f"><label>新主播的默认录制档位</label>
<select id="defaultQualityPref">
<option value="top">最高档</option>
<option value="second">第二高</option>
<option value="lowest">最低档</option>
<option value="custom">自定义码率上限</option>
</select></div>
<div class="f" id="defBrRow"><label>默认自定义码率上限(Kbps,取不超过它的最高档)</label><input type="text" id="defaultBitrate" inputmode="numeric" placeholder="例如 3000"></div>
<div class="f"><label>ffmpeg 路径(留空自动查找)</label>
<div class="pathf">
<input type="text" id="ffmpegPath" placeholder="例如 D:\ffmpeg\bin\ffmpeg.exe">
<button class="ghost" onclick="openPick('ffmpegPath','file')">浏览…</button>
</div>
</div>
</div>
<div class="hint">录播和弹幕合并都要用 ffmpeg:留空时先查 PATH,再查程序所在目录及其 bin、ffmpeg 子目录。</div>
<div class="hint">录制是 -c copy 直接封装,不转码,四种格式画质完全一样,只是容器不同。正常停止(停止按钮、Ctrl+C)都没问题;如果进程被强杀(任务管理器结束进程、断电),mp4 / flv / mkv 都会整个报废,实测只有 ts 还能播出已经录到的部分。合并出来的成片始终是 mp4。</div>
</div>
</section>
<!-- 合并 -->
<section class="pane" data-pane="mrg">
<div class="card">
<div class="card-h"><h2>自动合并</h2><span class="sub">直播正常结束时的自动处理</span></div>
<div class="sws">
<label class="sw"><input type="checkbox" id="autoMerge"><span class="sw-i"></span>直播结束后自动合并</label>
<label class="sw"><input type="checkbox" id="mergeDelVideo"><span class="sw-i"></span>合并后删除源视频</label>
<label class="sw"><input type="checkbox" id="mergeDelDanmu"><span class="sw-i"></span>合并后删除弹幕</label>
</div>
<div class="hint">三项互不影响,可以同时开启;需该主播同时开启弹幕和录播且都录到直播结束。手动停止或重新开始不会触发自动合并,那种情况用下面的手动合并。</div>
<div class="hint">同一主播下播后 20 秒内又上播会当成网络波动,接着录同一场,最终只合成一个成片。</div>
</div>
<div class="card">
<div class="card-h"><h2>成片输出</h2></div>
<div class="grid">
<div class="f"><label>成片移动到(留空 = 保存目录下的成片文件夹)</label>
<div class="pathf">
<input type="text" id="mergeOutputDir" placeholder="例如 D:\merged">
<button class="ghost" onclick="openPick('mergeOutputDir','dir')">浏览…</button>
</div>
</div>
<div class="f"><label>合成码率(Kbps,0 = 源片码率 + 500)</label><input type="text" id="mergeBitrate" inputmode="numeric" placeholder="例如 1500,填 0 自动"></div>
<div class="f"><label>弹幕字号(像素,0 = 跟随弹幕文件)</label><input type="text" id="danmuFontSize" inputmode="numeric" placeholder="例如 42,头像随字号一起缩放"></div>
</div>
<div class="hint">文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。</div>
</div>
<div class="card">
<div class="card-h"><h2>手动合并</h2><span class="sub">自己挑视频和弹幕合成</span></div>
<div class="hint" style="margin:0 0 12px">自动合并只在录制自然结束时触发(手动停止过、弹幕和视频对不上号、或者把文件挪过位置的都不会自动合并),这些可以在这里自己挑文件合成。视频一行一个,填多个会按文件名里的录制时间顺序拼成一个再合成。合并进度见「监控」页的运行日志。</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 class="pathf" style="margin-top:8px">
<button class="ghost" onclick="openPick('mrgVideos','file',1)">浏览…</button>
<span class="hint" style="margin:0;flex:1 1 auto;min-width:0">可多选,能换文件夹继续勾;选完追加到框里,不清掉已填内容</span>
</div>
</div>
<div class="f">
<label>弹幕文件(.jsonl 直接用,.ass 会先还原成 .jsonl;选了视频会自动找同名的)</label>
<div class="pathf">
<input type="text" id="mrgDanmu" list="mrgDanmuList" autocomplete="off" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.jsonl">
<button class="ghost" onclick="openPick('mrgDanmu','file')">浏览…</button>
</div>
<datalist id="mrgDanmuList"></datalist>
</div>
</div>
<div class="grid" style="margin-top:12px">
<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:10px">输出文件名(留空 = 自动命名,不覆盖已有成片)</label>
<input type="text" id="mrgOut" autocomplete="off" placeholder="留空自动">
</div>
</div>
<div class="bar">
<button class="ghost" id="mrgCheck" onclick="checkMerge()">检查</button>
<button id="mrgGo" onclick="startMerge()">开始合并</button>
<span class="hint" id="mrgState"></span>
</div>
<div class="hint" id="mrgInfo"></div>
</div>
</section>
<!-- 通知 -->
<section class="pane" data-pane="ntf">
<div class="card">
<div class="card-h"><h2>推送哪些事件</h2><span class="sub">下面两个渠道共用这份勾选</span></div>
<div class="sws">
<label class="sw"><input type="checkbox" id="notifyLiveOn"><span class="sw-i"></span>开播</label>
<label class="sw"><input type="checkbox" id="notifyLiveOff"><span class="sw-i"></span>下播</label>
<label class="sw"><input type="checkbox" id="notifyRecordStart"><span class="sw-i"></span>开始录制</label>
<label class="sw"><input type="checkbox" id="notifyMergeDone"><span class="sw-i"></span>合并完成</label>
</div>
<div class="hint">推送失败只记警告,不影响录制。</div>
</div>
<div class="card">
<div class="card-h"><h2>渠道一 · QQ 机器人</h2><span class="sub">自建机器人,走 HTTP POST JSON</span></div>
<label class="sw"><input type="checkbox" id="notifyEnabled"><span class="sw-i"></span>启用这个渠道</label>
<div class="grid" style="margin-top:12px">
<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 class="hint">推送体是 json,字段 event / uid / name / title / url / file / group / text / time,text 可以直接发给群。</div>
<div class="bar">
<button class="ghost" onclick="testNotify(this,'qq')">测试连通</button>
<span class="hint">用上面填写的地址发一条测试推送,不用先保存;改完记得点右上角「保存设置」</span>
</div>
</div>
<div class="card">
<div class="card-h"><h2>渠道二 · MeoW</h2><span class="sub">手机上直接收通知</span></div>
<label class="sw"><input type="checkbox" id="meowEnabled"><span class="sw-i"></span>启用这个渠道</label>
<div class="grid" style="margin-top:12px">
<div class="f"><label>MeoW 昵称(接收者)</label><input type="text" id="meowNickname" placeholder="你在 MeoW 里的昵称"></div>
<div class="f"><label>消息类型</label><select id="meowMsgType">
<option value="text">text(纯文本)</option>
<option value="html">html(在 App 里渲染)</option>
</select></div>
<div class="f" id="meowHeightRow"><label>HTML 显示高度(像素)</label><input type="text" id="meowHtmlHeight" inputmode="numeric" placeholder="200"></div>
<div class="f"><label>通知图标 URL(可选)</label><input type="text" id="meowImgUrl" placeholder="建议 216×216 的 PNG,留空用 MeoW 默认图标"></div>
</div>
<div class="hint">推给 MeoW 里这个昵称,点开通知可以跳回直播间;接口有频控(3 秒 1 条、每分钟 15 条、每小时 60 条)。</div>
<div class="hint bad" id="meowBlocked" style="display:none"></div>
<div class="bar">
<button class="ghost" onclick="testNotify(this,'meow')">测试连通</button>
<span class="hint">用上面填写的昵称发一条测试推送,不用先保存;改完记得按回车或点右上角「保存设置」</span>
</div>
</div>
</section>
<!-- 文件 -->
<section class="pane" data-pane="file">
<div class="card">
<div class="card-h"><h2>文件管理</h2><span class="sub" id="diskInfo">正在读取磁盘信息…</span></div>
<div class="bar" style="margin-top:0">
<button class="ghost" onclick="loadFiles(this)">刷新文件列表</button>
<span class="hint" id="filesInfo"></span>
</div>
<div id="files" style="margin-top:8px"></div>
</div>
</section>
<!-- 设置 -->
<section class="pane" data-pane="sys">
<div class="card">
<div class="card-h"><h2>访问方式</h2></div>
<label class="sw"><input type="checkbox" id="webExternal"><span class="sw-i"></span>允许外网访问(关闭时只有本机能打开本页)</label>
<div class="hint">开启后监听所有网卡,同一局域网的设备可用本机 IP 访问;修改后 web 服务会自动重启,页面会自动重连。</div>
</div>
<div class="card">
<div class="card-h"><h2>管理口令</h2><span class="sub">打开本页和管理录制都需要</span></div>
<div class="f"><label>当前口令(建议复制保存到别处)</label>
<div class="rowline">
<input type="text" id="authTokenView" readonly style="flex:1 1 200px;max-width:none">
<button class="ghost" onclick="copyToken()">复制</button>
</div>
</div>
<div class="f" style="margin-top:12px"><label>更换管理口令(至少 8 个字符)</label>
<div class="rowline">
<input type="text" id="authTokenNew" autocomplete="off" placeholder="输入新的管理口令" style="flex:1 1 200px;max-width:none">
<button class="ghost" onclick="changeToken()">更换</button>
</div>
</div>
<div class="hint">忘记口令时可以打开程序所在目录的 config.json,查看 authToken 字段。</div>
</div>
</section>
</div>
<div id="toast"></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:12px"><button onclick="login()">进入</button></div>
<div id="authMsg"></div>
</div>
</div>
<div class="mask" id="pick">
<div class="box pbox">
<h3 id="pickTitle">选择文件夹</h3>
<p id="pickDesc">逐级点选即可;也可以直接在地址栏里填或粘贴路径后按回车。选好后点下面的按钮填进设置框。</p>
<div class="pathf" style="margin-bottom:8px">
<button class="ghost" id="pickUp" onclick="pickUp()">↑ 上一级</button>
<input type="text" id="pickPath" spellcheck="false" autocomplete="off" placeholder="也可以直接填或粘贴路径,回车跳转">
<button class="ghost" onclick="pickGo(document.getElementById('pickPath').value)">转到</button>
</div>
<div class="roots" id="pickRoots"></div>
<div class="plist" id="pickList"></div>
<div class="hint" id="pickMsg"></div>
<div class="bar" style="margin-top:12px">
<label class="sw" style="margin-right:auto"><input type="checkbox" id="pickHidden"><span class="sw-i"></span>显示隐藏项</label>
<span class="hint" id="pickSel" style="margin:0"></span>
<button class="ghost" onclick="closePick()">取消</button>
<button id="pickOk" onclick="pickConfirm()">选用这个文件夹</button>
</div>
</div>
</div>
<script>
var esc = function(s){ return (s||"").replace(/[&<>"]/g, function(c){ return {"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;"}[c]; }); };
var saving = false; // 正在保存设置时暂停定时刷新对表单的覆写
var stSig = ""; // 主播列表的数据摘要,没变化就不重建 DOM
var logOffset = "0"; // 已经读取到的日志偏移量
var token = localStorage.getItem("acfunlive_token") || ""; // 管理口令,存在浏览器本地
var authed = false; // 是否已通过口令校验,未通过时停止轮询
var tokenLoaded = false;
// 设置表单里的字段,元素 id 与 /api/config 的字段名一致
var settingFields = ["directory","autoMerge","mergeDelVideo","mergeDelDanmu","mergeOutputDir","mergeBitrate","danmuFontSize","defaultQualityPref","defaultBitrate","recordSubdir","mergeSubdir","ffmpegPath","recordFormat","notifyEnabled","notifyUrl","notifyToken","notifyGroup","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","meowEnabled","meowNickname","meowMsgType","meowHtmlHeight","meowImgUrl","webExternal"];
var numberFields = {mergeBitrate:1, danmuFontSize:1, defaultBitrate:1, meowHtmlHeight:1};
// 用户改过、还没提交的字段。定时刷新会拿服务端的值填表单,不记下这些字段的话,
// 刚填好的数字 3 秒后会被刷回旧值,看起来就像「改了又自己恢复」
var dirty = {};
var lastSt = null; // 最近一次 /api/status 的结果,自动保存时用它填没提交的字段
function dirtyCount(){ return Object.keys(dirty).length; }
function markDirty(id){ if(id && !dirty[id]){ dirty[id] = true; syncDirtyUI(); } }
function dropDirty(id){ if(dirty[id]){ delete dirty[id]; syncDirtyUI(); } }
function syncDirtyUI(){
var n = dirtyCount();
var tip = document.getElementById("dirtyTip");
if(tip) tip.style.display = n ? "" : "none";
var b = document.getElementById("saveBtn");
if(b) b.classList.toggle("dirty", n > 0);
}
// 提交成功后才把对应的未保存标记清掉:值还是提交时那个才清,避免把提交期间的新改动也当成已保存
function clearSaved(body){
settingFields.forEach(function(id){
if(dirty[id] && String(fieldValue(id)).trim() === String(body[id]).trim()) delete dirty[id];
});
syncDirtyUI();
}
function fieldValue(id){
var el = document.getElementById(id);
if(!el) return "";
return el.type === "checkbox" ? !!el.checked : el.value;
}
// 按当前表单拼 /api/config 的请求体;serverForDirty 为真时,没提交的字段用服务端的值顶上
// (开关、下拉是改完立即保存的,不该被别的框里填了一半的内容连累到整批失败)
function settingsBody(serverForDirty){
var body = {};
settingFields.forEach(function(id){
var v = fieldValue(id);
if(typeof v === "string"){
v = v.trim();
if(numberFields[id]) v = parseInt(v, 10) || 0;
}
if(serverForDirty && dirty[id] && lastSt && lastSt[id] !== undefined) v = lastSt[id];
body[id] = v;
});
return body;
}
function setChk(id, v){ var el = document.getElementById(id); if(!saving && !dirty[id] && !el.matches(":focus")) el.checked = !!v; }
function setVal(id, v){ var el = document.getElementById(id); if(!saving && !dirty[id] && document.activeElement !== el) el.value = v; }
function show(el, v){ var e = document.getElementById(el); if(e) e.style.display = v ? "" : "none"; }
// 管理接口都要带上口令
function api(url, opt){
opt = opt || {};
var h = {};
var src = opt.headers || {};
for(var k in src){ h[k] = src[k]; }
// 请求头只接受 ISO-8859-1,口令里有中文的话浏览器会直接报错、请求发不出去,
// 所以统一先百分号编码,服务端两种写法都认
h["X-Auth-Token"] = encodeURIComponent(token);
opt.headers = h;
return fetch(url, opt);
}
// ========== 分区导航 ==========
var tabNames = {mon:"监控", rec:"录制", mrg:"合并", ntf:"通知", file:"文件", sys:"设置"};
var curTab = "";
function showTab(name){
if(!tabNames[name]) name = "mon";
if(curTab === name) return;
curTab = name;
var tabs = document.querySelectorAll(".tab");
for(var i = 0; i < tabs.length; i++){ tabs[i].classList.toggle("on", tabs[i].dataset.tab === name); }
var panes = document.querySelectorAll(".pane");
for(var j = 0; j < panes.length; j++){ panes[j].classList.toggle("on", panes[j].dataset.pane === name); }
try{ localStorage.setItem("acfunlive_tab", name); }catch(e){}
// 地址栏留个 #分区,刷新和收藏都能回到同一个分区(replaceState 不往历史里塞记录)
if(location.hash.slice(1) !== name){
try{ history.replaceState(null, "", "#" + name); }catch(e){ location.hash = name; }
}
// 切过去的时候顺手刷新那个分区里的实时数据
if(name === "file" && authed) loadFiles();
if(name === "mrg" && authed) fillMergePickers();
}
document.getElementById("tabs").addEventListener("click", function(e){
var t = e.target.closest(".tab");
if(t) showTab(t.dataset.tab);
});
window.addEventListener("hashchange", function(){ showTab(location.hash.slice(1)); });
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();
lastSt = st;
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("danmuFontSize", st.danmuFontSize||0);
setVal("defaultQualityPref", st.defaultQualityPref||"second");
setVal("defaultBitrate", st.defaultBitrate||0);
toggleDefaultBr();
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("notifyEnabled", st.notifyEnabled);
setChk("notifyLiveOn", st.notifyLiveOn);
setChk("notifyLiveOff", st.notifyLiveOff);
setChk("notifyRecordStart", st.notifyRecordStart);
setChk("notifyMergeDone", st.notifyMergeDone);
setChk("meowEnabled", st.meowEnabled);
setVal("meowNickname", st.meowNickname||"");
setVal("meowMsgType", st.meowMsgType||"text");
setVal("meowHtmlHeight", st.meowHtmlHeight||200);
setVal("meowImgUrl", st.meowImgUrl||"");
toggleMeowHeight();
var mb = document.getElementById("meowBlocked");
if(st.meowBlocked){ mb.style.display = ""; mb.textContent = "当前推不出去:" + st.meowBlocked; }
else show("meowBlocked", false);
setChk("webExternal", st.webExternal);
setChk("autoMerge", st.autoMerge);
setChk("mergeDelVideo", st.mergeDelVideo);
setChk("mergeDelDanmu", st.mergeDelDanmu);
syncMergeState(st);
// 顶栏的状态摘要
var sl = st.streamers||[];
var rec = 0, live = 0;
sl.forEach(function(s){ if(s.recordActive || s.danmuActive) rec++; else if(s.live) live++; });
var ts = document.getElementById("topStat");
if(rec) ts.innerHTML = '<i class="dot rec"></i>录制中 ' + rec;
else if(live) ts.innerHTML = '<i class="dot live"></i>直播中 ' + live;
else if(sl.length) ts.innerHTML = '<i class="dot"></i>空闲';
else ts.innerHTML = "";
var rows = sl.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);
var qs = s.qualities || [];
// 档位是「按实际档位里的第几档」来选的:四种偏好永远都在(不开播也能先设),
// 读到档位表后把对应到的档位名标在后面
var nameFor = function(p){
if(p === "custom") return s.qualityName || "";
if(!qs.length) return "";
if(p === "top") return qs[0].name;
if(p === "lowest") return qs[qs.length-1].name;
return qs[qs.length > 1 ? 1 : 0].name; // 第二高
};
var cur = s.qualityPref || "second";
var qOpts = [["top", "最高档"], ["second", "第二高"], ["lowest", "最低档"], ["custom", "自定义"]];
var opts = qOpts.map(function(o){
var label = o[1];
if(o[0] === "custom"){
// 自定义:标出当前上限对应到哪个档位
if(s.bitrate > 0) label += "(≤" + s.bitrate + "K" + (s.qualityName ? " = " + esc(s.qualityName) : "") + ")";
else label += qs.length ? "(填个码率上限)" : "(填个码率上限,开播后对应)";
}else{
var n = nameFor(o[0]);
if(n) label += "(" + esc(n) + ")";
}
return '<option value="' + o[0] + '"' + (o[0] === cur ? " selected" : "") + '>' + label + '</option>';
}).join("");
var tip = qs.length ? '录制档位:按这次直播的实际档位对应,正在录制时换档会自动重录'
: '录制档位:还没读到档位表(开播后才拿得到),随时可以先设好;点右边按钮能立刻读一次';
// data-qbitrate 只是给排查看的:真正「当前生效的档位码率」会从服务端状态里取
var brCell = '<select class="brsel" data-uid="' + s.uid + '" data-qbitrate="' + (s.qualityBitrate || 0)
+ '" title="' + tip + '">' + opts + '</select>';
if(cur === "custom"){
brCell += '<input type="text" class="brnum" data-uid="' + s.uid + '" inputmode="numeric" value="'
+ (s.bitrate || 0) + '" placeholder="Kbps" title="自定义:取不超过这个码率的最高档">';
}
brCell += '<button class="ghost mini" onclick="loadQualities(' + s.uid + ')" title="去直播间接一次码率档位">档位</button>';
return '<div class="row">'
+ '<span class="uid">uid ' + s.uid + '</span>'
+ '<span class="name">' + (esc(s.name) || "uid " + s.uid) + '</span>'
+ '<label class="pill"><input type="checkbox" data-uid="' + s.uid + '" data-k="danmu"' + (s.danmu?" checked":"") + '>弹幕</label>'
+ '<label class="pill"><input type="checkbox" data-uid="' + s.uid + '" data-k="record"' + (s.record?" checked":"") + '>录播</label>'
+ brCell
+ '<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);
// 只有「正在列表里的文本框里打字」才跳过重建:下拉和勾选都是改完立即提交的,
// 一直卡着不重建会让「选了自定义该出现的码率框」「录播中的徽标」迟迟不刷新
var ae = document.activeElement;
var typing = !!(ae && list.contains(ae) && ae.tagName === "INPUT" && ae.type !== "checkbox");
if(sig !== stSig && !typing){
stSig = sig;
// 重建前记下用户改过、还没提交的自定义码率,建完抄回去(填错的不用留)
var keepNum = {};
Array.prototype.forEach.call(list.querySelectorAll("input.brnum"), function(el){
var v = parseInt(el.value.trim(), 10);
if(el.dataset.dirty === "1" && !isNaN(v) && v >= 0) keepNum[el.dataset.uid] = el.value;
});
list.innerHTML = rows || '<div class="off">列表为空,在上面填 uid 添加</div>';
Object.keys(keepNum).forEach(function(uid){
var el = list.querySelector('input.brnum[data-uid="' + uid + '"]');
if(el){ el.value = keepNum[uid]; el.dataset.dirty = "1"; }
});
}
// 开播了但还没有档位缓存的,自动去读一次,让档位下拉能标出对应关系
autoLoadQualities(st.streamers);
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){}
}
// 右下角弹一条提示,成功 2.6 秒、失败 6 秒
function showMsg(ok, text){
var w = document.getElementById("toast");
if(!w){ return; }
var d = document.createElement("div");
d.className = "toast" + (ok ? "" : " bad");
d.textContent = text;
w.appendChild(d);
while(w.children.length > 4){ w.removeChild(w.firstChild); }
var life = ok ? 2600 : 6000;
setTimeout(function(){
d.classList.add("out");
setTimeout(function(){ if(d.parentNode) d.parentNode.removeChild(d); }, 220);
}, life);
}
async function post(url, body, errPrefix){
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, (errPrefix || "") + await r.text());
return r.ok;
}finally{
saving = false;
refresh();
// 保存目录、子文件名可能刚被改过,顺手刷新一遍文件列表
loadFiles();
}
}
async function saveSettings(b, autoSave){
if(b) b.disabled = true;
var body = settingsBody(!!autoSave);
var ok = await post("/api/config", body, autoSave ? "保存失败,这次改动没生效:" : "");
if(ok){
clearSaved(body);
showMsg(true, "已保存");
}
if(b) b.disabled = false;
}
// 勾选类设置修改后立即保存,避免被定时刷新还原
["autoMerge","mergeDelVideo","mergeDelDanmu","webExternal","notifyEnabled","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","meowEnabled"].forEach(function(id){
document.getElementById(id).addEventListener("change", function(){ dropDirty(id); saveSettings(null, true); });
});
// 下拉框改完也立即保存
["recordFormat","meowMsgType"].forEach(function(id){
document.getElementById(id).addEventListener("change", function(){ dropDirty(id); saveSettings(null, true); });
});
// 只有「自定义」才需要填码率上限
function toggleDefaultBr(){
document.getElementById("defBrRow").style.display =
document.getElementById("defaultQualityPref").value === "custom" ? "" : "none";
}
document.getElementById("defaultQualityPref").addEventListener("change", function(){
var el = document.getElementById("defaultBitrate");
// 切到自定义时补一个能用的初始值(只在用户主动切换时补,定时刷新不碰它),
// 不然新主播会按 0 落到最低档
if(this.value === "custom" && !(parseInt(el.value.trim(), 10) > 0)){
el.value = 3000;
dropDirty("defaultBitrate");
}
toggleDefaultBr();
dropDirty("defaultQualityPref");
saveSettings(null, true);
});
// 手动合并的视频框:手填/粘贴完(失焦)也自动找一次同名弹幕文件
document.getElementById("mrgVideos").addEventListener("change", function(){ autoMatchDanmu(); });
// HTML 高度只对 html 类型有意义,选 text 时收起来
function toggleMeowHeight(){
var isHTML = document.getElementById("meowMsgType").value === "html";
document.getElementById("meowHeightRow").style.display = isHTML ? "" : "none";
}
document.getElementById("meowMsgType").addEventListener("change", toggleMeowHeight);
// 文本类字段一改就记成「未保存」,定时刷新不再盖掉它,直到保存成功
document.addEventListener("input", function(e){
var el = e.target;
if(!el) return;
if(el.classList && el.classList.contains("br")){ el.dataset.dirty = "1"; return; }
if(el.classList && el.classList.contains("brnum")){ el.dataset.dirty = "1"; return; }
if(el.id && settingFields.indexOf(el.id) >= 0) markDirty(el.id);
});
// 回车等于点保存:输入框不在 form 里,浏览器默认不会提交,很容易以为已经生效了
document.addEventListener("keydown", function(e){
if(e.key !== "Enter" || e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return;
var el = e.target;
if(!el || el.tagName !== "INPUT" || el.type !== "text" || el.readOnly) return;
if(el.classList.contains("br") || el.classList.contains("brnum")){ el.blur(); return; } // 档位相关的框失焦即提交
if(el.id === "newuid"){ e.preventDefault(); addUid(); return; }
if(el.id === "authTokenNew"){ e.preventDefault(); changeToken(); return; }
if(el.id === "mrgOffset" || el.id === "mrgOut" || el.id === "mrgDanmu"){ e.preventDefault(); checkMerge(); return; }
if(settingFields.indexOf(el.id) >= 0){ e.preventDefault(); saveSettings(null); }
});
// 还有没保存的改动时提醒一句,别直接关掉页面
window.addEventListener("beforeunload", function(e){
if(dirtyCount() > 0){ e.preventDefault(); e.returnValue = ""; return ""; }
});
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 || [];
recDir = d.recordDir || "";
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 pickField = ""; // 选中的路径填回哪个输入框
var pickKind = "dir"; // dir 选文件夹,file 选文件
var pickCur = ""; // 当前所在目录
var pickParent = ""; // 上一级
var pickFile = ""; // 单选时选中的那个
var pickMulti = false; // 多选模式(视频框这种一行一个、可以选多个的)
var pickSel = []; // 多选已勾选的绝对路径,换目录也不清,所以能跨文件夹/跨盘攒
var recDir = ""; // 录播目录,挑弹幕和视频时从它开始
var lastPickKey = "", lastPickAt = 0; // 列表里连点同一项只算一次
var lastNavAt = 0; // 刚跳转过目录的这段时间里忽略列表上的点击,双击文件夹才不会连进两层
function openPick(id, kind, multi){
var el = document.getElementById(id);
pickField = id;
pickKind = kind === "file" ? "file" : "dir";
pickMulti = !!multi && pickKind === "file";
pickFile = "";
pickSel = [];
document.getElementById("pickTitle").textContent = pickMulti ? "选择文件(可多选)" : (pickKind === "file" ? "选择文件" : "选择文件夹");
document.getElementById("pickDesc").textContent = pickMulti
? "点一下文件名即勾选,再点一下取消;可以进到别的文件夹继续勾。选好后点下面的按钮追加到框里,已经填好的内容不会被清掉。"
: "逐级点选即可;也可以直接在地址栏里填或粘贴路径后按回车。选好后点下面的按钮填进设置框。";
document.getElementById("pickHidden").checked = false;
document.getElementById("pick").classList.toggle("multi", pickMulti);
document.getElementById("pick").classList.add("on");
// 多行框(视频)取第一行当起点,整段拿去当路径会解析出一个乱七八糟的目录
var start = el ? el.value.split("\n")[0].trim() : "";
// 框里是文件名而不是路径时(弹幕名、视频名这种)不算路径:这两个框从录播目录开始,其它从程序所在目录开始
if(start.indexOf("/") < 0 && start.indexOf("\\") < 0) start = (id === "mrgDanmu" || id === "mrgVideos") ? recDir : "";
pickGo(start);
}
function closePick(){ document.getElementById("pick").classList.remove("on"); }
function pickUp(){ if(pickParent) pickGo(pickParent); }
async function pickGo(path){
lastNavAt = Date.now();
var msg = document.getElementById("pickMsg");
msg.textContent = "";
var base = "/api/browse?kind=" + pickKind + (document.getElementById("pickHidden").checked ? "&hidden=1" : "");
var d = null, fellBack = false;
// 框里填的是文件、或者目录根本不存在时,往上退到能打开的一层,别停在空列表上
for(var i = 0; i < 6; i++){
var url = base + (path ? "&path=" + encodeURIComponent(path) : "");
var r = null;
try{ r = await api(url); }catch(e){}
if(!r){ msg.textContent = "读取失败,连接不上服务器"; return; }
if(r.status === 401){ closePick(); showLogin("管理口令已失效,请重新输入"); return; }
if(!r.ok){ msg.textContent = "读取失败:" + await r.text(); return; }
d = await r.json();
if(!d.error || !d.parent) break;
path = d.parent;
fellBack = true;
}
var items = d.entries || [];
pickCur = d.path || "";
pickParent = d.parent || "";
// 单选时换了目录、之前选中的文件就不在当前视图里了,清掉;多选是故意跨目录攒的,不动
if(!pickMulti && pickFile && !items.some(function(x){ return x.path === pickFile; })) pickFile = "";
document.getElementById("pickPath").value = pickCur;
document.getElementById("pickUp").disabled = !pickParent;
document.getElementById("pickRoots").innerHTML = (d.roots || []).map(function(x){
return '<button type="button" data-path="' + esc(x.path) + '">' + esc(x.name) + '</button>';
}).join("");
var list = document.getElementById("pickList");
if(!items.length){
list.innerHTML = '<div class="off">' + (pickKind === "file" ? "这里没有可选项" : "这里没有子文件夹") + '</div>';
}else{
list.innerHTML = items.map(function(x){
var on = "";
if(!x.dir){
on = (pickMulti ? pickSel.indexOf(x.path) >= 0 : x.path === pickFile) ? " on" : "";
}
return '<div class="pi' + (x.dir ? " pi-dir" : "") + on + '" data-path="' + esc(x.path) + '" data-dir="' + (x.dir ? "1" : "0") + '">'
+ '<span class="pi-c"></span>'
+ '<span class="pi-n" title="' + esc(x.path) + '">' + esc(x.name) + '</span>'
+ '<span class="pi-m">' + (x.dir ? "文件夹" : fmtSize(x.size)) + '</span></div>';
}).join("");
}
updatePickBar();
if(d.error){
msg.textContent = d.error;
}else if(fellBack){
msg.textContent = "原来填的位置打不开,已经跳到能打开的上级目录";
}else if(d.truncated){
msg.textContent = "条目太多,只列出了前 2000 项";
}
}
// 按钮文案和「已选 N 项」都跟着当前模式与勾选情况走
function updatePickBar(){
var ok = document.getElementById("pickOk");
var sel = document.getElementById("pickSel");
if(pickMulti){
ok.disabled = !pickSel.length;
ok.textContent = pickSel.length ? ("选用这 " + pickSel.length + " 个文件") : "选用选中的文件";
sel.innerHTML = pickSel.length
? ("已选 " + pickSel.length + " 项(<span class=\"lnk\" onclick=\"pickClearSel()\">清空</span>)")
: "点文件名即勾选,可换目录继续勾";
}else{
ok.disabled = pickKind === "file" && !pickFile;
ok.textContent = pickKind === "file" ? "选用这个文件" : "选用这个文件夹";
sel.innerHTML = "";
}
}
// 只重画高亮,不重新拉目录
function paintPickSel(){
var all = document.querySelectorAll("#pickList .pi");
for(var i = 0; i < all.length; i++){
var it = all[i];
if(it.dataset.dir === "1") continue;
it.classList.toggle("on", pickMulti ? pickSel.indexOf(it.dataset.path) >= 0 : it.dataset.path === pickFile);
}
updatePickBar();
}
function pickClearSel(){ pickSel = []; paintPickSel(); }
// 选好视频后自动找同目录同名的弹幕文件(.jsonl / .ass),找不到就让用户自己选。
// 弹幕框里已经有内容时不动它,避免把用户填好的覆盖掉
async function autoMatchDanmu(){
var vEl = document.getElementById("mrgVideos"), dEl = document.getElementById("mrgDanmu");
if(!vEl || !dEl || dEl.value.trim()) return;
var lines = vEl.value.split("\n").map(function(s){ return s.trim(); }).filter(function(s){ return s; });
if(!lines.length) return;
var r = null;
try{ r = await api("/api/merge/danmu?video=" + encodeURIComponent(lines[0])); }catch(e){}
if(!r || !r.ok) return;
var d = null;
try{ d = await r.json(); }catch(e){ return; }
if(!d || !d.danmu) return;
dEl.value = d.danmu;
showMsg(true, "已按视频文件名自动匹配弹幕:" + d.danmu);
}
// 录播目录下的文件就只填文件名,框里清爽,保存目录以后改了也还认得;别处的填绝对路径
function pickFillValue(p){
if(!recDir) return p;
var parent = p.replace(/[\\/][^\\/]*$/, "");
if(parent.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase() === recDir.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase()){
return p.split(/[\\/]/).pop();
}
return p;
}
function pickConfirm(){
var el = document.getElementById(pickField);
if(pickMulti){
if(!pickSel.length) return;
if(el){
// 追加:已有行原样留着,只把这次勾的加进去(同名/同路径不重复)
var lines = el.value.split("\n").map(function(s){ return s.trim(); }).filter(function(s){ return s; });
pickSel.forEach(function(p){
var name = pickFillValue(p);
if(lines.indexOf(name) < 0 && lines.indexOf(p) < 0) lines.push(name);
});
el.value = lines.join("\n");
// 手动合并的视频/弹幕框不进配置,标了「未保存」也永远清不掉,所以只对设置表单里的框标记
if(settingFields.indexOf(pickField) >= 0) markDirty(pickField);
}
closePick();
if(pickField === "mrgVideos") autoMatchDanmu();
return;
}
var v = pickKind === "file" ? pickFile : pickCur;
if(!v) return;
if(pickKind === "file") v = pickFillValue(v);
if(el){
el.value = v;
if(settingFields.indexOf(pickField) >= 0) markDirty(pickField);
}
closePick();
}
document.getElementById("pickList").addEventListener("click", function(e){
var it = e.target.closest(".pi");
if(!it) return;
if(Date.now() - lastNavAt < 350) return;
// 双击时浏览器会先发两次 click,同一条目短时间内重复点击只算一次
if(it.dataset.path === lastPickKey && Date.now() - lastPickAt < 350) return;
lastPickKey = it.dataset.path;
lastPickAt = Date.now();
if(it.dataset.dir === "1"){ pickGo(it.dataset.path); return; }
if(pickMulti){
var i = pickSel.indexOf(it.dataset.path);
if(i >= 0) pickSel.splice(i, 1); else pickSel.push(it.dataset.path);
}else{
pickFile = it.dataset.path;
}
paintPickSel();
});
// 文件双击直接选用(多选时把双击的这个也算上,再连同已勾的一起追加)
document.getElementById("pickList").addEventListener("dblclick", function(e){
var it = e.target.closest(".pi");
if(!it || it.dataset.dir === "1") return;
if(pickMulti && pickSel.indexOf(it.dataset.path) < 0) pickSel.push(it.dataset.path);
pickConfirm();
});
document.getElementById("pickRoots").addEventListener("click", function(e){
var b = e.target.closest("button");
if(b) pickGo(b.dataset.path);
});
document.getElementById("pickHidden").addEventListener("change", function(){ pickGo(pickCur); });
document.getElementById("pickPath").addEventListener("keydown", function(e){
if(e.key === "Enter"){ e.preventDefault(); pickGo(this.value.trim()); }
});
document.addEventListener("keydown", function(e){
if(e.key === "Escape" && document.getElementById("pick").classList.contains("on")) closePick();
});
// ========== 手动合并 ==========
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");
autoMatchDanmu();
}
}
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(){
// 弹幕框还空着就先按视频文件名自动找一次(选了视频直接点检查也能用)
if(!document.getElementById("mrgDanmu").value.trim()) await autoMatchDanmu();
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(){
if(!document.getElementById("mrgDanmu").value.trim()) await autoMatchDanmu();
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, channel){
if(b) b.disabled = true;
var body = {channel: channel || "qq"};
if(body.channel === "meow"){
body.nickname = document.getElementById("meowNickname").value.trim();
body.msgType = document.getElementById("meowMsgType").value;
body.imgUrl = document.getElementById("meowImgUrl").value.trim();
body.htmlHeight = parseInt(document.getElementById("meowHtmlHeight").value, 10) || 0;
}else{
body.url = document.getElementById("notifyUrl").value.trim();
body.token = document.getElementById("notifyToken").value.trim();
body.group = document.getElementById("notifyGroup").value.trim();
}
var r = null, d = null;
try{
r = await api("/api/notify/test", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)});
if(r && r.ok) d = await r.json();
}catch(e){}
if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); if(b) b.disabled = false; return; }
if(r && r.ok) showMsg(true, "测试成功:" + ((d && d.detail) ? d.detail : "推送已发出"));
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});
}
// 选到「自定义」时当场把码率框建出来:列表在「正在文本框里打字」时不会重建,
// 光等服务端回来是看不到输入框的,用户会以为没地方填。顺带把焦点放进去,直接就能敲数字
function showBrNum(row, sel, focus){
var el = row.querySelector("input.brnum");
if(el){
if(focus){ el.focus(); el.select(); }
return el;
}
var uid = parseInt(sel.dataset.uid, 10);
var cur = (lastSt && lastSt.streamers ? lastSt.streamers : []).filter(function(s){ return s.uid === uid; })[0];
el = document.createElement("input");
el.type = "text";
el.className = "brnum";
el.dataset.uid = String(uid);
el.inputMode = "numeric";
el.placeholder = "Kbps";
el.title = "自定义:取不超过这个码率的最高档";
// 初始值:填过自定义就用上次那个数(切走再切回来不该被改掉),没填过就用现在实际在用的那一档,
// 免得切过去的一瞬间变成最低档
var v = cur ? (cur.bitrate || 0) : 0;
if(!(v > 0) && cur) v = cur.qualityBitrate || 0;
el.value = v;
sel.parentNode.insertBefore(el, sel.nextSibling);
if(focus){ el.focus(); el.select(); }
return el;
}
function hideBrNum(row){
var el = row.querySelector("input.brnum");
if(el && el.parentNode) el.parentNode.removeChild(el);
}
// 读一行里选的档位:pref 是下拉的值;自定义时把输入框里的码率一起带上,
// 输入框不在(刚切到自定义、或不是自定义)就不带 bitrate,服务端会保留原来填的值
function rowQuality(row){
var sel = row.querySelector("select.brsel");
var pref = sel ? sel.value : "second";
var num = row.querySelector("input.brnum");
var out = {qualityPref: pref};
if(pref === "custom"){
var v;
if(num){
v = parseInt(num.value.trim(), 10);
if(isNaN(v) || v < 0) return null;
}else{
// 兜底:填过自定义就用上次那个数,没填过就用现在实际在用的那一档
var uid0 = parseInt(sel.dataset.uid, 10);
var cur0 = (lastSt && lastSt.streamers ? lastSt.streamers : []).filter(function(s){ return s.uid === uid0; })[0];
v = cur0 ? (cur0.bitrate || 0) : 0;
if(!(v > 0) && cur0) v = cur0.qualityBitrate || 0;
}
out.bitrate = v;
}
return out;
}
document.addEventListener("change", async 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 q = rowQuality(row);
if(!q){ showMsg(false, "自定义档位的码率必须是大于等于 0 的数字"); return; }
q.uid = uid;
q.danmu = row.querySelector('input[data-k=danmu]').checked;
q.record = row.querySelector('input[data-k=record]').checked;
post("/api/streamer", q);
}
if(t.matches('select.brsel')){
// 档位下拉是选完立即生效的(正在录制时会自动换档重录)
var uid3 = parseInt(t.dataset.uid, 10);
var row3 = t.closest(".row");
// 选到「自定义」当场把码率框建出来并聚焦,切走就收掉(不依赖列表重建,见 showBrNum)
if(t.value === "custom") showBrNum(row3, t, true);
else hideBrNum(row3);
var q3 = rowQuality(row3);
if(!q3){ showMsg(false, "自定义档位的码率必须是大于等于 0 的数字"); return; }
q3.uid = uid3;
q3.danmu = row3.querySelector('input[data-k=danmu]').checked;
q3.record = row3.querySelector('input[data-k=record]').checked;
await post("/api/streamer", q3);
}
// 自定义档位那个码率框:回车或失焦即提交
if(t.matches('input.brnum')){
var uid5 = parseInt(t.dataset.uid, 10);
var v2 = parseInt(t.value.trim(), 10);
if(isNaN(v2) || v2 < 0){
showMsg(false, "自定义档位的码率必须是大于等于 0 的数字");
delete t.dataset.dirty;
// 数据没变化时列表不会重建,得当场把框里的内容恢复成服务端的值
var c5 = (lastSt && lastSt.streamers ? lastSt.streamers : []).filter(function(s){ return s.uid === uid5; })[0];
t.value = c5 ? (c5.bitrate || 0) : 0;
return;
}
var row4 = t.closest(".row");
var q4 = rowQuality(row4);
if(!q4) return;
q4.uid = uid5;
q4.danmu = row4.querySelector('input[data-k=danmu]').checked;
q4.record = row4.querySelector('input[data-k=record]').checked;
// 提交成功才算「不再是没提交的输入」,列表重建时才不会把它当成用户还在填
if(await post("/api/streamer", q4)) delete t.dataset.dirty;
}
});
// 去直播间接一次码率档位;拿到后下拉里就列出实际档位(0 = 自动第二高)
var qualitiesTried = {};
async function loadQualities(uid, quiet){
if(qualitiesTried[uid]) return;
qualitiesTried[uid] = true;
// 主播没开播时服务端要重试三次才放弃(约 5 秒),先给个反馈,别让人以为按钮没反应
if(!quiet) showMsg(true, "正在读取码率档位…");
var r = null;
try{ r = await api("/api/streamer/qualities?uid=" + uid); }catch(e){}
if(!r || !r.ok){
showMsg(false, r ? await r.text() : "连接失败");
qualitiesTried[uid] = false;
return;
}
var d = null;
try{ d = await r.json(); }catch(e){ return; }
showMsg(true, "已读取 " + (d.qualities || []).length + " 个码率档位");
await refresh();
}
// 开播但还没档位缓存时自动读一次,让下拉直接能用(这种必定成功,不用提示「正在读取」)
function autoLoadQualities(list){
(list || []).forEach(function(s){
if(s.live && !(s.qualities || []).length) loadQualities(s.uid, true);
});
}
// 回到地址栏或上次看的那个分区
showTab(location.hash.slice(1) || localStorage.getItem("acfunlive_tab") || "mon");
// 先试一次(用本地保存的口令),失败会自动弹出输入框并停止轮询
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"` // 视频录制开关
QualityPref string `json:"qualityPref"` // 档位偏好:top / second / lowest / custom
Bitrate int `json:"bitrate"` // 自定义档位时的码率上限(Kbps)
Qualities []qualityTier `json:"qualities"` // 可选档位(按码率从高到低),没探测到时为空
QualityBitrate int `json:"qualityBitrate"` // 按偏好对应到的档位码率(没有档位表时为 0)
QualityName string `json:"qualityName"` // 对应到的档位名
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"`
DanmuFontSize int `json:"danmuFontSize"`
DefaultQualityPref string `json:"defaultQualityPref"`
DefaultBitrate int `json:"defaultBitrate"`
WebExternal bool `json:"webExternal"`
RecordSubdir string `json:"recordSubdir"`
MergeSubdir string `json:"mergeSubdir"`
FFmpegPath string `json:"ffmpegPath"`
RecordFormat string `json:"recordFormat"`
NotifyEnabled bool `json:"notifyEnabled"`
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"`
MeowEnabled bool `json:"meowEnabled"`
MeowNickname string `json:"meowNickname"`
MeowMsgType string `json:"meowMsgType"`
MeowHTMLHeight int `json:"meowHtmlHeight"`
MeowImgURL string `json:"meowImgUrl"`
MeowBlocked string `json:"meowBlocked"` // MeoW 推不出去的原因,空字符串表示正常
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,
DanmuFontSize: config.DanmuFontSize,
WebExternal: config.WebExternal,
RecordSubdir: config.RecordSubdir,
MergeSubdir: config.MergeSubdir,
FFmpegPath: config.FFmpegPath,
RecordFormat: normalizeRecordFormat(config.RecordFormat),
DefaultQualityPref: normalizeQualityPref(config.DefaultQualityPref),
DefaultBitrate: config.DefaultBitrate,
NotifyEnabled: config.NotifyEnabled,
NotifyURL: config.NotifyURL,
NotifyToken: config.NotifyToken,
NotifyGroup: config.NotifyGroup,
NotifyLiveOn: config.NotifyLiveOn,
NotifyLiveOff: config.NotifyLiveOff,
NotifyRecordStart: config.NotifyRecordStart,
NotifyMergeDone: config.NotifyMergeDone,
MeowEnabled: config.MeowEnabled,
MeowNickname: config.MeowNickname,
MeowMsgType: normalizeMeowMsgType(config.MeowMsgType),
MeowHTMLHeight: clampMeowHTMLHeight(config.MeowHTMLHeight),
MeowImgURL: config.MeowImgURL,
MeowBlocked: meowStatusProblem(),
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,
QualityPref: normalizeQualityPref(s.QualityPref), Bitrate: s.Bitrate}
ws.Qualities = sortQualitiesDesc(s.Qualities)
ws.QualityBitrate = resolveQualityBitrate(s.Qualities, ws.QualityPref, ws.Bitrate)
ws.QualityName = qualityNameFor(s.Qualities, ws.QualityBitrate)
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"`
DanmuFontSize int `json:"danmuFontSize"`
DefaultQualityPref string `json:"defaultQualityPref"`
DefaultBitrate int `json:"defaultBitrate"`
WebExternal bool `json:"webExternal"`
RecordSubdir string `json:"recordSubdir"`
MergeSubdir string `json:"mergeSubdir"`
FFmpegPath string `json:"ffmpegPath"`
RecordFormat string `json:"recordFormat"`
NotifyEnabled bool `json:"notifyEnabled"`
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"`
MeowEnabled bool `json:"meowEnabled"`
MeowNickname string `json:"meowNickname"`
MeowMsgType string `json:"meowMsgType"`
MeowHTMLHeight int `json:"meowHtmlHeight"`
MeowImgURL string `json:"meowImgUrl"`
}
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.DanmuFontSize < 0 {
http.Error(w, "danmuFontSize 不能小于 0(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
}
meowNickname := strings.TrimSpace(req.MeowNickname)
if msg := checkMeowNickname(meowNickname, true); msg != "" {
http.Error(w, msg, http.StatusBadRequest)
return
}
meowMsgType := strings.ToLower(strings.TrimSpace(req.MeowMsgType))
if meowMsgType == "" {
meowMsgType = meowMsgTypeText
}
if meowMsgType != meowMsgTypeText && meowMsgType != meowMsgTypeHTML {
http.Error(w, "MeoW 消息类型只能是 text 或 html:"+meowMsgType, http.StatusBadRequest)
return
}
if req.MeowHTMLHeight < 0 || req.MeowHTMLHeight > maxMeowHTMLHeight {
http.Error(w, fmt.Sprintf("MeoW 的 HTML 显示高度只能是 0(用默认 %d)到 %d 之间的整数",
defaultMeowHTMLHeight, maxMeowHTMLHeight), http.StatusBadRequest)
return
}
meowImgURL := strings.TrimSpace(req.MeowImgURL)
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.DanmuFontSize = req.DanmuFontSize
config.DefaultQualityPref = normalizeQualityPref(req.DefaultQualityPref)
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.NotifyEnabled = req.NotifyEnabled
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
config.MeowEnabled = req.MeowEnabled
config.MeowNickname = meowNickname
config.MeowMsgType = meowMsgType
config.MeowHTMLHeight = clampMeowHTMLHeight(req.MeowHTMLHeight)
config.MeowImgURL = meowImgURL
// 修改了子文件夹名后确保新文件夹存在
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、新主播默认档位 %s、外网访问 %v、录播子文件夹 %q、成片子文件夹 %q、ffmpeg 路径 %q、录播格式 %q",
req.Directory, req.AutoMerge, req.MergeDelVideo, req.MergeDelDanmu, req.MergeOutputDir,
req.MergeBitrate, qualityLogText(req.DefaultQualityPref, req.DefaultBitrate), req.WebExternal,
recordSubdir, mergeSubdir, config.FFmpegPath, recordFormat)
lPrintf("推送设置:QQ 机器人渠道 %v(地址 %q、群号 %q)、MeoW 渠道 %v(昵称 %q、类型 %s、高度 %d);事件 开播 %v、下播 %v、开始录制 %v、合并完成 %v",
req.NotifyEnabled, notifyURL, config.NotifyGroup, req.MeowEnabled, meowNickname, meowMsgType,
clampMeowHTMLHeight(req.MeowHTMLHeight),
req.NotifyLiveOn, req.NotifyLiveOff, req.NotifyRecordStart, req.NotifyMergeDone)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("true"))
}
// 新增主播或修改主播的弹幕/录播开关和码率
// 档位探测的节流:同一个主播短时间内只去直播间接一次(页面上是点按钮才会来)
var qualitiesFetchMu struct {
sync.Mutex
at map[int]time.Time
}
// GET /api/streamer/qualities?uid=N —— 去直播间接一次该主播的码率档位。
// 拿到的档位会缓存进配置(没开播时页面也能显示下拉),还没设过码率的主播顺便按
// 默认档位(第二高)定下来。
func streamerQualitiesHandler(w http.ResponseWriter, r *http.Request) {
uid, err := strconv.Atoi(r.URL.Query().Get("uid"))
if err != nil || uid <= 0 {
http.Error(w, "uid 不合法", http.StatusBadRequest)
return
}
qualitiesFetchMu.Lock()
if qualitiesFetchMu.at == nil {
qualitiesFetchMu.at = make(map[int]time.Time)
}
last := qualitiesFetchMu.at[uid]
qualitiesFetchMu.Unlock()
if time.Since(last) < 10*time.Second {
http.Error(w, "刚读过一次档位,等几秒再试", http.StatusBadGateway)
return
}
tiers, err := fetchStreamQualities(uid)
if err != nil {
http.Error(w, "读取码率档位失败:"+err.Error(), http.StatusBadGateway)
return
}
qualitiesFetchMu.Lock()
qualitiesFetchMu.at[uid] = time.Now()
qualitiesFetchMu.Unlock()
if setStreamerQualities(uid, tiers) {
saveConfig()
}
pref := qualityPrefDefault
bitrate := 0
streamers.RLock()
if s, ok := streamers.crt[uid]; ok {
pref = normalizeQualityPref(s.QualityPref)
bitrate = s.Bitrate
}
streamers.RUnlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
used := resolveQualityBitrate(tiers, pref, bitrate)
_ = json.NewEncoder(w).Encode(map[string]any{
"uid": uid,
"qualities": sortQualitiesDesc(tiers),
"qualityPref": pref,
"bitrate": bitrate,
"qualityBitrate": used,
"qualityName": qualityNameFor(tiers, used),
})
}
func webStreamerHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
UID int `json:"uid"`
Danmu bool `json:"danmu"`
Record bool `json:"record"`
QualityPref string `json:"qualityPref"` // top / second / lowest / custom,不传时用新主播的默认档位
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
}
// 已存在的主播:不传档位时保持原样(页面切到「最高档」时不会把自定义码率清掉);
// 新增的主播才用配置里的默认档位
pref := normalizeQualityPref(config.DefaultQualityPref)
bitrate := config.DefaultBitrate
cur, exists := getStreamer(req.UID)
if exists {
pref = normalizeQualityPref(cur.QualityPref)
bitrate = cur.Bitrate
}
if strings.TrimSpace(req.QualityPref) != "" {
pref = normalizeQualityPref(req.QualityPref)
}
if req.Bitrate != nil {
bitrate = *req.Bitrate
}
if bitrate < 0 {
http.Error(w, "bitrate 不能小于 0", http.StatusBadRequest)
return
}
// 档位变化时先停掉正在进行的录播,setStreamerFlags 之后用新档位重启
if exists && cur.Record && req.Record && (cur.QualityPref != pref || cur.Bitrate != bitrate) {
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.QualityPref != pref || cur.Bitrate != bitrate) {
setQuality(req.UID, pref, bitrate)
}
lPrintf("通过 web UI 设置 uid %d:弹幕 %v、录播 %v、录制档位 %s", req.UID, req.Danmu, req.Record,
qualityLogText(pref, 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 {
Channel string `json:"channel"` // qq(默认)或 meow
URL string `json:"url"`
Token string `json:"token"`
Group string `json:"group"`
Nickname string `json:"nickname"`
MsgType string `json:"msgType"`
ImgURL string `json:"imgUrl"`
Height int `json:"htmlHeight"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
switch strings.ToLower(strings.TrimSpace(req.Channel)) {
case "", "qq", "webhook":
testNotifyQQ(w, req.URL, req.Token, req.Group)
case "meow":
testNotifyMeow(w, req.Nickname, req.MsgType, req.ImgURL, req.Height)
default:
http.Error(w, "未知的推送渠道:"+req.Channel, http.StatusBadRequest)
}
}
// 给 webhook 发一条测试推送
func testNotifyQQ(w http.ResponseWriter, addr, token, group string) {
n := currentNotifySetting()
if addr = strings.TrimSpace(addr); addr == "" {
addr = n.url
}
if token = strings.TrimSpace(token); token == "" {
token = n.token
}
if group = strings.TrimSpace(group); group == "" {
group = n.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(),
}
start := time.Now()
code, err := postNotify(addr, token, p)
if err != nil {
lPrintWarnf("推送测试通知失败:%v", err)
http.Error(w, "推送失败:"+err.Error(), http.StatusBadGateway)
return
}
lPrintln("测试通知推送成功:" + addr)
writeTestResult(w, fmt.Sprintf("webhook 返回 HTTP %d,用时 %s。地址和口令没问题,能不能收到还要看机器人那边有没有正常处理",
code, elapsedText(time.Since(start))))
}
// 给 MeoW 发一条测试推送
func testNotifyMeow(w http.ResponseWriter, nick, msgType, imgURL string, height int) {
n := currentNotifySetting()
if nick = strings.TrimSpace(nick); nick == "" {
nick = n.meowNick
}
if imgURL = strings.TrimSpace(imgURL); imgURL == "" {
imgURL = n.meowImg
}
if strings.TrimSpace(msgType) == "" {
msgType = n.meowMsgType
}
if height <= 0 {
height = n.meowHTML
}
if nick == "" {
http.Error(w, "还没有填写 MeoW 昵称", http.StatusBadRequest)
return
}
if msg := checkMeowNickname(nick, false); msg != "" {
http.Error(w, msg, http.StatusBadRequest)
return
}
// 已经被 MeoW 判过「未注册」的昵称不再重试:官方提示一天内累计 3 次输错昵称就封 IP
if reason := meowBadNickReason(nick); reason != "" {
http.Error(w, fmt.Sprintf("昵称 %s 之前已被 MeoW 判定为%s,为避免 IP 被封不再重试。"+
"确认昵称没填错(或换一个昵称重新保存)就能恢复,重启程序也会清掉这个记录", nick, reason),
http.StatusBadRequest)
return
}
msgType = normalizeMeowMsgType(msgType)
title := "测试推送"
plain := "这是一条测试通知,收到就说明 MeoW 渠道配置正确"
content := plain
if msgType == meowMsgTypeHTML {
content = meowHTMLText(title + "\n" + plain)
}
start := time.Now()
if err := pushMeow(nick, title, content, "", imgURL, msgType, height); err != nil {
lPrintWarnf("推送测试通知到 MeoW 失败:%v", err)
detail := "推送失败:" + err.Error()
if meowBadNickReason(nick) != "" {
detail += "。MeoW 一天内累计 3 次收到不存在的昵称就会封 IP,这里已经暂停向这个昵称推送"
}
http.Error(w, detail, http.StatusBadGateway)
return
}
lPrintf("测试通知推送成功(MeoW):%s", nick)
writeTestResult(w, fmt.Sprintf("MeoW 返回 status 200,用时 %s。手机上应该已经收到通知(昵称 %s)",
elapsedText(time.Since(start)), nick))
}
// 测试结果,detail 会显示在页面的提示里
func writeTestResult(w http.ResponseWriter, detail string) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "detail": detail})
}
func elapsedText(d time.Duration) string {
return fmt.Sprintf("%.2f 秒", d.Seconds())
}
// 显示日志,支持 ?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("GET /api/streamer/qualities", streamerQualitiesHandler)
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/browse", browseHandler)
mux.HandleFunc("GET /api/file", fileDownloadHandler)
mux.HandleFunc("POST /api/file/remove", fileRemoveHandler)
mux.HandleFunc("POST /api/merge/info", mergeInfoHandler)
mux.HandleFunc("GET /api/merge/danmu", mergeDanmuMatchHandler)
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=。
// 浏览器不允许把非 ISO-8859-1 的字符放进请求头(中文口令会直接抛 TypeError,请求根本发不出去),
// 所以页面会先把口令百分号编码再放进头里;这里把原样和解码后的都当候选,
// 这样中文口令能用,手写 curl / 脚本用原样口令也照样能用。
func authTokenCandidates(r *http.Request) []string {
var raw []string
if h := strings.TrimSpace(r.Header.Get("Authorization")); h != "" {
if len(h) > 7 && strings.EqualFold(h[:7], "Bearer ") {
raw = append(raw, strings.TrimSpace(h[7:]))
}
}
if t := strings.TrimSpace(r.Header.Get("X-Auth-Token")); t != "" {
raw = append(raw, t)
}
if t := strings.TrimSpace(r.URL.Query().Get("token")); t != "" {
raw = append(raw, t)
}
out := make([]string, 0, len(raw)*2)
for _, v := range raw {
out = append(out, v)
if d, err := url.QueryUnescape(v); err == nil && d != v {
out = append(out, d)
}
}
return out
}
// 校验请求里的管理口令,用固定时间比较避免通过响应时间猜口令
func checkAuthToken(r *http.Request) bool {
want := currentAuthToken()
if want == "" {
return false
}
// 逐个候选都比完再出结果,不提前返回,免得响应时间泄露是哪一种写法对上了
ok := false
for _, got := range authTokenCandidates(r) {
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 {
ok = true
}
}
return ok
}
// 口令错误次数的记录,给反复尝试的请求加一点延迟
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)
}()
}