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

160 lines
18 KiB
Markdown
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.
# 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)。
### 第二批:无人值守能力(投入小、体感提升最大)
4. **通知推送**:开播 / 下播 / 合并完成 / 合并失败 / 磁盘不足,推送到 **NoneBot(你已有 Momoko)/ Bark / 企业微信 / Telegram**。这是长时间挂机录制最缺的一环,实现成本约百行。
- 佐证:**上游原版本来就有开播/下播通知**(`/addnotifyon`、`/startmirai`,走 Mirai 推 QQ),本改造版在剥离 Mirai 时把这套能力一起删掉了。现在本机还跑着那个上游实例(见上文"运行环境发现")。用 NoneBot 的 OneBot HTTP API 接回来,既恢复能力又不引入 Mirai 依赖。
5. **文件管理**:Web UI 列出 `record/`、`merged/` 中的文件(大小、时长、是否已合并),成片在线播放(Range)与下载、一键删除;显示磁盘剩余空间。
6. **日志落盘 + 轮转**,配合计划任务/nssm 后台运行。
### 第三批:进阶功能
7. **录制后处理流水线**:合并完成后自动转 H.265(省 40%~60% 空间,可与现有 `mergeBitrate` 联动)、生成缩略图、按 `{日期}/{主播}` 归档。
8. **弹幕数据再加工**:导出 B 站 XML / lrc;生成"弹幕热度曲线",方便后续剪辑挑精彩片段。
9. **定时/条件录制**:只在指定时段录制、按标题关键词过滤、按清晰度门槛过滤。
10. **Web UI 实时预览**:录制中定期截图或低码率 MJPEG 预览,配合显示当前文件大小/已录时长。
11. **多站点支持**:抽象 `LiveProvider` 接口(AcFun/B站/虎牙/抖音),是最有价值也最费力的方向。
12. **工程化**: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 可下载成片 |