feat: 增加 Web 管理界面,扫描整理改为页面点击触发
- main.go 改为 Web 服务入口(Gin + 内嵌前端 dist + 自动打开浏览器) - internal/service: 新增扫描整理后台任务,支持状态机与实时日志捕获 - internal/api: 新增统一响应、媒体列表/统计、扫描开始/状态接口 - internal/store: 追加 ListMedia/CountMedia/Stats 查询,MediaInfo 补 JSON tag - internal/config: application.properties 配置加载(端口/数据库/自动开浏览器) - frontend: Vue2 + ElementUI + Vite 工程,含扫描整理/统计看板/媒体库三页 - build.sh 整合前端构建,输出单一 kenaito-media-picker.exe
This commit is contained in:
+251
-20
@@ -3,9 +3,13 @@ package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"kenaito-media-picker/internal/classify"
|
||||
|
||||
sqlite "github.com/glebarez/go-sqlite"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
@@ -20,26 +24,26 @@ func init() {
|
||||
|
||||
// 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'"` // 最近更新时间
|
||||
Id int64 `xorm:"pk autoincr 'id'" json:"id"`
|
||||
FileMd5 string `xorm:"char(32) index 'file_md5'" json:"fileMd5"` // 文件MD5,唯一标识
|
||||
FilePath string `xorm:"text 'file_path'" json:"filePath"` // 扫描时的源路径
|
||||
FileName string `xorm:"varchar(255) 'file_name'" json:"fileName"` // 文件名(含扩展名)
|
||||
FileExt string `xorm:"varchar(32) 'file_ext'" json:"fileExt"` // 扩展名
|
||||
FileSize int64 `xorm:"bigint 'file_size'" json:"fileSize"` // 文件大小(字节)
|
||||
MimeType string `xorm:"varchar(128) 'mime_type'" json:"mimeType"` // MIME类型
|
||||
MediaType string `xorm:"varchar(16) index 'media_type'" json:"mediaType"` // image/video/audio
|
||||
Width int `xorm:"int index 'width'" json:"width"` // 宽(像素)
|
||||
Height int `xorm:"int index 'height'" json:"height"` // 高(像素)
|
||||
Duration float64 `xorm:"real 'duration'" json:"duration"` // 时长(秒)
|
||||
Codec string `xorm:"varchar(64) 'codec'" json:"codec"` // 视频编码,如h264/hevc
|
||||
Format string `xorm:"varchar(64) 'format'" json:"format"` // 容器格式,如mp4/mkv
|
||||
RawInfo string `xorm:"text 'raw_info'" json:"rawInfo"` // ffprobe原始JSON(完整信息兜底)
|
||||
HeadMd5 string `xorm:"char(32) index('idx_fp') 'head_md5'" json:"headMd5"` // 文件头256KB的MD5(指纹加速)
|
||||
TailMd5 string `xorm:"char(32) index('idx_fp') 'tail_md5'" json:"tailMd5"` // 文件尾256KB的MD5(指纹加速)
|
||||
Category string `xorm:"varchar(64) index 'category'" json:"category"` // 归类的目录名(去向)
|
||||
DestDir string `xorm:"text 'dest_dir'" json:"destDir"` // 移动后的目录路径(去向)
|
||||
CreatedAt time.Time `xorm:"created 'created_at'" json:"createdAt"` // 首次入库时间
|
||||
UpdatedAt time.Time `xorm:"updated 'updated_at'" json:"updatedAt"` // 最近更新时间
|
||||
}
|
||||
|
||||
// Store 封装 SQLite 存取,按实例持有连接,不再依赖全局状态
|
||||
@@ -128,3 +132,230 @@ func (s *Store) UpdateMediaDest(filePath, destDir string) error {
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// MediaQuery 媒体库列表查询条件;空值表示不过滤
|
||||
type MediaQuery struct {
|
||||
MediaType string // image / video / audio
|
||||
Orientation string // horizontal / vertical / square
|
||||
Keyword string // 文件名模糊搜索
|
||||
Codec string
|
||||
Format string
|
||||
Resolution string // 分辨率档位标签(标准后缀 / NK / 普通 / 原图)
|
||||
Page int // 从 1 开始
|
||||
PageSize int
|
||||
SortField string // id / fileSize / width / height / duration / createdAt
|
||||
SortOrder string // asc / desc
|
||||
}
|
||||
|
||||
// ListMedia 按条件查询媒体列表(含分页排序)
|
||||
func (s *Store) ListMedia(q MediaQuery) ([]MediaInfo, error) {
|
||||
if s == nil || s.engine == nil {
|
||||
return nil, nil
|
||||
}
|
||||
sess := s.newQuerySession(q)
|
||||
defer sess.Close()
|
||||
if order := sortColumn(q.SortField, q.SortOrder); order != "" {
|
||||
sess.OrderBy(order)
|
||||
} else {
|
||||
sess.Desc("id")
|
||||
}
|
||||
if q.Page > 0 {
|
||||
pageSize := q.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
sess.Limit(pageSize, (q.Page-1)*pageSize)
|
||||
}
|
||||
list := make([]MediaInfo, 0)
|
||||
err := sess.Find(&list)
|
||||
return list, err
|
||||
}
|
||||
|
||||
// CountMedia 统计满足条件的记录数(分页用)
|
||||
func (s *Store) CountMedia(q MediaQuery) (int64, error) {
|
||||
if s == nil || s.engine == nil {
|
||||
return 0, nil
|
||||
}
|
||||
sess := s.newQuerySession(q)
|
||||
defer sess.Close()
|
||||
return sess.Count(new(MediaInfo))
|
||||
}
|
||||
|
||||
// newQuerySession 创建带查询条件的会话
|
||||
func (s *Store) newQuerySession(q MediaQuery) *xorm.Session {
|
||||
sess := s.engine.NewSession()
|
||||
cond, args := buildMediaCond(q)
|
||||
if cond != "" {
|
||||
sess.Where(cond, args...)
|
||||
}
|
||||
return sess
|
||||
}
|
||||
|
||||
// buildMediaCond 根据查询条件构造 where 子句;返回 nil 表示无条件
|
||||
func buildMediaCond(q MediaQuery) (string, []interface{}) {
|
||||
var cond string
|
||||
var args []interface{}
|
||||
appendCond := func(fragment string, vals ...interface{}) {
|
||||
if cond != "" {
|
||||
cond += " AND "
|
||||
}
|
||||
cond += fragment
|
||||
args = append(args, vals...)
|
||||
}
|
||||
if q.MediaType != "" {
|
||||
appendCond("media_type = ?", q.MediaType)
|
||||
}
|
||||
switch q.Orientation {
|
||||
case "horizontal":
|
||||
appendCond("width > ? AND width > height", 0)
|
||||
case "vertical":
|
||||
appendCond("width > ? AND width < height", 0)
|
||||
case "square":
|
||||
appendCond("width > ? AND width = height", 0)
|
||||
}
|
||||
if q.Keyword != "" {
|
||||
appendCond("file_name LIKE ?", "%"+q.Keyword+"%")
|
||||
}
|
||||
if q.Codec != "" {
|
||||
appendCond("codec = ?", q.Codec)
|
||||
}
|
||||
if q.Format != "" {
|
||||
appendCond("format = ?", q.Format)
|
||||
}
|
||||
if q.Resolution != "" {
|
||||
if minLong, maxLong, exactW, exactH := classify.ResRange(q.Resolution); minLong >= 0 {
|
||||
if exactW > 0 && exactH > 0 {
|
||||
appendCond("(width = ? AND height = ?)", exactW, exactH)
|
||||
} else if maxLong >= 0 {
|
||||
appendCond("(max(width, height) >= ? AND max(width, height) < ?)", minLong, maxLong)
|
||||
} else {
|
||||
appendCond("max(width, height) >= ?", minLong)
|
||||
}
|
||||
}
|
||||
}
|
||||
if cond == "" {
|
||||
return "", nil
|
||||
}
|
||||
return cond, args
|
||||
}
|
||||
|
||||
// sortColumn 将排序字段白名单映射为数据库列名,避免 SQL 注入;返回 "" 表示不排序
|
||||
func sortColumn(field, order string) string {
|
||||
columns := map[string]string{
|
||||
"id": "id",
|
||||
"fileSize": "file_size",
|
||||
"width": "width",
|
||||
"height": "height",
|
||||
"duration": "duration",
|
||||
"createdAt": "created_at",
|
||||
}
|
||||
col, ok := columns[field]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if order == "desc" {
|
||||
return col + " DESC"
|
||||
}
|
||||
return col + " ASC"
|
||||
}
|
||||
|
||||
// MediaStats 媒体库统计结果
|
||||
type MediaStats struct {
|
||||
TotalFiles int64 `json:"totalFiles"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
TypeCounts []KV `json:"typeCounts"` // image/video/audio 数量与体积
|
||||
OrientCount []KV `json:"orientCount"` // horizontal/vertical/square 数量
|
||||
ResCount []KV `json:"resCount"` // 分辨率档位数量
|
||||
CodecCount []KV `json:"codecCount"` // 编码分布(视频)
|
||||
FormatCount []KV `json:"formatCount"` // 容器格式分布
|
||||
Duplicate int64 `json:"duplicate"` // 重复文件数(同一MD5出现多次的去重后计数)
|
||||
DupSize int64 `json:"dupSize"` // 重复文件占用的总字节
|
||||
TopLarge []KV `json:"topLarge"` // 体积最大的 10 个文件
|
||||
}
|
||||
|
||||
// KV 统计键值对
|
||||
type KV struct {
|
||||
Key string `json:"key"`
|
||||
Count int64 `json:"count"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// Stats 汇总媒体库统计信息
|
||||
func (s *Store) Stats() (*MediaStats, error) {
|
||||
if s == nil || s.engine == nil {
|
||||
return &MediaStats{}, nil
|
||||
}
|
||||
st := &MediaStats{}
|
||||
|
||||
if total, err := s.engine.Count(new(MediaInfo)); err == nil {
|
||||
st.TotalFiles = total
|
||||
}
|
||||
var sizeTotal struct {
|
||||
Size int64 `xorm:"size"`
|
||||
}
|
||||
if _, err := s.engine.SQL("SELECT COALESCE(SUM(file_size),0) AS size FROM media_info").Get(&sizeTotal); err == nil {
|
||||
st.TotalSize = sizeTotal.Size
|
||||
}
|
||||
|
||||
// 类型统计(数量 + 体积)
|
||||
typeRows := make([]KV, 0)
|
||||
if err := s.engine.SQL("SELECT media_type AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info GROUP BY media_type ORDER BY count DESC").Find(&typeRows); err == nil {
|
||||
st.TypeCounts = typeRows
|
||||
}
|
||||
// 方向统计(仅含有效尺寸的图片/视频)
|
||||
orientRows := make([]KV, 0)
|
||||
if err := s.engine.SQL("SELECT CASE WHEN width>height THEN 'horizontal' WHEN width<height THEN 'vertical' ELSE 'square' END AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE width>0 AND height>0 GROUP BY 1 ORDER BY count DESC").Find(&orientRows); err == nil {
|
||||
st.OrientCount = orientRows
|
||||
}
|
||||
// 编码 / 格式 / 分辨率 / 大文件统计
|
||||
codecRows := make([]KV, 0)
|
||||
if err := s.engine.SQL("SELECT COALESCE(NULLIF(codec,''),'未知') AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE media_type='video' AND codec <> '' GROUP BY codec ORDER BY count DESC LIMIT 10").Find(&codecRows); err == nil {
|
||||
st.CodecCount = codecRows
|
||||
}
|
||||
formatRows := make([]KV, 0)
|
||||
if err := s.engine.SQL("SELECT COALESCE(NULLIF(format,''),'未知') AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE format <> '' GROUP BY format ORDER BY count DESC LIMIT 10").Find(&formatRows); err == nil {
|
||||
st.FormatCount = formatRows
|
||||
}
|
||||
largeRows := make([]KV, 0)
|
||||
if err := s.engine.SQL("SELECT file_name AS key, 1 AS count, file_size AS size FROM media_info ORDER BY file_size DESC LIMIT 10").Find(&largeRows); err == nil {
|
||||
st.TopLarge = largeRows
|
||||
}
|
||||
|
||||
// 分辨率分布(Go 侧计算,复用归类规则)
|
||||
resMap := make(map[string]int64)
|
||||
rows, err := s.engine.SQL("SELECT width, height FROM media_info WHERE width>0 AND height>0").Query()
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
var w, h int
|
||||
if _, err := fmt.Sscanf(string(row["width"]), "%d", &w); err == nil {
|
||||
if _, err := fmt.Sscanf(string(row["height"]), "%d", &h); err == nil {
|
||||
label := classify.ResLabel(w, h)
|
||||
if label == "" {
|
||||
label = "未知"
|
||||
}
|
||||
resMap[label]++
|
||||
}
|
||||
}
|
||||
}
|
||||
for k, v := range resMap {
|
||||
st.ResCount = append(st.ResCount, KV{Key: k, Count: v})
|
||||
}
|
||||
sortKVs(st.ResCount)
|
||||
|
||||
// 重复文件统计(同一 MD5 出现多次)
|
||||
dupRows := make([]KV, 0)
|
||||
if err := s.engine.SQL("SELECT file_md5 AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info GROUP BY file_md5 HAVING COUNT(*)>1").Find(&dupRows); err == nil {
|
||||
for _, r := range dupRows {
|
||||
st.Duplicate += r.Count - 1
|
||||
st.DupSize += r.Size - r.Size/r.Count // 近似:等量分配单份体积
|
||||
}
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// sortKVs 按 count 降序排序
|
||||
func sortKVs(list []KV) {
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].Count > list[j].Count })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user