主播列表的码率改为直播实际档位下拉,没选过时默认第二档

原来是一个手填的码率上限(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 干净。
This commit is contained in:
tamakyi
2026-09-25 19:42:47 +08:00
parent a891094f29
commit eda83dd1e3
7 changed files with 505 additions and 36 deletions
+8 -7
View File
@@ -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`直接保存直播源,不转码,分辨率和码率跟随直播源,档位就是上面说的那个(默认选第二档,而不是吃满最高档)。
录制弹幕时会同时生成三个文件:
+115 -10
View File
@@ -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()
+2
View File
@@ -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>` 去直播间接一次该主播的**码率档位**(返回`{"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":"…"}`,找不到时是空字符串。值里录播目录/成片目录下的文件只写文件名,别处的写绝对路径,和`浏览`弹窗的填法一致。
+4
View File
@@ -106,6 +106,10 @@ func initialize() {
loadConfig()
checkConfig()
// 缓存过档位、但还没定过码率的主播,启动时按默认档位(第二高)定下来
if applyCachedDefaultQualities() {
saveConfig()
}
// 上次在合并途中被关掉的话,录播目录和成片目录里会留下临时文件,启动时清一次
cleanLeftoverTempFiles()
ensureAuthToken()
+200
View File
@@ -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)
}
}
}
+49 -4
View File
@@ -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 {
+127 -15
View File
@@ -99,6 +99,8 @@ const webuiHTML = `<!DOCTYPE html>
#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 = `<!DOCTYPE html>
</div>
<div class="card">
<div class="card-h"><h2>主播列表</h2><span class="sub">勾选 = 开启对应录制,改完立即生效</span></div>
<div class="hint" style="margin:0 0 10px">下载码率上限 = 挑选码率不超过该值的直播源档位(2000 约超清档),0 = 最高档;正在录制时修改会自动换档重新开始</div>
<div class="hint" style="margin:0 0 10px">码率这里选的是直播的实际档位(如蓝光4M / 高清),开播后自动读取,也能点「档位」按钮手动读一次;没手动选过的默认用<b>第二档</b>(自上往下第二高)。正在录制时换档会自动重录。没读过档位前可以直接填码率上限(Kbps,0 = 用默认档位)</div>
<div id="st"></div>
</div>
<div class="card">
@@ -273,7 +275,7 @@ const webuiHTML = `<!DOCTYPE html>
<option value="flv">flv(兼容性一般)</option>
<option value="mkv">mkv(可装多音轨)</option>
</select></div>
<div class="f"><label>默认下载码率上限(Kbps,0 = 最高档)</label><input type="text" id="defaultBitrate" inputmode="numeric" placeholder="例如 2000"></div>
<div class="f"><label>默认下载码率上限(Kbps,0 = 新主播按实际档位取第二档)</label><input type="text" id="defaultBitrate" inputmode="numeric" placeholder="例如 2000"></div>
<div class="f"><label>ffmpeg 路径(留空自动查找)</label>
<div class="pathf">
<input type="text" id="ffmpegPath" placeholder="例如 D:\ffmpeg\bin\ffmpeg.exe">
@@ -686,12 +688,30 @@ async function refresh(){
if(s.danmuActive) badges += '<span class="badge rec">弹幕中</span>';
if(!badges) badges = s.live ? '<span class="badge on">直播中</span>' : '<span class="off">离线</span>';
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 += '<option value="' + q.bitrate + '"' + (sel ? " selected" : "") + '>' + esc(q.name) + '(' + q.bitrate + 'K)</option>';
});
if(!matched) opts = '<option value="' + (s.bitrate||0) + '" selected>当前设置(' + (s.bitrate||0) + 'K)</option>' + opts;
brCell = '<select class="brsel" data-uid="' + s.uid + '" title="下载码率档位,正在录制时换档会自动重录">' + opts + '</select>';
}else{
brCell = '<input type="text" class="br" inputmode="numeric" data-uid="' + s.uid + '" value="' + (s.bitrate||0)
+ '" placeholder="0=默认" title="下载码率上限(Kbps);开播后点右边按钮读档位">'
+ '<button class="ghost mini" onclick="loadQualities(' + s.uid + ')" title="去直播间接一次码率档位">档位</button>';
}
return '<div class="row">'
+ '<span class="uid">uid ' + s.uid + '</span>'
+ '<span class="name">' + (esc(s.name) || "uid " + s.uid) + '</span>'
+ '<label class="pill"><input type="checkbox" data-uid="' + s.uid + '" data-k="danmu"' + (s.danmu?" checked":"") + '>弹幕</label>'
+ '<label class="pill"><input type="checkbox" data-uid="' + s.uid + '" data-k="record"' + (s.record?" checked":"") + '>录播</label>'
+ '<input type="text" class="br" inputmode="numeric" data-uid="' + s.uid + '" value="' + (s.bitrate||0) + '" placeholder="0=最高" title="下载码率上限(Kbps),0 = 最高档">'
+ brCell
+ '<span class="state">' + badges + '</span>'
+ '<span class="title"' + (title?' title="' + title + '"':'') + '>' + title + '</span>'
+ '<span class="ops">'
@@ -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)