Skip to content

Commit e776d2c

Browse files
committed
feat(func): media library
1 parent b5626b2 commit e776d2c

File tree

18 files changed

+3503
-253
lines changed

18 files changed

+3503
-253
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ require (
6464
github.com/pquerna/otp v1.5.0
6565
github.com/quic-go/quic-go v0.54.1
6666
github.com/rclone/rclone v1.70.3
67+
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
6768
github.com/shirou/gopsutil/v4 v4.25.5
6869
github.com/sirupsen/logrus v1.9.3
6970
github.com/spf13/afero v1.14.0

go.sum

Lines changed: 67 additions & 251 deletions
Large diffs are not rendered by default.

internal/bootstrap/data/setting.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,13 @@ func InitialSettings() []model.SettingItem {
242242
{Key: conf.StreamMaxClientUploadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
243243
{Key: conf.StreamMaxServerDownloadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
244244
{Key: conf.StreamMaxServerUploadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
245+
246+
// media settings
247+
{Key: conf.MediaTMDBKey, Value: "", Type: conf.TypeString, Group: model.MEDIA, Flag: model.PRIVATE},
248+
{Key: conf.MediaDiscogsToken, Value: "", Type: conf.TypeString, Group: model.MEDIA, Flag: model.PRIVATE},
249+
{Key: conf.MediaStoreThumbnail, Value: "false", Type: conf.TypeBool, Group: model.MEDIA, Flag: model.PRIVATE},
250+
{Key: conf.MediaThumbnailMode, Value: "base64", Type: conf.TypeSelect, Options: "base64,local", Group: model.MEDIA, Flag: model.PRIVATE},
251+
{Key: conf.MediaThumbnailPath, Value: "/.thumbnail", Type: conf.TypeString, Group: model.MEDIA, Flag: model.PRIVATE},
245252
}
246253
additionalSettingItems := tool.Tools.Items()
247254
// 固定顺序

internal/conf/const.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,13 @@ const (
161161
StreamMaxClientUploadSpeed = "max_client_upload_speed"
162162
StreamMaxServerDownloadSpeed = "max_server_download_speed"
163163
StreamMaxServerUploadSpeed = "max_server_upload_speed"
164+
165+
// media
166+
MediaTMDBKey = "media_tmdb_key"
167+
MediaDiscogsToken = "media_discogs_token"
168+
MediaThumbnailMode = "media_thumbnail_mode"
169+
MediaThumbnailPath = "media_thumbnail_path"
170+
MediaStoreThumbnail = "media_store_thumbnail"
164171
)
165172

166173
const (

internal/db/db.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ 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))
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.VirtualHost), new(model.MediaItem), new(model.MediaConfig))
16+
>>>>>>> 0308504c (feat(func): media library)
17+
=======
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))
19+
=======
20+
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.VirtualHost), new(model.MediaItem), new(model.MediaConfig))
21+
>>>>>>> 0308504c (feat(func): media library)
1622
if err != nil {
1723
log.Fatalf("failed migrate database: %s", err.Error())
1824
}

