1. 密集弹幕时头像偏离轨迹、弹幕突然加速、之后一段时间屏幕空白
分块渲染的固有问题,三个毛病叠在一起:
- writeChunkAss 把 \move 的起止时间截断到块边界(End=min(t0+10, chunk.end)),
但 x2 写死成 -Width —— 等于把 10 秒的路挤进可见的那零点几秒里,文字直接飞过去。
而头像 overlay 的 x 一直是「固定 /10 秒 + 真实 t0」,于是同一时刻文字已经走到
左边、头像还在半路,看起来就是「头像掉队(移动变慢)」。
- 块尾还会被单块上限收紧成「下一条弹幕出现的时间」,最极端时窗口只剩 0.01 秒。
- 每块只画「本块新出现」的弹幕,跨块的续画没有:块头屏幕上只剩刚出现的几条,
要 10 秒才重新填满 —— 就是「弹幕突然加速,然后一段时间屏幕上什么都没有」。
改法:文字层改成按绝对时间算轨迹(danmuTextX / danmuAvatarX / avatarXExpr 同源),
任何一块里的文字速度都等于真实速度;分块器改成「续画(起播时间在 start-10 ~ start)
+ 本块新出现的」,块尾取「下一条弹幕的起播时间」,这样每条弹幕只在唯一一块里算
新出现,显示期间跨过的后面几块把它接着画,一帧都不缺。
代价是每块要多画一倍多的弹幕,所以顺手把头像一个「绝对路径 + 每人一行 scale」改成
「预缩放 + 短相对路径」(prepareAvatarInputs / scaleAvatarFile),每条从实测 241
字符降到约 130,单块上限才能从 120 抬到 200(200 条 ≈ 26600,仍低于自检阈值 30000
和 Windows 的 32767)。
2. 合并失败没有任何通知
notify.go 新增 mergeFail 事件与 notifyMergeFailure(),接在 autoMerge() 和手动合并的
失败分支上。正常结束、文件里没有弹幕、以及用户自己点的停止都不算失败,不推 ——
最后一种推「合并失败」会误导。配置项 notifyMergeFail 默认开,且和 notifyEnabled 一样
在 Unmarshal 之前预置 true,免得老设置文件升级后被静默关掉。
3. 低帧率录播里的弹幕一格一格跳
新增设置项 mergeFps,默认 0 = 跟随源片(什么都不做)。只在目标高于源片帧率时才加
fps 滤镜,而且必须加在字幕/头像之前 —— 加在之后只是把已经画好的几帧复制一遍,
看起来还是卡。上限 240。
4. 顺带
- -danmuburn 命令行模式不走 initialize(),exeDir 从来没被赋值:头像缓存会落到
「当前工作目录/avatar_cache」,旧版还会把这个相对路径写进滤镜,而 ffmpeg 的工作
目录是弹幕目录,报「打不开 avatar_cache\xxx.png」整块失败。
- cleanTempFiles 见到目录就 continue,预缩放头像那个临时文件夹永远清不掉。
- command_len_test 不再用会过期的「每条 241 字符」估算,改成真跑一遍最坏情况;
探针的头像路径也改成真实的短相对路径(原来量的是早已不存在的长绝对路径)。
- 新增 TestWebuiSettingFieldsMatchInputs:把页面里 JS 的 settingFields/numberFields
抠出来,跟所有 input/select/textarea 的 id 比对,漏登记的设置项会被抓住。
验证
- gofmt / go vet / go test(174 个)/ go test -race / staticcheck / GOOS=linux go build 全过。
- 反向验证 32 项全过:把每个修复临时退回,对应测试确实失败。
- 真实片段实测(2023AcFun 晚会 120 秒高峰段,689 条弹幕,10 秒窗口内最多同时 140 条):
旧 6 块 / 绘制 689 次 / 54.1% 的弹幕文字被加速(中位 2.12x,最差 500x)/
54.2% 有轨迹缺口(中位缺 5.29 秒)/ 78.3s;
新 5 块 / 绘制 984 次 / 加速 0% / 缺口 0% / 98.8s(+26%)。
对照帧里旧版在块尾前 0.1 秒屏幕已经全空,块头后 0.4 秒仍然全空;新版一直是满屏。
- 输出帧率:15fps 源配 mergeFps=60,成片 60fps,1 秒内 60 帧哈希互不相同(不是复制帧);
默认设置下成片仍是 15fps。
770 lines
25 KiB
Go
770 lines
25 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// 把 MeoW 的接口地址临时换成本地测试服务器
|
||
func withMeowServer(t *testing.T, h http.HandlerFunc) *httptest.Server {
|
||
t.Helper()
|
||
srv := httptest.NewServer(h)
|
||
old := meowAPIBase
|
||
meowAPIBase = srv.URL + "/"
|
||
t.Cleanup(func() {
|
||
meowAPIBase = old
|
||
srv.Close()
|
||
})
|
||
return srv
|
||
}
|
||
|
||
func TestCheckMeowNickname(t *testing.T) {
|
||
cases := []struct {
|
||
nick string
|
||
allow bool
|
||
wantOK bool
|
||
}{
|
||
{"JohnDoe", false, true},
|
||
{"小小怪", false, true},
|
||
{" JohnDoe ", false, true}, // 前后空白会被去掉
|
||
{"", true, true}, // 允许留空
|
||
{"", false, false}, // 要推送时不能为空
|
||
{"John/Doe", false, false},
|
||
{`John\Doe`, false, false},
|
||
{"John?Doe", false, false},
|
||
{"John#Doe", false, false},
|
||
{strings.Repeat("a", 65), false, false},
|
||
{strings.Repeat("a", 64), false, true},
|
||
}
|
||
for _, c := range cases {
|
||
got := checkMeowNickname(c.nick, c.allow)
|
||
if (got == "") != c.wantOK {
|
||
t.Errorf("checkMeowNickname(%q, %v) = %q,期望通过=%v", c.nick, c.allow, got, c.wantOK)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestNormalizeMeowMsgTypeAndHeight(t *testing.T) {
|
||
for _, c := range []struct{ in, want string }{
|
||
{"", meowMsgTypeText},
|
||
{"text", meowMsgTypeText},
|
||
{"TEXT", meowMsgTypeText},
|
||
{" html ", meowMsgTypeHTML},
|
||
{"HTML", meowMsgTypeHTML},
|
||
{"markdown", meowMsgTypeText}, // 认不出来就回退
|
||
} {
|
||
if got := normalizeMeowMsgType(c.in); got != c.want {
|
||
t.Errorf("normalizeMeowMsgType(%q) = %q,期望 %q", c.in, got, c.want)
|
||
}
|
||
}
|
||
for _, c := range []struct{ in, want int }{
|
||
{0, defaultMeowHTMLHeight},
|
||
{-5, defaultMeowHTMLHeight},
|
||
{10, minMeowHTMLHeight},
|
||
{200, 200},
|
||
{99999, maxMeowHTMLHeight},
|
||
} {
|
||
if got := clampMeowHTMLHeight(c.in); got != c.want {
|
||
t.Errorf("clampMeowHTMLHeight(%d) = %d,期望 %d", c.in, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestMeowURL(t *testing.T) {
|
||
got := meowURL("小小怪", meowMsgTypeText, 200)
|
||
// text 类型不该带上 htmlHeight
|
||
if strings.Contains(got, "htmlHeight") {
|
||
t.Errorf("text 类型不该带 htmlHeight:%s", got)
|
||
}
|
||
if !strings.Contains(got, "msgType=text") {
|
||
t.Errorf("缺少 msgType:%s", got)
|
||
}
|
||
// 中文昵称必须转义,不能原样进地址
|
||
if strings.Contains(got, "小小怪") {
|
||
t.Errorf("昵称没做 URL 转义:%s", got)
|
||
}
|
||
if !strings.HasSuffix(got, "/%E5%B0%8F%E5%B0%8F%E6%80%AA?msgType=text") {
|
||
t.Errorf("昵称转义结果不对:%s", got)
|
||
}
|
||
|
||
got = meowURL("JohnDoe", meowMsgTypeHTML, 350)
|
||
if !strings.Contains(got, "msgType=html") || !strings.Contains(got, "htmlHeight=350") {
|
||
t.Errorf("html 类型的参数不对:%s", got)
|
||
}
|
||
// 超出范围的高度要夹住
|
||
if got := meowURL("JohnDoe", meowMsgTypeHTML, 99999); !strings.Contains(got, "htmlHeight=2000") {
|
||
t.Errorf("高度没夹住:%s", got)
|
||
}
|
||
}
|
||
|
||
func TestMeowHTMLText(t *testing.T) {
|
||
got := meowHTMLText("Xsphere 开播了\n今天画点啥\nhttps://live.acfun.cn/1\n<i>不该被解析</i>")
|
||
for _, want := range []string{
|
||
"<p><b>Xsphere 开播了</b></p>", // 第一行加粗
|
||
"<p>今天画点啥</p>", // 普通行
|
||
`<p><a href="https://live.acfun.cn/1">https://live.acfun.cn/1</a></p>`, // 链接可点
|
||
"<i>不该被解析</i>", // 内容要转义
|
||
} {
|
||
if !strings.Contains(got, want) {
|
||
t.Errorf("HTML 里缺少 %q,实际:%s", want, got)
|
||
}
|
||
}
|
||
if strings.Contains(got, "<i>") {
|
||
t.Errorf("内容没被转义,存在注入风险:%s", got)
|
||
}
|
||
if got := meowHTMLText(" "); got != "" {
|
||
t.Errorf("空内容应该得到空串,实际 %q", got)
|
||
}
|
||
}
|
||
|
||
// 成功路径:确认请求本身拼对了,并且能正确解析文档里的两种响应字段(message / msg)
|
||
func TestPushMeowSuccess(t *testing.T) {
|
||
var gotPath, gotQuery, gotMethod, gotType string
|
||
var gotBody map[string]string
|
||
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||
gotPath, gotQuery, gotMethod = r.URL.Path, r.URL.RawQuery, r.Method
|
||
gotType = r.Header.Get("Content-Type")
|
||
raw, _ := io.ReadAll(r.Body)
|
||
_ = json.Unmarshal(raw, &gotBody)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
|
||
})
|
||
|
||
err := pushMeow("小小怪", "开播提醒", "Xsphere 开播了", "https://live.acfun.cn/1", "https://x/icon.png",
|
||
meowMsgTypeHTML, 350)
|
||
if err != nil {
|
||
t.Fatalf("推送失败:%v", err)
|
||
}
|
||
if gotMethod != http.MethodPost {
|
||
t.Errorf("方法应该是 POST,实际 %s", gotMethod)
|
||
}
|
||
if gotPath != "/小小怪" {
|
||
t.Errorf("路径不对:%q", gotPath)
|
||
}
|
||
if !strings.Contains(gotQuery, "msgType=html") || !strings.Contains(gotQuery, "htmlHeight=350") {
|
||
t.Errorf("查询参数不对:%q", gotQuery)
|
||
}
|
||
if !strings.HasPrefix(gotType, "application/json") {
|
||
t.Errorf("Content-Type 不对:%q", gotType)
|
||
}
|
||
for k, want := range map[string]string{
|
||
"msg": "Xsphere 开播了",
|
||
"title": "开播提醒",
|
||
"url": "https://live.acfun.cn/1",
|
||
"imgUrl": "https://x/icon.png",
|
||
} {
|
||
if gotBody[k] != want {
|
||
t.Errorf("body 里的 %s = %q,期望 %q", k, gotBody[k], want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 空字段不能进 body:body 里的 title 优先级比路径参数高,传空串会把默认标题顶掉
|
||
func TestPushMeowOmitsEmptyFields(t *testing.T) {
|
||
var gotBody map[string]any
|
||
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||
raw, _ := io.ReadAll(r.Body)
|
||
_ = json.Unmarshal(raw, &gotBody)
|
||
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
|
||
})
|
||
|
||
if err := pushMeow("JohnDoe", "", "只有正文", "", "", meowMsgTypeText, 0); err != nil {
|
||
t.Fatalf("推送失败:%v", err)
|
||
}
|
||
if len(gotBody) != 1 || gotBody["msg"] != "只有正文" {
|
||
t.Errorf("应该只发 msg 一个字段,实际 %v", gotBody)
|
||
}
|
||
}
|
||
|
||
// 失败路径:文档里 HTTP 状态一律是 200,成败只能看 body 里的 status
|
||
func TestPushMeowStatusCodes(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
body string
|
||
want string
|
||
}{
|
||
{"昵称未注册", `{"status":404,"message":"昵称未注册"}`, "昵称未注册"},
|
||
{"频率超限", `{"status":429,"msg":"too many requests"}`, "频率限制"},
|
||
{"内容被拦", `{"status":403,"message":"forbidden"}`, "策略"},
|
||
{"参数错误", `{"status":400,"message":"bad request"}`, "参数错误"},
|
||
{"服务器错误", `{"status":500,"msg":"服务器错误"}`, "MeoW 服务器错误"},
|
||
{"不是 json", `<!doctype html><html>502</html>`, "不是合法 json"},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = w.Write([]byte(c.body))
|
||
})
|
||
err := pushMeow("JohnDoe", "标题", "正文", "", "", meowMsgTypeText, 0)
|
||
if err == nil {
|
||
t.Fatalf("status 不是 200 时必须报错")
|
||
}
|
||
if !strings.Contains(err.Error(), c.want) {
|
||
t.Errorf("错误信息里应包含 %q,实际:%v", c.want, err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestPushMeowRejectsBadInput(t *testing.T) {
|
||
// 昵称不合法时必须在发请求前就拦下来
|
||
if err := pushMeow("bad/nick", "标题", "正文", "", "", meowMsgTypeText, 0); err == nil {
|
||
t.Error("昵称带斜杠应该报错")
|
||
}
|
||
if err := pushMeow("JohnDoe", "标题", " ", "", "", meowMsgTypeText, 0); err == nil {
|
||
t.Error("内容为空应该报错")
|
||
}
|
||
}
|
||
|
||
// MeoW 对错误昵称记数并封 IP,所以 404 之后必须停止重试,否则事件反复触发会把用户 IP 封掉
|
||
func TestMeowBadNickGuard(t *testing.T) {
|
||
const nick = "guard_test_nick"
|
||
resetMeowBadNick()
|
||
t.Cleanup(resetMeowBadNick)
|
||
|
||
if got := meowBadNickReason(nick); got != "" {
|
||
t.Fatalf("一开始不该有记录,实际 %q", got)
|
||
}
|
||
if got := meowNickProblem(nick); got != "" {
|
||
t.Fatalf("没被封之前应该没问题,实际 %q", got)
|
||
}
|
||
|
||
// MeoW 返回 404 后,这个昵称要被记住
|
||
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = w.Write([]byte(`{"status":404,"message":"昵称未注册"}`))
|
||
})
|
||
if err := pushMeow(nick, "标题", "正文", "", "", meowMsgTypeText, 0); err == nil {
|
||
t.Fatal("404 时应该报错")
|
||
}
|
||
reason := meowBadNickReason(nick)
|
||
if reason == "" {
|
||
t.Fatal("404 之后应该记下这个不可用的昵称")
|
||
}
|
||
if !strings.Contains(reason, "未注册") {
|
||
t.Errorf("记录的原因不对:%q", reason)
|
||
}
|
||
// 之后再推同一个昵称就该被拦住了
|
||
if got := meowNickProblem(nick); got != reason {
|
||
t.Errorf("meowNickProblem 应该返回被拦的原因,实际 %q", got)
|
||
}
|
||
// 两者都不该影响别的昵称
|
||
if got := meowBadNickReason("another_nick"); got != "" {
|
||
t.Errorf("别的昵称不该被牵连,实际 %q", got)
|
||
}
|
||
// meowReady 要据此判定推不出去
|
||
n := notifySetting{meowEnabled: true, meowNick: nick}
|
||
if n.meowReady() {
|
||
t.Error("昵称被判过不存在时 meowReady 应该是 false")
|
||
}
|
||
if n.meowBlockedReason() != reason {
|
||
t.Errorf("meowBlockedReason 应返回被拦原因,实际 %q", n.meowBlockedReason())
|
||
}
|
||
|
||
// 4xx / 5xx 里只有 404 会触发拦截,429 和 400 不该记进去
|
||
resetMeowBadNick()
|
||
for _, body := range []string{`{"status":429,"message":"限流"}`, `{"status":400,"message":"参数错"}`} {
|
||
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = w.Write([]byte(body))
|
||
})
|
||
_ = pushMeow(nick, "标题", "正文", "", "", meowMsgTypeText, 0)
|
||
if got := meowBadNickReason(nick); got != "" {
|
||
t.Errorf("status %s 不该触发拦截,实际记成了 %q", body, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 被拦之后,测试接口要直接拒绝并说清原因,而不是又打一次接口
|
||
func TestNotifyTestHandlerMeowBlocked(t *testing.T) {
|
||
savedConfig := config
|
||
savedLocation := configFileLocation
|
||
oldDir := configDir
|
||
if configDir == nil {
|
||
configDir = new(string)
|
||
}
|
||
resetMeowBadNick()
|
||
defer func() {
|
||
config = savedConfig
|
||
configFileLocation = savedLocation
|
||
configDir = oldDir
|
||
resetMeowBadNick()
|
||
}()
|
||
*configDir = t.TempDir()
|
||
configFileLocation = filepath.Join(*configDir, configFile)
|
||
|
||
markMeowBadNick("blocked_nick", meowStatusText(404))
|
||
req := httptest.NewRequest(http.MethodPost, "/api/notify/test",
|
||
strings.NewReader(`{"channel":"meow","nickname":"blocked_nick"}`))
|
||
rec := httptest.NewRecorder()
|
||
notifyTestHandler(rec, req)
|
||
|
||
if rec.Code != 400 {
|
||
t.Errorf("被拦的昵称应该返回 400,实际 %d", rec.Code)
|
||
}
|
||
body := rec.Body.String()
|
||
if !strings.Contains(body, "不再重试") {
|
||
t.Errorf("提示要说明不会再重试,实际:%s", body)
|
||
}
|
||
}
|
||
|
||
// 端到端:sendNotify 要按事件勾选和两个渠道开关正确分发
|
||
func TestSendNotifyFanOut(t *testing.T) {
|
||
savedConfig := config
|
||
resetMeowBadNick()
|
||
defer func() {
|
||
config = savedConfig
|
||
resetMeowBadNick()
|
||
}()
|
||
|
||
var mu sync.Mutex
|
||
var hooks []notifyPayload
|
||
webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
var p notifyPayload
|
||
_ = json.NewDecoder(r.Body).Decode(&p)
|
||
mu.Lock()
|
||
hooks = append(hooks, p)
|
||
mu.Unlock()
|
||
_, _ = w.Write([]byte("ok"))
|
||
}))
|
||
defer webhook.Close()
|
||
|
||
var meows []map[string]string
|
||
var meowQuery []string
|
||
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||
var body map[string]string
|
||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||
mu.Lock()
|
||
meows = append(meows, body)
|
||
meowQuery = append(meowQuery, r.URL.RawQuery)
|
||
mu.Unlock()
|
||
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
|
||
})
|
||
|
||
config.NotifyURL = webhook.URL
|
||
config.NotifyToken = "hook-token"
|
||
config.NotifyGroup = "777163254"
|
||
config.NotifyLiveOn = true
|
||
config.MeowEnabled = true
|
||
config.MeowNickname = "小小怪"
|
||
config.MeowMsgType = "text"
|
||
|
||
counts := func() (int, int) {
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
return len(hooks), len(meows)
|
||
}
|
||
waitFor := func(tag string, wantHook, wantMeow int) {
|
||
t.Helper()
|
||
deadline := time.Now().Add(5 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
h, m := counts()
|
||
if h >= wantHook && m >= wantMeow {
|
||
return
|
||
}
|
||
time.Sleep(20 * time.Millisecond)
|
||
}
|
||
h, m := counts()
|
||
t.Fatalf("%s:等超时了,webhook %d 条(期望 %d)、MeoW %d 条(期望 %d)", tag, h, wantHook, m, wantMeow)
|
||
}
|
||
|
||
// 两个渠道都开:应该各收到一条
|
||
config.NotifyEnabled = true
|
||
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "",
|
||
"Xsphere 开播了\n今天画点啥\nhttps://live.acfun.cn/1")
|
||
waitFor("两个渠道都开", 1, 1)
|
||
|
||
mu.Lock()
|
||
h := hooks[0]
|
||
m := meows[0]
|
||
q := meowQuery[0]
|
||
mu.Unlock()
|
||
if h.Event != notifyEventLiveOn || h.Group != "777163254" || h.UID != 280178 {
|
||
t.Errorf("webhook 收到的内容不对:%+v", h)
|
||
}
|
||
if m["msg"] != "Xsphere 开播了\n今天画点啥\nhttps://live.acfun.cn/1" {
|
||
t.Errorf("MeoW 正文不对:%q", m["msg"])
|
||
}
|
||
if m["title"] != "开播提醒" {
|
||
t.Errorf("MeoW 标题不对:%q", m["title"])
|
||
}
|
||
if m["url"] != "https://live.acfun.cn/1" {
|
||
t.Errorf("MeoW 跳转链接不对:%q", m["url"])
|
||
}
|
||
if !strings.Contains(q, "msgType=text") {
|
||
t.Errorf("MeoW 查询参数不对:%q", q)
|
||
}
|
||
|
||
// 关掉 QQ 渠道,只剩 MeoW
|
||
config.NotifyEnabled = false
|
||
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "", "再来一条")
|
||
waitFor("只开 MeoW", 1, 2)
|
||
|
||
// 关掉 MeoW,只剩 QQ
|
||
config.NotifyEnabled = true
|
||
config.MeowEnabled = false
|
||
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "", "第三条")
|
||
waitFor("只开 QQ", 2, 2)
|
||
|
||
// 两个都关,一条都不该发
|
||
config.NotifyEnabled = false
|
||
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "", "不该发出")
|
||
time.Sleep(300 * time.Millisecond)
|
||
if h, m := counts(); h != 2 || m != 2 {
|
||
t.Errorf("两个渠道都关掉后不该再推送,实际 webhook %d 条、MeoW %d 条", h, m)
|
||
}
|
||
|
||
// 事件没勾选时也不该发(渠道都开着)
|
||
config.NotifyEnabled = true
|
||
config.MeowEnabled = true
|
||
config.NotifyLiveOn = false
|
||
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "", "事件没勾")
|
||
time.Sleep(300 * time.Millisecond)
|
||
if h, m := counts(); h != 2 || m != 2 {
|
||
t.Errorf("事件没勾选时不该推送,实际 webhook %d 条、MeoW %d 条", h, m)
|
||
}
|
||
}
|
||
|
||
func TestNotifyEventTitle(t *testing.T) {
|
||
for event, want := range map[string]string{
|
||
notifyEventLiveOn: "开播提醒",
|
||
notifyEventLiveOff: "下播提醒",
|
||
notifyEventRecordStart: "开始录制",
|
||
notifyEventMergeDone: "合并完成",
|
||
notifyEventMergeFail: "合并失败",
|
||
} {
|
||
if got := notifyEventTitle(event); got != want {
|
||
t.Errorf("notifyEventTitle(%q) = %q,期望 %q", event, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 合并失败要推送,而「正常结束 / 文件里没有弹幕 / 用户自己点的停止」都不许推:
|
||
// 前两种本来就不算失败,第三种推「合并失败」会误导
|
||
func TestNotifyMergeFailureFanOut(t *testing.T) {
|
||
savedConfig := config
|
||
resetMeowBadNick()
|
||
defer func() {
|
||
config = savedConfig
|
||
resetMeowBadNick()
|
||
}()
|
||
|
||
var mu sync.Mutex
|
||
var hooks []notifyPayload
|
||
webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
var p notifyPayload
|
||
_ = json.NewDecoder(r.Body).Decode(&p)
|
||
mu.Lock()
|
||
hooks = append(hooks, p)
|
||
mu.Unlock()
|
||
_, _ = w.Write([]byte("ok"))
|
||
}))
|
||
defer webhook.Close()
|
||
|
||
countHooks := func() int {
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
return len(hooks)
|
||
}
|
||
waitHooks := func(want int) {
|
||
t.Helper()
|
||
deadline := time.Now().Add(5 * time.Second)
|
||
for time.Now().Before(deadline) && countHooks() < want {
|
||
time.Sleep(20 * time.Millisecond)
|
||
}
|
||
}
|
||
|
||
config.NotifyEnabled = true
|
||
config.NotifyURL = webhook.URL
|
||
config.NotifyMergeFail = true
|
||
config.NotifyMergeDone = false
|
||
|
||
notifyMergeFailure(280178, "2026-09-25 01-00-00 甲_弹幕合并.mp4", errors.New("磁盘写满了"))
|
||
waitHooks(1)
|
||
mu.Lock()
|
||
got := append([]notifyPayload{}, hooks...)
|
||
mu.Unlock()
|
||
if len(got) != 1 {
|
||
t.Fatalf("合并失败应当推送一条,实际 %d 条", len(got))
|
||
}
|
||
if got[0].Event != notifyEventMergeFail {
|
||
t.Errorf("事件名应当是 %s,实际 %s", notifyEventMergeFail, got[0].Event)
|
||
}
|
||
if !strings.Contains(got[0].Text, "磁盘写满了") {
|
||
t.Errorf("正文里应当带上失败原因,实际:%s", got[0].Text)
|
||
}
|
||
|
||
notifyMergeFailure(280178, "x.mp4", nil)
|
||
notifyMergeFailure(280178, "x.mp4", errNoDanmu)
|
||
notifyMergeFailure(280178, "x.mp4", errJobStopped)
|
||
time.Sleep(300 * time.Millisecond)
|
||
if n := countHooks(); n != 1 {
|
||
t.Errorf("正常结束 / 没有弹幕 / 手动停止都不该推送,实际共 %d 条", n)
|
||
}
|
||
|
||
config.NotifyMergeFail = false
|
||
notifyMergeFailure(280178, "x.mp4", errors.New("又失败了"))
|
||
time.Sleep(300 * time.Millisecond)
|
||
if n := countHooks(); n != 1 {
|
||
t.Errorf("没勾「合并失败」时不该推送,实际共 %d 条", n)
|
||
}
|
||
}
|
||
|
||
// 两个渠道的启用条件必须互相独立,并且都受各自开关控制
|
||
func TestNotifySettingChannelReady(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
n notifySetting
|
||
wantQQ, wantM bool
|
||
}{
|
||
{"都没开", notifySetting{}, false, false},
|
||
{"QQ 开了但没填地址", notifySetting{qqEnabled: true}, false, false},
|
||
{"QQ 开了且填了地址", notifySetting{qqEnabled: true, url: "http://x/y"}, true, false},
|
||
{"QQ 填了地址但没开", notifySetting{url: "http://x/y"}, false, false},
|
||
{"MeoW 开了但没昵称", notifySetting{meowEnabled: true}, false, false},
|
||
{"MeoW 开了且有昵称", notifySetting{meowEnabled: true, meowNick: "JohnDoe"}, false, true},
|
||
{"MeoW 昵称非法", notifySetting{meowEnabled: true, meowNick: "bad/nick"}, false, false},
|
||
{"两个都开了", notifySetting{qqEnabled: true, url: "http://x/y", meowEnabled: true, meowNick: "JohnDoe"}, true, true},
|
||
}
|
||
for _, c := range cases {
|
||
if got := c.n.qqReady(); got != c.wantQQ {
|
||
t.Errorf("%s:qqReady = %v,期望 %v", c.name, got, c.wantQQ)
|
||
}
|
||
if got := c.n.meowReady(); got != c.wantM {
|
||
t.Errorf("%s:meowReady = %v,期望 %v", c.name, got, c.wantM)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 老设置文件里没有 notifyEnabled,升级后不能把已经配好的推送静默关掉
|
||
func TestNotifyEnabledBackwardCompat(t *testing.T) {
|
||
dir := t.TempDir()
|
||
savedConfig := config
|
||
savedLocation := configFileLocation
|
||
oldDir := configDir
|
||
if configDir == nil {
|
||
configDir = new(string)
|
||
}
|
||
defer func() {
|
||
config = savedConfig
|
||
configFileLocation = savedLocation
|
||
configDir = oldDir
|
||
}()
|
||
|
||
*configDir = dir
|
||
configFileLocation = filepath.Join(dir, configFile)
|
||
|
||
write := func(content string) {
|
||
t.Helper()
|
||
if err := os.WriteFile(configFileLocation, []byte(content), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// 老配置:完全没提 notifyEnabled,但已经配好了地址
|
||
write(`{"notifyUrl":"http://127.0.0.1:8080/acfunlive/notify","notifyLiveOn":true}`)
|
||
loadConfig()
|
||
if !config.NotifyEnabled {
|
||
t.Errorf("老配置里没有 notifyEnabled 时应该默认开启,实际关闭了")
|
||
}
|
||
if config.NotifyURL == "" || !config.NotifyLiveOn {
|
||
t.Errorf("其它字段被破坏:url=%q liveOn=%v", config.NotifyURL, config.NotifyLiveOn)
|
||
}
|
||
|
||
// 显式写了 true
|
||
write(`{"notifyUrl":"http://127.0.0.1:8080/acfunlive/notify","notifyEnabled":true}`)
|
||
loadConfig()
|
||
if !config.NotifyEnabled {
|
||
t.Errorf("显式写 true 时应该是开启")
|
||
}
|
||
|
||
// 显式写了 false,必须真的关掉
|
||
write(`{"notifyUrl":"http://127.0.0.1:8080/acfunlive/notify","notifyEnabled":false}`)
|
||
loadConfig()
|
||
if config.NotifyEnabled {
|
||
t.Errorf("显式写 false 时应该是关闭")
|
||
}
|
||
}
|
||
|
||
// 测试接口:没填地址或昵称时给 400,填了就往对应渠道发
|
||
func TestNotifyTestHandler(t *testing.T) {
|
||
savedConfig := config
|
||
savedLocation := configFileLocation
|
||
oldDir := configDir
|
||
if configDir == nil {
|
||
configDir = new(string)
|
||
}
|
||
defer func() {
|
||
config = savedConfig
|
||
configFileLocation = savedLocation
|
||
configDir = oldDir
|
||
}()
|
||
*configDir = t.TempDir()
|
||
configFileLocation = filepath.Join(*configDir, configFile)
|
||
|
||
var webhookHits int
|
||
webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
webhookHits++
|
||
_, _ = w.Write([]byte("ok"))
|
||
}))
|
||
defer webhook.Close()
|
||
|
||
var meowNick string
|
||
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||
meowNick = strings.TrimPrefix(r.URL.Path, "/")
|
||
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
|
||
})
|
||
|
||
call := func(body string) (int, string) {
|
||
t.Helper()
|
||
req := httptest.NewRequest(http.MethodPost, "/api/notify/test", strings.NewReader(body))
|
||
rec := httptest.NewRecorder()
|
||
notifyTestHandler(rec, req)
|
||
return rec.Code, rec.Body.String()
|
||
}
|
||
|
||
// 什么都没配
|
||
config.NotifyURL, config.MeowNickname = "", ""
|
||
if code, msg := call(`{"channel":"qq"}`); code != 400 || !strings.Contains(msg, "推送地址") {
|
||
t.Errorf("没填地址应该 400,实际 %d %s", code, msg)
|
||
}
|
||
if code, msg := call(`{"channel":"meow"}`); code != 400 || !strings.Contains(msg, "昵称") {
|
||
t.Errorf("没填昵称应该 400,实际 %d %s", code, msg)
|
||
}
|
||
if code, msg := call(`{"channel":"whatever"}`); code != 400 || !strings.Contains(msg, "未知的推送渠道") {
|
||
t.Errorf("未知渠道应该 400,实际 %d %s", code, msg)
|
||
}
|
||
|
||
// 用页面上的值直接测 webhook,不需要先保存
|
||
code, msg := call(`{"channel":"qq","url":"` + webhook.URL + `","group":"777163254"}`)
|
||
if code != 200 || !strings.Contains(msg, `"ok":true`) || !strings.Contains(msg, "HTTP 200") {
|
||
t.Errorf("QQ 渠道测试应该成功,实际 %d %s", code, msg)
|
||
}
|
||
if webhookHits != 1 {
|
||
t.Errorf("webhook 应该被调用 1 次,实际 %d 次", webhookHits)
|
||
}
|
||
|
||
// MeoW 渠道
|
||
code, msg = call(`{"channel":"meow","nickname":"小小怪","msgType":"html","htmlHeight":300}`)
|
||
if code != 200 || !strings.Contains(msg, "status 200") {
|
||
t.Errorf("MeoW 渠道测试应该成功,实际 %d %s", code, msg)
|
||
}
|
||
if meowNick != "小小怪" {
|
||
t.Errorf("MeoW 收到的昵称是 %q,期望 小小怪", meowNick)
|
||
}
|
||
|
||
// 昵称带斜杠要被拦下(否则会打到别的路径上去)
|
||
if code, _ := call(`{"channel":"meow","nickname":"bad/nick"}`); code != 400 {
|
||
t.Errorf("非法昵称应该 400,实际 %d", code)
|
||
}
|
||
}
|
||
|
||
// 手动合并完成也要推送 —— 以前只有自动合并会推,所以在页面上手动合并时 MeoW 一直没动静
|
||
func TestManualMergeSendsMergeDoneNotify(t *testing.T) {
|
||
setupMergeTest(t)
|
||
ffmpeg := getFFmpeg()
|
||
if ffmpeg == "" {
|
||
t.Skip("没有 ffmpeg")
|
||
}
|
||
|
||
savedConfig := config
|
||
defer func() { config = savedConfig }()
|
||
|
||
var mu sync.Mutex
|
||
var hooks []notifyPayload
|
||
webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
var p notifyPayload
|
||
_ = json.NewDecoder(r.Body).Decode(&p)
|
||
mu.Lock()
|
||
hooks = append(hooks, p)
|
||
mu.Unlock()
|
||
_, _ = w.Write([]byte("ok"))
|
||
}))
|
||
defer webhook.Close()
|
||
|
||
var meowQuery []string
|
||
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||
mu.Lock()
|
||
meowQuery = append(meowQuery, r.URL.RawQuery)
|
||
mu.Unlock()
|
||
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
|
||
})
|
||
|
||
// 只开合并完成这一个事件,两个渠道都开
|
||
config.NotifyURL = webhook.URL
|
||
config.NotifyLiveOn = false
|
||
config.NotifyMergeDone = true
|
||
config.MeowEnabled = true
|
||
config.MeowNickname = "小小怪"
|
||
config.MeowMsgType = "text"
|
||
config.UIDs = uidList{{UID: 999, Danmu: true, Record: true}}
|
||
// 文件名里带主播名,手动合并靠它认人
|
||
streamers.Lock()
|
||
streamers.crt = map[int]streamer{999: {UID: 999, Name: "测试主播"}}
|
||
streamers.Unlock()
|
||
|
||
name := "2026-09-25 01-00-00 测试主播 标题"
|
||
video := filepath.Join(recordSaveDir(), name+".mp4")
|
||
if out, err := exec.Command(ffmpeg, "-v", "error", "-y",
|
||
"-f", "lavfi", "-i", "color=c=black:size=320x180:rate=15", "-t", "3",
|
||
"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", video).CombinedOutput(); err != nil {
|
||
t.Fatalf("生成测试视频失败:%v\n%s", err, out)
|
||
}
|
||
lines := []string{`{"playResX":320,"playResY":180,"fontSize":16,"startTime":0}`,
|
||
`{"t":0.5,"uid":101,"name":"甲","content":"手动合并推送测试"}`,
|
||
`{"t":1.5,"uid":102,"name":"乙","content":"第二条"}`}
|
||
writeTestFile(t, filepath.Join(recordSaveDir(), name+".jsonl"), strings.Join(lines, "\n")+"\n")
|
||
|
||
plan, err := buildMergePlan(mergeRequest{Videos: []string{name + ".mp4"}, Danmu: name + ".jsonl"})
|
||
if err != nil {
|
||
t.Fatalf("检查失败:%v", err)
|
||
}
|
||
if err := runManualMerge(plan); err != nil {
|
||
t.Fatalf("手动合并失败:%v", err)
|
||
}
|
||
|
||
deadline := time.Now().Add(5 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
mu.Lock()
|
||
nh, nm := len(hooks), len(meowQuery)
|
||
mu.Unlock()
|
||
if nh > 0 && nm > 0 {
|
||
break
|
||
}
|
||
time.Sleep(30 * time.Millisecond)
|
||
}
|
||
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
if len(hooks) != 1 {
|
||
t.Fatalf("QQ 机器人应收到 1 条推送,实际 %d 条", len(hooks))
|
||
}
|
||
p := hooks[0]
|
||
if p.Event != notifyEventMergeDone {
|
||
t.Errorf("事件名应为 %s,实际 %s", notifyEventMergeDone, p.Event)
|
||
}
|
||
if p.UID != 999 || p.Name != "测试主播" {
|
||
t.Errorf("应从文件名认出主播 999/测试主播,实际 %d/%q", p.UID, p.Name)
|
||
}
|
||
if p.File != plan.out {
|
||
t.Errorf("推送里的文件应为成片 %q,实际 %q", plan.out, p.File)
|
||
}
|
||
if !strings.Contains(p.Text, "手动合并") || !strings.Contains(p.Text, plan.out) {
|
||
t.Errorf("推送文案不对:%q", p.Text)
|
||
}
|
||
if len(meowQuery) != 1 {
|
||
t.Fatalf("MeoW 应收到 1 条推送,实际 %d 条", len(meowQuery))
|
||
}
|
||
if !strings.Contains(meowQuery[0], "msgType=text") {
|
||
t.Errorf("MeoW 的 msgType 应走 query 参数,实际 %q", meowQuery[0])
|
||
}
|
||
}
|