档位改成「自动·第二高 + 实际档位」的下拉;手动合并也推送;头像缓存挪到程序目录

三件事:

1) 主播列表的录制档位
- 一律是下拉,不再有手填输入框。第一项「自动 · 第二高」(值 0)= 按这次读到的
  档位排序自上往下取第二高(只有一档就用它,读不到档位表才退回最高档);
  后面依次列出实际档位。选最高/最低/中间某一档直接点它,手动选过之后
  「自动」仍留在列表里,随时能改回去。
- 语义变化:0 不再被写死成某个具体码率。原来记录档位时会把「没选过」的主播
  按第二档定下来(写进 config.json),现在只在取流时解析,所以主播换了档位表
  也能跟着走;app 启动时的补齐逻辑(applyCachedDefaultQualities)随之去掉。
- 解析规则统一到 resolveQualityBitrate:0 = 第二高,>0 = 不超过它的最高档,
  都比它高时退回最低档;「自动」项会把解析到的档位名带出来
  (自动 · 第二高(当前 = 蓝光 4M))。/api/status 和档位接口都多了 qualityBitrate。
- 点「档位」按钮时先给一句「正在读取码率档位…」:主播没开播时服务端要重试三次
  才放弃,约 5 秒,之前这段时间界面没有任何反应。

2) 手动合并完成也推送(这就是「MeoW 只在测试时成功」的原因)
- 原来只有自动合并调 sendNotify,手动合并从头到尾没推过,所以事件开着、渠道也通
  却一直收不到。现在手动合并完成后同样按 mergeDone 事件推送两个渠道。
- 手动合并没有 uid,会拿成片名(再退到视频名)去对已有主播名,只有一个能对上时
  才在文案里带上主播名,对不上就用「手动合并完成」。

3) 头像缓存挪到程序所在目录
- 原来存在「录播目录/avatar_cache」,换一次保存目录(甚至换盘)就要把同一批观众
  的头像重新下一遍(一个头像可能 0.5MB)。现在统一放程序目录下的 avatar_cache。
- 旧位置作为只读回退:命中就搬过来(同盘改名,跨盘退回复制),不重复下载;
  没被用到的旧文件留在原地,用到哪个搬哪个。

验证:
- 新增 avatar_test.go 两个用例(缓存路径、旧位置搬家)和 notify_test.go 的
  TestManualMergeSendsMergeDoneNotify(真跑一次合并,断言 webhook 与 MeoW 各收到
  一条 mergeDone,且从文件名认出主播);qualities_test.go 改成新语义,新增
  resolveQualityBitrate(7 种档位/码率组合)和 guessUIDByFileName。
- 端到端:把旧位置的头像缓存放在录播目录、头像地址指向连不通的端口,合并仍然成功
  且画面里有那张头像(纯红像素 400 个),证明是搬来的而不是重新下载的;文件也从
  录播目录搬到了程序目录。
- 浏览器:verify_streamer_br.py 重写成档位下拉的 20 项断言,verify_qualities.py
  更新为新语义,两个都全过;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:03:24 +08:00
