档位改成「自动·第二高 + 实际档位」的下拉;手动合并也推送;头像缓存挪到程序目录

三件事:

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 干净。
This commit is contained in:
tamakyi
2026-09-25 20:03:24 +08:00
parent eda83dd1e3
commit 348e3bd675
11 changed files with 400 additions and 155 deletions
+55 -73
View File
@@ -98,8 +98,7 @@ const webuiHTML = `<!DOCTYPE html>
input[readonly]{background:var(--soft2);color:var(--sub)}
#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}
select.brsel{padding:6px;max-width:190px}
button.mini{padding:4px 6px;font-size:11.5px}
/* 开关 */
.sws{display:flex;flex-wrap:wrap;gap:10px 22px}
@@ -241,7 +240,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">码率这里选的是直播的实际档位(如蓝光4M / 高清),开播后自动读取,也能点「档位」按钮手动读一次;没手动选过的默认用<b>第二档</b>(自上往下第二高)。正在录制时换档会自动重录。没读过档位前可以直接填码率上限(Kbps,0 = 用默认档位)</div>
<div class="hint" style="margin:0 0 10px">码率这里选的是直播的<b>实际档位</b>(如蓝光 4M / 高清),开播后自动读取,也能点每行的「档位」按钮立刻读一次。默认「自动 · 第二高」= 按这次读到档位排序自上往下取第二高(不一定有蓝光 8M,所以只按实际档位排),也可以直接选最高、最低或中间某一档。正在录制时换档会自动重录。</div>
<div id="st"></div>
</div>
<div class="card">
@@ -275,7 +274,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="0"></div>
<div class="f"><label>ffmpeg 路径(留空自动查找)</label>
<div class="pathf">
<input type="text" id="ffmpegPath" placeholder="例如 D:\ffmpeg\bin\ffmpeg.exe">
@@ -689,23 +688,29 @@ async function refresh(){
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>';
// 档位一律用下拉:第一项「自动 · 第二高」(跟着实际档位排序走,值 0),
// 后面是这次读到的实际档位。手动选过的档位就是选中项,随时能选回「自动」。
var autoLabel = '自动 · 第二高';
if(!s.bitrate){
autoLabel += s.qualityName ? '(当前 = ' + esc(s.qualityName) + ')' : (qs.length ? '' : '(开播后才有)');
}
var opts = '<option value="0"' + (s.bitrate ? '' : ' selected') + '>' + autoLabel + '</option>';
var matched = false;
qs.forEach(function(q){
if(q.bitrate === s.bitrate) matched = true;
opts += '<option value="' + q.bitrate + '"' + (q.bitrate === s.bitrate ? " selected" : "") + '>'
+ esc(q.name) + '(' + q.bitrate + 'K)</option>';
});
// 指定过的档位现在这一场没有(比如主播换了档位表)时补一项,免得选中项空着
if(s.bitrate && !matched){
opts += '<option value="' + s.bitrate + '" selected>已指定:' + (s.qualityName || (s.bitrate + 'K'))
+ '(' + s.bitrate + 'K)</option>';
}
// 还没读到档位表时下拉里只有「自动」,旁边给个按钮去直播间接一次
var tip = qs.length ? '录制档位,正在录制时换档会自动重录;「自动 · 第二高」跟着实际档位排序走'
: '还没读到档位表(开播后才拿得到),点右边按钮去录一次';
var brCell = '<select class="brsel" data-uid="' + s.uid + '" title="' + tip + '">' + opts + '</select>'
+ '<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>'
@@ -721,20 +726,10 @@ async function refresh(){
}).join("");
var list = document.getElementById("st");
var sig = JSON.stringify(st.streamers);
// 数据没变化不重建;正在列表里输入时也跳过,避免把没提交的输入清掉
// 数据没变化不重建;正在列表里操作时也跳过
if(sig !== stSig && !list.contains(document.activeElement)){
stSig = sig;
// 重建前把用户改过、还没提交的码率记下来,建完再抄回去(填错的不用留)
var keepBr = {};
Array.prototype.forEach.call(list.querySelectorAll("input.br"), function(el){
var v = parseInt(el.value.trim(), 10);
if(el.dataset.dirty === "1" && !isNaN(v) && v >= 0) keepBr[el.dataset.uid] = el.value;
});
list.innerHTML = rows || '<div class="off">列表为空,在上面填 uid 添加</div>';
Object.keys(keepBr).forEach(function(uid){
var el = list.querySelector('input.br[data-uid="' + uid + '"]');
if(el){ el.value = keepBr[uid]; el.dataset.dirty = "1"; }
});
}
// 开播了但还没有档位缓存的,自动去读一次,让码率下拉能用
autoLoadQualities(st.streamers);
@@ -1246,13 +1241,6 @@ async function delUid(uid){
if(!confirm("确定删除 uid " + uid + " 并停止其录制?")) return;
post("/api/streamer/remove", {uid: uid});
}
// 码率填错时把框里的内容恢复成服务端记录的值,不能干等下一次列表重建
function restoreBr(el){
var uid = parseInt(el.dataset.uid, 10);
var cur = (lastSt && lastSt.streamers ? lastSt.streamers : []).filter(function(s){ return s.uid === uid; })[0];
delete el.dataset.dirty;
el.value = cur ? (cur.bitrate || 0) : 0;
}
document.addEventListener("change", async function(e){
var t = e.target;
if(t.matches('input[type=checkbox][data-uid]')){
@@ -1260,10 +1248,8 @@ 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') || row.querySelector('select.brsel');
var bitrate = parseInt(brEl.value, 10) || 0;
// 勾选时会把同一行的码率一起提交,提交成功后就不算「未提交的输入」了
if(await post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate}) && brEl.dataset) delete brEl.dataset.dirty;
var bitrate = parseInt(row.querySelector('select.brsel').value, 10) || 0;
post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate});
}
if(t.matches('select.brsel')){
// 档位下拉是选完立即生效的(正在录制时会自动换档重录)
@@ -1274,22 +1260,14 @@ document.addEventListener("change", async function(e){
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);
var row2 = t.closest(".row");
var v = parseInt(t.value.trim(), 10);
if(isNaN(v) || v < 0){ showMsg(false, "码率必须是大于等于 0 的数字"); restoreBr(t); return; }
var danmu2 = row2.querySelector('input[data-k=danmu]').checked;
var record2 = row2.querySelector('input[data-k=record]').checked;
// 提交成功才能把「未提交」标记去掉,列表重建时才不会把它当成用户还在填
if(await post("/api/streamer", {uid: uid2, danmu: danmu2, record: record2, bitrate: v})) delete t.dataset.dirty;
}
});
// 去直播间接一次码率档位;拿到后列表会显示成档位下拉,没设过码率的会被服务端按第二档定下来
// 去直播间接一次码率档位;拿到后下拉里就列出实际档位(0 = 自动第二高)
var qualitiesTried = {};
async function loadQualities(uid){
async function loadQualities(uid, quiet){
if(qualitiesTried[uid]) return;
qualitiesTried[uid] = true;
// 主播没开播时服务端要重试三次才放弃(约 5 秒),先给个反馈,别让人以为按钮没反应
if(!quiet) showMsg(true, "正在读取码率档位…");
var r = null;
try{ r = await api("/api/streamer/qualities?uid=" + uid); }catch(e){}
if(!r || !r.ok){
@@ -1302,10 +1280,10 @@ async function loadQualities(uid){
showMsg(true, "已读取 " + (d.qualities || []).length + " 个码率档位");
await refresh();
}
// 开播但还没档位缓存时自动读一次,让下拉直接能用
// 开播但还没档位缓存时自动读一次,让下拉直接能用(这种必定成功,不用提示「正在读取」)
function autoLoadQualities(list){
(list || []).forEach(function(s){
if(s.live && !(s.qualities || []).length) loadQualities(s.uid);
if(s.live && !(s.qualities || []).length) loadQualities(s.uid, true);
});
}
// 回到地址栏或上次看的那个分区
@@ -1321,18 +1299,19 @@ 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),对应档位里的一个档
Qualities []qualityTier `json:"qualities"` // 可选档位(按码率从高到低),没探测到时为空
QualityName string `json:"qualityName"` // 当前码率对应的档位名
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),0 = 自动(实际档位第二高)
Qualities []qualityTier `json:"qualities"` // 可选档位(按码率从高到低),没探测到时为空
QualityBitrate int `json:"qualityBitrate"` // 实际会用到的档位码率(自动时就是第二高的那个)
QualityName string `json:"qualityName"` // 实际会用到的档位名
DanmuActive bool `json:"danmuActive"` // 是否正在录制弹幕
RecordActive bool `json:"recordActive"` // 是否正在录制视频
Title string `json:"title"`
URL string `json:"url"`
}
// web UI 的状态数据
@@ -1440,7 +1419,8 @@ func statusHandler(w http.ResponseWriter, r *http.Request) {
}
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)
ws.QualityBitrate = resolveQualityBitrate(s.Qualities, s.Bitrate)
ws.QualityName = qualityNameFor(s.Qualities, ws.QualityBitrate)
if infoList, ok := getLiveInfoByUID(s.UID); ok {
for _, info := range infoList {
if info.isDanmu {
@@ -1692,11 +1672,13 @@ func streamerQualitiesHandler(w http.ResponseWriter, r *http.Request) {
}
streamers.RUnlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
used := resolveQualityBitrate(tiers, bitrate)
_ = json.NewEncoder(w).Encode(map[string]any{
"uid": uid,
"qualities": sortQualitiesDesc(tiers),
"bitrate": bitrate,
"qualityName": qualityNameFor(tiers, bitrate),
"uid": uid,
"qualities": sortQualitiesDesc(tiers),
"bitrate": bitrate,
"qualityBitrate": used,
"qualityName": qualityNameFor(tiers, used),
})
}