// 部分数据和函数定义 package main import ( "context" "fmt" "io" "log" "os" "strconv" "sync" "time" "github.com/orzogc/acfundanmu" ) type control int // 控制信息 const ( startCycle control = iota stopCycle quit ) // 主播的管道信息 type controlMsg struct { s streamer c control liveID string } // 主播信息 type streamerInfo struct { ch chan controlMsg // 控制信息 modify bool } // 一段录播视频:直播流意外中断后会重新开一段 type recordSegment struct { file string // 视频文件路径 startNs int64 // 这一段开始录制的时刻,纳秒 Unix } // 直播信息 type liveInfo struct { LiveID string // 直播 ID uid int // 主播的 uid isDanmu bool // 是否正在录制直播弹幕 danmuCancel context.CancelFunc // 用来停止录制弹幕 assFile string // 弹幕文件路径 cfg acfundanmu.SubConfig isRecording bool // 是否正在录制直播视频 recordCancel context.CancelFunc // 用来停止录制视频(中断当前分段) recordStop context.CancelFunc // 用来结束整个录制循环,避免停止后又被当成意外中断而重启 ffmpegStdin io.WriteCloser // ffmpeg 的 stdin recordFile string // 视频文件路径 firstRecordFile string // 第一个分段的视频文件路径(与弹幕文件同名配对,用于自动合并) recordStartNs int64 // 第一个分段开始录制的时刻,纳秒 Unix(用于计算弹幕与录播的时间偏移) recordSegments []recordSegment // 本场录播的所有分段(中断重连会产生新的分段),合并时全部参与 danmuDone bool // 弹幕录制是否已经结束 recordDone bool // 视频录制是否已经结束 danmuStarted bool // 本场是否启动过弹幕录制 recordStarted bool // 本场是否启动过视频录制 danmuStopped bool // 弹幕是否被用户手动停止(手动停止不触发自动合并) recordStopped bool // 视频是否被用户手动停止 mergeStarted bool // 自动合并是否已经启动 doneAt int64 // 最近一次录制结束的时刻,纳秒 Unix(用于延迟清理这条记录) resumeUntil int64 // 断流后等待直播恢复的截止时刻,纳秒 Unix;0 表示没在等待 } // streamerInfo 的 map var sInfoMap struct { sync.Mutex info map[int]*streamerInfo } // liveInfo 的 map var lInfoMap struct { sync.RWMutex info map[string]liveInfo } // 设备 ID var deviceID string var ( exeDir string // 运行程序所在文件夹 mainCh chan controlMsg // main() 的管道 mainCtx context.Context // main() 的 ctx configDir *string // 设置文件所在文件夹 recordDir *string // 弹幕文件保存的文件夹 logger = log.New(os.Stdout, "", log.LstdFlags) // 可以同步输出的 logger itoa = strconv.Itoa // 将 int 转换为字符串 ) // 检查错误 func checkErr(err error) { if err != nil { panic(err) } } // 后台任务的统一入口:兜底 recover,避免单个任务的 panic 终止整个进程 // name 用于日志里指出是哪个任务出错,f 里可以继续做自己的 recover func safeGo(name string, f func()) { go func() { defer func() { if err := recover(); err != nil { lPrintErrf("%s 发生错误并已恢复,其它录制任务不受影响:%v", name, err) } }() f() }() } // 尝试运行,三次出错后结束运行 func runThrice(f func() error) error { var err error for retry := 0; retry < 3; retry++ { if err = f(); err != nil { log.Printf("%v", err) } else { return nil } time.Sleep(2 * time.Second) } return fmt.Errorf("运行三次都出现错误:%v", err) } // 获取时间 func getTime() string { t := time.Now() return fmt.Sprintf("%d-%02d-%02d %02d-%02d-%02d", t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second()) } // 打印带时间戳的 log 信息 // lPrintln 定义在 webui.go 里,同时输出到控制台和 web UI 的日志 // 获取时间,按照 log 的时间格式 func getLogTime() string { t := time.Now() return fmt.Sprintf("%d/%02d/%02d %02d:%02d:%02d", t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second()) } // 打印警告信息 func lPrintWarn(msg ...any) { w := []any{"[WARN]"} msg = append(w, msg...) lPrintln(msg...) } // 打印错误信息 func lPrintErr(msg ...any) { e := []any{"[ERROR]"} msg = append(e, msg...) lPrintln(msg...) } // 打印带时间戳的 log 信息(格式化字符串) func lPrintf(format string, a ...any) { lPrintln(fmt.Sprintf(format, a...)) } // 打印警告信息(格式化字符串) func lPrintWarnf(format string, a ...any) { lPrintWarn(fmt.Sprintf(format, a...)) } // 打印错误信息(格式化字符串) func lPrintErrf(format string, a ...any) { lPrintErr(fmt.Sprintf(format, a...)) } // 返回 ID(UID)形式的字符串 func (s *streamer) longID() string { return fmt.Sprintf("%s(%d)", s.Name, s.UID) } // 日志里用的主播名:只使用内存里已经有的数据,取不到就不发网络请求。 // getName 缓存未命中时会重试 3 次 HTTP(最长 30 多秒),放在日志里会把界面卡住。 func nameForLog(uid int) string { if s, ok := getStreamer(uid); ok && s.Name != "" { return s.Name } liveRooms.RLock() defer liveRooms.RUnlock() if room, ok := liveRooms.rooms[uid]; ok { return room.name } return "" } // 返回 ID(UID)形式的字符串,用于日志,取不到名字时只打 uid func longID(uid int) string { name := nameForLog(uid) if name == "" { return itoa(uid) } return fmt.Sprintf("%s(%d)", name, uid) } // 根据 uid 获取 liveInfo func getLiveInfoByUID(uid int) (infoList []liveInfo, ok bool) { lInfoMap.RLock() defer lInfoMap.RUnlock() for _, info := range lInfoMap.info { if info.uid == uid { infoList = append(infoList, info) ok = true } } return infoList, ok } // 根据 liveID 获取 liveInfo func getLiveInfo(liveID string) (liveInfo, bool) { lInfoMap.RLock() defer lInfoMap.RUnlock() if info, ok := lInfoMap.info[liveID]; ok { return info, true } return liveInfo{}, false } // 将 info 放进 lInfoMap 里 func setLiveInfo(info liveInfo) { lInfoMap.Lock() defer lInfoMap.Unlock() lInfoMap.info[info.LiveID] = info } // 原地更新 liveInfo 的部分字段,不覆盖其他字段(弹幕和视频录制共用同一个 liveInfo) func updateLiveInfo(liveID string, f func(*liveInfo)) { lInfoMap.Lock() defer lInfoMap.Unlock() info, ok := lInfoMap.info[liveID] if !ok { info = liveInfo{LiveID: liveID} } f(&info) lInfoMap.info[liveID] = info } // 根据 liveID 查询是否正在录制直播弹幕 func isDanmu(liveID string) bool { if info, ok := getLiveInfo(liveID); ok { return info.isDanmu } return false } // 根据 liveID 查询是否正在录制直播视频 func isRecording(liveID string) bool { if info, ok := getLiveInfo(liveID); ok { return info.isRecording } return false }