Files
acfunlive-remake/webui.go
T
tamakyi ecc560c1f0 web UI 按用途分成六个分区,整体改版并跟随系统深浅色
需求是「界面比较杂乱,做一下分类,UI 也现代化一些」。原来所有设置挤在一张
「设置」卡片里,从保存目录一路排到管理口令,加上通知推送的两个渠道,一屏
拉不到底,找东西全靠翻。

1) 分成六个分区,顶部一条导航切换
   - 监控:添加主播 + 主播列表 + 运行日志(默认打开的就是这里)
   - 录制:保存目录、录播/弹幕和成片子文件夹、录播格式、默认码率上限、ffmpeg 路径
   - 合并:自动合并的三个开关、成片输出目录和合成码率、手动合并
   - 通知:推送哪些事件、QQ 机器人渠道、MeoW 渠道
   - 文件:磁盘容量 + 文件列表
   - 设置:外网访问、管理口令
   - 分区只靠 class 切显示,所有元素始终在 DOM 里,所以原来按 id 取值的
     那一大堆逻辑一行都不用改;「监控」是默认页,因为看录制状态是最高频的操作

2) 地址栏带上 #分区名
   - 用 replaceState 写,不往历史里塞记录,但刷新、收藏、把链接发给别人
     都能落到同一个分区;也顺便记住了上次看的是哪一页(localStorage 兜底)

3) 大改样式(都在同一个模板里,没有引入任何外部依赖)
   - 颜色、圆角、阴影收进 CSS 变量,卡片改成柔和阴影 + 大圆角,输入框加了
     hover 和 focus 光晕,按钮分主次两级
   - 勾选框改成开关样式(隐藏原生 input + 兄弟节点画轨道,所以 el.checked
     和 :focus 判断照旧能用);主播列表的「弹幕/录播」改成小圆片
   - 把原来那个行内的小 #msg 文案换成右下角提示条:成功 2.6 秒、失败 6 秒
     自动消失,最多同时留 4 条,报错信息不会再一闪而过
   - 顶栏常驻状态摘要(录制中 N / 直播中 N / 空闲),「保存设置」也挪到顶栏,
     一直可见——文本框可能在别的分区里,按钮跟着分区走会找不到
   - 跟随系统深浅色(prefers-color-scheme),配套改了 color-scheme 元信息;
     日志面板在两种模式下都是深色终端风格
   - 手机上标题居中、右侧按钮收成两个字(「保存」),分区导航横向滚动;
     顶栏用 1fr auto 1fr 的网格,两侧等宽才是真的居中
   - 文件列表里的文件名原来被 max-width:200px 截成「2026-09-24 20-00-00 Xsp…」,
     文件名本来就是用来认文件的,改成不截断、允许换行

4) 顺手修的两个真问题
   - 口令里有中文时,页面彻底用不了:HTTP 请求头只接受 ISO-8859-1,浏览器
     遇到非 ASCII 的 X-Auth-Token 会直接抛 TypeError
     (Failed to read the 'headers' property…),请求根本发不出去,页面只显示
     「无法连接到服务器」,用户既进不去也没法在页面上把口令改回来。
     现在页面统一把口令 encodeURIComponent 之后再放进头里,服务端
     authTokenCandidates() 把原样和解码后的都作为候选逐个定长比较
     (不提前返回,避免响应时间泄露是哪一种对上了),所以中文口令能用,
     curl / 脚本按文档原样发中文口令也照样能过
   - 删「正在写入」的文件返回 500 而不是 409,见上一个提交

5) 文档
   - README 的「使用方法」补分区表、保存规则(开关立即生效 / 文本框点保存)、
     顶栏状态和深浅色说明;各处「设置区」改成对应的分区名
   - doc/webapi.md 补口令编码那段,说明脚本侧按原样填即可

