- 移动阶段并行处理文件,按原子计数上报累计进度 - 扫描遍历阶段实时上报已发现文件数,界面不再卡在 0/0 - 新增 /api/scan/ws WebSocket 实时推送扫描状态;前端断线指数退避自动重连,并以低频轮询兜底 - 新增统计看板与媒体库筛选/排序,目录选择器路径不存在时回退到最近可用父目录 - 存放目录支持自定义并持久化,默认输出到 KMP已整理
158 lines
4.8 KiB
Go
158 lines
4.8 KiB
Go
// 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
|
|
}
|