internal/db/media.go

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
package db
2+
3+
import (
4+
"github.com/OpenListTeam/OpenList/v4/internal/model"
5+
"gorm.io/gorm"
6+
)
7+
8+
// ==================== MediaConfig ====================
9+
10+
// GetMediaConfig 获取指定类型的媒体库配置,不存在则返回默认值
11+
func GetMediaConfig(mediaType model.MediaType) (*model.MediaConfig, error) {
12+
var cfg model.MediaConfig
13+
result := db.Where("media_type = ?", mediaType).First(&cfg)
14+
if result.Error == gorm.ErrRecordNotFound {
15+
// 返回默认配置
16+
return &model.MediaConfig{
17+
MediaType: mediaType,
18+
Enabled: false,
19+
ScanPath: "/",
20+
PathMerge: false,
21+
}, nil
22+
}
23+
return &cfg, result.Error
24+
}
25+
26+
// GetAllMediaConfigs 获取所有媒体库配置
27+
func GetAllMediaConfigs() ([]model.MediaConfig, error) {
28+
var cfgs []model.MediaConfig
29+
err := db.Find(&cfgs).Error
30+
return cfgs, err
31+
}
32+
33+
// SaveMediaConfig 保存媒体库配置(upsert)
34+
func SaveMediaConfig(cfg *model.MediaConfig) error {
35+
var existing model.MediaConfig
36+
result := db.Where("media_type = ?", cfg.MediaType).First(&existing)
37+
if result.Error == gorm.ErrRecordNotFound {
38+
return db.Create(cfg).Error
39+
}
40+
cfg.ID = existing.ID
41+
return db.Save(cfg).Error
42+
}
43+
44+
// ==================== MediaItem ====================
45+
46+
// MediaItemQuery 媒体条目查询参数
47+
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
56+
}
57+
58+
// ListMediaItems 分页查询媒体条目
59+
func ListMediaItems(q MediaItemQuery) ([]model.MediaItem, int64, error) {
60+
tx := db.Model(&model.MediaItem{})
61+
if q.MediaType != "" {
62+
tx = tx.Where("media_type = ?", q.MediaType)
63+
}
64+
if q.FolderPath != "" {
65+
tx = tx.Where("folder_path = ?", q.FolderPath)
66+
}
67+
if q.Hidden != nil {
68+
tx = tx.Where("hidden = ?", *q.Hidden)
69+
}
70+
if q.Keyword != "" {
71+
like := "%" + q.Keyword + "%"
72+
tx = tx.Where("file_name LIKE ? OR scraped_name LIKE ?", like, like)
73+
}
74+
75+
var total int64
76+
if err := tx.Count(&total).Error; err != nil {
77+
return nil, 0, err
78+
}
79+
80+
// 排序
81+
orderCol := "created_at"
82+
switch q.OrderBy {
83+
case "name":
84+
orderCol = "COALESCE(NULLIF(scraped_name,''), file_name)"
85+
case "date":
86+
orderCol = "release_date"
87+
case "size":
88+
orderCol = "file_size"
89+
}
90+
dir := "asc"
91+
if q.OrderDir == "desc" {
92+
dir = "desc"
93+
}
94+
tx = tx.Order(orderCol + " " + dir)
95+
96+
// 分页
97+
if q.PageSize <= 0 {
98+
q.PageSize = 20
99+
}
100+
if q.Page <= 0 {
101+
q.Page = 1
102+
}
103+
tx = tx.Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize)
104+
105+
var items []model.MediaItem
106+
err := tx.Find(&items).Error
107+
return items, total, err
108+
}
109+
110+
// GetMediaItemByID 按ID获取媒体条目
111+
func GetMediaItemByID(id uint) (*model.MediaItem, error) {
112+
var item model.MediaItem
113+
err := db.First(&item, id).Error
114+
return &item, err
115+
}
116+
117+
// GetMediaItemByPath 按文件路径获取媒体条目
118+
func GetMediaItemByPath(filePath string) (*model.MediaItem, error) {
119+
var item model.MediaItem
120+
result := db.Where("file_path = ?", filePath).First(&item)
121+
return &item, result.Error
122+
}
123+
124+
// CreateOrUpdateMediaItem 创建或更新媒体条目(按 file_path 唯一)
125+
// 更新时保留已有的刮削数据,避免重新扫描时把已刮削的字段清空
126+
func CreateOrUpdateMediaItem(item *model.MediaItem) error {
127+
var existing model.MediaItem
128+
result := db.Where("file_path = ?", item.FilePath).First(&existing)
129+
if result.Error == gorm.ErrRecordNotFound {
130+
return db.Create(item).Error
131+
}
132+
if result.Error != nil {
133+
return result.Error
134+
}
135+
item.ID = existing.ID
136+
item.CreatedAt = existing.CreatedAt
137+
// 如果已有刮削数据,保留刮削字段,防止重新扫描时覆盖刮削结果
138+
if existing.ScrapedAt != nil {
139+
item.ScrapedAt = existing.ScrapedAt
140+
item.ScrapedName = existing.ScrapedName
141+
item.Cover = existing.Cover
142+
item.AlbumName = existing.AlbumName
143+
item.AlbumArtist = existing.AlbumArtist
144+
item.TrackNumber = existing.TrackNumber
145+
item.Duration = existing.Duration
146+
item.Genre = existing.Genre
147+
item.ReleaseDate = existing.ReleaseDate
148+
item.Rating = existing.Rating
149+
item.Plot = existing.Plot
150+
item.Authors = existing.Authors
151+
item.Description = existing.Description
152+
item.Publisher = existing.Publisher
153+
item.ISBN = existing.ISBN
154+
item.ExternalID = existing.ExternalID
155+
}
156+
return db.Save(item).Error
157+
}
158+
159+
// UpdateMediaItem 更新媒体条目(仅更新可编辑字段)
160+
func UpdateMediaItem(item *model.MediaItem) error {
161+
return db.Save(item).Error
162+
}
163+
164+
// DeleteMediaItem 硬删除媒体条目(真正从数据库删除)
165+
func DeleteMediaItem(id uint) error {
166+
return db.Unscoped().Delete(&model.MediaItem{}, id).Error
167+
}
168+
169+
// ClearMediaItems 硬删除指定类型的所有媒体条目(真正从数据库删除)
170+
func ClearMediaItems(mediaType model.MediaType) error {
171+
return db.Unscoped().Where("media_type = ?", mediaType).Delete(&model.MediaItem{}).Error
172+
}
173+
174+
// ListAlbums 列出所有专辑(音乐专用)
175+
func ListAlbums(q MediaItemQuery) ([]AlbumInfo, int64, error) {
176+
type albumRow struct {
177+
AlbumName string
178+
AlbumArtist string
179+
Cover string
180+
ReleaseDate string
181+
TrackCount int
182+
}
183+
184+
// 构建基础查询
185+
baseQuery := db.Model(&model.MediaItem{}).
186+
Where("media_type = ?", model.MediaTypeMusic)
187+
if q.Hidden != nil {
188+
baseQuery = baseQuery.Where("hidden = ?", *q.Hidden)
189+
}
190+
if q.Keyword != "" {
191+
like := "%" + q.Keyword + "%"
192+
baseQuery = baseQuery.Where("album_name LIKE ? OR album_artist LIKE ?", like, like)
193+
}
194+
195+
// 统计分组数(用子查询)
196+
var total int64
197+
if err := db.Table("(?) as sub", baseQuery.
198+
Select("album_name, album_artist").
199+
Group("album_name, album_artist")).
200+
Count(&total).Error; err != nil {
201+
return nil, 0, err
202+
}
203+
204+
if q.PageSize <= 0 {
205+
q.PageSize = 20
206+
}
207+
if q.Page <= 0 {
208+
q.Page = 1
209+
}
210+
211+
tx := baseQuery.
212+
Select("album_name, album_artist, MAX(cover) as cover, MAX(release_date) as release_date, COUNT(*) as track_count").
213+
Group("album_name, album_artist").
214+
Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize)
215+
216+
var rows []albumRow
217+
if err := tx.Scan(&rows).Error; err != nil {
218+
return nil, 0, err
219+
}
220+
221+
albums := make([]AlbumInfo, len(rows))
222+
for i, r := range rows {
223+
albums[i] = AlbumInfo{
224+
AlbumName: r.AlbumName,
225+
AlbumArtist: r.AlbumArtist,
226+
Cover: r.Cover,
227+
ReleaseDate: r.ReleaseDate,
228+
TrackCount: r.TrackCount,
229+
}
230+
}
231+
return albums, total, nil
232+
}
233+
234+
// AlbumInfo 专辑信息
235+
type AlbumInfo struct {
236+
AlbumName string `json:"album_name"`
237+
AlbumArtist string `json:"album_artist"`
238+
Cover string `json:"cover"`
239+
ReleaseDate string `json:"release_date"`
240+
TrackCount int `json:"track_count"`
241+
}
242+
243+
// GetAlbumTracks 获取专辑曲目列表
244+
func GetAlbumTracks(albumName, albumArtist string) ([]model.MediaItem, error) {
245+
var items []model.MediaItem
246+
tx := db.Where("media_type = ?", model.MediaTypeMusic)
247+
if albumName != "" {
248+
tx = tx.Where("album_name = ?", albumName)
249+
} else {
250+
// album_name 为空时,查询该艺术家下所有无专辑名的曲目
251+
tx = tx.Where("(album_name = '' OR album_name IS NULL)")
252+
}
253+
if albumArtist != "" {
254+
tx = tx.Where("album_artist = ?", albumArtist)
255+
}
256+
err := tx.Order("track_number asc").Find(&items).Error
257+
return items, err
258+
}
259+
260+
// ListFolderPaths 列出指定媒体类型下的所有文件夹路径(目录浏览模式)
261+
func ListFolderPaths(mediaType model.MediaType) ([]string, error) {
262+
var paths []string
263+
err := db.Model(&model.MediaItem{}).
264+
Where("media_type = ?", mediaType).
265+
Distinct("folder_path").
266+
Pluck("folder_path", &paths).Error
267+
return paths, err
268+
}
269+
270+
// GetUnscrappedItems 获取未刮削或刮削不完整的媒体条目
271+
// 只要 scraped_at 为空,或 cover/scraped_name/description 任一为空,就需要重新刮削
272+
func GetUnscrappedItems(mediaType model.MediaType, limit int) ([]model.MediaItem, error) {
273+
var items []model.MediaItem
274+
err := db.Where(
275+
"media_type = ? AND (scraped_at IS NULL OR cover = '' OR cover IS NULL OR scraped_name = '' OR scraped_name IS NULL OR description = '' OR description IS NULL)",
276+
mediaType,
277+
).
278+
Limit(limit).
279+
Find(&items).Error
280+
return items, err
281+
}

0 commit comments

Comments
 (0)