弹幕写盘出错不再被静默吞掉
审查发现:录制时每写一条弹幕都调用 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 全过, 全量测试连跑两次稳定。
This commit is contained in:
@@ -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 秒内)恢复时沿用同一个写入器接着写,
|
||||
|
||||
Reference in New Issue
Block a user