- 重构后端与前端,全程大白话注释(不懂程序也能看懂) - 移动阶段改为全局并行(MoveAll,工人数=CPU核心数) - 去掉记录每个文件搬去哪了(删除去向回填,解决写库卡顿) - 去掉重复文件标记与统计:重复判定改为 MD5+路径都相同才算,重复则原地忽略不移动;MD5 相同但路径不同仍照常搬 - 媒体库去掉归类目录列,看板去掉重复/归类分布统计
177 lines
6.8 KiB
Go
177 lines
6.8 KiB
Go
// 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 // 其他说不清的类型,保险起见验一下
|
||
}
|