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
+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
}