// 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 判定阈值 AnimeNsfwScore float64 `xorm:"real 'anime_nsfw_score'" json:"animeNsfwScore"` // 动漫 NSFW 检测分 0~1(0 表示未检测或非动漫) AnimeNsfwThreshold float64 `xorm:"real 'anime_nsfw_threshold'" json:"animeNsfwThreshold"` // 动漫 NSFW 判定阈值 AnimeNsfwRatings string `xorm:"text 'anime_nsfw_ratings'" json:"animeNsfwRatings"` // WD Tagger 四个 rating 的 JSON(G/S/Q/E) ManualNsfw string `xorm:"varchar(1) 'manual_nsfw'" json:"manualNsfw"` // 手动标记是否色情:Y/N,默认 N 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() } // HasScanLog 检查是否有扫描记录(用于判断是否可以直接移动文件)。 func (s *Store) HasScanLog() (bool, error) { if s == nil || s.engine == nil { return false, nil } total, err := s.engine.Count(new(ScanLog)) if err != nil { return false, err } return total > 0, nil } // ClearScanLog 清空全部续传记录。移动完成后调用。 func (s *Store) ClearScanLog() error { if s == nil || s.engine == nil { return nil } _, err := s.engine.Where("1=1").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() } // AvgManualNsfwScore 计算 manual_nsfw='Y' 的记录的 nsfw_score 均值。 func (s *Store) AvgManualNsfwScore() (float64, error) { if s == nil || s.engine == nil { return 0, nil } var result struct { Avg float64 `xorm:"avg"` } has, err := s.engine.Table("media_info"). Select("AVG(nsfw_score) as avg"). Where("manual_nsfw = 'Y'"). Get(&result) if err != nil { return 0, err } if !has { return 0, nil } return result.Avg, nil } // UpdateManualNsfw 更新单条记录的手动色情标记。 func (s *Store) UpdateManualNsfw(id int64, value string) error { if s == nil || s.engine == nil { return nil } _, err := s.engine.ID(id).Update(&MediaInfo{ManualNsfw: value}) 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 } // 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 width0 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 }) }