Files
acfunlive-remake/notify_test.go
T
tamakyi a409c5b4dd 新增「转码」分区与手动合并逐任务配置,修掉 PC 端图表文字被拉扁
一、PC 端图表文字被拉伸(bug)
.spark 折线图把 viewBox 写死成 "0 0 300 48" 又带 preserveAspectRatio="none":
移动端元素宽≈300px 时缩放≈1 看不出问题,PC 上卡片宽约 1030px,横轴被放大 3.4 倍
而纵轴仍是 48px,图里的 <text> 被整体拉扁。现在按元素实际宽度算 viewBox 宽度
(取不到时回落 300),并监听 resize 重画;preserveAspectRatio 保留不影响 1:1 显示。

二、新增「转码」分区(transcode.go,第七个 tab)
原来只有「录播 + 弹幕 → 成片」一条路,已有成片想压小、换 HEVC、降分辨率只能手搓命令行。
现在从文件列表多选加入,每个文件一行独立配置:编码器、码率控制(跟随源片 / 目标码率 /
质量 CRF·ICQ / 源片百分比)、编码速度、输出帧率、分辨率(只列不高于源片的档)、
音频(拷贝 / 128k / 192k / 去掉),并实时预估产物大小与整批合计。
 - POST /api/transcode/probe | estimate | transcode,都在既有的口令鉴权之后
 - 目标码率模式按「码率 × 时长」出准数;质量模式用单调系数表插值并标注是粗估
 - 落盘沿用合并那套:先在源文件所在目录生成,完成后再 placeOutput 到输出目录,
   失败或中断删临时文件,不留半成品
 - 与合并共用同一张进度卡片和任务位(mergeSlot),串行排队,可暂停 / 停止

三、手动合并改成「一次排多个任务」
原来一次只能合一场。现在多选视频后按「文件名里的录制时间 + 主体名」自动归组
(相邻两段间隔 2 小时内算同一场),每组一个任务卡,各自配弹幕、偏移、输出名和
合成参数(码率 / 帧率 / 字号 / 编码器),可拆组、可移除;服务端串行跑,逐个回传结果。
请求体从 {videos,danmu,...} 扩成 {jobs:[{...}]},老的单任务写法继续可用。

四、审阅中一并修掉 / 加固的点
 - 容器总码率含音频,直接当视频码率用会错两处:预估体积时又加一遍音轨(算成双份),
   「跟随源片」时把音轨码率也编进视频里。现在分开记「视频流码率」与「容器总码率」,
   转码侧按「视频流码率 → 容器码率 − 音轨 → 体积 ÷ 时长 − 音轨」三级取值;
   合并的自动码率仍按老口径(容器含音频),单独用 streamOrContainerKbps 锁住
 - 源片本来没有音轨时,预估不再凭空加 128 kbps 音频
 - 目标码率加上界(200 Mbps),单位写错的数在开跑前就被挡住(合并的逐任务码率同理)
 - 探测结果加缓存(ffmpeg + 路径 + 体积 + 修改时间),体积估算跟着输入框重发时不再
   每次都起一个 ffmpeg;文件一变(录制中追加、换了文件)缓存自然失效
 - 只有音轨 / 只有封面图的文件、以及正在写入的文件,排队时就被挡住并说清原因
 - probeMedia / parseMediaInfo 合并了原先重复的两套解析(probeDuration 与
   probeVideoStream 各起一次 ffmpeg 解析同一段输出),burnDanmu 现在一次探测拿全
 - 前端:估算参数非法时把服务端给的原因显示在合计行(原来静默不动,像是没改成功);
   估算请求加序号,慢的旧回包不再覆盖新结果;转码下拉刷新时保留已勾选项;
   跑完的行标「完成 / 失败」并淡化;删掉没有对应元素、从未使用的 CSS 规则

验证
- gofmt / go vet 干净;go build(windows)与 GOOS=linux go build 均通过
- go test ./... 20.9s 全过;go test -race ./... 26.1s 全过(182 → 206 个用例,
  transcode_test.go 新增 20 个)
- 反向验证 C:\OtherFiles\acf-test\reverse_check.py 从 37 项加到 43 项,全过
- 51899 隔离实例 + 真 ffmpeg 冒烟 29 项全过:401 鉴权、probe 的 hasVideo/hasAudio、
  ts 容器(不带每流码率)探测到的视频码率 = 容器 429 − 音轨 127、
  HEVC 按 0.65 折算、参数非法 400 带原因、只有音轨的文件排队即被拒、
  输出目录不存在被拒、两任务队列真转成功并落到指定目录、缩放成片高度确实是 240、
  日志里带「第 n/N 个」和参数摘要
- 页面 jsdom 冒烟 37 项全过、无 JS 报错(分组 / 拆组 / 逐任务参数 / 转码行渲染 /
  跳过无视频轨文件 / 估算失败提示 / 结果徽标)
2026-09-27 10:36:29 +08:00

770 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>`, // 链接可点
"&lt;i&gt;不该被解析&lt;/i&gt;", // 内容要转义
} {
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, 1, 1); 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])
}
}