x-movie/server/movie.go

317 lines
8.7 KiB
Go
Raw Normal View History

2023-07-09 03:00:29 +08:00
package main
import (
"bytes"
2023-07-09 08:25:39 +08:00
"fmt"
2023-07-09 03:00:29 +08:00
"io/fs"
"log"
2025-06-02 06:20:16 +08:00
"os"
2023-07-09 03:00:29 +08:00
"os/exec"
"path/filepath"
2025-06-02 03:19:36 +08:00
"regexp"
2023-07-09 03:00:29 +08:00
"sort"
"strconv"
"strings"
2025-06-02 21:29:31 +08:00
"sync"
2023-07-09 03:00:29 +08:00
)
2025-06-02 06:20:16 +08:00
// Movie 电影信息结构
2023-07-09 03:00:29 +08:00
type Movie struct {
2025-06-02 21:29:31 +08:00
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"` // 文件创建时间戳
mu sync.Mutex // 保护视频文件的并发访问
2023-07-09 03:00:29 +08:00
}
2025-06-02 21:29:31 +08:00
func (m *Movie) RenameVideo(newName string) error {
m.mu.Lock()
defer m.mu.Unlock()
if newName == m.FileName {
return nil // 如果新名称与当前名称相同,则不需要重命名
}
// 获取文件扩展名
videoExt := filepath.Ext(m.FileName)
imageExt := filepath.Ext(m.Image)
// 获取当前文件的目录
dir := filepath.Dir(m.VideoPath)
// 构建新的文件名(不带扩展名)
newBaseName := strings.TrimSuffix(newName, videoExt)
// 重命名视频文件
oldVideoPath := m.VideoPath
newVideoPath := filepath.Join(dir, newBaseName+videoExt)
if err := os.Rename(oldVideoPath, newVideoPath); err != nil {
return fmt.Errorf("重命名视频文件失败: %w", err)
}
// 重命名缩略图文件
oldImagePath := filepath.Join(dir, m.Image)
newImagePath := filepath.Join(dir, newBaseName+imageExt)
if _, err := os.Stat(oldImagePath); err == nil {
if err := os.Rename(oldImagePath, newImagePath); err != nil {
// 如果缩略图重命名失败,尝试回滚视频文件重命名
_ = os.Rename(newVideoPath, oldVideoPath)
return fmt.Errorf("重命名缩略图失败: %w", err)
}
m.Image = newBaseName + imageExt
}
// 更新结构体信息
m.VideoPath = newVideoPath
m.FileName = newBaseName + videoExt
return nil
}
var Movies []*Movie // 存储所有电影信息的全局切片
var MovieDict = make(map[string]*Movie) // 存储需要处理缩略图的视频字典
var MovieDictLock sync.Mutex // 保护MovieDict的并发访问
var IsRemakePNG = false // 是否重新生成所有PNG缩略图
var Categories = []string{ // 分类
"15min", "30min", "60min", "大于60min", "最新添加"}
2025-06-02 03:19:36 +08:00
2025-06-02 06:20:16 +08:00
// getVideoDuration 获取视频时长(秒)
2025-06-02 03:19:36 +08:00
func getVideoDuration(videoPath string) (float64, error) {
cmd := exec.Command("ffmpeg", "-i", videoPath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
2025-06-02 06:20:16 +08:00
_ = cmd.Run() // 忽略错误ffmpeg在没有输出文件时会返回错误
2025-06-02 03:19:36 +08:00
// 解析ffmpeg输出的时长信息
output := stderr.String()
re := regexp.MustCompile(`Duration: (\d{2}):(\d{2}):(\d{2}\.\d{2})`)
matches := re.FindStringSubmatch(output)
2025-06-02 06:20:16 +08:00
// 尝试匹配不含毫秒的格式
2025-06-02 03:19:36 +08:00
if len(matches) < 4 {
reAlt := regexp.MustCompile(`Duration: (\d{2}):(\d{2}):(\d{2})`)
matches = reAlt.FindStringSubmatch(output)
if len(matches) < 4 {
2025-06-02 06:20:16 +08:00
return 0, fmt.Errorf("无法在ffmpeg输出中找到时长: %s. 输出: %s", filepath.Base(videoPath), output)
2025-06-02 03:19:36 +08:00
}
2025-06-02 06:20:16 +08:00
matches[3] += ".00" // 添加毫秒部分保持格式一致
2025-06-02 03:19:36 +08:00
}
2025-06-02 06:20:16 +08:00
// 转换为秒数
hours, _ := strconv.ParseFloat(matches[1], 64)
minutes, _ := strconv.ParseFloat(matches[2], 64)
seconds, _ := strconv.ParseFloat(matches[3], 64)
2023-07-09 03:00:29 +08:00
2025-06-02 06:20:16 +08:00
return hours*3600 + minutes*60 + seconds, nil
2025-06-02 03:19:36 +08:00
}
2023-07-09 08:25:39 +08:00
2025-06-02 06:20:16 +08:00
// getFileCreationTime 获取文件创建时间戳
func getFileCreationTime(path string) (int64, error) {
fileInfo, err := os.Stat(path)
if err != nil {
return 0, err
}
return fileInfo.ModTime().Unix(), nil
}
2023-07-09 03:00:29 +08:00
2025-06-02 06:20:16 +08:00
// scanMovieFiles 扫描movie目录并返回文件列表
func scanMovieFiles() ([]string, error) {
files, err := filepath.Glob("movie/*")
2023-07-09 03:00:29 +08:00
if err != nil {
2025-06-02 06:20:16 +08:00
return nil, fmt.Errorf("扫描movie目录失败: %w", err)
2023-07-09 03:00:29 +08:00
}
2025-06-02 06:20:16 +08:00
return files, nil
}
// createMovieDict 创建需要处理缩略图的视频字典
func createMovieDict(files []string) map[string]*Movie {
movieDict := make(map[string]*Movie)
2023-07-09 03:00:29 +08:00
2025-06-02 06:20:16 +08:00
for _, fullPath := range files {
filename := filepath.Base(fullPath)
ext := filepath.Ext(filename)
baseName := strings.TrimSuffix(filename, ext)
2025-06-02 03:19:36 +08:00
2025-06-02 06:20:16 +08:00
// 跳过目录和系统文件
if ext == ".db" {
continue
}
// 处理PNG文件
2025-06-02 03:19:36 +08:00
if ext == ".png" {
2025-06-02 06:20:16 +08:00
if _, exists := movieDict[baseName]; exists && !IsRemakePNG {
delete(movieDict, baseName)
2025-06-02 03:19:36 +08:00
}
2025-06-02 06:20:16 +08:00
continue
}
// 处理视频文件
if ext != "" {
if _, exists := movieDict[baseName]; !exists || IsRemakePNG {
movieDict[baseName] = &Movie{VideoPath: fullPath}
2023-07-09 08:25:39 +08:00
}
2023-07-09 03:00:29 +08:00
}
}
2025-06-02 06:20:16 +08:00
return movieDict
}
2023-07-09 03:00:29 +08:00
2025-06-02 06:20:16 +08:00
// processVideoFile 处理单个视频文件
func processVideoFile(path string, movieDict map[string]*Movie) error {
filename := filepath.Base(path)
baseName := strings.TrimSuffix(filename, filepath.Ext(filename))
2023-07-09 03:00:29 +08:00
2025-06-02 06:20:16 +08:00
// 获取视频时长
durationSec, err := getVideoDuration(path)
if err != nil {
delete(movieDict, baseName)
return fmt.Errorf("获取时长失败: %s: %w", filename, err)
}
2023-07-09 03:00:29 +08:00
2025-06-02 06:20:16 +08:00
// 获取文件创建时间
createdTime, err := getFileCreationTime(path)
if err != nil {
return fmt.Errorf("获取创建时间失败: %s: %w", filename, err)
}
2023-07-09 08:25:39 +08:00
2025-06-02 06:20:16 +08:00
// 转换为分钟并分类
durationMin := int(durationSec / 60)
timeCat := getTimeCategory(durationMin)
2023-07-09 03:00:29 +08:00
2025-06-02 06:20:16 +08:00
// 创建电影记录
2025-06-02 21:29:31 +08:00
movie := &Movie{
2025-06-02 06:20:16 +08:00
FileName: filename,
Image: baseName + ".png",
Duration: durationMin,
TimeCategory: timeCat,
VideoPath: path,
CreatedTime: createdTime,
}
2025-06-02 21:29:31 +08:00
Movies = append(Movies, movie)
MovieDict[movie.FileName] = movie // 添加到全局字典
2023-07-09 03:00:29 +08:00
2025-06-02 06:20:16 +08:00
// 更新缩略图字典
if entry, exists := movieDict[baseName]; exists {
entry.FileName = movie.FileName
entry.Duration = movie.Duration
entry.VideoPath = movie.VideoPath
entry.CreatedTime = movie.CreatedTime
2025-06-02 03:19:36 +08:00
}
2025-06-02 06:20:16 +08:00
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"
}
}
// 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)
2025-06-02 21:29:31 +08:00
Movies = make([]*Movie, 0) // 重置全局电影列表
2025-06-02 03:19:36 +08:00
2025-06-02 06:20:16 +08:00
// 遍历处理每个视频文件
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
2023-07-09 08:25:39 +08:00
}
2025-06-02 06:20:16 +08:00
return processVideoFile(path, movieDict)
})
if err != nil {
log.Fatalf("遍历movie目录失败: %v", err)
}
// 为最近添加的文件添加"最新添加"类别
markRecentMovies()
// 排序电影列表
// sortMovies()
// 生成缩略图
for _, movie := range movieDict {
if movie.VideoPath == "" {
continue
2023-07-09 08:25:39 +08:00
}
2025-06-02 06:20:16 +08:00
generateThumbnail(movie)
}
2025-06-02 21:29:31 +08:00
log.Printf("成功初始化电影数据,共找到 %d 个视频文件", len(Movies))
2025-06-02 06:20:16 +08:00
}
// markRecentMovies 标记最近添加的电影
func markRecentMovies() {
// 首先按创建时间排序
2025-06-02 21:29:31 +08:00
sort.Slice(Movies, func(i, j int) bool {
return Movies[i].CreatedTime > Movies[j].CreatedTime
2025-06-02 06:20:16 +08:00
})
// 标记前20个最新文件为"最新添加"类别
2025-06-02 21:29:31 +08:00
for i := 0; i < len(Movies) && i < 20; i++ {
Movies[i].TimeCategory = "最新添加"
2023-07-09 08:25:39 +08:00
}
2023-07-09 03:00:29 +08:00
}