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

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

- 新增 `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
+17 -6
View File
@@ -70,8 +70,9 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
"uid": 23682490, // 主播的 uid "uid": 23682490, // 主播的 uid
"danmu": true, // 是否录制弹幕 "danmu": true, // 是否录制弹幕
"record": true, // 是否录制直播视频 "record": true, // 是否录制直播视频
"bitrate": 0, // 选定的档位码率(Kbps),对应下面 qualities 里的一个档位;0 = 自动(按实际档位取第二高) "qualityPref": "second", // 档位偏好:top(最高档)/ second(第二高)/ lowest(最低档)/ custom(自定义)
"qualities": [] // 开播时读到的码率档位列表,缓存下来让没开播时也能选 "bitrate": 0, // 只在 qualityPref 为 custom 时有意义:不超过这个码率(Kbps)的最高档
"qualities": [] // 开播时读到的码率档位列表,缓存下来让没开播时也能显示对应关系
} }
], ],
"directory": "", // 录制文件保存的文件夹,留空是程序运行时的 -record 文件夹 "directory": "", // 录制文件保存的文件夹,留空是程序运行时的 -record 文件夹
@@ -83,7 +84,8 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
"mergeOutputDir": "", // 合并完成后成片移动到该文件夹,留空则不移动 "mergeOutputDir": "", // 合并完成后成片移动到该文件夹,留空则不移动
"mergeBitrate": 0, // 合并成片的目标码率(Kbps),0 表示按「源片码率 + 500」自动计算 "mergeBitrate": 0, // 合并成片的目标码率(Kbps),0 表示按「源片码率 + 500」自动计算
"danmuFontSize": 0, // 合并时弹幕文字的字号(像素),0 表示跟随弹幕文件;头像随字号等比缩放 "danmuFontSize": 0, // 合并时弹幕文字的字号(像素),0 表示跟随弹幕文件;头像随字号等比缩放
"defaultBitrate": 0, // 新添加主播默认选的档位,0 = 自动(按实际档位取第二高) "defaultQualityPref": "second", // 新添加主播的默认档位偏好,取值同上面的 qualityPref
"defaultBitrate": 0, // 只在上面为 custom 时有意义:默认的自定义码率上限(Kbps)
"webExternal": false, // 是否允许外网访问 web UI,false 时只监听本机 127.0.0.1 "webExternal": false, // 是否允许外网访问 web UI,false 时只监听本机 127.0.0.1
"authToken": "…", // web UI 管理接口的访问口令,首次启动自动生成 "authToken": "…", // web UI 管理接口的访问口令,首次启动自动生成
@@ -112,9 +114,18 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
`uids`也兼容旧的纯数字数组格式,如`"uids": [23682490, 12345]`,此时弹幕和录播都默认开启、码率按实际档位取第二档。 `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`,成片优先移动到那里。 录播原片和弹幕文件(`.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`清空保存后,程序会自动重新生成一个,不会把自己锁在外面。 `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,找不到时启动日志里会有明确提示。 `ffmpegPath`用来指定 ffmpeg 的位置,可以填可执行文件本身(如`D:\ffmpeg\bin\ffmpeg.exe`)或它所在的文件夹,留空时按「PATH → 程序所在文件夹 → 程序所在文件夹的 bin、ffmpeg、ffmpeg/bin 子目录」的顺序自动查找。填相对路径时按程序所在目录解析,不会因为合并时会切换工作目录而失效(这是`ffmpeg.exe`这种只写文件名的写法能正常工作的原因)。录播和弹幕合并都依赖 ffmpeg,找不到时启动日志里会有明确提示。
+124 -36
View File
@@ -26,12 +26,57 @@ var configFileLocation string // 设置文件位置
// 主播的设置数据 // 主播的设置数据
type streamer struct { type streamer struct {
UID int `json:"uid"` // 主播 uid UID int `json:"uid"` // 主播 uid
Name string `json:"name"` // 主播名字 Name string `json:"name"` // 主播名字
Danmu bool `json:"-"` // 是否录制直播弹幕 Danmu bool `json:"-"` // 是否录制直播弹幕
Record bool `json:"-"` // 是否录制直播视频 Record bool `json:"-"` // 是否录制直播视频
Bitrate int `json:"-"` // 选定的档位码率(Kbps),0 = 自动(按实际档位取第二高) QualityPref string `json:"-"` // 档位偏好:最高/第二高/最低/自定义(见 qualityPref* 常量)
Qualities []qualityTier `json:"-"` // 上次探测到的码率档位,没开播时页面靠它显示下拉 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 { type uidSetting struct {
UID int `json:"uid"` UID int `json:"uid"`
Danmu bool `json:"danmu"` Danmu bool `json:"danmu"`
Record bool `json:"record"` Record bool `json:"record"`
Bitrate int `json:"bitrate"` // 选定的档位码率(Kbps),0 = 自动(按实际档位取第二高) QualityPref string `json:"qualityPref,omitempty"` // 档位偏好,留空按老规则从 bitrate 推(见 migrateQualityPref)
Qualities []qualityTier `json:"qualities,omitempty"` // 上次探测到的档位,缓存下来让没开播时也能在页面上选 Bitrate int `json:"bitrate"` // 自定义档位时的码率上限(Kbps)
Qualities []qualityTier `json:"qualities,omitempty"` // 上次探测到的档位,缓存下来让没开播时也能显示对应关系
} }
// uidSetting 列表,兼容旧的纯数字数组格式(旧格式默认弹幕和录播都开启) // uidSetting 列表,兼容旧的纯数字数组格式(旧格式默认弹幕和录播都开启)
@@ -93,7 +139,9 @@ type configData struct {
MergeBitrate int `json:"mergeBitrate"` // 合并成片的目标码率(Kbps),0 表示使用默认质量 MergeBitrate int `json:"mergeBitrate"` // 合并成片的目标码率(Kbps),0 表示使用默认质量
DanmuFontSize int `json:"danmuFontSize"` // 合并时弹幕文字的字号(头像随字号等比缩放),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 时只监听本机 WebExternal bool `json:"webExternal"` // 是否允许外网访问 web UI,false 时只监听本机
@@ -426,6 +474,8 @@ func checkConfig() {
os.Exit(1) os.Exit(1)
} }
} }
// 老配置没有 qualityPref,这里按 bitrate 推一个出来,不写盘(下次保存时自然带上)
upgradeQualityPrefs()
} }
// 清掉上次运行残留的合成临时文件,只在启动时调用一次。 // 清掉上次运行残留的合成临时文件,只在启动时调用一次。
@@ -448,6 +498,7 @@ func buildStreamers() {
if s, ok := streamers.crt[u.UID]; ok { if s, ok := streamers.crt[u.UID]; ok {
s.Danmu = u.Danmu s.Danmu = u.Danmu
s.Record = u.Record s.Record = u.Record
s.QualityPref = u.QualityPref
s.Bitrate = u.Bitrate s.Bitrate = u.Bitrate
s.Qualities = u.Qualities s.Qualities = u.Qualities
news[u.UID] = s news[u.UID] = s
@@ -457,11 +508,26 @@ func buildStreamers() {
if name == "" { if name == "" {
name = fmt.Sprintf("uid%d", u.UID) 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 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 { func sortQualitiesDesc(tiers []qualityTier) []qualityTier {
out := append([]qualityTier(nil), tiers...) out := append([]qualityTier(nil), tiers...)
@@ -469,8 +535,8 @@ func sortQualitiesDesc(tiers []qualityTier) []qualityTier {
return out return out
} }
// 没手动选过码率(0)时的默认档位:按码率自上往下取第二档; // 没手动选过(第二高)时的默认档位:按码率自上往下取第二档;
// 只有一个档位就用它,没有档位返回 0(交给调用方保持原样) // 只有一个档位就用它;没有档位返回 0
func defaultQualityBitrate(tiers []qualityTier) int { func defaultQualityBitrate(tiers []qualityTier) int {
desc := sortQualitiesDesc(tiers) desc := sortQualitiesDesc(tiers)
switch len(desc) { switch len(desc) {
@@ -534,23 +600,32 @@ func setStreamerQualities(uid int, tiers []qualityTier) bool {
return changed return changed
} }
// 按选定的码率决定实际用哪个档位: // 按偏好决定实际用哪个档位,返回档位码率:
// 0(自动)取自上往下第二档(只有一个档位就用它);填了具体码率就取不超过它的最高档, // 最高/第二高/最低按本次读到的档位排序取;自定义取不超过 custom 的最高档,都比它高时退回最低档。
// 都比它高时退回最低档。档位列表为空返回 0,交给调用方按老规矩取最高档。 // 档位列表为空时返回 0(自定义时返回 custom),交给调用方按老规矩取最高档。
func resolveQualityBitrate(tiers []qualityTier, bitrate int) int { func resolveQualityBitrate(tiers []qualityTier, pref string, custom int) int {
desc := sortQualitiesDesc(tiers) desc := sortQualitiesDesc(tiers)
if len(desc) == 0 { 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) 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) lPrintWarnf("不存在 uid 为%d的用户", uid)
return false 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 里的开关 // 同步 streamers.crt 里的开关
if s, ok := streamers.crt[uid]; ok { if s, ok := streamers.crt[uid]; ok {
@@ -710,12 +786,14 @@ func setStreamerFlags(uid int, danmu, record bool) bool {
return true return true
} }
// 设置指定主播的录播码率上限(Kbps,0 为码率最高的直播源)并保存,正在录制时自动重启录制让新码率生效 // 改某个主播的录制档位偏好(pref 见 qualityPref* 常量,custom 是自定义时的码率上限)。
func setBitrate(uid, bitrate int) bool { // 正在录制的会把这一段停掉重开,让新档位立刻生效
if uid <= 0 || bitrate < 0 { func setQuality(uid int, pref string, custom int) bool {
lPrintWarn("uid 必须大于 0,bitrate 不能小于 0") if uid <= 0 || custom < 0 {
lPrintWarn("uid 必须大于 0,自定义码率不能小于 0")
return false return false
} }
pref = normalizeQualityPref(pref)
wasRecording := false wasRecording := false
if infoList, ok := getLiveInfoByUID(uid); ok { if infoList, ok := getLiveInfoByUID(uid); ok {
@@ -730,14 +808,16 @@ func setBitrate(uid, bitrate int) bool {
found := false found := false
for i, u := range config.UIDs { for i, u := range config.UIDs {
if u.UID == uid { if u.UID == uid {
config.UIDs[i].Bitrate = bitrate config.UIDs[i].QualityPref = pref
config.UIDs[i].Bitrate = custom
found = true found = true
break break
} }
} }
if found { if found {
if s, ok := streamers.crt[uid]; ok { if s, ok := streamers.crt[uid]; ok {
s.Bitrate = bitrate s.QualityPref = pref
s.Bitrate = custom
streamers.crt[uid] = s streamers.crt[uid] = s
} }
} }
@@ -749,15 +829,23 @@ func setBitrate(uid, bitrate int) bool {
saveConfig() saveConfig()
// 码率只对新分段生效,正在录制时重启录制 // 档位只对新分段生效,正在录制时重启录制
if wasRecording { if wasRecording {
stopRecord(uid) stopRecord(uid)
startRecord(uid) startRecord(uid)
lPrintf("已将 uid %d 的录播码率上限改为 %d Kbps 并重启录制", uid, bitrate) lPrintf("已将 uid %d 的录制档位改为 %s并重启录制", uid, qualityLogText(pref, custom))
} }
return true 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 // 停止或重新开始指定主播的录制,target 为 danmu、record 或 all(空字符串表示全部),action 为 stop 或 restart
func controlStreamer(uid int, target, action string) error { func controlStreamer(uid int, target, action string) error {
if uid <= 0 { if uid <= 0 {
+3 -1
View File
@@ -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=` 里也都能通过。用脚本调这套接口时不用管编码,原样填就行。 口令支持任意字符,包括中文——不过 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>` 去直播间接一次该主播的**码率档位**(返回`{"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>` 去直播间接一次该主播的**码率档位**(返回`{"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":"…"}`,找不到时是空字符串。值里录播目录/成片目录下的文件只写文件名,别处的写绝对路径,和`浏览`弹窗的填法一致。 `GET /api/merge/danmu?video=<视频文件名或路径>` 按视频名找同目录下的同名弹幕文件,给页面「选好视频自动匹配弹幕」用。依次试`.jsonl`、`.ass`、`.nick.ass`,同目录没有就拿文件名去录播目录再找一次;返回`{"danmu":"…"}`,找不到时是空字符串。值里录播目录/成片目录下的文件只写文件名,别处的写绝对路径,和`浏览`弹窗的填法一致。
+136 -21
View File
@@ -123,38 +123,153 @@ func TestSetStreamerQualities(t *testing.T) {
} }
} }
// 实际用哪个档位:0 = 自动(第二高),填了具体码率 = 不超过它的最高档, // 按偏好决定实际用哪个档位:最高/第二高/最低按实际档位排序取,
// 都比它高时退回最低档 // 自定义取不超过给定码率的最高档、都比它高时退回最低档
func TestResolveQualityBitrate(t *testing.T) { func TestResolveQualityBitrate(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
bitrate int pref string
want int custom int
want int
}{ }{
{"自动取第二高", 0, 4000}, {"最高档", qualityPrefTop, 0, 8000},
{"指定最高档", 8000, 8000}, {"第二高(默认)", qualityPrefSecond, 0, 4000},
{"指定第二高", 4000, 4000}, {"最低档", qualityPrefLowest, 0, 1000},
{"指定最低档", 1000, 1000}, {"空偏好按默认的第二高", "", 0, 4000},
{"填了中间值取不超过它的最高档", 3000, 2000}, {"没见过的偏好也按第二高", "bogus", 0, 4000},
{"比所有档位都高就取最高档", 99999, 8000}, {"自定义取不超过上限的最高档", qualityPrefCustom, 3000, 2000},
{"比所有档位都低就退回最低档", 200, 1000}, {"自定义上限正好等于某一档", qualityPrefCustom, 4000, 4000},
{"自定义上限比所有档位都高就取最高档", qualityPrefCustom, 99999, 8000},
{"自定义上限比所有档位都低就退回最低档", qualityPrefCustom, 200, 1000},
{"自定义填 0 等于最低档", qualityPrefCustom, 0, 1000},
} }
for _, c := range cases { 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) t.Errorf("%s:应为 %d,实际 %d", c.name, c.want, got)
} }
} }
// 没有档位表时保持原样(取流逻辑会退回最高档) // 没有档位表时:相对偏好返回 0(取流时退回最高档),自定义返回填的上限
if got := resolveQualityBitrate(nil, 0); got != 0 { if got := resolveQualityBitrate(nil, qualityPrefSecond, 0); got != 0 {
t.Errorf("没有档位表时应返回 0,实际 %d", got) t.Errorf("没有档位表时相对偏好应返回 0,实际 %d", got)
} }
if got := resolveQualityBitrate(nil, 2000); got != 2000 { if got := resolveQualityBitrate(nil, qualityPrefCustom, 2500); got != 2500 {
t.Errorf("没有档位表时应原样返回,实际 %d", got) t.Errorf("没有档位表时自定义应返回填的 2500,实际 %d", got)
} }
// 只有一档时自动档就是它 // 只有一档时:最高/第二高都是它,最低也是它
one := []qualityTier{{"高清", 2000}} one := []qualityTier{{"高清", 2000}}
if got := resolveQualityBitrate(one, 0); got != 2000 { for _, pref := range []string{qualityPrefTop, qualityPrefSecond, qualityPrefLowest} {
t.Errorf("只有一档时自动应为 2000,实际 %d", got) 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 应该失败")
} }
} }
+6 -6
View File
@@ -121,9 +121,9 @@ func fetchStreamQualities(uid int) ([]qualityTier, error) {
return streamTiers(info.StreamList), nil return streamTiers(info.StreamList), nil
} }
// 获取 uid 的直播源(flv)。bitrate 是页面上选的档位码率,0 表示自动: // 获取 uid 的直播源(flv)。pref/custom 是页面上选的档位偏好(最高/第二高/最低/自定义),
// 按这次拿到的实际档位自上往下取第二高(只有一个档位就用它,拿不到档位表退回最高档) // 按这次拿到的实际档位对应上去;拿不到档位表时退回最高档
func getStreamURL(uid, bitrate int) (url string, quality string, err error) { func getStreamURL(uid int, pref string, custom int) (url string, quality string, err error) {
sInfo, err := newStreamInfo(uid) sInfo, err := newStreamInfo(uid)
if err != nil { if err != nil {
return "", "", err return "", "", err
@@ -131,14 +131,14 @@ func getStreamURL(uid, bitrate int) (url string, quality string, err error) {
if sInfo == nil || len(sInfo.StreamList) == 0 { if sInfo == nil || len(sInfo.StreamList) == 0 {
return "", "", fmt.Errorf("uid 为%d的主播没有可用的直播源", uid) return "", "", fmt.Errorf("uid 为%d的主播没有可用的直播源", uid)
} }
// 顺手把码率档位记下来:页面上要用它做下拉,没开播时靠缓存显示 // 顺手把码率档位记下来:页面上要用它显示「这一档现在对应哪个档位名」,没开播时靠缓存显示
var tiers []qualityTier var tiers []qualityTier
if tiers = streamTiers(sInfo.StreamList); len(tiers) > 0 { if tiers = streamTiers(sInfo.StreamList); len(tiers) > 0 {
if setStreamerQualities(uid, tiers) { if setStreamerQualities(uid, tiers) {
saveConfig() saveConfig()
} }
} }
want := resolveQualityBitrate(tiers, bitrate) want := resolveQualityBitrate(tiers, pref, custom)
// StreamList 按码率升序排列,从高往低找选中的那一档 // StreamList 按码率升序排列,从高往低找选中的那一档
st := sInfo.StreamList[len(sInfo.StreamList)-1] st := sInfo.StreamList[len(sInfo.StreamList)-1]
if want > 0 { if want > 0 {
@@ -262,7 +262,7 @@ func (s streamer) recordLive(ctx context.Context, liveID, filename string) {
var resumeFails int // 连续取不到直播源的次数 var resumeFails int // 连续取不到直播源的次数
for { for {
url, quality, err := getStreamURL(s.UID, s.Bitrate) url, quality, err := getStreamURL(s.UID, s.QualityPref, s.Bitrate)
if err != nil { if err != nil {
// 断流重连时直播源往往要过几秒才回来,窗口内多试几次,别把一场直播切成两段 // 断流重连时直播源往往要过几秒才回来,窗口内多试几次,别把一场直播切成两段
if resumeFails < maxResumeRetry && ctx.Err() == nil && s.waitResume(ctx) { if resumeFails < maxResumeRetry && ctx.Err() == nil && s.waitResume(ctx) {
+263 -145
View File
@@ -98,7 +98,8 @@ const webuiHTML = `<!DOCTYPE html>
input[readonly]{background:var(--soft2);color:var(--sub)} 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{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} #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} button.mini{padding:4px 6px;font-size:11.5px}
/* 开关 */ /* 开关 */
.sws{display:flex;flex-wrap:wrap;gap:10px 22px} .sws{display:flex;flex-wrap:wrap;gap:10px 22px}
@@ -274,7 +275,14 @@ const webuiHTML = `<!DOCTYPE html>
<option value="flv">flv(兼容性一般)</option> <option value="flv">flv(兼容性一般)</option>
<option value="mkv">mkv(可装多音轨)</option> <option value="mkv">mkv(可装多音轨)</option>
</select></div> </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="f"><label>ffmpeg 路径(留空自动查找)</label>
<div class="pathf"> <div class="pathf">
<input type="text" id="ffmpegPath" placeholder="例如 D:\ffmpeg\bin\ffmpeg.exe"> <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 authed = false; // 是否已通过口令校验,未通过时停止轮询
var tokenLoaded = false; var tokenLoaded = false;
// 设置表单里的字段,元素 id 与 /api/config 的字段名一致 // 设置表单里的字段,元素 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}; var numberFields = {mergeBitrate:1, danmuFontSize:1, defaultBitrate:1, meowHtmlHeight:1};
// 用户改过、还没提交的字段。定时刷新会拿服务端的值填表单,不记下这些字段的话, // 用户改过、还没提交的字段。定时刷新会拿服务端的值填表单,不记下这些字段的话,
// 刚填好的数字 3 秒后会被刷回旧值,看起来就像「改了又自己恢复」 // 刚填好的数字 3 秒后会被刷回旧值,看起来就像「改了又自己恢复」
@@ -645,7 +653,9 @@ async function refresh(){
setVal("mergeOutputDir", st.mergeOutputDir||""); setVal("mergeOutputDir", st.mergeOutputDir||"");
setVal("mergeBitrate", st.mergeBitrate||0); setVal("mergeBitrate", st.mergeBitrate||0);
setVal("danmuFontSize", st.danmuFontSize||0); setVal("danmuFontSize", st.danmuFontSize||0);
setVal("defaultQualityPref", st.defaultQualityPref||"second");
setVal("defaultBitrate", st.defaultBitrate||0); setVal("defaultBitrate", st.defaultBitrate||0);
toggleDefaultBr();
setVal("recordSubdir", st.recordSubdir||"record"); setVal("recordSubdir", st.recordSubdir||"record");
setVal("mergeSubdir", st.mergeSubdir||"merged"); setVal("mergeSubdir", st.mergeSubdir||"merged");
setVal("ffmpegPath", st.ffmpegPath||""); 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>'; if(!badges) badges = s.live ? '<span class="badge on">直播中</span>' : '<span class="off">离线</span>';
var title = esc(s.title); var title = esc(s.title);
var qs = s.qualities || []; var qs = s.qualities || [];
// 档位一律用下拉:第一项「自动 · 第二高」(跟着实际档位排序走,值 0), // 档位是「按实际档位里的第几档」来选的:四种偏好永远都在(不开播也能先设),
// 后面是这次读到的实际档位。手动选过的档位就是选中项,随时能选回「自动」。 // 读到档位表后把对应到的档位名标在后面
var autoLabel = '自动 · 第二高'; var nameFor = function(p){
if(!s.bitrate){ if(p === "custom") return s.qualityName || "";
autoLabel += s.qualityName ? '(当前 = ' + esc(s.qualityName) + ')' : (qs.length ? '' : '(开播后才有)'); 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>'; brCell += '<button class="ghost mini" onclick="loadQualities(' + s.uid + ')" title="去直播间接一次码率档位">档位</button>';
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>';
return '<div class="row">' return '<div class="row">'
+ '<span class="uid">uid ' + s.uid + '</span>' + '<span class="uid">uid ' + s.uid + '</span>'
+ '<span class="name">' + (esc(s.name) || "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)){ if(sig !== stSig && !list.contains(document.activeElement)){
stSig = sig; 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>'; 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); autoLoadQualities(st.streamers);
var lr = await api("/api/log?since=" + logOffset); var lr = await api("/api/log?since=" + logOffset);
if(lr.status === 401){ showLogin("管理口令已失效,请重新输入"); return; } if(lr.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
@@ -796,6 +825,23 @@ async function saveSettings(b, autoSave){
["recordFormat","meowMsgType"].forEach(function(id){ ["recordFormat","meowMsgType"].forEach(function(id){
document.getElementById(id).addEventListener("change", function(){ dropDirty(id); saveSettings(null, true); }); 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(); }); document.getElementById("mrgVideos").addEventListener("change", function(){ autoMatchDanmu(); });
// HTML 高度只对 html 类型有意义,选 text 时收起来 // HTML 高度只对 html 类型有意义,选 text 时收起来
@@ -809,6 +855,7 @@ document.addEventListener("input", function(e){
var el = e.target; var el = e.target;
if(!el) return; if(!el) return;
if(el.classList && el.classList.contains("br")){ el.dataset.dirty = "1"; 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); if(el.id && settingFields.indexOf(el.id) >= 0) markDirty(el.id);
}); });
// 回车等于点保存:输入框不在 form 里,浏览器默认不会提交,很容易以为已经生效了 // 回车等于点保存:输入框不在 form 里,浏览器默认不会提交,很容易以为已经生效了
@@ -816,7 +863,7 @@ document.addEventListener("keydown", function(e){
if(e.key !== "Enter" || e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return; if(e.key !== "Enter" || e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return;
var el = e.target; var el = e.target;
if(!el || el.tagName !== "INPUT" || el.type !== "text" || el.readOnly) return; 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 === "newuid"){ e.preventDefault(); addUid(); return; }
if(el.id === "authTokenNew"){ e.preventDefault(); changeToken(); return; } if(el.id === "authTokenNew"){ e.preventDefault(); changeToken(); return; }
if(el.id === "mrgOffset" || el.id === "mrgOut" || el.id === "mrgDanmu"){ e.preventDefault(); checkMerge(); 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; if(!confirm("确定删除 uid " + uid + " 并停止其录制?")) return;
post("/api/streamer/remove", {uid: uid}); 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){ document.addEventListener("change", async function(e){
var t = e.target; var t = e.target;
if(t.matches('input[type=checkbox][data-uid]')){ if(t.matches('input[type=checkbox][data-uid]')){
var uid = parseInt(t.dataset.uid, 10); var uid = parseInt(t.dataset.uid, 10);
var row = t.closest(".row"); var row = t.closest(".row");
var danmu = row.querySelector('input[data-k=danmu]').checked; var q = rowQuality(row);
var record = row.querySelector('input[data-k=record]').checked; if(!q){ showMsg(false, "自定义档位的码率必须是大于等于 0 的数字"); return; }
var bitrate = parseInt(row.querySelector('select.brsel').value, 10) || 0; q.uid = uid;
post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate}); 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')){ if(t.matches('select.brsel')){
// 档位下拉是选完立即生效的(正在录制时会自动换档重录) // 档位下拉是选完立即生效的(正在录制时会自动换档重录)
var uid3 = parseInt(t.dataset.uid, 10); var uid3 = parseInt(t.dataset.uid, 10);
var row3 = t.closest(".row"); var row3 = t.closest(".row");
var v3 = parseInt(t.value, 10) || 0; var q3 = rowQuality(row3);
var danmu3 = row3.querySelector('input[data-k=danmu]').checked; if(!q3){ showMsg(false, "自定义档位的码率必须是大于等于 0 的数字"); return; }
var record3 = row3.querySelector('input[data-k=record]').checked; q3.uid = uid3;
await post("/api/streamer", {uid: uid3, danmu: danmu3, record: record3, bitrate: v3}); 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 = 自动第二高) // 去直播间接一次码率档位;拿到后下拉里就列出实际档位(0 = 自动第二高)
@@ -1305,10 +1400,11 @@ type webStreamer struct {
Live bool `json:"live"` Live bool `json:"live"`
Danmu bool `json:"danmu"` // 弹幕录制开关 Danmu bool `json:"danmu"` // 弹幕录制开关
Record bool `json:"record"` // 视频录制开关 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"` // 可选档位(按码率从高到低),没探测到时为空 Qualities []qualityTier `json:"qualities"` // 可选档位(按码率从高到低),没探测到时为空
QualityBitrate int `json:"qualityBitrate"` // 实际会用到的档位码率(自动时就是第二高的那个) QualityBitrate int `json:"qualityBitrate"` // 按偏好对应到的档位码率(没有档位表时为 0)
QualityName string `json:"qualityName"` // 实际会用到的档位名 QualityName string `json:"qualityName"` // 对应到的档位名
DanmuActive bool `json:"danmuActive"` // 是否正在录制弹幕 DanmuActive bool `json:"danmuActive"` // 是否正在录制弹幕
RecordActive bool `json:"recordActive"` // 是否正在录制视频 RecordActive bool `json:"recordActive"` // 是否正在录制视频
Title string `json:"title"` Title string `json:"title"`
@@ -1317,39 +1413,40 @@ type webStreamer struct {
// web UI 的状态数据 // web UI 的状态数据
type webStatus struct { type webStatus struct {
UIDs []uidSetting `json:"uids"` UIDs []uidSetting `json:"uids"`
Directory string `json:"directory"` Directory string `json:"directory"`
AutoMerge bool `json:"autoMerge"` AutoMerge bool `json:"autoMerge"`
MergeDelVideo bool `json:"mergeDelVideo"` MergeDelVideo bool `json:"mergeDelVideo"`
MergeDelDanmu bool `json:"mergeDelDanmu"` MergeDelDanmu bool `json:"mergeDelDanmu"`
MergeOutputDir string `json:"mergeOutputDir"` MergeOutputDir string `json:"mergeOutputDir"`
MergeBitrate int `json:"mergeBitrate"` MergeBitrate int `json:"mergeBitrate"`
DanmuFontSize int `json:"danmuFontSize"` DanmuFontSize int `json:"danmuFontSize"`
DefaultBitrate int `json:"defaultBitrate"` DefaultQualityPref string `json:"defaultQualityPref"`
WebExternal bool `json:"webExternal"` DefaultBitrate int `json:"defaultBitrate"`
RecordSubdir string `json:"recordSubdir"` WebExternal bool `json:"webExternal"`
MergeSubdir string `json:"mergeSubdir"` RecordSubdir string `json:"recordSubdir"`
FFmpegPath string `json:"ffmpegPath"` MergeSubdir string `json:"mergeSubdir"`
RecordFormat string `json:"recordFormat"` FFmpegPath string `json:"ffmpegPath"`
NotifyEnabled bool `json:"notifyEnabled"` RecordFormat string `json:"recordFormat"`
NotifyURL string `json:"notifyUrl"` NotifyEnabled bool `json:"notifyEnabled"`
NotifyToken string `json:"notifyToken"` NotifyURL string `json:"notifyUrl"`
NotifyGroup string `json:"notifyGroup"` NotifyToken string `json:"notifyToken"`
NotifyLiveOn bool `json:"notifyLiveOn"` NotifyGroup string `json:"notifyGroup"`
NotifyLiveOff bool `json:"notifyLiveOff"` NotifyLiveOn bool `json:"notifyLiveOn"`
NotifyRecordStart bool `json:"notifyRecordStart"` NotifyLiveOff bool `json:"notifyLiveOff"`
NotifyMergeDone bool `json:"notifyMergeDone"` NotifyRecordStart bool `json:"notifyRecordStart"`
MeowEnabled bool `json:"meowEnabled"` NotifyMergeDone bool `json:"notifyMergeDone"`
MeowNickname string `json:"meowNickname"` MeowEnabled bool `json:"meowEnabled"`
MeowMsgType string `json:"meowMsgType"` MeowNickname string `json:"meowNickname"`
MeowHTMLHeight int `json:"meowHtmlHeight"` MeowMsgType string `json:"meowMsgType"`
MeowImgURL string `json:"meowImgUrl"` MeowHTMLHeight int `json:"meowHtmlHeight"`
MeowBlocked string `json:"meowBlocked"` // MeoW 推不出去的原因,空字符串表示正常 MeowImgURL string `json:"meowImgUrl"`
MergeBusy bool `json:"mergeBusy"` // 手动合并是否正在进行 MeowBlocked string `json:"meowBlocked"` // MeoW 推不出去的原因,空字符串表示正常
MergeOutName string `json:"mergeOutName"` // 正在进行的手动合并的输出文件名 MergeBusy bool `json:"mergeBusy"` // 手动合并是否正在进行
MergeErr string `json:"mergeErr"` // 最近一次手动合并的错误 MergeOutName string `json:"mergeOutName"` // 正在进行的手动合并的输出文件名
MergeDoneAt int64 `json:"mergeDoneAt"` // 最近一次手动合并结束的时刻 MergeErr string `json:"mergeErr"` // 最近一次手动合并的错误
Streamers []webStreamer `json:"streamers"` MergeDoneAt int64 `json:"mergeDoneAt"` // 最近一次手动合并结束的时刻
Streamers []webStreamer `json:"streamers"`
} }
// 打印日志到 web UI // 打印日志到 web UI
@@ -1378,39 +1475,40 @@ func statusHandler(w http.ResponseWriter, r *http.Request) {
mergeBusy, mergeOut, mergeErr, mergeDoneAt := mergeRunSnapshot() mergeBusy, mergeOut, mergeErr, mergeDoneAt := mergeRunSnapshot()
streamers.RLock() streamers.RLock()
status := webStatus{ status := webStatus{
UIDs: append(uidList{}, config.UIDs...), UIDs: append(uidList{}, config.UIDs...),
Directory: config.Directory, Directory: config.Directory,
AutoMerge: config.AutoMerge, AutoMerge: config.AutoMerge,
MergeDelVideo: config.MergeDelVideo, MergeDelVideo: config.MergeDelVideo,
MergeDelDanmu: config.MergeDelDanmu, MergeDelDanmu: config.MergeDelDanmu,
MergeOutputDir: config.MergeOutputDir, MergeOutputDir: config.MergeOutputDir,
MergeBitrate: config.MergeBitrate, MergeBitrate: config.MergeBitrate,
DanmuFontSize: config.DanmuFontSize, DanmuFontSize: config.DanmuFontSize,
WebExternal: config.WebExternal, WebExternal: config.WebExternal,
RecordSubdir: config.RecordSubdir, RecordSubdir: config.RecordSubdir,
MergeSubdir: config.MergeSubdir, MergeSubdir: config.MergeSubdir,
FFmpegPath: config.FFmpegPath, FFmpegPath: config.FFmpegPath,
RecordFormat: normalizeRecordFormat(config.RecordFormat), RecordFormat: normalizeRecordFormat(config.RecordFormat),
DefaultBitrate: config.DefaultBitrate, DefaultQualityPref: normalizeQualityPref(config.DefaultQualityPref),
NotifyEnabled: config.NotifyEnabled, DefaultBitrate: config.DefaultBitrate,
NotifyURL: config.NotifyURL, NotifyEnabled: config.NotifyEnabled,
NotifyToken: config.NotifyToken, NotifyURL: config.NotifyURL,
NotifyGroup: config.NotifyGroup, NotifyToken: config.NotifyToken,
NotifyLiveOn: config.NotifyLiveOn, NotifyGroup: config.NotifyGroup,
NotifyLiveOff: config.NotifyLiveOff, NotifyLiveOn: config.NotifyLiveOn,
NotifyRecordStart: config.NotifyRecordStart, NotifyLiveOff: config.NotifyLiveOff,
NotifyMergeDone: config.NotifyMergeDone, NotifyRecordStart: config.NotifyRecordStart,
MeowEnabled: config.MeowEnabled, NotifyMergeDone: config.NotifyMergeDone,
MeowNickname: config.MeowNickname, MeowEnabled: config.MeowEnabled,
MeowMsgType: normalizeMeowMsgType(config.MeowMsgType), MeowNickname: config.MeowNickname,
MeowHTMLHeight: clampMeowHTMLHeight(config.MeowHTMLHeight), MeowMsgType: normalizeMeowMsgType(config.MeowMsgType),
MeowImgURL: config.MeowImgURL, MeowHTMLHeight: clampMeowHTMLHeight(config.MeowHTMLHeight),
MeowBlocked: meowStatusProblem(), MeowImgURL: config.MeowImgURL,
MergeBusy: mergeBusy, MeowBlocked: meowStatusProblem(),
MergeOutName: mergeOut, MergeBusy: mergeBusy,
MergeErr: mergeErr, MergeOutName: mergeOut,
MergeDoneAt: mergeDoneAt, MergeErr: mergeErr,
Streamers: make([]webStreamer, 0, len(streamers.crt)), MergeDoneAt: mergeDoneAt,
Streamers: make([]webStreamer, 0, len(streamers.crt)),
} }
for _, s := range streamers.crt { for _, s := range streamers.crt {
live := s.isLiveOn() live := s.isLiveOn()
@@ -1418,9 +1516,10 @@ func statusHandler(w http.ResponseWriter, r *http.Request) {
if live { if live {
title = s.getTitle() 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.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) ws.QualityName = qualityNameFor(s.Qualities, ws.QualityBitrate)
if infoList, ok := getLiveInfoByUID(s.UID); ok { if infoList, ok := getLiveInfoByUID(s.UID); ok {
for _, info := range infoList { for _, info := range infoList {
@@ -1460,32 +1559,33 @@ func checkWebDir(path, name string) string {
// 修改设置(保存目录和自动合并相关) // 修改设置(保存目录和自动合并相关)
func webConfigHandler(w http.ResponseWriter, r *http.Request) { func webConfigHandler(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Directory string `json:"directory"` Directory string `json:"directory"`
AutoMerge bool `json:"autoMerge"` AutoMerge bool `json:"autoMerge"`
MergeDelVideo bool `json:"mergeDelVideo"` MergeDelVideo bool `json:"mergeDelVideo"`
MergeDelDanmu bool `json:"mergeDelDanmu"` MergeDelDanmu bool `json:"mergeDelDanmu"`
MergeOutputDir string `json:"mergeOutputDir"` MergeOutputDir string `json:"mergeOutputDir"`
MergeBitrate int `json:"mergeBitrate"` MergeBitrate int `json:"mergeBitrate"`
DanmuFontSize int `json:"danmuFontSize"` DanmuFontSize int `json:"danmuFontSize"`
DefaultBitrate int `json:"defaultBitrate"` DefaultQualityPref string `json:"defaultQualityPref"`
WebExternal bool `json:"webExternal"` DefaultBitrate int `json:"defaultBitrate"`
RecordSubdir string `json:"recordSubdir"` WebExternal bool `json:"webExternal"`
MergeSubdir string `json:"mergeSubdir"` RecordSubdir string `json:"recordSubdir"`
FFmpegPath string `json:"ffmpegPath"` MergeSubdir string `json:"mergeSubdir"`
RecordFormat string `json:"recordFormat"` FFmpegPath string `json:"ffmpegPath"`
NotifyEnabled bool `json:"notifyEnabled"` RecordFormat string `json:"recordFormat"`
NotifyURL string `json:"notifyUrl"` NotifyEnabled bool `json:"notifyEnabled"`
NotifyToken string `json:"notifyToken"` NotifyURL string `json:"notifyUrl"`
NotifyGroup string `json:"notifyGroup"` NotifyToken string `json:"notifyToken"`
NotifyLiveOn bool `json:"notifyLiveOn"` NotifyGroup string `json:"notifyGroup"`
NotifyLiveOff bool `json:"notifyLiveOff"` NotifyLiveOn bool `json:"notifyLiveOn"`
NotifyRecordStart bool `json:"notifyRecordStart"` NotifyLiveOff bool `json:"notifyLiveOff"`
NotifyMergeDone bool `json:"notifyMergeDone"` NotifyRecordStart bool `json:"notifyRecordStart"`
MeowEnabled bool `json:"meowEnabled"` NotifyMergeDone bool `json:"notifyMergeDone"`
MeowNickname string `json:"meowNickname"` MeowEnabled bool `json:"meowEnabled"`
MeowMsgType string `json:"meowMsgType"` MeowNickname string `json:"meowNickname"`
MeowHTMLHeight int `json:"meowHtmlHeight"` MeowMsgType string `json:"meowMsgType"`
MeowImgURL string `json:"meowImgUrl"` MeowHTMLHeight int `json:"meowHtmlHeight"`
MeowImgURL string `json:"meowImgUrl"`
} }
err := json.NewDecoder(r.Body).Decode(&req) err := json.NewDecoder(r.Body).Decode(&req)
if err != nil { if err != nil {
@@ -1577,6 +1677,7 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
config.MergeOutputDir = req.MergeOutputDir config.MergeOutputDir = req.MergeOutputDir
config.MergeBitrate = req.MergeBitrate config.MergeBitrate = req.MergeBitrate
config.DanmuFontSize = req.DanmuFontSize config.DanmuFontSize = req.DanmuFontSize
config.DefaultQualityPref = normalizeQualityPref(req.DefaultQualityPref)
config.DefaultBitrate = req.DefaultBitrate config.DefaultBitrate = req.DefaultBitrate
restartWeb := config.WebExternal != req.WebExternal restartWeb := config.WebExternal != req.WebExternal
config.WebExternal = req.WebExternal config.WebExternal = req.WebExternal
@@ -1615,9 +1716,10 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
lPrintln("外网访问设置已改变,重启 web UI 服务器") lPrintln("外网访问设置已改变,重启 web UI 服务器")
restartWebUI() 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.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", lPrintf("推送设置:QQ 机器人渠道 %v(地址 %q、群号 %q)、MeoW 渠道 %v(昵称 %q、类型 %s、高度 %d);事件 开播 %v、下播 %v、开始录制 %v、合并完成 %v",
req.NotifyEnabled, notifyURL, config.NotifyGroup, req.MeowEnabled, meowNickname, meowMsgType, req.NotifyEnabled, notifyURL, config.NotifyGroup, req.MeowEnabled, meowNickname, meowMsgType,
clampMeowHTMLHeight(req.MeowHTMLHeight), clampMeowHTMLHeight(req.MeowHTMLHeight),
@@ -1666,17 +1768,20 @@ func streamerQualitiesHandler(w http.ResponseWriter, r *http.Request) {
if setStreamerQualities(uid, tiers) { if setStreamerQualities(uid, tiers) {
saveConfig() saveConfig()
} }
pref := qualityPrefDefault
bitrate := 0 bitrate := 0
streamers.RLock() streamers.RLock()
if s, ok := streamers.crt[uid]; ok { if s, ok := streamers.crt[uid]; ok {
pref = normalizeQualityPref(s.QualityPref)
bitrate = s.Bitrate bitrate = s.Bitrate
} }
streamers.RUnlock() streamers.RUnlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8") 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{ _ = json.NewEncoder(w).Encode(map[string]any{
"uid": uid, "uid": uid,
"qualities": sortQualitiesDesc(tiers), "qualities": sortQualitiesDesc(tiers),
"qualityPref": pref,
"bitrate": bitrate, "bitrate": bitrate,
"qualityBitrate": used, "qualityBitrate": used,
"qualityName": qualityNameFor(tiers, 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) { func webStreamerHandler(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
UID int `json:"uid"` UID int `json:"uid"`
Danmu bool `json:"danmu"` Danmu bool `json:"danmu"`
Record bool `json:"record"` Record bool `json:"record"`
Bitrate *int `json:"bitrate"` // 不传时使用默认下载码率上限 QualityPref string `json:"qualityPref"` // top / second / lowest / custom,不传时用新主播的默认档位
Bitrate *int `json:"bitrate"` // 自定义档位时的码率上限,不传时用配置里的默认值
} }
err := json.NewDecoder(r.Body).Decode(&req) err := json.NewDecoder(r.Body).Decode(&req)
if err != nil { if err != nil {
@@ -1699,7 +1805,18 @@ func webStreamerHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "uid 必须大于 0", http.StatusBadRequest) http.Error(w, "uid 必须大于 0", http.StatusBadRequest)
return return
} }
// 已存在的主播:不传档位时保持原样(页面切到「最高档」时不会把自定义码率清掉);
// 新增的主播才用配置里的默认档位
pref := normalizeQualityPref(config.DefaultQualityPref)
bitrate := config.DefaultBitrate 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 { if req.Bitrate != nil {
bitrate = *req.Bitrate bitrate = *req.Bitrate
} }
@@ -1708,18 +1825,19 @@ func webStreamerHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// 码率变化时先停掉正在进行的录播,setStreamerFlags 会用新码率重启 // 档位变化时先停掉正在进行的录播,setStreamerFlags 之后用新档位重启
if cur, ok := getStreamer(req.UID); ok && cur.Bitrate != bitrate && cur.Record && req.Record { if exists && cur.Record && req.Record && (cur.QualityPref != pref || cur.Bitrate != bitrate) {
stopRecord(req.UID) stopRecord(req.UID)
} }
if !setStreamerFlags(req.UID, req.Danmu, req.Record) { if !setStreamerFlags(req.UID, req.Danmu, req.Record) {
http.Error(w, "不存在 uid 为"+itoa(req.UID)+"的用户", http.StatusBadRequest) http.Error(w, "不存在 uid 为"+itoa(req.UID)+"的用户", http.StatusBadRequest)
return return
} }
if cur, ok := getStreamer(req.UID); ok && cur.Bitrate != bitrate { if cur, ok := getStreamer(req.UID); ok && (cur.QualityPref != pref || cur.Bitrate != bitrate) {
setBitrate(req.UID, 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.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("true")) _, _ = w.Write([]byte("true"))