Files
acfunlive-remake/utils.go
T
2026-09-25 13:16:54 +08:00

260 lines
7.3 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"
"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
}