feat(core): 优化媒体文件扫描和处理功能
- 添加跳过ffprobe相关文件的逻辑,避免处理临时释放文件 - 修复图片处理中的变量引用错误 - 实现大文件指纹复用机制,避免重复全量读取计算MD5 - 新增文件头尾采样指纹算法,提升大文件处理性能 - 重构ffprobe内嵌资源管理,支持多平台自动选择对应版本 - 添加媒体信息数据库的指纹索引和去向跟踪功能 - 优化程序启动流程,失败时跳过媒体整理但保留图片处理 - 更新文档说明多平台ffprobe支持和构建要求
This commit is contained in:
@@ -27,3 +27,6 @@
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# 内嵌的 ffprobe 二进制(约 80~150MB),不入库,需自行放置于 vars/bin/
|
||||
vars/bin/
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6080e34165fcb0261f979bb1cef26c5fce616e2a416384b50bcce956aa265018
|
||||
size 140416365
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
LFS
BIN
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
//go:build darwin
|
||||
|
||||
package vars
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed bin/ffprobe_mac
|
||||
var VideoDecodeTool []byte // 需在 vars/bin/ 下放置 macOS 版 ffprobe(未入库,见 .gitignore)
|
||||
@@ -0,0 +1,6 @@
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package vars
|
||||
|
||||
// VideoDecodeTool 当前平台没有内嵌的ffprobe
|
||||
var VideoDecodeTool []byte
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build windows
|
||||
|
||||
package vars
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed bin/ffprobe.exe
|
||||
var VideoDecodeTool []byte // 需在 vars/bin/ 下放置 Windows 版 ffprobe(未入库,见 .gitignore)
|
||||
@@ -8,3 +8,4 @@ var GlobalImagePathList []string // 所有图片文件
|
||||
var GlobalMediaPathList []string // 待ffprobe确认的媒体候选文件
|
||||
var GlobalAudioPathList []string // 已确认的音频文件路径
|
||||
var GlobalPickerDir string // 已整理文件的输出目录(白名单,扫描时跳过)
|
||||
var GlobalPath2DestDirMap = make(map[string]string) // 文件路径 → 移动到的目录(去向)
|
||||
|
||||
@@ -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) // 视频路径和宽高比
|
||||
|
||||
Reference in New Issue
Block a user