Files
kenaito-media-picker/internal/store/store.go
T

609 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package store 负责把所有媒体信息存进数据库(SQLite),并提供查询。
//
// 大白话理解:这是"媒体库"的账本。扫描整理时把每个文件的来龙去脉记下来
// (叫什么、多大、什么类型),网页上「媒体库」和「统计看板」两个页面
// 就是从这本账本里查数据、算统计的。不记录文件搬去哪了——文件就在分类目录里。
//
// 技术细节:用的是 xorm + 纯 Go 的 SQLite 驱动(不用装任何 C 语言编译环境)。
package store
import (
"database/sql"
"fmt"
"sort"
"time"
"kenaito-media-picker/internal/classify"
sqlite "github.com/glebarez/go-sqlite"
"xorm.io/xorm"
)
// DBFileName 数据库文件名。数据库放在被整理目录的根目录下,扫描时会跳过它,
// 免得把"账本"自己当成媒体文件收走了。
const DBFileName = "media-info.db"
// init 把纯 Go 的 SQLite 驱动注册成 xorm 认识的 "sqlite3" 名字,
// 这样就不用依赖 CGO(C 语言编译器)了。
func init() {
sql.Register("sqlite3", &sqlite.Driver{})
}
// 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"` // 扩展名(如 .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(指纹用)
NsfwScore float64 `xorm:"real 'nsfw_score'" json:"nsfwScore"` // NSFW 检测分 0~1(0 表示未检测或非图片)
NsfwThreshold float64 `xorm:"real 'nsfw_threshold'" json:"nsfwThreshold"` // NSFW 判定阈值
CreatedAt time.Time `xorm:"created 'created_at'" json:"createdAt"` // 首次入库时间
UpdatedAt time.Time `xorm:"updated 'updated_at'" json:"updatedAt"` // 最近更新时间
}
// Store 数据库访问的封装。整个程序共用一个实例,通过它读写数据库。
type Store struct {
engine *xorm.Engine // xorm 的数据库引擎(连接)
}
// AppConfig 应用级键值配置表。目前只存一条:上次使用的存放目录。
// 存到数据库里,下次打开页面能自动回填,不用用户重新输。
type AppConfig struct {
Key string `xorm:"pk varchar(64) 'key'"` // 配置项名字
Value string `xorm:"text 'value'"` // 配置值
}
// ScanLog 一条"续传"记录:某个源文件上次被归类到了哪个目录。
// 用途:扫描中断/出错后重跑时,已经归类过的文件直接复用上次的目标目录,
// 不再重新探测尺寸 / 算 MD5 / 分析明暗 / 色情识别,省下几小时的重扫时间。
// 大文件记录头尾指纹,重扫时核对内容没变才放心跳过。
type ScanLog struct {
Id int64 `xorm:"pk autoincr 'id'" json:"id"`
SourcePath string `xorm:"varchar(1024) unique 'source_path'" json:"sourcePath"` // 源文件路径(唯一)
TargetDir string `xorm:"text 'target_dir'" json:"targetDir"` // 归类结果:目标目录
FileSize int64 `xorm:"bigint 'file_size'" json:"fileSize"` // 文件大小(校验内容是否变化)
HeadMd5 string `xorm:"char(32) 'head_md5'" json:"headMd5"` // 文件头 256KB MD5(大文件指纹)
TailMd5 string `xorm:"char(32) 'tail_md5'" json:"tailMd5"` // 文件尾 256KB MD5(大文件指纹)
UpdatedAt time.Time `xorm:"updated 'updated_at'" json:"updatedAt"` // 最近归类时间
}
// Open 打开(或第一次时创建)数据库,并保证表结构齐全。
func Open(dbPath string) (*Store, error) {
e, err := xorm.NewEngine("sqlite3", dbPath)
if err != nil {
return nil, err
}
e.ShowSQL(false)
// SQLite 同一时刻只允许一个写连接。这里把读连接放开几路:
// 扫描探测阶段是并发读(查指纹),被 1 个连接串行化会拖慢并行;
// 写仍走单事务(批量入库),由 SQLite 自己的锁机制串行,不会冲突。
e.SetMaxOpenConns(8)
// 给数据库加个忙等待,万一遇到并发写也不至于立刻报 SQLITE_BUSY
e.SetMaxIdleConns(8)
if _, err := e.Exec("PRAGMA busy_timeout = 10000"); err != nil {
_ = e.Close()
return nil, err
}
// 自动创建/补全四张表(媒体记录表、配置表、续传表)
if err := e.Sync(new(MediaInfo), new(AppConfig), new(ScanLog)); 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
}
// ============================ 应用配置 ============================
// SaveConfig 保存/更新一条应用配置(有就更新,没有就插入)。
func (s *Store) SaveConfig(key, value string) error {
if s == nil || s.engine == nil || key == "" {
return nil
}
existing := new(AppConfig)
has, err := s.engine.Where("key = ?", key).Get(existing)
if err != nil {
return err
}
if has {
_, err = s.engine.Where("key = ?", key).Update(&AppConfig{Value: value})
return err
}
_, err = s.engine.Insert(&AppConfig{Key: key, Value: value})
return err
}
// GetConfig 读取一条应用配置;不存在返回空串。
func (s *Store) GetConfig(key string) string {
if s == nil || s.engine == nil || key == "" {
return ""
}
cfg := new(AppConfig)
has, err := s.engine.Where("key = ?", key).Get(cfg)
if err != nil || !has {
return ""
}
return cfg.Value
}
// ============================ 续传表 ============================
// LoadScanLog 把全部续传记录读出来,按源路径建索引,供扫描时快速判断"这个文件上次归到哪了"。
func (s *Store) LoadScanLog() (map[string]ScanLog, error) {
m := make(map[string]ScanLog)
if s == nil || s.engine == nil {
return m, nil
}
list := make([]ScanLog, 0)
if err := s.engine.Find(&list); err != nil {
return nil, err
}
for _, e := range list {
m[e.SourcePath] = e
}
return m, nil
}
// SaveScanLogs 批量保存续传记录(按源路径 upsert)。攒一批一起写,少开事务。
// 单行删除 + 单行插入,没有 999 变量的限制,一批全在一个事务里。
func (s *Store) SaveScanLogs(entries []ScanLog) error {
if s == nil || s.engine == nil || len(entries) == 0 {
return nil
}
sess := s.engine.NewSession()
defer sess.Close()
if err := sess.Begin(); err != nil {
return err
}
for _, e := range entries {
if _, err := sess.Where("source_path = ?", e.SourcePath).Delete(new(ScanLog)); err != nil {
_ = sess.Rollback()
return err
}
if _, err := sess.Insert(&e); err != nil {
_ = sess.Rollback()
return err
}
}
return sess.Commit()
}
// ClearScanLog 清空全部续传记录。移动完成后调用。
func (s *Store) ClearScanLog() error {
if s == nil || s.engine == nil {
return nil
}
_, err := s.engine.Delete(new(ScanLog))
return err
}
// UpdateMediaFilePaths 批量更新 media_info 的 file_path。
// moved 的 key 是源路径,value 是新路径。
func (s *Store) UpdateMediaFilePaths(moved map[string]string) error {
if s == nil || s.engine == nil || len(moved) == 0 {
return nil
}
sess := s.engine.NewSession()
defer sess.Close()
if err := sess.Begin(); err != nil {
return err
}
for oldPath, newPath := range moved {
if _, err := sess.Where("file_path = ?", oldPath).
Update(&MediaInfo{FilePath: newPath}); err != nil {
_ = sess.Rollback()
return err
}
}
return sess.Commit()
}
// ============================ 媒体记录的新增/更新 ============================
// 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
}
// SaveMediaBatch 在一个事务里批量保存/更新媒体记录。
// 一次扫描可能有几千个文件,一个个提交事务很慢,攒一批一次写入快得多。
func (s *Store) SaveMediaBatch(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 {
existing := new(MediaInfo)
has, err := sess.Where("file_md5 = ?", info.FileMd5).Get(existing)
if err != nil {
_ = sess.Rollback()
return err
}
if has {
info.Id = existing.Id
if _, err = sess.ID(existing.Id).Update(info); err != nil {
_ = sess.Rollback()
return err
}
} else if _, err = sess.Insert(info); err != nil {
_ = sess.Rollback()
return err
}
}
return sess.Commit()
}
// ============================ 媒体记录的查询 ============================
// 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
}
// FindMediaByMd5 按文件 MD5 找记录(用于判断"同一个文件是不是又出现了一次")。
func (s *Store) FindMediaByMd5(md5 string) (*MediaInfo, bool) {
if s == nil || s.engine == nil || md5 == "" {
return nil, false
}
info := new(MediaInfo)
has, err := s.engine.Where("file_md5 = ?", md5).Get(info)
if err != nil || !has {
return nil, false
}
return info, true
}
// ============================ 媒体库列表查询 ============================
// MediaQuery 媒体库列表的查询条件。空值表示"这一项不过滤"。
type MediaQuery struct {
MediaType string // 类型:image / video / audio
Orientation string // 方向:horizontal / vertical / square
Keyword string // 文件名模糊搜索
Codec string // 编码
Format string // 容器格式
Resolution string // 分辨率档位(如 1080P / 5K / 普通 / 原图)
NsfwOnly bool // 只看色情图片(nsfw_score >= nsfw_threshold 且 threshold > 0)
Page int // 第几页(从 1 开始)
PageSize int // 每页几条
SortField string // 按哪个字段排序:id / fileSize / width / 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 根据查询条件拼 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 "
}
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 q.NsfwOnly {
appendCond("nsfw_score >= nsfw_threshold AND nsfw_threshold > 0")
}
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"` // 按类型(图片/视频/音频)的数量与体积
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"` // 平均时长(秒)
AvgSize int64 `json:"avgSize"` // 平均文件体积(字节)
MaxSize int64 `json:"maxSize"` // 最大文件体积(字节)
MinSize int64 `json:"minSize"` // 最小文件体积(字节)
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 统计里的一种"键值对":某一类是什么(key)、有多少个(count)、占多少字节(size)。
type KV struct {
Key string `json:"key"`
Count int64 `json:"count"`
Size int64 `json:"size"`
}
// Stats 汇总全部统计数据。用了一堆 SQL 查询,把各种分布都算出来。
func (s *Store) Stats() (*MediaStats, error) {
if s == nil || s.engine == nil {
return &MediaStats{}, nil
}
st := &MediaStats{
// 空库时这些列表默认为空数组(而不是 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").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").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
}
// 编码 / 格式 / 大文件 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 <> '' 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 <> '') 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 ORDER BY file_size DESC LIMIT 10").Find(&largeRows); err == nil {
st.TopLarge = largeRows
}
// 分辨率分布:这里不用 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").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)
// 时长统计(视频/音频):总时长 + 平均时长
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").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 GROUP BY key ORDER BY count DESC").Find(&durRows); err == nil {
st.DurationBins = durRows
}
// 平均/最大/最小文件体积
var sizeStat struct {
Avg int64 `xorm:"avg"`
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").Get(&sizeStat); err == nil {
st.AvgSize = sizeStat.Avg
st.MaxSize = sizeStat.Max
st.MinSize = sizeStat.Min
}
// 入库趋势(近 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 <> '' 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
}
// 扩展名分布
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 <> '' 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 <> '' 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 <> '' GROUP BY codec ORDER BY count DESC LIMIT 10").Find(&audioRows); err == nil {
st.AudioCodecCount = audioRows
}
return st, nil
}
// sortKVs 把统计结果按数量从多到少排序。
func sortKVs(list []KV) {
sort.Slice(list, func(i, j int) bool { return list[i].Count > list[j].Count })
}