// Package scanner 负责"扫描"这一步:把一个目录(包括所有子目录)翻个底朝天, // 找出里面所有文件,认出每个文件的"身份"(MIME 类型),再大致分成图片、视频/音频候选。 // // 大白话理解: // - Scan:走进一个文件夹,一层层翻下去,把每个文件登记在案,并判断"这是个啥文件" // - Filter:把登记好的文件分成两堆——一堆像图片,一堆可能是视频/音频 package scanner import ( "context" "fmt" "os" "path/filepath" "strings" "sync" "sync/atomic" "time" "kenaito-media-picker/internal/config" "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 { Ctx context.Context // 中止信号:ctx 被取消时扫描立即停下(可为 nil) 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 opts.Ctx != nil && opts.Ctx.Err() != nil { return opts.Ctx.Err() } 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) } // 并行认类型:每个 worker 只写自己名下那个元素(res.Files[i]),并发安全; // 认类型要读文件内容,数量多时串行太慢,并行能明显提速。 workers := config.WorkerCount() if workers > total { workers = total } if workers < 1 { workers = 1 } var wg sync.WaitGroup var done int32 jobs := make(chan int) for w := 0; w < workers; w++ { wg.Add(1) go func() { defer wg.Done() for i := range jobs { // 用户点了中止:不再认类型,但还要继续把通道里的任务消费完, // 否则发任务的主 goroutine 会一直堵在发送上 if opts.Ctx != nil && opts.Ctx.Err() != nil { continue } res.Files[i].Mime = detectMime(res.Files[i].Path) if opts.OnProgress != nil { opts.OnProgress(int(atomic.AddInt32(&done, 1)), total) } } }() } for i := range res.Files { jobs <- i } close(jobs) wg.Wait() // 中止时把"已取消"当错误带回去,好让上层识别成"已中止"而不是正常完成 if opts.Ctx != nil && opts.Ctx.Err() != nil { return res, opts.Ctx.Err() } 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 // 其他说不清的类型,保险起见验一下 }