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"
|
2025-06-03 00:44:35 +08:00
|
|
|
|
"time"
|
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的并发访问
|
|
|
|
|
|
2025-06-03 00:44:35 +08:00
|
|
|
|
var IsRemakePNG = true // 是否重新生成所有PNG缩略图
|
2025-06-02 21:29:31 +08:00
|
|
|
|
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")
|
|
|
|
|
|
2025-06-03 00:44:35 +08:00
|
|
|
|
// --- 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
|
|
|
|
|
}
|
2025-06-02 06:20:16 +08:00
|
|
|
|
}
|
2025-06-03 00:44:35 +08:00
|
|
|
|
filter := fmt.Sprintf("select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,%.3f)',scale=320:180,tile=3x3", intervalSeconds)
|
2025-06-02 06:20:16 +08:00
|
|
|
|
|
2025-06-03 00:44:35 +08:00
|
|
|
|
log.Printf("Movie: %s, OriginalDuration: %ds, SkipStart: %.1fs, DurationForSampling: %.2fs, CalculatedInterval: %.3fs",
|
|
|
|
|
movie.FileName, movie.Duration, skipStartSeconds, durationAfterSkip, intervalSeconds)
|
|
|
|
|
|
|
|
|
|
// Execute ffmpeg command
|
2025-06-02 06:20:16 +08:00
|
|
|
|
cmd := exec.Command("ffmpeg",
|
2025-06-03 00:44:35 +08:00
|
|
|
|
"-ss", fmt.Sprintf("%.0f", skipStartSeconds), // Using the defined skipStartSeconds
|
2025-06-02 06:20:16 +08:00
|
|
|
|
"-i", movie.VideoPath,
|
|
|
|
|
"-vf", filter,
|
2025-06-03 00:44:35 +08:00
|
|
|
|
"-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
|
2025-06-02 06:20:16 +08:00
|
|
|
|
outputPath,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var stderr bytes.Buffer
|
|
|
|
|
cmd.Stderr = &stderr
|
2025-06-03 00:44:35 +08:00
|
|
|
|
|
|
|
|
|
startTime := time.Now()
|
2025-06-02 06:20:16 +08:00
|
|
|
|
if err := cmd.Run(); err != nil {
|
2025-06-03 00:44:35 +08:00
|
|
|
|
// Assuming movie.Duration is in seconds, adjusted log from (%d min) to (%d s)
|
|
|
|
|
log.Printf("生成缩略图失败: %s (%d s): %v\n错误输出: %s",
|
2025-06-02 06:20:16 +08:00
|
|
|
|
movie.VideoPath, movie.Duration, err, stderr.String())
|
|
|
|
|
} else {
|
2025-06-03 00:44:35 +08:00
|
|
|
|
log.Printf("成功生成缩略图: %s (耗时: %v)", outputPath, time.Since(startTime))
|
2025-06-02 06:20:16 +08:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
}
|