主播列表的码率改为直播实际档位下拉,没选过时默认第二档
原来是一个手填的码率上限(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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user