198 lines
5.6 KiB
Go
198 lines
5.6 KiB
Go
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
|
||
})
|
||
}
|
||
}
|