Files
acfunlive-remake/config.go
T
tamakyi eda83dd1e3 主播列表的码率改为直播实际档位下拉,没选过时默认第二档
原来是一个手填的码率上限(Kbps),现在改成直播的实际档位(蓝光 8M / 高清…):

- 读到档位后,主播那一行渲染成下拉,选完立即生效;正在录制时换档会自动重录。
- 没手动选过(bitrate 0)的主播按**自上往下第二档**定下来(如
  蓝光 8M / 蓝光 4M / 高清 就选蓝光 4M,只有一档就用那一档),不会一上来就吃满
  最高档;已经手动选过的永远不动。
- 档位要开播后读直播源列表才知道,读到以后(录制开始时、或页面点「档位」按钮)
  缓存进 config.json 的 qualities,所以没开播也能看和改;启动时会把「有缓存但还
  没定过码率」的主播按第二档补齐。
- 从来没读过档位的行仍然是手填输入框,旁边多了个「档位」按钮去直播间接一次。

后端:新增 `GET /api/streamer/qualities?uid=…`(同一主播 10 秒内只读一次),
`/api/status` 的每个主播多了 `qualities`(按码率从高到低)和 `qualityName`;
抽出 `newStreamInfo()`/`fetchStreamQualities()`,`getStreamURL()` 取流时顺手缓存
档位,并在 bitrate 为 0 时改用刚定下来的默认档位(否则还是最高档)。
档位相关的纯逻辑放在 config.go:`sortQualitiesDesc` / `defaultQualityBitrate` /
`qualityNameFor` / `sameQualities` / `setStreamerQualities` / `applyCachedDefaultQualities`。

测试:qualities_test.go 7 个用例(排序、默认第二档的 5 种档位数、档位名匹配、
写配置时不覆盖手动选择、启动补齐、JSON 存取往返);浏览器实测 5 组断言全过
(下拉选中当前档位、换档服务端立即变、无档位时保留输入框+按钮、启动默认第二档、
手填仍可用),verify_streamer_br.py / verify_pick.py / verify_settings.py 无回归;
go test ./...、gofmt、go vet 干净。
2026-09-25 19:42:47 +08:00

1052 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
}
// 记下某个主播当前的码率档位(离线时页面靠它显示下拉)。
// 顺手把「还没设过码率」的主播按默认档位(第二高)定下来 —— 这样默认不会一直吃满最高档。
// 返回是否改动了配置,调用方决定要不要 saveConfig。
func setStreamerQualities(uid int, tiers []qualityTier) bool {
if uid <= 0 || len(tiers) == 0 {
return false
}
changed := false
streamers.Lock()
for i := range config.UIDs {
if config.UIDs[i].UID != uid {
continue
}
if !sameQualities(config.UIDs[i].Qualities, tiers) {
config.UIDs[i].Qualities = tiers
changed = true
}
if config.UIDs[i].Bitrate == 0 {
if def := defaultQualityBitrate(tiers); def > 0 {
config.UIDs[i].Bitrate = def
changed = true
lPrintf("uid %d 还没设过下载码率,按当前直播档位默认取第二档:%s(%d Kbps)",
uid, qualityNameFor(tiers, def), def)
}
}
if s, ok := streamers.crt[uid]; ok {
s.Qualities = config.UIDs[i].Qualities
// 档位定下来后正在录的那场不打断,配置值从下次录制开始生效
s.Bitrate = config.UIDs[i].Bitrate
streamers.crt[uid] = s
}
break
}
streamers.Unlock()
return changed
}
// 启动时把「缓存过档位、但还没定过码率」的主播按默认档位定下来
func applyCachedDefaultQualities() bool {
changed := false
for _, u := range append(uidList(nil), config.UIDs...) {
if u.Bitrate == 0 && len(u.Qualities) > 0 {
if setStreamerQualities(u.UID, u.Qualities) {
changed = true
}
}
}
return changed
}
// 保存 config.json,写入串行化并原子替换,避免并发写坏文件或读到写了一半的内容
func saveConfig() {
configMu.Lock()
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)
}