From 9b9b3fa2cd9ab60f622c9477438045ce947940fa Mon Sep 17 00:00:00 2001 From: tamakyi Date: Sat, 26 Sep 2026 03:14:07 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=B9=E5=B9=95=E5=86=99=E7=9B=98=E5=87=BA?= =?UTF-8?q?=E9=94=99=E4=B8=8D=E5=86=8D=E8=A2=AB=E9=9D=99=E9=BB=98=E5=90=9E?= =?UTF-8?q?=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审查发现:录制时每写一条弹幕都调用 os.File.Write / fmt.Fprintf, 但返回值一个都没接。磁盘写满、外接盘或网络盘掉线时,写入会失败而程序 毫无察觉 —— 照样打印「录制完成」、照样自动合并,最后拿到的成片缺了一大截 弹幕,界面上看不出是「缺了」还是「本来就没弹幕」。 1. 写入器记住写盘错误 danmuWriter 增加一个 err 字段:每条弹幕的 ass / nick.ass / jsonl 写入, 以及文件头的写入,都检查返回值。只保留第一个错误 —— 磁盘满了之后 每写一条都会失败,全记下来只会把日志刷爆,而第一个就足以说明原因。 2. close() 负责把错误交出来 三个文件的 Close 错误也要记:os.File 在部分系统上(NFS、某些网络盘) 是到 Close 才报出「写不进去」,只盯每次 Write 的返回值会漏掉这种情况。 getDanmu 里拿到错误就明确告警,并把这条记录标记成「弹幕文件写坏了」。 3. 写坏的本场跳过自动合并 光打一条日志不够 —— 照样会合成一个缺弹幕的成片。liveInfo 增加 danmuWriteFailed,finishPart 据此跳过合并,录播和弹幕文件都留着, 用户检查磁盘后还能手动合并。 已验证: - 新增 4 个用例: TestDanmuWriteErrorSurfacedOnClose(写入失败后 failed() 为真、 close() 必须把错误交出来、错误里能看出是哪个文件)、 TestDanmuWriteKeepsOnlyFirstError(只记第一个)、 TestDanmuWriteNoFalseAlarm(正常录制不能误判,这是加检查最容易踩的反向坑)、 TestFinishPartSkipsMergeWhenDanmuBroken(先跑一个「条件都满足应当合并」 的对照组确认测试本身有效,再验证标了写坏之后不合并)。 - 反向验证:把 jsonl 的两处 Write 错误改回丢弃, TestDanmuWriteErrorSurfacedOnClose 立刻报「failed() 应为 true」。 - 测试踩到的两个坑(都记进注释):替换 w.jsonl 前要先关掉原句柄否则 Windows 上临时目录删不掉;测试里没跑 initialize(),lInfoMap.info 是 nil, 直接赋值会 panic。 - gofmt / go vet / go build / GOOS=linux go build / go test / go test -race 全过, 全量测试连跑两次稳定。 --- automerge.go | 7 ++- danmu.go | 96 ++++++++++++++++++++++------ recordjsonl_test.go | 150 ++++++++++++++++++++++++++++++++++++++++++++ utils.go | 7 ++- 4 files changed, 237 insertions(+), 23 deletions(-) diff --git a/automerge.go b/automerge.go index 8ebfb6e..2ad1638 100644 --- a/automerge.go +++ b/automerge.go @@ -29,10 +29,15 @@ func finishPart(liveID string, danmu bool) { info.isRecording = false info.recordDone = true } - shouldMerge := config.AutoMerge && mainCtx.Err() == nil && + // 弹幕文件写坏过就别合并了:成片会缺一大截弹幕,界面上看不出是缺还是本来就没弹幕 + broke := info.danmuWriteFailed + shouldMerge := config.AutoMerge && mainCtx.Err() == nil && !broke && !info.danmuStopped && !info.recordStopped && info.danmuDone && info.recordDone && !info.mergeStarted && info.assFile != "" && info.firstRecordFile != "" + if broke && config.AutoMerge && info.danmuDone && info.recordDone && !info.mergeStarted { + lPrintWarn("本场弹幕文件在录制时写盘出错,跳过自动合并(录播和弹幕文件都留着)") + } var segs []recordSegment if shouldMerge { info.mergeStarted = true diff --git a/danmu.go b/danmu.go index 63e3fb2..dc23215 100644 --- a/danmu.go +++ b/danmu.go @@ -165,6 +165,21 @@ type danmuWriter struct { jsonl *os.File lastO []laneState // 原格式的弹道状态 lastN []laneState // 昵称头像版的弹道状态 + err error // 写盘出过的错。只记头一个,见 danmuWriter.fail +} + +// 记一次写盘错误。只保留第一个:磁盘满了之后每写一条都会失败, +// 全记下来只会把日志刷爆,而第一个就足以说明原因 +func (w *danmuWriter) fail(what string, err error) { + if w.err == nil { + w.err = fmt.Errorf("%s失败:%v", what, err) + } +} + +// 本场弹幕有没有写坏。写坏的文件不能当正常的用 —— 否则合并出来的成片 +// 会缺一大截弹幕,而日志里什么都看不出来 +func (w *danmuWriter) failed() bool { + return w.err != nil } // 返回昵称头像版 ASS 和 jsonl 的文件路径 @@ -189,17 +204,33 @@ func newDanmuWriter(liveID, streamName string, cfg acfundanmu.SubConfig, assFile w.jsonl, err = os.OpenFile(jsonlFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) checkErr(err) - fmt.Fprintf(w.ass, scriptInfoTmpl, liveID, streamName, cfg.Title, cfg.PlayResX, cfg.PlayResY) - fmt.Fprintf(w.ass, stylesTmpl, cfg.FontSize) - fmt.Fprint(w.ass, eventsTmpl) - fmt.Fprintf(w.nick, scriptInfoTmpl, liveID, streamName, cfg.Title+" 昵称头像版", cfg.PlayResX, cfg.PlayResY) - fmt.Fprintf(w.nick, stylesTmpl, cfg.FontSize) - fmt.Fprint(w.nick, eventsTmpl) + if _, err := fmt.Fprintf(w.ass, scriptInfoTmpl, liveID, streamName, cfg.Title, cfg.PlayResX, cfg.PlayResY); err != nil { + w.fail("写原格式 ASS 文件头", err) + } + if _, err := fmt.Fprintf(w.ass, stylesTmpl, cfg.FontSize); err != nil { + w.fail("写原格式 ASS 文件头", err) + } + if _, err := fmt.Fprint(w.ass, eventsTmpl); err != nil { + w.fail("写原格式 ASS 文件头", err) + } + if _, err := fmt.Fprintf(w.nick, scriptInfoTmpl, liveID, streamName, cfg.Title+" 昵称头像版", cfg.PlayResX, cfg.PlayResY); err != nil { + w.fail("写昵称头像版 ASS 文件头", err) + } + if _, err := fmt.Fprintf(w.nick, stylesTmpl, cfg.FontSize); err != nil { + w.fail("写昵称头像版 ASS 文件头", err) + } + if _, err := fmt.Fprint(w.nick, eventsTmpl); err != nil { + w.fail("写昵称头像版 ASS 文件头", err) + } meta, err := json.Marshal(danmuMeta{PlayResX: cfg.PlayResX, PlayResY: cfg.PlayResY, FontSize: cfg.FontSize, StartTime: cfg.StartTime}) checkErr(err) - w.jsonl.Write(meta) - w.jsonl.Write([]byte("\n")) + if _, err := w.jsonl.Write(meta); err != nil { + w.fail("写弹幕 jsonl 文件头", err) + } + if _, err := w.jsonl.Write([]byte("\n")); err != nil { + w.fail("写弹幕 jsonl 文件头", err) + } return w } @@ -218,10 +249,12 @@ func (w *danmuWriter) write(c *acfundanmu.Comment) { } leftO := rel + (int64(s.PlayResX)*danmuShowDur)/int64(s.PlayResX+length) if i := placeLane(w.lastO, dtO, leftO); i >= 0 { - fmt.Fprintf(w.ass, dialogueTmpl, + if _, err := fmt.Fprintf(w.ass, dialogueTmpl, assTime(dtO.appear), assTime(dtO.disappear), strings.ReplaceAll(c.Nickname, ",", " "), c.UserID, - s.PlayResX+length/2, s.FontSize*(i+1), -length/2, s.FontSize*(i+1), c.Content) + s.PlayResX+length/2, s.FontSize*(i+1), -length/2, s.FontSize*(i+1), c.Content); err != nil { + w.fail("写原格式 ASS", err) + } } // 昵称头像版 ASS,\an7 锚点在文本左上角,头像由 ffmpeg 叠在文字左侧,不依赖字体渲染宽度 @@ -236,10 +269,12 @@ func (w *danmuWriter) write(c *acfundanmu.Comment) { } leftN := rel + (int64(s.PlayResX)*danmuShowDur)/int64(s.PlayResX+total) if i := placeLane(w.lastN, dtN, leftN); i >= 0 { - fmt.Fprintf(w.nick, dialogueNickTmpl, + if _, err := fmt.Fprintf(w.nick, dialogueNickTmpl, assTime(dtN.appear), assTime(dtN.disappear), strings.ReplaceAll(c.Nickname, ",", " "), c.UserID, - s.PlayResX+avW+avGap, s.FontSize*i, -width, s.FontSize*i, text) + s.PlayResX+avW+avGap, s.FontSize*i, -width, s.FontSize*i, text); err != nil { + w.fail("写昵称头像版 ASS", err) + } rec := danmuRecord{ Time: float64(rel) / 1e9, UID: c.UserID, @@ -251,8 +286,12 @@ func (w *danmuWriter) write(c *acfundanmu.Comment) { } b, err := json.Marshal(rec) checkErr(err) - w.jsonl.Write(b) - w.jsonl.Write([]byte("\n")) + if _, err := w.jsonl.Write(b); err != nil { + w.fail("写弹幕 jsonl", err) + } + if _, err := w.jsonl.Write([]byte("\n")); err != nil { + w.fail("写弹幕 jsonl", err) + } } } @@ -276,11 +315,20 @@ func (w *danmuWriter) consume(ctx context.Context, ac *acfundanmu.AcFunLive) { } } -// 关闭所有文件 -func (w *danmuWriter) close() { - w.ass.Close() - w.nick.Close() - w.jsonl.Close() +// 关闭所有文件,并把整个录制过程中出过的写盘错误一并交出来。 +// Close 自己的错误也要记:os.File 在部分系统上(NFS、某些网络盘)是到 Close +// 才报出「写不进去」的,只盯每次 Write 的返回值会漏掉这种情况 +func (w *danmuWriter) close() error { + if err := w.ass.Close(); err != nil { + w.fail("关闭原格式 ASS", err) + } + if err := w.nick.Close(); err != nil { + w.fail("关闭昵称头像版 ASS", err) + } + if err := w.jsonl.Close(); err != nil { + w.fail("关闭弹幕 jsonl", err) + } + return w.err } // ==================== 录制流程 ==================== @@ -305,7 +353,15 @@ func (s streamer) getDanmu(ctx context.Context, info liveInfo) { _ = ac.StartDanmu(ctx, false) streamInfo := ac.GetStreamInfo() w := newDanmuWriter(streamInfo.LiveID, streamInfo.StreamName, info.cfg, info.assFile) - defer w.close() + // 写盘出错要到关文件时才收得全(有的系统到 Close 才报「写不进去」)。 + // 必须说出来:否则本场照常自动合并,出来的成片缺一大截弹幕,日志里却一切正常 + defer func() { + if err := w.close(); err != nil { + lPrintErrf("录制%s的直播弹幕时写盘出错,弹幕文件不完整:%v", s.longID(), err) + lPrintErrf("本场不做自动合并,弹幕文件保留在 %s,请检查磁盘空间后手动合并", info.assFile) + updateLiveInfo(info.LiveID, func(i *liveInfo) { i.danmuWriteFailed = true }) + } + }() w.consume(ctx, ac) // 断流后先观察一个窗口:网络波动或主播短暂掉线(20 秒内)恢复时沿用同一个写入器接着写, diff --git a/recordjsonl_test.go b/recordjsonl_test.go index 88e0b38..06c1fd6 100644 --- a/recordjsonl_test.go +++ b/recordjsonl_test.go @@ -2,10 +2,14 @@ package main import ( "bufio" + "context" "encoding/json" + "errors" "os" "path/filepath" + "strings" "testing" + "time" "github.com/orzogc/acfundanmu" ) @@ -91,3 +95,149 @@ func countEmpty(recs []danmuRecord) int { } return n } + +// ==================== 写盘出错必须能被发现 ==================== +// 弹幕是边录边写的,写盘失败(磁盘满、外接盘/网络盘掉线)如果没人管, +// 程序会照常宣告「录制完成」并自动合并 —— 出来的成片缺一大截弹幕, +// 而日志里什么都看不出来。下面几条例外情况锁住「失败要浮出来」。 + +// 写入失败必须被记下来,并且在 close() 时交出来 +func TestDanmuWriteErrorSurfacedOnClose(t *testing.T) { + dir := t.TempDir() + cfg := acfundanmu.SubConfig{Title: "测试直播", PlayResX: 1920, PlayResY: 1080, + FontSize: 48, StartTime: 1700000000000000000} + ass := filepath.Join(dir, "live.ass") + w := newDanmuWriter("liveid", "stream", cfg, ass) + if w.failed() { + t.Fatalf("刚建好的写入器不该有错误,实际:%v", w.err) + } + + // 用「写到已关闭的句柄」来走同一条错误路径(和磁盘满、盘掉线一样, + // 都是 os.File.Write 返回 error)。换掉之前先把原来的句柄关掉,别泄漏 + if err := w.jsonl.Close(); err != nil { + t.Fatal(err) + } + dead, err := os.Open(filepath.Join(dir, "live.jsonl")) + if err != nil { + t.Fatal(err) + } + if err := dead.Close(); err != nil { + t.Fatal(err) + } + w.jsonl = dead + + c := &acfundanmu.Comment{Content: "这条应该写失败"} + c.SendTime = (cfg.StartTime + int64(500)*1e6) / 1e6 + c.UserID = 7 + c.Nickname = "观众" + w.write(c) + + if !w.failed() { + t.Fatal("写入失败之后 failed() 应为 true(改之前错误是被丢掉的)") + } + err = w.close() + if err == nil { + t.Fatal("close() 必须把写盘错误交出来,实际返回 nil") + } + if !strings.Contains(err.Error(), "jsonl") { + t.Errorf("错误信息里要能看出是哪个文件出的问题,实际:%v", err) + } +} + +// 只记第一个错:磁盘满了之后每写一条都会失败,全记下来会把日志刷爆 +func TestDanmuWriteKeepsOnlyFirstError(t *testing.T) { + w := &danmuWriter{} + w.fail("第一次", errors.New("磁盘满了")) + first := w.err + w.fail("第二次", errors.New("还是写不进去")) + w.fail("第三次", errors.New("依旧写不进去")) + if w.err != first { + t.Fatalf("错误应保持在第一个,实际被后来的覆盖了:%v", w.err) + } + if !strings.Contains(w.err.Error(), "磁盘满了") { + t.Errorf("应保留第一个错误的原因,实际:%v", w.err) + } +} + +// 正常录制不能被误判成失败(这是「加检查」最容易踩的反向坑) +func TestDanmuWriteNoFalseAlarm(t *testing.T) { + dir := t.TempDir() + cfg := acfundanmu.SubConfig{Title: "测试直播", PlayResX: 1920, PlayResY: 1080, + FontSize: 48, StartTime: 1700000000000000000} + w := newDanmuWriter("liveid", "stream", cfg, filepath.Join(dir, "ok.ass")) + for i := 0; i < 30; i++ { + c := &acfundanmu.Comment{Content: "正常弹幕"} + c.SendTime = (cfg.StartTime + int64(i)*220*1e6) / 1e6 + c.UserID = int64(100 + i) + c.Nickname = "观众" + w.write(c) + } + if w.failed() { + t.Fatalf("正常写入不该判失败:%v", w.err) + } + if err := w.close(); err != nil { + t.Fatalf("正常写完后 close() 应返回 nil,实际:%v", err) + } +} + +// 弹幕文件写坏之后必须跳过自动合并:只是打一条日志的话, +// 照样会合成一个「缺了一大截弹幕」的成片,而且是静默的 +func TestFinishPartSkipsMergeWhenDanmuBroken(t *testing.T) { + oldAuto, oldCtx := config.AutoMerge, mainCtx + t.Cleanup(func() { + config.AutoMerge, mainCtx = oldAuto, oldCtx + // 等后台那两个(可能启动了的)合并跑完,别把任务状态留给后面的用例 + takeMergeSlot() + releaseMergeSlot() + }) + config.AutoMerge = true + mainCtx = context.Background() + + dir := t.TempDir() + video := filepath.Join(dir, "v.mp4") + ass := filepath.Join(dir, "v.ass") + if err := os.WriteFile(video, []byte("fake"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(ass, []byte("[Script Info]"), 0644); err != nil { + t.Fatal(err) + } + + // 测试里没跑 initialize(),这张表还没建起来 + lInfoMap.Lock() + if lInfoMap.info == nil { + lInfoMap.info = make(map[string]liveInfo) + } + lInfoMap.Unlock() + + // 对照组:一切都正常时应该触发合并 + good := "good-live-id" + lInfoMap.Lock() + lInfoMap.info[good] = liveInfo{LiveID: good, uid: 1, assFile: ass, firstRecordFile: video, + recordStartNs: time.Now().UnixNano(), recordSegments: []recordSegment{{file: video, startNs: time.Now().UnixNano()}}} + lInfoMap.Unlock() + finishPart(good, true) + finishPart(good, false) + lInfoMap.RLock() + started := lInfoMap.info[good].mergeStarted + lInfoMap.RUnlock() + if !started { + t.Fatal("对照组:条件都满足时应当触发自动合并(否则这条测试本身没意义)") + } + + // 实验组:同样条件,但弹幕写盘出过错 + bad := "bad-live-id" + lInfoMap.Lock() + lInfoMap.info[bad] = liveInfo{LiveID: bad, uid: 2, assFile: ass, firstRecordFile: video, + danmuWriteFailed: true, + recordStartNs: time.Now().UnixNano(), recordSegments: []recordSegment{{file: video, startNs: time.Now().UnixNano()}}} + lInfoMap.Unlock() + finishPart(bad, true) + finishPart(bad, false) + lInfoMap.RLock() + got := lInfoMap.info[bad].mergeStarted + lInfoMap.RUnlock() + if got { + t.Error("弹幕文件写坏过,不该触发自动合并(否则会合成一个缺弹幕的成片)") + } +} diff --git a/utils.go b/utils.go index 476a8ba..3276a8d 100644 --- a/utils.go +++ b/utils.go @@ -67,8 +67,11 @@ type liveInfo struct { danmuStopped bool // 弹幕是否被用户手动停止(手动停止不触发自动合并) recordStopped bool // 视频是否被用户手动停止 mergeStarted bool // 自动合并是否已经启动 - doneAt int64 // 最近一次录制结束的时刻,纳秒 Unix(用于延迟清理这条记录) - resumeUntil int64 // 断流后等待直播恢复的截止时刻,纳秒 Unix;0 表示没在等待 + // 弹幕文件在写盘时出过错(磁盘满、网络盘断开等)。文件是不完整的, + // 拿它合并会得到一个缺弹幕的成片,所以本场跳过自动合并 + danmuWriteFailed bool + doneAt int64 // 最近一次录制结束的时刻,纳秒 Unix(用于延迟清理这条记录) + resumeUntil int64 // 断流后等待直播恢复的截止时刻,纳秒 Unix;0 表示没在等待 } // streamerInfo 的 map