package main import ( "bytes" "fmt" "io/fs" "log" "os" "os/exec" "path/filepath" "regexp" "sort" "strconv" "strings" ) // Movie 电影信息结构 type Movie struct { FileName string `json:"filename"` // 原始文件名(如 "video1.mp4") Image string `json:"image"` // 对应的缩略图文件名(如 "video1.png") Duration int `json:"duration"` // 视频时长(分钟) TimeCategory string `json:"time_category"` // 时长分类(如 "15min", "30min") VideoPath string `json:"-"` // 视频文件完整路径(用于ffmpeg) CreatedTime int64 `json:"created_time"` // 文件创建时间戳 } var movies []Movie // 存储所有电影信息的全局切片 var IsRemakePNG = false // 是否重新生成所有PNG缩略图 // getVideoDuration 获取视频时长(秒) func getVideoDuration(videoPath string) (float64, error) { cmd := exec.Command("ffmpeg", "-i", videoPath) var stderr bytes.Buffer cmd.Stderr = &stderr _ = cmd.Run() // 忽略错误,ffmpeg在没有输出文件时会返回错误 // 解析ffmpeg输出的时长信息 output := stderr.String() re := regexp.MustCompile(`Duration: (\d{2}):(\d{2}):(\d{2}\.\d{2})`) matches := re.FindStringSubmatch(output) // 尝试匹配不含毫秒的格式 if len(matches) < 4 { reAlt := regexp.MustCompile(`Duration: (\d{2}):(\d{2}):(\d{2})`) matches = reAlt.FindStringSubmatch(output) if len(matches) < 4 { return 0, fmt.Errorf("无法在ffmpeg输出中找到时长: %s. 输出: %s", filepath.Base(videoPath), output) } matches[3] += ".00" // 添加毫秒部分保持格式一致 } // 转换为秒数 hours, _ := strconv.ParseFloat(matches[1], 64) minutes, _ := strconv.ParseFloat(matches[2], 64) seconds, _ := strconv.ParseFloat(matches[3], 64) return hours*3600 + minutes*60 + seconds, nil } // getFileCreationTime 获取文件创建时间戳 func getFileCreationTime(path string) (int64, error) { fileInfo, err := os.Stat(path) if err != nil { return 0, err } return fileInfo.ModTime().Unix(), nil } // scanMovieFiles 扫描movie目录并返回文件列表 func scanMovieFiles() ([]string, error) { files, err := filepath.Glob("movie/*") if err != nil { return nil, fmt.Errorf("扫描movie目录失败: %w", err) } return files, nil } // createMovieDict 创建需要处理缩略图的视频字典 func createMovieDict(files []string) map[string]*Movie { movieDict := make(map[string]*Movie) for _, fullPath := range files { filename := filepath.Base(fullPath) ext := filepath.Ext(filename) baseName := strings.TrimSuffix(filename, ext) // 跳过目录和系统文件 if ext == ".db" { continue } // 处理PNG文件 if ext == ".png" { if _, exists := movieDict[baseName]; exists && !IsRemakePNG { delete(movieDict, baseName) } continue } // 处理视频文件 if ext != "" { if _, exists := movieDict[baseName]; !exists || IsRemakePNG { movieDict[baseName] = &Movie{VideoPath: fullPath} } } } return movieDict } // processVideoFile 处理单个视频文件 func processVideoFile(path string, movieDict map[string]*Movie) error { filename := filepath.Base(path) baseName := strings.TrimSuffix(filename, filepath.Ext(filename)) // 获取视频时长 durationSec, err := getVideoDuration(path) if err != nil { delete(movieDict, baseName) return fmt.Errorf("获取时长失败: %s: %w", filename, err) } // 获取文件创建时间 createdTime, err := getFileCreationTime(path) if err != nil { return fmt.Errorf("获取创建时间失败: %s: %w", filename, err) } // 转换为分钟并分类 durationMin := int(durationSec / 60) timeCat := getTimeCategory(durationMin) // 创建电影记录 movie := Movie{ FileName: filename, Image: baseName + ".png", Duration: durationMin, TimeCategory: timeCat, VideoPath: path, CreatedTime: createdTime, } movies = append(movies, movie) // 更新缩略图字典 if entry, exists := movieDict[baseName]; exists { entry.FileName = movie.FileName entry.Duration = movie.Duration entry.VideoPath = movie.VideoPath entry.CreatedTime = movie.CreatedTime } return nil } // getTimeCategory 根据时长获取分类 func getTimeCategory(durationMin int) string { switch { case durationMin <= 15: return "15min" case durationMin <= 30: return "30min" case durationMin <= 60: return "60min" default: return "大于60min" } } // sortMovies 对电影列表排序 func sortMovies() { categoryOrder := map[string]int{ "15min": 0, "30min": 1, "60min": 2, "大于60min": 3, "最新添加": 4, // 新增的"最新添加"类别 } sort.Slice(movies, func(i, j int) bool { catI := categoryOrder[movies[i].TimeCategory] catJ := categoryOrder[movies[j].TimeCategory] if catI != catJ { return catI < catJ } // 如果是recent类别,按创建时间倒序排序 if movies[i].TimeCategory == "最新添加" { return movies[i].CreatedTime > movies[j].CreatedTime } // 其他类别按时长排序 return movies[i].Duration < movies[j].Duration }) } // generateThumbnail 生成单个视频缩略图 func generateThumbnail(movie *Movie) { baseName := strings.TrimSuffix(movie.FileName, filepath.Ext(movie.FileName)) outputPath := filepath.Join("movie", baseName+".png") // 根据时长选择不同的采样策略 var filter string switch { case movie.Duration <= 2: filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,5)',scale=320:180,tile=3x3" case movie.Duration <= 5: filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,10)',scale=320:180,tile=3x3" case movie.Duration <= 30: filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,20)',scale=320:180,tile=3x3" case movie.Duration <= 60: filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,35)',scale=320:180,tile=3x3" default: filter = "select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,60)',scale=320:180,tile=3x3" } // 执行ffmpeg命令生成缩略图 cmd := exec.Command("ffmpeg", "-i", movie.VideoPath, "-vf", filter, "-frames:v", "1", "-q:v", "3", "-y", // 覆盖已存在文件 outputPath, ) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { log.Printf("生成缩略图失败: %s (%d min): %v\n错误输出: %s", movie.VideoPath, movie.Duration, err, stderr.String()) } else { log.Printf("成功生成缩略图: %s", outputPath) } } // initMovie 初始化电影数据主函数 func initMovie() { // 扫描目录获取文件列表 files, err := scanMovieFiles() if err != nil { log.Fatal(err) } // 创建需要处理缩略图的视频字典 movieDict := createMovieDict(files) movies = make([]Movie, 0) // 重置全局电影列表 // 遍历处理每个视频文件 err = filepath.WalkDir("./movie", func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() || filepath.Ext(d.Name()) == ".png" || filepath.Ext(d.Name()) == ".db" { return err } return processVideoFile(path, movieDict) }) if err != nil { log.Fatalf("遍历movie目录失败: %v", err) } // 为最近添加的文件添加"最新添加"类别 markRecentMovies() // 排序电影列表 // sortMovies() // 生成缩略图 for _, movie := range movieDict { if movie.VideoPath == "" { continue } generateThumbnail(movie) } } // markRecentMovies 标记最近添加的电影 func markRecentMovies() { // 首先按创建时间排序 sort.Slice(movies, func(i, j int) bool { return movies[i].CreatedTime > movies[j].CreatedTime }) // 标记前20个最新文件为"最新添加"类别 for i := 0; i < len(movies) && i < 20; i++ { movies[i].TimeCategory = "最新添加" } }