录制档位改成「最高/第二高/最低/自定义」四种相对偏好,没开播也能先设

上一版的下拉里列的是「这次读到的实际档位」,没读到档位表(没开播)时下拉里就只剩
一项,想预先设「我要最高档」或「最低档」都做不到。改成按「实际档位里的第几档」来选:

- 新增 `qualityPref`:top(最高档)/ second(第二高,默认)/ lowest(最低档)/
  custom(取不超过给定码率的最高档,都比它高时退回最低档)。四种偏好任何时候都
  在列表里,开播后自动对应到实际档位;读到档位表后把对应结果标在选项后面
  (`第二高(蓝光 4M)`、`自定义(≤2500K = 高清)`)。
- `bitrate` 只在 custom 时有意义。`resolveQualityBitrate(tiers, pref, custom)` 统一
  解析:相对偏好没有档位表时返回 0(取流退回最高档),custom 返回填的上限。
  解析只在取流时做,所以主播换了档位表,偏好会跟着一起变。
- 老配置兼容:没有 qualityPref 时,`bitrate > 0` 当成 custom(就是原来的「码率上限」,
  行为不变),`bitrate == 0` 当成默认的第二高;在 checkConfig 里补齐(不写盘,下次
  保存自然带上)。`setBitrate` 相应换成 `setQuality(uid, pref, custom)`。
- 页面:选 custom 时旁边出现码率框,初始值就是**当前实际在用的那一档**(不会一切过去
  就掉到最低档),回车或失焦即提交,填错给提示并回填服务端的值;从 custom 切到别的
  档位不带 bitrate,服务端保留原值,改回来还是原来那个数。
- 设置区同步:新主播的默认档位也用同一套四项(`defaultQualityPref`),只有选自定义
  才露出码率上限框(切过去会补一个能用的 3000,免得新主播默默用最低档)。

顺带修一个脚本抓到的真 bug:档位名传成了档位对象(`esc()` 里会 `.replace`),只要
档位表非空就会抛异常,而 refresh() 整段包在 try/catch 里 —— 表现是整个主播列表
变成空白且没有任何报错。现在取的是 `qs[i].name`。

