Files
kenaito-media-picker/util/video.go
T
odboy 066e28a2d6 feat(core): 优化媒体文件扫描和处理功能
- 添加跳过ffprobe相关文件的逻辑,避免处理临时释放文件
- 修复图片处理中的变量引用错误
- 实现大文件指纹复用机制,避免重复全量读取计算MD5
- 新增文件头尾采样指纹算法,提升大文件处理性能
- 重构ffprobe内嵌资源管理,支持多平台自动选择对应版本
- 添加媒体信息数据库的指纹索引和去向跟踪功能
- 优化程序启动流程,失败时跳过媒体整理但保留图片处理
- 更新文档说明多平台ffprobe支持和构建要求
2025-03-21 21:00:59 +08:00

222 lines
6.3 KiB
Go

package util
import (
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"kenaito-media-picker/vars"
"github.com/redmask-hb/GoSimplePrint/goPrint"
uuid "github.com/satori/go.uuid"
)
// FFprobeExeName 内嵌解码器文件名
const FFprobeExeName = "ffprobe.exe"
// FFprobeExePath 返回带当前目录前缀的可执行路径(Windows/Unix均可直接执行)
func FFprobeExePath() string {
return "." + string(os.PathSeparator) + FFprobeExeName
}
// ReleaseFFprobe 释放内嵌的ffprobe到当前目录(按当前系统自动选择对应版本)
func ReleaseFFprobe() error {
if len(vars.VideoDecodeTool) == 0 {
return fmt.Errorf("当前系统没有内嵌的ffprobe")
}
return WriteByteArraysToFile(vars.VideoDecodeTool, FFprobeExeName)
}
// RemoveFFprobe 删除当前目录下的ffprobe
func RemoveFFprobe() {
if CheckFileIsExist(FFprobeExeName) {
_ = os.Remove(FFprobeExeName)
}
}
// StreamInfo ffprobe流信息
type StreamInfo struct {
Index int `json:"index"`
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Width int `json:"width"`
Height int `json:"height"`
}
// FormatInfo ffprobe容器信息
type FormatInfo struct {
Duration string `json:"duration"`
Format string `json:"format_name"`
}
// ProbeResult ffprobe探测结果
type ProbeResult struct {
Streams []StreamInfo `json:"streams"`
Format FormatInfo `json:"format"`
raw string
}
// VideoResolution 返回第一个有效的视频分辨率
func (p *ProbeResult) VideoResolution() (int, int) {
for _, s := range p.Streams {
if s.CodecType == "video" && s.Width > 0 && s.Height > 0 {
return s.Width, s.Height
}
}
return 0, 0
}
// HasAudio 是否包含音频流
func (p *ProbeResult) HasAudio() bool {
for _, s := range p.Streams {
if s.CodecType == "audio" {
return true
}
}
return false
}
// VideoCodec 第一个视频流的编码名称
func (p *ProbeResult) VideoCodec() string {
for _, s := range p.Streams {
if s.CodecType == "video" {
return s.CodecName
}
}
return ""
}
// AudioCodec 第一个音频流的编码名称
func (p *ProbeResult) AudioCodec() string {
for _, s := range p.Streams {
if s.CodecType == "audio" {
return s.CodecName
}
}
return ""
}
// FormatName 容器格式名称
func (p *ProbeResult) FormatName() string {
return p.Format.Format
}
// RawJSON 原始ffprobe JSON输出
func (p *ProbeResult) RawJSON() string {
return p.raw
}
// DurationSeconds 容器时长(秒),无法解析时返回0
func (p *ProbeResult) DurationSeconds() float64 {
d, err := strconv.ParseFloat(p.Format.Duration, 64)
if err != nil {
return 0
}
return d
}
// ProbeMediaFile 用ffprobe探测媒体文件,一次调用同时获取视频/音频流与时长
func ProbeMediaFile(ffprobeExecPath string, filePath string) (*ProbeResult, error) {
cmd := exec.Command(ffprobeExecPath, "-v", "error",
"-show_entries", "stream=index,codec_type,codec_name,width,height:format=duration,format_name",
"-of", "json", filePath)
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("ffprobe failed: %w", err)
}
var result ProbeResult
if err := json.Unmarshal(output, &result); err != nil {
return nil, fmt.Errorf("failed to parse ffprobe output: %w", err)
}
result.raw = string(output)
return &result, nil
}
// IsPotentialMedia 判断是否可能是媒体文件(交由ffprobe进一步确认)
func IsPotentialMedia(fileMime string) bool {
if fileMime == "" || fileMime == "application/octet-stream" {
return true
}
if strings.HasPrefix(fileMime, "text/") {
return false
}
if strings.HasPrefix(fileMime, "video/") || strings.HasPrefix(fileMime, "audio/") || strings.HasPrefix(fileMime, "image/") {
return true
}
switch fileMime {
case "application/zip", "application/gzip", "application/x-gzip", "application/pdf",
"application/json", "application/xml", "application/x-httpd-php", "application/x-sh",
"application/javascript", "application/x-7z-compressed", "application/x-rar-compressed",
"application/x-tar", "application/x-bzip2", "application/x-archive", "application/vnd.sqlite3",
"application/vnd.microsoft.portable-executable", "application/x-dosexec", "application/x-executable",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/msword", "application/vnd.ms-excel", "application/vnd.ms-powerpoint":
return false
}
return true
}
// DoMoveFileToDir 批量移动文件到目录
func DoMoveFileToDir(filePathList []string, videoDirPath string) {
total := len(filePathList)
var count = 0
bar := goPrint.NewBar(100)
bar.SetNotice("=== 移动文件到目录:")
bar.SetGraph(">")
pathSeparator := string(os.PathSeparator)
for _, videoFilePath := range filePathList {
moveFileToDir(videoFilePath, videoDirPath+pathSeparator)
vars.GlobalPath2DestDirMap[videoFilePath] = videoDirPath // 记录去向
count = count + 1
bar.PrintBar(CalcPercentage(count, total))
}
bar.PrintEnd("=== Finish")
}
// 移动文件到目录,跨文件系统时自动 fallback 为 copy+delete,目标文件名使用 uuid+原文件名防止同名覆盖
func moveFileToDir(sourceFilePath string, targetDirectory string) bool {
fileName := strings.ReplaceAll(uuid.NewV4().String(), "-", "") + "_" + filepath.Base(sourceFilePath)
targetFilePath := targetDirectory + fileName
err := os.Rename(sourceFilePath, targetFilePath)
if err == nil {
return true
}
// 跨文件系统等场景:fallback copy + delete
if err := copyFile(sourceFilePath, targetFilePath); err != nil {
fmt.Printf("=== 复制文件失败, 源: %s, 目标: %s, 错误: %v\n", sourceFilePath, targetFilePath, err)
return false
}
if err := os.Remove(sourceFilePath); err != nil {
fmt.Printf("=== 删除源文件失败: %s, 错误: %v\n", sourceFilePath, err)
// 复制成功但删除失败,文件已存在于两处,返回 true 表示目标已就位
}
return true
}
// copyFile 逐块复制文件
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Close()
}