档位改成「自动·第二高 + 实际档位」的下拉;手动合并也推送;头像缓存挪到程序目录
三件事: 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:
@@ -70,7 +70,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
|||||||
"uid": 23682490, // 主播的 uid
|
"uid": 23682490, // 主播的 uid
|
||||||
"danmu": true, // 是否录制弹幕
|
"danmu": true, // 是否录制弹幕
|
||||||
"record": true, // 是否录制直播视频
|
"record": true, // 是否录制直播视频
|
||||||
"bitrate": 0, // 录制的直播源码率(Kbps),对应下面 qualities 里的一个档位;0 = 还没选过(按第二档)
|
"bitrate": 0, // 选定的档位码率(Kbps),对应下面 qualities 里的一个档位;0 = 自动(按实际档位取第二高)
|
||||||
"qualities": [] // 开播时读到的码率档位列表,缓存下来让没开播时也能选
|
"qualities": [] // 开播时读到的码率档位列表,缓存下来让没开播时也能选
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -83,7 +83,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
|||||||
"mergeOutputDir": "", // 合并完成后成片移动到该文件夹,留空则不移动
|
"mergeOutputDir": "", // 合并完成后成片移动到该文件夹,留空则不移动
|
||||||
"mergeBitrate": 0, // 合并成片的目标码率(Kbps),0 表示按「源片码率 + 500」自动计算
|
"mergeBitrate": 0, // 合并成片的目标码率(Kbps),0 表示按「源片码率 + 500」自动计算
|
||||||
"danmuFontSize": 0, // 合并时弹幕文字的字号(像素),0 表示跟随弹幕文件;头像随字号等比缩放
|
"danmuFontSize": 0, // 合并时弹幕文字的字号(像素),0 表示跟随弹幕文件;头像随字号等比缩放
|
||||||
"defaultBitrate": 0, // 新添加主播的默认 bitrate,0 表示按实际档位取第二档(自上往下第二高)
|
"defaultBitrate": 0, // 新添加主播默认选的档位,0 = 自动(按实际档位取第二高)
|
||||||
|
|
||||||
"webExternal": false, // 是否允许外网访问 web UI,false 时只监听本机 127.0.0.1
|
"webExternal": false, // 是否允许外网访问 web UI,false 时只监听本机 127.0.0.1
|
||||||
"authToken": "…", // web UI 管理接口的访问口令,首次启动自动生成
|
"authToken": "…", // web UI 管理接口的访问口令,首次启动自动生成
|
||||||
@@ -95,7 +95,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
|||||||
"notifyLiveOn": false, // 开播时推送
|
"notifyLiveOn": false, // 开播时推送
|
||||||
"notifyLiveOff": false, // 下播时推送
|
"notifyLiveOff": false, // 下播时推送
|
||||||
"notifyRecordStart": false, // 开始录制直播视频时推送
|
"notifyRecordStart": false, // 开始录制直播视频时推送
|
||||||
"notifyMergeDone": false, // 自动合并完成时推送
|
"notifyMergeDone": false, // 合并完成时推送(自动合并和手动合并都会推)
|
||||||
|
|
||||||
"meowEnabled": false, // MeoW 推送的渠道开关
|
"meowEnabled": false, // MeoW 推送的渠道开关
|
||||||
"meowNickname": "", // MeoW 昵称,推送地址里的路径参数
|
"meowNickname": "", // MeoW 昵称,推送地址里的路径参数
|
||||||
@@ -112,7 +112,9 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
|||||||
|
|
||||||
`uids`也兼容旧的纯数字数组格式,如`"uids": [23682490, 12345]`,此时弹幕和录播都默认开启、码率按实际档位取第二档。
|
`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`,成片优先移动到那里。
|
录播原片和弹幕文件(`.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`;填相对路径时按程序所在目录解析,也可以填它所在的文件夹。
|
`-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`。
|
有 Intel 核显时优先用 QSV 硬件编码(`h264_qsv`),不可用或运行失败时自动回退到`libx264`。
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/sha1"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/gif"
|
"image/gif"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
"image/png"
|
"image/png"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -109,3 +113,57 @@ func TestDecodeAvatarGifGoesThroughCircleAvatar(t *testing.T) {
|
|||||||
t.Fatal("编码出来是空的")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ type streamer struct {
|
|||||||
Name string `json:"name"` // 主播名字
|
Name string `json:"name"` // 主播名字
|
||||||
Danmu bool `json:"-"` // 是否录制直播弹幕
|
Danmu bool `json:"-"` // 是否录制直播弹幕
|
||||||
Record bool `json:"-"` // 是否录制直播视频
|
Record bool `json:"-"` // 是否录制直播视频
|
||||||
Bitrate int `json:"-"` // 录制的直播源最高码率(Kbps),0 表示还没定(按实际档位取第二高)
|
Bitrate int `json:"-"` // 选定的档位码率(Kbps),0 = 自动(按实际档位取第二高)
|
||||||
Qualities []qualityTier `json:"-"` // 上次探测到的码率档位,没开播时页面靠它显示下拉
|
Qualities []qualityTier `json:"-"` // 上次探测到的码率档位,没开播时页面靠它显示下拉
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ type uidSetting struct {
|
|||||||
UID int `json:"uid"`
|
UID int `json:"uid"`
|
||||||
Danmu bool `json:"danmu"`
|
Danmu bool `json:"danmu"`
|
||||||
Record bool `json:"record"`
|
Record bool `json:"record"`
|
||||||
Bitrate int `json:"bitrate"` // 录制的直播源最高码率(Kbps),0 表示还没定(按实际档位取第二高)
|
Bitrate int `json:"bitrate"` // 选定的档位码率(Kbps),0 = 自动(按实际档位取第二高)
|
||||||
Qualities []qualityTier `json:"qualities,omitempty"` // 上次探测到的档位,缓存下来让没开播时也能在页面上选
|
Qualities []qualityTier `json:"qualities,omitempty"` // 上次探测到的档位,缓存下来让没开播时也能在页面上选
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ type configData struct {
|
|||||||
MergeBitrate int `json:"mergeBitrate"` // 合并成片的目标码率(Kbps),0 表示使用默认质量
|
MergeBitrate int `json:"mergeBitrate"` // 合并成片的目标码率(Kbps),0 表示使用默认质量
|
||||||
DanmuFontSize int `json:"danmuFontSize"` // 合并时弹幕文字的字号(头像随字号等比缩放),0 表示跟随弹幕文件
|
DanmuFontSize int `json:"danmuFontSize"` // 合并时弹幕文字的字号(头像随字号等比缩放),0 表示跟随弹幕文件
|
||||||
|
|
||||||
DefaultBitrate int `json:"defaultBitrate"` // 新添加主播的默认下载码率上限(Kbps),0 表示录制码率最高的直播源
|
DefaultBitrate int `json:"defaultBitrate"` // 新添加主播默认选的档位码率(Kbps),0 = 自动(按实际档位取第二高)
|
||||||
|
|
||||||
WebExternal bool `json:"webExternal"` // 是否允许外网访问 web UI,false 时只监听本机
|
WebExternal bool `json:"webExternal"` // 是否允许外网访问 web UI,false 时只监听本机
|
||||||
|
|
||||||
@@ -508,8 +508,8 @@ func sameQualities(a, b []qualityTier) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 记下某个主播当前的码率档位(离线时页面靠它显示下拉)。
|
// 记下某个主播当前的码率档位(离线时页面靠它显示下拉)。
|
||||||
// 顺手把「还没设过码率」的主播按默认档位(第二高)定下来 —— 这样默认不会一直吃满最高档。
|
// 不动用户选的码率:0 表示「自动、按实际档位取第二高」,只在真正取流时解析,
|
||||||
// 返回是否改动了配置,调用方决定要不要 saveConfig。
|
// 这样主播换了档位表也能跟着走,而不是把某个码率钉死。返回是否改动了配置。
|
||||||
func setStreamerQualities(uid int, tiers []qualityTier) bool {
|
func setStreamerQualities(uid int, tiers []qualityTier) bool {
|
||||||
if uid <= 0 || len(tiers) == 0 {
|
if uid <= 0 || len(tiers) == 0 {
|
||||||
return false
|
return false
|
||||||
@@ -524,18 +524,8 @@ func setStreamerQualities(uid int, tiers []qualityTier) bool {
|
|||||||
config.UIDs[i].Qualities = tiers
|
config.UIDs[i].Qualities = tiers
|
||||||
changed = true
|
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 {
|
if s, ok := streamers.crt[uid]; ok {
|
||||||
s.Qualities = config.UIDs[i].Qualities
|
s.Qualities = config.UIDs[i].Qualities
|
||||||
// 档位定下来后正在录的那场不打断,配置值从下次录制开始生效
|
|
||||||
s.Bitrate = config.UIDs[i].Bitrate
|
|
||||||
streamers.crt[uid] = s
|
streamers.crt[uid] = s
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@@ -544,17 +534,44 @@ func setStreamerQualities(uid int, tiers []qualityTier) bool {
|
|||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动时把「缓存过档位、但还没定过码率」的主播按默认档位定下来
|
// 按选定的码率决定实际用哪个档位:
|
||||||
func applyCachedDefaultQualities() bool {
|
// 0(自动)取自上往下第二档(只有一个档位就用它);填了具体码率就取不超过它的最高档,
|
||||||
changed := false
|
// 都比它高时退回最低档。档位列表为空返回 0,交给调用方按老规矩取最高档。
|
||||||
for _, u := range append(uidList(nil), config.UIDs...) {
|
func resolveQualityBitrate(tiers []qualityTier, bitrate int) int {
|
||||||
if u.Bitrate == 0 && len(u.Qualities) > 0 {
|
desc := sortQualitiesDesc(tiers)
|
||||||
if setStreamerQualities(u.UID, u.Qualities) {
|
if len(desc) == 0 {
|
||||||
changed = true
|
return bitrate
|
||||||
}
|
}
|
||||||
|
if bitrate <= 0 {
|
||||||
|
return defaultQualityBitrate(desc)
|
||||||
|
}
|
||||||
|
for _, t := range desc {
|
||||||
|
if t.Bitrate <= bitrate {
|
||||||
|
return t.Bitrate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return changed
|
return desc[len(desc)-1].Bitrate
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从文件(成片或录播)名里猜是哪个主播:名字出现在文件名里、且只有一个能对上时才认,
|
||||||
|
// 认不出来返回 0。手动合并的推送靠它带上主播名,猜不准就退回通用文案。
|
||||||
|
func guessUIDByFileName(name string) int {
|
||||||
|
if name == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
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,写入串行化并原子替换,避免并发写坏文件或读到写了一半的内容
|
// 保存 config.json,写入串行化并原子替换,避免并发写坏文件或读到写了一半的内容
|
||||||
|
|||||||
+46
-8
@@ -34,6 +34,15 @@ import (
|
|||||||
// 头像缓存目录名
|
// 头像缓存目录名
|
||||||
const avatarCacheDir = "avatar_cache"
|
const avatarCacheDir = "avatar_cache"
|
||||||
|
|
||||||
|
// 头像缓存放程序所在目录:换保存目录(甚至换盘)都还能复用,
|
||||||
|
// 不用为同一批观众重新下一次。旧版本放在弹幕文件同目录,读取时会搬过来。
|
||||||
|
func avatarCachePath() string {
|
||||||
|
if exeDir == "" {
|
||||||
|
return avatarCacheDir
|
||||||
|
}
|
||||||
|
return filepath.Join(exeDir, avatarCacheDir)
|
||||||
|
}
|
||||||
|
|
||||||
// 命令行 -vbitrate 指定的合成目标码率(Kbps),配合 config.MergeBitrate 使用
|
// 命令行 -vbitrate 指定的合成目标码率(Kbps),配合 config.MergeBitrate 使用
|
||||||
var burnBitrate int
|
var burnBitrate int
|
||||||
|
|
||||||
@@ -222,8 +231,10 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
|||||||
|
|
||||||
// 下载并圆形裁切头像
|
// 下载并圆形裁切头像
|
||||||
avatarPaths := make(map[int64]string)
|
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)
|
urlByUID := make(map[int64]string)
|
||||||
uids := make([]int64, 0, 64)
|
uids := make([]int64, 0, 64)
|
||||||
@@ -254,7 +265,7 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
|||||||
if url == "" {
|
if url == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if path := ensureAvatar(uid, url, cacheDir); path != "" {
|
if path := ensureAvatar(uid, url, cacheDir, legacyCache); path != "" {
|
||||||
avatarPaths[uid] = path
|
avatarPaths[uid] = path
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,7 +295,7 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
|||||||
meta: meta,
|
meta: meta,
|
||||||
avatars: avatarPaths,
|
avatars: avatarPaths,
|
||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
dir: dir,
|
dir: filepath.Dir(jsonlFile),
|
||||||
}
|
}
|
||||||
|
|
||||||
chunks := splitDanmuChunks(records, offset, videoDuration)
|
chunks := splitDanmuChunks(records, offset, videoDuration)
|
||||||
@@ -842,11 +853,13 @@ func formatSeconds(sec float64) string {
|
|||||||
return strconv.FormatFloat(sec, 'f', 3, 64)
|
return strconv.FormatFloat(sec, 'f', 3, 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载头像并圆形裁切,失败自动重试,返回缓存的 PNG 路径,失败返回空字符串
|
// 下载头像并圆形裁切,失败自动重试,返回缓存的 PNG 路径,失败返回空字符串。
|
||||||
func ensureAvatar(uid int64, url, cacheDir string) string {
|
// legacyDirs 是旧版本放缓存的位置,命中就搬过来用,省得换保存目录后重新下一次。
|
||||||
|
func ensureAvatar(uid int64, url, cacheDir string, legacyDirs ...string) string {
|
||||||
// 文件名里带上头像链接的摘要,主播换头像后不会一直用着旧缓存
|
// 文件名里带上头像链接的摘要,主播换头像后不会一直用着旧缓存
|
||||||
sum := sha1.Sum([]byte(url))
|
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 {
|
if _, err := os.Stat(path); err == nil {
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
@@ -854,8 +867,33 @@ func ensureAvatar(uid int64, url, cacheDir string) string {
|
|||||||
lPrintWarnf("创建头像缓存文件夹 %s 失败,弹幕只烧录文字不带头像:%v", cacheDir, err)
|
lPrintWarnf("创建头像缓存文件夹 %s 失败,弹幕只烧录文字不带头像:%v", cacheDir, err)
|
||||||
return ""
|
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 {
|
for _, p := range olds {
|
||||||
if p != path {
|
if p != path {
|
||||||
_ = os.Remove(p)
|
_ = os.Remove(p)
|
||||||
|
|||||||
+2
-2
@@ -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=` 里也都能通过。用脚本调这套接口时不用管编码,原样填就行。
|
口令支持任意字符,包括中文——不过 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":"…"}`,找不到时是空字符串。值里录播目录/成片目录下的文件只写文件名,别处的写绝对路径,和`浏览`弹窗的填法一致。
|
`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,并在响应体里给中文原因。
|
返回分段列表(名字、体积、时长、是否正在写入)、总时长、弹幕条数、弹幕时间范围、要用的弹幕文件和来源(`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 拒绝删除。
|
合并状态通过`GET /api/status`查看:`mergeBusy`(是否在跑)、`mergeOutName`(当前成片名)、`mergeErr`(失败原因)、`mergeDoneAt`(上次完成时间)。合并期间成片会出现在`GET /api/files`里且`inUse`为 true,此时`POST /api/file/remove`会返回 409 拒绝删除。
|
||||||
|
|
||||||
|
|||||||
@@ -106,10 +106,6 @@ func initialize() {
|
|||||||
|
|
||||||
loadConfig()
|
loadConfig()
|
||||||
checkConfig()
|
checkConfig()
|
||||||
// 缓存过档位、但还没定过码率的主播,启动时按默认档位(第二高)定下来
|
|
||||||
if applyCachedDefaultQualities() {
|
|
||||||
saveConfig()
|
|
||||||
}
|
|
||||||
// 上次在合并途中被关掉的话,录播目录和成片目录里会留下临时文件,启动时清一次
|
// 上次在合并途中被关掉的话,录播目录和成片目录里会留下临时文件,启动时清一次
|
||||||
cleanLeftoverTempFiles()
|
cleanLeftoverTempFiles()
|
||||||
ensureAuthToken()
|
ensureAuthToken()
|
||||||
|
|||||||
@@ -538,6 +538,16 @@ func runManualMerge(plan mergePlan) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lPrintln("手动合并完成:" + plan.out)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+102
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -592,3 +593,104 @@ func TestNotifyTestHandler(t *testing.T) {
|
|||||||
t.Errorf("非法昵称应该 400,实际 %d", code)
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+71
-25
@@ -75,7 +75,8 @@ func TestSameQualities(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录档位时顺便把「还没设过码率」的主播按默认档位定下来
|
// 记录档位时只缓存档位表,不动用户选的码率:
|
||||||
|
// 0 表示「自动、按实际档位取第二高」,主播换了档位表要能跟着走
|
||||||
func TestSetStreamerQualities(t *testing.T) {
|
func TestSetStreamerQualities(t *testing.T) {
|
||||||
savedConfig := config
|
savedConfig := config
|
||||||
savedStreamers := streamers.crt
|
savedStreamers := streamers.crt
|
||||||
@@ -85,8 +86,8 @@ func TestSetStreamerQualities(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
config.UIDs = uidList{
|
config.UIDs = uidList{
|
||||||
{UID: 1, Danmu: true, Record: true, Bitrate: 0}, // 没设过 → 应被定成第二档
|
{UID: 1, Danmu: true, Record: true, Bitrate: 0}, // 自动
|
||||||
{UID: 2, Danmu: true, Record: true, Bitrate: 1000}, // 手动设过 → 不动
|
{UID: 2, Danmu: true, Record: true, Bitrate: 1000}, // 手动指定过
|
||||||
}
|
}
|
||||||
streamers.crt = map[int]streamer{
|
streamers.crt = map[int]streamer{
|
||||||
1: {UID: 1, Bitrate: 0},
|
1: {UID: 1, Bitrate: 0},
|
||||||
@@ -96,14 +97,17 @@ func TestSetStreamerQualities(t *testing.T) {
|
|||||||
if !setStreamerQualities(1, tiersForTest()) {
|
if !setStreamerQualities(1, tiersForTest()) {
|
||||||
t.Error("第一次记录档位应该算改动了配置")
|
t.Error("第一次记录档位应该算改动了配置")
|
||||||
}
|
}
|
||||||
if config.UIDs[0].Bitrate != 4000 {
|
if config.UIDs[0].Bitrate != 0 {
|
||||||
t.Errorf("uid 1 没设过码率,应被定成第二档 4000,实际 %d", config.UIDs[0].Bitrate)
|
t.Errorf("自动的主播不该被写死成具体码率,实际 %d", config.UIDs[0].Bitrate)
|
||||||
}
|
}
|
||||||
if streamers.crt[1].Bitrate != 4000 || len(streamers.crt[1].Qualities) != 4 {
|
if len(config.UIDs[0].Qualities) != 4 || len(streamers.crt[1].Qualities) != 4 {
|
||||||
t.Errorf("运行时的主播数据没同步:%+v", streamers.crt[1])
|
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())
|
setStreamerQualities(2, tiersForTest())
|
||||||
if config.UIDs[1].Bitrate != 1000 {
|
if config.UIDs[1].Bitrate != 1000 {
|
||||||
t.Errorf("uid 2 手动设过 1000,不该被改成 %d", config.UIDs[1].Bitrate)
|
t.Errorf("uid 2 手动设过 1000,不该被改成 %d", config.UIDs[1].Bitrate)
|
||||||
@@ -119,8 +123,43 @@ func TestSetStreamerQualities(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动时按缓存里的档位把默认码率补齐
|
// 实际用哪个档位:0 = 自动(第二高),填了具体码率 = 不超过它的最高档,
|
||||||
func TestApplyCachedDefaultQualities(t *testing.T) {
|
// 都比它高时退回最低档
|
||||||
|
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
|
savedConfig := config
|
||||||
savedStreamers := streamers.crt
|
savedStreamers := streamers.crt
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -128,24 +167,31 @@ func TestApplyCachedDefaultQualities(t *testing.T) {
|
|||||||
streamers.crt = savedStreamers
|
streamers.crt = savedStreamers
|
||||||
}()
|
}()
|
||||||
|
|
||||||
config.UIDs = uidList{
|
config.UIDs = uidList{{UID: 1}, {UID: 2}, {UID: 3}}
|
||||||
{UID: 1, Bitrate: 0, Qualities: tiersForTest()}, // 有缓存、没定过 → 补上
|
streamers.crt = map[int]streamer{
|
||||||
{UID: 2, Bitrate: 2000, Qualities: tiersForTest()}, // 已经有值 → 不动
|
1: {UID: 1, Name: "AC娘本体"},
|
||||||
{UID: 3, Bitrate: 0}, // 没缓存 → 不动
|
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() {
|
cases := []struct {
|
||||||
t.Fatal("有需要补的主播时应算改动")
|
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 {
|
for _, c := range cases {
|
||||||
t.Errorf("uid 1 应补成 4000,实际 %d", config.UIDs[0].Bitrate)
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,8 @@ func fetchStreamQualities(uid int) ([]qualityTier, error) {
|
|||||||
return streamTiers(info.StreamList), nil
|
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) {
|
func getStreamURL(uid, bitrate int) (url string, quality string, err error) {
|
||||||
sInfo, err := newStreamInfo(uid)
|
sInfo, err := newStreamInfo(uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -131,26 +132,19 @@ func getStreamURL(uid, bitrate int) (url string, quality string, err error) {
|
|||||||
return "", "", fmt.Errorf("uid 为%d的主播没有可用的直播源", uid)
|
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) {
|
if setStreamerQualities(uid, tiers) {
|
||||||
saveConfig()
|
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]
|
st := sInfo.StreamList[len(sInfo.StreamList)-1]
|
||||||
if bitrate > 0 {
|
if want > 0 {
|
||||||
st = sInfo.StreamList[0]
|
st = sInfo.StreamList[0]
|
||||||
for i := len(sInfo.StreamList) - 1; i >= 0; i-- {
|
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]
|
st = sInfo.StreamList[i]
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,8 +98,7 @@ const webuiHTML = `<!DOCTYPE html>
|
|||||||
input[readonly]{background:var(--soft2);color:var(--sub)}
|
input[readonly]{background:var(--soft2);color:var(--sub)}
|
||||||
#mrgInfo{white-space:pre-wrap;margin-top:10px;font-size:12.5px;line-height:1.7;color:var(--text);background:var(--soft2);border-radius:var(--radius-s);padding:10px 12px}
|
#mrgInfo{white-space:pre-wrap;margin-top:10px;font-size:12.5px;line-height:1.7;color:var(--text);background:var(--soft2);border-radius:var(--radius-s);padding:10px 12px}
|
||||||
#mrgInfo:empty{display:none}
|
#mrgInfo:empty{display:none}
|
||||||
input.br{width:92px;text-align:center;padding:6px 8px}
|
select.brsel{padding:6px;max-width:190px}
|
||||||
select.brsel{padding:6px 6px;max-width:150px}
|
|
||||||
button.mini{padding:4px 6px;font-size:11.5px}
|
button.mini{padding:4px 6px;font-size:11.5px}
|
||||||
/* 开关 */
|
/* 开关 */
|
||||||
.sws{display:flex;flex-wrap:wrap;gap:10px 22px}
|
.sws{display:flex;flex-wrap:wrap;gap:10px 22px}
|
||||||
@@ -241,7 +240,7 @@ const webuiHTML = `<!DOCTYPE html>
|
|||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-h"><h2>主播列表</h2><span class="sub">勾选 = 开启对应录制,改完立即生效</span></div>
|
<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 id="st"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -275,7 +274,7 @@ const webuiHTML = `<!DOCTYPE html>
|
|||||||
<option value="flv">flv(兼容性一般)</option>
|
<option value="flv">flv(兼容性一般)</option>
|
||||||
<option value="mkv">mkv(可装多音轨)</option>
|
<option value="mkv">mkv(可装多音轨)</option>
|
||||||
</select></div>
|
</select></div>
|
||||||
<div class="f"><label>默认下载码率上限(Kbps,0 = 新主播按实际档位取第二档)</label><input type="text" id="defaultBitrate" inputmode="numeric" placeholder="例如 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="f"><label>ffmpeg 路径(留空自动查找)</label>
|
||||||
<div class="pathf">
|
<div class="pathf">
|
||||||
<input type="text" id="ffmpegPath" placeholder="例如 D:\ffmpeg\bin\ffmpeg.exe">
|
<input type="text" id="ffmpegPath" placeholder="例如 D:\ffmpeg\bin\ffmpeg.exe">
|
||||||
@@ -689,23 +688,29 @@ async function refresh(){
|
|||||||
if(!badges) badges = s.live ? '<span class="badge on">直播中</span>' : '<span class="off">离线</span>';
|
if(!badges) badges = s.live ? '<span class="badge on">直播中</span>' : '<span class="off">离线</span>';
|
||||||
var title = esc(s.title);
|
var title = esc(s.title);
|
||||||
var qs = s.qualities || [];
|
var qs = s.qualities || [];
|
||||||
var brCell;
|
// 档位一律用下拉:第一项「自动 · 第二高」(跟着实际档位排序走,值 0),
|
||||||
if(qs.length){
|
// 后面是这次读到的实际档位。手动选过的档位就是选中项,随时能选回「自动」。
|
||||||
// 有档位列表就用下拉,值是档位的码率;当前值匹配不上档位时补一个「当前设置」
|
var autoLabel = '自动 · 第二高';
|
||||||
var opts = '';
|
if(!s.bitrate){
|
||||||
var matched = false;
|
autoLabel += s.qualityName ? '(当前 = ' + esc(s.qualityName) + ')' : (qs.length ? '' : '(开播后才有)');
|
||||||
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>';
|
|
||||||
}
|
}
|
||||||
|
var opts = '<option value="0"' + (s.bitrate ? '' : ' selected') + '>' + autoLabel + '</option>';
|
||||||
|
var matched = false;
|
||||||
|
qs.forEach(function(q){
|
||||||
|
if(q.bitrate === s.bitrate) matched = true;
|
||||||
|
opts += '<option value="' + q.bitrate + '"' + (q.bitrate === s.bitrate ? " selected" : "") + '>'
|
||||||
|
+ esc(q.name) + '(' + q.bitrate + 'K)</option>';
|
||||||
|
});
|
||||||
|
// 指定过的档位现在这一场没有(比如主播换了档位表)时补一项,免得选中项空着
|
||||||
|
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">'
|
return '<div class="row">'
|
||||||
+ '<span class="uid">uid ' + s.uid + '</span>'
|
+ '<span class="uid">uid ' + s.uid + '</span>'
|
||||||
+ '<span class="name">' + (esc(s.name) || "uid " + s.uid) + '</span>'
|
+ '<span class="name">' + (esc(s.name) || "uid " + s.uid) + '</span>'
|
||||||
@@ -721,20 +726,10 @@ async function refresh(){
|
|||||||
}).join("");
|
}).join("");
|
||||||
var list = document.getElementById("st");
|
var list = document.getElementById("st");
|
||||||
var sig = JSON.stringify(st.streamers);
|
var sig = JSON.stringify(st.streamers);
|
||||||
// 数据没变化不重建;正在列表里输入时也跳过,避免把没提交的输入清掉
|
// 数据没变化不重建;正在列表里操作时也跳过
|
||||||
if(sig !== stSig && !list.contains(document.activeElement)){
|
if(sig !== stSig && !list.contains(document.activeElement)){
|
||||||
stSig = sig;
|
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>';
|
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);
|
autoLoadQualities(st.streamers);
|
||||||
@@ -1246,13 +1241,6 @@ async function delUid(uid){
|
|||||||
if(!confirm("确定删除 uid " + uid + " 并停止其录制?")) return;
|
if(!confirm("确定删除 uid " + uid + " 并停止其录制?")) return;
|
||||||
post("/api/streamer/remove", {uid: uid});
|
post("/api/streamer/remove", {uid: uid});
|
||||||
}
|
}
|
||||||
// 码率填错时把框里的内容恢复成服务端记录的值,不能干等下一次列表重建
|
|
||||||
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){
|
document.addEventListener("change", async function(e){
|
||||||
var t = e.target;
|
var t = e.target;
|
||||||
if(t.matches('input[type=checkbox][data-uid]')){
|
if(t.matches('input[type=checkbox][data-uid]')){
|
||||||
@@ -1260,10 +1248,8 @@ document.addEventListener("change", async function(e){
|
|||||||
var row = t.closest(".row");
|
var row = t.closest(".row");
|
||||||
var danmu = row.querySelector('input[data-k=danmu]').checked;
|
var danmu = row.querySelector('input[data-k=danmu]').checked;
|
||||||
var record = row.querySelector('input[data-k=record]').checked;
|
var record = row.querySelector('input[data-k=record]').checked;
|
||||||
var brEl = row.querySelector('input.br') || row.querySelector('select.brsel');
|
var bitrate = parseInt(row.querySelector('select.brsel').value, 10) || 0;
|
||||||
var bitrate = parseInt(brEl.value, 10) || 0;
|
post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate});
|
||||||
// 勾选时会把同一行的码率一起提交,提交成功后就不算「未提交的输入」了
|
|
||||||
if(await post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate}) && brEl.dataset) delete brEl.dataset.dirty;
|
|
||||||
}
|
}
|
||||||
if(t.matches('select.brsel')){
|
if(t.matches('select.brsel')){
|
||||||
// 档位下拉是选完立即生效的(正在录制时会自动换档重录)
|
// 档位下拉是选完立即生效的(正在录制时会自动换档重录)
|
||||||
@@ -1274,22 +1260,14 @@ document.addEventListener("change", async function(e){
|
|||||||
var record3 = row3.querySelector('input[data-k=record]').checked;
|
var record3 = row3.querySelector('input[data-k=record]').checked;
|
||||||
await post("/api/streamer", {uid: uid3, danmu: danmu3, record: record3, bitrate: v3});
|
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 = {};
|
var qualitiesTried = {};
|
||||||
async function loadQualities(uid){
|
async function loadQualities(uid, quiet){
|
||||||
if(qualitiesTried[uid]) return;
|
if(qualitiesTried[uid]) return;
|
||||||
qualitiesTried[uid] = true;
|
qualitiesTried[uid] = true;
|
||||||
|
// 主播没开播时服务端要重试三次才放弃(约 5 秒),先给个反馈,别让人以为按钮没反应
|
||||||
|
if(!quiet) showMsg(true, "正在读取码率档位…");
|
||||||
var r = null;
|
var r = null;
|
||||||
try{ r = await api("/api/streamer/qualities?uid=" + uid); }catch(e){}
|
try{ r = await api("/api/streamer/qualities?uid=" + uid); }catch(e){}
|
||||||
if(!r || !r.ok){
|
if(!r || !r.ok){
|
||||||
@@ -1302,10 +1280,10 @@ async function loadQualities(uid){
|
|||||||
showMsg(true, "已读取 " + (d.qualities || []).length + " 个码率档位");
|
showMsg(true, "已读取 " + (d.qualities || []).length + " 个码率档位");
|
||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
// 开播但还没档位缓存时自动读一次,让下拉直接能用
|
// 开播但还没档位缓存时自动读一次,让下拉直接能用(这种必定成功,不用提示「正在读取」)
|
||||||
function autoLoadQualities(list){
|
function autoLoadQualities(list){
|
||||||
(list || []).forEach(function(s){
|
(list || []).forEach(function(s){
|
||||||
if(s.live && !(s.qualities || []).length) loadQualities(s.uid);
|
if(s.live && !(s.qualities || []).length) loadQualities(s.uid, true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// 回到地址栏或上次看的那个分区
|
// 回到地址栏或上次看的那个分区
|
||||||
@@ -1321,18 +1299,19 @@ document.getElementById("tokenInput").addEventListener("keydown", function(e){ i
|
|||||||
|
|
||||||
// web UI 的主播状态
|
// web UI 的主播状态
|
||||||
type webStreamer struct {
|
type webStreamer struct {
|
||||||
UID int `json:"uid"`
|
UID int `json:"uid"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Live bool `json:"live"`
|
Live bool `json:"live"`
|
||||||
Danmu bool `json:"danmu"` // 弹幕录制开关
|
Danmu bool `json:"danmu"` // 弹幕录制开关
|
||||||
Record bool `json:"record"` // 视频录制开关
|
Record bool `json:"record"` // 视频录制开关
|
||||||
Bitrate int `json:"bitrate"` // 录制的直播源码率(Kbps),对应档位里的一个档
|
Bitrate int `json:"bitrate"` // 选定的档位码率(Kbps),0 = 自动(实际档位第二高)
|
||||||
Qualities []qualityTier `json:"qualities"` // 可选档位(按码率从高到低),没探测到时为空
|
Qualities []qualityTier `json:"qualities"` // 可选档位(按码率从高到低),没探测到时为空
|
||||||
QualityName string `json:"qualityName"` // 当前码率对应的档位名
|
QualityBitrate int `json:"qualityBitrate"` // 实际会用到的档位码率(自动时就是第二高的那个)
|
||||||
DanmuActive bool `json:"danmuActive"` // 是否正在录制弹幕
|
QualityName string `json:"qualityName"` // 实际会用到的档位名
|
||||||
RecordActive bool `json:"recordActive"` // 是否正在录制视频
|
DanmuActive bool `json:"danmuActive"` // 是否正在录制弹幕
|
||||||
Title string `json:"title"`
|
RecordActive bool `json:"recordActive"` // 是否正在录制视频
|
||||||
URL string `json:"url"`
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// web UI 的状态数据
|
// web UI 的状态数据
|
||||||
@@ -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 := 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.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 {
|
if infoList, ok := getLiveInfoByUID(s.UID); ok {
|
||||||
for _, info := range infoList {
|
for _, info := range infoList {
|
||||||
if info.isDanmu {
|
if info.isDanmu {
|
||||||
@@ -1692,11 +1672,13 @@ func streamerQualitiesHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
streamers.RUnlock()
|
streamers.RUnlock()
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
used := resolveQualityBitrate(tiers, bitrate)
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
"uid": uid,
|
"uid": uid,
|
||||||
"qualities": sortQualitiesDesc(tiers),
|
"qualities": sortQualitiesDesc(tiers),
|
||||||
"bitrate": bitrate,
|
"bitrate": bitrate,
|
||||||
"qualityName": qualityNameFor(tiers, bitrate),
|
"qualityBitrate": used,
|
||||||
|
"qualityName": qualityNameFor(tiers, used),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user