用户要求两件事:① QQ bot 推送要能测连通、并加一个开关;② 照 MeoW 的接口 文档(https://www.chuckfang.com/MeoW/api_doc.html)加一个推送渠道,同样要有 测试和开关。确认过的三个决定:QQ bot 就是现有 webhook;事件勾选两个渠道 共用、开关按渠道分开;MeoW 只填一个昵称。 1) 通知推送改成「共享事件勾选 + 每渠道一个开关」 - notify.go: notifySetting 扩成两个渠道,新增 qqReady()/meowReady(); sendNotify 按渠道各自起后台 goroutine 分发,事件没勾选、或两个渠道都 没启用时什么都不做 - config.go: 新增 notifyEnabled(QQ 机器人渠道开关)、meowEnabled、 meowNickname、meowMsgType、meowHtmlHeight、meowImgUrl - 关掉开关只是暂停推送,地址、昵称这些配置都留着,随时能再打开 2) 老配置的兼容(重要) - notifyEnabled 是普通 bool,老 config.json 里没有这个字段会解成 false, 升级后会把已经配好的推送静默关掉 - 解法:loadConfig() 里先置 true 再 json.Unmarshal(Unmarshal 只覆盖文件里 出现的字段),默认值里也写 true;文件里显式写 false 的仍然会关掉 - 已加回归测试 TestNotifyEnabledBackwardCompat 覆盖三种写法 3) MeoW 渠道(新增 meow.go) - 按文档走 POST {昵称},title/msg/url/imgUrl 放 json body, msgType/htmlHeight 是 query 参数(文档明确说这两个不属于 JSON 参数) - 文档自己前后矛盾:响应格式那节写 msg,示例写 message,两个字段都解析 - 空字段一律不放进 body:body 里的 title 优先级高于路径参数, 传空串会把 MeoW 的默认标题顶掉 - 业务状态码翻成中文(400 参数错、403 内容被拦、404 昵称未注册、 429 频控、500 服务器错误),失败信息直接显示在页面上 - msgType 为 html 时渲染成 HTML(首行加粗、链接可点、内容转义), 高度默认 200、范围 40-2000;事件正文复用 webhook 的那份 text 4) MeoW 的错误昵称会封 IP,所以加了保护(踩出来的) - 实测用不存在的昵称打真实接口,返回的提示是「一天之内累计3次输错对方 昵称您的IP将被封禁」。而开播+开始录制+下播+合并完成一天轻松触发 4 次, 昵称配错就会把用户 IP 封了 - meow.go: 新增 meowBadNick 记录,pushMeow 收到 404 就记下这个昵称; sendNotify 和测试接口命中记录时直接跳过/拒绝并说明原因, 改掉昵称或重启程序即恢复 - 只有 404 触发拦截:429 是频控、400 是参数问题,都应该继续尝试 - 已加测试 TestMeowBadNickGuard(404 要拦、429/400 不能拦、不影响别的昵称) 5) 测试连通 - /api/notify/test 合并成一个接口,用 channel 区分 qq / meow, 每个渠道的配置下面各有一个「测试连通」按钮 - 用页面上当前填的值直接测、不用先保存,留空的项自动用已保存的值补齐; 测试不受渠道开关和事件勾选影响(本来就是为了打开之前先确认配置) - 成功返回 {ok, detail},detail 就是页面上的结论:QQ 渠道显示 webhook 的 HTTP 状态码和耗时,MeoW 渠道显示它返回的 status 和耗时; webhook 返回 200 只代表地址口令没问题,能不能收到要看机器人那边, 这点也写在结果里 - /api/status 新增 meowBlocked,页面上红字显示「当前推不出去:…」 (昵称没填、格式不对、或已被判过未注册) 6) 页面 - 通知区分成「事件勾选 / 渠道一 QQ 机器人 / 渠道二 MeoW」三块, 每块用一条带标题的细线隔开,两个渠道各带开关和测试按钮 - 消息类型选 text 时自动收起 HTML 高度那一行 另外:README 的通知推送一节重写(两个渠道的对比表、MeoW 的频控与封禁提醒、 测试连通的说明),配置文件详解补上新字段;doc/webapi.md 补 /api/notify/test 和 meowBlocked。doc/momoko-acfunlive-notify.py 本来就处理 event="test",不用改。 已验证:新增 notify_test.go,15 个用例全部通过(URL 拼接与转义、昵称校验、 消息类型与高度归一、HTML 渲染与转义、接口成功/失败/空字段处理、6 种业务状态码 的文案、404 拦截保护、老配置兼容、测试接口各分支),其中 TestSendNotifyFanOut 是端到端分发测试:两个渠道都开→各收到 1 条、关掉任一个→只剩另一个、 两个都关→0 条、事件没勾→0 条。真机冒烟两轮:页面控件齐全、老配置默认开启、 QQ 渠道实测本地 webhook 收到完整推送(含 Authorization 和 X-Auth-Token 头与群号)、 空值回退到已保存配置、6 种非法输入全 400、开关能落盘且关开关不清地址、 MeoW 真实接口返回的 404 被正确翻成中文。全量 34 个测试通过, Windows/Linux 双平台构建、go vet、gofmt 全干净。
1530 lines
66 KiB
Go
1530 lines
66 KiB
Go
// web UI 相关
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"crypto/subtle"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net"
|
||
"net/http"
|
||
"os"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// 储存日志
|
||
var logString struct {
|
||
sync.Mutex
|
||
str strings.Builder
|
||
total int64 // 从启动到现在写入的总字节数,用于按偏移量增量读取日志
|
||
}
|
||
|
||
const webuiHTML = `<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>AcFun 直播录制</title>
|
||
<style>
|
||
:root{--line:#e8eaed;--soft:#f0f1f3;--sub:#8a8f98;--red:#fd4c5d}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;background:#f6f7f8;color:#222;font:14px/1.5 system-ui,-apple-system,"Microsoft YaHei",sans-serif;-webkit-text-size-adjust:100%}
|
||
.wrap{max-width:1000px;margin:0 auto;padding:14px 12px 28px}
|
||
h1{font-size:16px;font-weight:600;margin:0 0 2px}
|
||
.head-sub{font-size:12px;color:var(--sub);margin-bottom:12px}
|
||
.card{background:#fff;border:1px solid var(--line);border-radius:6px;padding:12px 14px;margin-bottom:10px}
|
||
.card h2{font-size:13px;font-weight:600;margin:0 0 10px;color:#333}
|
||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:10px 14px}
|
||
.f label{display:block;font-size:12px;color:var(--sub);margin-bottom:4px}
|
||
input[type=text],select,textarea{width:100%;border:1px solid #dcdfe4;border-radius:4px;padding:7px 9px;font:inherit;background:#fff;color:#222}
|
||
input[type=text]:focus,select:focus,textarea:focus{outline:none;border-color:var(--red)}
|
||
textarea{resize:vertical;line-height:1.5;font-size:13px}
|
||
select[multiple]{padding:4px}
|
||
#mrgInfo{white-space:pre-wrap;margin-top:8px}
|
||
#mrgInfo:empty{display:none}
|
||
input.br{width:88px;text-align:center}
|
||
.chk{display:inline-flex;align-items:center;gap:5px;margin:0 14px 0 0;font-size:13px;color:#333;cursor:pointer}
|
||
button{border:none;border-radius:4px;padding:8px 16px;font:inherit;cursor:pointer;background:var(--red);color:#fff}
|
||
button.ghost,a.ghost{background:#f1f2f4;color:#555;padding:6px 10px;font-size:12px;text-decoration:none;display:inline-block}
|
||
button:active,a.ghost:active{opacity:.8}
|
||
button:disabled{opacity:.5}
|
||
.hint{font-size:12px;color:var(--sub);margin-top:6px;line-height:1.6}
|
||
.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}
|
||
.uid{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}
|
||
.name{font-weight:600;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.title{flex:1 1 200px;min-width:0;font-size:12px;color:var(--sub);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.ops{margin-left:auto;display:flex;gap:6px;flex-wrap:wrap}
|
||
.badge{display:inline-block;padding:1px 7px;border-radius:3px;font-size:12px;margin-right:4px}
|
||
.on{background:#ffe8e8;color:#d63046}
|
||
.rec{background:#e6f4ec;color:#1d7a46}
|
||
.off{font-size:12px;color:var(--sub)}
|
||
pre{background:#1e1e1e;color:#d6d6d6;border-radius:6px;padding:10px;font-size:12px;max-height:300px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0}
|
||
#msg{font-size:12px;margin-left:8px}
|
||
input[readonly]{background:#f6f7f8;color:#555}
|
||
.mask{position:fixed;left:0;top:0;right:0;bottom:0;background:rgba(20,22,26,.55);display:none;align-items:center;justify-content:center;z-index:9;padding:16px}
|
||
.mask.on{display:flex}
|
||
.mask .box{background:#fff;border-radius:8px;padding:18px;width:100%;max-width:380px}
|
||
.mask h3{margin:0 0 6px;font-size:15px}
|
||
.mask p{margin:0 0 12px;font-size:12px;color:var(--sub);line-height:1.6}
|
||
#authMsg{font-size:12px;color:#d63046;min-height:17px;margin-top:8px}
|
||
@media (max-width:600px){
|
||
.name{max-width:none}
|
||
.title{flex:1 1 100%;order:8}
|
||
.ops{width:100%;margin-left:0;order:9}
|
||
.ops button,.ops a{flex:1 1 0;padding:8px 4px;text-align:center}
|
||
button.ghost,.mask button{padding:8px 10px;font-size:13px}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<h1>AcFun 直播录制</h1>
|
||
<div class="head-sub">弹幕 / 视频录制,直播结束后自动合并</div>
|
||
<div class="card">
|
||
<h2>设置</h2>
|
||
<div class="f"><label>文件保存目录(留空 = 程序所在目录)</label><input type="text" id="directory" placeholder="例如 D:\record"></div>
|
||
<div class="hr"></div>
|
||
<div>
|
||
<label class="chk"><input type="checkbox" id="autoMerge">直播结束后自动合并</label>
|
||
<label class="chk"><input type="checkbox" id="mergeDelVideo">合并后删除源视频</label>
|
||
<label class="chk"><input type="checkbox" id="mergeDelDanmu">合并后删除弹幕</label>
|
||
</div>
|
||
<div class="hint">三项互不影响,可以同时勾选;需该主播同时开启弹幕和录播且都录到直播结束,手动停止或重新开始不会触发合并</div>
|
||
<div class="grid" style="margin-top:10px">
|
||
<div class="f"><label>成片移动到(留空 = 保存目录下的成片文件夹)</label><input type="text" id="mergeOutputDir" placeholder="例如 D:\merged"></div>
|
||
<div class="f"><label>合成码率(Kbps,0 = 默认质量)</label><input type="text" id="mergeBitrate" inputmode="numeric" placeholder="例如 1500"></div>
|
||
<div class="f"><label>默认下载码率上限(Kbps,0 = 最高档)</label><input type="text" id="defaultBitrate" inputmode="numeric" placeholder="例如 2000"></div>
|
||
<div class="f"><label>录播/弹幕子文件夹</label><input type="text" id="recordSubdir" placeholder="record"></div>
|
||
<div class="f"><label>成片子文件夹</label><input type="text" id="mergeSubdir" placeholder="merged"></div>
|
||
<div class="f"><label>ffmpeg 路径(留空自动查找)</label><input type="text" id="ffmpegPath" placeholder="例如 D:\ffmpeg\bin\ffmpeg.exe"></div>
|
||
<div class="f"><label>录播文件格式</label><select id="recordFormat">
|
||
<option value="mp4">mp4(推荐,最通用)</option>
|
||
<option value="ts">ts(强杀后还能播已录部分)</option>
|
||
<option value="flv">flv(兼容性一般)</option>
|
||
<option value="mkv">mkv(可装多音轨)</option>
|
||
</select></div>
|
||
</div>
|
||
<div class="hint">录播和弹幕合并都要用 ffmpeg:留空时先查 PATH,再查程序所在目录及其 bin、ffmpeg 子目录</div>
|
||
<div class="hint">录制是 -c copy 直接封装,不转码,四种格式画质完全一样,只是容器不同。正常停止(停止按钮、Ctrl+C)都没问题;如果进程被强杀(任务管理器结束进程、断电),mp4 / flv / mkv 都会整个报废,实测只有 ts 还能播出已经录到的部分。合并出来的成片始终是 mp4。</div>
|
||
<div class="hint">同一主播下播后 20 秒内又上播会当成网络波动,接着录同一场,最终只合成一个成片。</div>
|
||
<div class="hr"></div>
|
||
<div class="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>
|
||
</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>
|
||
|
||
<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>
|
||
</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>
|
||
<div style="margin-top:10px">
|
||
<label class="chk"><input type="checkbox" id="webExternal">允许外网访问(关闭时只有本机能打开本页)</label>
|
||
</div>
|
||
<div class="hint">开启后监听所有网卡,同一局域网的设备可用本机 IP 访问;修改后 web 服务会自动重启,页面会自动重连</div>
|
||
<div class="hr"></div>
|
||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:8px">
|
||
<button onclick="saveSettings(this)">保存设置</button><span id="msg"></span>
|
||
</div>
|
||
<div class="hint">勾选类设置修改后立即保存,文本框内容点「保存设置」生效</div>
|
||
<div class="hr"></div>
|
||
<div class="f"><label>管理口令(打开本页和管理录制都需要,建议复制保存到别处)</label>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||
<input type="text" id="authTokenView" readonly style="flex:1 1 200px">
|
||
<button class="ghost" onclick="copyToken()">复制</button>
|
||
</div>
|
||
</div>
|
||
<div class="f" style="margin-top:8px"><label>更换管理口令(至少 8 个字符)</label>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||
<input type="text" id="authTokenNew" autocomplete="off" placeholder="输入新的管理口令" style="flex:1 1 200px">
|
||
<button class="ghost" onclick="changeToken()">更换</button>
|
||
</div>
|
||
</div>
|
||
<div class="hint">忘记口令时可以打开程序所在目录的 config.json,查看 authToken 字段</div>
|
||
<div class="hr"></div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
|
||
<input type="text" id="newuid" inputmode="numeric" placeholder="新增主播 uid,例如 23682490" style="max-width:230px">
|
||
<button class="ghost" onclick="addUid()">添加(弹幕+录播)</button>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h2>主播列表(勾选 = 开启对应录制,修改立即生效)</h2>
|
||
<div class="hint" style="margin:0 0 8px">下载码率上限 = 挑选码率不超过该值的直播源档位(2000 约超清档),0 = 最高档;正在录制时修改会自动换档重新开始</div>
|
||
<div id="st"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h2>文件管理</h2>
|
||
<div class="hint" id="diskInfo" style="margin:0 0 8px">点击下面的按钮读取文件和磁盘信息</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
|
||
<button class="ghost" onclick="loadFiles(this)">刷新文件列表</button>
|
||
<span class="hint" style="margin:0" id="filesInfo"></span>
|
||
</div>
|
||
<div id="files" style="margin-top:6px"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h2>手动合并</h2>
|
||
<div class="hint" style="margin:0 0 8px">自动合并只在录制自然结束时触发(手动停止过、弹幕和视频对不上号、或者把文件挪过位置的都不会自动合并),这些可以在这里自己挑文件合成。视频一行一个,填多个会按文件名里的录制时间顺序拼成一个再合成。合并进度见下方运行日志。</div>
|
||
<div class="grid">
|
||
<div class="f">
|
||
<label>视频文件(一行一个,也可填绝对路径)</label>
|
||
<textarea id="mrgVideos" rows="3" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.mp4"></textarea>
|
||
</div>
|
||
<div class="f">
|
||
<label>弹幕文件(.jsonl 直接用,.ass 会先还原成 .jsonl)</label>
|
||
<input type="text" id="mrgDanmu" list="mrgDanmuList" autocomplete="off" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.jsonl">
|
||
<datalist id="mrgDanmuList"></datalist>
|
||
</div>
|
||
</div>
|
||
<div class="grid" style="margin-top:10px">
|
||
<div class="f">
|
||
<label>从文件列表选视频(可多选,按 Ctrl 点选)</label>
|
||
<select id="mrgVideoPick" multiple size="4" onchange="pickVideos()"></select>
|
||
</div>
|
||
<div class="f">
|
||
<label>偏移(秒,留空 = 自动推算)</label>
|
||
<input type="text" id="mrgOffset" inputmode="decimal" autocomplete="off" placeholder="留空自动">
|
||
<label style="margin-top:8px">输出文件名(留空 = 自动命名,不覆盖已有成片)</label>
|
||
<input type="text" id="mrgOut" autocomplete="off" placeholder="留空自动">
|
||
</div>
|
||
</div>
|
||
<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px;align-items:center">
|
||
<button class="ghost" id="mrgCheck" onclick="checkMerge()">检查</button>
|
||
<button id="mrgGo" onclick="startMerge()">开始合并</button>
|
||
<span class="hint" style="margin:0" id="mrgState"></span>
|
||
</div>
|
||
<div class="hint" id="mrgInfo"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h2>运行日志</h2>
|
||
<pre id="log"></pre>
|
||
</div>
|
||
</div>
|
||
<div class="mask" id="auth">
|
||
<div class="box">
|
||
<h3>请输入管理口令</h3>
|
||
<p>管理口令在程序首次启动时自动生成并打印在运行日志里,也可以在程序所在目录的 config.json 的 authToken 字段查看。</p>
|
||
<input type="text" id="tokenInput" autocomplete="off" placeholder="管理口令">
|
||
<div style="margin-top:10px"><button onclick="login()">进入</button></div>
|
||
<div id="authMsg"></div>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
var esc = function(s){ return (s||"").replace(/[&<>"]/g, function(c){ return {"&":"&","<":"<",">":">","\"":"""}[c]; }); };
|
||
var saving = false; // 正在保存设置时暂停定时刷新对表单的覆写
|
||
var stSig = ""; // 主播列表的数据摘要,没变化就不重建 DOM
|
||
var logOffset = "0"; // 已经读取到的日志偏移量
|
||
var token = localStorage.getItem("acfunlive_token") || ""; // 管理口令,存在浏览器本地
|
||
var authed = false; // 是否已通过口令校验,未通过时停止轮询
|
||
var tokenLoaded = false;
|
||
function setChk(id, v){ var el = document.getElementById(id); if(!saving && !el.matches(":focus")) el.checked = !!v; }
|
||
function setVal(id, v){ var el = document.getElementById(id); if(!saving && document.activeElement !== el) el.value = v; }
|
||
// 管理接口都要带上口令
|
||
function api(url, opt){
|
||
opt = opt || {};
|
||
var h = {};
|
||
var src = opt.headers || {};
|
||
for(var k in src){ h[k] = src[k]; }
|
||
h["X-Auth-Token"] = token;
|
||
opt.headers = h;
|
||
return fetch(url, opt);
|
||
}
|
||
function showLogin(text){
|
||
authed = false;
|
||
document.getElementById("auth").classList.add("on");
|
||
document.getElementById("authMsg").textContent = text || "";
|
||
var ti = document.getElementById("tokenInput");
|
||
if(!ti.value) ti.value = token;
|
||
}
|
||
function hideLogin(){
|
||
authed = true;
|
||
document.getElementById("auth").classList.remove("on");
|
||
document.getElementById("authMsg").textContent = "";
|
||
}
|
||
async function login(){
|
||
var m = document.getElementById("authMsg");
|
||
var t = document.getElementById("tokenInput").value.trim();
|
||
if(!t){ m.textContent = "请输入管理口令"; return; }
|
||
token = t;
|
||
authed = true;
|
||
var r = null;
|
||
try{ r = await api("/api/auth"); }catch(e){}
|
||
if(r && r.ok){
|
||
localStorage.setItem("acfunlive_token", token);
|
||
hideLogin();
|
||
tokenLoaded = false;
|
||
refresh();
|
||
}else{
|
||
authed = false;
|
||
m.textContent = r ? "管理口令不正确" : "无法连接到服务器";
|
||
}
|
||
}
|
||
function copyToken(){
|
||
var el = document.getElementById("authTokenView");
|
||
if(!el.value) return;
|
||
el.removeAttribute("readonly");
|
||
el.select();
|
||
el.setSelectionRange(0, 999);
|
||
var ok = false;
|
||
try{ ok = document.execCommand("copy"); }catch(e){}
|
||
el.setAttribute("readonly", "readonly");
|
||
// 局域网 http 下 navigator.clipboard 不可用,上面的 execCommand 才是主要手段
|
||
if(window.isSecureContext && navigator.clipboard){
|
||
try{ navigator.clipboard.writeText(el.value); ok = true; }catch(e){}
|
||
}
|
||
showMsg(ok, ok ? "已复制管理口令" : "自动复制失败,请手动选中复制");
|
||
}
|
||
async function changeToken(){
|
||
var t = document.getElementById("authTokenNew").value.trim();
|
||
if(t.length < 8){ showMsg(false, "管理口令至少 8 个字符"); return; }
|
||
var r = null;
|
||
try{
|
||
r = await api("/api/auth/token", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({token: t})});
|
||
}catch(e){}
|
||
if(!r || !r.ok){ showMsg(false, r ? await r.text() : "连接失败"); return; }
|
||
token = t;
|
||
localStorage.setItem("acfunlive_token", token);
|
||
document.getElementById("authTokenView").value = t;
|
||
document.getElementById("authTokenNew").value = "";
|
||
hideLogin();
|
||
showMsg(true, "管理口令已更换,本机已自动更新");
|
||
refresh();
|
||
}
|
||
async function refresh(){
|
||
if(!authed) return;
|
||
try{
|
||
var r = await api("/api/status");
|
||
if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
|
||
if(!r.ok) return;
|
||
var st = await r.json();
|
||
hideLogin();
|
||
// 口令只在登录后拉取一次,填进设置区的只读框
|
||
if(!tokenLoaded){
|
||
tokenLoaded = true;
|
||
api("/api/auth/token").then(function(x){ return x.ok ? x.json() : null; }).then(function(d){
|
||
if(d && d.token) document.getElementById("authTokenView").value = d.token;
|
||
}).catch(function(){});
|
||
// 顺便读一次文件列表,不用等用户点刷新
|
||
loadFiles();
|
||
}
|
||
setVal("directory", st.directory||"");
|
||
setVal("mergeOutputDir", st.mergeOutputDir||"");
|
||
setVal("mergeBitrate", st.mergeBitrate||0);
|
||
setVal("defaultBitrate", st.defaultBitrate||0);
|
||
setVal("recordSubdir", st.recordSubdir||"record");
|
||
setVal("mergeSubdir", st.mergeSubdir||"merged");
|
||
setVal("ffmpegPath", st.ffmpegPath||"");
|
||
setVal("recordFormat", st.recordFormat||"mp4");
|
||
setVal("notifyUrl", st.notifyUrl||"");
|
||
setVal("notifyToken", st.notifyToken||"");
|
||
setVal("notifyGroup", st.notifyGroup||"");
|
||
setChk("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 mb.style.display = "none";
|
||
setChk("webExternal", st.webExternal);
|
||
setChk("autoMerge", st.autoMerge);
|
||
setChk("mergeDelVideo", st.mergeDelVideo);
|
||
setChk("mergeDelDanmu", st.mergeDelDanmu);
|
||
syncMergeState(st);
|
||
var rows = (st.streamers||[]).map(function(s){
|
||
var badges = "";
|
||
if(s.recordActive) badges += '<span class="badge on">录播中</span>';
|
||
if(s.danmuActive) badges += '<span class="badge rec">弹幕中</span>';
|
||
if(!badges) badges = s.live ? '<span class="badge on">直播中</span>' : '<span class="off">离线</span>';
|
||
var title = esc(s.title);
|
||
return '<div class="row">'
|
||
+ '<span class="uid">uid ' + s.uid + '</span>'
|
||
+ '<span class="name">' + (esc(s.name) || "uid " + s.uid) + '</span>'
|
||
+ '<label class="chk"><input type="checkbox" data-uid="' + s.uid + '" data-k="danmu"' + (s.danmu?" checked":"") + '>弹幕</label>'
|
||
+ '<label class="chk"><input type="checkbox" data-uid="' + s.uid + '" data-k="record"' + (s.record?" checked":"") + '>录播</label>'
|
||
+ '<input type="text" class="br" inputmode="numeric" data-uid="' + s.uid + '" value="' + (s.bitrate||0) + '" placeholder="0=最高" title="下载码率上限(Kbps),0 = 最高档">'
|
||
+ '<span class="state">' + badges + '</span>'
|
||
+ '<span class="title"' + (title?' title="' + title + '"':'') + '>' + title + '</span>'
|
||
+ '<span class="ops">'
|
||
+ '<button class="ghost" onclick="ctl(' + s.uid + ',\'stop\')">停止</button>'
|
||
+ '<button class="ghost" onclick="ctl(' + s.uid + ',\'restart\')">重新开始</button>'
|
||
+ '<button class="ghost" onclick="delUid(' + s.uid + ')">删除</button></span></div>';
|
||
}).join("");
|
||
var list = document.getElementById("st");
|
||
var sig = JSON.stringify(st.streamers);
|
||
// 数据没变化不重建;正在列表里输入时也跳过,避免把没提交的输入清掉
|
||
if(sig !== stSig && !list.contains(document.activeElement)){
|
||
stSig = sig;
|
||
list.innerHTML = rows || '<div class="off">列表为空,请在上方添加 uid</div>';
|
||
}
|
||
var lr = await api("/api/log?since=" + logOffset);
|
||
if(lr.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
|
||
var off = lr.headers.get("X-Log-Offset");
|
||
var chunk = await lr.text();
|
||
if(off) logOffset = off;
|
||
if(chunk){
|
||
var p = document.getElementById("log");
|
||
var stick = p.scrollHeight - p.scrollTop - p.clientHeight < 40;
|
||
p.textContent += chunk;
|
||
// 页面里保留的日志太长会拖慢浏览器,只留末尾一段
|
||
if(p.textContent.length > 300000) p.textContent = p.textContent.slice(-150000);
|
||
if(stick) p.scrollTop = p.scrollHeight;
|
||
}
|
||
}catch(e){}
|
||
}
|
||
function showMsg(ok, text){
|
||
var m = document.getElementById("msg");
|
||
m.textContent = text; m.style.color = ok ? "#1d7a46" : "#d63046";
|
||
setTimeout(function(){ m.textContent = ""; }, 3000);
|
||
}
|
||
async function post(url, body){
|
||
saving = true;
|
||
try{
|
||
var r = await api(url, {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)});
|
||
if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return false; }
|
||
if(!r.ok) showMsg(false, await r.text());
|
||
return r.ok;
|
||
}finally{
|
||
saving = false;
|
||
refresh();
|
||
// 保存目录、子文件名可能刚被改过,顺手刷新一遍文件列表
|
||
loadFiles();
|
||
}
|
||
}
|
||
async function saveSettings(b){
|
||
if(b) b.disabled = true;
|
||
var ok = await post("/api/config", {
|
||
directory: document.getElementById("directory").value.trim(),
|
||
autoMerge: document.getElementById("autoMerge").checked,
|
||
mergeDelVideo: document.getElementById("mergeDelVideo").checked,
|
||
mergeDelDanmu: document.getElementById("mergeDelDanmu").checked,
|
||
mergeOutputDir: document.getElementById("mergeOutputDir").value.trim(),
|
||
mergeBitrate: parseInt(document.getElementById("mergeBitrate").value, 10) || 0,
|
||
defaultBitrate: parseInt(document.getElementById("defaultBitrate").value, 10) || 0,
|
||
recordSubdir: document.getElementById("recordSubdir").value.trim(),
|
||
mergeSubdir: document.getElementById("mergeSubdir").value.trim(),
|
||
ffmpegPath: document.getElementById("ffmpegPath").value.trim(),
|
||
recordFormat: document.getElementById("recordFormat").value,
|
||
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 uid = parseInt(t.dataset.uid, 10);
|
||
var row = t.closest(".row");
|
||
var v = parseInt(t.value.trim(), 10);
|
||
if(isNaN(v) || v < 0){ showMsg(false, "码率必须是大于等于 0 的数字"); refresh(); return; }
|
||
var danmu = row.querySelector('input[data-k=danmu]').checked;
|
||
var record = row.querySelector('input[data-k=record]').checked;
|
||
post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: v});
|
||
}
|
||
});
|
||
// 先试一次(用本地保存的口令),失败会自动弹出输入框并停止轮询
|
||
authed = true;
|
||
refresh();
|
||
setInterval(refresh, 3000);
|
||
document.getElementById("tokenInput").addEventListener("keydown", function(e){ if(e.key === "Enter") login(); });
|
||
</script>
|
||
</body>
|
||
</html>`
|
||
|
||
// web UI 的主播状态
|
||
type webStreamer struct {
|
||
UID int `json:"uid"`
|
||
Name string `json:"name"`
|
||
Live bool `json:"live"`
|
||
Danmu bool `json:"danmu"` // 弹幕录制开关
|
||
Record bool `json:"record"` // 视频录制开关
|
||
Bitrate int `json:"bitrate"` // 录制的直播源最高码率(Kbps),0 表示码率最高的直播源
|
||
DanmuActive bool `json:"danmuActive"` // 是否正在录制弹幕
|
||
RecordActive bool `json:"recordActive"` // 是否正在录制视频
|
||
Title string `json:"title"`
|
||
URL string `json:"url"`
|
||
}
|
||
|
||
// web UI 的状态数据
|
||
type webStatus struct {
|
||
UIDs []uidSetting `json:"uids"`
|
||
Directory string `json:"directory"`
|
||
AutoMerge bool `json:"autoMerge"`
|
||
MergeDelVideo bool `json:"mergeDelVideo"`
|
||
MergeDelDanmu bool `json:"mergeDelDanmu"`
|
||
MergeOutputDir string `json:"mergeOutputDir"`
|
||
MergeBitrate int `json:"mergeBitrate"`
|
||
DefaultBitrate int `json:"defaultBitrate"`
|
||
WebExternal bool `json:"webExternal"`
|
||
RecordSubdir string `json:"recordSubdir"`
|
||
MergeSubdir string `json:"mergeSubdir"`
|
||
FFmpegPath string `json:"ffmpegPath"`
|
||
RecordFormat string `json:"recordFormat"`
|
||
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=
|
||
func authTokenFromRequest(r *http.Request) string {
|
||
if h := strings.TrimSpace(r.Header.Get("Authorization")); h != "" {
|
||
if len(h) > 7 && strings.EqualFold(h[:7], "Bearer ") {
|
||
return strings.TrimSpace(h[7:])
|
||
}
|
||
}
|
||
if t := strings.TrimSpace(r.Header.Get("X-Auth-Token")); t != "" {
|
||
return t
|
||
}
|
||
return strings.TrimSpace(r.URL.Query().Get("token"))
|
||
}
|
||
|
||
// 校验请求里的管理口令,用固定时间比较避免通过响应时间猜口令
|
||
func checkAuthToken(r *http.Request) bool {
|
||
want := currentAuthToken()
|
||
if want == "" {
|
||
return false
|
||
}
|
||
got := authTokenFromRequest(r)
|
||
if got == "" {
|
||
return false
|
||
}
|
||
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
|
||
}
|
||
|
||
// 口令错误次数的记录,给反复尝试的请求加一点延迟
|
||
var authFail struct {
|
||
sync.Mutex
|
||
m map[string]authFailEntry
|
||
}
|
||
|
||
type authFailEntry struct {
|
||
count int
|
||
last time.Time
|
||
}
|
||
|
||
// 口令错误时最多延迟这么久,避免拖垮服务器
|
||
const authMaxDelay = 2 * time.Second
|
||
|
||
// 取请求的来源 IP
|
||
func clientIP(r *http.Request) string {
|
||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||
return host
|
||
}
|
||
return r.RemoteAddr
|
||
}
|
||
|
||
// 记录一次口令错误并返回应该延迟的时长;口令正确时调用 authFailClear 清掉记录
|
||
func authFailDelay(ip string) time.Duration {
|
||
authFail.Lock()
|
||
defer authFail.Unlock()
|
||
if authFail.m == nil {
|
||
authFail.m = make(map[string]authFailEntry)
|
||
}
|
||
// 记录太多时清掉过期的,避免大量不同来源的请求把内存撑满
|
||
if len(authFail.m) > 1000 {
|
||
for k, v := range authFail.m {
|
||
if time.Since(v.last) > 10*time.Minute {
|
||
delete(authFail.m, k)
|
||
}
|
||
}
|
||
}
|
||
e := authFail.m[ip]
|
||
e.count++
|
||
e.last = time.Now()
|
||
authFail.m[ip] = e
|
||
d := time.Duration(e.count) * 100 * time.Millisecond
|
||
if d > authMaxDelay {
|
||
d = authMaxDelay
|
||
}
|
||
return d
|
||
}
|
||
|
||
// 口令正确时清掉错误记录
|
||
func authFailClear(ip string) {
|
||
authFail.Lock()
|
||
defer authFail.Unlock()
|
||
delete(authFail.m, ip)
|
||
}
|
||
|
||
// 管理接口的口令校验:页面本身可以匿名打开(前端拿到 401 后再提示输入口令),
|
||
// 所有 /api/ 接口都必须带上正确的口令
|
||
func webAuth(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if !strings.HasPrefix(r.URL.Path, "/api/") {
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
ip := clientIP(r)
|
||
if checkAuthToken(r) {
|
||
authFailClear(ip)
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
if d := authFailDelay(ip); d > 0 {
|
||
time.Sleep(d)
|
||
}
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.WriteHeader(http.StatusUnauthorized)
|
||
_, _ = w.Write([]byte(`{"error":"管理口令不正确,请在页面上重新输入"}`))
|
||
})
|
||
}
|
||
|
||
// 口令校验通过后才会走到这里,直接返回成功供前端登录使用
|
||
func authCheckHandler(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte("true"))
|
||
}
|
||
|
||
// 返回当前的管理口令,方便用户在设置区查看和复制
|
||
func authTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(map[string]string{"token": currentAuthToken()})
|
||
}
|
||
|
||
// 更换管理口令
|
||
func authTokenSetHandler(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
Token string `json:"token"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
token := strings.TrimSpace(req.Token)
|
||
if len([]rune(token)) < 8 {
|
||
http.Error(w, "管理口令至少需要 8 个字符", http.StatusBadRequest)
|
||
return
|
||
}
|
||
streamers.Lock()
|
||
config.AuthToken = token
|
||
streamers.Unlock()
|
||
saveConfig()
|
||
lPrintln("通过 web UI 更换了管理口令(authToken)")
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte("true"))
|
||
}
|
||
|
||
// 统一的响应头和请求体限制
|
||
func webMiddleware(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
h := w.Header()
|
||
h.Set("X-Content-Type-Options", "nosniff")
|
||
h.Set("X-Frame-Options", "DENY")
|
||
h.Set("Referrer-Policy", "no-referrer")
|
||
// 页面用的是内联脚本和样式,所以只放行内联
|
||
h.Set("Content-Security-Policy",
|
||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self'")
|
||
// 写接口的请求体都很小,限制大小避免被慢速大请求拖着
|
||
if r.Method == http.MethodPost {
|
||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
var (
|
||
webSrvMu sync.Mutex
|
||
webSrvCancel context.CancelFunc
|
||
)
|
||
|
||
// 切换外网访问后监听地址会变,需要重启 web UI 服务器
|
||
func restartWebUI() {
|
||
webSrvMu.Lock()
|
||
cancel := webSrvCancel
|
||
webSrvMu.Unlock()
|
||
if cancel != nil {
|
||
cancel()
|
||
}
|
||
go func() {
|
||
time.Sleep(500 * time.Millisecond)
|
||
startWebUI(mainCtx)
|
||
}()
|
||
}
|