refactor: 按模块重构项目结构
- 删除 vars 全局状态,按职责拆分 internal/ 包:scanner/probe/classify/organize/mover/store/digest - 数据驱动归类规则替代图片/视频重复分支,store 改为结构体实例 - build.sh 按系统类型编译:Windows 用 upx 压缩,macOS/Linux 不压缩 - 忽略目录拆分:图片_已忽略 / 视频_已忽略 - 输出构建产物加入 .gitignore
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build windows
|
||||
|
||||
package probe
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed bin/ffprobe.exe
|
||||
var embeddedFFprobe []byte // Windows 版 ffprobe(未入库,见 .gitignore)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user