Files
acfunlive-remake/record.go
T
tamakyi 348e3bd675 档位改成「自动·第二高 + 实际档位」的下拉;手动合并也推送;头像缓存挪到程序目录
三件事:

1) 主播列表的录制档位
- 一律是下拉,不再有手填输入框。第一项「自动 · 第二高」(值 0)= 按这次读到的
  档位排序自上往下取第二高(只有一档就用它,读不到档位表才退回最高档);
  后面依次列出实际档位。选最高/最低/中间某一档直接点它,手动选过之后
  「自动」仍留在列表里,随时能改回去。
- 语义变化:0 不再被写死成某个具体码率。原来记录档位时会把「没选过」的主播
  按第二档定下来(写进 config.json),现在只在取流时解析,所以主播换了档位表
  也能跟着走;app 启动时的补齐逻辑(applyCachedDefaultQualities)随之去掉。
- 解析规则统一到 resolveQualityBitrate:0 = 第二高,>0 = 不超过它的最高档,
  都比它高时退回最低档;「自动」项会把解析到的档位名带出来
  (自动 · 第二高(当前 = 蓝光 4M))。/api/status 和档位接口都多了 qualityBitrate。
- 点「档位」按钮时先给一句「正在读取码率档位…」:主播没开播时服务端要重试三次
  才放弃,约 5 秒,之前这段时间界面没有任何反应。

2) 手动合并完成也推送(这就是「MeoW 只在测试时成功」的原因)
- 原来只有自动合并调 sendNotify,手动合并从头到尾没推过,所以事件开着、渠道也通
  却一直收不到。现在手动合并完成后同样按 mergeDone 事件推送两个渠道。
- 手动合并没有 uid,会拿成片名(再退到视频名)去对已有主播名,只有一个能对上时
  才在文案里带上主播名,对不上就用「手动合并完成」。

3) 头像缓存挪到程序所在目录
- 原来存在「录播目录/avatar_cache」,换一次保存目录(甚至换盘)就要把同一批观众
  的头像重新下一遍(一个头像可能 0.5MB)。现在统一放程序目录下的 avatar_cache。
- 旧位置作为只读回退:命中就搬过来(同盘改名,跨盘退回复制),不重复下载;
  没被用到的旧文件留在原地,用到哪个搬哪个。

验证:
- 新增 avatar_test.go 两个用例(缓存路径、旧位置搬家)和 notify_test.go 的
  TestManualMergeSendsMergeDoneNotify(真跑一次合并,断言 webhook 与 MeoW 各收到
  一条 mergeDone,且从文件名认出主播);qualities_test.go 改成新语义,新增
  resolveQualityBitrate(7 种档位/码率组合)和 guessUIDByFileName。
- 端到端:把旧位置的头像缓存放在录播目录、头像地址指向连不通的端口,合并仍然成功
  且画面里有那张头像(纯红像素 400 个),证明是搬来的而不是重新下载的;文件也从
  录播目录搬到了程序目录。
- 浏览器:verify_streamer_br.py 重写成档位下拉的 20 项断言,verify_qualities.py
  更新为新语义,两个都全过;verify_settings.py / verify_pick.py / verify_danmu_match.py
  无回归;go test ./...、gofmt、go vet 干净。
2026-09-25 20:03:24 +08:00

352 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 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 p
}
}
return ""
}
// 检查设置里指定的 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)。bitrate 是页面上选的档位码率,0 表示自动:
// 按这次拿到的实际档位自上往下取第二高(只有一个档位就用它,拿不到档位表退回最高档)
func getStreamURL(uid, bitrate 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, bitrate)
// 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.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()
}
}