Files
acfunlive-remake/webui_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

448 lines
14 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.
// web UI 相关
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"regexp"
"testing"
"time"
)
// 测试期间换一个管理口令
func withAuthToken(t *testing.T, token string) {
t.Helper()
streamers.Lock()
old := config.AuthToken
config.AuthToken = token
streamers.Unlock()
t.Cleanup(func() {
streamers.Lock()
config.AuthToken = old
streamers.Unlock()
})
}
// 页面上发给服务端的 X-Auth-Token 是 encodeURIComponent 过的口令,
// 这里用 url.QueryEscape 造出同样的形状(对中文而言两者产出一致)
func pageHeader(r *http.Request, token string) *http.Request {
r.Header.Set("X-Auth-Token", url.QueryEscape(token))
return r
}
func TestCheckAuthTokenPlainASCII(t *testing.T) {
withAuthToken(t, "a1b2c3d4e5f6")
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("X-Auth-Token", "a1b2c3d4e5f6")
if !checkAuthToken(r) {
t.Fatal("ASCII 口令应该通过")
}
// 带 % 的口令:页面会编码成 %25,服务端解码后要能对上
withAuthToken(t, "ab%20cd")
r = pageHeader(httptest.NewRequest(http.MethodGet, "/api/status", nil), "ab%20cd")
if r.Header.Get("X-Auth-Token") != "ab%2520cd" {
t.Fatalf("编码结果不对:%q", r.Header.Get("X-Auth-Token"))
}
if !checkAuthToken(r) {
t.Fatal("含 % 的口令应该通过")
}
}
func TestCheckAuthTokenNonASCII(t *testing.T) {
// 中文口令是最容易踩的:原先直接把中文塞进请求头,浏览器会抛
// TypeError(Failed to read the 'headers' property…),请求根本发不出去,
// 页面上只显示「无法连接到服务器」,用户被彻底锁在门外
withAuthToken(t, "我的口令abc123")
r := pageHeader(httptest.NewRequest(http.MethodGet, "/api/status", nil), "我的口令abc123")
if r.Header.Get("X-Auth-Token") == "我的口令abc123" {
t.Fatal("测试前提不成立:头里不该是原文")
}
if !checkAuthToken(r) {
t.Fatal("编码后的中文口令应该通过")
}
// 手写 curl 按文档原样发中文口令,也要能通过
r = httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("X-Auth-Token", "我的口令abc123")
if !checkAuthToken(r) {
t.Fatal("原样的中文口令也应该通过")
}
}
func TestCheckAuthTokenAllSources(t *testing.T) {
withAuthToken(t, "口令abc123")
// Authorization: Bearer
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("Authorization", "Bearer "+url.QueryEscape("口令abc123"))
if !checkAuthToken(r) {
t.Fatal("Bearer 写法应该通过")
}
// ?token=(net/http 已经把查询串解码好了)
r = httptest.NewRequest(http.MethodGet, "/api/status?token="+url.QueryEscape("口令abc123"), nil)
if !checkAuthToken(r) {
t.Fatal("查询参数写法应该通过")
}
// 下载链接里的 ?token= 也是这个路径,混着 % 的口令同样要对上
withAuthToken(t, "口令%20abc")
r = httptest.NewRequest(http.MethodGet, "/api/file?token="+url.QueryEscape("口令%20abc"), nil)
if !checkAuthToken(r) {
t.Fatal("查询参数里的混合口令应该通过")
}
}
func TestCheckAuthTokenRejects(t *testing.T) {
withAuthToken(t, "正确的口令")
cases := map[string]*http.Request{
"口令错了": func() *http.Request {
return pageHeader(httptest.NewRequest(http.MethodGet, "/api/status", nil), "错误的口令")
}(),
"没带口令": httptest.NewRequest(http.MethodGet, "/api/status", nil),
"空口令头": func() *http.Request {
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("X-Auth-Token", "")
return r
}(),
"编码了但不是口令": func() *http.Request {
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("X-Auth-Token", "%E5%8F%A3%E4%BB%A4")
return r
}(),
}
for name, r := range cases {
if checkAuthToken(r) {
t.Fatalf("%s:不该通过", name)
}
}
// 口令没配时一律拒绝,不能因为「都是空」就放行
withAuthToken(t, "")
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("X-Auth-Token", "")
if checkAuthToken(r) {
t.Fatal("口令为空时不该通过")
}
}
func TestAuthTokenCandidatesOrder(t *testing.T) {
// 三种写法同时出现时按 Bearer → X-Auth-Token → ?token= 的顺序给候选,
// 顺序本身不影响结果,但别写出重复项(重复比较纯属浪费)
r := httptest.NewRequest(http.MethodGet, "/api/status?token=qqq", nil)
r.Header.Set("Authorization", "Bearer aaa")
r.Header.Set("X-Auth-Token", "bbb")
got := authTokenCandidates(r)
want := []string{"aaa", "bbb", "qqq"}
if len(got) != len(want) {
t.Fatalf("候选数量不对:%q", got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("候选顺序不对:%q,想要 %q", got, want)
}
}
// Authorization 不是 Bearer 前缀时不算数
r = httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("Authorization", "Basic zzz")
r.Header.Set("X-Auth-Token", "bbb")
got = authTokenCandidates(r)
if len(got) != 1 || got[0] != "bbb" {
t.Fatalf("非 Bearer 的 Authorization 不该当候选:%q", got)
}
}
// ==================== 口令错误记录的容量 ====================
// IP 是外部给的:只靠「过期清理」挡不住换着地址撞口令的情况,
// 必须要有硬上限。这些用例锁住这一点。
// 换着来源持续撞口令,表必须被压在上限以内,不能一直涨
func TestAuthFailTableHasHardCap(t *testing.T) {
saved, savedSweep := authFail.m, authFail.sinceSweep
t.Cleanup(func() {
authFail.Lock()
authFail.m, authFail.sinceSweep = saved, savedSweep
authFail.Unlock()
})
authFail.Lock()
authFail.m, authFail.sinceSweep = nil, 0
authFail.Unlock()
// 每个都是新来源、且都是「刚刚」失败过 —— 过期清理一条都删不掉的情况
for i := 0; i < authFailMaxEntries*2; i++ {
authFailDelay(fmt.Sprintf("198.51.100.%d", i))
}
got := 0
authFail.Lock()
got = len(authFail.m)
authFail.Unlock()
if got > authFailMaxEntries {
t.Errorf("记录数应被压在上限 %d 以内,实际 %d(换 IP 撞口令会把内存写满)",
authFailMaxEntries, got)
}
t.Logf(" %d 个新来源撞完,表内留下 %d 条(上限 %d)", authFailMaxEntries*2, got, authFailMaxEntries)
}
// 超过过期时间的记录要被扫掉,不是在表里一直躺着
func TestAuthFailExpiredEntriesGetSwept(t *testing.T) {
saved, savedSweep := authFail.m, authFail.sinceSweep
t.Cleanup(func() {
authFail.Lock()
authFail.m, authFail.sinceSweep = saved, savedSweep
authFail.Unlock()
})
authFail.Lock()
authFail.m, authFail.sinceSweep = nil, 0
authFail.Unlock()
for i := 0; i < authFailSweepEvery+5; i++ {
authFailDelay(fmt.Sprintf("203.0.113.%d", i))
}
authFail.Lock()
before := len(authFail.m)
// 把记录的最后时间推到过期之前
for k, v := range authFail.m {
authFail.m[k] = authFailEntry{count: v.count, last: time.Now().Add(-2 * authFailExpire)}
}
authFail.Unlock()
sweepAuthFail(time.Now())
authFail.Lock()
after := len(authFail.m)
authFail.Unlock()
if after != 0 {
t.Errorf("已经全部过期的记录应被扫干净,扫之前 %d 条,扫之后 %d 条", before, after)
}
}
// 扫表必须是自动发生的:不要求调用方记着手动清理,
// 混在里面的过期项应该在若干次记录之后自己消失
func TestAuthFailSweepsPeriodically(t *testing.T) {
saved, savedSweep := authFail.m, authFail.sinceSweep
t.Cleanup(func() {
authFail.Lock()
authFail.m, authFail.sinceSweep = saved, savedSweep
authFail.Unlock()
})
authFail.Lock()
authFail.m, authFail.sinceSweep = nil, 0
authFail.Unlock()
// 直接往里塞一条早就过期了的记录(绕过 authFailDelay,模拟「以前留下的」)
authFail.Lock()
if authFail.m == nil {
authFail.m = make(map[string]authFailEntry)
}
authFail.m["203.0.113.99"] = authFailEntry{count: 1, last: time.Now().Add(-2 * authFailExpire)}
authFail.Unlock()
// 之后只要还在有人失败,过一阵子它就应该被清掉
for i := 0; i < authFailSweepEvery+1; i++ {
authFailDelay(fmt.Sprintf("198.51.100.%d", i))
}
authFail.Lock()
_, still := authFail.m["203.0.113.99"]
authFail.Unlock()
if still {
t.Errorf("记了 %d 次之后,过期记录应当已经被自动扫掉", authFailSweepEvery+1)
}
}
// 同一个来源的计数不能无限涨(临界点是延迟早已封顶,再涨只是让数字溢出)
func TestAuthFailCountCapped(t *testing.T) {
saved, savedSweep := authFail.m, authFail.sinceSweep
t.Cleanup(func() {
authFail.Lock()
authFail.m, authFail.sinceSweep = saved, savedSweep
authFail.Unlock()
})
authFail.Lock()
authFail.m, authFail.sinceSweep = nil, 0
authFail.Unlock()
for i := 0; i < authFailMaxCount+50; i++ {
authFailDelay("192.0.2.9")
}
authFail.Lock()
c := authFail.m["192.0.2.9"].count
authFail.Unlock()
if c > authFailMaxCount {
t.Errorf("同一来源的次数应封顶在 %d,实际 %d", authFailMaxCount, c)
}
if d := authFailDelay("192.0.2.9"); d != authMaxDelay {
t.Errorf("打满之后延迟应封顶在 %v,实际 %v", authMaxDelay, d)
}
}
// 客户端断开时延迟等待必须立刻返回,不能傻等满 2 秒
func TestAwaitAuthDelayStopsOnCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(80 * time.Millisecond)
cancel()
}()
start := time.Now()
waited := awaitAuthDelay(ctx, 2*time.Second)
used := time.Since(start)
if waited {
t.Error("上游取消时 awaitAuthDelay 应返回 false")
}
if used > time.Second {
t.Errorf("上游取消后应立刻返回,实际等了 %v", used)
}
// 正常等待不受影响
if !awaitAuthDelay(context.Background(), 10*time.Millisecond) {
t.Error("没被取消时应当正常等完再返回 true")
}
}
var (
webInputIDRe = regexp.MustCompile(`<(?:input|select|textarea)[^>]*\bid="([A-Za-z0-9_]+)"`)
webSettingFieldsRe = regexp.MustCompile(`var settingFields = \[([^\]]*)\]`)
webNumberFieldsRe = regexp.MustCompile(`var numberFields = \{([^}]*)\}`)
webQuotedRe = regexp.MustCompile(`"([A-Za-z0-9_]+)"`)
)
// 设置页里每个能改的输入框都必须在 settingFields 里登记:漏登记的后果是「改了、按了保存,
// 值却没被提交」,3 秒后刷新又被刷回原值 —— 页面看起来完全正常,也没有任何报错。
// 反过来,settingFields 里写的 id 如果页面上不存在,refresh 时就静默填不进去。
//
// settingFields / numberFields 都是页面里的 JS 变量(整页在 webuiHTML 这个原始串里),
// 所以这里直接把它们从串里抠出来比对,不另维护一份名单
func TestWebuiSettingFieldsMatchInputs(t *testing.T) {
ids := map[string]bool{}
for _, m := range webInputIDRe.FindAllStringSubmatch(webuiHTML, -1) {
ids[m[1]] = true
}
m := webSettingFieldsRe.FindStringSubmatch(webuiHTML)
if m == nil {
t.Fatal("没能在页面里找到 settingFields 列表")
}
settings := map[string]bool{}
for _, q := range webQuotedRe.FindAllStringSubmatch(m[1], -1) {
settings[q[1]] = true
if !ids[q[1]] {
t.Errorf("settingFields 里的 %q 在页面上找不到对应的输入框", q[1])
}
}
// 这些输入框刻意不进设置:要么是手动合并卡片自己的参数(随合并请求提交),
// 要么是路径选择弹窗 / 口令框的内部状态
notSettings := map[string]string{
"authTokenNew": "重新生成口令,走单独的接口",
"authTokenView": "只读展示当前口令",
"tokenInput": "页面解锁框,不是设置项",
"newuid": "添加主播,走主播接口",
"pickHidden": "路径选择弹窗的内部状态",
"pickPath": "路径选择弹窗的内部状态",
"mrgVideoPick": "手动合并:视频列表,随合并请求提交",
"tcFilePick": "转码:待选文件列表,随转码请求提交",
"tcOutDir": "转码:输出目录,随转码请求提交",
}
for id := range ids {
if settings[id] {
continue
}
if _, ok := notSettings[id]; ok {
continue
}
t.Errorf("页面上的 %q 既不在 settingFields 里,也没说明为什么不算设置项:改了它不会被保存", id)
}
// numberFields 里的 id 必须都是设置项,否则 parseInt 的结果没人用
n := webNumberFieldsRe.FindStringSubmatch(webuiHTML)
if n == nil {
t.Fatal("没能在页面里找到 numberFields 列表")
}
for _, m := range regexp.MustCompile(`([A-Za-z0-9_]+)\s*:`).FindAllStringSubmatch(n[1], -1) {
if !settings[m[1]] {
t.Errorf("numberFields 里的 %q 不是设置项", m[1])
}
}
}
// 保存设置必须把新增字段真的落到 config 上:漏一个赋值就是「界面显示已保存、实际没生效」,
// 3 秒后刷新还被刷回原值,页面上完全看不出问题
func TestWebConfigHandlerSavesMergeFpsAndNotifyMergeFail(t *testing.T) {
dir := setupMergeTest(t)
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)
post := func(body map[string]any) string {
t.Helper()
data, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(data))
rec := httptest.NewRecorder()
webConfigHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("保存设置被拒(%d):%s", rec.Code, rec.Body.String())
}
return rec.Body.String()
}
base := func() map[string]any {
return map[string]any{
"directory": dir,
"ffmpegPath": testFFmpeg(t),
"recordSubdir": defaultRecordSubdir,
"mergeSubdir": defaultMergeSubdir,
}
}
config.MergeFps = 0
config.NotifyMergeFail = true
b := base()
b["mergeFps"] = 60
b["notifyMergeFail"] = false
post(b)
if config.MergeFps != 60 {
t.Errorf("mergeFps 没被保存下来:%d", config.MergeFps)
}
if config.NotifyMergeFail {
t.Error("notifyMergeFail=false 没被保存下来")
}
// 超范围(负数或大得离谱)必须挡在保存之前
for _, bad := range []int{-1, maxMergeFps + 1} {
config.MergeFps = 7
b = base()
b["mergeFps"] = bad
data, _ := json.Marshal(b)
req := httptest.NewRequest(http.MethodPost, "/api/config", bytes.NewReader(data))
rec := httptest.NewRecorder()
webConfigHandler(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("mergeFps=%d 应当被拒(400),实际 %d", bad, rec.Code)
}
if config.MergeFps != 7 {
t.Errorf("mergeFps=%d 被拒后不该改到配置里,实际 %d", bad, config.MergeFps)
}
}
}