Files
acfunlive-remake/record.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

365 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"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/orzogc/acfundanmu"
)
// 查找 ffmpeg:优先设置里指定的路径,其次 PATH,最后程序所在目录及其常见子目录
func getFFmpeg() string {
name := "ffmpeg"
if runtime.GOOS == "windows" {
name = "ffmpeg.exe"
}
// 设置里显式指定的路径优先,可以填可执行文件本身或它所在的文件夹
if p := strings.TrimSpace(config.FFmpegPath); p != "" {
// 相对路径先落在程序所在目录上。合并时 ffmpeg 的工作目录会被改成输出目录,
// 留着相对路径的话它会去那里找,报一个跟真实原因完全对不上的「找不到文件」
if !filepath.IsAbs(p) {
cand := p
if joined := filepath.Join(exeDir, p); filepath.IsAbs(joined) {
if _, err := os.Stat(joined); err == nil {
cand = joined
}
}
if abs, err := filepath.Abs(cand); err == nil {
p = abs
}
}
if info, err := os.Stat(p); err == nil && info.IsDir() {
p = filepath.Join(p, name)
}
if info, err := os.Stat(p); err == nil && !info.IsDir() {
return p
}
}
if p, err := exec.LookPath("ffmpeg"); err == nil {
return absExe(p)
}
for _, dir := range []string{
exeDir,
filepath.Join(exeDir, "bin"),
filepath.Join(exeDir, "ffmpeg"),
filepath.Join(exeDir, "ffmpeg", "bin"),
} {
p := filepath.Join(dir, name)
if info, err := os.Stat(p); err == nil && !info.IsDir() {
return absExe(p)
}
}
return ""
}
// 转成绝对路径再交给 exec:合并时 ffmpeg 的工作目录会被改成输出目录,
// 相对路径到那边就找不着了;而且 Windows 上 exec 会直接拒绝执行
// 「相对当前目录」找到的可执行文件(报错信息完全看不出跟路径有关)
func absExe(p string) string {
if p == "" || filepath.IsAbs(p) {
return p
}
if abs, err := filepath.Abs(p); err == nil {
return abs
}
return p
}
// 检查设置里指定的 ffmpeg 路径,返回错误信息,空字符串表示可用(或没填)
func checkFFmpegPath(path string) string {
p := strings.TrimSpace(path)
if p == "" {
return ""
}
info, err := os.Stat(p)
if err != nil {
return "ffmpeg 路径不存在:" + p
}
if info.IsDir() {
name := "ffmpeg"
if runtime.GOOS == "windows" {
name = "ffmpeg.exe"
}
if _, err := os.Stat(filepath.Join(p, name)); err != nil {
return "文件夹里没有找到 " + name + ":" + p
}
return ""
}
return ""
}
// 连到该主播的直播间并取直播流信息(获取直播源和探测码率档位都用它)
func newStreamInfo(uid int) (*acfundanmu.StreamInfo, error) {
var ac *acfundanmu.AcFunLive
var err error
err = runThrice(func() error {
ac, err = acfundanmu.NewAcFunLive(acfundanmu.SetLiverUID(int64(uid)))
return err
})
if err != nil {
return nil, err
}
return ac.GetStreamInfo(), nil
}
// 把直播源列表转成码率档位
func streamTiers(list []acfundanmu.StreamURL) []qualityTier {
tiers := make([]qualityTier, 0, len(list))
for _, st := range list {
tiers = append(tiers, qualityTier{Name: st.QualityName, Bitrate: st.Bitrate})
}
return tiers
}
// 取该主播当前的码率档位(没开播或拿不到直播源时返回错误)
func fetchStreamQualities(uid int) ([]qualityTier, error) {
info, err := newStreamInfo(uid)
if err != nil {
return nil, err
}
if info == nil || len(info.StreamList) == 0 {
return nil, fmt.Errorf("uid 为%d的主播现在没有可用的直播源(没开播?)", uid)
}
return streamTiers(info.StreamList), nil
}
// 获取 uid 的直播源(flv)。pref/custom 是页面上选的档位偏好(最高/第二高/最低/自定义),
// 按这次拿到的实际档位对应上去;拿不到档位表时退回最高档
func getStreamURL(uid int, pref string, custom int) (url string, quality string, err error) {
sInfo, err := newStreamInfo(uid)
if err != nil {
return "", "", err
}
if sInfo == nil || len(sInfo.StreamList) == 0 {
return "", "", fmt.Errorf("uid 为%d的主播没有可用的直播源", uid)
}
// 顺手把码率档位记下来:页面上要用它显示「这一档现在对应哪个档位名」,没开播时靠缓存显示
var tiers []qualityTier
if tiers = streamTiers(sInfo.StreamList); len(tiers) > 0 {
if setStreamerQualities(uid, tiers) {
saveConfig()
}
}
want := resolveQualityBitrate(tiers, pref, custom)
// StreamList 按码率升序排列,从高往低找选中的那一档
st := sInfo.StreamList[len(sInfo.StreamList)-1]
if want > 0 {
st = sInfo.StreamList[0]
for i := len(sInfo.StreamList) - 1; i >= 0; i-- {
if sInfo.StreamList[i].Bitrate <= want {
st = sInfo.StreamList[i]
break
}
}
}
return st.URL, st.QualityName, nil
}
// 临时录制指定主播的直播视频
func startRecord(uid int) bool {
if getFFmpeg() == "" {
lPrintErr("没有找到 ffmpeg,无法录制直播视频,请安装 ffmpeg 并加入 PATH,或把 ffmpeg 放在本程序所在文件夹")
return false
}
s, ok := getStreamer(uid)
if !ok {
name := getName(uid)
if name == "" {
lPrintWarnf("不存在 uid 为%d的用户", uid)
return false
}
s = streamer{UID: uid, Name: name}
}
liveID := getLiveID(uid)
if liveID == "" {
lPrintErr(s.longID() + "不在直播,取消录制直播视频")
return false
}
if info, ok := getLiveInfo(liveID); ok && info.isRecording {
lPrintWarnf("已经在录制%s的直播视频,如要重启录制,请先运行 stoprecord %d", s.longID(), uid)
return false
}
filename := getTime() + " " + s.Name + " " + s.getTitle()
safeGo(s.longID()+"的视频录制", func() { s.initRecord(mainCtx, liveID, filename) })
return true
}
// 停止录制指定主播的直播视频
func stopRecord(uid int) bool {
infoList, ok := getLiveInfoByUID(uid)
if !ok {
lPrintWarnf("没有在录制 uid 为%d的主播的直播视频", uid)
return true
}
for _, info := range infoList {
if info.isRecording {
lPrintf("开始停止录制%s的liveID为%s的直播视频", longID(uid), info.LiveID)
updateLiveInfo(info.LiveID, func(i *liveInfo) { i.recordStopped = true })
// 先结束整个录制循环,否则单段中断会被当成意外中断而重启录制
if info.recordStop != nil {
info.recordStop()
}
if info.recordCancel != nil {
info.recordCancel()
}
}
}
return true
}
// 退出直播视频录制相关操作
func quitRecord(liveID string) {
updateLiveInfo(liveID, func(i *liveInfo) { i.isRecording = false })
}
// 初始化直播视频录制
func (s streamer) initRecord(ctx context.Context, liveID, filename string) {
defer func() {
if err := recover(); err != nil {
lPrintErr("Recovering from panic in initRecord(), the error is:", err)
lPrintErrf("录制%s的直播视频发生错误,如要重启录制,请运行 startrecord %d", s.longID(), s.UID)
}
}()
if info, ok := getLiveInfo(liveID); ok && info.isRecording {
lPrintWarnf("已经在录制%s的直播视频,如要重启录制,请先运行 stoprecord %d", s.longID(), s.UID)
return
}
dctx, dcancel := context.WithCancel(ctx)
defer dcancel()
updateLiveInfo(liveID, func(i *liveInfo) {
i.recordStop = dcancel
i.recordStarted = true
})
lPrintln("开始录制" + s.longID() + "的直播视频")
lPrintf("如果想提前结束录制%s的直播视频,运行 stoprecord %d", s.longID(), s.UID)
s.recordLive(dctx, liveID, filename)
}
// 断流重连时取不到直播源的重试次数上限,避免直播源一直不可用时无限重试
const maxResumeRetry = 3
// 录制主播的直播视频,意外中断且主播还在播时重启录制新的分段
func (s streamer) recordLive(ctx context.Context, liveID, filename string) {
defer func() {
if err := recover(); err != nil {
lPrintErr("Recovering from panic in recordLive(), the error is:", err)
lPrintErrf("下载%s的直播视频发生错误,如要重启下载,请运行 startrecord %d", s.longID(), s.UID)
}
// 录制结束(含出错),检查是否满足自动合并条件
finishRecordPart(liveID)
}()
ffmpegFile := getFFmpeg()
if ffmpegFile == "" {
lPrintErr("没有找到 ffmpeg,无法录制直播视频")
return
}
var resumeFails int // 连续取不到直播源的次数
for {
url, quality, err := getStreamURL(s.UID, s.QualityPref, s.Bitrate)
if err != nil {
// 断流重连时直播源往往要过几秒才回来,窗口内多试几次,别把一场直播切成两段
if resumeFails < maxResumeRetry && ctx.Err() == nil && s.waitResume(ctx) {
resumeFails++
lPrintWarnf("暂时取不到%s的直播源,稍后重试(%d/%d):%v", s.longID(), resumeFails, maxResumeRetry, err)
continue
}
lPrintErrf("无法获取%s的直播源,退出录制直播视频:%v", s.longID(), err)
return
}
resumeFails = 0
base := transFilename(filename)
if base == "" {
return
}
recordFile := base + recordFileExt()
// -c copy 直接封装原始流,不转码,分辨率就是直播源的实际分辨率
rctx, cancel := context.WithCancel(ctx)
cmd := exec.CommandContext(rctx, ffmpegFile,
"-y", "-rw_timeout", "20000000", "-timeout", "20000000",
"-i", url, "-c", "copy", recordFile)
stdin, err := cmd.StdinPipe()
if err != nil {
cancel()
lPrintErrf("启动 ffmpeg 失败:%v", err)
return
}
// 上下文取消时向 ffmpeg 发 q 让它正常收尾(写出索引、把缓冲刷盘),超过 WaitDelay 才强杀。
// 默认的取消行为是直接杀进程,缓冲区里的数据会丢,mp4 还会因为缺少 moov 而整个报废。
cmd.Cancel = func() error {
_, werr := io.WriteString(stdin, "q")
return werr
}
cmd.WaitDelay = 10 * time.Second
startNs := time.Now().UnixNano()
updateLiveInfo(liveID, func(i *liveInfo) {
i.uid = s.UID
i.isRecording = true
i.recordCancel = cancel
i.ffmpegStdin = stdin
i.recordFile = recordFile
// 每一段都记下来:直播流中断后重连会产生新分段,合并时要把它们拼起来
i.recordSegments = append(i.recordSegments, recordSegment{file: recordFile, startNs: startNs})
if i.firstRecordFile == "" {
i.firstRecordFile = recordFile
i.recordStartNs = startNs
}
})
lPrintln("本次录制的视频保存在" + recordFile + "(码率档位:" + quality + ")")
// 标记成正在写入,避免在 web UI 的文件管理里把它删掉
markFileActive(recordFile, true)
runErr := cmd.Run()
markFileActive(recordFile, false)
// 正常情况下上面设置的 Cancel 已经让 ffmpeg 收到 q 并正常退出,这里只做资源清理
_ = stdin.Close()
cancel()
updateLiveInfo(liveID, func(i *liveInfo) { i.isRecording = false })
s.moveFile(recordFile)
// 用户主动停止或程序退出
if ctx.Err() != nil {
lPrintln(s.longID() + "的直播视频录制已经结束")
return
}
if runErr != nil {
lPrintWarnf("下载%s的直播视频出现错误:%v", s.longID(), runErr)
}
// 断流后先观察一个窗口:网络波动或主播短暂掉线(20 秒内)恢复时接着录同一场,
// 这样视频和弹幕仍然是同一对文件,最终只会合成一个成片
markResumeWait(liveID)
resumed := s.waitResume(ctx)
clearResumeWait(liveID)
if !resumed {
lPrintln(s.longID() + "的直播视频录制已经结束")
return
}
lPrintWarn("因意外结束录制" + s.longID() + "的直播视频,尝试重启录制")
// 新分段用新文件名,避免覆盖上一段;它和第一段一起参与后面的拼接
filename = getTime() + " " + s.Name + " " + s.getTitle()
}
}