From 237fffa8fea923a39372a99518d1763a601580ed Mon Sep 17 00:00:00 2001 From: tamakyi Date: Fri, 25 Sep 2026 20:42:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BD=95=E5=88=B6=E6=A1=A3=E4=BD=8D=E6=94=B9?= =?UTF-8?q?=E6=88=90=E3=80=8C=E6=9C=80=E9=AB=98/=E7=AC=AC=E4=BA=8C?= =?UTF-8?q?=E9=AB=98/=E6=9C=80=E4=BD=8E/=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E3=80=8D=E5=9B=9B=E7=A7=8D=E7=9B=B8=E5=AF=B9=E5=81=8F=E5=A5=BD?= =?UTF-8?q?=EF=BC=8C=E6=B2=A1=E5=BC=80=E6=92=AD=E4=B9=9F=E8=83=BD=E5=85=88?= =?UTF-8?q?=E8=AE=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版的下拉里列的是「这次读到的实际档位」,没读到档位表(没开播)时下拉里就只剩 一项,想预先设「我要最高档」或「最低档」都做不到。改成按「实际档位里的第几档」来选: - 新增 `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 干净。 --- README.md | 23 ++- config.go | 160 ++++++++++++++---- doc/webapi.md | 4 +- qualities_test.go | 157 +++++++++++++++--- record.go | 12 +- webui.go | 408 ++++++++++++++++++++++++++++++---------------- 6 files changed, 549 insertions(+), 215 deletions(-) diff --git a/README.md b/README.md index 9bfeb8d..543ad78 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,9 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe "uid": 23682490, // 主播的 uid "danmu": true, // 是否录制弹幕 "record": true, // 是否录制直播视频 - "bitrate": 0, // 选定的档位码率(Kbps),对应下面 qualities 里的一个档位;0 = 自动(按实际档位取第二高) - "qualities": [] // 开播时读到的码率档位列表,缓存下来让没开播时也能选 + "qualityPref": "second", // 档位偏好:top(最高档)/ second(第二高)/ lowest(最低档)/ custom(自定义) + "bitrate": 0, // 只在 qualityPref 为 custom 时有意义:不超过这个码率(Kbps)的最高档 + "qualities": [] // 开播时读到的码率档位列表,缓存下来让没开播时也能显示对应关系 } ], "directory": "", // 录制文件保存的文件夹,留空是程序运行时的 -record 文件夹 @@ -83,7 +84,8 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe "mergeOutputDir": "", // 合并完成后成片移动到该文件夹,留空则不移动 "mergeBitrate": 0, // 合并成片的目标码率(Kbps),0 表示按「源片码率 + 500」自动计算 "danmuFontSize": 0, // 合并时弹幕文字的字号(像素),0 表示跟随弹幕文件;头像随字号等比缩放 - "defaultBitrate": 0, // 新添加主播默认选的档位,0 = 自动(按实际档位取第二高) + "defaultQualityPref": "second", // 新添加主播的默认档位偏好,取值同上面的 qualityPref + "defaultBitrate": 0, // 只在上面为 custom 时有意义:默认的自定义码率上限(Kbps) "webExternal": false, // 是否允许外网访问 web UI,false 时只监听本机 127.0.0.1 "authToken": "…", // web UI 管理接口的访问口令,首次启动自动生成 @@ -112,9 +114,18 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe `uids`也兼容旧的纯数字数组格式,如`"uids": [23682490, 12345]`,此时弹幕和录播都默认开启、码率按实际档位取第二档。 -`bitrate`是**直播的实际档位**(如`蓝光 8M`、`高清`),在 web UI 的主播列表里一律用下拉选择,正在录制时换档会自动重录。下拉的第一项是`自动 · 第二高`(值 0),意思是**按这次读到的档位排序自上往下取第二高** —— 因为不一定每场都有`蓝光 8M`这类最高档,按位置排序比写死某个码率稳;只要一档就用那一档,实在读不到档位表才退回最高档。后面依次列出这次读到的实际档位,想固定用最高、最低或中间某一档直接选它;手动选过之后下拉里仍留着`自动`项,随时能改回去。 +`qualityPref`是**按档位里的第几档**来选的,不是写死某个码率 —— 因为每场直播的档位表不一定一样(不一定都有`蓝光 8M`): -档位要开播后读直播源列表才知道,读到以后写进`config.json`的`qualities`里缓存,所以没开播也能看和改;从来没读过档位的行,下拉里只有`自动`一项,旁边有`档位`按钮可以去直播间接一次(主播没开播时会失败并提示)。读档位的时机是:录制开始取流时、页面上点`档位`按钮、以及发现主播在直播但还没有档位缓存时自动读一次(同一个主播 10 秒内只读一次)。录制始终是原画质直接保存(`-c copy`),不会重新编码,所以只能在这几个档位里挑。 +| 取值 | 页面上的选项 | 含义 | +| --- | --- | --- | +| `top` | 最高档 | 这次能读到的最高档 | +| `second` | 第二高(默认) | 按实际档位排序自上往下第二高,只有一档时用它 | +| `lowest` | 最低档 | 这次能读到的最低档 | +| `custom` | 自定义 | 取不超过`bitrate`(Kbps)的最高档,都比它高时退回最低档 | + +四种偏好**永远都在下拉里**,所以还没开播、连档位表都没读到的时候也能先设好;读到档位表以后,每个选项后面会标出它现在对应哪个档位(例如`第二高(蓝光 4M)`、`自定义(≤2500K = 高清)`),选完立即生效,正在录制时换档会自动重录。选`自定义`时会出现一个码率框,初始值就是当前实际在用的那一档,填完回车即生效。 + +档位表要开播后读直播源列表才知道(写在`config.json`的`qualities`里缓存)。没读到过的行,选项后面不会带档位名,旁边的`档位`按钮可以去直播间接一次(主播没开播时会失败并提示,服务端本身要重试三次、约 5 秒,所以点下去会先给一句`正在读取码率档位…`)。读档位的时机是:录制开始取流时、页面上点`档位`按钮、以及发现主播在直播但还没有档位缓存时自动读一次(同一个主播 10 秒内只读一次)。录制始终是原画质直接保存(`-c copy`),不会重新编码,所以只能在这几个档位里挑。 录播原片和弹幕文件(`.ass`、`.nick.ass`、`.jsonl`)保存在保存目录下的`recordSubdir`子文件夹里(默认`record`),两者同名配对才能自动合并;合并后的成片保存在`mergeSubdir`子文件夹里(默认`merged`),留空则用默认值,两个名字不能相同。如果设置了`mergeOutputDir`,成片优先移动到那里。 @@ -139,7 +150,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe `authToken`是管理接口的访问口令,首次启动时自动生成(12 字节随机数的十六进制)并打印在运行日志里,忘记时可以打开`config.json`查看,也能在 web UI 的`设置`分区查看、复制和更换。**所有`/api/`接口都必须带口令**,页面本身可以匿名打开,打开后会要求输入口令并保存在浏览器本地。口令可以用三种方式提供:`X-Auth-Token`请求头、`Authorization: Bearer <口令>`、或者`?token=<口令>`查询参数。口令可以用任意字符,包括中文。注意 HTTP 请求头只接受 ISO-8859-1,浏览器遇到中文口令会直接拒绝发请求,所以页面发给服务端的`X-Auth-Token`是百分号编码过的口令,服务端原样和解码后的都认;用 curl 或脚本调接口时按原样填即可,不用自己编码。同一来源连续输错时每次会增加最多 2 秒延迟,避免被暴力尝试。手工把`config.json`里的`authToken`清空保存后,程序会自动重新生成一个,不会把自己锁在外面。 -`defaultBitrate`是下载码率的默认值:在 web UI 里新增主播、且不单独指定码率时使用它,也可以在 web UI 的`录制`分区随时修改,只对新添加的主播生效,已有主播保持各自的值。填 0 时按主播**实际能用的档位**来决定 —— 取自上而下的**第二档**(比如有`蓝光 8M / 蓝光 4M / 高清`就录`蓝光 4M`),只有一档时就用那一档;这个档位是开播后读到直播源列表才知道的,读到以后会记进`config.json`,没开播时页面也能显示和修改。 +`defaultQualityPref`/`defaultBitrate`是新主播的默认档位:在 web UI 里新增主播、且没有单独指定档位时用它,`录制`分区可以随时改,只对新添加的主播生效,已有主播保持各自选的。默认是`第二高`,也就是按主播**实际能用的档位**自上往下取第二档(比如有`蓝光 8M / 蓝光 4M / 高清`就录`蓝光 4M`),只有一档时就用那一档;档位表要开播后才读得到,读到以后会记进`config.json`,没开播时页面也能显示和修改。 `ffmpegPath`用来指定 ffmpeg 的位置,可以填可执行文件本身(如`D:\ffmpeg\bin\ffmpeg.exe`)或它所在的文件夹,留空时按「PATH → 程序所在文件夹 → 程序所在文件夹的 bin、ffmpeg、ffmpeg/bin 子目录」的顺序自动查找。填相对路径时按程序所在目录解析,不会因为合并时会切换工作目录而失效(这是`ffmpeg.exe`这种只写文件名的写法能正常工作的原因)。录播和弹幕合并都依赖 ffmpeg,找不到时启动日志里会有明确提示。 diff --git a/config.go b/config.go index 378f22f..b53bb5c 100644 --- a/config.go +++ b/config.go @@ -26,12 +26,57 @@ var configFileLocation string // 设置文件位置 // 主播的设置数据 type streamer struct { - UID int `json:"uid"` // 主播 uid - Name string `json:"name"` // 主播名字 - Danmu bool `json:"-"` // 是否录制直播弹幕 - Record bool `json:"-"` // 是否录制直播视频 - Bitrate int `json:"-"` // 选定的档位码率(Kbps),0 = 自动(按实际档位取第二高) - Qualities []qualityTier `json:"-"` // 上次探测到的码率档位,没开播时页面靠它显示下拉 + UID int `json:"uid"` // 主播 uid + Name string `json:"name"` // 主播名字 + Danmu bool `json:"-"` // 是否录制直播弹幕 + Record bool `json:"-"` // 是否录制直播视频 + QualityPref string `json:"-"` // 档位偏好:最高/第二高/最低/自定义(见 qualityPref* 常量) + Bitrate int `json:"-"` // 自定义档位时的码率上限(Kbps),只在 QualityPref 为 custom 时有意义 + Qualities []qualityTier `json:"-"` // 上次探测到的码率档位,没开播时页面靠它显示对应关系 +} + +// 录制档位的偏好。档位名每次开播都可能不一样(不一定都有「蓝光 8M」), +// 所以按「实际档位里的第几档」来选,不开播也能先设好,开播后自动对应上去。 +const ( + qualityPrefTop = "top" // 最高档 + qualityPrefSecond = "second" // 自上往下第二高(默认) + qualityPrefLowest = "lowest" // 最低档 + qualityPrefCustom = "custom" // 自定义:不超过指定码率的最高档 + qualityPrefDefault = qualityPrefSecond +) + +// 把档位偏好规整成合法值,空值或没见过的值一律当默认(第二高) +func normalizeQualityPref(pref string) string { + switch pref { + case qualityPrefTop, qualityPrefSecond, qualityPrefLowest, qualityPrefCustom: + return pref + } + return qualityPrefDefault +} + +// 老配置里没有 qualityPref,只有 bitrate:填了码率就是「自定义上限」,0 就是默认的第二高 +func migrateQualityPref(pref string, bitrate int) string { + if pref == "" { + if bitrate > 0 { + return qualityPrefCustom + } + return qualityPrefDefault + } + return normalizeQualityPref(pref) +} + +// 档位偏好对应的中文说法,用于日志 +func qualityPrefText(pref string) string { + switch normalizeQualityPref(pref) { + case qualityPrefTop: + return "最高档" + case qualityPrefLowest: + return "最低档" + case qualityPrefCustom: + return "自定义" + default: + return "第二高" + } } // 直播源的码率档位 @@ -42,11 +87,12 @@ type qualityTier struct { // 单个主播的开关设置 type uidSetting struct { - UID int `json:"uid"` - Danmu bool `json:"danmu"` - Record bool `json:"record"` - Bitrate int `json:"bitrate"` // 选定的档位码率(Kbps),0 = 自动(按实际档位取第二高) - Qualities []qualityTier `json:"qualities,omitempty"` // 上次探测到的档位,缓存下来让没开播时也能在页面上选 + UID int `json:"uid"` + Danmu bool `json:"danmu"` + Record bool `json:"record"` + QualityPref string `json:"qualityPref,omitempty"` // 档位偏好,留空按老规则从 bitrate 推(见 migrateQualityPref) + Bitrate int `json:"bitrate"` // 自定义档位时的码率上限(Kbps) + Qualities []qualityTier `json:"qualities,omitempty"` // 上次探测到的档位,缓存下来让没开播时也能显示对应关系 } // uidSetting 列表,兼容旧的纯数字数组格式(旧格式默认弹幕和录播都开启) @@ -93,7 +139,9 @@ type configData struct { MergeBitrate int `json:"mergeBitrate"` // 合并成片的目标码率(Kbps),0 表示使用默认质量 DanmuFontSize int `json:"danmuFontSize"` // 合并时弹幕文字的字号(头像随字号等比缩放),0 表示跟随弹幕文件 - DefaultBitrate int `json:"defaultBitrate"` // 新添加主播默认选的档位码率(Kbps),0 = 自动(按实际档位取第二高) + // 新添加主播的默认录制档位(留空/非法按第二高)与自定义时的码率上限 + DefaultQualityPref string `json:"defaultQualityPref,omitempty"` + DefaultBitrate int `json:"defaultBitrate"` WebExternal bool `json:"webExternal"` // 是否允许外网访问 web UI,false 时只监听本机 @@ -426,6 +474,8 @@ func checkConfig() { os.Exit(1) } } + // 老配置没有 qualityPref,这里按 bitrate 推一个出来,不写盘(下次保存时自然带上) + upgradeQualityPrefs() } // 清掉上次运行残留的合成临时文件,只在启动时调用一次。 @@ -448,6 +498,7 @@ func buildStreamers() { if s, ok := streamers.crt[u.UID]; ok { s.Danmu = u.Danmu s.Record = u.Record + s.QualityPref = u.QualityPref s.Bitrate = u.Bitrate s.Qualities = u.Qualities news[u.UID] = s @@ -457,11 +508,26 @@ func buildStreamers() { if name == "" { name = fmt.Sprintf("uid%d", u.UID) } - news[u.UID] = streamer{UID: u.UID, Name: name, Danmu: u.Danmu, Record: u.Record, Bitrate: u.Bitrate, Qualities: u.Qualities} + news[u.UID] = streamer{UID: u.UID, Name: name, Danmu: u.Danmu, Record: u.Record, + QualityPref: u.QualityPref, Bitrate: u.Bitrate, Qualities: u.Qualities} } streamers.crt = news } +// 老配置里没有 qualityPref(只有 bitrate),读进来时补一个,并把结果写回 config.UIDs, +// 这样运行时各处读到的偏好都是确定的,下次保存也就把新字段落了盘 +func upgradeQualityPrefs() { + for i := range config.UIDs { + want := migrateQualityPref(config.UIDs[i].QualityPref, config.UIDs[i].Bitrate) + if config.UIDs[i].QualityPref != want { + config.UIDs[i].QualityPref = want + } + } + if want := migrateQualityPref(config.DefaultQualityPref, config.DefaultBitrate); config.DefaultQualityPref != want { + config.DefaultQualityPref = want + } +} + // 档位按码率从高到低排(页面的下拉按这个顺序显示) func sortQualitiesDesc(tiers []qualityTier) []qualityTier { out := append([]qualityTier(nil), tiers...) @@ -469,8 +535,8 @@ func sortQualitiesDesc(tiers []qualityTier) []qualityTier { return out } -// 没手动选过码率(0)时的默认档位:按码率自上往下取第二档; -// 只有一个档位就用它,没有档位返回 0(交给调用方保持原样) +// 没手动选过(第二高)时的默认档位:按码率自上往下取第二档; +// 只有一个档位就用它;没有档位返回 0 func defaultQualityBitrate(tiers []qualityTier) int { desc := sortQualitiesDesc(tiers) switch len(desc) { @@ -534,23 +600,32 @@ func setStreamerQualities(uid int, tiers []qualityTier) bool { return changed } -// 按选定的码率决定实际用哪个档位: -// 0(自动)取自上往下第二档(只有一个档位就用它);填了具体码率就取不超过它的最高档, -// 都比它高时退回最低档。档位列表为空返回 0,交给调用方按老规矩取最高档。 -func resolveQualityBitrate(tiers []qualityTier, bitrate int) int { +// 按偏好决定实际用哪个档位,返回档位码率: +// 最高/第二高/最低按本次读到的档位排序取;自定义取不超过 custom 的最高档,都比它高时退回最低档。 +// 档位列表为空时返回 0(自定义时返回 custom),交给调用方按老规矩取最高档。 +func resolveQualityBitrate(tiers []qualityTier, pref string, custom int) int { desc := sortQualitiesDesc(tiers) if len(desc) == 0 { - return bitrate + if normalizeQualityPref(pref) == qualityPrefCustom { + return custom + } + return 0 } - if bitrate <= 0 { + switch normalizeQualityPref(pref) { + case qualityPrefTop: + return desc[0].Bitrate + case qualityPrefLowest: + return desc[len(desc)-1].Bitrate + case qualityPrefCustom: + for _, t := range desc { + if t.Bitrate <= custom { + return t.Bitrate + } + } + return desc[len(desc)-1].Bitrate + default: return defaultQualityBitrate(desc) } - for _, t := range desc { - if t.Bitrate <= bitrate { - return t.Bitrate - } - } - return desc[len(desc)-1].Bitrate } // 从文件(成片或录播)名里猜是哪个主播:名字出现在文件名里、且只有一个能对上时才认, @@ -683,7 +758,8 @@ func setStreamerFlags(uid int, danmu, record bool) bool { lPrintWarnf("不存在 uid 为%d的用户", uid) return false } - config.UIDs = append(config.UIDs, uidSetting{UID: uid, Danmu: danmu, Record: record, Bitrate: config.DefaultBitrate}) + config.UIDs = append(config.UIDs, uidSetting{UID: uid, Danmu: danmu, Record: record, + QualityPref: normalizeQualityPref(config.DefaultQualityPref), Bitrate: config.DefaultBitrate}) } // 同步 streamers.crt 里的开关 if s, ok := streamers.crt[uid]; ok { @@ -710,12 +786,14 @@ func setStreamerFlags(uid int, danmu, record bool) bool { return true } -// 设置指定主播的录播码率上限(Kbps,0 为码率最高的直播源)并保存,正在录制时自动重启录制让新码率生效 -func setBitrate(uid, bitrate int) bool { - if uid <= 0 || bitrate < 0 { - lPrintWarn("uid 必须大于 0,bitrate 不能小于 0") +// 改某个主播的录制档位偏好(pref 见 qualityPref* 常量,custom 是自定义时的码率上限)。 +// 正在录制的会把这一段停掉重开,让新档位立刻生效 +func setQuality(uid int, pref string, custom int) bool { + if uid <= 0 || custom < 0 { + lPrintWarn("uid 必须大于 0,自定义码率不能小于 0") return false } + pref = normalizeQualityPref(pref) wasRecording := false if infoList, ok := getLiveInfoByUID(uid); ok { @@ -730,14 +808,16 @@ func setBitrate(uid, bitrate int) bool { found := false for i, u := range config.UIDs { if u.UID == uid { - config.UIDs[i].Bitrate = bitrate + config.UIDs[i].QualityPref = pref + config.UIDs[i].Bitrate = custom found = true break } } if found { if s, ok := streamers.crt[uid]; ok { - s.Bitrate = bitrate + s.QualityPref = pref + s.Bitrate = custom streamers.crt[uid] = s } } @@ -749,15 +829,23 @@ func setBitrate(uid, bitrate int) bool { saveConfig() - // 码率只对新分段生效,正在录制时重启录制 + // 档位只对新分段生效,正在录制时重启录制 if wasRecording { stopRecord(uid) startRecord(uid) - lPrintf("已将 uid %d 的录播码率上限改为 %d Kbps 并重启录制", uid, bitrate) + lPrintf("已将 uid %d 的录制档位改为 %s并重启录制", uid, qualityLogText(pref, custom)) } return true } +// 档位的中文描述,用于日志 +func qualityLogText(pref string, custom int) string { + if normalizeQualityPref(pref) == qualityPrefCustom { + return fmt.Sprintf("自定义(不超过 %d Kbps)", custom) + } + return qualityPrefText(pref) +} + // 停止或重新开始指定主播的录制,target 为 danmu、record 或 all(空字符串表示全部),action 为 stop 或 restart func controlStreamer(uid int, target, action string) error { if uid <= 0 { diff --git a/doc/webapi.md b/doc/webapi.md index a6431ac..ae077f3 100644 --- a/doc/webapi.md +++ b/doc/webapi.md @@ -80,7 +80,9 @@ web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全 口令支持任意字符,包括中文——不过 HTTP 请求头只接受 ISO-8859-1,浏览器遇到非 ASCII 的口令会直接拒绝发请求(报`String contains non ISO-8859-1 code point`),所以**页面发给服务端的`X-Auth-Token`是百分号编码过的口令**(`encodeURIComponent`)。服务端两种写法都认:先拿原样比,再拿解码后的比,所以手写 curl 或者脚本按原样把中文口令放进请求头、`Authorization: Bearer`、`?token=` 里也都能通过。用脚本调这套接口时不用管编码,原样填就行。 -`GET /api/streamer/qualities?uid=` 去直播间接一次该主播的**码率档位**(返回`{"uid":…,"qualities":[{"name":"蓝光 4M","bitrate":4000},…],"bitrate":选定的档位码率,"qualityBitrate":实际会用到的档位码率,"qualityName":实际会用到的档位名}`,`qualities`按码率从高到低)。读到的档位会缓存进`config.json`(没开播时页面靠它显示下拉),**但不会改动用户选的档位**:`bitrate`为 0 表示`自动`(按档位排序取第二高),只在取流时解析,所以主播换了档位表也能跟着走。同一个主播 10 秒内只让读一次,太频繁返回 502 并给中文原因(没开播时也会返回 502,且没开播时服务端本来就要重试三次,页面上会先给一句`正在读取码率档位…`)。`GET /api/status`里每个主播的`qualities`是这份缓存,`qualityBitrate`/`qualityName`是根据当前选择解析出来的结果(`bitrate`为 0 时就是第二高的那一档)。 +`GET /api/streamer/qualities?uid=` 去直播间接一次该主播的**码率档位**(返回`{"uid":…,"qualities":[{"name":"蓝光 4M","bitrate":4000},…],"qualityPref":"second","bitrate":0,"qualityBitrate":实际会用到的档位码率,"qualityName":实际会用到的档位名}`,`qualities`按码率从高到低)。读到的档位会缓存进`config.json`(没开播时页面靠它把偏好标成具体档位名),**但不会改动用户选的偏好**。同一个主播 10 秒内只让读一次,太频繁返回 502 并给中文原因(没开播时也会返回 502,且没开播时服务端本来就要重试三次,页面上会先给一句`正在读取码率档位…`)。 + +`POST /api/streamer`里的`qualityPref`是**按第几档**来选,而不是写死码率:`top`(最高档)、`second`(第二高,默认)、`lowest`(最低档)、`custom`(取不超过`bitrate`的最高档,都比它高时退回最低档)。四种偏好任何时候都能设,开播后自动对应到实际档位。`bitrate`只在`custom`时有意义:已存在的主播不传就保持原值(页面从`最高档`切到别的档位时不会把填过的自定义码率清掉)。`GET /api/status`里每个主播的`qualities`是缓存的档位表,`qualityPref`/`bitrate`是用户选的,`qualityBitrate`/`qualityName`是按偏好解析出来的结果(还没读到档位表时,前三种偏好解析为 0、`custom`解析为填的上限)。 跟手动合并有关的是下面三个: `GET /api/merge/danmu?video=<视频文件名或路径>` 按视频名找同目录下的同名弹幕文件,给页面「选好视频自动匹配弹幕」用。依次试`.jsonl`、`.ass`、`.nick.ass`,同目录没有就拿文件名去录播目录再找一次;返回`{"danmu":"…"}`,找不到时是空字符串。值里录播目录/成片目录下的文件只写文件名,别处的写绝对路径,和`浏览`弹窗的填法一致。 diff --git a/qualities_test.go b/qualities_test.go index 393e3ea..3edc648 100644 --- a/qualities_test.go +++ b/qualities_test.go @@ -123,38 +123,153 @@ func TestSetStreamerQualities(t *testing.T) { } } -// 实际用哪个档位:0 = 自动(第二高),填了具体码率 = 不超过它的最高档, -// 都比它高时退回最低档 +// 按偏好决定实际用哪个档位:最高/第二高/最低按实际档位排序取, +// 自定义取不超过给定码率的最高档、都比它高时退回最低档 func TestResolveQualityBitrate(t *testing.T) { cases := []struct { - name string - bitrate int - want int + name string + pref string + custom int + want int }{ - {"自动取第二高", 0, 4000}, - {"指定最高档", 8000, 8000}, - {"指定第二高", 4000, 4000}, - {"指定最低档", 1000, 1000}, - {"填了中间值取不超过它的最高档", 3000, 2000}, - {"比所有档位都高就取最高档", 99999, 8000}, - {"比所有档位都低就退回最低档", 200, 1000}, + {"最高档", qualityPrefTop, 0, 8000}, + {"第二高(默认)", qualityPrefSecond, 0, 4000}, + {"最低档", qualityPrefLowest, 0, 1000}, + {"空偏好按默认的第二高", "", 0, 4000}, + {"没见过的偏好也按第二高", "bogus", 0, 4000}, + {"自定义取不超过上限的最高档", qualityPrefCustom, 3000, 2000}, + {"自定义上限正好等于某一档", qualityPrefCustom, 4000, 4000}, + {"自定义上限比所有档位都高就取最高档", qualityPrefCustom, 99999, 8000}, + {"自定义上限比所有档位都低就退回最低档", qualityPrefCustom, 200, 1000}, + {"自定义填 0 等于最低档", qualityPrefCustom, 0, 1000}, } for _, c := range cases { - if got := resolveQualityBitrate(tiersForTest(), c.bitrate); got != c.want { + if got := resolveQualityBitrate(tiersForTest(), c.pref, c.custom); got != c.want { t.Errorf("%s:应为 %d,实际 %d", c.name, c.want, got) } } - // 没有档位表时保持原样(取流逻辑会退回最高档) - if got := resolveQualityBitrate(nil, 0); got != 0 { - t.Errorf("没有档位表时应返回 0,实际 %d", got) + // 没有档位表时:相对偏好返回 0(取流时退回最高档),自定义返回填的上限 + if got := resolveQualityBitrate(nil, qualityPrefSecond, 0); got != 0 { + t.Errorf("没有档位表时相对偏好应返回 0,实际 %d", got) } - if got := resolveQualityBitrate(nil, 2000); got != 2000 { - t.Errorf("没有档位表时应原样返回,实际 %d", got) + if got := resolveQualityBitrate(nil, qualityPrefCustom, 2500); got != 2500 { + t.Errorf("没有档位表时自定义应返回填的 2500,实际 %d", got) } - // 只有一档时自动档就是它 + // 只有一档时:最高/第二高都是它,最低也是它 one := []qualityTier{{"高清", 2000}} - if got := resolveQualityBitrate(one, 0); got != 2000 { - t.Errorf("只有一档时自动应为 2000,实际 %d", got) + for _, pref := range []string{qualityPrefTop, qualityPrefSecond, qualityPrefLowest} { + if got := resolveQualityBitrate(one, pref, 0); got != 2000 { + t.Errorf("只有一档时 %s 应为 2000,实际 %d", pref, got) + } + } +} + +// 老配置只有 bitrate,读进来时要能推断出偏好:>0 是「自定义上限」,0 是默认的第二高 +func TestMigrateQualityPref(t *testing.T) { + cases := []struct { + pref string + bitrate int + want string + }{ + {"", 0, qualityPrefSecond}, + {"", 5000, qualityPrefCustom}, + {"", 1000, qualityPrefCustom}, + {qualityPrefTop, 0, qualityPrefTop}, + {qualityPrefLowest, 5000, qualityPrefLowest}, + {qualityPrefCustom, 3000, qualityPrefCustom}, + {" ", 0, qualityPrefSecond}, // 认不出来的值按默认 + {"TOP", 0, qualityPrefSecond}, // 大小写不敏感不做,保持严格 + } + for _, c := range cases { + if got := migrateQualityPref(c.pref, c.bitrate); got != c.want { + t.Errorf("pref=%q bitrate=%d 应为 %s,实际 %s", c.pref, c.bitrate, c.want, got) + } + } + // normalizeQualityPref 对四种合法值原样返回 + for _, p := range []string{qualityPrefTop, qualityPrefSecond, qualityPrefLowest, qualityPrefCustom} { + if got := normalizeQualityPref(p); got != p { + t.Errorf("%s 应原样返回,实际 %s", p, got) + } + } +} + +// 升级老配置:config.UIDs 和默认档位都要补上 qualityPref +func TestUpgradeQualityPrefs(t *testing.T) { + savedConfig := config + defer func() { config = savedConfig }() + + config.UIDs = uidList{ + {UID: 1}, // 老配置:bitrate 0 → 第二高 + {UID: 2, Bitrate: 5000}, // 老配置:有码率 → 自定义 + {UID: 3, QualityPref: qualityPrefTop}, // 已经是新配置 → 不动 + } + config.DefaultQualityPref = "" + config.DefaultBitrate = 0 + upgradeQualityPrefs() + if config.UIDs[0].QualityPref != qualityPrefSecond { + t.Errorf("uid 1 应补成 second,实际 %s", config.UIDs[0].QualityPref) + } + if config.UIDs[1].QualityPref != qualityPrefCustom { + t.Errorf("uid 2 应补成 custom,实际 %s", config.UIDs[1].QualityPref) + } + if config.UIDs[2].QualityPref != qualityPrefTop { + t.Errorf("uid 3 不该被改动,实际 %s", config.UIDs[2].QualityPref) + } + if config.DefaultQualityPref != qualityPrefSecond { + t.Errorf("默认档位应补成 second,实际 %s", config.DefaultQualityPref) + } + // 幂等 + upgradeQualityPrefs() + if config.UIDs[1].QualityPref != qualityPrefCustom { + t.Error("重复升级不该改变结果") + } +} + +// 改档位偏好:写配置 + 同步运行时数据 +func TestSetQuality(t *testing.T) { + savedConfig := config + savedStreamers := streamers.crt + savedLocation := configFileLocation + oldDir := configDir + if configDir == nil { + configDir = new(string) + } + defer func() { + config = savedConfig + streamers.crt = savedStreamers + configFileLocation = savedLocation + configDir = oldDir + }() + + *configDir = t.TempDir() + configFileLocation = filepath.Join(*configDir, configFile) + + config.UIDs = uidList{{UID: 7, QualityPref: qualityPrefSecond}} + streamers.crt = map[int]streamer{7: {UID: 7, QualityPref: qualityPrefSecond}} + + if !setQuality(7, qualityPrefTop, 0) { + t.Fatal("改档位应该成功") + } + if config.UIDs[0].QualityPref != qualityPrefTop || streamers.crt[7].QualityPref != qualityPrefTop { + t.Errorf("档位偏好没同步:%+v / %+v", config.UIDs[0], streamers.crt[7]) + } + // 自定义时码率上限也要存下来 + if !setQuality(7, qualityPrefCustom, 2500) { + t.Fatal("改成自定义应该成功") + } + if streamers.crt[7].Bitrate != 2500 || config.UIDs[0].Bitrate != 2500 { + t.Errorf("自定义码率没存:%+v / %+v", config.UIDs[0], streamers.crt[7]) + } + // 认不出来的偏好按默认处理,负数码率直接拒绝 + setQuality(7, "bogus", 2500) + if streamers.crt[7].QualityPref != qualityPrefSecond { + t.Errorf("非法偏好应按默认第二高,实际 %s", streamers.crt[7].QualityPref) + } + if setQuality(7, qualityPrefCustom, -1) { + t.Error("负数码率应该被拒绝") + } + if setQuality(999, qualityPrefTop, 0) { + t.Error("不在列表里的 uid 应该失败") } } diff --git a/record.go b/record.go index 776b21e..fc959ff 100644 --- a/record.go +++ b/record.go @@ -121,9 +121,9 @@ func fetchStreamQualities(uid int) ([]qualityTier, error) { return streamTiers(info.StreamList), nil } -// 获取 uid 的直播源(flv)。bitrate 是页面上选的档位码率,0 表示自动: -// 按这次拿到的实际档位自上往下取第二高(只有一个档位就用它,拿不到档位表退回最高档) -func getStreamURL(uid, bitrate int) (url string, quality string, err error) { +// 获取 uid 的直播源(flv)。pref/custom 是页面上选的档位偏好(最高/第二高/最低/自定义), +// 按这次拿到的实际档位对应上去;拿不到档位表时退回最高档 +func getStreamURL(uid int, pref string, custom int) (url string, quality string, err error) { sInfo, err := newStreamInfo(uid) if err != nil { return "", "", err @@ -131,14 +131,14 @@ func getStreamURL(uid, bitrate int) (url string, quality string, err error) { if sInfo == nil || len(sInfo.StreamList) == 0 { return "", "", fmt.Errorf("uid 为%d的主播没有可用的直播源", uid) } - // 顺手把码率档位记下来:页面上要用它做下拉,没开播时靠缓存显示 + // 顺手把码率档位记下来:页面上要用它显示「这一档现在对应哪个档位名」,没开播时靠缓存显示 var tiers []qualityTier if tiers = streamTiers(sInfo.StreamList); len(tiers) > 0 { if setStreamerQualities(uid, tiers) { saveConfig() } } - want := resolveQualityBitrate(tiers, bitrate) + want := resolveQualityBitrate(tiers, pref, custom) // StreamList 按码率升序排列,从高往低找选中的那一档 st := sInfo.StreamList[len(sInfo.StreamList)-1] if want > 0 { @@ -262,7 +262,7 @@ func (s streamer) recordLive(ctx context.Context, liveID, filename string) { var resumeFails int // 连续取不到直播源的次数 for { - url, quality, err := getStreamURL(s.UID, s.Bitrate) + url, quality, err := getStreamURL(s.UID, s.QualityPref, s.Bitrate) if err != nil { // 断流重连时直播源往往要过几秒才回来,窗口内多试几次,别把一场直播切成两段 if resumeFails < maxResumeRetry && ctx.Err() == nil && s.waitResume(ctx) { diff --git a/webui.go b/webui.go index 92231e3..a307943 100644 --- a/webui.go +++ b/webui.go @@ -98,7 +98,8 @@ const webuiHTML = ` 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 = ` -
+
+
+
@@ -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 ? '直播中' : '离线'; 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 ''; + }).join(""); + var tip = qs.length ? '录制档位:按这次直播的实际档位对应,正在录制时换档会自动重录' + : '录制档位:还没读到档位表(开播后才拿得到),随时可以先设好;点右边按钮能立刻读一次'; + // data-qbitrate 只是给排查看的:真正「当前生效的档位码率」会从服务端状态里取 + var brCell = ''; + if(cur === "custom"){ + brCell += ''; } - var opts = ''; - var matched = false; - qs.forEach(function(q){ - if(q.bitrate === s.bitrate) matched = true; - opts += ''; - }); - // 指定过的档位现在这一场没有(比如主播换了档位表、或还没读到档位表)时补一项, - // 免得选中项空着。>0 的语义是「不超过它的最高档」,所以文案写成 ≤ - if(s.bitrate && !matched){ - var pin = '已指定:≤' + s.bitrate + 'K' + (s.qualityName ? '(' + esc(s.qualityName) + ')' : ''); - opts += ''; - } - // 还没读到档位表时下拉里只有「自动」,旁边给个按钮去直播间接一次 - var tip = qs.length ? '录制档位,正在录制时换档会自动重录;「自动 · 第二高」跟着实际档位排序走' - : '还没读到档位表(开播后才拿得到),点右边按钮去录一次'; - var brCell = '' - + ''; + brCell += ''; return '
' + 'uid ' + s.uid + '' + '' + (esc(s.name) || "uid " + s.uid) + '' @@ -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 || '
列表为空,在上面填 uid 添加
'; + 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"))