主播列表的码率改为直播实际档位下拉,没选过时默认第二档
原来是一个手填的码率上限(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:
@@ -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`直接保存直播源,不转码,分辨率和码率跟随直播源,档位就是上面说的那个(默认选第二档,而不是吃满最高档)。
|
||||
|
||||
录制弹幕时会同时生成三个文件:
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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":"…"}`,找不到时是空字符串。值里录播目录/成片目录下的文件只写文件名,别处的写绝对路径,和`浏览`弹窗的填法一致。
|
||||
|
||||
|
||||
@@ -106,6 +106,10 @@ func initialize() {
|
||||
|
||||
loadConfig()
|
||||
checkConfig()
|
||||
// 缓存过档位、但还没定过码率的主播,启动时按默认档位(第二高)定下来
|
||||
if applyCachedDefaultQualities() {
|
||||
saveConfig()
|
||||
}
|
||||
// 上次在合并途中被关掉的话,录播目录和成片目录里会留下临时文件,启动时清一次
|
||||
cleanLeftoverTempFiles()
|
||||
ensureAuthToken()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user