parent eda83dd1e3
commit 348e3bd675
11 changed files with 400 additions and 155 deletions
+7 -5
View File
@@ -70,7 +70,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
"uid": 23682490, // 主播的 uid
"danmu": true, // 是否录制弹幕
"record": true, // 是否录制直播视频
"bitrate": 0, // 录制的直播源码率(Kbps),对应下面 qualities 里的一个档位;0 = 还没选过(按第二档)
"bitrate": 0, // 选定的档位码率(Kbps),对应下面 qualities 里的一个档位;0 = 自动(按实际档位取第二高)
"qualities": [] // 开播时读到的码率档位列表,缓存下来让没开播时也能选
}
],
@@ -83,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, // 新添加主播默认选的档位,0 = 自动(按实际档位取第二高)
"webExternal": false, // 是否允许外网访问 web UI,false 时只监听本机 127.0.0.1
"authToken": "…", // web UI 管理接口的访问口令,首次启动自动生成
@@ -95,7 +95,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
"notifyLiveOn": false, // 开播时推送
"notifyLiveOff": false, // 下播时推送
"notifyRecordStart": false, // 开始录制直播视频时推送
"notifyMergeDone": false, // 自动合并完成时推送
"notifyMergeDone": false, // 合并完成时推送(自动合并和手动合并都会推)
"meowEnabled": false, // MeoW 推送的渠道开关
"meowNickname": "", // MeoW 昵称,推送地址里的路径参数
@@ -112,7 +112,9 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
`uids`也兼容旧的纯数字数组格式,如`"uids": [23682490, 12345]`,此时弹幕和录播都默认开启、码率按实际档位取第二档。
`bitrate`是**直播的实际档位**(如`蓝光 8M`、`高清`),在 web UI 的主播列表里下拉选择,正在录制时换档会自动重录。没选过(0)时按实际档位取**第二档**(自上往下第二高),只有一个档位就用那一档。档位要开播后读直播源列表才知道,读到以后写进`config.json`的`qualities`里缓存,所以没开播也能看和改;从来没读过档位的话,列表里还是让你直接填码率上限(Kbps)。录制始终是原画质直接保存(`-c copy`),不会重新编码,所以只能在这几个档位里挑。
`bitrate`是**直播的实际档位**(如`蓝光 8M`、`高清`),在 web UI 的主播列表里一律用下拉选择,正在录制时换档会自动重录。下拉的第一项是`自动 · 第二高`(值 0),意思是**按这次读到的档位排序自上往下取第二高** —— 因为不一定每场都有`蓝光 8M`这类最高档,按位置排序比写死某个码率稳;只要一档就用那一档,实在读不到档位表才退回最高档。后面依次列出这次读到的实际档位,想固定用最高、最低或中间某一档直接选它;手动选过之后下拉里仍留着`自动`项,随时能改回去。
档位要开播后读直播源列表才知道,读到以后写进`config.json`的`qualities`里缓存,所以没开播也能看和改;从来没读过档位的行,下拉里只有`自动`一项,旁边有`档位`按钮可以去直播间接一次(主播没开播时会失败并提示)。读档位的时机是:录制开始取流时、页面上点`档位`按钮、以及发现主播在直播但还没有档位缓存时自动读一次(同一个主播 10 秒内只读一次)。录制始终是原画质直接保存(`-c copy`),不会重新编码,所以只能在这几个档位里挑。
录播原片和弹幕文件(`.ass`、`.nick.ass`、`.jsonl`)保存在保存目录下的`recordSubdir`子文件夹里(默认`record`),两者同名配对才能自动合并;合并后的成片保存在`mergeSubdir`子文件夹里(默认`merged`),留空则用默认值,两个名字不能相同。如果设置了`mergeOutputDir`,成片优先移动到那里。
@@ -201,7 +203,7 @@ acfunlive -danmuburn xxx.jsonl -video xxx.flv -ffmpeg D:\tools\ffmpeg.exe
`-danmuburn`的弹幕文件支持`.jsonl`和`.ass`两种。给的是`.ass`时,如果同目录下已经有同名的非空`.jsonl`就直接用,否则按 ASS 里的`PlayResX`/`PlayResY`、字号和`\move`弹道把弹幕还原成 jsonl,并把还原结果写回弹幕文件旁边,下次再合就不用重新解析。`-out`不指定时输出到弹幕文件同目录,重名会自动加`(2)`之类的后缀,不会覆盖已有文件。ffmpeg 的路径除了`-ffmpeg`参数,也会读设置文件里的`ffmpegPath`;填相对路径时按程序所在目录解析,也可以填它所在的文件夹。
头像会按 uid 缓存到录制文件夹下的`avatar_cache`里,圆形裁切后的直径与弹幕文字的墨迹高度一致。头像图片支持 jpg、png、gif 和静态 webp:gif(包括多帧动图)取第一帧来裁切,动画 webp 解不开、遇到就只烧文字。上游原版的`.ass`里没有头像链接(`nick.ass`只有昵称和 uid),遇到这种情况会按 uid 向 AcFun 的`userInfo`接口反查头像,查不到的只烧文字。反查走网络且有超时,所以离线环境下合并会退化成纯文字弹幕并给出警告。
头像会按 uid 缓存到**程序所在目录**下的`avatar_cache`里(不放在录播目录,所以换保存目录、甚至换盘都不用重新下载);程序启动合并时会顺手把旧位置(以前存在录播目录下的`avatar_cache`)里已有的头像搬过来。圆形裁切后的直径与弹幕文字的墨迹高度一致。头像图片支持 jpg、png、gif 和静态 webp:gif(包括多帧动图)取第一帧来裁切,动画 webp 解不开、遇到就只烧文字。上游原版的`.ass`里没有头像链接(`nick.ass`只有昵称和 uid),遇到这种情况会按 uid 向 AcFun 的`userInfo`接口反查头像,查不到的只烧文字。反查走网络且有超时,所以离线环境下合并会退化成纯文字弹幕并给出警告。
有 Intel 核显时优先用 QSV 硬件编码(`h264_qsv`),不可用或运行失败时自动回退到`libx264`。
+58
View File
@@ -2,12 +2,16 @@ package main
import (
"bytes"
"crypto/sha1"
"encoding/base64"
"fmt"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"os"
"path/filepath"
"testing"
)
@@ -109,3 +113,57 @@ func TestDecodeAvatarGifGoesThroughCircleAvatar(t *testing.T) {
t.Fatal("编码出来是空的")
}
}
// 头像缓存在程序所在目录,不再跟着保存目录跑
func TestAvatarCachePathUnderExeDir(t *testing.T) {
old := exeDir
defer func() { exeDir = old }()
exeDir = filepath.Join("C:", "app", "acfunlive")
want := filepath.Join("C:", "app", "acfunlive", avatarCacheDir)
if got := avatarCachePath(); got != want {
t.Errorf("应缓存到 %q,实际 %q", want, got)
}
// 取不到程序目录时退回相对路径,别拼出 /avatar_cache 这种绝对路径
exeDir = ""
if got := avatarCachePath(); got != avatarCacheDir {
t.Errorf("没有程序目录时应退回 %q,实际 %q", avatarCacheDir, got)
}
}
// 旧版本把头像存在录播目录里,换保存目录后要能直接搬过来用,不重新下载
func TestEnsureAvatarMigratesLegacyCache(t *testing.T) {
root := t.TempDir()
cacheDir := filepath.Join(root, "program", avatarCacheDir)
legacyDir := filepath.Join(root, "record", avatarCacheDir)
if err := os.MkdirAll(legacyDir, 0755); err != nil {
t.Fatal(err)
}
// 这个地址连不通:命中旧缓存就不该去下载(下载会重试 3 次,很慢)
url := "http://127.0.0.1:9/never.png"
sum := sha1.Sum([]byte(url))
name := fmt.Sprintf("%d_%x.png", int64(7), sum[:6])
old := filepath.Join(legacyDir, name)
if err := os.WriteFile(old, []byte("cached-png"), 0644); err != nil {
t.Fatal(err)
}
got := ensureAvatar(7, url, cacheDir, legacyDir)
want := filepath.Join(cacheDir, name)
if got != want {
t.Fatalf("应搬到 %q,实际 %q", want, got)
}
data, err := os.ReadFile(want)
if err != nil || string(data) != "cached-png" {
t.Fatalf("搬过来的内容不对:%v %q", err, data)
}
if _, err := os.Stat(old); !os.IsNotExist(err) {
t.Errorf("旧位置的文件应被搬走,实际还在:%v", err)
}
// 已经在程序目录里的直接命中,不用管旧目录
if got := ensureAvatar(7, url, cacheDir, legacyDir); got != want {
t.Errorf("命中缓存应返回 %q,实际 %q", want, got)
}
}
+40 -23
View File
@@ -30,7 +30,7 @@ type streamer struct {
Name string `json:"name"` // 主播名字
Danmu bool `json:"-"` // 是否录制直播弹幕
Record bool `json:"-"` // 是否录制直播视频
Bitrate int `json:"-"` // 录制的直播源最高码率(Kbps),0 表示还没定(按实际档位取第二高)
Bitrate int `json:"-"` // 选定的档位码率(Kbps),0 = 自动(按实际档位取第二高)
Qualities []qualityTier `json:"-"` // 上次探测到的码率档位,没开播时页面靠它显示下拉
}
@@ -45,7 +45,7 @@ type uidSetting struct {
UID int `json:"uid"`
Danmu bool `json:"danmu"`
Record bool `json:"record"`
Bitrate int `json:"bitrate"` // 录制的直播源最高码率(Kbps),0 表示还没定(按实际档位取第二高)
Bitrate int `json:"bitrate"` // 选定的档位码率(Kbps),0 = 自动(按实际档位取第二高)
Qualities []qualityTier `json:"qualities,omitempty"` // 上次探测到的档位,缓存下来让没开播时也能在页面上选
}
@@ -93,7 +93,7 @@ type configData struct {
MergeBitrate int `json:"mergeBitrate"` // 合并成片的目标码率(Kbps),0 表示使用默认质量
DanmuFontSize int `json:"danmuFontSize"` // 合并时弹幕文字的字号(头像随字号等比缩放),0 表示跟随弹幕文件
DefaultBitrate int `json:"defaultBitrate"` // 新添加主播的默认下载码率上限(Kbps),0 表示录制码率最高的直播源
DefaultBitrate int `json:"defaultBitrate"` // 新添加主播默认选的档位码率(Kbps),0 = 自动(按实际档位取第二高)
WebExternal bool `json:"webExternal"` // 是否允许外网访问 web UI,false 时只监听本机
@@ -508,8 +508,8 @@ func sameQualities(a, b []qualityTier) bool {
}
// 记下某个主播当前的码率档位(离线时页面靠它显示下拉)。
// 顺手把「还没设过码率」的主播按默认档位(第二高)定下来 —— 这样默认不会一直吃满最高档。
// 返回是否改动了配置,调用方决定要不要 saveConfig。
// 不动用户选的码率:0 表示「自动、按实际档位取第二高」,只在真正取流时解析,
// 这样主播换了档位表也能跟着走,而不是把某个码率钉死。返回是否改动了配置。
func setStreamerQualities(uid int, tiers []qualityTier) bool {
if uid <= 0 || len(tiers) == 0 {
return false
@@ -524,18 +524,8 @@ func setStreamerQualities(uid int, tiers []qualityTier) bool {
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
@@ -544,17 +534,44 @@ func setStreamerQualities(uid int, tiers []qualityTier) bool {
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
// 按选定的码率决定实际用哪个档位:
// 0(自动)取自上往下第二档(只有一个档位就用它);填了具体码率就取不超过它的最高档,
// 都比它高时退回最低档。档位列表为空返回 0,交给调用方按老规矩取最高档。
func resolveQualityBitrate(tiers []qualityTier, bitrate int) int {
desc := sortQualitiesDesc(tiers)
if len(desc) == 0 {
return bitrate
}
if bitrate <= 0 {
return defaultQualityBitrate(desc)
}
for _, t := range desc {
if t.Bitrate <= bitrate {
return t.Bitrate
}
}
return desc[len(desc)-1].Bitrate
}
// 从文件(成片或录播)名里猜是哪个主播:名字出现在文件名里、且只有一个能对上时才认,
// 认不出来返回 0。手动合并的推送靠它带上主播名,猜不准就退回通用文案。
func guessUIDByFileName(name string) int {
if name == "" {
return 0
}
return changed
base := filepath.Base(name)
matched := 0
for _, s := range append(uidList(nil), config.UIDs...) {
n := nameForLog(s.UID)
if len([]rune(n)) < 2 || !strings.Contains(base, n) {
continue
}
if matched != 0 {
return 0 // 多个主播都对得上,不猜了
}
matched = s.UID
}
return matched
}
// 保存 config.json,写入串行化并原子替换,避免并发写坏文件或读到写了一半的内容
+46 -8
View File
@@ -34,6 +34,15 @@ import (
// 头像缓存目录名
const avatarCacheDir = "avatar_cache"
// 头像缓存放程序所在目录:换保存目录(甚至换盘)都还能复用,
// 不用为同一批观众重新下一次。旧版本放在弹幕文件同目录,读取时会搬过来。
func avatarCachePath() string {
if exeDir == "" {
return avatarCacheDir
}
return filepath.Join(exeDir, avatarCacheDir)
}
// 命令行 -vbitrate 指定的合成目标码率(Kbps),配合 config.MergeBitrate 使用
var burnBitrate int
@@ -222,8 +231,10 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
// 下载并圆形裁切头像
avatarPaths := make(map[int64]string)
dir := filepath.Dir(jsonlFile)
cacheDir := filepath.Join(dir, avatarCacheDir)
// 缓存放在程序所在目录,换保存目录(甚至换盘)都不用重新下载;
// 旧版本放在弹幕文件同目录,作为回退来源读一次并搬过去
cacheDir := avatarCachePath()
legacyCache := filepath.Join(filepath.Dir(jsonlFile), avatarCacheDir)
// 先去重,顺便记下每个用户自带的头像链接
urlByUID := make(map[int64]string)
uids := make([]int64, 0, 64)
@@ -254,7 +265,7 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
if url == "" {
continue
}
if path := ensureAvatar(uid, url, cacheDir); path != "" {
if path := ensureAvatar(uid, url, cacheDir, legacyCache); path != "" {
avatarPaths[uid] = path
}
}
@@ -284,7 +295,7 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
meta: meta,
avatars: avatarPaths,
bitrate: bitrate,
dir: dir,
dir: filepath.Dir(jsonlFile),
}
chunks := splitDanmuChunks(records, offset, videoDuration)
@@ -842,11 +853,13 @@ func formatSeconds(sec float64) string {
return strconv.FormatFloat(sec, 'f', 3, 64)
}
// 下载头像并圆形裁切,失败自动重试,返回缓存的 PNG 路径,失败返回空字符串
func ensureAvatar(uid int64, url, cacheDir string) string {
// 下载头像并圆形裁切,失败自动重试,返回缓存的 PNG 路径,失败返回空字符串。
// legacyDirs 是旧版本放缓存的位置,命中就搬过来用,省得换保存目录后重新下一次。
func ensureAvatar(uid int64, url, cacheDir string, legacyDirs ...string) string {
// 文件名里带上头像链接的摘要,主播换头像后不会一直用着旧缓存
sum := sha1.Sum([]byte(url))
path := filepath.Join(cacheDir, fmt.Sprintf("%d_%x.png", uid, sum[:6]))
name := fmt.Sprintf("%d_%x.png", uid, sum[:6])
path := filepath.Join(cacheDir, name)
if _, err := os.Stat(path); err == nil {
return path
}
@@ -854,8 +867,33 @@ func ensureAvatar(uid int64, url, cacheDir string) string {
lPrintWarnf("创建头像缓存文件夹 %s 失败,弹幕只烧录文字不带头像:%v", cacheDir, err)
return ""
}
// 旧位置(以前存在录播目录里)有同一个头像就直接搬过来,不重复下载
for _, dir := range legacyDirs {
if dir == "" || dir == cacheDir {
continue
}
old := filepath.Join(dir, name)
if _, err := os.Stat(old); err != nil {
continue
}
if err := os.Rename(old, path); err == nil {
return path
}
// 跨盘改名会失败,退回复制
if copyFile(old, path) == nil {
_ = os.Remove(old)
return path
}
}
// 清掉这个用户已经失效的旧头像
if olds, err := filepath.Glob(filepath.Join(cacheDir, strconv.FormatInt(uid, 10)+"_*.png")); err == nil {
for _, dir := range append([]string{cacheDir}, legacyDirs...) {
if dir == "" {
continue
}
olds, err := filepath.Glob(filepath.Join(dir, strconv.FormatInt(uid, 10)+"_*.png"))
if err != nil {
continue
}
for _, p := range olds {
if p != path {
_ = os.Remove(p)
+2 -2
View File
@@ -80,7 +80,7 @@ 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/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/merge/danmu?video=<视频文件名或路径>` 按视频名找同目录下的同名弹幕文件,给页面「选好视频自动匹配弹幕」用。依次试`.jsonl`、`.ass`、`.nick.ass`,同目录没有就拿文件名去录播目录再找一次;返回`{"danmu":"…"}`,找不到时是空字符串。值里录播目录/成片目录下的文件只写文件名,别处的写绝对路径,和`浏览`弹窗的填法一致。
@@ -97,7 +97,7 @@ web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全
返回分段列表(名字、体积、时长、是否正在写入)、总时长、弹幕条数、弹幕时间范围、要用的弹幕文件和来源(`jsonl 弹幕数据` / `同名的 jsonl 弹幕数据` / `由 xxx.ass 还原`)、推算出的偏移和原因、最终输出文件名、以及提示(`warnings`)。参数不合法一律 400,并在响应体里给中文原因。
`POST /api/merge` 真正开始合并,请求体和上面一样,另外可以带`outName`指定输出文件名(不合法会被清理成安全的文件名,重名自动加后缀)。成功后立刻返回`{"outName":"…"}`,合并在后台跑。已经有一个手动合并任务在跑时返回 409。
`POST /api/merge` 真正开始合并,请求体和上面一样,另外可以带`outName`指定输出文件名(不合法会被清理成安全的文件名,重名自动加后缀)。成功后立刻返回`{"outName":"…"}`,合并在后台跑。已经有一个手动合并任务在跑时返回 409。**合并完成也会触发一次推送**(和自动合并同一个`mergeDone`事件):手动合并没有 uid,会拿成片或视频的文件名去对已有的主播名,只有一个能对上时才在文案里带上主播名,对不上就用`手动合并完成`。
合并状态通过`GET /api/status`查看:`mergeBusy`(是否在跑)、`mergeOutName`(当前成片名)、`mergeErr`(失败原因)、`mergeDoneAt`(上次完成时间)。合并期间成片会出现在`GET /api/files`里且`inUse`为 true,此时`POST /api/file/remove`会返回 409 拒绝删除。
-4
View File
@@ -106,10 +106,6 @@ func initialize() {
loadConfig()
checkConfig()
// 缓存过档位、但还没定过码率的主播,启动时按默认档位(第二高)定下来
if applyCachedDefaultQualities() {
saveConfig()
}
// 上次在合并途中被关掉的话,录播目录和成片目录里会留下临时文件,启动时清一次
cleanLeftoverTempFiles()
ensureAuthToken()
+10
View File
@@ -538,6 +538,16 @@ func runManualMerge(plan mergePlan) error {
}
lPrintln("手动合并完成:" + plan.out)
// 通知:手动合并没有 uid,能从文件名对上哪个主播就带上名字,对不上就用通用文案
uid := guessUIDByFileName(plan.out)
if uid == 0 {
uid = guessUIDByFileName(video)
}
text := "手动合并完成\n" + plan.out
if uid > 0 {
text = longID(uid) + " 的录播和弹幕(手动合并)完成\n" + plan.out
}
sendNotify(notifyEventMergeDone, uid, nameForLog(uid), "", "", plan.out, text)
return nil
}
+102
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
@@ -592,3 +593,104 @@ func TestNotifyTestHandler(t *testing.T) {
t.Errorf("非法昵称应该 400,实际 %d", code)
}
}
// 手动合并完成也要推送 —— 以前只有自动合并会推,所以在页面上手动合并时 MeoW 一直没动静
func TestManualMergeSendsMergeDoneNotify(t *testing.T) {
setupMergeTest(t)
ffmpeg := getFFmpeg()
if ffmpeg == "" {
t.Skip("没有 ffmpeg")
}
savedConfig := config
defer func() { config = savedConfig }()
var mu sync.Mutex
var hooks []notifyPayload
webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var p notifyPayload
_ = json.NewDecoder(r.Body).Decode(&p)
mu.Lock()
hooks = append(hooks, p)
mu.Unlock()
_, _ = w.Write([]byte("ok"))
}))
defer webhook.Close()
var meowQuery []string
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
meowQuery = append(meowQuery, r.URL.RawQuery)
mu.Unlock()
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
})
// 只开合并完成这一个事件,两个渠道都开
config.NotifyURL = webhook.URL
config.NotifyLiveOn = false
config.NotifyMergeDone = true
config.MeowEnabled = true
config.MeowNickname = "小小怪"
config.MeowMsgType = "text"
config.UIDs = uidList{{UID: 999, Danmu: true, Record: true}}
// 文件名里带主播名,手动合并靠它认人
streamers.Lock()
streamers.crt = map[int]streamer{999: {UID: 999, Name: "测试主播"}}
streamers.Unlock()
name := "2026-09-25 01-00-00 测试主播 标题"
video := filepath.Join(recordSaveDir(), name+".mp4")
if out, err := exec.Command(ffmpeg, "-v", "error", "-y",
"-f", "lavfi", "-i", "color=c=black:size=320x180:rate=15", "-t", "3",
"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", video).CombinedOutput(); err != nil {
t.Fatalf("生成测试视频失败:%v\n%s", err, out)
}
lines := []string{`{"playResX":320,"playResY":180,"fontSize":16,"startTime":0}`,
`{"t":0.5,"uid":101,"name":"甲","content":"手动合并推送测试"}`,
`{"t":1.5,"uid":102,"name":"乙","content":"第二条"}`}
writeTestFile(t, filepath.Join(recordSaveDir(), name+".jsonl"), strings.Join(lines, "\n")+"\n")
plan, err := buildMergePlan(mergeRequest{Videos: []string{name + ".mp4"}, Danmu: name + ".jsonl"})
if err != nil {
t.Fatalf("检查失败:%v", err)
}
if err := runManualMerge(plan); err != nil {
t.Fatalf("手动合并失败:%v", err)
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
mu.Lock()
nh, nm := len(hooks), len(meowQuery)
mu.Unlock()
if nh > 0 && nm > 0 {
break
}
time.Sleep(30 * time.Millisecond)
}
mu.Lock()
defer mu.Unlock()
if len(hooks) != 1 {
t.Fatalf("QQ 机器人应收到 1 条推送,实际 %d 条", len(hooks))
}
p := hooks[0]
if p.Event != notifyEventMergeDone {
t.Errorf("事件名应为 %s,实际 %s", notifyEventMergeDone, p.Event)
}
if p.UID != 999 || p.Name != "测试主播" {
t.Errorf("应从文件名认出主播 999/测试主播,实际 %d/%q", p.UID, p.Name)
}
if p.File != plan.out {
t.Errorf("推送里的文件应为成片 %q,实际 %q", plan.out, p.File)
}
if !strings.Contains(p.Text, "手动合并") || !strings.Contains(p.Text, plan.out) {
t.Errorf("推送文案不对:%q", p.Text)
}
if len(meowQuery) != 1 {
t.Fatalf("MeoW 应收到 1 条推送,实际 %d 条", len(meowQuery))
}
if !strings.Contains(meowQuery[0], "msgType=text") {
t.Errorf("MeoW 的 msgType 应走 query 参数,实际 %q", meowQuery[0])
}
}
+70 -24
View File
@@ -75,7 +75,8 @@ func TestSameQualities(t *testing.T) {
}
}
// 记录档位时顺便把「还没设过码率」的主播按默认档位定下来
// 记录档位时只缓存档位表,不动用户选的码率:
// 0 表示「自动、按实际档位取第二高」,主播换了档位表要能跟着走
func TestSetStreamerQualities(t *testing.T) {
savedConfig := config
savedStreamers := streamers.crt
@@ -85,8 +86,8 @@ func TestSetStreamerQualities(t *testing.T) {
}()
config.UIDs = uidList{
{UID: 1, Danmu: true, Record: true, Bitrate: 0}, // 没设过 → 应被定成第二档
{UID: 2, Danmu: true, Record: true, Bitrate: 1000}, // 手动设过 → 不动
{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},
@@ -96,14 +97,17 @@ func TestSetStreamerQualities(t *testing.T) {
if !setStreamerQualities(1, tiersForTest()) {
t.Error("第一次记录档位应该算改动了配置")
}
if config.UIDs[0].Bitrate != 4000 {
t.Errorf("uid 1 没设过码率,应被定成第二档 4000,实际 %d", config.UIDs[0].Bitrate)
if config.UIDs[0].Bitrate != 0 {
t.Errorf("自动的主播不该被写死成具体码率,实际 %d", config.UIDs[0].Bitrate)
}
if streamers.crt[1].Bitrate != 4000 || len(streamers.crt[1].Qualities) != 4 {
t.Errorf("运行时的主播数据没同步:%+v", streamers.crt[1])
if len(config.UIDs[0].Qualities) != 4 || len(streamers.crt[1].Qualities) != 4 {
t.Errorf("档位没缓存到配置或运行时数据:%+v / %+v", config.UIDs[0], streamers.crt[1])
}
if streamers.crt[1].Bitrate != 0 {
t.Errorf("运行时数据的码率不该被改动,实际 %d", streamers.crt[1].Bitrate)
}
// 手动设过的不能被默认档位覆盖
// 手动设过的同样不动
setStreamerQualities(2, tiersForTest())
if config.UIDs[1].Bitrate != 1000 {
t.Errorf("uid 2 手动设过 1000,不该被改成 %d", config.UIDs[1].Bitrate)
@@ -119,8 +123,43 @@ func TestSetStreamerQualities(t *testing.T) {
}
}
// 启动时按缓存里的档位把默认码率补齐
func TestApplyCachedDefaultQualities(t *testing.T) {
// 实际用哪个档位:0 = 自动(第二高),填了具体码率 = 不超过它的最高档,
// 都比它高时退回最低档
func TestResolveQualityBitrate(t *testing.T) {
cases := []struct {
name string
bitrate int
want int
}{
{"自动取第二高", 0, 4000},
{"指定最高档", 8000, 8000},
{"指定第二高", 4000, 4000},
{"指定最低档", 1000, 1000},
{"填了中间值取不超过它的最高档", 3000, 2000},
{"比所有档位都高就取最高档", 99999, 8000},
{"比所有档位都低就退回最低档", 200, 1000},
}
for _, c := range cases {
if got := resolveQualityBitrate(tiersForTest(), c.bitrate); got != c.want {
t.Errorf("%s:应为 %d,实际 %d", c.name, c.want, got)
}
}
// 没有档位表时保持原样(取流逻辑会退回最高档)
if got := resolveQualityBitrate(nil, 0); got != 0 {
t.Errorf("没有档位表时应返回 0,实际 %d", got)
}
if got := resolveQualityBitrate(nil, 2000); got != 2000 {
t.Errorf("没有档位表时应原样返回,实际 %d", got)
}
// 只有一档时自动档就是它
one := []qualityTier{{"高清", 2000}}
if got := resolveQualityBitrate(one, 0); got != 2000 {
t.Errorf("只有一档时自动应为 2000,实际 %d", got)
}
}
// 手动合并的通知靠文件名猜主播:只有一个名字对得上才认
func TestGuessUIDByFileName(t *testing.T) {
savedConfig := config
savedStreamers := streamers.crt
defer func() {
@@ -128,24 +167,31 @@ func TestApplyCachedDefaultQualities(t *testing.T) {
streamers.crt = savedStreamers
}()
config.UIDs = uidList{
{UID: 1, Bitrate: 0, Qualities: tiersForTest()}, // 有缓存、没定过 → 补上
{UID: 2, Bitrate: 2000, Qualities: tiersForTest()}, // 已经有值 → 不动
{UID: 3, Bitrate: 0}, // 没缓存 → 不动
config.UIDs = uidList{{UID: 1}, {UID: 2}, {UID: 3}}
streamers.crt = map[int]streamer{
1: {UID: 1, Name: "AC娘本体"},
2: {UID: 2, Name: "随便唠会"},
3: {UID: 3, Name: "阿"},
}
streamers.crt = map[int]streamer{1: {UID: 1}, 2: {UID: 2, Bitrate: 2000}, 3: {UID: 3}}
if !applyCachedDefaultQualities() {
t.Fatal("有需要补的主播时应算改动")
cases := []struct {
file string
want int
}{
{"2024-10-14 00-33-32 AC娘本体 嘿嘿 醒了_弹幕合并.mp4", 1},
{"AC娘本体.mp4", 1},
{"2024-10-14 00-33-32 随便唠会 摸鱼.mp4", 2},
// 两个名字都在文件名里,猜不准就不猜
{"AC娘本体 和 随便唠会 联动.mp4", 0},
{"完全不认识的主播.mp4", 0},
// 名字只有一个字的不参与匹配,太容易误伤
{"阿 的录播.mp4", 0},
{"", 0},
}
if config.UIDs[0].Bitrate != 4000 {
t.Errorf("uid 1 应补成 4000,实际 %d", config.UIDs[0].Bitrate)
for _, c := range cases {
if got := guessUIDByFileName(c.file); got != c.want {
t.Errorf("%q 应猜出 %d,实际 %d", c.file, c.want, got)
}
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)
}
}
+8 -14
View File
@@ -121,7 +121,8 @@ func fetchStreamQualities(uid int) ([]qualityTier, error) {
return streamTiers(info.StreamList), nil
}
// 获取 uid 的直播源(flv),bitrate 为 0 时取码率最高的源,否则取码率不超过 bitrate(Kbps)的最高档
// 获取 uid 的直播源(flv)。bitrate 是页面上选的档位码率,0 表示自动:
// 按这次拿到的实际档位自上往下取第二高(只有一个档位就用它,拿不到档位表退回最高档)
func getStreamURL(uid, bitrate int) (url string, quality string, err error) {
sInfo, err := newStreamInfo(uid)
if err != nil {
@@ -131,26 +132,19 @@ func getStreamURL(uid, bitrate int) (url string, quality string, err error) {
return "", "", fmt.Errorf("uid 为%d的主播没有可用的直播源", uid)
}
// 顺手把码率档位记下来:页面上要用它做下拉,没开播时靠缓存显示
if tiers := streamTiers(sInfo.StreamList); len(tiers) > 0 {
var tiers []qualityTier
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 按码率升序排列
want := resolveQualityBitrate(tiers, bitrate)
// StreamList 按码率升序排列,从高往低找选中的那一档
st := sInfo.StreamList[len(sInfo.StreamList)-1]
if bitrate > 0 {
if want > 0 {
st = sInfo.StreamList[0]
for i := len(sInfo.StreamList) - 1; i >= 0; i-- {
if sInfo.StreamList[i].Bitrate <= bitrate {
if sInfo.StreamList[i].Bitrate <= want {
st = sInfo.StreamList[i]
break
}
+39 -57
View File
@@ -98,8 +98,7 @@ const webuiHTML = `<!DOCTYPE html>
input[readonly]{background:var(--soft2);color:var(--sub)}
#mrgInfo{white-space:pre-wrap;margin-top:10px;font-size:12.5px;line-height:1.7;color:var(--text);background:var(--soft2);border-radius:var(--radius-s);padding:10px 12px}
#mrgInfo:empty{display:none}
input.br{width:92px;text-align:center;padding:6px 8px}
select.brsel{padding:6px 6px;max-width:150px}
select.brsel{padding:6px;max-width:190px}
button.mini{padding:4px 6px;font-size:11.5px}
/* 开关 */
.sws{display:flex;flex-wrap:wrap;gap:10px 22px}
@@ -241,7 +240,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">码率这里选的是直播的实际档位(如蓝光4M / 高清),开播后自动读取,也能点「档位」按钮手动读一次;没手动选过的默认用<b>第二档</b>(自上往下第二高)。正在录制时换档会自动重录。没读过档位前可以直接填码率上限(Kbps,0 = 用默认档位)</div>
<div class="hint" style="margin:0 0 10px">码率这里选的是直播的<b>实际档位</b>(如蓝光 4M / 高清),开播后自动读取,也能点每行的「档位」按钮立刻读一次。默认「自动 · 第二高」= 按这次读到档位排序自上往下取第二高(不一定有蓝光 8M,所以只按实际档位排),也可以直接选最高、最低或中间某一档。正在录制时换档会自动重录。</div>
<div id="st"></div>
</div>
<div class="card">
@@ -275,7 +274,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="0"></div>
<div class="f"><label>ffmpeg 路径(留空自动查找)</label>
<div class="pathf">
<input type="text" id="ffmpegPath" placeholder="例如 D:\ffmpeg\bin\ffmpeg.exe">
@@ -689,23 +688,29 @@ async function refresh(){
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 = '';
// 档位一律用下拉:第一项「自动 · 第二高」(跟着实际档位排序走,值 0),
// 后面是这次读到的实际档位。手动选过的档位就是选中项,随时能选回「自动」。
var autoLabel = '自动 · 第二高';
if(!s.bitrate){
autoLabel += s.qualityName ? '(当前 = ' + esc(s.qualityName) + ')' : (qs.length ? '' : '(开播后才有)');
}
var opts = '<option value="0"' + (s.bitrate ? '' : ' selected') + '>' + autoLabel + '</option>';
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(q.bitrate === s.bitrate) matched = true;
opts += '<option value="' + q.bitrate + '"' + (q.bitrate === s.bitrate ? " 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>';
// 指定过的档位现在这一场没有(比如主播换了档位表)时补一项,免得选中项空着
if(s.bitrate && !matched){
opts += '<option value="' + s.bitrate + '" selected>已指定:' + (s.qualityName || (s.bitrate + 'K'))
+ '(' + s.bitrate + 'K)</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">'
+ '<span class="uid">uid ' + s.uid + '</span>'
+ '<span class="name">' + (esc(s.name) || "uid " + s.uid) + '</span>'
@@ -721,20 +726,10 @@ async function refresh(){
}).join("");
var list = document.getElementById("st");
var sig = JSON.stringify(st.streamers);
// 数据没变化不重建;正在列表里输入时也跳过,避免把没提交的输入清掉
// 数据没变化不重建;正在列表里操作时也跳过
if(sig !== stSig && !list.contains(document.activeElement)){
stSig = sig;
// 重建前把用户改过、还没提交的码率记下来,建完再抄回去(填错的不用留)
var keepBr = {};
Array.prototype.forEach.call(list.querySelectorAll("input.br"), function(el){
var v = parseInt(el.value.trim(), 10);
if(el.dataset.dirty === "1" && !isNaN(v) && v >= 0) keepBr[el.dataset.uid] = el.value;
});
list.innerHTML = rows || '<div class="off">列表为空,在上面填 uid 添加</div>';
Object.keys(keepBr).forEach(function(uid){
var el = list.querySelector('input.br[data-uid="' + uid + '"]');
if(el){ el.value = keepBr[uid]; el.dataset.dirty = "1"; }
});
}
// 开播了但还没有档位缓存的,自动去读一次,让码率下拉能用
autoLoadQualities(st.streamers);
@@ -1246,13 +1241,6 @@ async function delUid(uid){
if(!confirm("确定删除 uid " + uid + " 并停止其录制?")) return;
post("/api/streamer/remove", {uid: uid});
}
// 码率填错时把框里的内容恢复成服务端记录的值,不能干等下一次列表重建
function restoreBr(el){
var uid = parseInt(el.dataset.uid, 10);
var cur = (lastSt && lastSt.streamers ? lastSt.streamers : []).filter(function(s){ return s.uid === uid; })[0];
delete el.dataset.dirty;
el.value = cur ? (cur.bitrate || 0) : 0;
}
document.addEventListener("change", async function(e){
var t = e.target;
if(t.matches('input[type=checkbox][data-uid]')){
@@ -1260,10 +1248,8 @@ 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') || row.querySelector('select.brsel');
var bitrate = parseInt(brEl.value, 10) || 0;
// 勾选时会把同一行的码率一起提交,提交成功后就不算「未提交的输入」了
if(await post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate}) && brEl.dataset) delete brEl.dataset.dirty;
var bitrate = parseInt(row.querySelector('select.brsel').value, 10) || 0;
post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate});
}
if(t.matches('select.brsel')){
// 档位下拉是选完立即生效的(正在录制时会自动换档重录)
@@ -1274,22 +1260,14 @@ document.addEventListener("change", async function(e){
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);
var row2 = t.closest(".row");
var v = parseInt(t.value.trim(), 10);
if(isNaN(v) || v < 0){ showMsg(false, "码率必须是大于等于 0 的数字"); restoreBr(t); return; }
var danmu2 = row2.querySelector('input[data-k=danmu]').checked;
var record2 = row2.querySelector('input[data-k=record]').checked;
// 提交成功才能把「未提交」标记去掉,列表重建时才不会把它当成用户还在填
if(await post("/api/streamer", {uid: uid2, danmu: danmu2, record: record2, bitrate: v})) delete t.dataset.dirty;
}
});
// 去直播间接一次码率档位;拿到后列表会显示成档位下拉,没设过码率的会被服务端按第二档定下来
// 去直播间接一次码率档位;拿到后下拉里就列出实际档位(0 = 自动第二高)
var qualitiesTried = {};
async function loadQualities(uid){
async function loadQualities(uid, quiet){
if(qualitiesTried[uid]) return;
qualitiesTried[uid] = true;
// 主播没开播时服务端要重试三次才放弃(约 5 秒),先给个反馈,别让人以为按钮没反应
if(!quiet) showMsg(true, "正在读取码率档位…");
var r = null;
try{ r = await api("/api/streamer/qualities?uid=" + uid); }catch(e){}
if(!r || !r.ok){
@@ -1302,10 +1280,10 @@ async function loadQualities(uid){
showMsg(true, "已读取 " + (d.qualities || []).length + " 个码率档位");
await refresh();
}
// 开播但还没档位缓存时自动读一次,让下拉直接能用
// 开播但还没档位缓存时自动读一次,让下拉直接能用(这种必定成功,不用提示「正在读取」)
function autoLoadQualities(list){
(list || []).forEach(function(s){
if(s.live && !(s.qualities || []).length) loadQualities(s.uid);
if(s.live && !(s.qualities || []).length) loadQualities(s.uid, true);
});
}
// 回到地址栏或上次看的那个分区
@@ -1326,9 +1304,10 @@ type webStreamer struct {
Live bool `json:"live"`
Danmu bool `json:"danmu"` // 弹幕录制开关
Record bool `json:"record"` // 视频录制开关
Bitrate int `json:"bitrate"` // 录制的直播源码率(Kbps),对应档位里的一个档
Bitrate int `json:"bitrate"` // 选定的档位码率(Kbps),0 = 自动(实际档位第二高)
Qualities []qualityTier `json:"qualities"` // 可选档位(按码率从高到低),没探测到时为空
QualityName string `json:"qualityName"` // 当前码率对应的档位名
QualityBitrate int `json:"qualityBitrate"` // 实际会用到的档位码率(自动时就是第二高的那个)
QualityName string `json:"qualityName"` // 实际会用到的档位名
DanmuActive bool `json:"danmuActive"` // 是否正在录制弹幕
RecordActive bool `json:"recordActive"` // 是否正在录制视频
Title string `json:"title"`
@@ -1440,7 +1419,8 @@ func statusHandler(w http.ResponseWriter, r *http.Request) {
}
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)
ws.QualityBitrate = resolveQualityBitrate(s.Qualities, s.Bitrate)
ws.QualityName = qualityNameFor(s.Qualities, ws.QualityBitrate)
if infoList, ok := getLiveInfoByUID(s.UID); ok {
for _, info := range infoList {
if info.isDanmu {
@@ -1692,11 +1672,13 @@ func streamerQualitiesHandler(w http.ResponseWriter, r *http.Request) {
}
streamers.RUnlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
used := resolveQualityBitrate(tiers, bitrate)
_ = json.NewEncoder(w).Encode(map[string]any{
"uid": uid,
"qualities": sortQualitiesDesc(tiers),
"bitrate": bitrate,
"qualityName": qualityNameFor(tiers, bitrate),
"qualityBitrate": used,
"qualityName": qualityNameFor(tiers, used),
})
}