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 的上游原版全程未动。
This commit is contained in:
@@ -114,7 +114,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
||||
|
||||
录播原片和弹幕文件(`.ass`、`.nick.ass`、`.jsonl`)保存在保存目录下的`recordSubdir`子文件夹里(默认`record`),两者同名配对才能自动合并;合并后的成片保存在`mergeSubdir`子文件夹里(默认`merged`),留空则用默认值,两个名字不能相同。如果设置了`mergeOutputDir`,成片优先移动到那里。
|
||||
|
||||
`recordFormat`决定录播原片的封装格式,默认为`mp4`(和原版`config.Output`的默认值一致),也可以改成`flv`、`ts`、`mkv`,在 web UI 设置区用下拉框修改。录制始终是`-c copy`直接封装直播源、不重新编码,所以四种格式的**画质和码率完全一样,区别只在容器**。
|
||||
`recordFormat`决定录播原片的封装格式,默认为`mp4`(和原版`config.Output`的默认值一致),也可以改成`flv`、`ts`、`mkv`,在 web UI 的`录制`分区用下拉框修改。录制始终是`-c copy`直接封装直播源、不重新编码,所以四种格式的**画质和码率完全一样,区别只在容器**。
|
||||
|
||||
正常停止(web UI 的停止按钮、`stoprecord`、`Ctrl+C`、`quit`)会先向 ffmpeg 发`q`让它正常收尾,**四种格式都能正常播放**——本机 ffmpeg 6.0 实测录 5 秒后发`q`:mp4 161 KB、flv 163 KB、ts 191 KB、mkv 160 KB,全部可读且时长 5.14 秒。但如果进程被强杀(任务管理器结束进程、断电、系统崩溃),差别就很大了——同样录制 8 秒后强杀:
|
||||
|
||||
@@ -131,9 +131,9 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
||||
|
||||
`webExternal`为 false 时 web UI 只监听`127.0.0.1`,只有本机能打开;设为 true 后监听所有网卡,同一局域网的设备可以用本机 IP 加端口访问。这项设置改动后 web 服务会自动重启。
|
||||
|
||||
`authToken`是管理接口的访问口令,首次启动时自动生成(12 字节随机数的十六进制)并打印在运行日志里,忘记时可以打开`config.json`查看,也能在 web UI 设置区查看、复制和更换。**所有`/api/`接口都必须带口令**,页面本身可以匿名打开,打开后会要求输入口令并保存在浏览器本地。口令可以用三种方式提供:`X-Auth-Token`请求头、`Authorization: Bearer <口令>`、或者`?token=<口令>`查询参数。同一来源连续输错时每次会增加最多 2 秒延迟,避免被暴力尝试。手工把`config.json`里的`authToken`清空保存后,程序会自动重新生成一个,不会把自己锁在外面。
|
||||
`authToken`是管理接口的访问口令,首次启动时自动生成(12 字节随机数的十六进制)并打印在运行日志里,忘记时可以打开`config.json`查看,也能在 web UI 的`设置`分区查看、复制和更换。**所有`/api/`接口都必须带口令**,页面本身可以匿名打开,打开后会要求输入口令并保存在浏览器本地。口令可以用三种方式提供:`X-Auth-Token`请求头、`Authorization: Bearer <口令>`、或者`?token=<口令>`查询参数。口令可以用任意字符,包括中文。注意 HTTP 请求头只接受 ISO-8859-1,浏览器遇到中文口令会直接拒绝发请求,所以页面发给服务端的`X-Auth-Token`是百分号编码过的口令,服务端原样和解码后的都认;用 curl 或脚本调接口时按原样填即可,不用自己编码。同一来源连续输错时每次会增加最多 2 秒延迟,避免被暴力尝试。手工把`config.json`里的`authToken`清空保存后,程序会自动重新生成一个,不会把自己锁在外面。
|
||||
|
||||
`defaultBitrate`是下载码率的默认值:在 web UI 里新增主播、且不单独指定码率时使用它,也可以在 web UI 设置区随时修改,只对新添加的主播生效,已有主播保持各自的值。
|
||||
`defaultBitrate`是下载码率的默认值:在 web UI 里新增主播、且不单独指定码率时使用它,也可以在 web UI 的`录制`分区随时修改,只对新添加的主播生效,已有主播保持各自的值。
|
||||
|
||||
`ffmpegPath`用来指定 ffmpeg 的位置,可以填可执行文件本身(如`D:\ffmpeg\bin\ffmpeg.exe`)或它所在的文件夹,留空时按「PATH → 程序所在文件夹 → 程序所在文件夹的 bin、ffmpeg、ffmpeg/bin 子目录」的顺序自动查找。填相对路径时按程序所在目录解析,不会因为合并时会切换工作目录而失效(这是`ffmpeg.exe`这种只写文件名的写法能正常工作的原因)。录播和弹幕合并都依赖 ffmpeg,找不到时启动日志里会有明确提示。
|
||||
|
||||
@@ -145,6 +145,21 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
||||
|
||||
web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`),可以在网页里增删主播、切换每个主播的弹幕和录播开关、修改码率上限、修改保存目录和自动合并设置。
|
||||
|
||||
页面按用途分成六个分区,顶部一条导航切换,地址栏会跟着带上`#分区名`,所以刷新、收藏、直接把链接发给别人都能落到同一个分区:
|
||||
|
||||
| 分区 | 内容 |
|
||||
| --- | --- |
|
||||
| 监控 | 添加主播、主播列表(默认打开的分区),以及运行日志 |
|
||||
| 录制 | 保存目录和子文件夹、录播格式、默认码率上限、ffmpeg 路径 |
|
||||
| 合并 | 自动合并开关、成片输出目录和合成码率、手动合并 |
|
||||
| 通知 | 推送哪些事件,以及 QQ 机器人 / MeoW 两个渠道的配置和测试连通 |
|
||||
| 文件 | 磁盘剩余容量、录播和成片的文件列表 |
|
||||
| 设置 | 是否允许外网访问、管理口令的查看与更换 |
|
||||
|
||||
开关和下拉框改完立即保存;文本框(目录、地址、口令这些)填完点右上角的`保存设置`才生效,这一条在每个有文本框的分区里也写了提示。
|
||||
|
||||
顶栏右上角一直显示当前状态(`录制中 N` / `直播中 N` / `空闲`)。窄屏上按钮收起成短文案、标题居中,手机上也能正常用;页面跟随系统的深浅色设置,深色模式下日志和表单自动变深色,不用额外配置。
|
||||
|
||||
主播列表每行右侧的`停止`和`重新开始`按钮控制该主播当前的录制:`停止`会结束这一场的弹幕和录播(同时把该主播的弹幕、录播开关关掉,避免被监控循环自动重启),`重新开始`会重新开启开关并立刻开始新的一组录制文件。这两个按钮同时作用于弹幕和录播,只控制其中一项可以用对应的勾选框。
|
||||
|
||||
命令行模式运行时可以输入命令控制本程序,输入`help`查看全部命令的解释,输入`quit`退出程序。
|
||||
@@ -196,7 +211,7 @@ acfunlive -danmuburn xxx.jsonl -video xxx.flv -ffmpeg D:\tools\ffmpeg.exe
|
||||
|
||||
### 手动合并
|
||||
|
||||
web UI 的`手动合并`区用来自己挑视频和弹幕出片,适合手动停止录制过的一场、或者手里只有零散文件的情况。
|
||||
web UI 的`合并`分区里的`手动合并`用来自己挑视频和弹幕出片,适合手动停止录制过的一场、或者手里只有零散文件的情况。
|
||||
|
||||
- **视频**:既可以在文本框里一行一个地填,也可以从录播目录的下拉列表里多选(选完自动填进文本框)。填的名字不带路径就按录播目录找,带路径的相对和绝对都能用,所以录播目录以外的文件也合得来。粘贴路径时带上的引号会被自动去掉。
|
||||
- **弹幕**:支持`.jsonl`和`.ass`。填`.ass`时会先还原成 jsonl 再合并(规则和`-danmuburn`一样,优先用同名的 jsonl),还原结果写回录播目录,页面的下拉列表里也会出现。
|
||||
@@ -266,7 +281,7 @@ webhook 返回 200 只代表**地址和口令没问题**,能不能真的收到
|
||||
|
||||
### 文件管理
|
||||
|
||||
web UI 的`文件管理`区可以列出录播文件夹和成片文件夹里的文件(名称、大小、修改时间),并显示保存目录所在磁盘的剩余和总容量。
|
||||
web UI 的`文件`分区可以列出录播文件夹和成片文件夹里的文件(名称、大小、修改时间),并显示保存目录所在磁盘的剩余和总容量。
|
||||
|
||||
- `下载`:以附件形式下载,中文文件名用 RFC 5987 编码,不会乱码
|
||||
- `播放`:只对浏览器能直接播的格式(mp4、m4v、webm、ogv)显示,在新标签页里内联播放;`.flv` 浏览器放不了,只提供下载
|
||||
|
||||
+4
-1
@@ -76,8 +76,11 @@ web API 默认本地端口为 51880
|
||||
|
||||
## 新版 web UI 的管理接口
|
||||
|
||||
web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全部需要带管理口令:请求头`X-Auth-Token: <口令>`,或者在 URL 上带`?token=<口令>`(下载链接用后者,因为浏览器跳转带不了请求头)。跟手动合并有关的是下面两个:
|
||||
web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全部需要带管理口令:请求头`X-Auth-Token: <口令>`,或者在 URL 上带`?token=<口令>`(下载链接用后者,因为浏览器跳转带不了请求头)。
|
||||
|
||||
口令支持任意字符,包括中文——不过 HTTP 请求头只接受 ISO-8859-1,浏览器遇到非 ASCII 的口令会直接拒绝发请求(报`String contains non ISO-8859-1 code point`),所以**页面发给服务端的`X-Auth-Token`是百分号编码过的口令**(`encodeURIComponent`)。服务端两种写法都认:先拿原样比,再拿解码后的比,所以手写 curl 或者脚本按原样把中文口令放进请求头、`Authorization: Bearer`、`?token=` 里也都能通过。用脚本调这套接口时不用管编码,原样填就行。
|
||||
|
||||
跟手动合并有关的是下面两个:
|
||||
`POST /api/merge/info` 预览一次手动合并的计划,不落盘。
|
||||
|
||||
```
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -28,221 +29,368 @@ const webuiHTML = `<!DOCTYPE html>
|
||||
<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{--line:#e8eaed;--soft:#f0f1f3;--sub:#8a8f98;--red:#fd4c5d}
|
||||
: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:#f6f7f8;color:#222;font:14px/1.5 system-ui,-apple-system,"Microsoft YaHei",sans-serif;-webkit-text-size-adjust:100%}
|
||||
.wrap{max-width:1000px;margin:0 auto;padding:14px 12px 28px}
|
||||
h1{font-size:16px;font-weight:600;margin:0 0 2px}
|
||||
.head-sub{font-size:12px;color:var(--sub);margin-bottom:12px}
|
||||
.card{background:#fff;border:1px solid var(--line);border-radius:6px;padding:12px 14px;margin-bottom:10px}
|
||||
.card h2{font-size:13px;font-weight:600;margin:0 0 10px;color:#333}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:10px 14px}
|
||||
.f label{display:block;font-size:12px;color:var(--sub);margin-bottom:4px}
|
||||
input[type=text],select,textarea{width:100%;border:1px solid #dcdfe4;border-radius:4px;padding:7px 9px;font:inherit;background:#fff;color:#222}
|
||||
input[type=text]:focus,select:focus,textarea:focus{outline:none;border-color:var(--red)}
|
||||
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:4px}
|
||||
#mrgInfo{white-space:pre-wrap;margin-top:8px}
|
||||
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:88px;text-align:center}
|
||||
.chk{display:inline-flex;align-items:center;gap:5px;margin:0 14px 0 0;font-size:13px;color:#333;cursor:pointer}
|
||||
button{border:none;border-radius:4px;padding:8px 16px;font:inherit;cursor:pointer;background:var(--red);color:#fff}
|
||||
button.ghost,a.ghost{background:#f1f2f4;color:#555;padding:6px 10px;font-size:12px;text-decoration:none;display:inline-block}
|
||||
button:active,a.ghost:active{opacity:.8}
|
||||
button:disabled{opacity:.5}
|
||||
.hint{font-size:12px;color:var(--sub);margin-top:6px;line-height:1.6}
|
||||
.hint.bad{color:#d63046}
|
||||
.hr{height:1px;background:var(--soft);margin:12px -14px}
|
||||
.ch{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:#333;margin:12px 0 8px}
|
||||
.ch::after{content:"";flex:1;height:1px;background:var(--soft)}
|
||||
.row{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;padding:10px 0;border-top:1px solid var(--soft)}
|
||||
.row:first-child{border-top:none;padding-top:2px}
|
||||
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:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.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}
|
||||
.badge{display:inline-block;padding:1px 7px;border-radius:3px;font-size:12px;margin-right:4px}
|
||||
.on{background:#ffe8e8;color:#d63046}
|
||||
.rec{background:#e6f4ec;color:#1d7a46}
|
||||
.off{font-size:12px;color:var(--sub)}
|
||||
pre{background:#1e1e1e;color:#d6d6d6;border-radius:6px;padding:10px;font-size:12px;max-height:300px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0}
|
||||
#msg{font-size:12px;margin-left:8px}
|
||||
input[readonly]{background:#f6f7f8;color:#555}
|
||||
.mask{position:fixed;left:0;top:0;right:0;bottom:0;background:rgba(20,22,26,.55);display:none;align-items:center;justify-content:center;z-index:9;padding:16px}
|
||||
/* 文件名是拿来认文件的关键信息,在文件列表里放开宽度不要截 */
|
||||
#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:#fff;border-radius:8px;padding:18px;width:100%;max-width:380px}
|
||||
.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.6}
|
||||
#authMsg{font-size:12px;color:#d63046;min-height:17px;margin-top:8px}
|
||||
@media (max-width:600px){
|
||||
.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 10px;font-size:13px}
|
||||
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">
|
||||
<h1>AcFun 直播录制</h1>
|
||||
<div class="head-sub">弹幕 / 视频录制,直播结束后自动合并</div>
|
||||
<div class="card">
|
||||
<h2>设置</h2>
|
||||
<div class="f"><label>文件保存目录(留空 = 程序所在目录)</label><input type="text" id="directory" placeholder="例如 D:\record"></div>
|
||||
<div class="hr"></div>
|
||||
<div>
|
||||
<label class="chk"><input type="checkbox" id="autoMerge">直播结束后自动合并</label>
|
||||
<label class="chk"><input type="checkbox" id="mergeDelVideo">合并后删除源视频</label>
|
||||
<label class="chk"><input type="checkbox" id="mergeDelDanmu">合并后删除弹幕</label>
|
||||
</div>
|
||||
<div class="hint">三项互不影响,可以同时勾选;需该主播同时开启弹幕和录播且都录到直播结束,手动停止或重新开始不会触发合并</div>
|
||||
<div class="grid" style="margin-top:10px">
|
||||
<div class="f"><label>成片移动到(留空 = 保存目录下的成片文件夹)</label><input type="text" id="mergeOutputDir" placeholder="例如 D:\merged"></div>
|
||||
<div class="f"><label>合成码率(Kbps,0 = 默认质量)</label><input type="text" id="mergeBitrate" inputmode="numeric" placeholder="例如 1500"></div>
|
||||
<div class="f"><label>默认下载码率上限(Kbps,0 = 最高档)</label><input type="text" id="defaultBitrate" inputmode="numeric" placeholder="例如 2000"></div>
|
||||
<div class="f"><label>录播/弹幕子文件夹</label><input type="text" id="recordSubdir" placeholder="record"></div>
|
||||
<div class="f"><label>成片子文件夹</label><input type="text" id="mergeSubdir" placeholder="merged"></div>
|
||||
<div class="f"><label>ffmpeg 路径(留空自动查找)</label><input type="text" id="ffmpegPath" placeholder="例如 D:\ffmpeg\bin\ffmpeg.exe"></div>
|
||||
<div class="f"><label>录播文件格式</label><select id="recordFormat">
|
||||
<option value="mp4">mp4(推荐,最通用)</option>
|
||||
<option value="ts">ts(强杀后还能播已录部分)</option>
|
||||
<option value="flv">flv(兼容性一般)</option>
|
||||
<option value="mkv">mkv(可装多音轨)</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<div class="hint">录播和弹幕合并都要用 ffmpeg:留空时先查 PATH,再查程序所在目录及其 bin、ffmpeg 子目录</div>
|
||||
<div class="hint">录制是 -c copy 直接封装,不转码,四种格式画质完全一样,只是容器不同。正常停止(停止按钮、Ctrl+C)都没问题;如果进程被强杀(任务管理器结束进程、断电),mp4 / flv / mkv 都会整个报废,实测只有 ts 还能播出已经录到的部分。合并出来的成片始终是 mp4。</div>
|
||||
<div class="hint">同一主播下播后 20 秒内又上播会当成网络波动,接着录同一场,最终只合成一个成片。</div>
|
||||
<div class="hr"></div>
|
||||
<div class="ch">通知推送</div>
|
||||
<div>
|
||||
<label class="chk"><input type="checkbox" id="notifyLiveOn">开播</label>
|
||||
<label class="chk"><input type="checkbox" id="notifyLiveOff">下播</label>
|
||||
<label class="chk"><input type="checkbox" id="notifyRecordStart">开始录制</label>
|
||||
<label class="chk"><input type="checkbox" id="notifyMergeDone">合并完成</label>
|
||||
</div>
|
||||
<div class="hint">这一行勾的是「推哪些事件」,下面两个渠道共用;推送失败只记警告,不影响录制</div>
|
||||
|
||||
<div class="ch">渠道一 · QQ 机器人(webhook)</div>
|
||||
<div><label class="chk"><input type="checkbox" id="notifyEnabled">启用这个渠道</label></div>
|
||||
<div class="grid" style="margin-top:8px">
|
||||
<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>
|
||||
<!-- 监控 -->
|
||||
<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="hint">推送体是 json,字段 event / uid / name / title / url / file / group / text / time,text 可以直接发给群</div>
|
||||
<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px;align-items:center">
|
||||
<button class="ghost" onclick="testNotify(this,'qq')">测试连通</button>
|
||||
<span class="hint" style="margin:0">用上面填写的地址发一条测试推送,不用先保存</span>
|
||||
<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>
|
||||
|
||||
<div class="ch">渠道二 · MeoW</div>
|
||||
<div><label class="chk"><input type="checkbox" id="meowEnabled">启用这个渠道</label></div>
|
||||
<div class="grid" style="margin-top:8px">
|
||||
<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>
|
||||
<!-- 录制 -->
|
||||
<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="hint">推给 MeoW 里这个昵称,手机上直接收通知,点开可以跳回直播间;接口有频控(3 秒 1 条、每分钟 15 条、每小时 60 条)</div>
|
||||
<div class="hint bad" id="meowBlocked" style="display:none"></div>
|
||||
<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px;align-items:center">
|
||||
<button class="ghost" onclick="testNotify(this,'meow')">测试连通</button>
|
||||
<span class="hint" style="margin:0">用上面填写的昵称发一条测试推送,不用先保存</span>
|
||||
<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>
|
||||
<div style="margin-top:10px">
|
||||
<label class="chk"><input type="checkbox" id="webExternal">允许外网访问(关闭时只有本机能打开本页)</label>
|
||||
</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="hint">开启后监听所有网卡,同一局域网的设备可用本机 IP 访问;修改后 web 服务会自动重启,页面会自动重连</div>
|
||||
<div class="hr"></div>
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:8px">
|
||||
<button onclick="saveSettings(this)">保存设置</button><span id="msg"></span>
|
||||
<div 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="hint">勾选类设置修改后立即保存,文本框内容点「保存设置」生效</div>
|
||||
<div class="hr"></div>
|
||||
<div class="f"><label>管理口令(打开本页和管理录制都需要,建议复制保存到别处)</label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<input type="text" id="authTokenView" readonly style="flex:1 1 200px">
|
||||
<button class="ghost" onclick="copyToken()">复制</button>
|
||||
<div 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="f" style="margin-top:8px"><label>更换管理口令(至少 8 个字符)</label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<input type="text" id="authTokenNew" autocomplete="off" placeholder="输入新的管理口令" style="flex:1 1 200px">
|
||||
<button class="ghost" onclick="changeToken()">更换</button>
|
||||
<div 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>
|
||||
<div class="hint">忘记口令时可以打开程序所在目录的 config.json,查看 authToken 字段</div>
|
||||
<div class="hr"></div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
|
||||
<input type="text" id="newuid" inputmode="numeric" placeholder="新增主播 uid,例如 23682490" style="max-width:230px">
|
||||
<button class="ghost" onclick="addUid()">添加(弹幕+录播)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>主播列表(勾选 = 开启对应录制,修改立即生效)</h2>
|
||||
<div class="hint" style="margin:0 0 8px">下载码率上限 = 挑选码率不超过该值的直播源档位(2000 约超清档),0 = 最高档;正在录制时修改会自动换档重新开始</div>
|
||||
<div id="st"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>文件管理</h2>
|
||||
<div class="hint" id="diskInfo" style="margin:0 0 8px">点击下面的按钮读取文件和磁盘信息</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
|
||||
<button class="ghost" onclick="loadFiles(this)">刷新文件列表</button>
|
||||
<span class="hint" style="margin:0" id="filesInfo"></span>
|
||||
</div>
|
||||
<div id="files" style="margin-top:6px"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>手动合并</h2>
|
||||
<div class="hint" style="margin:0 0 8px">自动合并只在录制自然结束时触发(手动停止过、弹幕和视频对不上号、或者把文件挪过位置的都不会自动合并),这些可以在这里自己挑文件合成。视频一行一个,填多个会按文件名里的录制时间顺序拼成一个再合成。合并进度见下方运行日志。</div>
|
||||
<div class="grid">
|
||||
<div class="f">
|
||||
<label>视频文件(一行一个,也可填绝对路径)</label>
|
||||
<textarea id="mrgVideos" rows="3" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.mp4"></textarea>
|
||||
</div>
|
||||
<div class="f">
|
||||
<label>弹幕文件(.jsonl 直接用,.ass 会先还原成 .jsonl)</label>
|
||||
<input type="text" id="mrgDanmu" list="mrgDanmuList" autocomplete="off" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.jsonl">
|
||||
<datalist id="mrgDanmuList"></datalist>
|
||||
</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>
|
||||
<div class="grid" style="margin-top:10px">
|
||||
<div class="f">
|
||||
<label>从文件列表选视频(可多选,按 Ctrl 点选)</label>
|
||||
<select id="mrgVideoPick" multiple size="4" onchange="pickVideos()"></select>
|
||||
</div>
|
||||
<div class="f">
|
||||
<label>偏移(秒,留空 = 自动推算)</label>
|
||||
<input type="text" id="mrgOffset" inputmode="decimal" autocomplete="off" placeholder="留空自动">
|
||||
<label style="margin-top:8px">输出文件名(留空 = 自动命名,不覆盖已有成片)</label>
|
||||
<input type="text" id="mrgOut" autocomplete="off" placeholder="留空自动">
|
||||
</div>
|
||||
</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 style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px;align-items:center">
|
||||
<button class="ghost" id="mrgCheck" onclick="checkMerge()">检查</button>
|
||||
<button id="mrgGo" onclick="startMerge()">开始合并</button>
|
||||
<span class="hint" style="margin:0" id="mrgState"></span>
|
||||
<div 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>
|
||||
<div class="hint" id="mrgInfo"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>运行日志</h2>
|
||||
<pre id="log"></pre>
|
||||
</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:10px"><button onclick="login()">进入</button></div>
|
||||
<div style="margin-top:12px"><button onclick="login()">进入</button></div>
|
||||
<div id="authMsg"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,16 +404,44 @@ 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]; }
|
||||
h["X-Auth-Token"] = token;
|
||||
// 请求头只接受 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");
|
||||
@@ -368,13 +544,22 @@ async function refresh(){
|
||||
toggleMeowHeight();
|
||||
var mb = document.getElementById("meowBlocked");
|
||||
if(st.meowBlocked){ mb.style.display = ""; mb.textContent = "当前推不出去:" + st.meowBlocked; }
|
||||
else mb.style.display = "none";
|
||||
else show("meowBlocked", false);
|
||||
setChk("webExternal", st.webExternal);
|
||||
setChk("autoMerge", st.autoMerge);
|
||||
setChk("mergeDelVideo", st.mergeDelVideo);
|
||||
setChk("mergeDelDanmu", st.mergeDelDanmu);
|
||||
syncMergeState(st);
|
||||
var rows = (st.streamers||[]).map(function(s){
|
||||
// 顶栏的状态摘要
|
||||
var 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>';
|
||||
@@ -383,8 +568,8 @@ async function refresh(){
|
||||
return '<div class="row">'
|
||||
+ '<span class="uid">uid ' + s.uid + '</span>'
|
||||
+ '<span class="name">' + (esc(s.name) || "uid " + s.uid) + '</span>'
|
||||
+ '<label class="chk"><input type="checkbox" data-uid="' + s.uid + '" data-k="danmu"' + (s.danmu?" checked":"") + '>弹幕</label>'
|
||||
+ '<label class="chk"><input type="checkbox" data-uid="' + s.uid + '" data-k="record"' + (s.record?" checked":"") + '>录播</label>'
|
||||
+ '<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>'
|
||||
@@ -398,7 +583,7 @@ async function refresh(){
|
||||
// 数据没变化不重建;正在列表里输入时也跳过,避免把没提交的输入清掉
|
||||
if(sig !== stSig && !list.contains(document.activeElement)){
|
||||
stSig = sig;
|
||||
list.innerHTML = rows || '<div class="off">列表为空,请在上方添加 uid</div>';
|
||||
list.innerHTML = rows || '<div class="off">列表为空,在上面填 uid 添加</div>';
|
||||
}
|
||||
var lr = await api("/api/log?since=" + logOffset);
|
||||
if(lr.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
|
||||
@@ -415,10 +600,20 @@ async function refresh(){
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
// 右下角弹一条提示,成功 2.6 秒、失败 6 秒
|
||||
function showMsg(ok, text){
|
||||
var m = document.getElementById("msg");
|
||||
m.textContent = text; m.style.color = ok ? "#1d7a46" : "#d63046";
|
||||
setTimeout(function(){ m.textContent = ""; }, 3000);
|
||||
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;
|
||||
@@ -590,7 +785,7 @@ function setMergeState(){
|
||||
go.disabled = mrgBusy;
|
||||
go.textContent = mrgBusy ? "合并中…" : "开始合并";
|
||||
document.getElementById("mrgCheck").disabled = mrgBusy;
|
||||
document.getElementById("mrgState").textContent = mrgBusy ? "正在合并,进度见下方运行日志" : "";
|
||||
document.getElementById("mrgState").textContent = mrgBusy ? "正在合并,进度见「监控」页的运行日志" : "";
|
||||
}
|
||||
async function checkMerge(){
|
||||
var body = mrgPayload();
|
||||
@@ -619,7 +814,7 @@ async function startMerge(){
|
||||
var body = mrgPayload();
|
||||
if(!body) return;
|
||||
if(!body.videos.length || !body.danmu){ showMsg(false, "请先填写视频和弹幕文件"); return; }
|
||||
if(!confirm("开始合并?合成期间可以在下方运行日志里看进度。")) return;
|
||||
if(!confirm("开始合并?合成期间可以在「监控」页的运行日志里看进度。")) return;
|
||||
var b = document.getElementById("mrgGo");
|
||||
b.disabled = true;
|
||||
var r = null;
|
||||
@@ -644,7 +839,7 @@ function syncMergeState(st){
|
||||
if(busy) return;
|
||||
loadFiles();
|
||||
if(st.mergeErr) showMsg(false, "合并失败:" + st.mergeErr);
|
||||
else showMsg(true, "手动合并结束,结果见运行日志");
|
||||
else showMsg(true, "手动合并结束,结果见「监控」页的运行日志");
|
||||
}
|
||||
async function testNotify(b, channel){
|
||||
if(b) b.disabled = true;
|
||||
@@ -695,15 +890,17 @@ document.addEventListener("change", function(e){
|
||||
post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate});
|
||||
}
|
||||
if(t.matches('input.br')){
|
||||
var uid = parseInt(t.dataset.uid, 10);
|
||||
var row = t.closest(".row");
|
||||
var 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 danmu = row.querySelector('input[data-k=danmu]').checked;
|
||||
var record = row.querySelector('input[data-k=record]').checked;
|
||||
post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: v});
|
||||
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();
|
||||
@@ -1351,17 +1548,32 @@ func webUIAddr() string {
|
||||
return "127.0.0.1:" + itoa(config.WebPort)
|
||||
}
|
||||
|
||||
// 从请求里取管理口令:优先 Authorization: Bearer,其次 X-Auth-Token,最后 ?token=
|
||||
func authTokenFromRequest(r *http.Request) string {
|
||||
// 从请求里取管理口令的所有可能写法,按优先级排: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 ") {
|
||||
return strings.TrimSpace(h[7:])
|
||||
raw = append(raw, strings.TrimSpace(h[7:]))
|
||||
}
|
||||
}
|
||||
if t := strings.TrimSpace(r.Header.Get("X-Auth-Token")); t != "" {
|
||||
return t
|
||||
raw = append(raw, t)
|
||||
}
|
||||
return strings.TrimSpace(r.URL.Query().Get("token"))
|
||||
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
|
||||
}
|
||||
|
||||
// 校验请求里的管理口令,用固定时间比较避免通过响应时间猜口令
|
||||
@@ -1370,11 +1582,14 @@ func checkAuthToken(r *http.Request) bool {
|
||||
if want == "" {
|
||||
return false
|
||||
}
|
||||
got := authTokenFromRequest(r)
|
||||
if got == "" {
|
||||
return false
|
||||
// 逐个候选都比完再出结果,不提前返回,免得响应时间泄露是哪一种写法对上了
|
||||
ok := false
|
||||
for _, got := range authTokenCandidates(r) {
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 {
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
|
||||
return ok
|
||||
}
|
||||
|
||||
// 口令错误次数的记录,给反复尝试的请求加一点延迟
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
// web UI 相关
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 测试期间换一个管理口令
|
||||
func withAuthToken(t *testing.T, token string) {
|
||||
t.Helper()
|
||||
streamers.Lock()
|
||||
old := config.AuthToken
|
||||
config.AuthToken = token
|
||||
streamers.Unlock()
|
||||
t.Cleanup(func() {
|
||||
streamers.Lock()
|
||||
config.AuthToken = old
|
||||
streamers.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
// 页面上发给服务端的 X-Auth-Token 是 encodeURIComponent 过的口令,
|
||||
// 这里用 url.QueryEscape 造出同样的形状(对中文而言两者产出一致)
|
||||
func pageHeader(r *http.Request, token string) *http.Request {
|
||||
r.Header.Set("X-Auth-Token", url.QueryEscape(token))
|
||||
return r
|
||||
}
|
||||
|
||||
func TestCheckAuthTokenPlainASCII(t *testing.T) {
|
||||
withAuthToken(t, "a1b2c3d4e5f6")
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||
r.Header.Set("X-Auth-Token", "a1b2c3d4e5f6")
|
||||
if !checkAuthToken(r) {
|
||||
t.Fatal("ASCII 口令应该通过")
|
||||
}
|
||||
|
||||
// 带 % 的口令:页面会编码成 %25,服务端解码后要能对上
|
||||
withAuthToken(t, "ab%20cd")
|
||||
r = pageHeader(httptest.NewRequest(http.MethodGet, "/api/status", nil), "ab%20cd")
|
||||
if r.Header.Get("X-Auth-Token") != "ab%2520cd" {
|
||||
t.Fatalf("编码结果不对:%q", r.Header.Get("X-Auth-Token"))
|
||||
}
|
||||
if !checkAuthToken(r) {
|
||||
t.Fatal("含 % 的口令应该通过")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAuthTokenNonASCII(t *testing.T) {
|
||||
// 中文口令是最容易踩的:原先直接把中文塞进请求头,浏览器会抛
|
||||
// TypeError(Failed to read the 'headers' property…),请求根本发不出去,
|
||||
// 页面上只显示「无法连接到服务器」,用户被彻底锁在门外
|
||||
withAuthToken(t, "我的口令abc123")
|
||||
|
||||
r := pageHeader(httptest.NewRequest(http.MethodGet, "/api/status", nil), "我的口令abc123")
|
||||
if r.Header.Get("X-Auth-Token") == "我的口令abc123" {
|
||||
t.Fatal("测试前提不成立:头里不该是原文")
|
||||
}
|
||||
if !checkAuthToken(r) {
|
||||
t.Fatal("编码后的中文口令应该通过")
|
||||
}
|
||||
|
||||
// 手写 curl 按文档原样发中文口令,也要能通过
|
||||
r = httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||
r.Header.Set("X-Auth-Token", "我的口令abc123")
|
||||
if !checkAuthToken(r) {
|
||||
t.Fatal("原样的中文口令也应该通过")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAuthTokenAllSources(t *testing.T) {
|
||||
withAuthToken(t, "口令abc123")
|
||||
|
||||
// Authorization: Bearer
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+url.QueryEscape("口令abc123"))
|
||||
if !checkAuthToken(r) {
|
||||
t.Fatal("Bearer 写法应该通过")
|
||||
}
|
||||
|
||||
// ?token=(net/http 已经把查询串解码好了)
|
||||
r = httptest.NewRequest(http.MethodGet, "/api/status?token="+url.QueryEscape("口令abc123"), nil)
|
||||
if !checkAuthToken(r) {
|
||||
t.Fatal("查询参数写法应该通过")
|
||||
}
|
||||
|
||||
// 下载链接里的 ?token= 也是这个路径,混着 % 的口令同样要对上
|
||||
withAuthToken(t, "口令%20abc")
|
||||
r = httptest.NewRequest(http.MethodGet, "/api/file?token="+url.QueryEscape("口令%20abc"), nil)
|
||||
if !checkAuthToken(r) {
|
||||
t.Fatal("查询参数里的混合口令应该通过")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAuthTokenRejects(t *testing.T) {
|
||||
withAuthToken(t, "正确的口令")
|
||||
|
||||
cases := map[string]*http.Request{
|
||||
"口令错了": func() *http.Request {
|
||||
return pageHeader(httptest.NewRequest(http.MethodGet, "/api/status", nil), "错误的口令")
|
||||
}(),
|
||||
"没带口令": httptest.NewRequest(http.MethodGet, "/api/status", nil),
|
||||
"空口令头": func() *http.Request {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||
r.Header.Set("X-Auth-Token", "")
|
||||
return r
|
||||
}(),
|
||||
"编码了但不是口令": func() *http.Request {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||
r.Header.Set("X-Auth-Token", "%E5%8F%A3%E4%BB%A4")
|
||||
return r
|
||||
}(),
|
||||
}
|
||||
for name, r := range cases {
|
||||
if checkAuthToken(r) {
|
||||
t.Fatalf("%s:不该通过", name)
|
||||
}
|
||||
}
|
||||
|
||||
// 口令没配时一律拒绝,不能因为「都是空」就放行
|
||||
withAuthToken(t, "")
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||
r.Header.Set("X-Auth-Token", "")
|
||||
if checkAuthToken(r) {
|
||||
t.Fatal("口令为空时不该通过")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthTokenCandidatesOrder(t *testing.T) {
|
||||
// 三种写法同时出现时按 Bearer → X-Auth-Token → ?token= 的顺序给候选,
|
||||
// 顺序本身不影响结果,但别写出重复项(重复比较纯属浪费)
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/status?token=qqq", nil)
|
||||
r.Header.Set("Authorization", "Bearer aaa")
|
||||
r.Header.Set("X-Auth-Token", "bbb")
|
||||
got := authTokenCandidates(r)
|
||||
want := []string{"aaa", "bbb", "qqq"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("候选数量不对:%q", got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("候选顺序不对:%q,想要 %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Authorization 不是 Bearer 前缀时不算数
|
||||
r = httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||
r.Header.Set("Authorization", "Basic zzz")
|
||||
r.Header.Set("X-Auth-Token", "bbb")
|
||||
got = authTokenCandidates(r)
|
||||
if len(got) != 1 || got[0] != "bbb" {
|
||||
t.Fatalf("非 Bearer 的 Authorization 不该当候选:%q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user