录制档位改成「最高/第二高/最低/自定义」四种相对偏好,没开播也能先设
上一版的下拉里列的是「这次读到的实际档位」,没读到档位表(没开播)时下拉里就只剩 一项,想预先设「我要最高档」或「最低档」都做不到。改成按「实际档位里的第几档」来选: - 新增 `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:
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user