feat(core): 优化媒体文件扫描和处理功能

- 添加跳过ffprobe相关文件的逻辑,避免处理临时释放文件
- 修复图片处理中的变量引用错误
- 实现大文件指纹复用机制,避免重复全量读取计算MD5
- 新增文件头尾采样指纹算法,提升大文件处理性能
- 重构ffprobe内嵌资源管理,支持多平台自动选择对应版本
- 添加媒体信息数据库的指纹索引和去向跟踪功能
- 优化程序启动流程,失败时跳过媒体整理但保留图片处理
- 更新文档说明多平台ffprobe支持和构建要求
This commit is contained in:
2025-03-21 21:00:59 +08:00
parent b276e8c56f
commit 066e28a2d6
16 changed files with 156 additions and 45 deletions
+3
View File
@@ -27,3 +27,6 @@
# Go workspace file
go.work
# 内嵌的 ffprobe 二进制(约 80~150MB),不入库,需自行放置于 vars/bin/
vars/bin/
+5 -4
View File
@@ -11,7 +11,7 @@
- **音频归类**:音频文件单独归入「音频」目录
- **异常兜底**:读取失败、不支持的格式自动归入「读取异常」「已忽略」目录
- **自动整理**:无需人工干预,扫描 → 分析 → 移动一次完成,跨磁盘自动降级为复制后删除
- **全格式支持**:视频/音频以内嵌的 `ffprobe.exe`(Windows x64)作为最终判定依据,**凡是 ffmpeg 支持的格式都能识别**,不再依赖扩展名白名单
- **全格式支持**:视频/音频以**内嵌的 ffprobe**(按系统自动内嵌 macOS/Windows 对应版本)作为最终判定依据,**凡是 ffmpeg 支持的格式都能识别**,不再依赖扩展名白名单
- **媒体信息入库**:自动将文件的 MD5 与完整媒体信息(类型/宽高/时长/编码/容器等)存入根目录下的 `media-info.db`(SQLite,xorm 驱动),支持按字段查询与去重
## 支持的格式
@@ -31,6 +31,7 @@ sh build.sh
```
- 依赖 Go 1.19+(`go.mod` 指定 1.19)
- 每个平台构建时只内嵌**本平台**的 ffprobe:`vars/bin/ffprobe_mac`(macOS)、`vars/bin/ffprobe.exe`(Windows),需提前放置(未入库,见 `.gitignore`)
- `build.sh` 使用 `upx` 压缩产物,输出 `kenaito-media-picker.exe`(Windows 单文件)
- 编译所需工具(ffmpeg / upx)压缩包已放在 `doc/` 目录,按需解压使用
@@ -89,7 +90,7 @@ kenaito-media-picker.exe
已忽略 ← 非媒体文件或无法识别
```
> 视频时长与分辨率通过内嵌 `ffprobe.exe` 读取(处理时会临时释放,完成后自动删除);凡 ffmpeg 支持的视频/音频格式均可识别。
> 视频时长与分辨率通过内嵌 ffprobe 读取(处理时会临时释放到当前目录、完成后自动删除);凡 ffmpeg 支持的视频/音频格式均可识别。
## 目录结构
@@ -97,7 +98,7 @@ kenaito-media-picker.exe
├── main.go # 程序入口(扫描 → 过滤 → 图片/媒体处理)
├── core/ # 核心逻辑:文件扫描、MIME 过滤、图片/视频/音频分组与移动
├── util/ # 工具:MIME 识别、图片尺寸读取、ffprobe 封装、文件移动
├── vars/ # 全局状态 + 内嵌 ffprobe.exe(go:embed)
├── vars/ # 全局状态 + 按系统内嵌 ffprobe(go:embed,二进制在 vars/bin/)
├── doc/ # 构建依赖(ffmpeg / upx 压缩包)
└── build.sh # 编译脚本(upx 压缩)
```
@@ -107,7 +108,7 @@ kenaito-media-picker.exe
- 默认使用**移动**模式,原文件会从源目录移走;同名文件跨磁盘时自动降级为复制 + 删除
- 已整理文件全部移动到 `{当前目录名}-picker` 白名单目录内,重复运行会跳过该目录,并将新文件并入已有的分类目录
- GIF 图片单独归入「图片_GIF」;GIF 视频按方向归入「视频_*_GIF」
- 视频/音频处理依赖 Windows 平台的 `ffprobe.exe`(已内嵌),非 Windows 平台需自行调整
- ffprobe 按系统内嵌(macOS/Windows),运行前释放到当前目录、结束后自动删除;若当前平台无内嵌版本,视频/音频将不处理
## License
+3
View File
@@ -87,6 +87,9 @@ func visit(currentPath string, info os.FileInfo, err error) error {
if filepath.Base(currentPath) == store.DBFileName {
return nil // 跳过媒体信息数据库文件
}
if filepath.Base(currentPath) == util.FFprobeExeName || filepath.Base(currentPath) == "ffprobe_mac" {
return nil // 跳过ffprobe相关文件(中断残留的释放文件 / vars/bin内嵌源文件)
}
vars.GlobalFilePathList = append(vars.GlobalFilePathList, currentPath)
// filename, include ext
vars.GlobalFilePath2FileNameMap[currentPath] = filepath.Base(currentPath)
+1 -1
View File
@@ -117,7 +117,7 @@ func DoHandleImage(pickerDir string) {
// 条件图片并分组存放
func doPickImageFile(pickerDir string, imagePath2WidthHeightMap map[string]string) {
if len(vars.ImagePath2WidthHeightMap) == 0 {
if len(imagePath2WidthHeightMap) == 0 {
fmt.Printf("=== 当前目录下没有扫描到图片文件, %s \n", pickerDir)
return
}
+25 -6
View File
@@ -8,18 +8,38 @@ import (
"os"
)
// saveMediaRecord 计算MD5并将媒体信息写入数据库
// saveMediaRecord 将媒体信息写入数据库;大文件优先用指纹(头尾采样)复用已入库的MD5,避免全量读取
func saveMediaRecord(filePath string, mediaType string, width int, height int, duration float64, codec string, format string, rawInfo string) {
md5Str, err := util.CalcFileMd5(filePath)
st, err := os.Stat(filePath)
if err != nil {
fmt.Printf("=== 计算MD5失败: %s, 错误: %v\n", filePath, err)
fmt.Printf("=== 读取文件信息失败: %s, 错误: %v\n", filePath, err)
return
}
size := st.Size()
md5Str := ""
headMd5, tailMd5 := "", ""
if size > util.FingerprintThreshold {
if head, tail, ferr := util.CalcFileFingerprint(filePath, size); ferr == nil {
headMd5, tailMd5 = head, tail
if existing, ok := store.FindMediaByFingerprint(size, head, tail); ok && existing.FileMd5 != "" {
md5Str = existing.FileMd5 // 指纹命中,复用MD5,跳过全量读取
}
}
}
if md5Str == "" {
if md5Str, err = util.CalcFileMd5(filePath); err != nil {
fmt.Printf("=== 计算MD5失败: %s, 错误: %v\n", filePath, err)
return
}
}
info := &store.MediaInfo{
FileMd5: md5Str,
FilePath: filePath,
FileName: vars.GlobalFilePath2FileNameMap[filePath],
FileExt: vars.GlobalFilePath2FileExtMap[filePath],
FileSize: size,
MimeType: vars.GlobalFilePath2MimeInfoMap[filePath],
MediaType: mediaType,
Width: width,
@@ -28,9 +48,8 @@ func saveMediaRecord(filePath string, mediaType string, width int, height int, d
Codec: codec,
Format: format,
RawInfo: rawInfo,
}
if st, err := os.Stat(filePath); err == nil {
info.FileSize = st.Size()
HeadMd5: headMd5,
TailMd5: tailMd5,
}
if err := store.SaveMedia(info); err != nil {
fmt.Printf("=== 保存媒体信息失败: %s, 错误: %v\n", filePath, err)
-3
View File
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6080e34165fcb0261f979bb1cef26c5fce616e2a416384b50bcce956aa265018
size 140416365
+12 -5
View File
@@ -34,18 +34,25 @@ func main() {
fmt.Println("=== 初始化数据库失败(继续整理, 不记录媒体信息):", err)
}
defer store.Close()
// 释放内嵌ffprobe(按当前系统);失败只跳过媒体整理,图片照常处理
ffprobeReady := true
if len(vars.GlobalImagePathList) > 0 || len(vars.GlobalMediaPathList) > 0 {
if err := util.ReleaseFFprobe(); err != nil {
fmt.Println("=== 释放解码器失败, 5秒后将自动退出", err)
time.Sleep(time.Second * 5)
return
fmt.Println("=== 释放解码器失败, 视频/音频整理将跳过(图片照常处理):", err)
ffprobeReady = false
}
}
defer util.RemoveFFprobe()
if len(vars.GlobalImagePathList) > 0 {
core.DoHandleImage(pickerDir)
}
if len(vars.GlobalMediaPathList) > 0 {
if len(vars.GlobalMediaPathList) > 0 && ffprobeReady {
core.DoHandleMedia(pickerDir)
}
util.RemoveFFprobe()
// 所有移动完成后,回填文件的归类目录与去向
for src, dest := range vars.GlobalPath2DestDirMap {
if err := store.UpdateMediaDest(src, dest); err != nil {
fmt.Printf("=== 更新媒体去向失败: %s, 错误: %v\n", src, err)
}
}
}
+45 -15
View File
@@ -2,6 +2,7 @@ package store
import (
"database/sql"
"path/filepath"
"time"
sqlite "github.com/glebarez/go-sqlite"
@@ -19,21 +20,25 @@ func init() {
// MediaInfo 媒体文件信息记录,按字段拆分存放,便于查询
type MediaInfo struct {
Id int64 `xorm:"pk autoincr 'id'"`
FileMd5 string `xorm:"char(32) index 'file_md5'"` // 文件MD5,唯一标识
FilePath string `xorm:"text 'file_path'"` // 扫描时的源路径
FileName string `xorm:"varchar(255) 'file_name'"` // 文件名(含扩展名)
FileExt string `xorm:"varchar(32) 'file_ext'"` // 扩展名
FileSize int64 `xorm:"bigint 'file_size'"` // 文件大小(字节)
MimeType string `xorm:"varchar(128) 'mime_type'"` // MIME类型
MediaType string `xorm:"varchar(16) index 'media_type'"` // image/video/audio
Width int `xorm:"int index 'width'"` // 宽(像素)
Height int `xorm:"int index 'height'"` // 高(像素)
Duration float64 `xorm:"real 'duration'"` // 时长(秒)
Codec string `xorm:"varchar(64) 'codec'"` // 视频编码,如h264/hevc
Format string `xorm:"varchar(64) 'format'"` // 容器格式,如mp4/mkv
RawInfo string `xorm:"text 'raw_info'"` // ffprobe原始JSON(完整信息兜底)
CreatedAt time.Time `xorm:"created 'created_at'"` // 首次入库时间
UpdatedAt time.Time `xorm:"updated 'updated_at'"` // 最近更新时间
FileMd5 string `xorm:"char(32) index 'file_md5'"` // 文件MD5,唯一标识
FilePath string `xorm:"text 'file_path'"` // 扫描时的源路径
FileName string `xorm:"varchar(255) 'file_name'"` // 文件名(含扩展名)
FileExt string `xorm:"varchar(32) 'file_ext'"` // 扩展名
FileSize int64 `xorm:"bigint 'file_size'"` // 文件大小(字节)
MimeType string `xorm:"varchar(128) 'mime_type'"` // MIME类型
MediaType string `xorm:"varchar(16) index 'media_type'"` // image/video/audio
Width int `xorm:"int index 'width'"` // 宽(像素)
Height int `xorm:"int index 'height'"` // 高(像素)
Duration float64 `xorm:"real 'duration'"` // 时长(秒)
Codec string `xorm:"varchar(64) 'codec'"` // 视频编码,如h264/hevc
Format string `xorm:"varchar(64) 'format'"` // 容器格式,如mp4/mkv
RawInfo string `xorm:"text 'raw_info'"` // ffprobe原始JSON(完整信息兜底)
HeadMd5 string `xorm:"char(32) index('idx_fp') 'head_md5'"` // 文件头256KB的MD5(指纹加速)
TailMd5 string `xorm:"char(32) index('idx_fp') 'tail_md5'"` // 文件尾256KB的MD5(指纹加速)
Category string `xorm:"varchar(64) index 'category'"` // 归类的目录名(去向)
DestDir string `xorm:"text 'dest_dir'"` // 移动后的目录路径(去向)
CreatedAt time.Time `xorm:"created 'created_at'"` // 首次入库时间
UpdatedAt time.Time `xorm:"updated 'updated_at'"` // 最近更新时间
}
var engine *xorm.Engine
@@ -78,3 +83,28 @@ func SaveMedia(info *MediaInfo) error {
_, err = engine.Insert(info)
return err
}
// FindMediaByFingerprint 按 (文件大小 + 头256KB + 尾256KB) 查找已入库记录,避免对大文件重复计算全量MD5
func FindMediaByFingerprint(size int64, headMd5 string, tailMd5 string) (*MediaInfo, bool) {
if engine == nil || headMd5 == "" || tailMd5 == "" {
return nil, false
}
info := new(MediaInfo)
has, err := engine.Where("file_size = ? AND head_md5 = ? AND tail_md5 = ?", size, headMd5, tailMd5).Get(info)
if err != nil || !has {
return nil, false
}
return info, true
}
// UpdateMediaDest 按源路径回填文件的归类目录与去向
func UpdateMediaDest(filePath string, destDir string) error {
if engine == nil {
return nil
}
_, err := engine.Where("file_path = ?", filePath).Update(&MediaInfo{
Category: filepath.Base(destDir),
DestDir: destDir,
})
return err
}
+33
View File
@@ -14,6 +14,9 @@ import (
// CreateDir 创建目录(含递归创建父目录)
func CreateDir(dirPath string) bool {
err := os.MkdirAll(dirPath, 0755)
if err != nil {
fmt.Printf("=== 创建目录失败: %s, 错误: %v\n", dirPath, err)
}
return err == nil
}
@@ -61,6 +64,36 @@ func CalcFileMd5(filePath string) (string, error) {
return hex.EncodeToString(h.Sum(nil)), nil
}
// MD5指纹加速相关常量
const (
FingerprintThreshold = 1 << 20 // 文件大于1MB才使用指纹复用,避免全量MD5
fingerprintHeadSize = 256 << 10 // 文件头采样256KB
fingerprintTailSize = 256 << 10 // 文件尾采样256KB
)
// CalcFileFingerprint 计算文件的头部与尾部采样MD5(只读取很少字节,用于快速识别相同文件)
func CalcFileFingerprint(filePath string, size int64) (headMd5 string, tailMd5 string, err error) {
file, err := os.Open(filePath)
if err != nil {
return "", "", err
}
defer file.Close()
h := md5.New()
if _, err := io.CopyN(h, file, fingerprintHeadSize); err != nil {
return "", "", err
}
headMd5 = hex.EncodeToString(h.Sum(nil))
h = md5.New()
if _, err := file.Seek(size-fingerprintTailSize, io.SeekStart); err != nil {
return "", "", err
}
if _, err := io.CopyN(h, file, fingerprintTailSize); err != nil {
return "", "", err
}
tailMd5 = hex.EncodeToString(h.Sum(nil))
return headMd5, tailMd5, nil
}
//// 移动文件到根目录
//func renameFile(rootDir string, modelType string, videoList []string, pathSeparator string) {
// total := len(videoList)
+6 -2
View File
@@ -24,9 +24,12 @@ func FFprobeExePath() string {
return "." + string(os.PathSeparator) + FFprobeExeName
}
// ReleaseFFprobe 释放内嵌的ffprobe到当前目录
// ReleaseFFprobe 释放内嵌的ffprobe到当前目录(按当前系统自动选择对应版本)
func ReleaseFFprobe() error {
return WriteByteArraysToFile(vars.VideoDecodeToolWin64, FFprobeExeName)
if len(vars.VideoDecodeTool) == 0 {
return fmt.Errorf("当前系统没有内嵌的ffprobe")
}
return WriteByteArraysToFile(vars.VideoDecodeTool, FFprobeExeName)
}
// RemoveFFprobe 删除当前目录下的ffprobe
@@ -170,6 +173,7 @@ func DoMoveFileToDir(filePathList []string, videoDirPath string) {
pathSeparator := string(os.PathSeparator)
for _, videoFilePath := range filePathList {
moveFileToDir(videoFilePath, videoDirPath+pathSeparator)
vars.GlobalPath2DestDirMap[videoFilePath] = videoDirPath // 记录去向
count = count + 1
bar.PrintBar(CalcPercentage(count, total))
}
BIN
View File
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
//go:build darwin
package vars
import _ "embed"
//go:embed bin/ffprobe_mac
var VideoDecodeTool []byte // 需在 vars/bin/ 下放置 macOS 版 ffprobe(未入库,见 .gitignore)
+6
View File
@@ -0,0 +1,6 @@
//go:build !darwin && !windows
package vars
// VideoDecodeTool 当前平台没有内嵌的ffprobe
var VideoDecodeTool []byte
+8
View File
@@ -0,0 +1,8 @@
//go:build windows
package vars
import _ "embed"
//go:embed bin/ffprobe.exe
var VideoDecodeTool []byte // 需在 vars/bin/ 下放置 Windows 版 ffprobe(未入库,见 .gitignore)
+1
View File
@@ -8,3 +8,4 @@ var GlobalImagePathList []string // 所有图片文件
var GlobalMediaPathList []string // 待ffprobe确认的媒体候选文件
var GlobalAudioPathList []string // 已确认的音频文件路径
var GlobalPickerDir string // 已整理文件的输出目录(白名单,扫描时跳过)
var GlobalPath2DestDirMap = make(map[string]string) // 文件路径 → 移动到的目录(去向)
-6
View File
@@ -1,11 +1,5 @@
package vars
import (
_ "embed" // 内嵌文件支持
)
//go:embed ffprobe.exe
var VideoDecodeToolWin64 []byte
var VideoIgnorePathList []string // 忽略的文件路径
var VideoReadErrorPathList []string // 读取信息异常的路径
var VideoPath2WidthHeightMap = make(map[string]string) // 视频路径和宽高比