335 lines
9.5 KiB
Go
335 lines
9.5 KiB
Go
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 = "最新添加"
|
||
}
|
||
}
|