Files
acfunlive-remake/record.go
T
tamakyi eda83dd1e3 主播列表的码率改为直播实际档位下拉,没选过时默认第二档
原来是一个手填的码率上限(Kbps),现在改成直播的实际档位(蓝光 8M / 高清…):

- 读到档位后,主播那一行渲染成下拉,选完立即生效;正在录制时换档会自动重录。
- 没手动选过(bitrate 0)的主播按**自上往下第二档**定下来(如
  蓝光 8M / 蓝光 4M / 高清 就选蓝光 4M,只有一档就用那一档),不会一上来就吃满
  最高档;已经手动选过的永远不动。
- 档位要开播后读直播源列表才知道,读到以后(录制开始时、或页面点「档位」按钮)
  缓存进 config.json 的 qualities,所以没开播也能看和改;启动时会把「有缓存但还
  没定过码率」的主播按第二档补齐。
- 从来没读过档位的行仍然是手填输入框,旁边多了个「档位」按钮去直播间接一次。

后端:新增 `GET /api/streamer/qualities?uid=…`(同一主播 10 秒内只读一次),
`/api/status` 的每个主播多了 `qualities`(按码率从高到低)和 `qualityName`;
抽出 `newStreamInfo()`/`fetchStreamQualities()`,`getStreamURL()` 取流时顺手缓存
档位,并在 bitrate 为 0 时改用刚定下来的默认档位(否则还是最高档)。
档位相关的纯逻辑放在 config.go:`sortQualitiesDesc` / `defaultQualityBitrate` /
`qualityNameFor` / `sameQualities` / `setStreamerQualities` / `applyCachedDefaultQualities`。

测试:qualities_test.go 7 个用例(排序、默认第二档的 5 种档位数、档位名匹配、
写配置时不覆盖手动选择、启动补齐、JSON 存取往返);浏览器实测 5 组断言全过
(下拉选中当前档位、换档服务端立即变、无档位时保留输入框+按钮、启动默认第二档、
手填仍可用),verify_streamer_br.py / verify_pick.py / verify_settings.py 无回归;
go test ./...、gofmt、go vet 干净。
2026-09-25 19:42:47 +08:00

358 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 时取码率最高的源,否则取码率不超过 bitrate(Kbps)的最高档
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)
}
// 顺手把码率档位记下来:页面上要用它做下拉,没开播时靠缓存显示
if tiers := streamTiers(sInfo.StreamList); len(tiers) > 0 {
if setStreamerQualities(uid, tiers) {
saveConfig()
}
// 还没设过码率的(0)刚被默认档位定下来,这里跟着用它,别又回到最高档
if bitrate == 0 {
for _, u := range config.UIDs {
if u.UID == uid {
bitrate = u.Bitrate
break
}
}
}
}
// StreamList 按码率升序排列
st := sInfo.StreamList[len(sInfo.StreamList)-1]
if bitrate > 0 {
st = sInfo.StreamList[0]
for i := len(sInfo.StreamList) - 1; i >= 0; i-- {
if sInfo.StreamList[i].Bitrate <= bitrate {
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()
}
}