已验证:新增 webui_test.go 5 个用例(ASCII 口令、含 % 的口令、中文口令的
编码与原样两种写法、Bearer 与 ?token= 三个来源、以及错口令/空口令/口令未配
一律拒绝、候选顺序与非 Bearer 的 Authorization 不算数),全量 39 个用例通过。
真机用 Edge 无头浏览器跑了两轮共 40 项断言,覆盖:自动登录、六个分区逐一点开
(只显示该分区、页签高亮、hash 跟着变)、六个分区 + 深色 + 三个移动端截图、
开关点了立刻落盘并能点回来、顶栏保存写到配置文件、在别的分区保存不会把隐藏
表单的值清空、刷新后仍在原分区、MeoW 红字提示的显示与隐藏、合并中按钮禁用
并改文案、口令弹窗的失败与重试,桌面/深色/移动端三处控制台零报错。
中文口令另做了端到端验证:配置里放中文口令能直接进、换口令后接口继续可用、
重开页面用新口令能进、旧口令被拒;把客户端编码去掉后同一个测试立刻失败,
确认这个修复是必需的而不是想当然。Windows/Linux 双平台构建、go vet、
gofmt 全干净。51880 的上游原版全程未动。
2026-09-25 14:19:44 +08:00

1745 lines
78 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)}
/* 分区导航 */
.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}
input.br{width:92px;text-align:center;padding:6px 8px}
/* 开关 */
.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}
.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" 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">下载码率上限 = 挑选码率不超过该值的直播源档位(2000 约超清档),0 = 最高档;正在录制时修改会自动换档重新开始</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><input type="text" id="directory" placeholder="例如 D:\record"></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>默认下载码率上限(Kbps,0 = 最高档)</label><input type="text" id="defaultBitrate" inputmode="numeric" placeholder="例如 2000"></div>
<div class="f"><label>ffmpeg 路径(留空自动查找)</label><input type="text" id="ffmpegPath" placeholder="例如 D:\ffmpeg\bin\ffmpeg.exe"></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><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>
<div class="hint">文本框改完点右上角「保存设置」生效。</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>
<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: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>
<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;
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 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();
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("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);
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>'
+ '<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){}
}
// 右下角弹一条提示,成功 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){
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,
notifyEnabled: document.getElementById("notifyEnabled").checked,
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,
meowEnabled: document.getElementById("meowEnabled").checked,
meowNickname: document.getElementById("meowNickname").value.trim(),
meowMsgType: document.getElementById("meowMsgType").value,
meowHtmlHeight: parseInt(document.getElementById("meowHtmlHeight").value, 10) || 0,
meowImgUrl: document.getElementById("meowImgUrl").value.trim(),
webExternal: document.getElementById("webExternal").checked
});
if(ok) 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(){ saveSettings(null); });
});
// 下拉框改完也立即保存
document.getElementById("recordFormat").addEventListener("change", function(){ saveSettings(null); });
// HTML 高度只对 html 类型有意义,选 text 时收起来
function toggleMeowHeight(){
var isHTML = document.getElementById("meowMsgType").value === "html";
document.getElementById("meowHeightRow").style.display = isHTML ? "" : "none";
}
document.getElementById("meowMsgType").addEventListener("change", function(){ toggleMeowHeight(); 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, 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});
}
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 uid2 = parseInt(t.dataset.uid, 10);
var row2 = t.closest(".row");
var v = parseInt(t.value.trim(), 10);
if(isNaN(v) || v < 0){ showMsg(false, "码率必须是大于等于 0 的数字"); refresh(); return; }
var danmu2 = row2.querySelector('input[data-k=danmu]').checked;
var record2 = row2.querySelector('input[data-k=record]').checked;
post("/api/streamer", {uid: uid2, danmu: danmu2, record: record2, bitrate: v});
}
});
// 回到地址栏或上次看的那个分区
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"` // 视频录制开关
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"`
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,
WebExternal: config.WebExternal,
RecordSubdir: config.RecordSubdir,
MergeSubdir: config.MergeSubdir,
FFmpegPath: config.FFmpegPath,
RecordFormat: normalizeRecordFormat(config.RecordFormat),
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, 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"`
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.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.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、默认下载码率 %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("推送设置: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"))
}
// 新增主播或修改主播的弹幕/录播开关和码率
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 {
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("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=。
// 浏览器不允许把非 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)
}()
}