Files
kenaito-media-picker/internal/scanner/scanner.go
T
odboy 8979c744a8 refactor: 全面重构代码并加白话注释,移动全局并行,去掉去向/重复记录
- 重构后端与前端,全程大白话注释(不懂程序也能看懂)
- 移动阶段改为全局并行(MoveAll,工人数=CPU核心数)
- 去掉记录每个文件搬去哪了(删除去向回填,解决写库卡顿)
- 去掉重复文件标记与统计:重复判定改为 MD5+路径都相同才算,重复则原地忽略不移动;MD5 相同但路径不同仍照常搬
- 媒体库去掉归类目录列,看板去掉重复/归类分布统计
2025-05-11 21:05:35 +08:00

177 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package scanner 负责"扫描"这一步:把一个目录(包括所有子目录)翻个底朝天,
// 找出里面所有文件,认出每个文件的"身份"(MIME 类型),再大致分成图片、视频/音频候选。
//
// 大白话理解:
// - Scan:走进一个文件夹,一层层翻下去,把每个文件登记在案,并判断"这是个啥文件"
// - Filter:把登记好的文件分成两堆——一堆像图片,一堆可能是视频/音频
package scanner
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"kenaito-media-picker/internal/probe"
"github.com/gabriel-vasile/mimetype"
)
// File 扫描到的一个文件。
type File struct {
Path string // 文件的完整路径(从哪找到的)
Name string // 文件名(含扩展名)
Ext string // 扩展名(带点,如 .jpg)
Mime string // 文件类型(MIME 类型,如 image/jpeg、video/mp4)
}
// Result 扫描 + 过滤后的结果。
type Result struct {
Files []File // 全部文件(还没分堆时)
Images []File // 图片文件
Media []File // 可能是视频/音频的文件(还需要 ffprobe 进一步确认)
}
// Options 扫描的参数。
type Options struct {
RootDir string // 从哪个目录开始扫
SkipDir string // 这个目录整个跳过(整理输出目录,不能把自己整理的成果又扫一遍)
SkipDirNames []string // 名字叫这些的目录跳过(比如任意位置的「KMP已整理」)
SkipNames []string // 名字叫这些的文件跳过(数据库文件、ffprobe 程序等)
OnProgress func(done, total int) // 每处理完一些文件就报一次进度
}
// Scan 从根目录开始递归扫描,找出所有文件并识别每个文件的 MIME 类型。
//
// 分两个阶段:
// 1. 走目录树(遍历):把文件一个个找出来登记。这个阶段还不知道总数,
// 所以每发现一些文件就报一次"已发现 N 个"(总数先报 0,前端会特殊显示)。
// 2. 认身份(MIME):逐个读取文件内容判断类型。这时总数已确定,进度就是"已认完 N/总数"。
func Scan(opts Options) (*Result, error) {
res := &Result{}
// ---------- 第一阶段:走目录树,登记文件 ----------
// 节流上报:最多每 0.1 秒报一次,免得目录特别大时推送消息刷爆。
walkCount := 0
lastReport := time.Now()
err := filepath.Walk(opts.RootDir, func(currentPath string, info os.FileInfo, err error) error {
if err != nil {
// 某个文件夹打不开(没权限/坏了)不影响大局,警告一声跳过它继续扫
fmt.Printf("=== 扫描跳过(无法访问): %s, 错误: %v\n", currentPath, err)
return nil
}
// 是目录:检查要不要跳过
if info.IsDir() {
if opts.SkipDir != "" && currentPath == opts.SkipDir {
return filepath.SkipDir // 整个目录连同子目录一起跳过
}
for _, n := range opts.SkipDirNames {
if info.Name() == n {
return filepath.SkipDir
}
}
return nil
}
// 是文件:名字在跳过名单里的,直接忽略
for _, n := range opts.SkipNames {
if info.Name() == n {
return nil
}
}
// 空文件不可能是媒体,跳过,免得白费力气去认它
if info.Size() == 0 {
return nil
}
// 登记这个文件(MIME 还没认,留到第二阶段)
res.Files = append(res.Files, File{
Path: currentPath,
Name: info.Name(),
Ext: filepath.Ext(currentPath),
})
walkCount++
if opts.OnProgress != nil && time.Since(lastReport) >= 100*time.Millisecond {
lastReport = time.Now()
opts.OnProgress(walkCount, 0)
}
return nil
})
if err != nil {
return nil, err
}
// ---------- 第二阶段:逐个文件认身份(MIME) ----------
total := len(res.Files)
fmt.Printf("=== 文件总数: %d \n", total)
// 开认之前,先把总数报给前端(之前是 0,现在补上)
if opts.OnProgress != nil {
opts.OnProgress(total, total)
}
for i := range res.Files {
res.Files[i].Mime = detectMime(res.Files[i].Path)
if opts.OnProgress != nil {
opts.OnProgress(i+1, total)
}
}
return res, nil
}
// Filter 把扫描结果分堆:图片一堆,可能是视频/音频的一堆,其余不管。
func (r *Result) Filter() {
for _, f := range r.Files {
// 是图片(MIME 说图片,或扩展名是认识的图片格式,或 PSD)
if strings.Contains(f.Mime, "image/") || probe.IsSupportImage(f.Ext) || strings.EqualFold(f.Ext, ".psd") {
r.Images = append(r.Images, f)
continue
}
// 可能是视频/音频:先放进候选堆,后面用 ffprobe 验证真伪
if isPotentialMedia(f.Mime) {
r.Media = append(r.Media, f)
continue
}
// 一眼就看出不是媒体的(文档、压缩包、代码等):直接不管
}
}
// detectMime 读取文件内容,判断它是什么类型的文件。认不出来返回空串。
func detectMime(filePath string) string {
mt, err := mimetype.DetectFile(filePath)
if err != nil {
return ""
}
return mt.String()
}
// IsExplicitMediaMime 判断 MIME 是否明确说了自己是视频/音频
// (区别于 application/octet-stream、空值这种"说不清是啥"的情况)。
func IsExplicitMediaMime(fileMime string) bool {
return strings.HasPrefix(fileMime, "video/") || strings.HasPrefix(fileMime, "audio/")
}
// 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 // 明确是媒体
}
// 这些 MIME 一眼就看得出不是媒体,不用浪费 ffprobe 的时间
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 // 其他说不清的类型,保险起见验一下
}