refactor: 全面重构代码并加白话注释,移动全局并行,去掉去向/重复记录
- 重构后端与前端,全程大白话注释(不懂程序也能看懂) - 移动阶段改为全局并行(MoveAll,工人数=CPU核心数) - 去掉记录每个文件搬去哪了(删除去向回填,解决写库卡顿) - 去掉重复文件标记与统计:重复判定改为 MD5+路径都相同才算,重复则原地忽略不移动;MD5 相同但路径不同仍照常搬 - 媒体库去掉归类目录列,看板去掉重复/归类分布统计
This commit is contained in:
+127
-161
@@ -1,10 +1,15 @@
|
||||
// Package store 提供媒体信息的 SQLite 持久化(xorm + 纯 Go 驱动,免 CGO)。
|
||||
// Package store 负责把所有媒体信息存进数据库(SQLite),并提供查询。
|
||||
//
|
||||
// 大白话理解:这是"媒体库"的账本。扫描整理时把每个文件的来龙去脉记下来
|
||||
// (叫什么、多大、什么类型),网页上「媒体库」和「统计看板」两个页面
|
||||
// 就是从这本账本里查数据、算统计的。不记录文件搬去哪了——文件就在分类目录里。
|
||||
//
|
||||
// 技术细节:用的是 xorm + 纯 Go 的 SQLite 驱动(不用装任何 C 语言编译环境)。
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
@@ -14,61 +19,60 @@ import (
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// DBFileName 数据库文件名(存放于被整理目录的根目录,扫描时跳过)
|
||||
// DBFileName 数据库文件名。数据库放在被整理目录的根目录下,扫描时会跳过它,
|
||||
// 免得把"账本"自己当成媒体文件收走了。
|
||||
const DBFileName = "media-info.db"
|
||||
|
||||
// CategoryDuplicate 重复文件的归类目录名(入库记录用于统计重复数量,媒体统计中排除)
|
||||
const CategoryDuplicate = "重复文件"
|
||||
|
||||
// init 将纯Go的sqlite驱动注册为 xorm 认识的 sqlite3 驱动名(避免CGO依赖)
|
||||
// init 把纯 Go 的 SQLite 驱动注册成 xorm 认识的 "sqlite3" 名字,
|
||||
// 这样就不用依赖 CGO(C 语言编译器)了。
|
||||
func init() {
|
||||
sql.Register("sqlite3", &sqlite.Driver{})
|
||||
}
|
||||
|
||||
// MediaInfo 媒体文件信息记录,按字段拆分存放,便于查询
|
||||
// MediaInfo 一条媒体文件的记录:文件本身的信息——路径、名字、大小、类型、宽高、编码、格式等。
|
||||
type MediaInfo struct {
|
||||
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"` // 移动后的目录路径(去向)
|
||||
DestPath string `xorm:"text 'dest_path'" json:"destPath"` // 移动后的完整文件路径(判断库中副本是否仍存在)
|
||||
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"` // 扩展名(如 .jpg)
|
||||
FileSize int64 `xorm:"bigint 'file_size'" json:"fileSize"` // 文件大小(字节)
|
||||
MimeType string `xorm:"varchar(128) 'mime_type'" json:"mimeType"` // MIME 类型(如 image/jpeg)
|
||||
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(指纹用)
|
||||
CreatedAt time.Time `xorm:"created 'created_at'" json:"createdAt"` // 首次入库时间
|
||||
UpdatedAt time.Time `xorm:"updated 'updated_at'" json:"updatedAt"` // 最近更新时间
|
||||
}
|
||||
|
||||
// Store 封装 SQLite 存取,按实例持有连接,不再依赖全局状态
|
||||
// Store 数据库访问的封装。整个程序共用一个实例,通过它读写数据库。
|
||||
type Store struct {
|
||||
engine *xorm.Engine
|
||||
engine *xorm.Engine // xorm 的数据库引擎(连接)
|
||||
}
|
||||
|
||||
// AppConfig 应用级键值配置(如上次使用的存放目录),持久化到数据库便于复用
|
||||
// AppConfig 应用级键值配置表。目前只存一条:上次使用的存放目录。
|
||||
// 存到数据库里,下次打开页面能自动回填,不用用户重新输。
|
||||
type AppConfig struct {
|
||||
Key string `xorm:"pk varchar(64) 'key'"`
|
||||
Value string `xorm:"text 'value'"`
|
||||
Key string `xorm:"pk varchar(64) 'key'"` // 配置项名字
|
||||
Value string `xorm:"text 'value'"` // 配置值
|
||||
}
|
||||
|
||||
// Open 打开(或创建)数据库并同步表结构
|
||||
// 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同一时刻只允许一个写连接
|
||||
// SQLite 同一时刻只允许一个写连接,这里限制成 1 个,避免并发写冲突
|
||||
e.SetMaxOpenConns(1)
|
||||
// 自动创建/补全两张表(媒体记录表、配置表)
|
||||
if err := e.Sync(new(MediaInfo), new(AppConfig)); err != nil {
|
||||
_ = e.Close()
|
||||
return nil, err
|
||||
@@ -76,7 +80,7 @@ func Open(dbPath string) (*Store, error) {
|
||||
return &Store{engine: e}, nil
|
||||
}
|
||||
|
||||
// Close 关闭数据库连接
|
||||
// Close 关闭数据库连接。
|
||||
func (s *Store) Close() error {
|
||||
if s == nil || s.engine == nil {
|
||||
return nil
|
||||
@@ -86,7 +90,9 @@ func (s *Store) Close() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveConfig 保存/更新应用级配置项
|
||||
// ============================ 应用配置 ============================
|
||||
|
||||
// SaveConfig 保存/更新一条应用配置(有就更新,没有就插入)。
|
||||
func (s *Store) SaveConfig(key, value string) error {
|
||||
if s == nil || s.engine == nil || key == "" {
|
||||
return nil
|
||||
@@ -104,7 +110,7 @@ func (s *Store) SaveConfig(key, value string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// GetConfig 读取应用级配置项;不存在时返回空串
|
||||
// GetConfig 读取一条应用配置;不存在返回空串。
|
||||
func (s *Store) GetConfig(key string) string {
|
||||
if s == nil || s.engine == nil || key == "" {
|
||||
return ""
|
||||
@@ -117,7 +123,9 @@ func (s *Store) GetConfig(key string) string {
|
||||
return cfg.Value
|
||||
}
|
||||
|
||||
// SaveMedia 按MD5去重保存/更新媒体信息
|
||||
// ============================ 媒体记录的新增/更新 ============================
|
||||
|
||||
// SaveMedia 保存/更新一条媒体记录:按 MD5 找,有就更新,没有就新增。
|
||||
func (s *Store) SaveMedia(info *MediaInfo) error {
|
||||
if s == nil || s.engine == nil {
|
||||
return nil
|
||||
@@ -136,16 +144,8 @@ func (s *Store) SaveMedia(info *MediaInfo) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// InsertDuplicate 记录重复文件(与已入库内容 MD5 相同,独立入库便于统计重复数量)
|
||||
func (s *Store) InsertDuplicate(info *MediaInfo) error {
|
||||
if s == nil || s.engine == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := s.engine.Insert(info)
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveMediaBatch 在一个事务内批量按MD5去重保存/更新,避免大量文件逐个提交事务的 fsync 开销
|
||||
// SaveMediaBatch 在一个事务里批量保存/更新媒体记录。
|
||||
// 一次扫描可能有几千个文件,一个个提交事务很慢,攒一批一次写入快得多。
|
||||
func (s *Store) SaveMediaBatch(infos []*MediaInfo) error {
|
||||
if s == nil || s.engine == nil || len(infos) == 0 {
|
||||
return nil
|
||||
@@ -176,26 +176,10 @@ func (s *Store) SaveMediaBatch(infos []*MediaInfo) error {
|
||||
return sess.Commit()
|
||||
}
|
||||
|
||||
// InsertDuplicateBatch 在一个事务内批量插入重复文件记录
|
||||
func (s *Store) InsertDuplicateBatch(infos []*MediaInfo) error {
|
||||
if s == nil || s.engine == nil || len(infos) == 0 {
|
||||
return nil
|
||||
}
|
||||
sess := s.engine.NewSession()
|
||||
defer sess.Close()
|
||||
if err := sess.Begin(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, info := range infos {
|
||||
if _, err := sess.Insert(info); err != nil {
|
||||
_ = sess.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return sess.Commit()
|
||||
}
|
||||
// ============================ 媒体记录的查询 ============================
|
||||
|
||||
// FindMediaByFingerprint 按 (文件大小 + 头256KB + 尾256KB) 查找已入库记录,避免对大文件重复计算全量MD5
|
||||
// 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
|
||||
@@ -208,7 +192,7 @@ func (s *Store) FindMediaByFingerprint(size int64, headMd5, tailMd5 string) (*Me
|
||||
return info, true
|
||||
}
|
||||
|
||||
// FindMediaByMd5 按文件MD5查找已入库记录(用于识别重复文件)
|
||||
// FindMediaByMd5 按文件 MD5 找记录(用于判断"同一个文件是不是又出现了一次")。
|
||||
func (s *Store) FindMediaByMd5(md5 string) (*MediaInfo, bool) {
|
||||
if s == nil || s.engine == nil || md5 == "" {
|
||||
return nil, false
|
||||
@@ -221,34 +205,23 @@ func (s *Store) FindMediaByMd5(md5 string) (*MediaInfo, bool) {
|
||||
return info, true
|
||||
}
|
||||
|
||||
// UpdateMediaDest 按源路径回填文件的归类目录与去向(destPath 为移动后的完整文件路径)
|
||||
func (s *Store) UpdateMediaDest(filePath, destDir, destPath 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,
|
||||
DestPath: destPath,
|
||||
})
|
||||
return err
|
||||
}
|
||||
// ============================ 媒体库列表查询 ============================
|
||||
|
||||
// MediaQuery 媒体库列表查询条件;空值表示不过滤
|
||||
// MediaQuery 媒体库列表的查询条件。空值表示"这一项不过滤"。
|
||||
type MediaQuery struct {
|
||||
MediaType string // image / video / audio
|
||||
Orientation string // horizontal / vertical / square
|
||||
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
|
||||
Codec string // 编码
|
||||
Format string // 容器格式
|
||||
Resolution string // 分辨率档位(如 1080P / 5K / 普通 / 原图)
|
||||
Page int // 第几页(从 1 开始)
|
||||
PageSize int // 每页几条
|
||||
SortField string // 按哪个字段排序:id / fileSize / width / duration / createdAt
|
||||
SortOrder string // asc 升序 / desc 降序
|
||||
}
|
||||
|
||||
// ListMedia 按条件查询媒体列表(含分页排序)
|
||||
// ListMedia 按条件查媒体列表(带分页和排序)。
|
||||
func (s *Store) ListMedia(q MediaQuery) ([]MediaInfo, error) {
|
||||
if s == nil || s.engine == nil {
|
||||
return nil, nil
|
||||
@@ -258,7 +231,7 @@ func (s *Store) ListMedia(q MediaQuery) ([]MediaInfo, error) {
|
||||
if order := sortColumn(q.SortField, q.SortOrder); order != "" {
|
||||
sess.OrderBy(order)
|
||||
} else {
|
||||
sess.Desc("id")
|
||||
sess.Desc("id") // 默认按入库顺序倒序,最新的排最前
|
||||
}
|
||||
if q.Page > 0 {
|
||||
pageSize := q.PageSize
|
||||
@@ -272,7 +245,7 @@ func (s *Store) ListMedia(q MediaQuery) ([]MediaInfo, error) {
|
||||
return list, err
|
||||
}
|
||||
|
||||
// CountMedia 统计满足条件的记录数(分页用)
|
||||
// CountMedia 统计满足条件的记录数(给分页算"总共多少页"用)。
|
||||
func (s *Store) CountMedia(q MediaQuery) (int64, error) {
|
||||
if s == nil || s.engine == nil {
|
||||
return 0, nil
|
||||
@@ -282,10 +255,9 @@ func (s *Store) CountMedia(q MediaQuery) (int64, error) {
|
||||
return sess.Count(new(MediaInfo))
|
||||
}
|
||||
|
||||
// newQuerySession 创建带查询条件的会话(默认排除重复文件分类记录)
|
||||
// newQuerySession 建一个带查询条件的会话。
|
||||
func (s *Store) newQuerySession(q MediaQuery) *xorm.Session {
|
||||
sess := s.engine.NewSession()
|
||||
sess.Where("COALESCE(category,'') <> ?", CategoryDuplicate)
|
||||
cond, args := buildMediaCond(q)
|
||||
if cond != "" {
|
||||
sess.Where(cond, args...)
|
||||
@@ -293,10 +265,11 @@ func (s *Store) newQuerySession(q MediaQuery) *xorm.Session {
|
||||
return sess
|
||||
}
|
||||
|
||||
// buildMediaCond 根据查询条件构造 where 子句;返回 nil 表示无条件
|
||||
// buildMediaCond 根据查询条件拼 SQL 的 where 部分;一个条件都没有返回空。
|
||||
func buildMediaCond(q MediaQuery) (string, []interface{}) {
|
||||
var cond string
|
||||
var args []interface{}
|
||||
// appendCond 是拼装小工具:把"片段 + 参数"用 AND 串起来
|
||||
appendCond := func(fragment string, vals ...interface{}) {
|
||||
if cond != "" {
|
||||
cond += " AND "
|
||||
@@ -307,16 +280,17 @@ func buildMediaCond(q MediaQuery) (string, []interface{}) {
|
||||
if q.MediaType != "" {
|
||||
appendCond("media_type = ?", q.MediaType)
|
||||
}
|
||||
// 方向筛选:其实就看宽高谁大谁小
|
||||
switch q.Orientation {
|
||||
case "horizontal":
|
||||
appendCond("width > ? AND width > height", 0)
|
||||
appendCond("width > ? AND width > height", 0) // 横屏:宽 > 高
|
||||
case "vertical":
|
||||
appendCond("width > ? AND width < height", 0)
|
||||
appendCond("width > ? AND width < height", 0) // 竖屏:宽 < 高
|
||||
case "square":
|
||||
appendCond("width > ? AND width = height", 0)
|
||||
appendCond("width > ? AND width = height", 0) // 等比:宽 = 高
|
||||
}
|
||||
if q.Keyword != "" {
|
||||
appendCond("file_name LIKE ?", "%"+q.Keyword+"%")
|
||||
appendCond("file_name LIKE ?", "%"+q.Keyword+"%") // 文件名模糊匹配
|
||||
}
|
||||
if q.Codec != "" {
|
||||
appendCond("codec = ?", q.Codec)
|
||||
@@ -324,13 +298,17 @@ func buildMediaCond(q MediaQuery) (string, []interface{}) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -341,7 +319,8 @@ func buildMediaCond(q MediaQuery) (string, []interface{}) {
|
||||
return cond, args
|
||||
}
|
||||
|
||||
// sortColumn 将排序字段白名单映射为数据库列名,避免 SQL 注入;返回 "" 表示不排序
|
||||
// sortColumn 把排序字段翻译成数据库列名。
|
||||
// 只认白名单里的字段,防止用户传个奇怪字段名来搞 SQL 注入。返回 "" 表示不排序。
|
||||
func sortColumn(field, order string) string {
|
||||
columns := map[string]string{
|
||||
"id": "id",
|
||||
@@ -361,95 +340,96 @@ func sortColumn(field, order string) string {
|
||||
return col + " ASC"
|
||||
}
|
||||
|
||||
// MediaStats 媒体库统计结果
|
||||
// ============================ 统计看板 ============================
|
||||
|
||||
// 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 个文件
|
||||
TotalFiles int64 `json:"totalFiles"` // 媒体总数
|
||||
TotalSize int64 `json:"totalSize"` // 总体积(字节)
|
||||
TypeCounts []KV `json:"typeCounts"` // 按类型(图片/视频/音频)的数量与体积
|
||||
OrientCount []KV `json:"orientCount"` // 按方向(横/竖/正方)的数量
|
||||
ResCount []KV `json:"resCount"` // 按分辨率档位的数量
|
||||
CodecCount []KV `json:"codecCount"` // 视频编码分布
|
||||
FormatCount []KV `json:"formatCount"` // 容器格式分布
|
||||
TopLarge []KV `json:"topLarge"` // 体积最大的 10 个文件
|
||||
|
||||
DurationTotal int64 `json:"durationTotal"` // 视频/音频总时长(秒)
|
||||
DurationAvg int64 `json:"durationAvg"` // 视频/音频平均时长(秒)
|
||||
DurationAvg int64 `json:"durationAvg"` // 平均时长(秒)
|
||||
AvgSize int64 `json:"avgSize"` // 平均文件体积(字节)
|
||||
MaxSize int64 `json:"maxSize"` // 最大文件体积(字节)
|
||||
MinSize int64 `json:"minSize"` // 最小文件体积(字节)
|
||||
|
||||
DurationBins []KV `json:"durationBins"` // 时长区间分布(视频/音频)
|
||||
CreatedCounts []KV `json:"createdCounts"` // 入库趋势(按日,近 30 天)
|
||||
CategoryCounts []KV `json:"categoryCounts"` // 归类目录分布
|
||||
ExtCounts []KV `json:"extCounts"` // 扩展名分布
|
||||
MimeCounts []KV `json:"mimeCounts"` // MIME 类型分布
|
||||
DurationBins []KV `json:"durationBins"` // 时长区间分布(如 1-5分钟)
|
||||
CreatedCounts []KV `json:"createdCounts"` // 入库趋势(近 30 天,按天)
|
||||
ExtCounts []KV `json:"extCounts"` // 扩展名分布
|
||||
MimeCounts []KV `json:"mimeCounts"` // MIME 类型分布
|
||||
AudioCodecCount []KV `json:"audioCodecCount"` // 音频编码分布
|
||||
}
|
||||
|
||||
// KV 统计键值对
|
||||
// KV 统计里的一种"键值对":某一类是什么(key)、有多少个(count)、占多少字节(size)。
|
||||
type KV struct {
|
||||
Key string `json:"key"`
|
||||
Count int64 `json:"count"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// Stats 汇总媒体库统计信息
|
||||
// Stats 汇总全部统计数据。用了一堆 SQL 查询,把各种分布都算出来。
|
||||
func (s *Store) Stats() (*MediaStats, error) {
|
||||
if s == nil || s.engine == nil {
|
||||
return &MediaStats{}, nil
|
||||
}
|
||||
st := &MediaStats{
|
||||
ResCount: []KV{}, // 避免空库时返回 null,前端 .slice 直接可用
|
||||
DurationBins: []KV{},
|
||||
CreatedCounts: []KV{},
|
||||
CategoryCounts: []KV{},
|
||||
ExtCounts: []KV{},
|
||||
MimeCounts: []KV{},
|
||||
// 空库时这些列表默认为空数组(而不是 null),前端直接能用
|
||||
ResCount: []KV{},
|
||||
DurationBins: []KV{},
|
||||
CreatedCounts: []KV{},
|
||||
ExtCounts: []KV{},
|
||||
MimeCounts: []KV{},
|
||||
AudioCodecCount: []KV{},
|
||||
}
|
||||
|
||||
// 总数和总体积
|
||||
var totalCnt struct {
|
||||
C int64 `xorm:"c"`
|
||||
}
|
||||
if _, err := s.engine.SQL("SELECT COUNT(*) AS c FROM media_info WHERE COALESCE(category,'') <> '重复文件'").Get(&totalCnt); err == nil {
|
||||
if _, err := s.engine.SQL("SELECT COUNT(*) AS c FROM media_info").Get(&totalCnt); err == nil {
|
||||
st.TotalFiles = totalCnt.C
|
||||
}
|
||||
var sizeTotal struct {
|
||||
Size int64 `xorm:"size"`
|
||||
}
|
||||
if _, err := s.engine.SQL("SELECT COALESCE(SUM(file_size),0) AS size FROM media_info WHERE COALESCE(category,'') <> '重复文件'").Get(&sizeTotal); err == nil {
|
||||
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 WHERE COALESCE(category,'') <> '重复文件' GROUP BY media_type ORDER BY count DESC").Find(&typeRows); err == nil {
|
||||
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 AND COALESCE(category,'') <> '重复文件' GROUP BY 1 ORDER BY count DESC").Find(&orientRows); err == nil {
|
||||
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
|
||||
}
|
||||
// 编码 / 格式 / 分辨率 / 大文件统计
|
||||
// 编码 / 格式 / 大文件 TOP10
|
||||
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 <> '' AND COALESCE(category,'') <> '重复文件' GROUP BY codec ORDER BY count DESC LIMIT 10").Find(&codecRows); err == nil {
|
||||
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,''), LOWER(ltrim(file_ext,'.'))) AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE (format <> '' OR file_ext <> '') AND COALESCE(category,'') <> '重复文件' GROUP BY key ORDER BY count DESC LIMIT 10").Find(&formatRows); err == nil {
|
||||
if err := s.engine.SQL("SELECT COALESCE(NULLIF(format,''), LOWER(ltrim(file_ext,'.'))) AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE (format <> '' OR file_ext <> '') GROUP BY key 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 WHERE COALESCE(category,'') <> '重复文件' ORDER BY file_size DESC LIMIT 10").Find(&largeRows); err == nil {
|
||||
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 侧计算,复用归类规则)
|
||||
// 分辨率分布:这里不用 SQL 分组,而是把每个文件的大小取出来,
|
||||
// 用和归类时同一套规则(classify.ResLabel)算档位再计数,保证和分类结果完全一致。
|
||||
resMap := make(map[string]int64)
|
||||
rows, err := s.engine.SQL("SELECT width, height FROM media_info WHERE width>0 AND height>0 AND COALESCE(category,'') <> '重复文件'").Query()
|
||||
rows, err := s.engine.SQL("SELECT width, height FROM media_info WHERE width>0 AND height>0").Query()
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
@@ -470,18 +450,18 @@ func (s *Store) Stats() (*MediaStats, error) {
|
||||
}
|
||||
sortKVs(st.ResCount)
|
||||
|
||||
// 时长统计(视频/音频)
|
||||
// 时长统计(视频/音频):总时长 + 平均时长
|
||||
var dur struct {
|
||||
Total float64 `xorm:"total"`
|
||||
Avg float64 `xorm:"avg"`
|
||||
}
|
||||
if _, err := s.engine.SQL("SELECT COALESCE(SUM(duration),0) AS total, COALESCE(AVG(CASE WHEN duration>0 THEN duration END),0) AS avg FROM media_info WHERE duration>0 AND COALESCE(category,'') <> '重复文件'").Get(&dur); err == nil {
|
||||
if _, err := s.engine.SQL("SELECT COALESCE(SUM(duration),0) AS total, COALESCE(AVG(CASE WHEN duration>0 THEN duration END),0) AS avg FROM media_info WHERE duration>0").Get(&dur); err == nil {
|
||||
st.DurationTotal = int64(dur.Total)
|
||||
st.DurationAvg = int64(dur.Avg)
|
||||
}
|
||||
// 时长区间分布
|
||||
// 时长区间分布:<1分钟 / 1-5分钟 / 5-30分钟 / 30分钟-1小时 / >1小时
|
||||
durRows := make([]KV, 0)
|
||||
if err := s.engine.SQL("SELECT CASE WHEN duration<60 THEN '<1分钟' WHEN duration<300 THEN '1-5分钟' WHEN duration<1800 THEN '5-30分钟' WHEN duration<3600 THEN '30分钟-1小时' ELSE '>1小时' END AS key, COUNT(*) AS count, COALESCE(SUM(duration),0) AS size FROM media_info WHERE duration>0 AND COALESCE(category,'') <> '重复文件' GROUP BY key ORDER BY count DESC").Find(&durRows); err == nil {
|
||||
if err := s.engine.SQL("SELECT CASE WHEN duration<60 THEN '<1分钟' WHEN duration<300 THEN '1-5分钟' WHEN duration<1800 THEN '5-30分钟' WHEN duration<3600 THEN '30分钟-1小时' ELSE '>1小时' END AS key, COUNT(*) AS count, COALESCE(SUM(duration),0) AS size FROM media_info WHERE duration>0 GROUP BY key ORDER BY count DESC").Find(&durRows); err == nil {
|
||||
st.DurationBins = durRows
|
||||
}
|
||||
// 平均/最大/最小文件体积
|
||||
@@ -490,52 +470,38 @@ func (s *Store) Stats() (*MediaStats, error) {
|
||||
Max int64 `xorm:"max"`
|
||||
Min int64 `xorm:"min"`
|
||||
}
|
||||
if _, err := s.engine.SQL("SELECT COALESCE(AVG(file_size),0) AS avg, COALESCE(MAX(file_size),0) AS max, COALESCE(MIN(file_size),0) AS min FROM media_info WHERE COALESCE(category,'') <> '重复文件'").Get(&sizeStat); err == nil {
|
||||
if _, err := s.engine.SQL("SELECT COALESCE(AVG(file_size),0) AS avg, COALESCE(MAX(file_size),0) AS max, COALESCE(MIN(file_size),0) AS min FROM media_info").Get(&sizeStat); err == nil {
|
||||
st.AvgSize = sizeStat.Avg
|
||||
st.MaxSize = sizeStat.Max
|
||||
st.MinSize = sizeStat.Min
|
||||
}
|
||||
// 入库趋势(近 30 天按日分组)
|
||||
// 入库趋势(近 30 天,按天):把时间字段截出日期再分组,倒序排完再翻正
|
||||
createdRows := make([]KV, 0)
|
||||
if err := s.engine.SQL("SELECT substr(created_at,1,10) AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE created_at <> '' AND COALESCE(category,'') <> '重复文件' GROUP BY key ORDER BY key DESC LIMIT 30").Find(&createdRows); err == nil {
|
||||
if err := s.engine.SQL("SELECT substr(created_at,1,10) AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE created_at <> '' GROUP BY key ORDER BY key DESC LIMIT 30").Find(&createdRows); err == nil {
|
||||
for i, j := 0, len(createdRows)-1; i < j; i, j = i+1, j-1 {
|
||||
createdRows[i], createdRows[j] = createdRows[j], createdRows[i]
|
||||
}
|
||||
st.CreatedCounts = createdRows
|
||||
}
|
||||
// 归类目录分布
|
||||
catRows := make([]KV, 0)
|
||||
if err := s.engine.SQL("SELECT category AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE category <> '' AND COALESCE(category,'') <> '重复文件' GROUP BY category ORDER BY count DESC LIMIT 15").Find(&catRows); err == nil {
|
||||
st.CategoryCounts = catRows
|
||||
}
|
||||
// 扩展名分布
|
||||
extRows := make([]KV, 0)
|
||||
if err := s.engine.SQL("SELECT LOWER(ltrim(file_ext,'.')) AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE file_ext <> '' AND COALESCE(category,'') <> '重复文件' GROUP BY key ORDER BY count DESC LIMIT 10").Find(&extRows); err == nil {
|
||||
if err := s.engine.SQL("SELECT LOWER(ltrim(file_ext,'.')) AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE file_ext <> '' GROUP BY key ORDER BY count DESC LIMIT 10").Find(&extRows); err == nil {
|
||||
st.ExtCounts = extRows
|
||||
}
|
||||
// MIME 类型分布
|
||||
mimeRows := make([]KV, 0)
|
||||
if err := s.engine.SQL("SELECT mime_type AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE mime_type <> '' AND COALESCE(category,'') <> '重复文件' GROUP BY mime_type ORDER BY count DESC LIMIT 10").Find(&mimeRows); err == nil {
|
||||
if err := s.engine.SQL("SELECT mime_type AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE mime_type <> '' GROUP BY mime_type ORDER BY count DESC LIMIT 10").Find(&mimeRows); err == nil {
|
||||
st.MimeCounts = mimeRows
|
||||
}
|
||||
// 音频编码分布
|
||||
audioRows := 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='audio' AND codec <> '' AND COALESCE(category,'') <> '重复文件' GROUP BY codec ORDER BY count DESC LIMIT 10").Find(&audioRows); err == nil {
|
||||
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='audio' AND codec <> '' GROUP BY codec ORDER BY count DESC LIMIT 10").Find(&audioRows); err == nil {
|
||||
st.AudioCodecCount = audioRows
|
||||
}
|
||||
|
||||
// 重复文件统计(同一 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 降序排序
|
||||
// sortKVs 把统计结果按数量从多到少排序。
|
||||
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