From eda83dd1e3b818fa5a009607c941695d88669056 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Fri, 25 Sep 2026 19:42:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BB=E6=92=AD=E5=88=97=E8=A1=A8=E7=9A=84?= =?UTF-8?q?=E7=A0=81=E7=8E=87=E6=94=B9=E4=B8=BA=E7=9B=B4=E6=92=AD=E5=AE=9E?= =?UTF-8?q?=E9=99=85=E6=A1=A3=E4=BD=8D=E4=B8=8B=E6=8B=89=EF=BC=8C=E6=B2=A1?= =?UTF-8?q?=E9=80=89=E8=BF=87=E6=97=B6=E9=BB=98=E8=AE=A4=E7=AC=AC=E4=BA=8C?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来是一个手填的码率上限(Kbps),现在改成直播的实际档位(蓝光 8M / 高清…): - 读到档位后,主播那一行渲染成下拉,选完立即生效;正在录制时换档会自动重录。 - 没手动选过(bitrate 0)的主播按**自上往下第二档**定下来(如 蓝光 8M / 蓝光 4M / 高清 就选蓝光 4M,只有一档就用那一档),不会一上来就吃满 最高档;已经手动选过的永远不动。 - 档位要开播后读直播源列表才知道,读到以后(录制开始时、或页面点「档位」按钮) 缓存进 config.json 的 qualities,所以没开播也能看和改;启动时会把「有缓存但还 没定过码率」的主播按第二档补齐。 - 从来没读过档位的行仍然是手填输入框,旁边多了个「档位」按钮去直播间接一次。 后端:新增 `GET /api/streamer/qualities?uid=…`(同一主播 10 秒内只读一次), `/api/status` 的每个主播多了 `qualities`(按码率从高到低)和 `qualityName`; 抽出 `newStreamInfo()`/`fetchStreamQualities()`,`getStreamURL()` 取流时顺手缓存 档位,并在 bitrate 为 0 时改用刚定下来的默认档位(否则还是最高档)。 档位相关的纯逻辑放在 config.go:`sortQualitiesDesc` / `defaultQualityBitrate` / `qualityNameFor` / `sameQualities` / `setStreamerQualities` / `applyCachedDefaultQualities`。 测试:qualities_test.go 7 个用例(排序、默认第二档的 5 种档位数、档位名匹配、 写配置时不覆盖手动选择、启动补齐、JSON 存取往返);浏览器实测 5 组断言全过 (下拉选中当前档位、换档服务端立即变、无档位时保留输入框+按钮、启动默认第二档、 手填仍可用),verify_streamer_br.py / verify_pick.py / verify_settings.py 无回归; go test ./...、gofmt、go vet 干净。 --- README.md | 15 ++-- config.go | 125 ++++++++++++++++++++++++++--- doc/webapi.md | 2 + main.go | 4 + qualities_test.go | 200 ++++++++++++++++++++++++++++++++++++++++++++++ record.go | 53 +++++++++++- webui.go | 142 ++++++++++++++++++++++++++++---- 7 files changed, 505 insertions(+), 36 deletions(-) create mode 100644 qualities_test.go diff --git a/README.md b/README.md index 7458a98..b10efcb 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,8 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe "uid": 23682490, // 主播的 uid "danmu": true, // 是否录制弹幕 "record": true, // 是否录制直播视频 - "bitrate": 0 // 录制的直播源最高码率(Kbps),0 表示码率最高的直播源 + "bitrate": 0, // 录制的直播源码率(Kbps),对应下面 qualities 里的一个档位;0 = 还没选过(按第二档) + "qualities": [] // 开播时读到的码率档位列表,缓存下来让没开播时也能选 } ], "directory": "", // 录制文件保存的文件夹,留空是程序运行时的 -record 文件夹 @@ -82,7 +83,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe "mergeOutputDir": "", // 合并完成后成片移动到该文件夹,留空则不移动 "mergeBitrate": 0, // 合并成片的目标码率(Kbps),0 表示按「源片码率 + 500」自动计算 "danmuFontSize": 0, // 合并时弹幕文字的字号(像素),0 表示跟随弹幕文件;头像随字号等比缩放 - "defaultBitrate": 0, // 新添加主播的默认 bitrate,0 表示码率最高的直播源 + "defaultBitrate": 0, // 新添加主播的默认 bitrate,0 表示按实际档位取第二档(自上往下第二高) "webExternal": false, // 是否允许外网访问 web UI,false 时只监听本机 127.0.0.1 "authToken": "…", // web UI 管理接口的访问口令,首次启动自动生成 @@ -109,9 +110,9 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe } ``` -`uids`也兼容旧的纯数字数组格式,如`"uids": [23682490, 12345]`,此时弹幕和录播都默认开启、码率取最高。 +`uids`也兼容旧的纯数字数组格式,如`"uids": [23682490, 12345]`,此时弹幕和录播都默认开启、码率按实际档位取第二档。 -`bitrate`为 0 时录制码率最高的直播源,设置为其他数字时录制码率不超过该值的最高档(如 2000 大约是超清档),可以在 web UI 的主播列表里随时修改。录制始终是原画质直接保存(`-c copy`),不会重新编码,所以`bitrate`只能在这几个档位里挑选。 +`bitrate`是**直播的实际档位**(如`蓝光 8M`、`高清`),在 web UI 的主播列表里下拉选择,正在录制时换档会自动重录。没选过(0)时按实际档位取**第二档**(自上往下第二高),只有一个档位就用那一档。档位要开播后读直播源列表才知道,读到以后写进`config.json`的`qualities`里缓存,所以没开播也能看和改;从来没读过档位的话,列表里还是让你直接填码率上限(Kbps)。录制始终是原画质直接保存(`-c copy`),不会重新编码,所以只能在这几个档位里挑。 录播原片和弹幕文件(`.ass`、`.nick.ass`、`.jsonl`)保存在保存目录下的`recordSubdir`子文件夹里(默认`record`),两者同名配对才能自动合并;合并后的成片保存在`mergeSubdir`子文件夹里(默认`merged`),留空则用默认值,两个名字不能相同。如果设置了`mergeOutputDir`,成片优先移动到那里。 @@ -136,7 +137,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 的`录制`分区随时修改,只对新添加的主播生效,已有主播保持各自的值。 +`defaultBitrate`是下载码率的默认值:在 web UI 里新增主播、且不单独指定码率时使用它,也可以在 web UI 的`录制`分区随时修改,只对新添加的主播生效,已有主播保持各自的值。填 0 时按主播**实际能用的档位**来决定 —— 取自上而下的**第二档**(比如有`蓝光 8M / 蓝光 4M / 高清`就录`蓝光 4M`),只有一档时就用那一档;这个档位是开播后读到直播源列表才知道的,读到以后会记进`config.json`,没开播时页面也能显示和修改。 `ffmpegPath`用来指定 ffmpeg 的位置,可以填可执行文件本身(如`D:\ffmpeg\bin\ffmpeg.exe`)或它所在的文件夹,留空时按「PATH → 程序所在文件夹 → 程序所在文件夹的 bin、ffmpeg、ffmpeg/bin 子目录」的顺序自动查找。填相对路径时按程序所在目录解析,不会因为合并时会切换工作目录而失效(这是`ffmpeg.exe`这种只写文件名的写法能正常工作的原因)。录播和弹幕合并都依赖 ffmpeg,找不到时启动日志里会有明确提示。 @@ -146,7 +147,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe 运行时可用参数`-record`指定录制文件保存的文件夹,用`-config`指定配置文件所在文件夹。 -web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`),可以在网页里增删主播、切换每个主播的弹幕和录播开关、修改码率上限、修改保存目录和自动合并设置。 +web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`),可以在网页里增删主播、切换每个主播的弹幕和录播开关、选择录制的码率档位、修改保存目录和自动合并设置。 页面按用途分成六个分区,顶部一条导航切换,地址栏会跟着带上`#分区名`,所以刷新、收藏、直接把链接发给别人都能落到同一个分区: @@ -173,7 +174,7 @@ web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`), 命令行模式运行时可以输入命令控制本程序,输入`help`查看全部命令的解释,输入`quit`退出程序。 -录制直播视频时用`ffmpeg -c copy`直接保存直播源,不转码,分辨率和码率跟随直播源,默认取码率最高的源。 +录制直播视频时用`ffmpeg -c copy`直接保存直播源,不转码,分辨率和码率跟随直播源,档位就是上面说的那个(默认选第二档,而不是吃满最高档)。 录制弹幕时会同时生成三个文件: diff --git a/config.go b/config.go index 87ca6cc..b4a06df 100644 --- a/config.go +++ b/config.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "runtime" + "sort" "strings" "sync" "syscall" @@ -25,19 +26,27 @@ 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 表示码率最高的直播源 + UID int `json:"uid"` // 主播 uid + Name string `json:"name"` // 主播名字 + Danmu bool `json:"-"` // 是否录制直播弹幕 + Record bool `json:"-"` // 是否录制直播视频 + Bitrate int `json:"-"` // 录制的直播源最高码率(Kbps),0 表示还没定(按实际档位取第二高) + Qualities []qualityTier `json:"-"` // 上次探测到的码率档位,没开播时页面靠它显示下拉 +} + +// 直播源的码率档位 +type qualityTier struct { + Name string `json:"name"` // 档位名,如「蓝光4M」「高清」 + Bitrate int `json:"bitrate"` // 档位码率(Kbps) } // 单个主播的开关设置 type uidSetting struct { - UID int `json:"uid"` - Danmu bool `json:"danmu"` - Record bool `json:"record"` - Bitrate int `json:"bitrate"` // 录制的直播源最高码率(Kbps),0 表示码率最高的直播源 + UID int `json:"uid"` + Danmu bool `json:"danmu"` + Record bool `json:"record"` + Bitrate int `json:"bitrate"` // 录制的直播源最高码率(Kbps),0 表示还没定(按实际档位取第二高) + Qualities []qualityTier `json:"qualities,omitempty"` // 上次探测到的档位,缓存下来让没开播时也能在页面上选 } // uidSetting 列表,兼容旧的纯数字数组格式(旧格式默认弹幕和录播都开启) @@ -440,6 +449,7 @@ func buildStreamers() { s.Danmu = u.Danmu s.Record = u.Record s.Bitrate = u.Bitrate + s.Qualities = u.Qualities news[u.UID] = s continue } @@ -447,11 +457,106 @@ 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} + news[u.UID] = streamer{UID: u.UID, Name: name, Danmu: u.Danmu, Record: u.Record, Bitrate: u.Bitrate, Qualities: u.Qualities} } streamers.crt = news } +// 档位按码率从高到低排(页面的下拉按这个顺序显示) +func sortQualitiesDesc(tiers []qualityTier) []qualityTier { + out := append([]qualityTier(nil), tiers...) + sort.SliceStable(out, func(i, j int) bool { return out[i].Bitrate > out[j].Bitrate }) + return out +} + +// 没手动选过码率(0)时的默认档位:按码率自上往下取第二档; +// 只有一个档位就用它,没有档位返回 0(交给调用方保持原样) +func defaultQualityBitrate(tiers []qualityTier) int { + desc := sortQualitiesDesc(tiers) + switch len(desc) { + case 0: + return 0 + case 1: + return desc[0].Bitrate + default: + return desc[1].Bitrate + } +} + +// 某个码率对应的档位名,匹配不上返回空 +func qualityNameFor(tiers []qualityTier, bitrate int) string { + for _, t := range sortQualitiesDesc(tiers) { + if t.Bitrate == bitrate { + return t.Name + } + } + return "" +} + +// 两个档位列表是不是一样(避免每次都写配置) +func sameQualities(a, b []qualityTier) bool { + if len(a) != len(b) { + return false + } + da, db := sortQualitiesDesc(a), sortQualitiesDesc(b) + for i := range da { + if da[i] != db[i] { + return false + } + } + return true +} + +// 记下某个主播当前的码率档位(离线时页面靠它显示下拉)。 +// 顺手把「还没设过码率」的主播按默认档位(第二高)定下来 —— 这样默认不会一直吃满最高档。 +// 返回是否改动了配置,调用方决定要不要 saveConfig。 +func setStreamerQualities(uid int, tiers []qualityTier) bool { + if uid <= 0 || len(tiers) == 0 { + return false + } + changed := false + streamers.Lock() + for i := range config.UIDs { + if config.UIDs[i].UID != uid { + continue + } + if !sameQualities(config.UIDs[i].Qualities, tiers) { + config.UIDs[i].Qualities = tiers + changed = true + } + if config.UIDs[i].Bitrate == 0 { + if def := defaultQualityBitrate(tiers); def > 0 { + config.UIDs[i].Bitrate = def + changed = true + lPrintf("uid %d 还没设过下载码率,按当前直播档位默认取第二档:%s(%d Kbps)", + uid, qualityNameFor(tiers, def), def) + } + } + if s, ok := streamers.crt[uid]; ok { + s.Qualities = config.UIDs[i].Qualities + // 档位定下来后正在录的那场不打断,配置值从下次录制开始生效 + s.Bitrate = config.UIDs[i].Bitrate + streamers.crt[uid] = s + } + break + } + streamers.Unlock() + return changed +} + +// 启动时把「缓存过档位、但还没定过码率」的主播按默认档位定下来 +func applyCachedDefaultQualities() bool { + changed := false + for _, u := range append(uidList(nil), config.UIDs...) { + if u.Bitrate == 0 && len(u.Qualities) > 0 { + if setStreamerQualities(u.UID, u.Qualities) { + changed = true + } + } + } + return changed +} + // 保存 config.json,写入串行化并原子替换,避免并发写坏文件或读到写了一半的内容 func saveConfig() { configMu.Lock() diff --git a/doc/webapi.md b/doc/webapi.md index af48b56..be2953d 100644 --- a/doc/webapi.md +++ b/doc/webapi.md @@ -80,6 +80,8 @@ 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":当前码率,"qualityName":当前档位名}`,`qualities`按码率从高到低)。读到的档位会缓存进`config.json`(没开播时页面靠它显示下拉),并且如果这个主播还没选过码率(0),会按**第二档**定下来。同一个主播 10 秒内只让读一次,太频繁返回 502 并给中文原因(没开播时也会返回 502)。`GET /api/status`里每个主播的`qualities`/`qualityName`就是这份缓存。 + 跟手动合并有关的是下面三个: `GET /api/merge/danmu?video=<视频文件名或路径>` 按视频名找同目录下的同名弹幕文件,给页面「选好视频自动匹配弹幕」用。依次试`.jsonl`、`.ass`、`.nick.ass`,同目录没有就拿文件名去录播目录再找一次;返回`{"danmu":"…"}`,找不到时是空字符串。值里录播目录/成片目录下的文件只写文件名,别处的写绝对路径,和`浏览`弹窗的填法一致。 diff --git a/main.go b/main.go index e74e7c2..7ad43d2 100644 --- a/main.go +++ b/main.go @@ -106,6 +106,10 @@ func initialize() { loadConfig() checkConfig() + // 缓存过档位、但还没定过码率的主播,启动时按默认档位(第二高)定下来 + if applyCachedDefaultQualities() { + saveConfig() + } // 上次在合并途中被关掉的话,录播目录和成片目录里会留下临时文件,启动时清一次 cleanLeftoverTempFiles() ensureAuthToken() diff --git a/qualities_test.go b/qualities_test.go new file mode 100644 index 0000000..4e14949 --- /dev/null +++ b/qualities_test.go @@ -0,0 +1,200 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func tiersForTest() []qualityTier { + // 故意乱序,验证排序 + return []qualityTier{ + {Name: "高清", Bitrate: 2000}, + {Name: "蓝光 8M", Bitrate: 8000}, + {Name: "标清", Bitrate: 1000}, + {Name: "蓝光 4M", Bitrate: 4000}, + } +} + +func TestSortQualitiesDesc(t *testing.T) { + got := sortQualitiesDesc(tiersForTest()) + want := []string{"蓝光 8M", "蓝光 4M", "高清", "标清"} + for i, name := range want { + if got[i].Name != name { + t.Fatalf("第 %d 个应为 %s,实际 %s(%v)", i, name, got[i].Name, got) + } + } + // 不能改动传进来的切片 + in := tiersForTest() + _ = sortQualitiesDesc(in) + if in[0].Name != "高清" { + t.Errorf("排序不该改动原切片:%v", in) + } +} + +// 默认档位 = 自上往下第二档(不是最高档) +func TestDefaultQualityBitrate(t *testing.T) { + cases := []struct { + name string + tiers []qualityTier + want int + }{ + {"四档取第二高", tiersForTest(), 4000}, + {"三档取第二高(你给的例子)", []qualityTier{{"高清", 2000}, {"蓝光 8M", 8000}, {"蓝光 4M", 4000}}, 4000}, + {"两档取低的那档", []qualityTier{{"蓝光 4M", 4000}, {"高清", 2000}}, 2000}, + {"只有一档就用它", []qualityTier{{"高清", 2000}}, 2000}, + {"没有档位返回 0", nil, 0}, + } + for _, c := range cases { + if got := defaultQualityBitrate(c.tiers); got != c.want { + t.Errorf("%s:应为 %d,实际 %d", c.name, c.want, got) + } + } +} + +func TestQualityNameFor(t *testing.T) { + if got := qualityNameFor(tiersForTest(), 4000); got != "蓝光 4M" { + t.Errorf("4000 应匹配「蓝光 4M」,实际 %q", got) + } + if got := qualityNameFor(tiersForTest(), 9999); got != "" { + t.Errorf("匹配不上应返回空,实际 %q", got) + } +} + +func TestSameQualities(t *testing.T) { + a := tiersForTest() + b := []qualityTier{{"标清", 1000}, {"蓝光 4M", 4000}, {"高清", 2000}, {"蓝光 8M", 8000}} + if !sameQualities(a, b) { + t.Error("顺序不同但内容相同的档位列表应视为一样") + } + c := append([]qualityTier(nil), a[:3]...) + if sameQualities(a, c) { + t.Error("档位数量不同应视为不一样") + } +} + +// 记录档位时顺便把「还没设过码率」的主播按默认档位定下来 +func TestSetStreamerQualities(t *testing.T) { + savedConfig := config + savedStreamers := streamers.crt + defer func() { + config = savedConfig + streamers.crt = savedStreamers + }() + + config.UIDs = uidList{ + {UID: 1, Danmu: true, Record: true, Bitrate: 0}, // 没设过 → 应被定成第二档 + {UID: 2, Danmu: true, Record: true, Bitrate: 1000}, // 手动设过 → 不动 + } + streamers.crt = map[int]streamer{ + 1: {UID: 1, Bitrate: 0}, + 2: {UID: 2, Bitrate: 1000}, + } + + if !setStreamerQualities(1, tiersForTest()) { + t.Error("第一次记录档位应该算改动了配置") + } + if config.UIDs[0].Bitrate != 4000 { + t.Errorf("uid 1 没设过码率,应被定成第二档 4000,实际 %d", config.UIDs[0].Bitrate) + } + if streamers.crt[1].Bitrate != 4000 || len(streamers.crt[1].Qualities) != 4 { + t.Errorf("运行时的主播数据没同步:%+v", streamers.crt[1]) + } + + // 手动设过的不能被默认档位覆盖 + setStreamerQualities(2, tiersForTest()) + if config.UIDs[1].Bitrate != 1000 { + t.Errorf("uid 2 手动设过 1000,不该被改成 %d", config.UIDs[1].Bitrate) + } + + // 同样的档位再来一次不算改动 + if setStreamerQualities(1, tiersForTest()) { + t.Error("档位没变化时不该算改动") + } + // 档位变了才算改动 + if !setStreamerQualities(1, []qualityTier{{"高清", 2000}, {"流畅", 500}}) { + t.Error("档位变化时应算改动") + } +} + +// 启动时按缓存里的档位把默认码率补齐 +func TestApplyCachedDefaultQualities(t *testing.T) { + savedConfig := config + savedStreamers := streamers.crt + defer func() { + config = savedConfig + streamers.crt = savedStreamers + }() + + config.UIDs = uidList{ + {UID: 1, Bitrate: 0, Qualities: tiersForTest()}, // 有缓存、没定过 → 补上 + {UID: 2, Bitrate: 2000, Qualities: tiersForTest()}, // 已经有值 → 不动 + {UID: 3, Bitrate: 0}, // 没缓存 → 不动 + } + streamers.crt = map[int]streamer{1: {UID: 1}, 2: {UID: 2, Bitrate: 2000}, 3: {UID: 3}} + + if !applyCachedDefaultQualities() { + t.Fatal("有需要补的主播时应算改动") + } + if config.UIDs[0].Bitrate != 4000 { + t.Errorf("uid 1 应补成 4000,实际 %d", config.UIDs[0].Bitrate) + } + if config.UIDs[1].Bitrate != 2000 { + t.Errorf("uid 2 不该被改动,实际 %d", config.UIDs[1].Bitrate) + } + if config.UIDs[2].Bitrate != 0 { + t.Errorf("uid 3 没有档位缓存,不该被改动,实际 %d", config.UIDs[2].Bitrate) + } +} + +// 档位要能存进 config.json 再读回来 +func TestQualitiesConfigRoundTrip(t *testing.T) { + savedConfig := config + savedLocation := configFileLocation + oldDir := configDir + if configDir == nil { + configDir = new(string) + } + defer func() { + config = savedConfig + configFileLocation = savedLocation + configDir = oldDir + }() + + dir := t.TempDir() + *configDir = dir + configFileLocation = filepath.Join(dir, configFile) + + body := `{"uids":[{"uid":23682490,"danmu":true,"record":true,"bitrate":4000, + "qualities":[{"name":"蓝光 8M","bitrate":8000},{"name":"蓝光 4M","bitrate":4000}]}]}` + if err := os.WriteFile(configFileLocation, []byte(body), 0644); err != nil { + t.Fatal(err) + } + config = configData{} + loadConfig() + + if len(config.UIDs) != 1 { + t.Fatalf("应解析出 1 个主播,实际 %d", len(config.UIDs)) + } + u := config.UIDs[0] + if u.Bitrate != 4000 || len(u.Qualities) != 2 || u.Qualities[0].Name != "蓝光 8M" { + t.Fatalf("档位没正确读出来:%+v", u) + } + if got := qualityNameFor(u.Qualities, u.Bitrate); got != "蓝光 4M" { + t.Errorf("当前档位名应为「蓝光 4M」,实际 %q", got) + } + + // 再序列化一次,字段名要对得上(qualities / name / bitrate) + config.UIDs[0].Qualities = sortQualitiesDesc(config.UIDs[0].Qualities) + data, err := json.Marshal(config.UIDs[0]) + if err != nil { + t.Fatal(err) + } + for _, key := range []string{`"qualities"`, `"name"`, `"bitrate"`} { + if !json.Valid(data) || !strings.Contains(string(data), key) { + t.Errorf("序列化结果里应包含 %s:%s", key, data) + } + } +} diff --git a/record.go b/record.go index 8e8bf6e..b8cfb9e 100644 --- a/record.go +++ b/record.go @@ -86,20 +86,65 @@ func checkFFmpegPath(path string) string { return "" } -// 获取 uid 的直播源(flv),bitrate 为 0 时取码率最高的源,否则取码率不超过 bitrate(Kbps)的最高档 -func getStreamURL(uid, bitrate int) (url string, quality string, err error) { +// 连到该主播的直播间并取直播流信息(获取直播源和探测码率档位都用它) +func newStreamInfo(uid int) (*acfundanmu.StreamInfo, error) { var ac *acfundanmu.AcFunLive + var err error err = runThrice(func() error { ac, err = acfundanmu.NewAcFunLive(acfundanmu.SetLiverUID(int64(uid))) return err }) + if err != nil { + return nil, err + } + return ac.GetStreamInfo(), nil +} + +// 把直播源列表转成码率档位 +func streamTiers(list []acfundanmu.StreamURL) []qualityTier { + tiers := make([]qualityTier, 0, len(list)) + for _, st := range list { + tiers = append(tiers, qualityTier{Name: st.QualityName, Bitrate: st.Bitrate}) + } + return tiers +} + +// 取该主播当前的码率档位(没开播或拿不到直播源时返回错误) +func fetchStreamQualities(uid int) ([]qualityTier, error) { + info, err := newStreamInfo(uid) + if err != nil { + return nil, err + } + if info == nil || len(info.StreamList) == 0 { + return nil, fmt.Errorf("uid 为%d的主播现在没有可用的直播源(没开播?)", uid) + } + return streamTiers(info.StreamList), nil +} + +// 获取 uid 的直播源(flv),bitrate 为 0 时取码率最高的源,否则取码率不超过 bitrate(Kbps)的最高档 +func getStreamURL(uid, bitrate int) (url string, quality string, err error) { + sInfo, err := newStreamInfo(uid) if err != nil { return "", "", err } - sInfo := ac.GetStreamInfo() - if len(sInfo.StreamList) == 0 { + if sInfo == nil || len(sInfo.StreamList) == 0 { return "", "", fmt.Errorf("uid 为%d的主播没有可用的直播源", uid) } + // 顺手把码率档位记下来:页面上要用它做下拉,没开播时靠缓存显示 + if tiers := streamTiers(sInfo.StreamList); len(tiers) > 0 { + if setStreamerQualities(uid, tiers) { + saveConfig() + } + // 还没设过码率的(0)刚被默认档位定下来,这里跟着用它,别又回到最高档 + if bitrate == 0 { + for _, u := range config.UIDs { + if u.UID == uid { + bitrate = u.Bitrate + break + } + } + } + } // StreamList 按码率升序排列 st := sInfo.StreamList[len(sInfo.StreamList)-1] if bitrate > 0 { diff --git a/webui.go b/webui.go index 97b1030..cf3763c 100644 --- a/webui.go +++ b/webui.go @@ -99,6 +99,8 @@ const webuiHTML = ` #mrgInfo{white-space:pre-wrap;margin-top:10px;font-size:12.5px;line-height:1.7;color:var(--text);background:var(--soft2);border-radius:var(--radius-s);padding:10px 12px} #mrgInfo:empty{display:none} input.br{width:92px;text-align:center;padding:6px 8px} + select.brsel{padding:6px 6px;max-width:150px} + button.mini{padding:4px 6px;font-size:11.5px} /* 开关 */ .sws{display:flex;flex-wrap:wrap;gap:10px 22px} .sw{display:inline-flex;align-items:center;gap:9px;cursor:pointer;font-size:13px;position:relative} @@ -239,7 +241,7 @@ const webuiHTML = `

