// Package scanner 负责递归扫描文件、读取 MIME 并按类型过滤。 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 // 扩展名(含点) Mime string // MIME 类型 } // 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 信息 func Scan(opts Options) (*Result, error) { res := &Result{} // 遍历阶段尚不知道总文件数,先按「已发现文件数」实时上报(total=0), // 并节流上报频率,避免目录很大时界面长时间无动静或推送过密。 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 { // 空文件不可能是媒体,直接跳过,避免无谓的 MIME/ffprobe 探测 return nil } 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 } total := len(res.Files) fmt.Printf("=== 文件总数: %d \n", total) // 遍历结束、开始 MIME 探测前,上报一次完整总数 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 { if strings.Contains(f.Mime, "image/") || probe.IsSupportImage(f.Ext) || strings.EqualFold(f.Ext, ".psd") { r.Images = append(r.Images, f) continue } if isPotentialMedia(f.Mime) { r.Media = append(r.Media, f) continue } // 明显的非媒体文件(代码/文档/压缩包等)直接不处理 } } // detectMime 读取文件的 MIME 类型 func detectMime(filePath string) string { mt, err := mimetype.DetectFile(filePath) if err != nil { return "" } return mt.String() } // IsExplicitMediaMime 判断 MIME 是否明确属于视频/音频(区别于 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 } 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 }