主播列表的码率改为直播实际档位下拉,没选过时默认第二档

原来是一个手填的码率上限(Kbps),现在改成直播的实际档位(蓝光 8M / 高清…):

- 读到档位后,主播那一行渲染成下拉,选完立即生效;正在录制时换档会自动重录。
- 没手动选过(bitrate 0)的主播按**自上往下第二档**定下来(如
  蓝光 8M / 蓝光 4M / 高清 就选蓝光 4M,只有一档就用那一档),不会一上来就吃满
  最高档;已经手动选过的永远不动。
- 档位要开播后读直播源列表才知道,读到以后(录制开始时、或页面点「档位」按钮)
  缓存进 config.json 的 qualities,所以没开播也能看和改;启动时会把「有缓存但还
  没定过码率」的主播按第二档补齐。
- 从来没读过档位的行仍然是手填输入框,旁边多了个「档位」按钮去直播间接一次。

后端:新增 `GET /api/streamer/qualities?uid=…`(同一主播 10 秒内只读一次),
`/api/status` 的每个主播多了 `qualities`(按码率从高到低)和 `qualityName`;
抽出 `newStreamInfo()`/`fetchStreamQualities()`,`getStreamURL()` 取流时顺手缓存
档位,并在 bitrate 为 0 时改用刚定下来的默认档位(否则还是最高档)。
档位相关的纯逻辑放在 config.go:`sortQualitiesDesc` / `defaultQualityBitrate` /
`qualityNameFor` / `sameQualities` / `setStreamerQualities` / `applyCachedDefaultQualities`。

