Skip to content

Commit 3527cab

Browse files
committed
feat(func): media library support select
1 parent dc92fcc commit 3527cab

File tree

10 files changed

+715
-167
lines changed

10 files changed

+715
-167
lines changed

internal/bootstrap/db.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@ import (
2020
func InitDB() {
2121
logLevel := logger.Silent
2222
if flags.Debug || flags.Dev {
23-
logLevel = logger.Info
23+
logLevel = logger.Warn // Warn 级别:只输出慢查询和错误,不输出每条 SQL
2424
}
2525
newLogger := logger.New(
2626
stdlog.New(log.StandardLogger().Out, "\r\n", stdlog.LstdFlags),
2727
logger.Config{
28-
SlowThreshold: time.Second,
28+
SlowThreshold: 200 * time.Millisecond, // 超过 200ms 才记录慢查询
2929
LogLevel: logLevel,
3030
IgnoreRecordNotFoundError: true,
3131
Colorful: true,

internal/db/db.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,41 @@ var db *gorm.DB
1212

1313
func Init(d *gorm.DB) {
1414
db = d
15-
err := AutoMigrate(new(model.Storage), new(model.User), new(model.Meta), new(model.SettingItem), new(model.SearchNode), new(model.TaskItem), new(model.SSHPublicKey), new(model.SharingDB), new(model.MediaItem), new(model.MediaConfig))
15+
// 迁移前处理:删除旧的 folder_path 唯一索引(如果存在),避免迁移冲突
16+
// 同时清空旧的 media_items 数据(folder_path 语义已变更,旧数据不可复用)
17+
migrateMediaItems()
18+
err := AutoMigrate(new(model.Storage), new(model.User), new(model.Meta), new(model.SettingItem), new(model.SearchNode), new(model.TaskItem), new(model.SSHPublicKey), new(model.SharingDB), new(model.MediaItem), new(model.MediaConfig), new(model.MediaScanPath))
1619
if err != nil {
1720
log.Fatalf("failed migrate database: %s", err.Error())
1821
}
1922
}
2023

24+
// migrateMediaItems 处理 media_items 表的迁移兼容性
25+
// 存储语义已变更:folder_path 恒定为扫描根路径,file_name 为文件/文件夹名
26+
// 唯一性由 folder_path + file_name + album_name 组合索引保证
27+
func migrateMediaItems() {
28+
// 检查表是否存在
29+
if !db.Migrator().HasTable("x_media_items") {
30+
return
31+
}
32+
// 已迁移到新组合索引,跳过
33+
if db.Migrator().HasIndex("x_media_items", "idx_media_folder_file_album") {
34+
return
35+
}
36+
// 旧表存在但没有新组合索引,说明是旧版本数据,需要清空后重建
37+
// 旧数据的 folder_path 语义已变更(原来存完整路径,现在恒定为扫描根路径),无法复用
38+
log.Info("media_items: 检测到旧版本数据,清空后重新迁移(存储结构已变更)")
39+
// 先尝试删除旧的单字段唯一索引(如果存在),避免 AutoMigrate 冲突
40+
if db.Migrator().HasIndex("x_media_items", "idx_x_media_items_folder_path") {
41+
if err := db.Migrator().DropIndex("x_media_items", "idx_x_media_items_folder_path"); err != nil {
42+
log.Warnf("media_items: 删除旧唯一索引失败: %v", err)
43+
}
44+
}
45+
if err := db.Exec("DELETE FROM x_media_items").Error; err != nil {
46+
log.Warnf("media_items: 清空旧数据失败: %v", err)
47+
}
48+
}
49+
2150
func AutoMigrate(dst ...interface{}) error {
2251
var err error
2352
if conf.Conf.Database.Type == "mysql" {

internal/db/media.go

Lines changed: 121 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package db
22

33
import (
4+
"encoding/json"
5+
"strings"
6+
47
"github.com/OpenListTeam/OpenList/v4/internal/model"
58
"gorm.io/gorm"
69
)
@@ -12,12 +15,9 @@ func GetMediaConfig(mediaType model.MediaType) (*model.MediaConfig, error) {
1215
var cfg model.MediaConfig
1316
result := db.Where("media_type = ?", mediaType).First(&cfg)
1417
if result.Error == gorm.ErrRecordNotFound {
15-
// 返回默认配置
1618
return &model.MediaConfig{
1719
MediaType: mediaType,
1820
Enabled: false,
19-
ScanPath: "/",
20-
PathMerge: false,
2121
}, nil
2222
}
2323
return &cfg, result.Error
@@ -41,18 +41,56 @@ func SaveMediaConfig(cfg *model.MediaConfig) error {
4141
return db.Save(cfg).Error
4242
}
4343

44+
// ==================== MediaScanPath ====================
45+
46+
// ListMediaScanPaths 获取指定媒体类型的所有扫描路径
47+
func ListMediaScanPaths(mediaType model.MediaType) ([]model.MediaScanPath, error) {
48+
var paths []model.MediaScanPath
49+
tx := db.Model(&model.MediaScanPath{})
50+
if mediaType != "" {
51+
tx = tx.Where("media_type = ?", mediaType)
52+
}
53+
err := tx.Order("id asc").Find(&paths).Error
54+
return paths, err
55+
}
56+
57+
// GetMediaScanPath 按ID获取扫描路径
58+
func GetMediaScanPath(id uint) (*model.MediaScanPath, error) {
59+
var p model.MediaScanPath
60+
err := db.First(&p, id).Error
61+
return &p, err
62+
}
63+
64+
// CreateMediaScanPath 创建扫描路径
65+
func CreateMediaScanPath(p *model.MediaScanPath) error {
66+
return db.Create(p).Error
67+
}
68+
69+
// UpdateMediaScanPath 更新扫描路径
70+
func UpdateMediaScanPath(p *model.MediaScanPath) error {
71+
return db.Save(p).Error
72+
}
73+
74+
// DeleteMediaScanPath 删除扫描路径(硬删除)
75+
func DeleteMediaScanPath(id uint) error {
76+
return db.Unscoped().Delete(&model.MediaScanPath{}, id).Error
77+
}
78+
4479
// ==================== MediaItem ====================
4580

4681
// MediaItemQuery 媒体条目查询参数
4782
type MediaItemQuery struct {
48-
MediaType model.MediaType
49-
FolderPath string
50-
Hidden *bool
51-
Keyword string
52-
OrderBy string // "name", "date", "size"
53-
OrderDir string // "asc", "desc"
54-
Page int
55-
PageSize int
83+
MediaType model.MediaType
84+
ScanPathID uint // 按扫描路径ID筛选
85+
FolderPath string // 按文件夹路径筛选
86+
TypeTag string // 按类型标签筛选(电影、电视剧等)
87+
ContentTag string // 按内容标签筛选(喜剧、惊悚等)
88+
Hidden *bool
89+
Keyword string
90+
OrderBy string // "name", "date", "size"
91+
OrderDir string // "asc", "desc"
92+
Page int
93+
PageSize int
5694
}
5795

5896
// ListMediaItems 分页查询媒体条目
@@ -61,6 +99,9 @@ func ListMediaItems(q MediaItemQuery) ([]model.MediaItem, int64, error) {
6199
if q.MediaType != "" {
62100
tx = tx.Where("media_type = ?", q.MediaType)
63101
}
102+
if q.ScanPathID > 0 {
103+
tx = tx.Where("scan_path_id = ?", q.ScanPathID)
104+
}
64105
if q.FolderPath != "" {
65106
tx = tx.Where("folder_path = ?", q.FolderPath)
66107
}
@@ -71,6 +112,16 @@ func ListMediaItems(q MediaItemQuery) ([]model.MediaItem, int64, error) {
71112
like := "%" + q.Keyword + "%"
72113
tx = tx.Where("file_name LIKE ? OR scraped_name LIKE ?", like, like)
73114
}
115+
// 按类型标签筛选(通过关联扫描路径的type_tag)
116+
if q.TypeTag != "" {
117+
tx = tx.Joins("JOIN media_scan_paths ON media_scan_paths.id = media_items.scan_path_id").
118+
Where("media_scan_paths.type_tag = ?", q.TypeTag)
119+
}
120+
// 按内容标签筛选(通过关联扫描路径的content_tags)
121+
if q.ContentTag != "" {
122+
tx = tx.Joins("JOIN media_scan_paths ON media_scan_paths.id = media_items.scan_path_id").
123+
Where("media_scan_paths.content_tags LIKE ?", "%"+q.ContentTag+"%")
124+
}
74125

75126
var total int64
76127
if err := tx.Count(&total).Error; err != nil {
@@ -114,18 +165,18 @@ func GetMediaItemByID(id uint) (*model.MediaItem, error) {
114165
return &item, err
115166
}
116167

117-
// GetMediaItemByPath 按文件路径获取媒体条目
118-
func GetMediaItemByPath(filePath string) (*model.MediaItem, error) {
168+
// GetMediaItemByFolderPath 按文件夹路径获取媒体条目(用于合并模式)
169+
func GetMediaItemByFolderPath(folderPath string) (*model.MediaItem, error) {
119170
var item model.MediaItem
120-
result := db.Where("file_path = ?", filePath).First(&item)
171+
result := db.Where("folder_path = ?", folderPath).First(&item)
121172
return &item, result.Error
122173
}
123174

124-
// CreateOrUpdateMediaItem 创建或更新媒体条目(按 file_path 唯一
175+
// CreateOrUpdateMediaItem 创建或更新媒体条目(按 folder_path + file_name + album_name 组合唯一
125176
// 更新时保留已有的刮削数据,避免重新扫描时把已刮削的字段清空
126177
func CreateOrUpdateMediaItem(item *model.MediaItem) error {
127178
var existing model.MediaItem
128-
result := db.Where("file_path = ?", item.FilePath).First(&existing)
179+
result := db.Where("folder_path = ? AND file_name = ? AND album_name = ?", item.FolderPath, item.FileName, item.AlbumName).First(&existing)
129180
if result.Error == gorm.ErrRecordNotFound {
130181
return db.Create(item).Error
131182
}
@@ -171,6 +222,11 @@ func ClearMediaItems(mediaType model.MediaType) error {
171222
return db.Unscoped().Where("media_type = ?", mediaType).Delete(&model.MediaItem{}).Error
172223
}
173224

225+
// ClearMediaItemsByScanPath 硬删除指定扫描路径的所有媒体条目
226+
func ClearMediaItemsByScanPath(scanPathID uint) error {
227+
return db.Unscoped().Where("scan_path_id = ?", scanPathID).Delete(&model.MediaItem{}).Error
228+
}
229+
174230
// ListAlbums 列出所有专辑(音乐专用)
175231
func ListAlbums(q MediaItemQuery) ([]AlbumInfo, int64, error) {
176232
type albumRow struct {
@@ -179,6 +235,7 @@ func ListAlbums(q MediaItemQuery) ([]AlbumInfo, int64, error) {
179235
Cover string
180236
ReleaseDate string
181237
TrackCount int
238+
ScanPathID uint
182239
}
183240

184241
// 构建基础查询
@@ -187,9 +244,12 @@ func ListAlbums(q MediaItemQuery) ([]AlbumInfo, int64, error) {
187244
if q.Hidden != nil {
188245
baseQuery = baseQuery.Where("hidden = ?", *q.Hidden)
189246
}
247+
if q.ScanPathID > 0 {
248+
baseQuery = baseQuery.Where("scan_path_id = ?", q.ScanPathID)
249+
}
190250
if q.Keyword != "" {
191251
like := "%" + q.Keyword + "%"
192-
baseQuery = baseQuery.Where("album_name LIKE ? OR album_artist LIKE ?", like, like)
252+
baseQuery = baseQuery.Where("album_name LIKE ? OR album_artist LIKE ? OR scraped_name LIKE ?", like, like, like)
193253
}
194254

195255
// 统计分组数(用子查询)
@@ -209,7 +269,7 @@ func ListAlbums(q MediaItemQuery) ([]AlbumInfo, int64, error) {
209269
}
210270

211271
tx := baseQuery.
212-
Select("album_name, album_artist, MAX(cover) as cover, MAX(release_date) as release_date, COUNT(*) as track_count").
272+
Select("album_name, album_artist, MAX(cover) as cover, MAX(release_date) as release_date, COUNT(*) as track_count, MAX(scan_path_id) as scan_path_id").
213273
Group("album_name, album_artist").
214274
Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize)
215275

@@ -226,6 +286,7 @@ func ListAlbums(q MediaItemQuery) ([]AlbumInfo, int64, error) {
226286
Cover: r.Cover,
227287
ReleaseDate: r.ReleaseDate,
228288
TrackCount: r.TrackCount,
289+
ScanPathID: r.ScanPathID,
229290
}
230291
}
231292
return albums, total, nil
@@ -238,23 +299,63 @@ type AlbumInfo struct {
238299
Cover string `json:"cover"`
239300
ReleaseDate string `json:"release_date"`
240301
TrackCount int `json:"track_count"`
302+
ScanPathID uint `json:"scan_path_id"`
241303
}
242304

243305
// GetAlbumTracks 获取专辑曲目列表
306+
// 支持两种模式:
307+
// 1. 普通模式(is_folder=false):直接返回独立文件记录
308+
// 2. 合并文件夹模式(is_folder=true):把 episodes 展开成虚拟 MediaItem 列表
309+
// 展开后每条记录的 folder_path = 原folder_path/file_name(文件夹实际路径),file_name = episode.FileName
244310
func GetAlbumTracks(albumName, albumArtist string) ([]model.MediaItem, error) {
245311
var items []model.MediaItem
246312
tx := db.Where("media_type = ?", model.MediaTypeMusic)
247313
if albumName != "" {
248314
tx = tx.Where("album_name = ?", albumName)
249315
} else {
250-
// album_name 为空时,查询该艺术家下所有无专辑名的曲目
251316
tx = tx.Where("(album_name = '' OR album_name IS NULL)")
252317
}
253318
if albumArtist != "" {
254319
tx = tx.Where("album_artist = ?", albumArtist)
255320
}
256321
err := tx.Order("track_number asc").Find(&items).Error
257-
return items, err
322+
if err != nil {
323+
return nil, err
324+
}
325+
326+
// 展开合并文件夹条目的 episodes
327+
var result []model.MediaItem
328+
for _, item := range items {
329+
if !item.IsFolder || item.Episodes == "" {
330+
result = append(result, item)
331+
continue
332+
}
333+
// 解析 episodes
334+
type EpisodeInfo struct {
335+
FileName string `json:"file_name"`
336+
Index int `json:"index"`
337+
Title string `json:"title"`
338+
}
339+
var eps []EpisodeInfo
340+
if err := json.Unmarshal([]byte(item.Episodes), &eps); err != nil || len(eps) == 0 {
341+
// 解析失败则跳过该条目(不返回文件夹本身,避免播放路径错误)
342+
continue
343+
}
344+
// 文件夹实际路径 = folder_path + "/" + file_name
345+
actualDir := strings.TrimRight(item.FolderPath, "/") + "/" + item.FileName
346+
for _, ep := range eps {
347+
track := item // 复制基础信息(封面、专辑名、艺术家等)
348+
track.ID = 0
349+
track.IsFolder = false
350+
track.FolderPath = actualDir
351+
track.FileName = ep.FileName
352+
track.TrackNumber = ep.Index
353+
track.ScrapedName = ep.Title
354+
track.Episodes = ""
355+
result = append(result, track)
356+
}
357+
}
358+
return result, nil
258359
}
259360

260361
// ListFolderPaths 列出指定媒体类型下的所有文件夹路径(目录浏览模式)

0 commit comments

Comments
 (0)