Files
acfunlive-remake/config.go
T
tamakyi 348e3bd675 档位改成「自动·第二高 + 实际档位」的下拉;手动合并也推送;头像缓存挪到程序目录
三件事:

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 干净。
2026-09-25 20:03:24 +08:00

1069 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 设置相关
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"syscall"
"time"
"github.com/fsnotify/fsnotify"
)
const configFile = "config.json" // 设置文件名字
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 = 自动(按实际档位取第二高)
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 = 自动(按实际档位取第二高)
Qualities []qualityTier `json:"qualities,omitempty"` // 上次探测到的档位,缓存下来让没开播时也能在页面上选
}
// uidSetting 列表,兼容旧的纯数字数组格式(旧格式默认弹幕和录播都开启)
type uidList []uidSetting
func (l *uidList) UnmarshalJSON(data []byte) error {
var ints []int
if err := json.Unmarshal(data, &ints); err == nil {
*l = (*l)[:0]
for _, uid := range ints {
*l = append(*l, uidSetting{UID: uid, Danmu: true, Record: true})
}
return nil
}
type rawList []uidSetting
var arr rawList
if err := json.Unmarshal(data, &arr); err != nil {
return err
}
*l = uidList(arr)
return nil
}
// 存放主播的设置数据
var streamers struct {
sync.RWMutex // crt 的锁
crt map[int]streamer // 现在的主播的设置数据
old map[int]streamer // 旧的主播的设置数据
}
// 串行化 config.json 的写入,避免多个 goroutine 同时写坏设置文件
var configMu sync.Mutex
// 设置数据
type configData struct {
UIDs uidList `json:"uids"` // 要录制的主播 uid 列表和各自的开关
Directory string `json:"directory"` // 弹幕文件录制结束后会被移动到该文件夹
WebPort int `json:"webPort"` // web UI 的本地端口,为 0 时不启动 web UI
AutoMerge bool `json:"autoMerge"` // 直播结束后自动把弹幕层合并进录播
MergeDelVideo bool `json:"mergeDelVideo"` // 合并完成后删除源视频
MergeDelDanmu bool `json:"mergeDelDanmu"` // 合并完成后删除弹幕文件
MergeOutputDir string `json:"mergeOutputDir"` // 合并完成后成片移动到该文件夹,留空不移动
MergeBitrate int `json:"mergeBitrate"` // 合并成片的目标码率(Kbps),0 表示使用默认质量
DanmuFontSize int `json:"danmuFontSize"` // 合并时弹幕文字的字号(头像随字号等比缩放),0 表示跟随弹幕文件
DefaultBitrate int `json:"defaultBitrate"` // 新添加主播默认选的档位码率(Kbps),0 = 自动(按实际档位取第二高)
WebExternal bool `json:"webExternal"` // 是否允许外网访问 web UI,false 时只监听本机
AuthToken string `json:"authToken"` // web UI 管理接口的访问口令,首次启动自动生成
NotifyEnabled bool `json:"notifyEnabled"` // QQ 机器人(webhook)推送的渠道开关,关掉后即使填了地址也不推送
NotifyURL string `json:"notifyUrl"` // 事件推送到该地址(HTTP POST JSON),留空不推送
NotifyToken string `json:"notifyToken"` // 推送时附带的 Bearer 口令,留空不带
NotifyGroup string `json:"notifyGroup"` // 推送时带上的群号,机器人可以直接用它决定发到哪个群
NotifyLiveOn bool `json:"notifyLiveOn"` // 开播时推送
NotifyLiveOff bool `json:"notifyLiveOff"` // 下播时推送
NotifyRecordStart bool `json:"notifyRecordStart"` // 开始录制直播视频时推送
NotifyMergeDone bool `json:"notifyMergeDone"` // 自动合并完成时推送
MeowEnabled bool `json:"meowEnabled"` // MeoW 推送的渠道开关
MeowNickname string `json:"meowNickname"` // MeoW 昵称,推送地址里的路径参数
MeowMsgType string `json:"meowMsgType"` // MeoW 消息类型:text(默认,纯文本)或 html
MeowHTMLHeight int `json:"meowHtmlHeight"` // MeoW 在 msgType 为 html 时的显示高度(像素),默认 200
MeowImgURL string `json:"meowImgUrl"` // MeoW 通知图标 URL,建议 216x216 的 PNG,留空用 MeoW 默认图标
FFmpegPath string `json:"ffmpegPath"` // ffmpeg 可执行文件(或所在文件夹)的路径,留空则自动查找
RecordFormat string `json:"recordFormat"` // 录播视频的封装格式:mp4(默认,与原版一致)、flv、ts、mkv
RecordSubdir string `json:"recordSubdir"` // 录播和弹幕文件存放在保存目录下的子文件夹名,默认为 record
MergeSubdir string `json:"mergeSubdir"` // 合并成片存放在保存目录下的子文件夹名,默认为 merged
}
// 尽量转成绝对路径,失败就原样返回
func absOrSelf(path string) string {
if path == "" {
return ""
}
if abs, err := filepath.Abs(path); err == nil {
return abs
}
return path
}
// 保存录制文件的根目录:设置了 directory 就用它,否则用 -record 指定的文件夹
// 一律归一成绝对路径:录制侧的文件名由这里拼出来,而 web UI 的文件管理接口拼的是绝对路径,
// 两边形式不统一会让「这个文件正在写入」的判断失效(删掉正在录制的文件)
func saveRootDir() string {
if config.Directory != "" {
return absOrSelf(config.Directory)
}
if recordDir == nil {
return ""
}
return absOrSelf(*recordDir)
}
// 录播和弹幕文件保存的文件夹
func recordSaveDir() string {
return filepath.Join(saveRootDir(), config.RecordSubdir)
}
// 录制过程中文件直接写入的文件夹
func recordingDir() string {
if recordDir == nil {
return config.RecordSubdir
}
return filepath.Join(absOrSelf(*recordDir), config.RecordSubdir)
}
// 合并成片保存的文件夹
func mergeSaveDir() string {
if config.MergeOutputDir != "" {
return absOrSelf(config.MergeOutputDir)
}
return filepath.Join(saveRootDir(), config.MergeSubdir)
}
// 默认的存放子文件夹名
const (
defaultRecordSubdir = "record"
defaultMergeSubdir = "merged"
)
// 录播视频默认的封装格式。原版(orzogc/acfunlive)用 config.Output 决定扩展名,默认就是 mp4
const defaultRecordFormat = "mp4"
// 支持的录播封装格式。录制用的是 -c copy,只换容器不转码,所以这些都只是封装差别
var recordFormats = map[string]struct{}{
"mp4": {},
"flv": {},
"ts": {},
"mkv": {},
}
// 把设置里的录播格式规整成受支持的值,空或不认识的值回退到 mp4
func normalizeRecordFormat(format string) string {
f := strings.ToLower(strings.TrimSpace(format))
if f == "" {
return defaultRecordFormat
}
if _, ok := recordFormats[f]; !ok {
lPrintWarnf("不支持的录播格式 %q,改用 %s(可选:mp4、flv、ts、mkv)", format, defaultRecordFormat)
return defaultRecordFormat
}
return f
}
// 录播文件的扩展名(含点),供录制和合并拼文件名用
func recordFileExt() string {
f := strings.ToLower(strings.TrimSpace(config.RecordFormat))
if _, ok := recordFormats[f]; !ok {
f = defaultRecordFormat
}
return "." + f
}
// 默认设置
var config = configData{
UIDs: uidList{},
WebPort: 51880,
RecordSubdir: defaultRecordSubdir,
MergeSubdir: defaultMergeSubdir,
NotifyEnabled: true,
}
// Windows 下不能用作文件(夹)名的保留字
var reservedNames = map[string]struct{}{
"CON": {}, "PRN": {}, "AUX": {}, "NUL": {},
"COM1": {}, "COM2": {}, "COM3": {}, "COM4": {}, "COM5": {},
"COM6": {}, "COM7": {}, "COM8": {}, "COM9": {},
"LPT1": {}, "LPT2": {}, "LPT3": {}, "LPT4": {}, "LPT5": {},
"LPT6": {}, "LPT7": {}, "LPT8": {}, "LPT9": {},
}
// 子文件夹名不能包含路径分隔符和文件名非法字符,也不能是系统保留名
func isBadSubdir(name string) bool {
if name == "" || name == "." || name == ".." {
return true
}
if strings.ContainsAny(name, `\/:*?"<>|`) {
return true
}
// Windows 下结尾的空格和点会被忽略,容易和别的名字撞车
if strings.HasSuffix(name, " ") || strings.HasSuffix(name, ".") {
return true
}
if _, bad := reservedNames[strings.ToUpper(name)]; bad {
return true
}
return false
}
// 从 streamers.crt 获取 streamer
func getStreamer(uid int) (streamer, bool) {
streamers.RLock()
defer streamers.RUnlock()
s, ok := streamers.crt[uid]
return s, ok
}
// 查看设置文件是否存在
func isConfigFileExist(filename string) bool {
fileLocation := filepath.Join(*configDir, filename)
info, err := os.Stat(fileLocation)
if os.IsNotExist(err) {
return false
}
checkErr(err)
if info.IsDir() {
lPrintErr(fileLocation + " 不能是目录")
os.Exit(1)
}
return true
}
// 读取 config.json,调用方需要自己处理 streamers 的锁
func loadConfig() {
if !isConfigFileExist(configFile) {
return
}
data, err := os.ReadFile(configFileLocation)
checkErr(err)
if json.Valid(data) {
// 老设置文件里没有 notifyEnabled 这个字段,默认按「开启」处理,
// 否则升级后已经配好的推送会被静默关掉。写在 Unmarshal 前面,
// 文件里显式写了 false 时仍会被覆盖成 false
config.NotifyEnabled = true
err = json.Unmarshal(data, &config)
checkErr(err)
} else {
lPrintErr("设置文件" + configFile + "的内容不符合 json 格式,请检查其内容")
}
}
// 命令行合成模式(-danmuburn)也读取设置文件,让 ffmpeg 路径、成片目录等配置生效
func loadConfigForDanmuburn() {
if *configDir == "" {
if exePath, err := os.Executable(); err == nil {
*configDir = filepath.Dir(exePath)
}
}
if *configDir == "" {
return
}
configFileLocation = filepath.Join(*configDir, configFile)
if !isConfigFileExist(configFile) {
return
}
loadConfig()
// 设置文件里没写到的字段保持默认值
if strings.TrimSpace(config.RecordSubdir) == "" {
config.RecordSubdir = defaultRecordSubdir
}
if strings.TrimSpace(config.MergeSubdir) == "" {
config.MergeSubdir = defaultMergeSubdir
}
}
// web UI 管理接口的访问口令长度(字节),编码成十六进制字符串
const authTokenBytes = 12
// 生成 web UI 管理接口的访问口令,首次启动时自动生成并写入设置文件
func ensureAuthToken() {
if strings.TrimSpace(currentAuthToken()) != "" {
return
}
buf := make([]byte, authTokenBytes)
if _, err := rand.Read(buf); err != nil {
lPrintErrf("生成 web UI 管理口令失败:%v", err)
return
}
token := hex.EncodeToString(buf)
streamers.Lock()
config.AuthToken = token
streamers.Unlock()
saveConfig()
lPrintln("已生成 web UI 管理口令(authToken):" + token)
lPrintln("打开 web UI 后需要输入该口令才能管理录制,请妥善保存;忘记时可以在 config.json 的 authToken 里查看")
}
// 读取当前的 web UI 管理口令
func currentAuthToken() string {
streamers.RLock()
defer streamers.RUnlock()
return config.AuthToken
}
// 检查 config.json 里的配置
func checkConfig() {
for _, u := range config.UIDs {
if u.UID <= 0 {
lPrintErr(configFile + "里的 uid 必须大于 0")
os.Exit(1)
}
}
if config.WebPort != 0 && (config.WebPort < 1024 || config.WebPort > 65535) {
lPrintErr(configFile + "里的 webPort 必须为 0(不启动 web UI)或者大于 1023 且少于 65536")
os.Exit(1)
}
if config.Directory != "" {
info, err := os.Stat(config.Directory)
checkErr(err)
if !info.IsDir() {
lPrintErrf("%s里的directory必须是存在的文件夹:%s", configFile, config.Directory)
os.Exit(1)
}
}
if config.MergeOutputDir != "" {
info, err := os.Stat(config.MergeOutputDir)
checkErr(err)
if !info.IsDir() {
lPrintErrf("%s里的mergeOutputDir必须是存在的文件夹:%s", configFile, config.MergeOutputDir)
os.Exit(1)
}
}
if config.MergeBitrate < 0 {
lPrintErrf("%s里的mergeBitrate不能小于 0", configFile)
os.Exit(1)
}
if config.DanmuFontSize < 0 {
lPrintErrf("%s里的danmuFontSize不能小于 0(0 表示跟随弹幕文件)", configFile)
os.Exit(1)
}
if config.DefaultBitrate < 0 {
lPrintErrf("%s里的defaultBitrate不能小于 0", configFile)
os.Exit(1)
}
if strings.TrimSpace(config.RecordSubdir) == "" {
config.RecordSubdir = defaultRecordSubdir
}
if strings.TrimSpace(config.MergeSubdir) == "" {
config.MergeSubdir = defaultMergeSubdir
}
if isBadSubdir(config.RecordSubdir) {
lPrintErrf("%s里的recordSubdir不能包含路径分隔符或非法字符:%s", configFile, config.RecordSubdir)
os.Exit(1)
}
if isBadSubdir(config.MergeSubdir) {
lPrintErrf("%s里的mergeSubdir不能包含路径分隔符或非法字符:%s", configFile, config.MergeSubdir)
os.Exit(1)
}
if config.RecordSubdir == config.MergeSubdir && config.MergeOutputDir == "" {
lPrintErrf("%s里的recordSubdir和mergeSubdir不能相同", configFile)
os.Exit(1)
}
// 录播格式写错不致命,回退到 mp4,免得整个程序起不来
config.RecordFormat = normalizeRecordFormat(config.RecordFormat)
// MeoW 这几项写错同样不致命:类型和高度回退到默认值,昵称不可用只警告
config.MeowNickname = strings.TrimSpace(config.MeowNickname)
config.MeowMsgType = normalizeMeowMsgType(config.MeowMsgType)
config.MeowImgURL = strings.TrimSpace(config.MeowImgURL)
if config.MeowHTMLHeight <= 0 {
config.MeowHTMLHeight = defaultMeowHTMLHeight
}
if msg := checkMeowNickname(config.MeowNickname, true); msg != "" {
lPrintWarnf("%s里的meowNickname不可用,不会推送 MeoW:%s", configFile, msg)
}
// ffmpeg 路径填错时不致命(可能只是还没打算录视频),清空后回退到自动查找
if msg := checkFFmpegPath(config.FFmpegPath); msg != "" {
lPrintWarnf("%s里的ffmpegPath不可用,将改为自动查找 ffmpeg:%s", configFile, msg)
config.FFmpegPath = ""
}
// 确保保存文件夹存在
for _, d := range []string{recordingDir(), recordSaveDir(), mergeSaveDir()} {
if err := os.MkdirAll(d, 0755); err != nil {
lPrintErrf("创建文件夹 %s 失败:%v", d, err)
os.Exit(1)
}
}
for _, u := range config.UIDs {
if u.Bitrate < 0 {
lPrintErrf("%s里的uid %d的bitrate不能小于 0", configFile, u.UID)
os.Exit(1)
}
}
}
// 清掉上次运行残留的合成临时文件,只在启动时调用一次。
// 不能放进 loadConfig:改设置(web UI 或直接改 config.json)会重新读取配置,
// 那样会把正在进行中的合并的临时文件(分块字幕、分块视频、先本地合成的成片)
// 一起删掉,合并当场失败。
func cleanLeftoverTempFiles() {
cleanTempFiles(recordSaveDir())
cleanTempFiles(mergeSaveDir())
}
// 根据 config.UIDs 构建 streamers.crt,调用方需要自己处理 streamers 的锁
func buildStreamers() {
news := make(map[int]streamer, len(config.UIDs))
for _, u := range config.UIDs {
if u.UID <= 0 {
continue
}
// 已有的主播保留名字,更新开关设置
if s, ok := streamers.crt[u.UID]; ok {
s.Danmu = u.Danmu
s.Record = u.Record
s.Bitrate = u.Bitrate
s.Qualities = u.Qualities
news[u.UID] = s
continue
}
name := getName(u.UID)
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, 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
}
// 记下某个主播当前的码率档位(离线时页面靠它显示下拉)。
// 不动用户选的码率:0 表示「自动、按实际档位取第二高」,只在真正取流时解析,
// 这样主播换了档位表也能跟着走,而不是把某个码率钉死。返回是否改动了配置。
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 s, ok := streamers.crt[uid]; ok {
s.Qualities = config.UIDs[i].Qualities
streamers.crt[uid] = s
}
break
}
streamers.Unlock()
return changed
}
// 按选定的码率决定实际用哪个档位:
// 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
}
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,写入串行化并原子替换,避免并发写坏文件或读到写了一半的内容
func saveConfig() {
configMu.Lock()
defer configMu.Unlock()
// 在锁内取快照,避免与并发的设置修改(如 removeUID)竞态,顺便去掉重复的 uid
streamers.Lock()
seen := make(map[int]struct{}, len(config.UIDs))
uids := make(uidList, 0, len(config.UIDs))
for _, u := range config.UIDs {
if _, ok := seen[u.UID]; ok {
continue
}
seen[u.UID] = struct{}{}
uids = append(uids, u)
}
config.UIDs = uids
snapshot := config
streamers.Unlock()
data, err := json.MarshalIndent(snapshot, "", " ")
if err != nil {
lPrintErrf("保存%s失败:%v", configFile, err)
return
}
// 先写临时文件再改名,读取方不会看到不完整的设置文件
tmp := configFileLocation + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
lPrintErrf("保存%s失败:%v", configFile, err)
return
}
if err := os.Rename(tmp, configFileLocation); err != nil {
lPrintErrf("保存%s失败:%v", configFile, err)
_ = os.Remove(tmp)
}
}
// 添加 uid 到录制列表并保存(弹幕和录播默认都开启),启动监听由设置文件监控触发
func addUID(uid int) bool {
return setStreamerFlags(uid, true, true)
}
// 将 uid 移出录制列表并保存,同时停止其正在进行的录制,停止监听由设置文件监控触发
func removeUID(uid int) bool {
streamers.Lock()
found := false
for i, u := range config.UIDs {
if u.UID == uid {
config.UIDs = append(config.UIDs[:i], config.UIDs[i+1:]...)
found = true
break
}
}
streamers.Unlock()
if !found {
lPrintWarnf("录制列表里没有 uid 为%d的主播", uid)
return true
}
saveConfig()
stopDanmu(uid)
stopRecord(uid)
if s, ok := getStreamer(uid); ok {
lPrintln("成功将" + s.longID() + "移出录制列表")
} else {
lPrintf("成功将 uid 为%d的主播移出录制列表", uid)
}
return true
}
// 只修改指定主播的录播开关,弹幕开关保持不变,uid 不在列表里时以弹幕关闭添加
func setRecordFlag(uid int, on bool) bool {
streamers.RLock()
var danmu bool
for _, u := range config.UIDs {
if u.UID == uid {
danmu = u.Danmu
break
}
}
streamers.RUnlock()
return setStreamerFlags(uid, danmu, on)
}
// 设置指定主播的弹幕和录播开关并保存;uid 不在列表里时先添加;运行中的录制立即启停
func setStreamerFlags(uid int, danmu, record bool) bool {
if uid <= 0 {
lPrintWarn("uid 必须大于 0")
return false
}
streamers.Lock()
found := false
for i, u := range config.UIDs {
if u.UID == uid {
config.UIDs[i].Danmu = danmu
config.UIDs[i].Record = record
found = true
break
}
}
if !found {
name := getName(uid)
if name == "" {
streamers.Unlock()
lPrintWarnf("不存在 uid 为%d的用户", uid)
return false
}
config.UIDs = append(config.UIDs, uidSetting{UID: uid, Danmu: danmu, Record: record, Bitrate: config.DefaultBitrate})
}
// 同步 streamers.crt 里的开关
if s, ok := streamers.crt[uid]; ok {
s.Danmu, s.Record = danmu, record
streamers.crt[uid] = s
} else {
streamers.crt[uid] = streamer{UID: uid, Name: getName(uid), Danmu: danmu, Record: record, Bitrate: config.DefaultBitrate}
}
streamers.Unlock()
saveConfig()
// 运行中的录制按新开关立即启停
if danmu {
startDanmu(uid)
} else {
stopDanmu(uid)
}
if record {
startRecord(uid)
} else {
stopRecord(uid)
}
return true
}
// 设置指定主播的录播码率上限(Kbps,0 为码率最高的直播源)并保存,正在录制时自动重启录制让新码率生效
func setBitrate(uid, bitrate int) bool {
if uid <= 0 || bitrate < 0 {
lPrintWarn("uid 必须大于 0,bitrate 不能小于 0")
return false
}
wasRecording := false
if infoList, ok := getLiveInfoByUID(uid); ok {
for _, info := range infoList {
if info.isRecording {
wasRecording = true
}
}
}
streamers.Lock()
found := false
for i, u := range config.UIDs {
if u.UID == uid {
config.UIDs[i].Bitrate = bitrate
found = true
break
}
}
if found {
if s, ok := streamers.crt[uid]; ok {
s.Bitrate = bitrate
streamers.crt[uid] = s
}
}
streamers.Unlock()
if !found {
lPrintWarnf("录制列表里没有 uid 为%d的主播", uid)
return false
}
saveConfig()
// 码率只对新分段生效,正在录制时重启录制
if wasRecording {
stopRecord(uid)
startRecord(uid)
lPrintf("已将 uid %d 的录播码率上限改为 %d Kbps 并重启录制", uid, bitrate)
}
return true
}
// 停止或重新开始指定主播的录制,target 为 danmu、record 或 all(空字符串表示全部),action 为 stop 或 restart
func controlStreamer(uid int, target, action string) error {
if uid <= 0 {
return fmt.Errorf("uid 必须大于 0")
}
restart := action == "restart"
if !restart && action != "stop" {
return fmt.Errorf("未知的操作:%s", action)
}
streamers.Lock()
s, ok := streamers.crt[uid]
if !ok {
for _, u := range config.UIDs {
if u.UID == uid {
s = streamer{UID: uid, Name: getName(uid), Danmu: u.Danmu, Record: u.Record}
ok = true
break
}
}
}
streamers.Unlock()
if !ok {
return fmt.Errorf("录制列表里没有 uid 为%d的主播", uid)
}
danmu, record := s.Danmu, s.Record
switch target {
case "danmu":
danmu = restart
case "record":
record = restart
default:
// 重新开始只作用于原本就开启的录制,不会把用户关掉的录制也打开;停止则全部停止
if !restart {
danmu, record = false, false
}
}
if restart && !danmu && !record {
lPrintWarnf("uid 为%d的主播没有开启任何录制,跳过重新开始", uid)
return nil
}
// 重新开始:先停止正在录的部分,才会产生新的分段
if restart {
if danmu {
stopDanmu(uid)
}
if record {
stopRecord(uid)
}
time.Sleep(2 * time.Second)
resetMergeState(uid, danmu, record)
}
if !setStreamerFlags(uid, danmu, record) {
return fmt.Errorf("设置 uid 为%d的主播失败", uid)
}
return nil
}
// 重新开始录制后重置自动合并用到的状态,让新的一对文件能被合并
func resetMergeState(uid int, danmu, record bool) {
infoList, ok := getLiveInfoByUID(uid)
if !ok {
return
}
for _, info := range infoList {
updateLiveInfo(info.LiveID, func(i *liveInfo) {
if danmu {
i.assFile = ""
i.danmuDone = false
i.danmuStopped = false
}
if record {
i.firstRecordFile = ""
i.recordStartNs = 0
i.recordSegments = nil
i.recordDone = false
i.recordStopped = false
}
i.mergeStarted = false
i.resumeUntil = 0
})
}
}
// 启动指定主播的循环
func startStreamerCycle(s streamer) {
start := controlMsg{s: s, c: startCycle}
sInfoMap.Lock()
if m, ok := sInfoMap.info[s.UID]; ok {
lPrintErr("sInfoMap不应该有%s的key", s.longID())
m.modify = true
} else {
sInfoMap.info[s.UID] = &streamerInfo{modify: true}
}
sInfoMap.Unlock()
mainCh <- start
}
// 停止指定主播的循环
func stopStreamerCycle(s streamer) {
stop := controlMsg{s: s, c: stopCycle}
sInfoMap.Lock()
if m, ok := sInfoMap.info[s.UID]; ok {
m.modify = true
m.ch <- stop
} else {
lPrintErr("sInfoMap没有%s的key", s.longID())
}
sInfoMap.Unlock()
}
// 监控 config.json 是否被修改,是的话重新设置
func cycleConfig(ctx context.Context) {
defer func() {
if err := recover(); err != nil {
lPrintErr("Recovering from panic in cycleConfig(), the error is:", err)
lPrintErr("监控设置文件" + configFile + "时出错,请重启本程序")
}
}()
lPrintln("开始监控设置文件" + configFile)
watcher, err := fsnotify.NewWatcher()
checkErr(err)
defer watcher.Close()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case event, ok := <-watcher.Events:
if !ok {
return
}
// 很多时候保存文件会分为数段写入,避免读取未完成写入的设置文件
time.Sleep(100 * time.Millisecond)
changed := isConfigFileEvent(event)
Outer:
for {
select {
case event, ok = <-watcher.Events:
if !ok {
return
}
if isConfigFileEvent(event) {
changed = true
}
time.Sleep(100 * time.Millisecond)
default:
break Outer
}
}
if changed {
lPrintln("设置文件" + configFile + "被修改,重新读取设置")
loadNewConfig()
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
lPrintErr("监控设置文件"+configFile+"时出现错误:", err)
}
}
}()
err = watcher.Add(*configDir)
checkErr(err)
wg.Wait()
lPrintln("停止监控设置文件" + configFile)
}
// 判断 fsnotify 事件是否作用在设置文件上(路径比较在 Windows 下大小写不敏感)
func isConfigFileEvent(event fsnotify.Event) bool {
if !strings.EqualFold(filepath.Clean(event.Name), filepath.Clean(configFileLocation)) {
return false
}
// 监听的是目录而不是文件本身,改名替换(原子保存)只产生 Create/Rename 事件
return event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) != 0
}
// 读取修改后的 config.json,对比新旧主播列表,启动和停止相应主播的循环
func loadNewConfig() {
var toStart []streamer
var toStop []streamer
streamers.Lock()
oldCrt := make(map[int]streamer, len(streamers.crt))
for uid, s := range streamers.crt {
oldCrt[uid] = s
}
loadConfig()
checkConfig()
buildStreamers()
var changedFlags []streamer
for uid, s := range streamers.crt {
old, ok := oldCrt[uid]
if !ok {
toStart = append(toStart, s)
continue
}
// 开关有变化的主播立即按新开关启停运行中的录制
if old.Danmu != s.Danmu || old.Record != s.Record {
changedFlags = append(changedFlags, s)
}
}
for uid, s := range oldCrt {
if _, ok := streamers.crt[uid]; !ok {
toStop = append(toStop, s)
}
}
streamers.Unlock()
// 必须在释放 streamers 锁之后调用:ensureAuthToken 内部要重新加锁,互斥锁不可重入会死锁
// 设置文件里的 authToken 被清空时重新生成,否则所有管理接口都会因为口令为空而拒绝访问
ensureAuthToken()
for _, s := range toStart {
lPrintln("新增" + s.longID() + "的录制设置")
startStreamerCycle(s)
}
for _, s := range toStop {
lPrintln(s.longID() + "的录制设置被删除")
stopStreamerCycle(s)
stopDanmu(s.UID)
stopRecord(s.UID)
}
for _, s := range changedFlags {
if s.Danmu {
startDanmu(s.UID)
} else {
stopDanmu(s.UID)
}
if s.Record {
startRecord(s.UID)
} else {
stopRecord(s.UID)
}
}
}
// 移动弹幕录制产出的三个文件(原格式 ASS、昵称头像版 ASS、jsonl)
func (s *streamer) moveDanmuFiles(assFile string) {
s.moveFile(assFile)
nickFile, jsonlFile := nickJsonlFile(assFile)
s.moveFile(nickFile)
s.moveFile(jsonlFile)
}
// 移动文件
func (s *streamer) moveFile(oldFile string) {
if oldFile == "" {
return
}
// 录播和弹幕文件统一放到保存目录下的子文件夹里
directory := recordSaveDir()
if err := os.MkdirAll(directory, 0755); err != nil {
lPrintErrf("创建文件夹 %s 失败:%v", directory, err)
return
}
newFile := filepath.Join(directory, filepath.Base(oldFile))
if newFile == oldFile {
return
}
_, err := os.Stat(oldFile)
if os.IsNotExist(err) {
lPrintErrf("文件 %s 不存在", oldFile)
return
}
checkErr(err)
// https://github.com/cloudfoundry/bosh-utils/blob/master/fileutil/mover.go
err = os.Rename(oldFile, newFile)
if err != nil {
le, ok := err.(*os.LinkError)
if !ok {
lPrintErrf("将文件 %s 移动到 %s 失败:%v", oldFile, newFile, err)
return
}
if le.Err == syscall.EXDEV || (runtime.GOOS == "windows" && le.Err == syscall.Errno(0x11)) {
inputFile, err := os.Open(oldFile)
checkErr(err)
defer inputFile.Close()
outputFile, err := os.Create(newFile)
checkErr(err)
defer outputFile.Close()
_, err = io.Copy(outputFile, inputFile)
checkErr(err)
_ = inputFile.Close()
err = os.Remove(oldFile)
checkErr(err)
} else {
lPrintErrf("将文件 %s 移动到 %s 失败:%+v", oldFile, newFile, le)
return
}
}
lPrintf("成功将文件 %s 移动到 %s", oldFile, newFile)
}