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

原来是一个手填的码率上限(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
+115 -10
View File
@@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"syscall"
@@ -25,19 +26,27 @@ var configFileLocation string // 设置文件位置
// 主播的设置数据
type streamer struct {
UID int `json:"uid"` // 主播 uid
Name string `json:"name"` // 主播名字
Danmu bool `json:"-"` // 是否录制直播弹幕
Record bool `json:"-"` // 是否录制直播视频
Bitrate int `json:"-"` // 录制的直播源最高码率(Kbps),0 表示码率最高的直播源
UID int `json:"uid"` // 主播 uid
Name string `json:"name"` // 主播名字
Danmu bool `json:"-"` // 是否录制直播弹幕
Record bool `json:"-"` // 是否录制直播视频
Bitrate int `json:"-"` // 录制的直播源最高码率(Kbps),0 表示还没定(按实际档位取第二高)
Qualities []qualityTier `json:"-"` // 上次探测到的码率档位,没开播时页面靠它显示下拉
}
// 直播源的码率档位
type qualityTier struct {
Name string `json:"name"` // 档位名,如「蓝光4M」「高清」
Bitrate int `json:"bitrate"` // 档位码率(Kbps)
}
// 单个主播的开关设置
type uidSetting struct {
UID int `json:"uid"`
Danmu bool `json:"danmu"`
Record bool `json:"record"`
Bitrate int `json:"bitrate"` // 录制的直播源最高码率(Kbps),0 表示码率最高的直播源
UID int `json:"uid"`
Danmu bool `json:"danmu"`
Record bool `json:"record"`
Bitrate int `json:"bitrate"` // 录制的直播源最高码率(Kbps),0 表示还没定(按实际档位取第二高)
Qualities []qualityTier `json:"qualities,omitempty"` // 上次探测到的档位,缓存下来让没开播时也能在页面上选
}
// uidSetting 列表,兼容旧的纯数字数组格式(旧格式默认弹幕和录播都开启)
@@ -440,6 +449,7 @@ func buildStreamers() {
s.Danmu = u.Danmu
s.Record = u.Record
s.Bitrate = u.Bitrate
s.Qualities = u.Qualities
news[u.UID] = s
continue
}
@@ -447,11 +457,106 @@ func buildStreamers() {
if name == "" {
name = fmt.Sprintf("uid%d", u.UID)
}
news[u.UID] = streamer{UID: u.UID, Name: name, Danmu: u.Danmu, Record: u.Record, Bitrate: u.Bitrate}
news[u.UID] = streamer{UID: u.UID, Name: name, Danmu: u.Danmu, Record: u.Record, Bitrate: u.Bitrate, Qualities: u.Qualities}
}
streamers.crt = news
}
// 档位按码率从高到低排(页面的下拉按这个顺序显示)
func sortQualitiesDesc(tiers []qualityTier) []qualityTier {
out := append([]qualityTier(nil), tiers...)
sort.SliceStable(out, func(i, j int) bool { return out[i].Bitrate > out[j].Bitrate })
return out
}
// 没手动选过码率(0)时的默认档位:按码率自上往下取第二档;
// 只有一个档位就用它,没有档位返回 0(交给调用方保持原样)
func defaultQualityBitrate(tiers []qualityTier) int {
desc := sortQualitiesDesc(tiers)
switch len(desc) {
case 0:
return 0
case 1:
return desc[0].Bitrate
default:
return desc[1].Bitrate
}
}
// 某个码率对应的档位名,匹配不上返回空
func qualityNameFor(tiers []qualityTier, bitrate int) string {
for _, t := range sortQualitiesDesc(tiers) {
if t.Bitrate == bitrate {
return t.Name
}
}
return ""
}
// 两个档位列表是不是一样(避免每次都写配置)
func sameQualities(a, b []qualityTier) bool {
if len(a) != len(b) {
return false
}
da, db := sortQualitiesDesc(a), sortQualitiesDesc(b)
for i := range da {
if da[i] != db[i] {
return false
}
}
return true
}
// 记下某个主播当前的码率档位(离线时页面靠它显示下拉)。
// 顺手把「还没设过码率」的主播按默认档位(第二高)定下来 —— 这样默认不会一直吃满最高档。
// 返回是否改动了配置,调用方决定要不要 saveConfig。
func setStreamerQualities(uid int, tiers []qualityTier) bool {
if uid <= 0 || len(tiers) == 0 {
return false
}
changed := false
streamers.Lock()
for i := range config.UIDs {
if config.UIDs[i].UID != uid {
continue
}
if !sameQualities(config.UIDs[i].Qualities, tiers) {
config.UIDs[i].Qualities = tiers
changed = true
}
if config.UIDs[i].Bitrate == 0 {
if def := defaultQualityBitrate(tiers); def > 0 {
config.UIDs[i].Bitrate = def
changed = true
lPrintf("uid %d 还没设过下载码率,按当前直播档位默认取第二档:%s(%d Kbps)",
uid, qualityNameFor(tiers, def), def)
}
}
if s, ok := streamers.crt[uid]; ok {
s.Qualities = config.UIDs[i].Qualities
// 档位定下来后正在录的那场不打断,配置值从下次录制开始生效
s.Bitrate = config.UIDs[i].Bitrate
streamers.crt[uid] = s
}
break
}
streamers.Unlock()
return changed
}
// 启动时把「缓存过档位、但还没定过码率」的主播按默认档位定下来
func applyCachedDefaultQualities() bool {
changed := false
for _, u := range append(uidList(nil), config.UIDs...) {
if u.Bitrate == 0 && len(u.Qualities) > 0 {
if setStreamerQualities(u.UID, u.Qualities) {
changed = true
}
}
}
return changed
}
// 保存 config.json,写入串行化并原子替换,避免并发写坏文件或读到写了一半的内容
func saveConfig() {
configMu.Lock()