测试:qualities_test.go 7 个用例(排序、默认第二档的 5 种档位数、档位名匹配、
写配置时不覆盖手动选择、启动补齐、JSON 存取往返);浏览器实测 5 组断言全过
(下拉选中当前档位、换档服务端立即变、无档位时保留输入框+按钮、启动默认第二档、
手填仍可用),verify_streamer_br.py / verify_pick.py / verify_settings.py 无回归;
go test ./...、gofmt、go vet 干净。
This commit is contained in:
tamakyi
2026-09-25 19:42:47 +08:00
parent a891094f29
commit eda83dd1e3
7 changed files with 505 additions and 36 deletions
+200
View File
@@ -0,0 +1,200 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func tiersForTest() []qualityTier {
// 故意乱序,验证排序
return []qualityTier{
{Name: "高清", Bitrate: 2000},
{Name: "蓝光 8M", Bitrate: 8000},
{Name: "标清", Bitrate: 1000},
{Name: "蓝光 4M", Bitrate: 4000},
}
}
func TestSortQualitiesDesc(t *testing.T) {
got := sortQualitiesDesc(tiersForTest())
want := []string{"蓝光 8M", "蓝光 4M", "高清", "标清"}
for i, name := range want {
if got[i].Name != name {
t.Fatalf("第 %d 个应为 %s,实际 %s(%v)", i, name, got[i].Name, got)
}
}
// 不能改动传进来的切片
in := tiersForTest()
_ = sortQualitiesDesc(in)
if in[0].Name != "高清" {
t.Errorf("排序不该改动原切片:%v", in)
}
}
// 默认档位 = 自上往下第二档(不是最高档)
func TestDefaultQualityBitrate(t *testing.T) {
cases := []struct {
name string
tiers []qualityTier
want int
}{
{"四档取第二高", tiersForTest(), 4000},
{"三档取第二高(你给的例子)", []qualityTier{{"高清", 2000}, {"蓝光 8M", 8000}, {"蓝光 4M", 4000}}, 4000},
{"两档取低的那档", []qualityTier{{"蓝光 4M", 4000}, {"高清", 2000}}, 2000},
{"只有一档就用它", []qualityTier{{"高清", 2000}}, 2000},
{"没有档位返回 0", nil, 0},
}
for _, c := range cases {
if got := defaultQualityBitrate(c.tiers); got != c.want {
t.Errorf("%s:应为 %d,实际 %d", c.name, c.want, got)
}
}
}
func TestQualityNameFor(t *testing.T) {
if got := qualityNameFor(tiersForTest(), 4000); got != "蓝光 4M" {
t.Errorf("4000 应匹配「蓝光 4M」,实际 %q", got)
}
if got := qualityNameFor(tiersForTest(), 9999); got != "" {
t.Errorf("匹配不上应返回空,实际 %q", got)
}
}
func TestSameQualities(t *testing.T) {
a := tiersForTest()
b := []qualityTier{{"标清", 1000}, {"蓝光 4M", 4000}, {"高清", 2000}, {"蓝光 8M", 8000}}
if !sameQualities(a, b) {
t.Error("顺序不同但内容相同的档位列表应视为一样")
}
c := append([]qualityTier(nil), a[:3]...)
if sameQualities(a, c) {
t.Error("档位数量不同应视为不一样")
}
}
// 记录档位时顺便把「还没设过码率」的主播按默认档位定下来
func TestSetStreamerQualities(t *testing.T) {
savedConfig := config
savedStreamers := streamers.crt
defer func() {
config = savedConfig
streamers.crt = savedStreamers
}()
config.UIDs = uidList{
{UID: 1, Danmu: true, Record: true, Bitrate: 0}, // 没设过 → 应被定成第二档
{UID: 2, Danmu: true, Record: true, Bitrate: 1000}, // 手动设过 → 不动
}
streamers.crt = map[int]streamer{
1: {UID: 1, Bitrate: 0},
2: {UID: 2, Bitrate: 1000},
}
if !setStreamerQualities(1, tiersForTest()) {
t.Error("第一次记录档位应该算改动了配置")
}
if config.UIDs[0].Bitrate != 4000 {
t.Errorf("uid 1 没设过码率,应被定成第二档 4000,实际 %d", config.UIDs[0].Bitrate)
}
if streamers.crt[1].Bitrate != 4000 || len(streamers.crt[1].Qualities) != 4 {
t.Errorf("运行时的主播数据没同步:%+v", streamers.crt[1])
}
// 手动设过的不能被默认档位覆盖
setStreamerQualities(2, tiersForTest())
if config.UIDs[1].Bitrate != 1000 {
t.Errorf("uid 2 手动设过 1000,不该被改成 %d", config.UIDs[1].Bitrate)
}
// 同样的档位再来一次不算改动
if setStreamerQualities(1, tiersForTest()) {
t.Error("档位没变化时不该算改动")
}
// 档位变了才算改动
if !setStreamerQualities(1, []qualityTier{{"高清", 2000}, {"流畅", 500}}) {
t.Error("档位变化时应算改动")
}
}
// 启动时按缓存里的档位把默认码率补齐
func TestApplyCachedDefaultQualities(t *testing.T) {
savedConfig := config
savedStreamers := streamers.crt
defer func() {
config = savedConfig
streamers.crt = savedStreamers
}()
config.UIDs = uidList{
{UID: 1, Bitrate: 0, Qualities: tiersForTest()}, // 有缓存、没定过 → 补上
{UID: 2, Bitrate: 2000, Qualities: tiersForTest()}, // 已经有值 → 不动
{UID: 3, Bitrate: 0}, // 没缓存 → 不动
}
streamers.crt = map[int]streamer{1: {UID: 1}, 2: {UID: 2, Bitrate: 2000}, 3: {UID: 3}}
if !applyCachedDefaultQualities() {
t.Fatal("有需要补的主播时应算改动")
}
if config.UIDs[0].Bitrate != 4000 {
t.Errorf("uid 1 应补成 4000,实际 %d", config.UIDs[0].Bitrate)
}
if config.UIDs[1].Bitrate != 2000 {
t.Errorf("uid 2 不该被改动,实际 %d", config.UIDs[1].Bitrate)
}
if config.UIDs[2].Bitrate != 0 {
t.Errorf("uid 3 没有档位缓存,不该被改动,实际 %d", config.UIDs[2].Bitrate)
}
}
// 档位要能存进 config.json 再读回来
func TestQualitiesConfigRoundTrip(t *testing.T) {
savedConfig := config
savedLocation := configFileLocation
oldDir := configDir
if configDir == nil {
configDir = new(string)
}
defer func() {
config = savedConfig
configFileLocation = savedLocation
configDir = oldDir
}()
dir := t.TempDir()
*configDir = dir
configFileLocation = filepath.Join(dir, configFile)
body := `{"uids":[{"uid":23682490,"danmu":true,"record":true,"bitrate":4000,
"qualities":[{"name":"蓝光 8M","bitrate":8000},{"name":"蓝光 4M","bitrate":4000}]}]}`
if err := os.WriteFile(configFileLocation, []byte(body), 0644); err != nil {
t.Fatal(err)
}
config = configData{}
loadConfig()
if len(config.UIDs) != 1 {
t.Fatalf("应解析出 1 个主播,实际 %d", len(config.UIDs))
}
u := config.UIDs[0]
if u.Bitrate != 4000 || len(u.Qualities) != 2 || u.Qualities[0].Name != "蓝光 8M" {
t.Fatalf("档位没正确读出来:%+v", u)
}
if got := qualityNameFor(u.Qualities, u.Bitrate); got != "蓝光 4M" {
t.Errorf("当前档位名应为「蓝光 4M」,实际 %q", got)
}
// 再序列化一次,字段名要对得上(qualities / name / bitrate)
config.UIDs[0].Qualities = sortQualitiesDesc(config.UIDs[0].Qualities)
data, err := json.Marshal(config.UIDs[0])
if err != nil {
t.Fatal(err)
}
for _, key := range []string{`"qualities"`, `"name"`, `"bitrate"`} {
if !json.Valid(data) || !strings.Contains(string(data), key) {
t.Errorf("序列化结果里应包含 %s:%s", key, data)
}
}
}