18 KiB
18 KiB
acfunlive 源码审阅报告
- 日期:2026-09-25
- 范围:13 个 Go 文件共 3735 行(config/webui/danmu/danmuburn/record/automerge/cycle/fetch/main/utils/handle/input/filename),外加 config.json、Dockerfile、README
- 方法:逐文件通读 + 行级取证 +
go vet(无输出)+ 关键假设实测(Windows 命令行长度上限、进程上限) - 结论:本次未修改任何代码。P0 3 项、P1 9 项、P2 16 项,其中 P0-3 已用实测数据证明"长直播的自动合并必然失败"
运行环境发现(非代码问题,但优先级很高)
- 本机同时存在两个录制程序实例:
acfunlive-danmu.exe(PID 14616,本改造版),监听0.0.0.0:51881;acfunlive(PID 10708,上游原版,有/listlive/startmirai等接口),监听0.0.0.0:51880。
- 判定依据:
netstat -ano显示 51880 → PID 10708、51881 → PID 14616;GET http://127.0.0.1:51880/返回上游帮助文本(/listlive、/startwebui…),而/api/status返回 404。 - 风险:如果两个实例的 uid 列表重叠,会对同一主播重复下载、重复占用带宽,产物互相覆盖或重复;上游版的 web API 同样是无鉴权的写接口(
/addnotifyon/...),且监听所有网卡。 - 建议:确认后关掉不需要的那一个;若上游版仍需保留,把它的 web 监听限制在本机。
1. 结论速览
| 级别 | 数量 | 主题 | 最值得先做的一件事 |
|---|---|---|---|
| P0 安全/数据 | 3 | 无认证的 web 管理面、合并 goroutine 崩溃会杀掉整个录制进程、合并命令超命令行上限 | 给管理接口加鉴权 + 合并改分块渲染 |
| P1 正确性/竞态 | 9 | 数据竞态、硬杀 ffmpeg 导致文件损坏、合并静默丢失、并发合并字幕错配、停止操作卡 30 秒 | 修 removeUID 竞态、优雅停止、临时文件唯一化 |
| P2 健壮性/体验/工程 | 16 | 配置错误直接退出、无信号处理、锁内 IO、无超时、日志不落盘等 | 统一 goroutine 兜底 + 配置降级 |
2. P0:安全与数据完整性
P0-1 web UI 无任何认证,且当前配置已对外开放 🔴
- 证据:
webui.go:546-553注册的路由(POST /api/config、/api/streamer、/api/streamer/control、/api/streamer/remove)全部无鉴权、无 token、无来源校验;config.json中webExternal: true意味着监听0.0.0.0(webui.go:589-594)。 - 影响:同一局域网(或做了端口映射的机器上)任何人可以:
- 改
directory/recordSubdir/mergeSubdir/mergeOutputDir,把录制产物写到任意已存在目录; - 打开
mergeDelVideo/mergeDelDanmu(这两个开关的实际效果是删除源文件); - 增删主播、停止全部录制、把码率改成 1 导致画质损毁。
- 改
- 缓解事实:POST 接口要求
application/json,浏览器跨站表单会触发 CORS 预检且服务端不返回 CORS 头,因此 CSRF 难利用;但这不改变"无认证"这一根本问题。 - 建议:
- 增加
webToken(首次启动随机生成并打印到控制台/日志),页面?token=或Authorization: Bearer; - 更贴合个人工具的方案:管理类接口(config/streamer/remove/control)默认只接受来自 127.0.0.1 的请求,
webExternal只用于查看状态; mergeDelVideo/mergeDelDanmu这类破坏性开关再加一层"仅在本地控制台/带 token 时可改"。
- 增加
P0-2 自动合并 goroutine 没有 recover,一次 panic 会杀掉整个录制进程 🔴
- 证据链:
automerge.go:42go autoMerge(...)(无defer recover)→automerge.go:116调burnDanmu→danmuburn.go:328checkErr(os.MkdirAll(cacheDir, 0755))、danmuburn.go:369checkErr(png.Encode(...));checkErr的实现是panic(utils.go:87-91)。runThrice(utils.go:94-105)只重试返回 error 的调用,捕不住 panic。 - 影响:头像缓存目录创建失败(权限/被占用/磁盘满)或 PNG 编码失败 → panic 逃出 goroutine → Go 运行时终止整个进程,其它主播正在进行的录制全部中断,flv 文件被留下且未移动。
- 对比:程序里绝大多数 goroutine 都有 recover(
cycle/handleInput/getDanmu/initRecord/startWebUI),只有autoMerge这条最"重"的路径没有。 - 建议:① 所有
go出去的入口加safeGo(func())包装(统一 recover + 日志);②danmuburn.go里的checkErr换成返回 error 或就地判空返回。
P0-3 弹幕条数 ≳200 时,合并命令超出 Windows 命令行上限,自动合并必然失败 🔴
- 证据:
danmuburn.go:221-240为每一条弹幕生成一个 overlay 片段,danmuburn.go:247把它们作为单个-filter_complex参数传给 ffmpeg。 - 实测(本机复刻格式串):单条片段 118 字符,实测样例
[s1234][av56789012_3]overlay=x='1920-(1920+1258)*(t-1234.567)/10':y=726:enable='between(t,1234.567,1244.567)'[s1235]; - 实测(Windows CreateProcess 上限):30000 / 32000 字符 → 成功;40000 字符 →
WinError 206 文件名或扩展名太长。
| 弹幕条数 | filter_complex 长度 | 整条命令估算 | Windows (≤32767) |
|---|---|---|---|
| 100 | 11.8 KB | 16.4 KB | ok |
| 200 | 23.6 KB | 32.2 KB | 已到临界 |
| 290 | 34.2 KB | 46.4 KB | 必然失败 |
| 1000 | 118 KB | 130 KB | 必然失败 |
| 10000 | 1.2 MB | 1.3 MB | 必然失败 |
- 影响:一场正常直播(几千到几万条弹幕)的自动合并/命令行合成必定报错,且报错是
WinError 206,完全看不出真实原因。之前实测能成功,只是因为测试用的直播间弹幕量都不到 200 条。Linux 端单参数上限MAX_ARG_STRLEN为 128 KB,约 1100 条也会踩到。 - 附带性能问题:O(N) 条 overlay 链,每条都对整帧做一次重编码,即使不超长度,几千条时耗时与内存也不可行。
- 建议(推荐第 2 项):
- 分块渲染:按时间窗(如每 60~120 秒)切分成多个 ffmpeg 任务,每块只包含该时段出现的弹幕与用户头像,编成 mp4 分片后用 concat demuxer 合并;音频直接从原视频
-c:a copy。每块弹幕数可控,链长下降两个数量级。 - 头像预合成:把"头像+昵称"先渲染成不透明小图(或按用户合成一张 sprite),再按用户而非按弹幕做 overlay,配合
enable表达式按用户合并时间段 —— 数量从"弹幕数"降到"用户数",可再降一个数量级。 - 至少在超限前主动报错并降级(如提示用户改用分块或关闭头像),而不是把 ffmpeg 的错误原样抛出。
- 分块渲染:按时间窗(如每 60~120 秒)切分成多个 ffmpeg 任务,每块只包含该时段出现的弹幕与用户头像,编成 mp4 分片后用 concat demuxer 合并;音频直接从原视频
3. P1:正确性 / 竞态 / 数据
| 编号 | 问题 | 证据 | 影响 | 建议 |
|---|---|---|---|---|
| P1-1 | removeUID 在无锁状态下改 config.UIDs |
config.go:296-319 直接 append;而 setStreamerFlags(config.go:342-368)持 streamers.Lock 改同一切片 |
数据竞态;并发增删时可能丢 uid 或读到脏切片;saveConfig 又在锁外遍历它(config.go:270-288),并发写同一文件会互相覆盖 |
统一用 streamers.Lock;saveConfig 用独立 configMu 串行化 + 临时文件原子替换 |
| P1-2 | 停止录制是硬杀 ffmpeg,q 写在进程已死之后 |
record.go:176 用 exec.CommandContext;record.go:200-204 先 cmd.Run() 返回,再 io.WriteString(stdin, "q") |
取消 ctx 时 Go 直接 TerminateProcess,flv 尾部无正常收尾 → 合并时报 corrupt packet(已实测到该现象);q 写入是无效代码 |
用 cmd.Cancel(写 q,不做 Kill)+ cmd.WaitDelay = 5s,超时再杀 |
| P1-3 | 只有首段录播参与合并,断流后的分段被忽略 | record.go:186-197 firstRecordFile/recordStartNs 只在为空时赋值;record.go:221 续段生成新文件名;automerge.go 只处理 firstRecordFile |
直播中途断一次流,后半场视频没有弹幕,且合并只输出首段(弹幕时间轴也对不上)→ 内容丢失 | 方案 A(小改):续段复用同一 flv 文件名,让 ffmpeg 追加/重连;方案 B(推荐):记录所有分段与各自起始偏移,合并时先 concat 所有分段再叠弹幕 |
| P1-4 | cycleDelKey 60 秒清理会静默吞掉自动合并 |
cycle.go:106-124 删除"既无弹幕也无录播"的 liveInfo;record.go:205 分段之间会把 isRecording 置 false 并 time.Sleep(5s)(record.go:217);automerge.go:16-20 找不到 key 时直接 return 且无日志 |
录播收尾晚于弹幕超过 60 秒(或分段间隙撞上清理周期)时,自动合并永远不触发,用户只看到"没生成成片" | 保留条件放宽:有 assFile/firstRecordFile/mergeStarted 的项不删;清理周期改 10 分钟;finishPart 找不到 key 时打日志 |
| P1-5 | controlStreamer 的"重新开始"会打开原本关闭的录制 |
config.go:461-468,前端固定传 target:"all"(webui.go:196)→ danmu, record = restart, restart(都置 true) |
只开弹幕的主播点一次"重新开始",录播也被打开,产生用户没要的视频文件 | restart 只对"该项原本已开启"的部分重启:danmu = s.Danmu && restart,或让前端按当前勾选传 target |
| P1-6 | 并发合并时临时字幕文件同名冲突,可能张冠李戴 | danmuburn.go:170 硬编码 acfunlive_nick_tmp.ass,且开头 _ = os.Remove(tmpNick)、结尾 defer os.Remove(tmpNick) |
两个主播同时下播 → 两个 autoMerge 并发 → 一方删掉另一方正在用的字幕,最坏情况把 A 的弹幕烧进 B 的视频 | 临时名加唯一后缀(liveID/uid/时间戳),或改到各自独立临时目录 |
| P1-7 | 停止操作可能卡 4~30 秒 | record.go:102、danmu.go:424 的日志里调 longID(uid)(utils.go:157-159)→ getName → 缓存未命中时 tryFetchLiveInfo 走 3 次重试(每次 10 秒超时 + 2 秒 sleep,utils.go:94-105) |
主播下播后点"停止",HTTP 请求要等到日志打印完才返回,web 页面像卡死;setStreamerFlags 里 getName 还会调两次(config.go:353,366) |
日志走缓存名字,取不到就只打 uid;getName 加一层短期缓存 |
| P1-8 | web UI 每 3 秒重建主播列表 DOM | webui.go:144 innerHTML = rows;webui.go:224 setInterval(refresh, 3000) |
正在输入"下载码率上限"时会被重建清空(setVal 只保护设置区表单,不保护列表行) |
数据无变化时不重建(比较 JSON 摘要);或在 :focus 元素所在行跳过重建 |
| P1-9 | 每 3 秒全量拉取日志 | webui.go:523-526 返回最多 1 MB 日志,webui.go:124 每 3 秒取一次 |
每分钟最多 20 MB 流量,手机浏览器明显吃力,日志越长越严重 | 支持增量(since 偏移/时间戳)+ 前端只追加 |
4. P2:健壮性、体验与工程
| 编号 | 问题 | 证据 / 说明 |
|---|---|---|
| P2-1 | 配置错误直接 os.Exit(1),会中断正在进行的录制 |
config.go:177-243 大量 os.Exit;config.go:610 在 loadNewConfig 里也会调它。用户手改 config.json 写错一个字符 → 整个录制进程退出。建议:保留旧配置 + 告警(只有启动阶段才致命) |
| P2-2 | 无 SIGINT/SIGTERM 处理 | 没有 signal.Notify;Ctrl+C 或杀进程时 ffmpeg 变孤儿继续录(上次已实测),且退出流程是 main.go:164 硬 sleep 20s |
| P2-3 | mainCh 的发送可能与退出竞争 |
main.go:116 容量 20;config.go:527-537 stopStreamerCycle 持有 sInfoMap 锁做 m.ch <- stop,若无人接收会持锁阻塞(潜在死锁);main 退出后任何 mainCh <- 永久阻塞。建议 select + default 并显式从 map 删除 |
| P2-4 | http.Server 只设了 IdleTimeout |
webui.go:560-564 缺 ReadHeaderTimeout/ReadTimeout/WriteTimeout;请求体也没有 http.MaxBytesReader 与速率限制 → 慢连接可长期占用连接 |
| P2-5 | 页面缺少安全响应头 | 可加 X-Content-Type-Options、X-Frame-Options、Referrer-Policy、CSP;/api/log 会泄露主播名、直播标题、本机路径 |
| P2-6 | 目录名校验不够严 | config.go:130-135 只拒分隔符与 "..";未处理 Windows 保留名(CON/NUL/PRN)、结尾空格或点;mergeOutputDir 可为任意已存在目录 |
| P2-7 | Linux 文件名截断可能切碎 UTF-8 | filename_other.go:19-21 按字节 filename[:250],多字节汉字会被截半 → 非法文件名。应按 rune 截断 |
| P2-8 | 头像缓存无失效、无上限、位置混放 | danmuburn.go:323-338 缓存名是 uid.png,主播换头像后永远用旧图;缓存在 record/ 目录内与成对文件混在一起;建议按 URL 摘要命名 + 放 cache/ 子目录 + 定期清理 |
| P2-9 | 时间轴对齐存在 1~3 秒系统误差 | record.go:195 在 ffmpeg 启动前记录 recordStartNs;ffmpeg 建立连接与缓冲有延迟 → 弹幕整体偏早。可考虑用 flv 首个时间戳校正 |
| P2-10 | 弹幕坐标系可能与实际分辨率不符 | danmu.go:43-55 用最高档码率猜分辨率(≥4000→1080)。竖屏直播配上≥4000 档会选到横屏坐标系 1920×1080 → libass 拉伸变形 |
| P2-11 | assTime 超过 24 小时会回绕 |
danmu.go:127-133 用 time.Unix(0, t).UTC() 取 Hour();超长直播(>24h)时间轴错乱 |
| P2-12 | QSV 能力每次合并都探测 | danmuburn.go:374-379 每次跑一次 0.5 秒的试编码;建议进程内缓存结果 |
| P2-13 | 日志不落盘、无轮转 | 只有内存 + stdout(webui.go:260-270);用计划任务/nssm 后台运行时排障困难。建议同时写 log/ 并按大小轮转 |
| P2-14 | ffmpeg 只在 PATH 与 exeDir 查找 | record.go:18-31,且不校验版本/编码器能力,出错提示不够可操作 |
| P2-15 | initRecord/initDanmu 无 recover |
由 main.go:88-89 与 cycle.go:85-90 直接 go 出;当前实现无 panic 点,但一旦后续改动引入 checkErr 就是整机崩溃。建议统一 safeGo 包装 |
| P2-16 | 大量 time.Sleep 兜底、缺少状态同步 |
cycle.go:101 1s、record.go:217 5s、danmu.go:310/327 5s/10s、config.go:478 2s、webui.go:610 500ms;restartWebUI 依赖 panic-recover 重试(webui.go:529-537) |
5. 优化建议(不改行为)
- 配置写入串行化 + 原子替换:
saveConfig是目前唯一的多写者共享资源,改为「写config.json.tmp→os.Rename」可同时解决半截文件与并发覆盖。 - 锁顺序固化:现有顺序是
streamers → liveRooms(如buildStreamers持前者调getName取后者)。建议在注释中固化,并把"锁内不做 IO"写成硬约束:getTitle/getName/longID一律先取值后打印。 safeGo统一兜底:所有后台任务走同一入口(recover + 日志 + 可选重启),把"是否 recover"从人工记忆变成机制。- 合并任务串行化:加一个
mergeCh(容量 1 的 worker),避免并发合并抢 CPU/内存与临时文件冲突(同时解掉 P1-6)。 - 前端轮询改造:状态接口返回
version(配置/列表变更摘要),前端只在变更时重建;日志改增量拉取。 /api/status瘦身:UIDs字段已冗余(前端未使用),可去掉。- CI:加
gofmt -l、go vet、go build(windows/linux 双平台)与govulncheck;依赖里有acfundanmu等较旧的包,建议定期看 CVE。 - Docker 场景目录嵌套:
recordDir=/acfunlive/record+recordSubdir=record会变成/acfunlive/record/record;建议允许recordSubdir为空表示"不建子目录",或文档说明。 - 录制前检查磁盘水位:新增剩余空间检查,低水位告警/停止录制,避免写满盘导致所有录制同时失败。
6. 下一步值得做的功能
第一批:补齐稳定性(建议先做,和上面的 P0/P1 是同一批工作)
- 合并改为分块渲染 + concat,彻底解决 P0-3,顺带把 3 小时的直播合并从"不可行"变成"可接受"。
- 优雅停止 + 断流续录拼接(P1-2 / P1-3):停止时 ffmpeg 正常收尾(flv 不再损坏);断流后的分段自动拼接,弹幕覆盖全场。
- 管理接口鉴权 + 敏感操作本地限定(P0-1)。
第二批:无人值守能力(投入小、体感提升最大)
- 通知推送:开播 / 下播 / 合并完成 / 合并失败 / 磁盘不足,推送到 NoneBot(你已有 Momoko)/ Bark / 企业微信 / Telegram。这是长时间挂机录制最缺的一环,实现成本约百行。
- 佐证:上游原版本来就有开播/下播通知(
/addnotifyon、/startmirai,走 Mirai 推 QQ),本改造版在剥离 Mirai 时把这套能力一起删掉了。现在本机还跑着那个上游实例(见上文"运行环境发现")。用 NoneBot 的 OneBot HTTP API 接回来,既恢复能力又不引入 Mirai 依赖。
- 佐证:上游原版本来就有开播/下播通知(
- 文件管理:Web UI 列出
record/、merged/中的文件(大小、时长、是否已合并),成片在线播放(Range)与下载、一键删除;显示磁盘剩余空间。 - 日志落盘 + 轮转,配合计划任务/nssm 后台运行。
第三批:进阶功能
- 录制后处理流水线:合并完成后自动转 H.265(省 40%~60% 空间,可与现有
mergeBitrate联动)、生成缩略图、按{日期}/{主播}归档。 - 弹幕数据再加工:导出 B 站 XML / lrc;生成"弹幕热度曲线",方便后续剪辑挑精彩片段。
- 定时/条件录制:只在指定时段录制、按标题关键词过滤、按清晰度门槛过滤。
- Web UI 实时预览:录制中定期截图或低码率 MJPEG 预览,配合显示当前文件大小/已录时长。
- 多站点支持:抽象
LiveProvider接口(AcFun/B站/虎牙/抖音),是最有价值也最费力的方向。 - 工程化:systemd unit / Windows 服务 + 崩溃自愈;发布构建脚本与 Docker compose 示例。
7. 建议的落地顺序
| 批次 | 内容 | 验收要点 |
|---|---|---|
| 1 | P0-2 goroutine 兜底、P1-1 竞态、P1-5 restart 语义、P1-6 临时文件唯一化、P1-8/P1-9 前端 | 并发增删主播 -race 无告警;两个主播同时下播合并互不干扰 |
| 2 | P1-2 优雅停止、P1-3 断流拼接、P1-4 清理条件 | 手动停止后 flv 无损坏 packet;断流一次后成片覆盖全场 |
| 3 | P0-3 分块渲染 | 用 3000+ 条弹幕的 jsonl 实测合并成功,耗时可接受 |
| 4 | P0-1 鉴权 | 局域网设备无 token 时打不开管理接口 |
| 5 | 通知 + 文件管理 | 下播合并完成后 NoneBot 收到消息;Web UI 可下载成片 |