Files
acfunlive-remake/utils.go
T
tamakyi 8e82aa54c6 修第二批 P1:合并收尾、分块上限、确定性失败与 ffmpeg 路径归一
一、合并收尾只删真正进了成片的分段
   prepareForMerge 中途有 4 处会回退成「只合并第一个分段」,而收尾仍按原分段列表
   全删源视频 —— 后半场没进成片却照样被删掉,等于永久丢数据。现在 prepareForMerge
   把「实际参与合并的源文件」一并返回(新增 used),收尾只删这些。

二、拼接中断不再在录播目录留几 GB 中间文件
   concat 失败分支原来只有「被停止」那条路径删了 acfunlive_chunk_joined.*,
   普通失败会把它留在录播目录,而且带着 chunkTempPrefix,界面文件列表里根本看不见。

三、把「被停止」和「拼接失败」分开
   停止任务会杀掉 ffmpeg 让它非 0 退出,原来一律报成「拼接失败」,
   卡片上显示的是失败而不是已停止;prepareForMerge 那里也统一改用 errors.Is 判断。

四、单块弹幕条数加硬上限(maxDanmuPerChunkHard = 200)
   块尾会把「显示时间跨过块尾」的弹幕一并并入,弹幕密集时单块能膨胀到几百条。
   一条弹幕在 -filter_complex 里约占 110 字节,几百条足以顶破 Windows 的
   32767 字符命令行上限,表现同样是「合并失败且看不出原因」。到上限就把块尾
   收到下一条弹幕出现之前,保证被挤到下一块的弹幕不会因为「整条早于块头」被丢弃。

五、确定性失败不再白重试三次
   头像超出体积/尺寸上限这类失败重试多少次结果都一样,原来会白白把十几 MB 重新下三遍。
   新增 noRetryError(runThrice 用 errors.As 识别)只标记这两个上限,网络类错误照旧重试。

六、ffmpeg 路径统一归一成绝对路径
   PATH 里带「.」这类相对条目、或程序目录探测失败时,getFFmpeg 会返回相对路径。
   合并时 ffmpeg 工作目录被改成输出目录,相对路径到那边就找不着了;而且 Windows 上
   exec 会直接拒绝执行「相对当前目录」找到的可执行文件,报错还跟路径完全看不出关系。

验证:
- gofmt -l 空;go vet ./... 空;go build ./... 与 GOOS=linux go build 通过
- go test ./... 全绿(10.8s);go test -race ./... 全绿(14.7s)
- 新增 chunks_test.go 10 个用例;6 项修复逐条反向验证(临时退回修复 → 对应测试确实失败)
- 51897 隔离实例上新产物实测:e2e.py 24 项全过;smoke_extra.py 34 项全过
  (含并发合并返回 409 并报出在跑的任务名、口令失败后正确口令仍立刻可用、
  超大头像被拒不误伤正常头像且只尝试 1 次、停止后无临时文件残留)
2026-09-26 09:35:51 +08:00

391 lines
11 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"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"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 // 自动合并是否已经启动
// 弹幕文件在写盘时出过错(磁盘满、网络盘断开等)。文件是不完整的,
// 拿它合并会得到一个缺弹幕的成片,所以本场跳过自动合并
danmuWriteFailed 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()
}()
}
// 不需要重试的错误:再试一遍也只是重复同样的失败(比如头像本身就超出体积/尺寸上限),
// 硬重试只会白白把几 MB 的响应重新下一遍
type noRetryError struct{ err error }
func (e noRetryError) Error() string { return e.err.Error() }
func (e noRetryError) Unwrap() error { return e.err }
// 把错误标记成「重试也没用」
func noRetry(err error) error {
if err == nil {
return nil
}
return noRetryError{err}
}
// 尝试运行,三次出错后结束运行
func runThrice(f func() error) error {
var err error
for retry := 0; retry < 3; retry++ {
if err = f(); err != nil {
var nr noRetryError
if errors.As(err, &nr) {
return err
}
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
}
// 跨盘搬运成片时的临时后缀:拷一半失败也能一眼看出不是成品
const partSuffix = ".acfunlive.part"
// 合成时的临时成片放在弹幕文件所在目录(本地),
// 完成后再落到成片目录 —— 成片目录可能是网络盘或外接盘,直接往里写、
// 写到一半失败会在那里留下一个看起来正常的半成品。文件名沿用临时前缀,
// 所以不会出现在文件管理的列表里,启动时也会清理残留。
func tempMergeOutput(danmuFile, outBase string) string {
return filepath.Join(filepath.Dir(danmuFile), chunkTempPrefix+"merging_"+outBase)
}
// 把合成好的本地临时文件落到成片位置:同盘直接改名(瞬时),跨盘退回拷贝
func placeOutput(tmp, out string) error {
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
return fmt.Errorf("创建成片文件夹失败:%v", err)
}
if err := os.Rename(tmp, out); err == nil {
return nil
}
// 跨盘时 os.Rename 会失败(Windows 上尤其明确),退回拷贝:
// 先拷成 .part 再改名,中途失败也不会在成片目录留下像成品的文件
part := out + partSuffix
total := fileSizeOrZero(tmp)
if err := copyFileWith(tmp, part, func(done int64) {
if total > 0 {
jobSetPhasePercent(float64(done) / float64(total))
}
}); err != nil {
_ = os.Remove(part)
return fmt.Errorf("拷贝到成片目录 %s 失败:%v", filepath.Dir(out), err)
}
if err := os.Rename(part, out); err != nil {
_ = os.Remove(part)
return fmt.Errorf("成片改名失败:%v", err)
}
_ = os.Remove(tmp)
return nil
}
// 拷贝文件,跨盘搬运成片时用
func copyFile(src, dst string) error {
return copyFileWith(src, dst, nil)
}
// 拷贝文件。onBytes 不为空时按累计已拷贝字节回调,跨盘搬运成片时用来报进度
func copyFileWith(src, dst string, onBytes func(int64)) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
buf := make([]byte, 1<<20)
var done int64
for {
n, rerr := in.Read(buf)
if n > 0 {
if _, werr := out.Write(buf[:n]); werr != nil {
_ = out.Close()
return werr
}
done += int64(n)
if onBytes != nil {
onBytes(done)
}
}
if rerr == io.EOF {
break
}
if rerr != nil {
_ = out.Close()
return rerr
}
}
return out.Close()
}
// 清掉上次运行残留的合成临时文件。正常结束时都会删掉,这里是兜底:
// 程序在合成中途被强杀时,临时文件会留在录播目录或成片目录里。
// 注意强杀不会连带杀掉 ffmpeg 子进程,它可能还在写这个文件(Windows 上就删不掉),
// 所以删不掉时只提示一句,下次启动会再清一次。
func cleanTempFiles(dir string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasPrefix(name, chunkTempPrefix) && !strings.HasSuffix(name, partSuffix) {
continue
}
path := filepath.Join(dir, name)
if err := os.Remove(path); err == nil {
lPrintln("清理上次留下的临时文件:" + name)
} else {
lPrintWarnf("临时文件 %s 现在删不掉(多半是上次强杀程序后残留的 ffmpeg 还在写),下次启动会自动清理:%v", path, err)
}
}
}