三件事: 1) 主播列表的录制档位 - 一律是下拉,不再有手填输入框。第一项「自动 · 第二高」(值 0)= 按这次读到的 档位排序自上往下取第二高(只有一档就用它,读不到档位表才退回最高档); 后面依次列出实际档位。选最高/最低/中间某一档直接点它,手动选过之后 「自动」仍留在列表里,随时能改回去。 - 语义变化:0 不再被写死成某个具体码率。原来记录档位时会把「没选过」的主播 按第二档定下来(写进 config.json),现在只在取流时解析,所以主播换了档位表 也能跟着走;app 启动时的补齐逻辑(applyCachedDefaultQualities)随之去掉。 - 解析规则统一到 resolveQualityBitrate:0 = 第二高,>0 = 不超过它的最高档, 都比它高时退回最低档;「自动」项会把解析到的档位名带出来 (自动 · 第二高(当前 = 蓝光 4M))。/api/status 和档位接口都多了 qualityBitrate。 - 点「档位」按钮时先给一句「正在读取码率档位…」:主播没开播时服务端要重试三次 才放弃,约 5 秒,之前这段时间界面没有任何反应。 2) 手动合并完成也推送(这就是「MeoW 只在测试时成功」的原因) - 原来只有自动合并调 sendNotify,手动合并从头到尾没推过,所以事件开着、渠道也通 却一直收不到。现在手动合并完成后同样按 mergeDone 事件推送两个渠道。 - 手动合并没有 uid,会拿成片名(再退到视频名)去对已有主播名,只有一个能对上时 才在文案里带上主播名,对不上就用「手动合并完成」。 3) 头像缓存挪到程序所在目录 - 原来存在「录播目录/avatar_cache」,换一次保存目录(甚至换盘)就要把同一批观众 的头像重新下一遍(一个头像可能 0.5MB)。现在统一放程序目录下的 avatar_cache。 - 旧位置作为只读回退:命中就搬过来(同盘改名,跨盘退回复制),不重复下载; 没被用到的旧文件留在原地,用到哪个搬哪个。 验证: - 新增 avatar_test.go 两个用例(缓存路径、旧位置搬家)和 notify_test.go 的 TestManualMergeSendsMergeDoneNotify(真跑一次合并,断言 webhook 与 MeoW 各收到 一条 mergeDone,且从文件名认出主播);qualities_test.go 改成新语义,新增 resolveQualityBitrate(7 种档位/码率组合)和 guessUIDByFileName。 - 端到端:把旧位置的头像缓存放在录播目录、头像地址指向连不通的端口,合并仍然成功 且画面里有那张头像(纯红像素 400 个),证明是搬来的而不是重新下载的;文件也从 录播目录搬到了程序目录。 - 浏览器:verify_streamer_br.py 重写成档位下拉的 20 项断言,verify_qualities.py 更新为新语义,两个都全过;verify_settings.py / verify_pick.py / verify_danmu_match.py 无回归;go test ./...、gofmt、go vet 干净。
170 lines
5.1 KiB
Go
170 lines
5.1 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha1"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/gif"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// 2x2 的静态 webp(纯红),用来确认 webp 解码器确实注册上了
|
|
const tinyWebPBase64 = "UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoCAAIAAgA0JaACdLoB+AADsAD+8Oj3/yC5YXXI1/8gP+QH/ID/+PIAAAA="
|
|
|
|
// 多帧 gif 解出来应该是第一帧(AcFun 上有动图头像,压成静态就用第一帧)
|
|
func TestDecodeAvatarGifTakesFirstFrame(t *testing.T) {
|
|
pal := color.Palette{color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255}}
|
|
first := image.NewPaletted(image.Rect(0, 0, 8, 8), pal)
|
|
second := image.NewPaletted(image.Rect(0, 0, 8, 8), pal)
|
|
for i := range second.Pix {
|
|
second.Pix[i] = 1
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := gif.EncodeAll(&buf, &gif.GIF{
|
|
Image: []*image.Paletted{first, second},
|
|
Delay: []int{10, 10},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
src, err := decodeAvatar(buf.Bytes(), "image/gif")
|
|
if err != nil {
|
|
t.Fatalf("gif 解码失败:%v", err)
|
|
}
|
|
r, g, b, _ := src.At(4, 4).RGBA()
|
|
if r>>8 != 255 || g>>8 != 0 || b>>8 != 0 {
|
|
t.Errorf("应该取第一帧(红),实际拿到 r=%d g=%d b=%d", r>>8, g>>8, b>>8)
|
|
}
|
|
}
|
|
|
|
// jpg / png / 静态 webp 都能解,解不出来的要给中文原因
|
|
func TestDecodeAvatarFormats(t *testing.T) {
|
|
var pngBuf, jpgBuf bytes.Buffer
|
|
img := image.NewRGBA(image.Rect(0, 0, 8, 8))
|
|
if err := png.Encode(&pngBuf, img); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := jpeg.Encode(&jpgBuf, img, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
webpData, err := base64.StdEncoding.DecodeString(tinyWebPBase64)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
data []byte
|
|
contentType string
|
|
wantErr bool
|
|
}{
|
|
{"png", pngBuf.Bytes(), "image/png", false},
|
|
{"jpeg", jpgBuf.Bytes(), "image/jpeg", false},
|
|
{"webp", webpData, "image/webp", false},
|
|
{"不是图片", []byte("这不是图片"), "text/html", true},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
src, err := decodeAvatar(c.data, c.contentType)
|
|
if c.wantErr {
|
|
if err == nil {
|
|
t.Fatal("应该报错")
|
|
}
|
|
if !bytes.Contains([]byte(err.Error()), []byte(c.contentType)) {
|
|
t.Errorf("错误信息里应该带上 Content-Type 方便排查:%v", err)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("解码失败:%v", err)
|
|
}
|
|
if src == nil {
|
|
t.Fatal("解出来是空的")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 解出来的头像要能走通圆形裁切 + 缓存用的 PNG 编码(gif 是 *image.Paletted,和 jpg/png 的底层类型都不同)
|
|
func TestDecodeAvatarGifGoesThroughCircleAvatar(t *testing.T) {
|
|
pal := color.Palette{color.RGBA{G: 255, A: 255}, color.RGBA{R: 255, B: 255, A: 255}}
|
|
frame := image.NewPaletted(image.Rect(0, 0, 16, 16), pal)
|
|
var buf bytes.Buffer
|
|
if err := gif.EncodeAll(&buf, &gif.GIF{Image: []*image.Paletted{frame}, Delay: []int{10}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
src, err := decodeAvatar(buf.Bytes(), "image/gif")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var out bytes.Buffer
|
|
if err := png.Encode(&out, circleAvatar(src, avatarCacheSize)); err != nil {
|
|
t.Fatalf("圆形裁切后的 PNG 编码失败:%v", err)
|
|
}
|
|
if out.Len() == 0 {
|
|
t.Fatal("编码出来是空的")
|
|
}
|
|
}
|
|
|
|
// 头像缓存在程序所在目录,不再跟着保存目录跑
|
|
func TestAvatarCachePathUnderExeDir(t *testing.T) {
|
|
old := exeDir
|
|
defer func() { exeDir = old }()
|
|
|
|
exeDir = filepath.Join("C:", "app", "acfunlive")
|
|
want := filepath.Join("C:", "app", "acfunlive", avatarCacheDir)
|
|
if got := avatarCachePath(); got != want {
|
|
t.Errorf("应缓存到 %q,实际 %q", want, got)
|
|
}
|
|
|
|
// 取不到程序目录时退回相对路径,别拼出 /avatar_cache 这种绝对路径
|
|
exeDir = ""
|
|
if got := avatarCachePath(); got != avatarCacheDir {
|
|
t.Errorf("没有程序目录时应退回 %q,实际 %q", avatarCacheDir, got)
|
|
}
|
|
}
|
|
|
|
// 旧版本把头像存在录播目录里,换保存目录后要能直接搬过来用,不重新下载
|
|
func TestEnsureAvatarMigratesLegacyCache(t *testing.T) {
|
|
root := t.TempDir()
|
|
cacheDir := filepath.Join(root, "program", avatarCacheDir)
|
|
legacyDir := filepath.Join(root, "record", avatarCacheDir)
|
|
if err := os.MkdirAll(legacyDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// 这个地址连不通:命中旧缓存就不该去下载(下载会重试 3 次,很慢)
|
|
url := "http://127.0.0.1:9/never.png"
|
|
sum := sha1.Sum([]byte(url))
|
|
name := fmt.Sprintf("%d_%x.png", int64(7), sum[:6])
|
|
old := filepath.Join(legacyDir, name)
|
|
if err := os.WriteFile(old, []byte("cached-png"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got := ensureAvatar(7, url, cacheDir, legacyDir)
|
|
want := filepath.Join(cacheDir, name)
|
|
if got != want {
|
|
t.Fatalf("应搬到 %q,实际 %q", want, got)
|
|
}
|
|
data, err := os.ReadFile(want)
|
|
if err != nil || string(data) != "cached-png" {
|
|
t.Fatalf("搬过来的内容不对:%v %q", err, data)
|
|
}
|
|
if _, err := os.Stat(old); !os.IsNotExist(err) {
|
|
t.Errorf("旧位置的文件应被搬走,实际还在:%v", err)
|
|
}
|
|
|
|
// 已经在程序目录里的直接命中,不用管旧目录
|
|
if got := ensureAvatar(7, url, cacheDir, legacyDir); got != want {
|
|
t.Errorf("命中缓存应返回 %q,实际 %q", want, got)
|
|
}
|
|
}
|