一、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 报错(分组 / 拆组 / 逐任务参数 / 转码行渲染 /
跳过无视频轨文件 / 估算失败提示 / 结果徽标)
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, 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])
|
||
}
|
||
}
|