测试:qualities_test.go 重写 TestResolveQualityBitrate(10 种偏好/码率组合)并新增
TestMigrateQualityPref、TestUpgradeQualityPrefs、TestSetQuality;verify_streamer_br.py
改成带迷你后端的假服务(POST 会真正落到假状态上),30 项断言全过;verify_qualities.py
25 项(含设置区与 config.json 往返)全过;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:42:22 +08:00
parent 7b853c1a22
commit 237fffa8fe
6 changed files with 549 additions and 215 deletions
+263 -145
View File
@@ -98,7 +98,8 @@ 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}
select.brsel{padding:6px;max-width:190px}
select.brsel{padding:6px;max-width:200px}
input.brnum{width:76px;text-align:center;padding:6px 8px}
button.mini{padding:4px 6px;font-size:11.5px}
/* 开关 */
.sws{display:flex;flex-wrap:wrap;gap:10px 22px}
@@ -274,7 +275,14 @@ 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="0"></div>
<div class="f"><label>新主播的默认录制档位</label>
<select id="defaultQualityPref">
<option value="top">最高档</option>
<option value="second">第二高</option>
<option value="lowest">最低档</option>
<option value="custom">自定义码率上限</option>
</select></div>
<div class="f" id="defBrRow"><label>默认自定义码率上限(Kbps,取不超过它的最高档)</label><input type="text" id="defaultBitrate" inputmode="numeric" placeholder="例如 3000"></div>
<div class="f"><label>ffmpeg 路径(留空自动查找)</label>
<div class="pathf">
<input type="text" id="ffmpegPath" placeholder="例如 D:\ffmpeg\bin\ffmpeg.exe">
@@ -479,7 +487,7 @@ var token = localStorage.getItem("acfunlive_token") || ""; // 管理口令,存
var authed = false; // 是否已通过口令校验,未通过时停止轮询
var tokenLoaded = false;
// 设置表单里的字段,元素 id 与 /api/config 的字段名一致
var settingFields = ["directory","autoMerge","mergeDelVideo","mergeDelDanmu","mergeOutputDir","mergeBitrate","danmuFontSize","defaultBitrate","recordSubdir","mergeSubdir","ffmpegPath","recordFormat","notifyEnabled","notifyUrl","notifyToken","notifyGroup","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","meowEnabled","meowNickname","meowMsgType","meowHtmlHeight","meowImgUrl","webExternal"];
var settingFields = ["directory","autoMerge","mergeDelVideo","mergeDelDanmu","mergeOutputDir","mergeBitrate","danmuFontSize","defaultQualityPref","defaultBitrate","recordSubdir","mergeSubdir","ffmpegPath","recordFormat","notifyEnabled","notifyUrl","notifyToken","notifyGroup","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","meowEnabled","meowNickname","meowMsgType","meowHtmlHeight","meowImgUrl","webExternal"];
var numberFields = {mergeBitrate:1, danmuFontSize:1, defaultBitrate:1, meowHtmlHeight:1};
// 用户改过、还没提交的字段。定时刷新会拿服务端的值填表单,不记下这些字段的话,
// 刚填好的数字 3 秒后会被刷回旧值,看起来就像「改了又自己恢复」
@@ -645,7 +653,9 @@ async function refresh(){
setVal("mergeOutputDir", st.mergeOutputDir||"");
setVal("mergeBitrate", st.mergeBitrate||0);
setVal("danmuFontSize", st.danmuFontSize||0);
setVal("defaultQualityPref", st.defaultQualityPref||"second");
setVal("defaultBitrate", st.defaultBitrate||0);
toggleDefaultBr();
setVal("recordSubdir", st.recordSubdir||"record");
setVal("mergeSubdir", st.mergeSubdir||"merged");
setVal("ffmpegPath", st.ffmpegPath||"");
@@ -688,30 +698,39 @@ 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 || [];
// 档位一律用下拉:第一项「自动 · 第二高」(跟着实际档位排序走,值 0),
// 后面是这次读到的实际档位。手动选过的档位就是选中项,随时能选回「自动」。
var autoLabel = '自动 · 第二高';
if(!s.bitrate){
autoLabel += s.qualityName ? '(当前 = ' + esc(s.qualityName) + ')' : (qs.length ? '' : '(开播后才有)');
// 档位是「按实际档位里的第几档」来选的:四种偏好永远都在(不开播也能先设),
// 读到档位表后把对应到的档位名标在后面
var nameFor = function(p){
if(p === "custom") return s.qualityName || "";
if(!qs.length) return "";
if(p === "top") return qs[0].name;
if(p === "lowest") return qs[qs.length-1].name;
return qs[qs.length > 1 ? 1 : 0].name; // 第二高
};
var cur = s.qualityPref || "second";
var qOpts = [["top", "最高档"], ["second", "第二高"], ["lowest", "最低档"], ["custom", "自定义"]];
var opts = qOpts.map(function(o){
var label = o[1];
if(o[0] === "custom"){
// 自定义:标出当前上限对应到哪个档位
if(s.bitrate > 0) label += "(≤" + s.bitrate + "K" + (s.qualityName ? " = " + esc(s.qualityName) : "") + ")";
else label += qs.length ? "(填个码率上限)" : "(填个码率上限,开播后对应)";
}else{
var n = nameFor(o[0]);
if(n) label += "(" + esc(n) + ")";
}
return '<option value="' + o[0] + '"' + (o[0] === cur ? " selected" : "") + '>' + label + '</option>';
}).join("");
var tip = qs.length ? '录制档位:按这次直播的实际档位对应,正在录制时换档会自动重录'
: '录制档位:还没读到档位表(开播后才拿得到),随时可以先设好;点右边按钮能立刻读一次';
// data-qbitrate 只是给排查看的:真正「当前生效的档位码率」会从服务端状态里取
var brCell = '<select class="brsel" data-uid="' + s.uid + '" data-qbitrate="' + (s.qualityBitrate || 0)
+ '" title="' + tip + '">' + opts + '</select>';
if(cur === "custom"){
brCell += '<input type="text" class="brnum" data-uid="' + s.uid + '" inputmode="numeric" value="'
+ (s.bitrate || 0) + '" placeholder="Kbps" title="自定义:取不超过这个码率的最高档">';
}
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>';
});
// 指定过的档位现在这一场没有(比如主播换了档位表、或还没读到档位表)时补一项,
// 免得选中项空着。>0 的语义是「不超过它的最高档」,所以文案写成 ≤
if(s.bitrate && !matched){
var pin = '已指定:≤' + s.bitrate + 'K' + (s.qualityName ? '(' + esc(s.qualityName) + ')' : '');
opts += '<option value="' + s.bitrate + '" selected>' + pin + '</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>';
brCell += '<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>'
@@ -730,9 +749,19 @@ async function refresh(){
// 数据没变化不重建;正在列表里操作时也跳过
if(sig !== stSig && !list.contains(document.activeElement)){
stSig = sig;
// 重建前记下用户改过、还没提交的自定义码率,建完抄回去(填错的不用留)
var keepNum = {};
Array.prototype.forEach.call(list.querySelectorAll("input.brnum"), function(el){
var v = parseInt(el.value.trim(), 10);
if(el.dataset.dirty === "1" && !isNaN(v) && v >= 0) keepNum[el.dataset.uid] = el.value;
});
list.innerHTML = rows || '<div class="off">列表为空,在上面填 uid 添加</div>';
Object.keys(keepNum).forEach(function(uid){
var el = list.querySelector('input.brnum[data-uid="' + uid + '"]');
if(el){ el.value = keepNum[uid]; el.dataset.dirty = "1"; }
});
}
// 开播了但还没有档位缓存的,自动去读一次,让码率下拉能用
// 开播了但还没有档位缓存的,自动去读一次,让档位下拉能标出对应关系
autoLoadQualities(st.streamers);
var lr = await api("/api/log?since=" + logOffset);
if(lr.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
@@ -796,6 +825,23 @@ async function saveSettings(b, autoSave){
["recordFormat","meowMsgType"].forEach(function(id){
document.getElementById(id).addEventListener("change", function(){ dropDirty(id); saveSettings(null, true); });
});
// 只有「自定义」才需要填码率上限
function toggleDefaultBr(){
document.getElementById("defBrRow").style.display =
document.getElementById("defaultQualityPref").value === "custom" ? "" : "none";
}
document.getElementById("defaultQualityPref").addEventListener("change", function(){
var el = document.getElementById("defaultBitrate");
// 切到自定义时补一个能用的初始值(只在用户主动切换时补,定时刷新不碰它),
// 不然新主播会按 0 落到最低档
if(this.value === "custom" && !(parseInt(el.value.trim(), 10) > 0)){
el.value = 3000;
dropDirty("defaultBitrate");
}
toggleDefaultBr();
dropDirty("defaultQualityPref");
saveSettings(null, true);
});
// 手动合并的视频框:手填/粘贴完(失焦)也自动找一次同名弹幕文件
document.getElementById("mrgVideos").addEventListener("change", function(){ autoMatchDanmu(); });
// HTML 高度只对 html 类型有意义,选 text 时收起来
@@ -809,6 +855,7 @@ document.addEventListener("input", function(e){
var el = e.target;
if(!el) return;
if(el.classList && el.classList.contains("br")){ el.dataset.dirty = "1"; return; }
if(el.classList && el.classList.contains("brnum")){ el.dataset.dirty = "1"; return; }
if(el.id && settingFields.indexOf(el.id) >= 0) markDirty(el.id);
});
// 回车等于点保存:输入框不在 form 里,浏览器默认不会提交,很容易以为已经生效了
@@ -816,7 +863,7 @@ document.addEventListener("keydown", function(e){
if(e.key !== "Enter" || e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return;
var el = e.target;
if(!el || el.tagName !== "INPUT" || el.type !== "text" || el.readOnly) return;
if(el.classList.contains("br")){ el.blur(); return; } // 码率失焦即提交
if(el.classList.contains("br") || el.classList.contains("brnum")){ el.blur(); return; } // 档位相关的框失焦即提交
if(el.id === "newuid"){ e.preventDefault(); addUid(); return; }
if(el.id === "authTokenNew"){ e.preventDefault(); changeToken(); return; }
if(el.id === "mrgOffset" || el.id === "mrgOut" || el.id === "mrgDanmu"){ e.preventDefault(); checkMerge(); return; }
@@ -1242,24 +1289,72 @@ async function delUid(uid){
if(!confirm("确定删除 uid " + uid + " 并停止其录制?")) return;
post("/api/streamer/remove", {uid: uid});
}
// 读一行里选的档位:pref 是下拉的值;自定义时把输入框里的码率一起带上,
// 输入框不在(刚切到自定义、或不是自定义)就不带 bitrate,服务端会保留原来填的值
function rowQuality(row){
var sel = row.querySelector("select.brsel");
var pref = sel ? sel.value : "second";
var num = row.querySelector("input.brnum");
var out = {qualityPref: pref};
if(pref === "custom"){
var v;
if(num){
v = parseInt(num.value.trim(), 10);
if(isNaN(v) || v < 0) return null;
}else{
// 刚切到自定义、输入框还没建出来:用「现在实际用到的档位码率」当起点,
// 免得切过去的一瞬间掉到最低档(uid 可能还没设过自定义值)
var uid = parseInt(sel.dataset.uid, 10);
var cur = (lastSt && lastSt.streamers ? lastSt.streamers : []).filter(function(s){ return s.uid === uid; })[0];
v = cur ? (cur.qualityBitrate || cur.bitrate || 0) : 0;
}
out.bitrate = v;
}
return out;
}
document.addEventListener("change", async function(e){
var t = e.target;
if(t.matches('input[type=checkbox][data-uid]')){
var uid = parseInt(t.dataset.uid, 10);
var row = t.closest(".row");
var danmu = row.querySelector('input[data-k=danmu]').checked;
var record = row.querySelector('input[data-k=record]').checked;
var bitrate = parseInt(row.querySelector('select.brsel').value, 10) || 0;
post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate});
var q = rowQuality(row);
if(!q){ showMsg(false, "自定义档位的码率必须是大于等于 0 的数字"); return; }
q.uid = uid;
q.danmu = row.querySelector('input[data-k=danmu]').checked;
q.record = row.querySelector('input[data-k=record]').checked;
post("/api/streamer", q);
}
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});
var q3 = rowQuality(row3);
if(!q3){ showMsg(false, "自定义档位的码率必须是大于等于 0 的数字"); return; }
q3.uid = uid3;
q3.danmu = row3.querySelector('input[data-k=danmu]').checked;
q3.record = row3.querySelector('input[data-k=record]').checked;
await post("/api/streamer", q3);
}
// 自定义档位那个码率框:回车或失焦即提交
if(t.matches('input.brnum')){
var uid5 = parseInt(t.dataset.uid, 10);
var v2 = parseInt(t.value.trim(), 10);
if(isNaN(v2) || v2 < 0){
showMsg(false, "自定义档位的码率必须是大于等于 0 的数字");
delete t.dataset.dirty;
// 数据没变化时列表不会重建,得当场把框里的内容恢复成服务端的值
var c5 = (lastSt && lastSt.streamers ? lastSt.streamers : []).filter(function(s){ return s.uid === uid5; })[0];
t.value = c5 ? (c5.bitrate || 0) : 0;
return;
}
var row4 = t.closest(".row");
var q4 = rowQuality(row4);
if(!q4) return;
q4.uid = uid5;
q4.danmu = row4.querySelector('input[data-k=danmu]').checked;
q4.record = row4.querySelector('input[data-k=record]').checked;
// 提交成功才算「不再是没提交的输入」,列表重建时才不会把它当成用户还在填
if(await post("/api/streamer", q4)) delete t.dataset.dirty;
}
});
// 去直播间接一次码率档位;拿到后下拉里就列出实际档位(0 = 自动第二高)
@@ -1305,10 +1400,11 @@ type webStreamer struct {
Live bool `json:"live"`
Danmu bool `json:"danmu"` // 弹幕录制开关
Record bool `json:"record"` // 视频录制开关
Bitrate int `json:"bitrate"` // 选定的档位码率(Kbps),0 = 自动(实际档位第二高)
QualityPref string `json:"qualityPref"` // 档位偏好:top / second / lowest / custom
Bitrate int `json:"bitrate"` // 自定义档位时的码率上限(Kbps)
Qualities []qualityTier `json:"qualities"` // 可选档位(按码率从高到低),没探测到时为空
QualityBitrate int `json:"qualityBitrate"` // 实际会用到的档位码率(自动时就是第二高的那个)
QualityName string `json:"qualityName"` // 实际会用到的档位名
QualityBitrate int `json:"qualityBitrate"` // 按偏好对应到的档位码率(没有档位表时为 0)
QualityName string `json:"qualityName"` // 对应到的档位名
DanmuActive bool `json:"danmuActive"` // 是否正在录制弹幕
RecordActive bool `json:"recordActive"` // 是否正在录制视频
Title string `json:"title"`
@@ -1317,39 +1413,40 @@ type webStreamer struct {
// web UI 的状态数据
type webStatus struct {
UIDs []uidSetting `json:"uids"`
Directory string `json:"directory"`
AutoMerge bool `json:"autoMerge"`
MergeDelVideo bool `json:"mergeDelVideo"`
MergeDelDanmu bool `json:"mergeDelDanmu"`
MergeOutputDir string `json:"mergeOutputDir"`
MergeBitrate int `json:"mergeBitrate"`
DanmuFontSize int `json:"danmuFontSize"`
DefaultBitrate int `json:"defaultBitrate"`
WebExternal bool `json:"webExternal"`
RecordSubdir string `json:"recordSubdir"`
MergeSubdir string `json:"mergeSubdir"`
FFmpegPath string `json:"ffmpegPath"`
RecordFormat string `json:"recordFormat"`
NotifyEnabled bool `json:"notifyEnabled"`
NotifyURL string `json:"notifyUrl"`
NotifyToken string `json:"notifyToken"`
NotifyGroup string `json:"notifyGroup"`
NotifyLiveOn bool `json:"notifyLiveOn"`
NotifyLiveOff bool `json:"notifyLiveOff"`
NotifyRecordStart bool `json:"notifyRecordStart"`
NotifyMergeDone bool `json:"notifyMergeDone"`
MeowEnabled bool `json:"meowEnabled"`
MeowNickname string `json:"meowNickname"`
MeowMsgType string `json:"meowMsgType"`
MeowHTMLHeight int `json:"meowHtmlHeight"`
MeowImgURL string `json:"meowImgUrl"`
MeowBlocked string `json:"meowBlocked"` // MeoW 推不出去的原因,空字符串表示正常
MergeBusy bool `json:"mergeBusy"` // 手动合并是否正在进行
MergeOutName string `json:"mergeOutName"` // 正在进行的手动合并的输出文件名
MergeErr string `json:"mergeErr"` // 最近一次手动合并的错误
MergeDoneAt int64 `json:"mergeDoneAt"` // 最近一次手动合并结束的时刻
Streamers []webStreamer `json:"streamers"`
UIDs []uidSetting `json:"uids"`
Directory string `json:"directory"`
AutoMerge bool `json:"autoMerge"`
MergeDelVideo bool `json:"mergeDelVideo"`
MergeDelDanmu bool `json:"mergeDelDanmu"`
MergeOutputDir string `json:"mergeOutputDir"`
MergeBitrate int `json:"mergeBitrate"`
DanmuFontSize int `json:"danmuFontSize"`
DefaultQualityPref string `json:"defaultQualityPref"`
DefaultBitrate int `json:"defaultBitrate"`
WebExternal bool `json:"webExternal"`
RecordSubdir string `json:"recordSubdir"`
MergeSubdir string `json:"mergeSubdir"`
FFmpegPath string `json:"ffmpegPath"`
RecordFormat string `json:"recordFormat"`
NotifyEnabled bool `json:"notifyEnabled"`
NotifyURL string `json:"notifyUrl"`
NotifyToken string `json:"notifyToken"`
NotifyGroup string `json:"notifyGroup"`
NotifyLiveOn bool `json:"notifyLiveOn"`
NotifyLiveOff bool `json:"notifyLiveOff"`
NotifyRecordStart bool `json:"notifyRecordStart"`
NotifyMergeDone bool `json:"notifyMergeDone"`
MeowEnabled bool `json:"meowEnabled"`
MeowNickname string `json:"meowNickname"`
MeowMsgType string `json:"meowMsgType"`
MeowHTMLHeight int `json:"meowHtmlHeight"`
MeowImgURL string `json:"meowImgUrl"`
MeowBlocked string `json:"meowBlocked"` // MeoW 推不出去的原因,空字符串表示正常
MergeBusy bool `json:"mergeBusy"` // 手动合并是否正在进行
MergeOutName string `json:"mergeOutName"` // 正在进行的手动合并的输出文件名
MergeErr string `json:"mergeErr"` // 最近一次手动合并的错误
MergeDoneAt int64 `json:"mergeDoneAt"` // 最近一次手动合并结束的时刻
Streamers []webStreamer `json:"streamers"`
}
// 打印日志到 web UI
@@ -1378,39 +1475,40 @@ func statusHandler(w http.ResponseWriter, r *http.Request) {
mergeBusy, mergeOut, mergeErr, mergeDoneAt := mergeRunSnapshot()
streamers.RLock()
status := webStatus{
UIDs: append(uidList{}, config.UIDs...),
Directory: config.Directory,
AutoMerge: config.AutoMerge,
MergeDelVideo: config.MergeDelVideo,
MergeDelDanmu: config.MergeDelDanmu,
MergeOutputDir: config.MergeOutputDir,
MergeBitrate: config.MergeBitrate,
DanmuFontSize: config.DanmuFontSize,
WebExternal: config.WebExternal,
RecordSubdir: config.RecordSubdir,
MergeSubdir: config.MergeSubdir,
FFmpegPath: config.FFmpegPath,
RecordFormat: normalizeRecordFormat(config.RecordFormat),
DefaultBitrate: config.DefaultBitrate,
NotifyEnabled: config.NotifyEnabled,
NotifyURL: config.NotifyURL,
NotifyToken: config.NotifyToken,
NotifyGroup: config.NotifyGroup,
NotifyLiveOn: config.NotifyLiveOn,
NotifyLiveOff: config.NotifyLiveOff,
NotifyRecordStart: config.NotifyRecordStart,
NotifyMergeDone: config.NotifyMergeDone,
MeowEnabled: config.MeowEnabled,
MeowNickname: config.MeowNickname,
MeowMsgType: normalizeMeowMsgType(config.MeowMsgType),
MeowHTMLHeight: clampMeowHTMLHeight(config.MeowHTMLHeight),
MeowImgURL: config.MeowImgURL,
MeowBlocked: meowStatusProblem(),
MergeBusy: mergeBusy,
MergeOutName: mergeOut,
MergeErr: mergeErr,
MergeDoneAt: mergeDoneAt,
Streamers: make([]webStreamer, 0, len(streamers.crt)),
UIDs: append(uidList{}, config.UIDs...),
Directory: config.Directory,
AutoMerge: config.AutoMerge,
MergeDelVideo: config.MergeDelVideo,
MergeDelDanmu: config.MergeDelDanmu,
MergeOutputDir: config.MergeOutputDir,
MergeBitrate: config.MergeBitrate,
DanmuFontSize: config.DanmuFontSize,
WebExternal: config.WebExternal,
RecordSubdir: config.RecordSubdir,
MergeSubdir: config.MergeSubdir,
FFmpegPath: config.FFmpegPath,
RecordFormat: normalizeRecordFormat(config.RecordFormat),
DefaultQualityPref: normalizeQualityPref(config.DefaultQualityPref),
DefaultBitrate: config.DefaultBitrate,
NotifyEnabled: config.NotifyEnabled,
NotifyURL: config.NotifyURL,
NotifyToken: config.NotifyToken,
NotifyGroup: config.NotifyGroup,
NotifyLiveOn: config.NotifyLiveOn,
NotifyLiveOff: config.NotifyLiveOff,
NotifyRecordStart: config.NotifyRecordStart,
NotifyMergeDone: config.NotifyMergeDone,
MeowEnabled: config.MeowEnabled,
MeowNickname: config.MeowNickname,
MeowMsgType: normalizeMeowMsgType(config.MeowMsgType),
MeowHTMLHeight: clampMeowHTMLHeight(config.MeowHTMLHeight),
MeowImgURL: config.MeowImgURL,
MeowBlocked: meowStatusProblem(),
MergeBusy: mergeBusy,
MergeOutName: mergeOut,
MergeErr: mergeErr,
MergeDoneAt: mergeDoneAt,
Streamers: make([]webStreamer, 0, len(streamers.crt)),
}
for _, s := range streamers.crt {
live := s.isLiveOn()
@@ -1418,9 +1516,10 @@ func statusHandler(w http.ResponseWriter, r *http.Request) {
if live {
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 := webStreamer{UID: s.UID, Name: s.Name, Live: live, Title: title, URL: s.getURL(), Danmu: s.Danmu, Record: s.Record,
QualityPref: normalizeQualityPref(s.QualityPref), Bitrate: s.Bitrate}
ws.Qualities = sortQualitiesDesc(s.Qualities)
ws.QualityBitrate = resolveQualityBitrate(s.Qualities, s.Bitrate)
ws.QualityBitrate = resolveQualityBitrate(s.Qualities, ws.QualityPref, ws.Bitrate)
ws.QualityName = qualityNameFor(s.Qualities, ws.QualityBitrate)
if infoList, ok := getLiveInfoByUID(s.UID); ok {
for _, info := range infoList {
@@ -1460,32 +1559,33 @@ func checkWebDir(path, name string) string {
// 修改设置(保存目录和自动合并相关)
func webConfigHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Directory string `json:"directory"`
AutoMerge bool `json:"autoMerge"`
MergeDelVideo bool `json:"mergeDelVideo"`
MergeDelDanmu bool `json:"mergeDelDanmu"`
MergeOutputDir string `json:"mergeOutputDir"`
MergeBitrate int `json:"mergeBitrate"`
DanmuFontSize int `json:"danmuFontSize"`
DefaultBitrate int `json:"defaultBitrate"`
WebExternal bool `json:"webExternal"`
RecordSubdir string `json:"recordSubdir"`
MergeSubdir string `json:"mergeSubdir"`
FFmpegPath string `json:"ffmpegPath"`
RecordFormat string `json:"recordFormat"`
NotifyEnabled bool `json:"notifyEnabled"`
NotifyURL string `json:"notifyUrl"`
NotifyToken string `json:"notifyToken"`
NotifyGroup string `json:"notifyGroup"`
NotifyLiveOn bool `json:"notifyLiveOn"`
NotifyLiveOff bool `json:"notifyLiveOff"`
NotifyRecordStart bool `json:"notifyRecordStart"`
NotifyMergeDone bool `json:"notifyMergeDone"`
MeowEnabled bool `json:"meowEnabled"`
MeowNickname string `json:"meowNickname"`
MeowMsgType string `json:"meowMsgType"`
MeowHTMLHeight int `json:"meowHtmlHeight"`
MeowImgURL string `json:"meowImgUrl"`
Directory string `json:"directory"`
AutoMerge bool `json:"autoMerge"`
MergeDelVideo bool `json:"mergeDelVideo"`
MergeDelDanmu bool `json:"mergeDelDanmu"`
MergeOutputDir string `json:"mergeOutputDir"`
MergeBitrate int `json:"mergeBitrate"`
DanmuFontSize int `json:"danmuFontSize"`
DefaultQualityPref string `json:"defaultQualityPref"`
DefaultBitrate int `json:"defaultBitrate"`
WebExternal bool `json:"webExternal"`
RecordSubdir string `json:"recordSubdir"`
MergeSubdir string `json:"mergeSubdir"`
FFmpegPath string `json:"ffmpegPath"`
RecordFormat string `json:"recordFormat"`
NotifyEnabled bool `json:"notifyEnabled"`
NotifyURL string `json:"notifyUrl"`
NotifyToken string `json:"notifyToken"`
NotifyGroup string `json:"notifyGroup"`
NotifyLiveOn bool `json:"notifyLiveOn"`
NotifyLiveOff bool `json:"notifyLiveOff"`
NotifyRecordStart bool `json:"notifyRecordStart"`
NotifyMergeDone bool `json:"notifyMergeDone"`
MeowEnabled bool `json:"meowEnabled"`
MeowNickname string `json:"meowNickname"`
MeowMsgType string `json:"meowMsgType"`
MeowHTMLHeight int `json:"meowHtmlHeight"`
MeowImgURL string `json:"meowImgUrl"`
}
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
@@ -1577,6 +1677,7 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
config.MergeOutputDir = req.MergeOutputDir
config.MergeBitrate = req.MergeBitrate
config.DanmuFontSize = req.DanmuFontSize
config.DefaultQualityPref = normalizeQualityPref(req.DefaultQualityPref)
config.DefaultBitrate = req.DefaultBitrate
restartWeb := config.WebExternal != req.WebExternal
config.WebExternal = req.WebExternal
@@ -1615,9 +1716,10 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
lPrintln("外网访问设置已改变,重启 web UI 服务器")
restartWebUI()
}
lPrintf("通过 web UI 修改了设置:目录 %q、自动合并 %v、合并删视频 %v、合并删弹幕 %v、成片目录 %q、合并码率 %d、默认下载码率 %d、外网访问 %v、录播子文件夹 %q、成片子文件夹 %q、ffmpeg 路径 %q、录播格式 %q",
lPrintf("通过 web UI 修改了设置:目录 %q、自动合并 %v、合并删视频 %v、合并删弹幕 %v、成片目录 %q、合并码率 %d、新主播默认档位 %s、外网访问 %v、录播子文件夹 %q、成片子文件夹 %q、ffmpeg 路径 %q、录播格式 %q",
req.Directory, req.AutoMerge, req.MergeDelVideo, req.MergeDelDanmu, req.MergeOutputDir,
req.MergeBitrate, req.DefaultBitrate, req.WebExternal, recordSubdir, mergeSubdir, config.FFmpegPath, recordFormat)
req.MergeBitrate, qualityLogText(req.DefaultQualityPref, req.DefaultBitrate), req.WebExternal,
recordSubdir, mergeSubdir, config.FFmpegPath, recordFormat)
lPrintf("推送设置:QQ 机器人渠道 %v(地址 %q、群号 %q)、MeoW 渠道 %v(昵称 %q、类型 %s、高度 %d);事件 开播 %v、下播 %v、开始录制 %v、合并完成 %v",
req.NotifyEnabled, notifyURL, config.NotifyGroup, req.MeowEnabled, meowNickname, meowMsgType,
clampMeowHTMLHeight(req.MeowHTMLHeight),
@@ -1666,17 +1768,20 @@ func streamerQualitiesHandler(w http.ResponseWriter, r *http.Request) {
if setStreamerQualities(uid, tiers) {
saveConfig()
}
pref := qualityPrefDefault
bitrate := 0
streamers.RLock()
if s, ok := streamers.crt[uid]; ok {
pref = normalizeQualityPref(s.QualityPref)
bitrate = s.Bitrate
}
streamers.RUnlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
used := resolveQualityBitrate(tiers, bitrate)
used := resolveQualityBitrate(tiers, pref, bitrate)
_ = json.NewEncoder(w).Encode(map[string]any{
"uid": uid,
"qualities": sortQualitiesDesc(tiers),
"qualityPref": pref,
"bitrate": bitrate,
"qualityBitrate": used,
"qualityName": qualityNameFor(tiers, used),
@@ -1685,10 +1790,11 @@ func streamerQualitiesHandler(w http.ResponseWriter, r *http.Request) {
func webStreamerHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
UID int `json:"uid"`
Danmu bool `json:"danmu"`
Record bool `json:"record"`
Bitrate *int `json:"bitrate"` // 不传时使用默认下载码率上限
UID int `json:"uid"`
Danmu bool `json:"danmu"`
Record bool `json:"record"`
QualityPref string `json:"qualityPref"` // top / second / lowest / custom,不传时用新主播的默认档位
Bitrate *int `json:"bitrate"` // 自定义档位时的码率上限,不传时用配置里的默认值
}
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
@@ -1699,7 +1805,18 @@ func webStreamerHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "uid 必须大于 0", http.StatusBadRequest)
return
}
// 已存在的主播:不传档位时保持原样(页面切到「最高档」时不会把自定义码率清掉);
// 新增的主播才用配置里的默认档位
pref := normalizeQualityPref(config.DefaultQualityPref)
bitrate := config.DefaultBitrate
cur, exists := getStreamer(req.UID)
if exists {
pref = normalizeQualityPref(cur.QualityPref)
bitrate = cur.Bitrate
}
if strings.TrimSpace(req.QualityPref) != "" {
pref = normalizeQualityPref(req.QualityPref)
}
if req.Bitrate != nil {
bitrate = *req.Bitrate
}
@@ -1708,18 +1825,19 @@ func webStreamerHandler(w http.ResponseWriter, r *http.Request) {
return
}
// 码率变化时先停掉正在进行的录播,setStreamerFlags 会用新码率重启
if cur, ok := getStreamer(req.UID); ok && cur.Bitrate != bitrate && cur.Record && req.Record {
// 档位变化时先停掉正在进行的录播,setStreamerFlags 之后用新档位重启
if exists && cur.Record && req.Record && (cur.QualityPref != pref || cur.Bitrate != bitrate) {
stopRecord(req.UID)
}
if !setStreamerFlags(req.UID, req.Danmu, req.Record) {
http.Error(w, "不存在 uid 为"+itoa(req.UID)+"的用户", http.StatusBadRequest)
return
}
if cur, ok := getStreamer(req.UID); ok && cur.Bitrate != bitrate {
setBitrate(req.UID, bitrate)
if cur, ok := getStreamer(req.UID); ok && (cur.QualityPref != pref || cur.Bitrate != bitrate) {
setQuality(req.UID, pref, bitrate)
}
lPrintf("通过 web UI 设置 uid %d:弹幕 %v、录播 %v、码率上限 %d", req.UID, req.Danmu, req.Record, bitrate)
lPrintf("通过 web UI 设置 uid %d:弹幕 %v、录播 %v、录制档位 %s", req.UID, req.Danmu, req.Record,
qualityLogText(pref, bitrate))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("true"))