- 添加跳过ffprobe相关文件的逻辑,避免处理临时释放文件 - 修复图片处理中的变量引用错误 - 实现大文件指纹复用机制,避免重复全量读取计算MD5 - 新增文件头尾采样指纹算法,提升大文件处理性能 - 重构ffprobe内嵌资源管理,支持多平台自动选择对应版本 - 添加媒体信息数据库的指纹索引和去向跟踪功能 - 优化程序启动流程,失败时跳过媒体整理但保留图片处理 - 更新文档说明多平台ffprobe支持和构建要求
222 lines
6.3 KiB
Go
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()
|
|
}
|