package handlers import ( "context" "fmt" "log" "os" "os/exec" "path/filepath" "strings" "time" "go.mongodb.org/mongo-driver/v2/bson" "yh_web/server/config" ) func skipPromotionTranscode() bool { v := strings.TrimSpace(os.Getenv("SKIP_PROMOTION_TRANSCODE")) return v == "1" || strings.EqualFold(v, "true") } func ffmpegAvailable() bool { _, err := exec.LookPath("ffmpeg") return err == nil } func isMOVUnderPromotion(relPath string, ext string) bool { if strings.ToLower(ext) != ".mov" { return false } return strings.Contains(filepath.ToSlash(relPath), "/promotion/") } func mp4PathForMOV(movPath string) string { return strings.TrimSuffix(movPath, filepath.Ext(movPath)) + ".mp4" } func needsTranscode(movPath, mp4Path string) bool { mi, err1 := os.Stat(movPath) if err1 != nil || mi.IsDir() { return false } pi, err2 := os.Stat(mp4Path) if err2 != nil { return true } return mi.ModTime().After(pi.ModTime()) } // runFFmpegMOVToMP4 将 mov 转为浏览器通用 mp4(与前端/脚本参数一致) func runFFmpegMOVToMP4(ctx context.Context, movPath, mp4Path string) error { cmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", movPath, "-c:v", "libx264", "-profile:v", "high", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", mp4Path, ) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("%w: %s", err, string(out)) } return nil } func relPathFromUploadRoot(uploadRoot, fullPath string) (string, error) { r, err := filepath.Rel(uploadRoot, fullPath) if err != nil { return "", err } return filepath.ToSlash(r), nil } // replaceMOVWithMP4InDB 将 site_assets 中对应 .mov 记录更新为 .mp4(转码成功后调用) func replaceMOVWithMP4InDB(siteID, oldRelPath, mp4FullPath string, insertedID any) { if config.MongoClient == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) defer cancel() coll := config.GetDB(config.DBName).Collection("site_assets") newRel := strings.TrimSuffix(oldRelPath, filepath.Ext(oldRelPath)) + ".mp4" fi, err := os.Stat(mp4FullPath) if err != nil { log.Printf("[promotion-transcode] stat mp4: %v", err) return } set := bson.M{ "file_path": newRel, "name": filepath.Base(newRel), "size": fi.Size(), "content_type": "video/mp4", } filter := bson.M{"site_id": siteID, "file_path": oldRelPath} if oid, ok := insertedID.(bson.ObjectID); ok && !oid.IsZero() { filter = bson.M{"_id": oid, "site_id": siteID} } _, err = coll.UpdateOne(ctx, filter, bson.M{"$set": set}) if err != nil { log.Printf("[promotion-transcode] 更新数据库失败: %v", err) } } // ScheduleTranscodeAfterUpload 上传保存成功后异步:promotion 下 .mov -> .mp4,并更新本条 site_assets func ScheduleTranscodeAfterUpload(siteID, relPath, movFullPath string, insertedID any) { if skipPromotionTranscode() || !isMOVUnderPromotion(relPath, filepath.Ext(movFullPath)) { return } go func() { if !ffmpegAvailable() { log.Printf("[promotion-transcode] 已上传 .mov 但未安装 ffmpeg,无法转码: %s", relPath) return } mp4Full := mp4PathForMOV(movFullPath) if !needsTranscode(movFullPath, mp4Full) { return } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour) defer cancel() log.Printf("[promotion-transcode] 开始转码: %s -> %s", movFullPath, mp4Full) if err := runFFmpegMOVToMP4(ctx, movFullPath, mp4Full); err != nil { log.Printf("[promotion-transcode] 转码失败 %s: %v", relPath, err) return } if err := os.Remove(movFullPath); err != nil { log.Printf("[promotion-transcode] 删除原 .mov 失败(可手动删): %v", err) } replaceMOVWithMP4InDB(siteID, relPath, mp4Full, insertedID) log.Printf("[promotion-transcode] 完成: %s", newRelLog(relPath)) }() } func newRelLog(oldRel string) string { return strings.TrimSuffix(oldRel, filepath.Ext(oldRel)) + ".mp4" } // SweepPromotionTranscodeOnStartup 扫描 uploads/sites/*/promotion/**.mov,补转码并同步数据库(已有文件) func SweepPromotionTranscodeOnStartup() { time.Sleep(3 * time.Second) if skipPromotionTranscode() { log.Println("[promotion-transcode] 启动扫描已跳过 SKIP_PROMOTION_TRANSCODE=1") return } if !ffmpegAvailable() { log.Println("[promotion-transcode] 启动扫描跳过:未找到 ffmpeg(安装后可重启服务)") return } root := getUploadDir() sitesDir := filepath.Join(root, "sites") fi, err := os.Stat(sitesDir) if err != nil || !fi.IsDir() { return } entries, err := os.ReadDir(sitesDir) if err != nil { return } for _, e := range entries { if !e.IsDir() { continue } siteID := e.Name() promoRoot := filepath.Join(sitesDir, siteID, "promotion") _ = filepath.WalkDir(promoRoot, func(path string, d os.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } if strings.ToLower(filepath.Ext(path)) != ".mov" { return nil } mp4Full := mp4PathForMOV(path) if !needsTranscode(path, mp4Full) { return nil } rel, err := relPathFromUploadRoot(root, path) if err != nil { return nil } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour) log.Printf("[promotion-transcode] [启动补转] %s", rel) err = runFFmpegMOVToMP4(ctx, path, mp4Full) cancel() if err != nil { log.Printf("[promotion-transcode] [启动补转] 失败 %s: %v", rel, err) return nil } _ = os.Remove(path) replaceMOVWithMP4InDB(siteID, rel, mp4Full, nil) log.Printf("[promotion-transcode] [启动补转] 完成 %s", newRelLog(rel)) return nil }) } }