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

215 lines
6.5 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.
// AcFun 直播弹幕录制工具
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/orzogc/acfundanmu"
)
// 命令行参数处理
func argsHandle() {
const usageStr = "本程序用于录制 AcFun 主播的直播弹幕,在 " + configFile + " 的 uids 里填入要录制弹幕的主播 uid 列表\n" +
"指定 -danmuburn 时进入弹幕合成模式:读取录制弹幕时生成的 jsonl 文件,合成带头像的弹幕层\n" +
"弹幕文件也可以直接给 .ass(原版录制的就是这种),会先还原成 jsonl 再合成"
isHelp := flag.Bool("h", false, "输出本帮助信息")
configDir = flag.String("config", "", "设置文件所在文件夹,默认是本程序所在文件夹")
recordDir = flag.String("record", "", "弹幕文件保存的文件夹,默认是本程序所在文件夹")
danmuburnFile := flag.String("danmuburn", "", "要合成的弹幕文件(.jsonl 或 .ass)路径,指定后进行弹幕合成然后退出,不启动录制")
burnVideo := flag.String("video", "", "录播视频路径,配合 -danmuburn 把弹幕压进视频;不指定则输出透明头像层 mov")
burnOut := flag.String("out", "", "合成输出文件路径,默认与弹幕文件同目录")
burnOffset := flag.Float64("offset", 0, "录播视频相对弹幕录制开始的时间偏移,单位秒,配合 -danmuburn -video 使用")
burnFFmpeg := flag.String("ffmpeg", "", "ffmpeg 可执行文件路径;不指定时读设置文件里的 ffmpegPath,仍旧找不到再按常见位置查找")
burnBitrate = *flag.Int("vbitrate", 0, "合成视频的目标码率(Kbps),0 为按「源片码率 + 500」自动计算,配合 -danmuburn 使用")
flag.Parse()
if *isHelp {
fmt.Println(usageStr)
flag.PrintDefaults()
os.Exit(0)
}
if flag.NArg() != 0 {
fmt.Println(usageStr)
flag.PrintDefaults()
os.Exit(1)
}
if *danmuburnFile != "" {
loadConfigForDanmuburn()
// 命令行显式给的优先于设置文件里的
if *burnFFmpeg != "" {
config.FFmpegPath = *burnFFmpeg
}
runDanmuburn(*danmuburnFile, *burnVideo, *burnOut, *burnOffset)
os.Exit(0)
}
initialize()
}
// 程序初始化
func initialize() {
exePath, err := os.Executable()
checkErr(err)
exeDir = filepath.Dir(exePath)
if *configDir == "" {
*configDir = exeDir
} else {
info, err := os.Stat(*configDir)
checkErr(err)
if !info.IsDir() {
lPrintErrf("指定的设置文件夹 %s 并不是真正的文件夹", *configDir)
os.Exit(1)
}
}
if *recordDir == "" {
*recordDir = exeDir
} else {
info, err := os.Stat(*recordDir)
checkErr(err)
if !info.IsDir() {
lPrintErrf("指定的弹幕保存文件夹 %s 并不是真正的文件夹", *recordDir)
os.Exit(1)
}
}
configFileLocation = filepath.Join(*configDir, configFile)
if !isConfigFileExist(configFile) {
data, err := json.MarshalIndent(config, "", " ")
checkErr(err)
err = os.WriteFile(configFileLocation, data, 0644)
checkErr(err)
lPrintln("创建设置文件" + configFile + ",请在 uids 里填入要录制弹幕的主播 uid 列表")
}
sInfoMap.info = make(map[int]*streamerInfo)
lInfoMap.info = make(map[string]liveInfo)
streamers.crt = make(map[int]streamer)
streamers.old = make(map[int]streamer)
deviceID, err = acfundanmu.GetDeviceID()
checkErr(err)
if ok := fetchAllRooms(); !ok {
os.Exit(1)
}
liveRooms.rooms = liveRooms.newRooms
loadConfig()
checkConfig()
// 缓存过档位、但还没定过码率的主播,启动时按默认档位(第二高)定下来
if applyCachedDefaultQualities() {
saveConfig()
}
// 上次在合并途中被关掉的话,录播目录和成片目录里会留下临时文件,启动时清一次
cleanLeftoverTempFiles()
ensureAuthToken()
buildStreamers()
for uid, s := range streamers.crt {
streamers.old[uid] = s
}
}
func main() {
argsHandle()
if len(streamers.crt) == 0 {
lPrintWarn("请在" + configFile + "的 uids 里填入要录制弹幕的主播 uid 列表")
} else {
lPrintln("本程序开始监听主播的直播状态,自动录制直播弹幕")
}
if getFFmpeg() == "" {
lPrintWarn("没有找到 ffmpeg,无法录制直播视频,弹幕合并也无法进行;请安装 ffmpeg 并加入 PATH," +
"或把 ffmpeg 放在本程序所在文件夹,或在 web UI 设置区填写「ffmpeg 路径」")
}
lPrintf("录播文件格式:%s(可在 web UI 或 %s 的 recordFormat 里改成 flv / ts / mkv)",
normalizeRecordFormat(config.RecordFormat), configFile)
mainCh = make(chan controlMsg, 20)
for _, s := range streamers.crt {
st := s
safeGo(st.longID()+"的监听循环", func() { st.cycle("") })
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
mainCtx = ctx
safeGo("监控设置文件", func() { cycleConfig(ctx) })
safeGo("获取直播间数据", func() { cycleFetch(ctx) })
safeGo("清理无用的直播信息", func() { cycleDelKey(ctx) })
if config.WebPort > 0 {
safeGo("web UI 服务器", func() { startWebUI(ctx) })
}
lPrintln("现在可以输入命令控制本程序,输入 help 查看全部命令的解释")
safeGo("命令行输入", handleInput)
// 收到 Ctrl+C(或 Linux 下的终止信号)时走和 quit 命令一样的退出流程,
// 否则进程会直接消失,ffmpeg 变成孤儿进程继续录制
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
safeGo("退出信号监听", func() {
sig := <-sigCh
lPrintln("收到 " + sig.String() + " 信号,正在退出(再按一次可强制退出)")
select {
case mainCh <- controlMsg{c: quit}:
default:
return
}
<-sigCh
lPrintln("再次收到退出信号,强制退出")
os.Exit(1)
})
Outer:
for msg := range mainCh {
switch msg.c {
case startCycle:
m := msg
safeGo(m.s.longID()+"的监听循环", func() { m.s.cycle(m.liveID) })
case quit:
// 结束所有 mainCtx 的子 ctx
cancel()
// 结束 cycle()
lPrintln("正在退出各主播的循环")
sInfoMap.Lock()
for _, info := range sInfoMap.info {
// 退出各主播的循环
if info.ch != nil {
info.ch <- msg
}
}
sInfoMap.Unlock()
lInfoMap.RLock()
for _, info := range lInfoMap.info {
// 结束录制弹幕
if info.isDanmu {
info.danmuCancel()
}
}
lInfoMap.RUnlock()
// 等待 20 秒,等待弹幕文件写入完成
time.Sleep(20 * time.Second)
break Outer
default:
lPrintErrf("未知 controlMsg:%+v", msg)
}
}
lPrintln("本程序结束运行")
}