主播列表

勾选 = 开启对应录制,改完立即生效
-
下载码率上限 = 挑选码率不超过该值的直播源档位(2000 约超清档),0 = 最高档;正在录制时修改会自动换档重新开始
+
码率这里选的是直播的实际档位(如蓝光4M / 高清),开播后自动读取,也能点「档位」按钮手动读一次;没手动选过的默认用第二档(自上往下第二高)。正在录制时换档会自动重录。没读过档位前可以直接填码率上限(Kbps,0 = 用默认档位)
@@ -273,7 +275,7 @@ const webuiHTML = `
-
+
@@ -686,12 +688,30 @@ async function refresh(){ if(s.danmuActive) badges += '弹幕中'; if(!badges) badges = s.live ? '直播中' : '离线'; var title = esc(s.title); + var qs = s.qualities || []; + var brCell; + if(qs.length){ + // 有档位列表就用下拉,值是档位的码率;当前值匹配不上档位时补一个「当前设置」 + var opts = ''; + var matched = false; + qs.forEach(function(q){ + var sel = (q.bitrate === s.bitrate); + if(sel) matched = true; + opts += ''; + }); + if(!matched) opts = '' + opts; + brCell = ''; + }else{ + brCell = '' + + ''; + } return '
' + 'uid ' + s.uid + '' + '' + (esc(s.name) || "uid " + s.uid) + '' + '' + '' - + '' + + brCell + '' + badges + '' + '' + title + '' + '' @@ -716,6 +736,8 @@ async function refresh(){ if(el){ el.value = keepBr[uid]; el.dataset.dirty = "1"; } }); } + // 开播了但还没有档位缓存的,自动去读一次,让码率下拉能用 + autoLoadQualities(st.streamers); var lr = await api("/api/log?since=" + logOffset); if(lr.status === 401){ showLogin("管理口令已失效,请重新输入"); return; } var off = lr.headers.get("X-Log-Offset"); @@ -1238,10 +1260,19 @@ document.addEventListener("change", async function(e){ var row = t.closest(".row"); var danmu = row.querySelector('input[data-k=danmu]').checked; var record = row.querySelector('input[data-k=record]').checked; - var brEl = row.querySelector('input.br'); + var brEl = row.querySelector('input.br') || row.querySelector('select.brsel'); var bitrate = parseInt(brEl.value, 10) || 0; // 勾选时会把同一行的码率一起提交,提交成功后就不算「未提交的输入」了 - if(await post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate})) delete brEl.dataset.dirty; + if(await post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate}) && brEl.dataset) delete brEl.dataset.dirty; + } + 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}); } if(t.matches('input.br')){ var uid2 = parseInt(t.dataset.uid, 10); @@ -1254,6 +1285,29 @@ document.addEventListener("change", async function(e){ if(await post("/api/streamer", {uid: uid2, danmu: danmu2, record: record2, bitrate: v})) delete t.dataset.dirty; } }); +// 去直播间接一次码率档位;拿到后列表会显示成档位下拉,没设过码率的会被服务端按第二档定下来 +var qualitiesTried = {}; +async function loadQualities(uid){ + if(qualitiesTried[uid]) return; + qualitiesTried[uid] = true; + var r = null; + try{ r = await api("/api/streamer/qualities?uid=" + uid); }catch(e){} + if(!r || !r.ok){ + showMsg(false, r ? await r.text() : "连接失败"); + qualitiesTried[uid] = false; + return; + } + var d = null; + try{ d = await r.json(); }catch(e){ return; } + showMsg(true, "已读取 " + (d.qualities || []).length + " 个码率档位"); + await refresh(); +} +// 开播但还没档位缓存时自动读一次,让下拉直接能用 +function autoLoadQualities(list){ + (list || []).forEach(function(s){ + if(s.live && !(s.qualities || []).length) loadQualities(s.uid); + }); +} // 回到地址栏或上次看的那个分区 showTab(location.hash.slice(1) || localStorage.getItem("acfunlive_tab") || "mon"); // 先试一次(用本地保存的口令),失败会自动弹出输入框并停止轮询 @@ -1267,16 +1321,18 @@ document.getElementById("tokenInput").addEventListener("keydown", function(e){ i // web UI 的主播状态 type webStreamer struct { - UID int `json:"uid"` - Name string `json:"name"` - Live bool `json:"live"` - Danmu bool `json:"danmu"` // 弹幕录制开关 - Record bool `json:"record"` // 视频录制开关 - Bitrate int `json:"bitrate"` // 录制的直播源最高码率(Kbps),0 表示码率最高的直播源 - DanmuActive bool `json:"danmuActive"` // 是否正在录制弹幕 - RecordActive bool `json:"recordActive"` // 是否正在录制视频 - Title string `json:"title"` - URL string `json:"url"` + UID int `json:"uid"` + Name string `json:"name"` + Live bool `json:"live"` + Danmu bool `json:"danmu"` // 弹幕录制开关 + Record bool `json:"record"` // 视频录制开关 + Bitrate int `json:"bitrate"` // 录制的直播源码率(Kbps),对应档位里的一个档 + Qualities []qualityTier `json:"qualities"` // 可选档位(按码率从高到低),没探测到时为空 + QualityName string `json:"qualityName"` // 当前码率对应的档位名 + DanmuActive bool `json:"danmuActive"` // 是否正在录制弹幕 + RecordActive bool `json:"recordActive"` // 是否正在录制视频 + Title string `json:"title"` + URL string `json:"url"` } // web UI 的状态数据 @@ -1383,6 +1439,8 @@ func statusHandler(w http.ResponseWriter, r *http.Request) { 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.Qualities = sortQualitiesDesc(s.Qualities) + ws.QualityName = qualityNameFor(s.Qualities, s.Bitrate) if infoList, ok := getLiveInfoByUID(s.UID); ok { for _, info := range infoList { if info.isDanmu { @@ -1589,6 +1647,59 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) { } // 新增主播或修改主播的弹幕/录播开关和码率 +// 档位探测的节流:同一个主播短时间内只去直播间接一次(页面上是点按钮才会来) +var qualitiesFetchMu struct { + sync.Mutex + at map[int]time.Time +} + +// GET /api/streamer/qualities?uid=N —— 去直播间接一次该主播的码率档位。 +// 拿到的档位会缓存进配置(没开播时页面也能显示下拉),还没设过码率的主播顺便按 +// 默认档位(第二高)定下来。 +func streamerQualitiesHandler(w http.ResponseWriter, r *http.Request) { + uid, err := strconv.Atoi(r.URL.Query().Get("uid")) + if err != nil || uid <= 0 { + http.Error(w, "uid 不合法", http.StatusBadRequest) + return + } + qualitiesFetchMu.Lock() + if qualitiesFetchMu.at == nil { + qualitiesFetchMu.at = make(map[int]time.Time) + } + last := qualitiesFetchMu.at[uid] + qualitiesFetchMu.Unlock() + if time.Since(last) < 10*time.Second { + http.Error(w, "刚读过一次档位,等几秒再试", http.StatusBadGateway) + return + } + + tiers, err := fetchStreamQualities(uid) + if err != nil { + http.Error(w, "读取码率档位失败:"+err.Error(), http.StatusBadGateway) + return + } + qualitiesFetchMu.Lock() + qualitiesFetchMu.at[uid] = time.Now() + qualitiesFetchMu.Unlock() + + if setStreamerQualities(uid, tiers) { + saveConfig() + } + bitrate := 0 + streamers.RLock() + if s, ok := streamers.crt[uid]; ok { + bitrate = s.Bitrate + } + streamers.RUnlock() + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{ + "uid": uid, + "qualities": sortQualitiesDesc(tiers), + "bitrate": bitrate, + "qualityName": qualityNameFor(tiers, bitrate), + }) +} + func webStreamerHandler(w http.ResponseWriter, r *http.Request) { var req struct { UID int `json:"uid"` @@ -1857,6 +1968,7 @@ func startWebUI(ctx context.Context) { mux.HandleFunc("GET /api/status", statusHandler) mux.HandleFunc("POST /api/config", webConfigHandler) mux.HandleFunc("POST /api/streamer", webStreamerHandler) + mux.HandleFunc("GET /api/streamer/qualities", streamerQualitiesHandler) mux.HandleFunc("POST /api/streamer/control", webStreamerControlHandler) mux.HandleFunc("POST /api/streamer/remove", webStreamerRemoveHandler) mux.HandleFunc("GET /api/log", logHandler)