Files
acfunlive-remake/doc/review-2026-09-25.md
2026-09-25 13:16:54 +08:00

18 KiB
Raw Permalink Blame History

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 难利用;但这不改变"无认证"这一根本问题。
  • 建议:
    1. 增加 webToken(首次启动随机生成并打印到控制台/日志),页面 ?token= 或 Authorization: Bearer;
    2. 更贴合个人工具的方案:管理类接口(config/streamer/remove/control)默认只接受来自 127.0.0.1 的请求,webExternal 只用于查看状态;
    3. mergeDelVideo/mergeDelDanmu 这类破坏性开关再加一层"仅在本地控制台/带 token 时可改"。

P0-2 自动合并 goroutine 没有 recover,一次 panic 会杀掉整个录制进程 🔴

  • 证据链:automerge.go:42 go autoMerge(...)(无 defer recover)→ automerge.go:116 调 burnDanmu → danmuburn.go:328 checkErr(os.MkdirAll(cacheDir, 0755))、danmuburn.go:369 checkErr(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 项):
    1. 分块渲染:按时间窗(如每 60~120 秒)切分成多个 ffmpeg 任务,每块只包含该时段出现的弹幕与用户头像,编成 mp4 分片后用 concat demuxer 合并;音频直接从原视频 -c:a copy。每块弹幕数可控,链长下降两个数量级。
    2. 头像预合成:把"头像+昵称"先渲染成不透明小图(或按用户合成一张 sprite),再按用户而非按弹幕做 overlay,配合 enable 表达式按用户合并时间段 —— 数量从"弹幕数"降到"用户数",可再降一个数量级。
    3. 至少在超限前主动报错并降级(如提示用户改用分块或关闭头像),而不是把 ffmpeg 的错误原样抛出。

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. 优化建议(不改行为)

  1. 配置写入串行化 + 原子替换:saveConfig 是目前唯一的多写者共享资源,改为「写 config.json.tmp → os.Rename」可同时解决半截文件与并发覆盖。
  2. 锁顺序固化:现有顺序是 streamers → liveRooms(如 buildStreamers 持前者调 getName 取后者)。建议在注释中固化,并把"锁内不做 IO"写成硬约束:getTitle/getName/longID 一律先取值后打印。
  3. safeGo 统一兜底:所有后台任务走同一入口(recover + 日志 + 可选重启),把"是否 recover"从人工记忆变成机制。
  4. 合并任务串行化:加一个 mergeCh(容量 1 的 worker),避免并发合并抢 CPU/内存与临时文件冲突(同时解掉 P1-6)。
  5. 前端轮询改造:状态接口返回 version(配置/列表变更摘要),前端只在变更时重建;日志改增量拉取。
  6. /api/status 瘦身:UIDs 字段已冗余(前端未使用),可去掉。
  7. CI:加 gofmt -l、go vet、go build(windows/linux 双平台)与 govulncheck;依赖里有 acfundanmu 等较旧的包,建议定期看 CVE。
  8. Docker 场景目录嵌套:recordDir=/acfunlive/record + recordSubdir=record 会变成 /acfunlive/record/record;建议允许 recordSubdir 为空表示"不建子目录",或文档说明。
  9. 录制前检查磁盘水位:新增剩余空间检查,低水位告警/停止录制,避免写满盘导致所有录制同时失败。

6. 下一步值得做的功能

第一批:补齐稳定性(建议先做,和上面的 P0/P1 是同一批工作)

  1. 合并改为分块渲染 + concat,彻底解决 P0-3,顺带把 3 小时的直播合并从"不可行"变成"可接受"。
  2. 优雅停止 + 断流续录拼接(P1-2 / P1-3):停止时 ffmpeg 正常收尾(flv 不再损坏);断流后的分段自动拼接,弹幕覆盖全场。
  3. 管理接口鉴权 + 敏感操作本地限定(P0-1)。

第二批:无人值守能力(投入小、体感提升最大)

  1. 通知推送:开播 / 下播 / 合并完成 / 合并失败 / 磁盘不足,推送到 NoneBot(你已有 Momoko)/ Bark / 企业微信 / Telegram。这是长时间挂机录制最缺的一环,实现成本约百行。
    • 佐证:上游原版本来就有开播/下播通知(/addnotifyon、/startmirai,走 Mirai 推 QQ),本改造版在剥离 Mirai 时把这套能力一起删掉了。现在本机还跑着那个上游实例(见上文"运行环境发现")。用 NoneBot 的 OneBot HTTP API 接回来,既恢复能力又不引入 Mirai 依赖。
  2. 文件管理:Web UI 列出 record/、merged/ 中的文件(大小、时长、是否已合并),成片在线播放(Range)与下载、一键删除;显示磁盘剩余空间。
  3. 日志落盘 + 轮转,配合计划任务/nssm 后台运行。

第三批:进阶功能

  1. 录制后处理流水线:合并完成后自动转 H.265(省 40%~60% 空间,可与现有 mergeBitrate 联动)、生成缩略图、按 {日期}/{主播} 归档。
  2. 弹幕数据再加工:导出 B 站 XML / lrc;生成"弹幕热度曲线",方便后续剪辑挑精彩片段。
  3. 定时/条件录制:只在指定时段录制、按标题关键词过滤、按清晰度门槛过滤。
  4. Web UI 实时预览:录制中定期截图或低码率 MJPEG 预览,配合显示当前文件大小/已录时长。
  5. 多站点支持:抽象 LiveProvider 接口(AcFun/B站/虎牙/抖音),是最有价值也最费力的方向。
  6. 工程化: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 可下载成片