fix: 重复文件归类、图片探测与明暗分析健壮性

- organize/store: 按 MD5 去重,重复图片/视频/音频统一移入「重复文件」目录,不再覆盖已有记录
- probe/image: webp/bmp/psd/psb 专用解码失败后走 ffprobe 兜底,与通用路径一致
- probe/brightness: 大图内存缩放为缩略图再分析明暗,超4000万像素跳过分析防OOM
- README: 补充重复文件目录说明,修正 Go 版本为 1.25.0
This commit is contained in:
2025-04-12 21:02:16 +08:00
parent 39fe02b424
commit a667f7fa35
5 changed files with 162 additions and 38 deletions
+60 -17
View File
@@ -23,6 +23,7 @@ const (
dirVideoErr = "视频_读取异常"
dirVideoIgnored = "视频_已忽略"
dirAudio = "音频"
dirDuplicate = "重复文件"
)
// Organizer 整理器:按目标目录收集文件,处理完成后统一建目录并移动
@@ -55,17 +56,19 @@ func (o *Organizer) add(f scanner.File, subDir string) {
o.buckets[dir] = append(o.buckets[dir], f.Path)
}
// record 将媒体信息写入数据库(大文件优先用指纹复用已入库的 MD5)
func (o *Organizer) record(f scanner.File, mediaType string, res *probe.ProbeResult) {
// calcFileInfo 读取文件大小并计算 MD5(大文件优先用指纹复用已入库的 MD5,跳过全量读取)。
// store 为 nil 时直接返回空值(无需去重/入库)。
func (o *Organizer) calcFileInfo(f scanner.File) (md5Str, headMd5, tailMd5 string, size int64) {
if o.store == nil {
return "", "", "", 0
}
st, err := os.Stat(f.Path)
if err != nil {
fmt.Printf("=== 读取文件信息失败: %s, 错误: %v\n", f.Path, err)
return
return "", "", "", 0
}
size := st.Size()
size = st.Size()
md5Str := ""
headMd5, tailMd5 := "", ""
if size > digest.FingerprintThreshold {
if head, tail, ferr := digest.CalcFileFingerprint(f.Path, size); ferr == nil {
headMd5, tailMd5 = head, tail
@@ -77,10 +80,26 @@ func (o *Organizer) record(f scanner.File, mediaType string, res *probe.ProbeRes
if md5Str == "" {
if md5Str, err = digest.CalcFileMd5(f.Path); err != nil {
fmt.Printf("=== 计算MD5失败: %s, 错误: %v\n", f.Path, err)
return
return "", "", "", size
}
}
return md5Str, headMd5, tailMd5, size
}
// isDuplicate 判断文件是否与已入库记录重复(按 MD5)
func (o *Organizer) isDuplicate(md5Str string) bool {
if o.store == nil || md5Str == "" {
return false
}
_, ok := o.store.FindMediaByMd5(md5Str)
return ok
}
// record 将媒体信息写入数据库
func (o *Organizer) record(f scanner.File, mediaType string, res *probe.ProbeResult, md5Str, headMd5, tailMd5 string, size int64) {
if o.store == nil || md5Str == "" {
return
}
codec := res.VideoCodec
if mediaType == "audio" {
codec = res.AudioCodec
@@ -107,20 +126,22 @@ func (o *Organizer) record(f scanner.File, mediaType string, res *probe.ProbeRes
}
}
// ProcessImages 处理全部图片:探测尺寸并归类
// ProcessImages 处理全部图片:去重 → 探测尺寸并归类
func (o *Organizer) ProcessImages(images []scanner.File) {
success, fail, ignore := 0, 0, 0
success, fail, ignore, dup := 0, 0, 0, 0
for _, f := range images {
switch {
case strings.EqualFold(f.Ext, ".psd"):
o.add(f, dirImagePSD)
success++
continue
case !probe.IsSupportImage(f.Ext):
case !probe.IsSupportImage(f.Ext) && o.ffprobePath == "":
// 非白名单扩展名且无 ffprobe 兜底时直接忽略
o.add(f, dirImageIgnored)
ignore++
continue
}
res, err := probe.ProbeImage(f.Path, o.ffprobePath)
if err != nil {
o.add(f, dirImageErr)
@@ -128,7 +149,15 @@ func (o *Organizer) ProcessImages(images []scanner.File) {
fmt.Printf("=== 异常图片: %s \n", f.Path)
continue
}
o.record(f, "image", res)
md5Str, headMd5, tailMd5, size := o.calcFileInfo(f)
if o.isDuplicate(md5Str) {
o.add(f, dirDuplicate)
dup++
fmt.Printf("=== 重复图片: %s \n", f.Path)
continue
}
o.record(f, "image", res, md5Str, headMd5, tailMd5, size)
darkScore, lightScore := 0.0, 0.0
if !strings.EqualFold(f.Ext, ".gif") {
dark, light, berr := probe.AnalyzeBrightness(f.Path)
@@ -141,12 +170,12 @@ func (o *Organizer) ProcessImages(images []scanner.File) {
o.add(f, classify.ImageDir(f.Ext, res.Width, res.Height, darkScore, lightScore))
success++
}
fmt.Printf("=== 图片处理完毕,成功: %d, 读取异常: %d, 忽略: %d \n\n", success, fail, ignore)
fmt.Printf("=== 图片处理完毕,成功: %d, 读取异常: %d, 忽略: %d, 重复: %d \n\n", success, fail, ignore, dup)
}
// ProcessMedia 处理媒体候选:ffprobe 判定视频/音频/不支持
// ProcessMedia 处理媒体候选:去重 → ffprobe 判定视频/音频/不支持
func (o *Organizer) ProcessMedia(media []scanner.File) {
video, audio, fail, ignore := 0, 0, 0, 0
video, audio, fail, ignore, dup := 0, 0, 0, 0, 0
for _, f := range media {
res, err := probe.ProbeMediaFile(o.ffprobePath, f.Path)
if err != nil {
@@ -167,12 +196,26 @@ func (o *Organizer) ProcessMedia(media []scanner.File) {
}
switch {
case res.HasVideo:
md5Str, headMd5, tailMd5, size := o.calcFileInfo(f)
if o.isDuplicate(md5Str) {
dup++
o.add(f, dirDuplicate)
fmt.Printf("=== 重复媒体: %s \n", f.Path)
continue
}
video++
o.record(f, "video", res)
o.record(f, "video", res, md5Str, headMd5, tailMd5, size)
o.add(f, classify.VideoDir(f.Ext, res.Width, res.Height))
case res.HasAudio:
md5Str, headMd5, tailMd5, size := o.calcFileInfo(f)
if o.isDuplicate(md5Str) {
dup++
o.add(f, dirDuplicate)
fmt.Printf("=== 重复媒体: %s \n", f.Path)
continue
}
audio++
o.record(f, "audio", res)
o.record(f, "audio", res, md5Str, headMd5, tailMd5, size)
o.add(f, dirAudio)
default:
// ffprobe 无音视频流:MIME 明确为媒体的归入忽略目录,其余原地不动
@@ -185,7 +228,7 @@ func (o *Organizer) ProcessMedia(media []scanner.File) {
}
}
}
fmt.Printf("=== 媒体处理完毕,视频: %d, 音频: %d, 读取异常: %d, 忽略: %d \n\n", video, audio, fail, ignore)
fmt.Printf("=== 媒体处理完毕,视频: %d, 音频: %d, 读取异常: %d, 忽略: %d, 重复: %d \n\n", video, audio, fail, ignore, dup)
}
// Flush 将所有收集到的文件移动到对应目录,并回填归类去向