diff --git a/.gitignore b/.gitignore index 5ef97a7..8b87646 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ # Go workspace file go.work +# 内嵌的 ffprobe 二进制(约 80~150MB),不入库,需自行放置于 vars/bin/ +vars/bin/ + diff --git a/README.md b/README.md index a6d5887..5b08479 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/core/file_scanner.go b/core/file_scanner.go index e39900e..4b13b54 100644 --- a/core/file_scanner.go +++ b/core/file_scanner.go @@ -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) diff --git a/core/handle_image.go b/core/handle_image.go index 254949f..85d15f1 100644 --- a/core/handle_image.go +++ b/core/handle_image.go @@ -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 } diff --git a/core/save_media.go b/core/save_media.go index 6cb8e5b..12ce620 100644 --- a/core/save_media.go +++ b/core/save_media.go @@ -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) diff --git a/doc/ffmpeg-master-latest-win64-gpl.zip b/doc/ffmpeg-master-latest-win64-gpl.zip deleted file mode 100644 index 30a0e34..0000000 --- a/doc/ffmpeg-master-latest-win64-gpl.zip +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6080e34165fcb0261f979bb1cef26c5fce616e2a416384b50bcce956aa265018 -size 140416365 diff --git a/main.go b/main.go index 1f71f5f..67e56de 100644 --- a/main.go +++ b/main.go @@ -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) + } + } } diff --git a/store/store.go b/store/store.go index a20b6a3..2af452f 100644 --- a/store/store.go +++ b/store/store.go @@ -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 +} diff --git a/util/file.go b/util/file.go index 969942e..9de4ea2 100644 --- a/util/file.go +++ b/util/file.go @@ -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) diff --git a/util/video.go b/util/video.go index 5d105e3..452996d 100644 --- a/util/video.go +++ b/util/video.go @@ -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)) } diff --git a/vars/ffprobe.exe b/vars/ffprobe.exe deleted file mode 100644 index 985f013..0000000 --- a/vars/ffprobe.exe +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9fc77ae031fd4e01b8a2840a37be6ae10bcf62430b4e8601a2fcc05669179fec -size 122363392 diff --git a/vars/ffprobe_darwin.go b/vars/ffprobe_darwin.go new file mode 100644 index 0000000..10ce7c9 --- /dev/null +++ b/vars/ffprobe_darwin.go @@ -0,0 +1,8 @@ +//go:build darwin + +package vars + +import _ "embed" + +//go:embed bin/ffprobe_mac +var VideoDecodeTool []byte // 需在 vars/bin/ 下放置 macOS 版 ffprobe(未入库,见 .gitignore) diff --git a/vars/ffprobe_unsupported.go b/vars/ffprobe_unsupported.go new file mode 100644 index 0000000..93ad773 --- /dev/null +++ b/vars/ffprobe_unsupported.go @@ -0,0 +1,6 @@ +//go:build !darwin && !windows + +package vars + +// VideoDecodeTool 当前平台没有内嵌的ffprobe +var VideoDecodeTool []byte diff --git a/vars/ffprobe_windows.go b/vars/ffprobe_windows.go new file mode 100644 index 0000000..edeba9f --- /dev/null +++ b/vars/ffprobe_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package vars + +import _ "embed" + +//go:embed bin/ffprobe.exe +var VideoDecodeTool []byte // 需在 vars/bin/ 下放置 Windows 版 ffprobe(未入库,见 .gitignore) diff --git a/vars/global.go b/vars/global.go index 73bbd66..8449db2 100644 --- a/vars/global.go +++ b/vars/global.go @@ -8,3 +8,4 @@ var GlobalImagePathList []string // 所有图片文件 var GlobalMediaPathList []string // 待ffprobe确认的媒体候选文件 var GlobalAudioPathList []string // 已确认的音频文件路径 var GlobalPickerDir string // 已整理文件的输出目录(白名单,扫描时跳过) +var GlobalPath2DestDirMap = make(map[string]string) // 文件路径 → 移动到的目录(去向) diff --git a/vars/video.go b/vars/video.go index d287ab8..415307b 100644 --- a/vars/video.go +++ b/vars/video.go @@ -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) // 视频路径和宽高比