refactor: 按模块重构项目结构

- 删除 vars 全局状态,按职责拆分 internal/ 包:scanner/probe/classify/organize/mover/store/digest
- 数据驱动归类规则替代图片/视频重复分支,store 改为结构体实例
- build.sh 按系统类型编译:Windows 用 upx 压缩,macOS/Linux 不压缩
- 忽略目录拆分:图片_已忽略 / 视频_已忽略
- 输出构建产物加入 .gitignore
This commit is contained in:
2025-03-26 21:08:12 +08:00
parent 26cbd5bb27
commit 4da4b738e6
36 changed files with 1016 additions and 2476 deletions
+72
View File
@@ -0,0 +1,72 @@
// Package classify 提供纯函数的分辨率归类规则,不依赖任何状态。
package classify
import "strings"
// Orientation 画面方向
type Orientation int
const (
OrientationSquare Orientation = iota
OrientationHorizontal
OrientationVertical
)
// OrientationOf 由宽高判定画面方向
func OrientationOf(width, height int) Orientation {
switch {
case width > height:
return OrientationHorizontal
case width < height:
return OrientationVertical
default:
return OrientationSquare
}
}
func (o Orientation) String() string {
switch o {
case OrientationHorizontal:
return "横屏"
case OrientationVertical:
return "竖屏"
default:
return "等比"
}
}
// ImageDir 返回图片归类子目录名,如 "图片_横屏_1080P" / "图片_GIF"
func ImageDir(ext string, width, height int) string {
if strings.EqualFold(ext, ".gif") {
return "图片_GIF"
}
return resolutionDir("图片", ext, width, height)
}
// VideoDir 返回视频归类子目录名,如 "视频_竖屏_4k" / "视频_横屏_GIF"
func VideoDir(ext string, width, height int) string {
return resolutionDir("视频", ext, width, height)
}
func resolutionDir(kind, ext string, width, height int) string {
ori := OrientationOf(width, height).String()
if strings.EqualFold(ext, ".gif") {
return kind + "_" + ori + "_GIF"
}
if suffix := matchStandard(width, height); suffix != "" {
return kind + "_" + ori + "_" + suffix
}
overflow := "原图"
if kind == "视频" {
overflow = "原画质"
}
return kind + "_" + ori + "_" + levelName(longEdge(width, height), overflow)
}
// longEdge 返回长边像素
func longEdge(w, h int) int {
if w > h {
return w
}
return h
}
+52
View File
@@ -0,0 +1,52 @@
package classify
import "fmt"
// standardResolution 标准分辨率规则:宽高精确匹配
type standardResolution struct {
width int
height int
suffix string
}
var standardResolutions = []standardResolution{
{1280, 720, "720P"}, // 720p 标准高清
{1600, 900, "1D5KP"}, // 介于 HD 与 FHD 之间
{1920, 1080, "1080P"}, // 1080p 全高清
{2048, 1080, "2DKP"}, // 影院 2K
{2560, 1440, "2D5KP"}, // 2.5K
{2560, 1080, "UltraWide2KP"}, // 带鱼屏
{3440, 1440, "UltraWide3KP"}, // 带鱼屏
{5120, 2160, "UltraWide4KP"}, // 带鱼屏
{3840, 2160, "4KP"}, // 主流 4K
{4096, 2160, "4KHP"}, // DCI 4K
{7680, 4320, "8KP"}, // 8K
}
// 非标分辨率分档参数
const (
bucketStep = 1024 // 每 1024px 一档
maxLevel = 19 // 1K ~ 19K 共 19 档(普通另算 1 档,合计 20 档)
)
// matchStandard 精确匹配标准分辨率,命中返回后缀,否则返回空串
func matchStandard(width, height int) string {
for _, r := range standardResolutions {
if r.width == width && r.height == height {
return r.suffix
}
}
return ""
}
// levelName 按长边分档:普通(<1024) / 1K~19K(每1024一档) / overflow(≥20480)
func levelName(longEdge int, overflow string) string {
if longEdge < bucketStep {
return "普通"
}
level := longEdge / bucketStep
if level > maxLevel {
return overflow
}
return fmt.Sprintf("%dK", level)
}
+53
View File
@@ -0,0 +1,53 @@
// Package digest 提供文件 MD5 与指纹采样计算,供媒体入库时复用。
package digest
import (
"crypto/md5"
"encoding/hex"
"io"
"os"
)
const (
// FingerprintThreshold 文件大于该字节数才使用指纹复用,避免全量MD5
FingerprintThreshold = 1 << 20
fingerprintHeadSize = 256 << 10 // 文件头采样256KB
fingerprintTailSize = 256 << 10 // 文件尾采样256KB
)
// CalcFileMd5 计算文件MD5(分块读取,适合大文件)
func CalcFileMd5(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", err
}
defer file.Close()
h := md5.New()
if _, err := io.Copy(h, file); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// CalcFileFingerprint 计算文件头部与尾部采样MD5(只读取很少字节,用于快速识别相同文件)
func CalcFileFingerprint(filePath string, size int64) (headMd5, tailMd5 string, err error) {
file, err := os.Open(filePath)
if err != nil {
return "", "", err
}
defer file.Close()
h := md5.New()
if _, err := io.CopyN(h, file, fingerprintHeadSize); err != nil {
return "", "", err
}
headMd5 = hex.EncodeToString(h.Sum(nil))
h = md5.New()
if _, err := file.Seek(size-fingerprintTailSize, io.SeekStart); err != nil {
return "", "", err
}
if _, err := io.CopyN(h, file, fingerprintTailSize); err != nil {
return "", "", err
}
tailMd5 = hex.EncodeToString(h.Sum(nil))
return headMd5, tailMd5, nil
}
+88
View File
@@ -0,0 +1,88 @@
// Package mover 负责文件的移动,跨文件系统时自动回退为复制后删除。
package mover
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/redmask-hb/GoSimplePrint/goPrint"
uuid "github.com/satori/go.uuid"
)
// Mover 文件移动器
type Mover struct{}
// MoveToDir 将一批文件移动到目标目录(目标文件名使用 uuid 前缀防止同名覆盖)。
// 返回移动成功的 源路径 → 目标目录 映射,供记录去向使用。
func (m *Mover) MoveToDir(filePaths []string, targetDir string) map[string]string {
moved := make(map[string]string)
if len(filePaths) == 0 {
return moved
}
if err := os.MkdirAll(targetDir, 0755); err != nil {
fmt.Printf("=== 创建目录失败: %s, 错误: %v\n", targetDir, err)
return moved
}
bar := goPrint.NewBar(100)
bar.SetNotice("=== 移动文件到目录:")
bar.SetGraph(">")
total := len(filePaths)
for i, src := range filePaths {
if moveOne(src, targetDir) {
moved[src] = targetDir
}
bar.PrintBar(percent(i+1, total))
}
bar.PrintEnd("=== Finish")
return moved
}
// moveOne 移动单个文件;跨文件系统时 fallback 为 copy+delete
func moveOne(src, targetDir string) bool {
fileName := strings.ReplaceAll(uuid.NewV4().String(), "-", "") + "_" + filepath.Base(src)
target := filepath.Join(targetDir, fileName)
if err := os.Rename(src, target); err == nil {
return true
}
if err := copyFile(src, target); err != nil {
fmt.Printf("=== 复制文件失败, 源: %s, 目标: %s, 错误: %v\n", src, target, err)
return false
}
if err := os.Remove(src); err != nil {
fmt.Printf("=== 删除源文件失败: %s, 错误: %v\n", src, err)
// 复制成功但删除失败,文件已存在于两处,视为移动成功
}
return true
}
// copyFile 逐块复制文件
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Close()
}
// percent 计算百分比(0~100)
func percent(part, total int) int {
if total <= 0 {
return 100
}
return int(float64(part) / float64(total) * 100)
}
+181
View File
@@ -0,0 +1,181 @@
// Package organize 编排整理流程:探测 → 归类 → 收集 bucket → 一次性移动并记录去向。
package organize
import (
"fmt"
"os"
"path/filepath"
"strings"
"kenaito-media-picker/internal/classify"
"kenaito-media-picker/internal/digest"
"kenaito-media-picker/internal/mover"
"kenaito-media-picker/internal/probe"
"kenaito-media-picker/internal/scanner"
"kenaito-media-picker/internal/store"
)
// 特殊归类目录名
const (
dirImagePSD = "图片_PSD"
dirImageErr = "图片_读取异常"
dirImageIgnored = "图片_已忽略"
dirVideoErr = "视频_读取异常"
dirVideoIgnored = "视频_已忽略"
dirAudio = "音频"
)
// Organizer 整理器:按目标目录收集文件,处理完成后统一建目录并移动
type Organizer struct {
pickerDir string
ffprobePath string
store *store.Store
mover *mover.Mover
buckets map[string][]string // 目标目录 → 文件路径
order []string // 目标目录的创建顺序(保证输出稳定)
}
// New 创建整理器;store 可为 nil(数据库初始化失败时仅不记录信息)
func New(pickerDir, ffprobePath string, st *store.Store) *Organizer {
return &Organizer{
pickerDir: pickerDir,
ffprobePath: ffprobePath,
store: st,
mover: &mover.Mover{},
buckets: make(map[string][]string),
}
}
// add 将文件归入某个子目录
func (o *Organizer) add(f scanner.File, subDir string) {
dir := filepath.Join(o.pickerDir, subDir)
if _, ok := o.buckets[dir]; !ok {
o.order = append(o.order, dir)
}
o.buckets[dir] = append(o.buckets[dir], f.Path)
}
// record 将媒体信息写入数据库(大文件优先用指纹复用已入库的 MD5)
func (o *Organizer) record(f scanner.File, mediaType string, res *probe.ProbeResult) {
st, err := os.Stat(f.Path)
if err != nil {
fmt.Printf("=== 读取文件信息失败: %s, 错误: %v\n", f.Path, err)
return
}
size := st.Size()
md5Str := ""
headMd5, tailMd5 := "", ""
if size > digest.FingerprintThreshold {
if head, tail, ferr := digest.CalcFileFingerprint(f.Path, size); ferr == nil {
headMd5, tailMd5 = head, tail
if existing, ok := o.store.FindMediaByFingerprint(size, head, tail); ok && existing.FileMd5 != "" {
md5Str = existing.FileMd5 // 指纹命中,复用MD5,跳过全量读取
}
}
}
if md5Str == "" {
if md5Str, err = digest.CalcFileMd5(f.Path); err != nil {
fmt.Printf("=== 计算MD5失败: %s, 错误: %v\n", f.Path, err)
return
}
}
codec := res.VideoCodec
if mediaType == "audio" {
codec = res.AudioCodec
}
info := &store.MediaInfo{
FileMd5: md5Str,
FilePath: f.Path,
FileName: f.Name,
FileExt: f.Ext,
FileSize: size,
MimeType: f.Mime,
MediaType: mediaType,
Width: res.Width,
Height: res.Height,
Duration: res.Duration,
Codec: codec,
Format: res.Format,
RawInfo: res.RawJSON,
HeadMd5: headMd5,
TailMd5: tailMd5,
}
if err := o.store.SaveMedia(info); err != nil {
fmt.Printf("=== 保存媒体信息失败: %s, 错误: %v\n", f.Path, err)
}
}
// ProcessImages 处理全部图片:探测尺寸并归类
func (o *Organizer) ProcessImages(images []scanner.File) {
success, fail, ignore := 0, 0, 0
for _, f := range images {
switch {
case strings.EqualFold(f.Ext, ".psd"):
o.add(f, dirImagePSD)
success++
continue
case !probe.IsSupportImage(f.Ext):
o.add(f, dirImageIgnored)
ignore++
continue
}
res, err := probe.ProbeImage(f.Path, o.ffprobePath)
if err != nil {
o.add(f, dirImageErr)
fail++
fmt.Printf("=== 异常图片: %s \n", f.Path)
continue
}
o.record(f, "image", res)
o.add(f, classify.ImageDir(f.Ext, res.Width, res.Height))
success++
}
fmt.Printf("=== 图片处理完毕,成功: %d, 读取异常: %d, 忽略: %d \n\n", success, fail, ignore)
}
// ProcessMedia 处理媒体候选:ffprobe 判定视频/音频/不支持
func (o *Organizer) ProcessMedia(media []scanner.File) {
video, audio, fail, ignore := 0, 0, 0, 0
for _, f := range media {
res, err := probe.ProbeMediaFile(o.ffprobePath, f.Path)
if err != nil {
if strings.HasPrefix(f.Mime, "video/") {
fail++
o.add(f, dirVideoErr)
fmt.Printf("=== 异常视频: %s \n", f.Path)
} else {
ignore++
o.add(f, dirVideoIgnored)
}
continue
}
switch {
case res.HasVideo:
video++
o.record(f, "video", res)
o.add(f, classify.VideoDir(f.Ext, res.Width, res.Height))
case res.HasAudio:
audio++
o.record(f, "audio", res)
o.add(f, dirAudio)
default:
ignore++
o.add(f, dirVideoIgnored)
}
}
fmt.Printf("=== 媒体处理完毕,视频: %d, 音频: %d, 读取异常: %d, 忽略: %d \n\n", video, audio, fail, ignore)
}
// Flush 将所有收集到的文件移动到对应目录,并回填归类去向
func (o *Organizer) Flush() {
for _, dir := range o.order {
moved := o.mover.MoveToDir(o.buckets[dir], dir)
for src, dest := range moved {
if err := o.store.UpdateMediaDest(src, dest); err != nil {
fmt.Printf("=== 更新媒体去向失败: %s, 错误: %v\n", src, err)
}
}
}
}
+52
View File
@@ -0,0 +1,52 @@
package probe
import (
"encoding/binary"
"fmt"
"os"
)
// probeBmp 读取 BMP 文件头中的宽高;自顶向下 BMP 的高度为负值,取绝对值
func probeBmp(filePath string) (*ProbeResult, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
var fileHeader struct {
FileType [2]byte
FileSize uint32
Reserved1 uint16
Reserved2 uint16
DataOffset uint32
}
if err := binary.Read(f, binary.LittleEndian, &fileHeader); err != nil {
return nil, err
}
if string(fileHeader.FileType[:]) != "BM" {
return nil, fmt.Errorf("not a valid BMP file")
}
var infoHeader struct {
Size uint32
Width int32
Height int32
Planes uint16
BitsPerPixel uint16
Compression uint32
SizeImage uint32
XPelsPerMeter int32
YPelsPerMeter int32
}
if err := binary.Read(f, binary.LittleEndian, &infoHeader); err != nil {
return nil, err
}
return &ProbeResult{Width: abs(infoHeader.Width), Height: abs(infoHeader.Height)}, nil
}
func abs(v int32) int {
if v < 0 {
return int(-v)
}
return int(v)
}
+32
View File
@@ -0,0 +1,32 @@
package probe
import (
"fmt"
"os"
)
// FFprobeExeName 释放到当前目录的解码器文件名
const FFprobeExeName = "ffprobe.exe"
// FFprobePath 返回当前目录下的 ffprobe 可执行路径(Windows/Unix 均可直接执行)
func FFprobePath() string {
return "." + string(os.PathSeparator) + FFprobeExeName
}
// HasEmbeddedFFprobe 当前平台是否内嵌了 ffprobe
func HasEmbeddedFFprobe() bool {
return len(embeddedFFprobe) > 0
}
// ReleaseFFprobe 释放内嵌的 ffprobe 到当前目录(按当前系统自动选择对应版本)
func ReleaseFFprobe() error {
if len(embeddedFFprobe) == 0 {
return fmt.Errorf("当前系统没有内嵌的ffprobe")
}
return os.WriteFile(FFprobeExeName, embeddedFFprobe, 0777)
}
// RemoveFFprobe 删除当前目录下释放的 ffprobe
func RemoveFFprobe() {
_ = os.Remove(FFprobeExeName)
}
+8
View File
@@ -0,0 +1,8 @@
//go:build darwin
package probe
import _ "embed"
//go:embed bin/ffprobe_mac
var embeddedFFprobe []byte // macOS 版 ffprobe(未入库,见 .gitignore)
@@ -0,0 +1,6 @@
//go:build !darwin && !windows
package probe
// embeddedFFprobe 当前平台没有内嵌的 ffprobe
var embeddedFFprobe []byte
+8
View File
@@ -0,0 +1,8 @@
//go:build windows
package probe
import _ "embed"
//go:embed bin/ffprobe.exe
var embeddedFFprobe []byte // Windows 版 ffprobe(未入库,见 .gitignore)
+76
View File
@@ -0,0 +1,76 @@
package probe
import (
"fmt"
"image"
"os"
"path/filepath"
"strings"
)
// supportImageTypes 支持的标准图片扩展名(大小写不敏感)
var supportImageTypes = []string{
".bmp",
".gif",
".jpg",
".jpeg",
".jpe",
".png",
".webp",
".psb",
".jfif",
}
// IsSupportImage 判断扩展名是否属于支持的图片格式
func IsSupportImage(ext string) bool {
for _, t := range supportImageTypes {
if strings.EqualFold(t, ext) {
return true
}
}
return false
}
// ProbeImage 统一探测图片尺寸:
// 1. 特殊格式(webp/bmp/psd/psb)走专用解码器
// 2. 其余支持格式走 Go 标准库 image.DecodeConfig
// 3. 标准库失败时用 ffprobe 兜底(ffmpeg 支持的图片格式更广,如 tiff/svg 等)
//
// ffprobePath 为空表示不启用 ffprobe 兜底;扩展名不属于支持格式时返回错误。
func ProbeImage(filePath, ffprobePath string) (*ProbeResult, error) {
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".webp":
return probeWebp(filePath)
case ".bmp":
return probeBmp(filePath)
case ".psd", ".psb":
return probePsd(filePath)
}
if !IsSupportImage(ext) {
return nil, fmt.Errorf("不支持的图片格式: %s", ext)
}
if w, h, err := decodeConfig(filePath); err == nil {
return &ProbeResult{Width: w, Height: h}, nil
}
if ffprobePath != "" {
if res, err := ProbeMediaFile(ffprobePath, filePath); err == nil && res.HasVideo {
return res, nil
}
}
return nil, fmt.Errorf("无法读取图片尺寸: %s", filePath)
}
// decodeConfig 用 Go 标准库读取图片宽高
func decodeConfig(filePath string) (int, int, error) {
f, err := os.Open(filePath)
if err != nil {
return 0, 0, err
}
defer f.Close()
cfg, _, err := image.DecodeConfig(f)
if err != nil {
return 0, 0, err
}
return cfg.Width, cfg.Height, nil
}
+80
View File
@@ -0,0 +1,80 @@
// Package probe 负责媒体信息探测:图片尺寸读取、ffprobe 视频/音频解析与内嵌解码器释放。
package probe
import (
"encoding/json"
"fmt"
"os/exec"
"strconv"
)
// ProbeResult 媒体探测结果(图片/视频/音频通用)
type ProbeResult struct {
HasVideo bool
HasAudio bool
Width int
Height int
Duration float64
VideoCodec string
AudioCodec string
Format string
RawJSON string
}
type streamInfo struct {
Index int `json:"index"`
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Width int `json:"width"`
Height int `json:"height"`
}
type formatInfo struct {
Duration string `json:"duration"`
Format string `json:"format_name"`
}
type probeOutput struct {
Streams []streamInfo `json:"streams"`
Format formatInfo `json:"format"`
}
// ProbeMediaFile 用 ffprobe 探测媒体文件,一次调用同时获取视频/音频流与时长
func ProbeMediaFile(ffprobePath, filePath string) (*ProbeResult, error) {
cmd := exec.Command(ffprobePath, "-v", "error",
"-show_entries", "stream=index,codec_type,codec_name,width,height:format=duration,format_name",
"-of", "json", filePath)
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("ffprobe failed: %w", err)
}
var out probeOutput
if err := json.Unmarshal(output, &out); err != nil {
return nil, fmt.Errorf("parse ffprobe output: %w", err)
}
res := &ProbeResult{
Format: out.Format.Format,
RawJSON: string(output),
}
if d, err := strconv.ParseFloat(out.Format.Duration, 64); err == nil {
res.Duration = d
}
for _, s := range out.Streams {
switch s.CodecType {
case "video":
res.HasVideo = true
if res.VideoCodec == "" {
res.VideoCodec = s.CodecName
}
if res.Width == 0 && s.Width > 0 && s.Height > 0 {
res.Width, res.Height = s.Width, s.Height
}
case "audio":
res.HasAudio = true
if res.AudioCodec == "" {
res.AudioCodec = s.CodecName
}
}
}
return res, nil
}
+32
View File
@@ -0,0 +1,32 @@
package probe
import (
"encoding/binary"
"fmt"
"io"
"os"
)
// probePsd 读取 PSD/PSB 格式的宽高(两者共享相同的 8BPS 文件头)
func probePsd(filePath string) (*ProbeResult, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
var header [26]byte
if _, err := io.ReadFull(f, header[:]); err != nil {
return nil, err
}
if string(header[0:4]) != "8BPS" {
return nil, fmt.Errorf("not a valid PSD/PSB file")
}
version := binary.BigEndian.Uint16(header[4:6])
if version != 1 && version != 2 {
return nil, fmt.Errorf("unsupported PSD/PSB version: %d", version)
}
height := int(binary.BigEndian.Uint32(header[14:18]))
width := int(binary.BigEndian.Uint32(header[18:22]))
return &ProbeResult{Width: width, Height: height}, nil
}
+22
View File
@@ -0,0 +1,22 @@
package probe
import (
"fmt"
"os"
"golang.org/x/image/webp"
)
// probeWebp 读取 WebP 图片宽高(只解码配置,不加载完整像素数据)
func probeWebp(filePath string) (*ProbeResult, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
cfg, err := webp.DecodeConfig(f)
if err != nil {
return nil, fmt.Errorf("decode webp: %w", err)
}
return &ProbeResult{Width: cfg.Width, Height: cfg.Height}, nil
}
+138
View File
@@ -0,0 +1,138 @@
// Package scanner 负责递归扫描文件、读取 MIME 并按类型过滤。
package scanner
import (
"fmt"
"os"
"path"
"path/filepath"
"strings"
"kenaito-media-picker/internal/probe"
"github.com/gabriel-vasile/mimetype"
"github.com/redmask-hb/GoSimplePrint/goPrint"
)
// 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 // 白名单输出目录,整目录跳过
SkipNames []string // 按文件名跳过(数据库、ffprobe 等)
}
// Scan 递归扫描目录并读取每个文件的 MIME 信息
func Scan(opts Options) (*Result, error) {
res := &Result{}
err := filepath.Walk(opts.RootDir, func(currentPath string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
if opts.SkipDir != "" && currentPath == opts.SkipDir {
return filepath.SkipDir
}
return nil
}
for _, n := range opts.SkipNames {
if info.Name() == n {
return nil
}
}
res.Files = append(res.Files, File{
Path: currentPath,
Name: info.Name(),
Ext: path.Ext(currentPath),
})
return nil
})
if err != nil {
return nil, err
}
total := len(res.Files)
fmt.Printf("=== 文件总数: %d \n", total)
bar := goPrint.NewBar(100)
bar.SetNotice("=== 扫描文件Mime:")
bar.SetGraph(">")
bar.SetNoticeColor(goPrint.FontColor.Red)
for i := range res.Files {
res.Files[i].Mime = detectMime(res.Files[i].Path)
bar.PrintBar(percent(i+1, total))
}
bar.PrintEnd("=== Finish")
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()
}
// 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
}
// percent 计算百分比(0~100),total 为 0 时返回 100
func percent(part, total int) int {
if total <= 0 {
return 100
}
return int(float64(part) / float64(total) * 100)
}
+117
View File
@@ -0,0 +1,117 @@
// Package store 提供媒体信息的 SQLite 持久化(xorm + 纯 Go 驱动,免 CGO)。
package store
import (
"database/sql"
"path/filepath"
"time"
sqlite "github.com/glebarez/go-sqlite"
"xorm.io/xorm"
)
// DBFileName 数据库文件名(存放于被整理目录的根目录,扫描时跳过)
const DBFileName = "media-info.db"
// init 将纯Go的sqlite驱动注册为 xorm 认识的 sqlite3 驱动名(避免CGO依赖)
func init() {
sql.Register("sqlite3", &sqlite.Driver{})
}
// MediaInfo 媒体文件信息记录,按字段拆分存放,便于查询
type MediaInfo struct {
Id int64 `xorm:"pk autoincr 'id'"`
FileMd5 string `xorm:"char(32) index 'file_md5'"` // 文件MD5,唯一标识
FilePath string `xorm:"text 'file_path'"` // 扫描时的源路径
FileName string `xorm:"varchar(255) 'file_name'"` // 文件名(含扩展名)
FileExt string `xorm:"varchar(32) 'file_ext'"` // 扩展名
FileSize int64 `xorm:"bigint 'file_size'"` // 文件大小(字节)
MimeType string `xorm:"varchar(128) 'mime_type'"` // MIME类型
MediaType string `xorm:"varchar(16) index 'media_type'"` // image/video/audio
Width int `xorm:"int index 'width'"` // 宽(像素)
Height int `xorm:"int index 'height'"` // 高(像素)
Duration float64 `xorm:"real 'duration'"` // 时长(秒)
Codec string `xorm:"varchar(64) 'codec'"` // 视频编码,如h264/hevc
Format string `xorm:"varchar(64) 'format'"` // 容器格式,如mp4/mkv
RawInfo string `xorm:"text 'raw_info'"` // ffprobe原始JSON(完整信息兜底)
HeadMd5 string `xorm:"char(32) index('idx_fp') 'head_md5'"` // 文件头256KB的MD5(指纹加速)
TailMd5 string `xorm:"char(32) index('idx_fp') 'tail_md5'"` // 文件尾256KB的MD5(指纹加速)
Category string `xorm:"varchar(64) index 'category'"` // 归类的目录名(去向)
DestDir string `xorm:"text 'dest_dir'"` // 移动后的目录路径(去向)
CreatedAt time.Time `xorm:"created 'created_at'"` // 首次入库时间
UpdatedAt time.Time `xorm:"updated 'updated_at'"` // 最近更新时间
}
// Store 封装 SQLite 存取,按实例持有连接,不再依赖全局状态
type Store struct {
engine *xorm.Engine
}
// Open 打开(或创建)数据库并同步表结构
func Open(dbPath string) (*Store, error) {
e, err := xorm.NewEngine("sqlite3", dbPath)
if err != nil {
return nil, err
}
e.ShowSQL(false)
e.SetMaxOpenConns(1) // sqlite同一时刻只允许一个写连接
if err := e.Sync(new(MediaInfo)); err != nil {
_ = e.Close()
return nil, err
}
return &Store{engine: e}, nil
}
// Close 关闭数据库连接
func (s *Store) Close() error {
if s == nil || s.engine == nil {
return nil
}
err := s.engine.Close()
s.engine = nil
return err
}
// SaveMedia 按MD5去重保存/更新媒体信息
func (s *Store) SaveMedia(info *MediaInfo) error {
if s == nil || s.engine == nil {
return nil
}
existing := new(MediaInfo)
has, err := s.engine.Where("file_md5 = ?", info.FileMd5).Get(existing)
if err != nil {
return err
}
if has {
info.Id = existing.Id
_, err = s.engine.ID(existing.Id).Update(info)
return err
}
_, err = s.engine.Insert(info)
return err
}
// FindMediaByFingerprint 按 (文件大小 + 头256KB + 尾256KB) 查找已入库记录,避免对大文件重复计算全量MD5
func (s *Store) FindMediaByFingerprint(size int64, headMd5, tailMd5 string) (*MediaInfo, bool) {
if s == nil || s.engine == nil || headMd5 == "" || tailMd5 == "" {
return nil, false
}
info := new(MediaInfo)
has, err := s.engine.Where("file_size = ? AND head_md5 = ? AND tail_md5 = ?", size, headMd5, tailMd5).Get(info)
if err != nil || !has {
return nil, false
}
return info, true
}
// UpdateMediaDest 按源路径回填文件的归类目录与去向
func (s *Store) UpdateMediaDest(filePath, destDir string) error {
if s == nil || s.engine == nil {
return nil
}
_, err := s.engine.Where("file_path = ?", filePath).Update(&MediaInfo{
Category: filepath.Base(destDir),
DestDir: destDir,
})
return err
}