x-movie/server/movie.go
2025-06-03 00:44:35 +08:00

335 lines
9.5 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"
"sync"
"time"
)
// 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"` // 文件创建时间戳
mu sync.Mutex // 保护视频文件的并发访问
}
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 = true // 是否重新生成所有PNG缩略图
var Categories = []string{ // 分类
"15min", "30min", "60min", "大于60min", "最新添加"}
// 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)
MovieDict[movie.FileName] = 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"
}
}
// generateThumbnail 生成单个视频缩略图
func generateThumbnail(movie *Movie) {
baseName := strings.TrimSuffix(movie.FileName, filepath.Ext(movie.FileName))
outputPath := filepath.Join("movie", baseName+".png")
// --- Start of improved logic ---
// Fixed skip for the beginning of the video, as per original code's ffmpeg args
const skipStartSeconds = 10.0
// Number of frames we want in the tile (for a 3x3 grid)
const numFramesInTile = 9.0 // Use float for calculations
durationAfterSkip := float64(movie.Duration) - skipStartSeconds
var intervalSeconds float64
if durationAfterSkip <= 0.1 { // Use a small threshold like 0.1s
intervalSeconds = 0.05 // A very small interval to grab whatever is there.
} else {
if numFramesInTile > 1 {
intervalSeconds = durationAfterSkip / (numFramesInTile - 1.0)
} else {
intervalSeconds = durationAfterSkip
}
if intervalSeconds <= 0 {
intervalSeconds = 0.05 // Fallback to a tiny interval
}
}
filter := fmt.Sprintf("select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,%.3f)',scale=320:180,tile=3x3", intervalSeconds)
log.Printf("Movie: %s, OriginalDuration: %ds, SkipStart: %.1fs, DurationForSampling: %.2fs, CalculatedInterval: %.3fs",
movie.FileName, movie.Duration, skipStartSeconds, durationAfterSkip, intervalSeconds)
// Execute ffmpeg command
cmd := exec.Command("ffmpeg",
"-ss", fmt.Sprintf("%.0f", skipStartSeconds), // Using the defined skipStartSeconds
"-i", movie.VideoPath,
"-vf", filter,
"-frames:v", "1", // Crucial for outputting a single tiled image
"-q:v", "3", // Quality for the frames before tiling/PNG compression
"-y", // Overwrite output file if it exists
outputPath,
)
var stderr bytes.Buffer
cmd.Stderr = &stderr
startTime := time.Now()
if err := cmd.Run(); err != nil {
// Assuming movie.Duration is in seconds, adjusted log from (%d min) to (%d s)
log.Printf("生成缩略图失败: %s (%d s): %v\n错误输出: %s",
movie.VideoPath, movie.Duration, err, stderr.String())
} else {
log.Printf("成功生成缩略图: %s (耗时: %v)", outputPath, time.Since(startTime))
}
}
// 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)
}
log.Printf("成功初始化电影数据,共找到 %d 个视频文件", len(Movies))
}
// 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 = "最新添加"
}
}