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

原来是一个手填的码率上限(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
+127 -15
View File
@@ -99,6 +99,8 @@ const webuiHTML = `<!DOCTYPE html>
#mrgInfo{white-space:pre-wrap;margin-top:10px;font-size:12.5px;line-height:1.7;color:var(--text);background:var(--soft2);border-radius:var(--radius-s);padding:10px 12px}
#mrgInfo:empty{display:none}
input.br{width:92px;text-align:center;padding:6px 8px}
select.brsel{padding:6px 6px;max-width:150px}
button.mini{padding:4px 6px;font-size:11.5px}
/* 开关 */
.sws{display:flex;flex-wrap:wrap;gap:10px 22px}
.sw{display:inline-flex;align-items:center;gap:9px;cursor:pointer;font-size:13px;position:relative}
@@ -239,7 +241,7 @@ const webuiHTML = `<!DOCTYPE html>
</div>
<div class="card">
<div class="card-h"><h2>主播列表</h2><span class="sub">勾选 = 开启对应录制,改完立即生效</span></div>
<div class="hint" style="margin:0 0 10px">下载码率上限 = 挑选码率不超过该值的直播源档位(2000 约超清档),0 = 最高档;正在录制时修改会自动换档重新开始</div>
<div class="hint" style="margin:0 0 10px">码率这里选的是直播的实际档位(如蓝光4M / 高清),开播后自动读取,也能点「档位」按钮手动读一次;没手动选过的默认用<b>第二档</b>(自上往下第二高)。正在录制时换档会自动重录。没读过档位前可以直接填码率上限(Kbps,0 = 用默认档位)</div>
<div id="st"></div>
</div>
<div class="card">
@@ -273,7 +275,7 @@ const webuiHTML = `<!DOCTYPE html>
<option value="flv">flv(兼容性一般)</option>
<option value="mkv">mkv(可装多音轨)</option>
</select></div>
<div class="f"><label>默认下载码率上限(Kbps,0 = 最高档)</label><input type="text" id="defaultBitrate" inputmode="numeric" placeholder="例如 2000"></div>
<div class="f"><label>默认下载码率上限(Kbps,0 = 新主播按实际档位取第二档)</label><input type="text" id="defaultBitrate" inputmode="numeric" placeholder="例如 2000"></div>
<div class="f"><label>ffmpeg 路径(留空自动查找)</label>
<div class="pathf">
<input type="text" id="ffmpegPath" placeholder="例如 D:\ffmpeg\bin\ffmpeg.exe">
@@ -686,12 +688,30 @@ async function refresh(){
if(s.danmuActive) badges += '<span class="badge rec">弹幕中</span>';
if(!badges) badges = s.live ? '<span class="badge on">直播中</span>' : '<span class="off">离线</span>';
var title = esc(s.title);
var qs = s.qualities || [];
var brCell;
if(qs.length){
// 有档位列表就用下拉,值是档位的码率;当前值匹配不上档位时补一个「当前设置」
var opts = '';
var matched = false;
qs.forEach(function(q){
var sel = (q.bitrate === s.bitrate);
if(sel) matched = true;
opts += '<option value="' + q.bitrate + '"' + (sel ? " selected" : "") + '>' + esc(q.name) + '(' + q.bitrate + 'K)</option>';
});
if(!matched) opts = '<option value="' + (s.bitrate||0) + '" selected>当前设置(' + (s.bitrate||0) + 'K)</option>' + opts;
brCell = '<select class="brsel" data-uid="' + s.uid + '" title="下载码率档位,正在录制时换档会自动重录">' + opts + '</select>';
}else{
brCell = '<input type="text" class="br" inputmode="numeric" data-uid="' + s.uid + '" value="' + (s.bitrate||0)
+ '" placeholder="0=默认" title="下载码率上限(Kbps);开播后点右边按钮读档位">'
+ '<button class="ghost mini" onclick="loadQualities(' + s.uid + ')" title="去直播间接一次码率档位">档位</button>';
}
return '<div class="row">'
+ '<span class="uid">uid ' + s.uid + '</span>'
+ '<span class="name">' + (esc(s.name) || "uid " + s.uid) + '</span>'
+ '<label class="pill"><input type="checkbox" data-uid="' + s.uid + '" data-k="danmu"' + (s.danmu?" checked":"") + '>弹幕</label>'
+ '<label class="pill"><input type="checkbox" data-uid="' + s.uid + '" data-k="record"' + (s.record?" checked":"") + '>录播</label>'
+ '<input type="text" class="br" inputmode="numeric" data-uid="' + s.uid + '" value="' + (s.bitrate||0) + '" placeholder="0=最高" title="下载码率上限(Kbps),0 = 最高档">'
+ brCell
+ '<span class="state">' + badges + '</span>'
+ '<span class="title"' + (title?' title="' + title + '"':'') + '>' + title + '</span>'
+ '<span class="ops">'
@@ -716,6 +736,8 @@ async function refresh(){
if(el){ el.value = keepBr[uid]; el.dataset.dirty = "1"; }
});
}
// 开播了但还没有档位缓存的,自动去读一次,让码率下拉能用
autoLoadQualities(st.streamers);
var lr = await api("/api/log?since=" + logOffset);
if(lr.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
var off = lr.headers.get("X-Log-Offset");
@@ -1238,10 +1260,19 @@ document.addEventListener("change", async function(e){
var row = t.closest(".row");
var danmu = row.querySelector('input[data-k=danmu]').checked;
var record = row.querySelector('input[data-k=record]').checked;
var brEl = row.querySelector('input.br');
var brEl = row.querySelector('input.br') || row.querySelector('select.brsel');
var bitrate = parseInt(brEl.value, 10) || 0;
// 勾选时会把同一行的码率一起提交,提交成功后就不算「未提交的输入」了
if(await post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate})) delete brEl.dataset.dirty;
if(await post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate}) && brEl.dataset) delete brEl.dataset.dirty;
}
if(t.matches('select.brsel')){
// 档位下拉是选完立即生效的(正在录制时会自动换档重录)
var uid3 = parseInt(t.dataset.uid, 10);
var row3 = t.closest(".row");
var v3 = parseInt(t.value, 10) || 0;
var danmu3 = row3.querySelector('input[data-k=danmu]').checked;
var record3 = row3.querySelector('input[data-k=record]').checked;
await post("/api/streamer", {uid: uid3, danmu: danmu3, record: record3, bitrate: v3});
}
if(t.matches('input.br')){
var uid2 = parseInt(t.dataset.uid, 10);
@@ -1254,6 +1285,29 @@ document.addEventListener("change", async function(e){
if(await post("/api/streamer", {uid: uid2, danmu: danmu2, record: record2, bitrate: v})) delete t.dataset.dirty;
}
});
// 去直播间接一次码率档位;拿到后列表会显示成档位下拉,没设过码率的会被服务端按第二档定下来
var qualitiesTried = {};
async function loadQualities(uid){
if(qualitiesTried[uid]) return;
qualitiesTried[uid] = true;
var r = null;
try{ r = await api("/api/streamer/qualities?uid=" + uid); }catch(e){}
if(!r || !r.ok){
showMsg(false, r ? await r.text() : "连接失败");
qualitiesTried[uid] = false;
return;
}
var d = null;
try{ d = await r.json(); }catch(e){ return; }
showMsg(true, "已读取 " + (d.qualities || []).length + " 个码率档位");
await refresh();
}
// 开播但还没档位缓存时自动读一次,让下拉直接能用
function autoLoadQualities(list){
(list || []).forEach(function(s){
if(s.live && !(s.qualities || []).length) loadQualities(s.uid);
});
}
// 回到地址栏或上次看的那个分区
showTab(location.hash.slice(1) || localStorage.getItem("acfunlive_tab") || "mon");
// 先试一次(用本地保存的口令),失败会自动弹出输入框并停止轮询
@@ -1267,16 +1321,18 @@ document.getElementById("tokenInput").addEventListener("keydown", function(e){ i
// web UI 的主播状态
type webStreamer struct {
UID int `json:"uid"`
Name string `json:"name"`
Live bool `json:"live"`
Danmu bool `json:"danmu"` // 弹幕录制开关
Record bool `json:"record"` // 视频录制开关
Bitrate int `json:"bitrate"` // 录制的直播源最高码率(Kbps),0 表示码率最高的直播源
DanmuActive bool `json:"danmuActive"` // 是否正在录制弹幕
RecordActive bool `json:"recordActive"` // 是否正在录制视频
Title string `json:"title"`
URL string `json:"url"`
UID int `json:"uid"`
Name string `json:"name"`
Live bool `json:"live"`
Danmu bool `json:"danmu"` // 弹幕录制开关
Record bool `json:"record"` // 视频录制开关
Bitrate int `json:"bitrate"` // 录制的直播源码率(Kbps),对应档位里的一个档
Qualities []qualityTier `json:"qualities"` // 可选档位(按码率从高到低),没探测到时为空
QualityName string `json:"qualityName"` // 当前码率对应的档位名
DanmuActive bool `json:"danmuActive"` // 是否正在录制弹幕
RecordActive bool `json:"recordActive"` // 是否正在录制视频
Title string `json:"title"`
URL string `json:"url"`
}
// web UI 的状态数据
@@ -1383,6 +1439,8 @@ func statusHandler(w http.ResponseWriter, r *http.Request) {
title = s.getTitle()
}
ws := webStreamer{UID: s.UID, Name: s.Name, Live: live, Title: title, URL: s.getURL(), Danmu: s.Danmu, Record: s.Record, Bitrate: s.Bitrate}
ws.Qualities = sortQualitiesDesc(s.Qualities)
ws.QualityName = qualityNameFor(s.Qualities, s.Bitrate)
if infoList, ok := getLiveInfoByUID(s.UID); ok {
for _, info := range infoList {
if info.isDanmu {
@@ -1589,6 +1647,59 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
}
// 新增主播或修改主播的弹幕/录播开关和码率
// 档位探测的节流:同一个主播短时间内只去直播间接一次(页面上是点按钮才会来)
var qualitiesFetchMu struct {
sync.Mutex
at map[int]time.Time
}
// GET /api/streamer/qualities?uid=N —— 去直播间接一次该主播的码率档位。
// 拿到的档位会缓存进配置(没开播时页面也能显示下拉),还没设过码率的主播顺便按
// 默认档位(第二高)定下来。
func streamerQualitiesHandler(w http.ResponseWriter, r *http.Request) {
uid, err := strconv.Atoi(r.URL.Query().Get("uid"))
if err != nil || uid <= 0 {
http.Error(w, "uid 不合法", http.StatusBadRequest)
return
}
qualitiesFetchMu.Lock()
if qualitiesFetchMu.at == nil {
qualitiesFetchMu.at = make(map[int]time.Time)
}
last := qualitiesFetchMu.at[uid]
qualitiesFetchMu.Unlock()
if time.Since(last) < 10*time.Second {
http.Error(w, "刚读过一次档位,等几秒再试", http.StatusBadGateway)
return
}
tiers, err := fetchStreamQualities(uid)
if err != nil {
http.Error(w, "读取码率档位失败:"+err.Error(), http.StatusBadGateway)
return
}
qualitiesFetchMu.Lock()
qualitiesFetchMu.at[uid] = time.Now()
qualitiesFetchMu.Unlock()
if setStreamerQualities(uid, tiers) {
saveConfig()
}
bitrate := 0
streamers.RLock()
if s, ok := streamers.crt[uid]; ok {
bitrate = s.Bitrate
}
streamers.RUnlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{
"uid": uid,
"qualities": sortQualitiesDesc(tiers),
"bitrate": bitrate,
"qualityName": qualityNameFor(tiers, bitrate),
})
}
func webStreamerHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
UID int `json:"uid"`
@@ -1857,6 +1968,7 @@ func startWebUI(ctx context.Context) {
mux.HandleFunc("GET /api/status", statusHandler)
mux.HandleFunc("POST /api/config", webConfigHandler)
mux.HandleFunc("POST /api/streamer", webStreamerHandler)
mux.HandleFunc("GET /api/streamer/qualities", streamerQualitiesHandler)
mux.HandleFunc("POST /api/streamer/control", webStreamerControlHandler)
mux.HandleFunc("POST /api/streamer/remove", webStreamerRemoveHandler)
mux.HandleFunc("GET /api/log", logHandler)