fix(notify): 修复录制摘要通知在 Pipeline 处理前发送导致文件列表不准确

录制摘要通知原来在 recorder.run() 退出时立即发送,此时 Pipeline(转码、修复、上传等)
尚未执行。开启「上传后删除」时,通知中的文件实际已被删除,消息严重失真。

修复方案:
- recorder 中添加 pipelineEnqueued 标志,Pipeline 任务入队后跳过摘要推送
- Pipeline Manager 在任务完成/失败后发送摘要,反映最终文件状态
- FileInfo 添加 Size 字段,创建时预存文件大小,确保文件删除后仍可构建摘要
- 提取 sendToAllChannels 消除通道推送重复代码
- 新增 buildUploadedSummaryBody 构造「已上传到云端」场景的消息体

Co-Authored-By: Kscc <noreply@owtffssent.com>
This commit is contained in:
JoftalandKscc committed 2026-08-13 16:53:57 +08:00
1 parent 2b75cd31f9
commit ce35cfd26c
5 files changed
+271 -15

No files matched your search

+74 -2
View File
@@ -16,8 +16,9 @@ import (
// RecordingFileDetail 录制文件详情
type RecordingFileDetail struct {
Name string // 文件名(不含路径)
Size int64 // 文件大小(字节)
Name string // 文件名(不含路径)
Size int64 // 文件大小(字节)
Uploaded bool // 是否已上传到云端(用于摘要消息标注)
}
// SendNotification 发送统一通知函数
@@ -250,6 +251,29 @@ func buildRecordingSummaryMessage(hostName, platform string, files []RecordingFi
return
}
// buildUploadedSummaryBody 构造"已上传到云端"场景的录制摘要消息体
// 与 buildRecordingSummaryMessage 类似,但标注文件已上传、不显示磁盘空间
func buildUploadedSummaryBody(platform string, files []RecordingFileDetail) string {
const maxDisplayFiles = 30
var sb strings.Builder
fmt.Fprintf(&sb, "平台:%s\n", platform)
fmt.Fprintf(&sb, "录制文件:%d 个(已上传到云端)\n", len(files))
var totalSize int64
for i, f := range files {
totalSize += f.Size
if i < maxDisplayFiles {
fmt.Fprintf(&sb, " %d. %s (%s)\n", i+1, f.Name, formatFileSize(f.Size))
}
}
if len(files) > maxDisplayFiles {
fmt.Fprintf(&sb, " ... 还有 %d 个文件未显示\n", len(files)-maxDisplayFiles)
}
fmt.Fprintf(&sb, "总大小:%s\n", formatFileSize(totalSize))
fmt.Fprintf(&sb, "本地文件已清理")
return sb.String()
}
// SendRecordingSummary 录制结束后发送录制文件摘要通知
// outputPath 为录制输出路径,用于获取剩余磁盘空间
func SendRecordingSummary(logger *livelogger.LiveLogger, hostName, platform string, files []RecordingFileDetail, outputPath string) {
@@ -262,7 +286,55 @@ func SendRecordingSummary(logger *livelogger.LiveLogger, hostName, platform stri
}
title, body := buildRecordingSummaryMessage(hostName, platform, files, outputPath)
sendToAllChannels(cfg, logger, title, body)
}
// SendPipelineRecordingSummary Pipeline 完成后发送录制摘要通知
// originalFiles: 原始录制文件列表(allUploaded 时用于显示)
// finalFiles: Pipeline 处理后的最终文件列表(含 Uploaded 标记)
func SendPipelineRecordingSummary(
logger *livelogger.LiveLogger,
hostName, platform string,
originalFiles, finalFiles []RecordingFileDetail,
outputPath string,
) {
cfg := configs.GetCurrentConfig()
if cfg == nil || !cfg.Notify.SendRecordingSummary {
return
}
// 分离已上传文件和保留文件
var kept []RecordingFileDetail
allUploaded := len(finalFiles) > 0
for _, f := range finalFiles {
if f.Uploaded {
// 已上传文件不显示在最终列表中(已从磁盘删除)
} else {
allUploaded = false
kept = append(kept, f)
}
}
var title, body string
if allUploaded {
// 所有文件已上传并删除:显示原始文件列表 + 上传状态
if len(originalFiles) == 0 {
return
}
title = fmt.Sprintf("%s 录制完成", hostName)
body = buildUploadedSummaryBody(platform, originalFiles)
} else if len(kept) > 0 {
// 有文件保留在磁盘:显示保留的文件列表
title, body = buildRecordingSummaryMessage(hostName, platform, kept, outputPath)
} else {
return
}
sendToAllChannels(cfg, logger, title, body)
}
// sendToAllChannels 向所有已启用的通知通道推送摘要消息
func sendToAllChannels(cfg *configs.Config, logger *livelogger.LiveLogger, title, body string) {
// Telegram
if cfg.Notify.Telegram.Enable {
msg := fmt.Sprintf("%s\n%s", title, body)
+17
View File
@@ -114,6 +114,9 @@ func (e *Executor) Execute(
output, commands, logs, err = e.executeStage(ctx, stageCfg, files)
}
// 回填新产出文件的 Size(阶段内构造 FileInfo 时可能未 stat)
backfillFileSizes(output)
// 记录结果
result := StageResult{
StageName: stageCfg.Name,
@@ -173,6 +176,8 @@ func (e *Executor) Execute(
}
return results, ctx.Ctx.Err()
}
// 保存最后阶段输出的快照(用于回调提取上传文件详情),再执行清理
ctx.LastStageFiles = files
keptFiles := e.deleteMarkedFiles(files)
if len(results) > 0 {
results[len(results)-1].OutputFiles = keptFiles
@@ -472,6 +477,18 @@ func deduplicateFiles(files []FileInfo) []FileInfo {
return result
}
// backfillFileSizes 对 Size 为 0 的文件执行 os.Stat 回填大小
// 阶段内构造 FileInfo 时可能未 stat,导致摘要通知漏掉这些文件
func backfillFileSizes(files []FileInfo) {
for i := range files {
if files[i].Size == 0 {
if fi, err := os.Stat(files[i].Path); err == nil {
files[i].Size = fi.Size()
}
}
}
}
// getTimeNow 获取当前时间(可用于测试时 mock)
var getTimeNow = func() time.Time {
return time.Now()
+29 -1
View File
@@ -13,6 +13,7 @@ import (
"github.com/bililive-go/bililive-go/src/configs"
"github.com/bililive-go/bililive-go/src/instance"
"github.com/bililive-go/bililive-go/src/live"
"github.com/bililive-go/bililive-go/src/notify"
"github.com/bililive-go/bililive-go/src/pkg/events"
"github.com/bililive-go/bililive-go/src/pkg/livelogger"
bilisentry "github.com/bililive-go/bililive-go/src/pkg/sentry"
@@ -270,6 +271,9 @@ func (m *Manager) executeTask(ctx context.Context, task *PipelineTask) {
logrus.WithField("task_id", task.ID).Info("pipeline task completed successfully")
}
// 保存最后阶段输出的快照(清理前),用于回调提取上传文件详情
task.LastStageFiles = pipelineCtx.LastStageFiles
// 更新任务状态
if err := m.store.UpdateTask(m.ctx, task); err != nil {
logrus.WithError(err).Error("failed to update pipeline task status after execution")
@@ -277,6 +281,11 @@ func (m *Manager) executeTask(ctx context.Context, task *PipelineTask) {
// 广播任务状态变化
m.broadcastTaskUpdate(task)
// 调用任务完成回调(recorder 用于追踪 Pipeline 状态并统一发送摘要)
if task.OnTaskComplete != nil {
task.OnTaskComplete(task)
}
}
// broadcastTaskUpdate 广播任务更新事件
@@ -286,6 +295,22 @@ func (m *Manager) broadcastTaskUpdate(task *PipelineTask) {
}
}
// FileInfoToDetails 将 FileInfo 切片转换为 RecordingFileDetail 切片
// 使用 FileInfo 中预存的 Size,无需再次 stat 文件
// 仅包含视频和其他类型文件,排除封面(封面不在录制摘要中展示)
func FileInfoToDetails(files []FileInfo) []notify.RecordingFileDetail {
details := make([]notify.RecordingFileDetail, 0, len(files))
for _, f := range files {
if f.Size > 0 && f.Type != FileTypeCover {
details = append(details, notify.RecordingFileDetail{
Name: filepath.Base(f.Path),
Size: f.Size,
})
}
}
return details
}
// EnqueueTask 添加任务到队列
func (m *Manager) EnqueueTask(task *PipelineTask) error {
if err := m.store.CreateTask(m.ctx, task); err != nil {
@@ -309,10 +334,12 @@ func (m *Manager) EnqueueTask(task *PipelineTask) error {
// EnqueueRecordingTask 创建并入队录制完成后的处理任务
// 这是 recorder.go 调用的主要入口
// onTaskComplete: 任务完成回调(可选),用于通知 recorder 任务结束
func (m *Manager) EnqueueRecordingTask(
info *live.Info,
pipelineConfig *PipelineConfig,
outputFiles []string,
onTaskComplete func(task *PipelineTask),
) error {
// 构建文件信息列表
files := make([]FileInfo, len(outputFiles))
@@ -322,6 +349,7 @@ func (m *Manager) EnqueueRecordingTask(
// 创建任务
task := NewPipelineTask(NewRecordInfo(info), pipelineConfig, files)
task.OnTaskComplete = onTaskComplete
return m.EnqueueTask(task)
}
@@ -644,7 +672,7 @@ func (m *Manager) EnqueueUploadTask(absPaths []string) (enqueued int, skipped []
platform, hostName := inferUploadPathInfo(relPath)
recordInfo := RecordInfo{
LiveID: types.LiveID("manual-upload"),
LiveID: types.LiveID(ManualUploadLiveID),
Platform: platform, // 用于上传路径模板:{{ .Platform }}
HostName: hostName, // 用于上传路径模板:{{ .HostName }}
RoomName: "", // 手动上传无房间名,不影响路径模板
+17 -2
View File
@@ -4,6 +4,7 @@ package pipeline
import (
"context"
"os"
"time"
"github.com/bililive-go/bililive-go/src/live"
@@ -15,6 +16,9 @@ import (
// PipelineTaskUpdateEvent 管道任务更新事件
const PipelineTaskUpdateEvent events.EventType = "PipelineTaskUpdate"
// ManualUploadLiveID 手动上传任务的 LiveID 标识
const ManualUploadLiveID = "manual-upload"
// FileType 文件类型
type FileType string
@@ -30,18 +34,23 @@ const (
// FileInfo 表示管道中流转的文件信息
type FileInfo struct {
Path string `json:"path"` // 文件绝对路径
Size int64 `json:"size,omitempty"` // 文件大小(字节),在任务创建时记录
Type FileType `json:"type"` // 文件类型
SourcePath string `json:"source_path,omitempty"` // 来源文件路径(用于追踪转换链)
Metadata map[string]any `json:"metadata,omitempty"` // 额外元数据
Deletable bool `json:"deletable,omitempty"` // 标记此文件在管道全部成功后可删除
}
// NewVideoFileInfo 创建视频文件信息
// NewVideoFileInfo 创建视频文件信息,自动记录文件大小
func NewVideoFileInfo(path string) FileInfo {
return FileInfo{
fi := FileInfo{
Path: path,
Type: FileTypeVideo,
}
if stat, err := os.Stat(path); err == nil {
fi.Size = stat.Size()
}
return fi
}
// NewCoverFileInfo 创建封面文件信息
@@ -84,6 +93,10 @@ type PipelineContext struct {
// FFmpegPath 是 ffmpeg 可执行文件的路径
FFmpegPath string
// LastStageFiles 最后阶段输出的文件快照(清理前保存)
// 用于回调提取上传文件详情,因为 deleteMarkedFiles 会删除文件
LastStageFiles []FileInfo
}
// Stage 管道阶段接口
@@ -243,6 +256,8 @@ type PipelineTask struct {
CompletedAt *time.Time `json:"completed_at,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
CanRetry bool `json:"can_retry"` // 是否可以重试
OnTaskComplete func(task *PipelineTask) `json:"-"` // 任务完成回调(不持久化)
LastStageFiles []FileInfo `json:"-"` // 最后阶段输出文件(清理前快照,不持久化)
}
// NewPipelineTask 创建新的管道任务
+134 -10
View File
@@ -281,6 +281,18 @@ type recorder struct {
currentFileLock sync.RWMutex
currentFilePath string
// pipelineEnqueued 标记 Pipeline 任务已入队,由 Pipeline Manager 负责发送录制摘要
// 避免 recorder 在 Pipeline 处理完成前发送不准确的原始文件列表
pipelineEnqueued bool
// pipelinePendingCount 追踪尚未完成的 Pipeline 任务数量
// 仅当所有任务完成且录制结束后,才发送统一的录制摘要
pipelinePendingCount int
// pipelineMu 保护 pipelinePendingCount 和 pipelineDetails 的并发访问
pipelineMu sync.Mutex
// pipelineDetails 收集各 Pipeline 任务的最终文件详情
// 任务完成时通过回调写入,录制结束时读取用于构造摘要
pipelineDetails []notify.RecordingFileDetail
// 当前录制的流信息(来自平台 API)
currentStreamInfo *live.AvailableStreamInfo
@@ -784,9 +796,11 @@ func (r *recorder) tryRecord(ctx context.Context) {
}
// 入队 Pipeline 任务
if err := pipelineManager.EnqueueRecordingTask(info, pipelineConfig, outputFiles); err != nil {
if err := pipelineManager.EnqueueRecordingTask(info, pipelineConfig, outputFiles, r.onPipelineTaskComplete); err != nil {
r.getLogger().WithError(err).Error("failed to enqueue pipeline task")
} else {
r.pipelineEnqueued = true
r.pipelinePendingCount++
// 记录 Pipeline 阶段顺序
var stageNames []string
for _, stage := range pipelineConfig.Stages {
@@ -958,22 +972,26 @@ func (r *recorder) accumulateRecordedFiles(files ...string) {
// sendAccumulatedSummary 录制结束后统一推送录制文件摘要通知
// 在 run() 退出时通过 defer 调用,确保所有分段文件汇总为一条通知
func (r *recorder) sendAccumulatedSummary() {
r.pipelineMu.Lock()
pendingCount := r.pipelinePendingCount
r.pipelineMu.Unlock()
// Pipeline 任务尚未全部完成,等待回调触发最终摘要
if r.pipelineEnqueued && pendingCount > 0 {
r.getLogger().Infof("Pipeline 任务尚未全部完成(剩余 %d 个),延迟摘要推送", pendingCount)
return
}
r.recordedFilesMu.Lock()
defer r.recordedFilesMu.Unlock()
if r.suppressSummary {
r.getLogger().Infof("录制摘要推送已抑制(分段重启),累积 %d 个文件将传递给新 recorder", len(r.recordedFiles))
return
}
if len(r.recordedFiles) == 0 {
if len(r.recordedFiles) == 0 && len(r.pipelineDetails) == 0 {
r.getLogger().Info("无录制文件,跳过摘要推送")
return
}
obj, err := r.cache.Get(r.Live)
if err != nil {
r.getLogger().WithError(err).Error("获取直播信息失败,无法推送录制摘要")
return
}
info := obj.(*live.Info)
// 获取录制输出路径,用于查询剩余磁盘空间
cfg := configs.GetCurrentConfig()
@@ -984,8 +1002,114 @@ func (r *recorder) sendAccumulatedSummary() {
outputPath = resolved.OutPutPath
}
r.getLogger().Infof("推送录制摘要:%d 个文件", len(r.recordedFiles))
notify.SendRecordingSummary(r.getLogger(), info.HostName, r.Live.GetPlatformCNName(), r.recordedFiles, outputPath)
// 使用 Pipeline 收集的最终文件详情(如有),否则用录制累积的原始文件
if len(r.pipelineDetails) > 0 {
r.getLogger().Infof("推送 Pipeline 录制摘要:%d 个文件", len(r.pipelineDetails))
hostName := ""
platform := r.Live.GetPlatformCNName()
if obj, cacheErr := r.cache.Get(r.Live); cacheErr == nil {
if info, ok := obj.(*live.Info); ok {
hostName = info.HostName
}
}
notify.SendPipelineRecordingSummary(
r.getLogger(),
hostName,
platform,
r.recordedFiles, // originalFiles(allUploaded 时显示用)
r.pipelineDetails, // finalFiles
outputPath,
)
} else {
// Pipeline 未产出有效文件详情,回退到录制累积
if len(r.recordedFiles) == 0 {
return
}
obj, err := r.cache.Get(r.Live)
if err != nil {
r.getLogger().WithError(err).Error("获取直播信息失败,无法推送录制摘要")
return
}
info := obj.(*live.Info)
r.getLogger().Infof("推送录制摘要:%d 个文件", len(r.recordedFiles))
notify.SendRecordingSummary(r.getLogger(), info.HostName, r.Live.GetPlatformCNName(), r.recordedFiles, outputPath)
}
}
// onPipelineTaskComplete Pipeline 任务完成回调
// 由 Pipeline Manager 在 executeTask 结束时调用
func (r *recorder) onPipelineTaskComplete(task *pipeline.PipelineTask) {
r.pipelineMu.Lock()
r.pipelinePendingCount--
// 收集任务的最终文件详情
switch task.Status {
case pipeline.PipelineStatusCompleted:
finalFiles := task.CurrentFiles
if len(finalFiles) == 0 {
// 所有文件已上传并删除:从 LastStageFiles 提取真正上传的文件
uploadedDetails := extractUploadedDetails(task.LastStageFiles)
if len(uploadedDetails) > 0 {
r.pipelineDetails = append(r.pipelineDetails, uploadedDetails...)
} else {
// 回退:无上传标记时用原始文件
for _, d := range pipeline.FileInfoToDetails(task.InitialFiles) {
d.Uploaded = true
r.pipelineDetails = append(r.pipelineDetails, d)
}
}
} else {
r.pipelineDetails = append(r.pipelineDetails, pipeline.FileInfoToDetails(finalFiles)...)
}
case pipeline.PipelineStatusFailed, pipeline.PipelineStatusCancelled:
// 失败/取消时优先用 CurrentFiles(最后成功阶段的输出),回退到 InitialFiles
if len(task.CurrentFiles) > 0 {
r.pipelineDetails = append(r.pipelineDetails, pipeline.FileInfoToDetails(task.CurrentFiles)...)
} else {
r.pipelineDetails = append(r.pipelineDetails, pipeline.FileInfoToDetails(task.InitialFiles)...)
}
}
remaining := r.pipelinePendingCount
// 防止回调被重复执行(如内存中重试场景)
task.OnTaskComplete = nil
r.pipelineMu.Unlock()
r.getLogger().Infof("Pipeline 任务完成(状态: %s),剩余 %d 个任务", task.Status, remaining)
// 所有任务完成且录制已结束,发送统一摘要
// 使用 goroutine 避免通知发送(Telegram/Email 等)阻塞 Pipeline 任务槽位
if remaining == 0 {
select {
case <-r.done:
// run() 已退出,异步发送摘要(不阻塞 executeTask)
go r.sendAccumulatedSummary()
default:
// run() 仍在运行,等待 run() 退出时由 sendAccumulatedSummary 发送
}
}
}
// extractUploadedDetails 从最后阶段输出文件中提取有上传标记的文件详情
// CloudUploadStage 会为成功上传的文件设置 Metadata["uploaded"]=true
func extractUploadedDetails(files []pipeline.FileInfo) []notify.RecordingFileDetail {
var details []notify.RecordingFileDetail
for _, f := range files {
if f.Metadata != nil {
if uploaded, ok := f.Metadata["uploaded"].(bool); ok && uploaded {
if f.Size > 0 {
details = append(details, notify.RecordingFileDetail{
Name: filepath.Base(f.Path),
Size: f.Size,
Uploaded: true,
})
}
}
}
}
return details
}
func (r *recorder) logStreamURLRetry(err error) {