x-movie/server/movie.go
2025-06-02 06:20:16 +08:00

291 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = "最新添加"
}
}