From 38d8706038e3e11831b7a7a994b10b8d68243f1f Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 18 Jan 2026 13:49:20 +0800 Subject: [PATCH] feat: generate media cover assets --- backend/app/jobs/media_process_job.go | 131 +++++++++++++++++++++++++- docs/todo_list.md | 1 + 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/backend/app/jobs/media_process_job.go b/backend/app/jobs/media_process_job.go index 017046b..dfb8bc7 100644 --- a/backend/app/jobs/media_process_job.go +++ b/backend/app/jobs/media_process_job.go @@ -2,20 +2,26 @@ package jobs import ( "context" + "crypto/md5" + "encoding/hex" "errors" + "io" "os" "os/exec" + "path" "path/filepath" "strings" "time" "quyun/v2/app/jobs/args" + "quyun/v2/database/fields" "quyun/v2/database/models" "quyun/v2/pkg/consts" "quyun/v2/providers/storage" "github.com/riverqueue/river" log "github.com/sirupsen/logrus" + "go.ipao.vip/gen/types" "gorm.io/gorm" ) @@ -74,8 +80,8 @@ func (j *MediaProcessWorker) Work(ctx context.Context, job *river.Job[args.Media log.Warn("ffmpeg not found, skipping real transcoding") } else { outputDir := filepath.Dir(inputFile) - coverKey := asset.ObjectKey + ".jpg" - coverFile := filepath.Join(outputDir, filepath.Base(coverKey)) + coverTempKey := asset.ObjectKey + ".jpg" + coverFile := filepath.Join(outputDir, filepath.Base(coverTempKey)) // 生成封面,作为后续管线的占位输出。 cmd := exec.CommandContext( @@ -95,7 +101,11 @@ func (j *MediaProcessWorker) Work(ctx context.Context, job *river.Job[args.Media finalStatus = consts.MediaAssetStatusFailed } else { log.Infof("Generated cover: %s", coverFile) - // TODO: 生成封面资产或写入 meta,由后续管线接管。 + // 生成封面资产记录,便于后台可追踪产物。 + if err := j.registerCoverAsset(ctx, asset, coverFile); err != nil { + log.Errorf("register cover failed: %s", err) + finalStatus = consts.MediaAssetStatusFailed + } } } } else { @@ -112,3 +122,118 @@ func (j *MediaProcessWorker) Work(ctx context.Context, job *river.Job[args.Media }) return err } + +func (j *MediaProcessWorker) registerCoverAsset(ctx context.Context, asset *models.MediaAsset, coverFile string) error { + if asset == nil || coverFile == "" { + return nil + } + if _, err := os.Stat(coverFile); err != nil { + return err + } + + // 已存在封面派生资产时直接跳过。 + tbl, q := models.MediaAssetQuery.QueryContext(ctx) + existing, err := q.Where( + tbl.SourceAssetID.Eq(asset.ID), + tbl.Type.Eq(consts.MediaAssetTypeImage), + ).First() + if err == nil && existing != nil { + return nil + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + hash, size, err := fileMD5(coverFile) + if err != nil { + return err + } + + var tenant *models.Tenant + if asset.TenantID > 0 { + tenant, err = models.TenantQuery.WithContext(ctx). + Where(models.TenantQuery.ID.Eq(asset.TenantID)). + First() + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + } + + filename := asset.Meta.Data().Filename + if filename == "" { + filename = filepath.Base(asset.ObjectKey) + } + coverName := coverFilename(filename) + objectKey := buildObjectKey(tenant, hash, coverName) + + // 本地存储将文件移动到目标 objectKey 位置,保持路径规范。 + localPath := j.storage.Config.LocalPath + if localPath == "" { + localPath = "./storage" + } + dstPath := filepath.Join(localPath, filepath.FromSlash(objectKey)) + if coverFile != dstPath { + if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil { + return err + } + if _, err := os.Stat(dstPath); err == nil { + _ = os.Remove(coverFile) + } else if err := os.Rename(coverFile, dstPath); err != nil { + return err + } + } + + coverAsset := &models.MediaAsset{ + TenantID: asset.TenantID, + UserID: asset.UserID, + Type: consts.MediaAssetTypeImage, + Status: consts.MediaAssetStatusReady, + Provider: asset.Provider, + Bucket: asset.Bucket, + ObjectKey: objectKey, + Hash: hash, + Variant: consts.MediaAssetVariantMain, + SourceAssetID: asset.ID, + Meta: types.NewJSONType(fields.MediaAssetMeta{ + Filename: coverName, + Size: size, + }), + } + if err := models.MediaAssetQuery.WithContext(ctx).Create(coverAsset); err != nil { + return err + } + return nil +} + +func coverFilename(filename string) string { + base := strings.TrimSuffix(filename, filepath.Ext(filename)) + if base == "" { + base = "cover" + } + return base + "_cover.jpg" +} + +func buildObjectKey(tenant *models.Tenant, hash, filename string) string { + // 按租户维度组织对象路径:quyun//. + tenantUUID := "public" + if tenant != nil && tenant.UUID.String() != "" { + tenantUUID = tenant.UUID.String() + } + ext := strings.ToLower(filepath.Ext(filename)) + return path.Join("quyun", tenantUUID, hash+ext) +} + +func fileMD5(filename string) (string, int64, error) { + f, err := os.Open(filename) + if err != nil { + return "", 0, err + } + defer f.Close() + + h := md5.New() + size, err := io.Copy(h, f) + if err != nil { + return "", size, err + } + return hex.EncodeToString(h.Sum(nil)), size, nil +} diff --git a/docs/todo_list.md b/docs/todo_list.md index b0f0e3e..d0724a1 100644 --- a/docs/todo_list.md +++ b/docs/todo_list.md @@ -268,6 +268,7 @@ - Worker:从对象存储下载源文件到临时目录 → FFmpeg 处理 → 结果上传回对象存储 → 清理临时文件。 - 产物:封面/预览片段自动生成并回写 `media_assets`。 - 本地 FS 仍保留兼容路径(开发/测试使用)。 + - 进度:本地视频处理已可生成封面资产(ffmpeg 可用时)。 **测试方案** - 对象存储模式下上传视频触发处理,封面/预览可访问;任务异常可重试。