Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ require (
github.com/pquerna/otp v1.5.0
github.com/quic-go/quic-go v0.54.1
github.com/rclone/rclone v1.70.3
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
github.com/shirou/gopsutil/v4 v4.25.5
github.com/sirupsen/logrus v1.9.3
github.com/spf13/afero v1.14.0
Expand All @@ -89,8 +90,8 @@ require (
)

require (
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e // indirect
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
Expand Down
274 changes: 27 additions & 247 deletions go.sum

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions internal/bootstrap/data/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,13 @@ func InitialSettings() []model.SettingItem {
{Key: conf.StreamMaxClientUploadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
{Key: conf.StreamMaxServerDownloadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
{Key: conf.StreamMaxServerUploadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},

// media settings
{Key: conf.MediaTMDBKey, Value: "", Type: conf.TypeString, Group: model.MEDIA, Flag: model.PRIVATE},
{Key: conf.MediaDiscogsToken, Value: "", Type: conf.TypeString, Group: model.MEDIA, Flag: model.PRIVATE},
{Key: conf.MediaStoreThumbnail, Value: "false", Type: conf.TypeBool, Group: model.MEDIA, Flag: model.PRIVATE},
{Key: conf.MediaThumbnailMode, Value: "base64", Type: conf.TypeSelect, Options: "base64,local", Group: model.MEDIA, Flag: model.PRIVATE},
{Key: conf.MediaThumbnailPath, Value: "/.thumbnail", Type: conf.TypeString, Group: model.MEDIA, Flag: model.PRIVATE},
}
additionalSettingItems := tool.Tools.Items()
// 固定顺序
Expand Down
7 changes: 7 additions & 0 deletions internal/conf/const.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,13 @@ const (
StreamMaxClientUploadSpeed = "max_client_upload_speed"
StreamMaxServerDownloadSpeed = "max_server_download_speed"
StreamMaxServerUploadSpeed = "max_server_upload_speed"

// media
MediaTMDBKey = "media_tmdb_key"
MediaDiscogsToken = "media_discogs_token"
MediaThumbnailMode = "media_thumbnail_mode"
MediaThumbnailPath = "media_thumbnail_path"
MediaStoreThumbnail = "media_store_thumbnail"
)

const (
Expand Down
2 changes: 1 addition & 1 deletion internal/db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ var db *gorm.DB

func Init(d *gorm.DB) {
db = d
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))
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))
if err != nil {
log.Fatalf("failed migrate database: %s", err.Error())
}
Expand Down
281 changes: 281 additions & 0 deletions internal/db/media.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
package db

import (
"github.com/OpenListTeam/OpenList/v4/internal/model"
"gorm.io/gorm"
)

// ==================== MediaConfig ====================

// GetMediaConfig 获取指定类型的媒体库配置,不存在则返回默认值
func GetMediaConfig(mediaType model.MediaType) (*model.MediaConfig, error) {
var cfg model.MediaConfig
result := db.Where("media_type = ?", mediaType).First(&cfg)
if result.Error == gorm.ErrRecordNotFound {
// 返回默认配置
return &model.MediaConfig{
MediaType: mediaType,
Enabled: false,
ScanPath: "/",
PathMerge: false,
}, nil
Comment on lines +11 to +21
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These queries check result.Error == gorm.ErrRecordNotFound. Elsewhere in the repo the pattern is errors.Is(err, gorm.ErrRecordNotFound) (important when errors may be wrapped). Consider switching to errors.Is here for consistency and robustness.

Copilot uses AI. Check for mistakes.
}
return &cfg, result.Error
}

// GetAllMediaConfigs 获取所有媒体库配置
func GetAllMediaConfigs() ([]model.MediaConfig, error) {
var cfgs []model.MediaConfig
err := db.Find(&cfgs).Error
return cfgs, err
}

// SaveMediaConfig 保存媒体库配置(upsert)
func SaveMediaConfig(cfg *model.MediaConfig) error {
var existing model.MediaConfig
result := db.Where("media_type = ?", cfg.MediaType).First(&existing)
if result.Error == gorm.ErrRecordNotFound {
return db.Create(cfg).Error
}
cfg.ID = existing.ID
return db.Save(cfg).Error
}

// ==================== MediaItem ====================

// MediaItemQuery 媒体条目查询参数
type MediaItemQuery struct {
MediaType model.MediaType
FolderPath string
Hidden *bool
Keyword string
OrderBy string // "name", "date", "size"
OrderDir string // "asc", "desc"
Page int
PageSize int
}

// ListMediaItems 分页查询媒体条目
func ListMediaItems(q MediaItemQuery) ([]model.MediaItem, int64, error) {
tx := db.Model(&model.MediaItem{})
if q.MediaType != "" {
tx = tx.Where("media_type = ?", q.MediaType)
}
if q.FolderPath != "" {
tx = tx.Where("folder_path = ?", q.FolderPath)
}
if q.Hidden != nil {
tx = tx.Where("hidden = ?", *q.Hidden)
}
if q.Keyword != "" {
like := "%" + q.Keyword + "%"
tx = tx.Where("file_name LIKE ? OR scraped_name LIKE ?", like, like)
}

var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}

// 排序
orderCol := "created_at"
switch q.OrderBy {
case "name":
orderCol = "COALESCE(NULLIF(scraped_name,''), file_name)"
case "date":
orderCol = "release_date"
case "size":
orderCol = "file_size"
}
dir := "asc"
if q.OrderDir == "desc" {
dir = "desc"
}
tx = tx.Order(orderCol + " " + dir)

// 分页
if q.PageSize <= 0 {
q.PageSize = 20
}
if q.Page <= 0 {
q.Page = 1
}
tx = tx.Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize)

var items []model.MediaItem
err := tx.Find(&items).Error
return items, total, err
}

// GetMediaItemByID 按ID获取媒体条目
func GetMediaItemByID(id uint) (*model.MediaItem, error) {
var item model.MediaItem
err := db.First(&item, id).Error
return &item, err
}

// GetMediaItemByPath 按文件路径获取媒体条目
func GetMediaItemByPath(filePath string) (*model.MediaItem, error) {
var item model.MediaItem
result := db.Where("file_path = ?", filePath).First(&item)
return &item, result.Error
}

// CreateOrUpdateMediaItem 创建或更新媒体条目(按 file_path 唯一)
// 更新时保留已有的刮削数据,避免重新扫描时把已刮削的字段清空
func CreateOrUpdateMediaItem(item *model.MediaItem) error {
var existing model.MediaItem
result := db.Where("file_path = ?", item.FilePath).First(&existing)
if result.Error == gorm.ErrRecordNotFound {
return db.Create(item).Error
}
if result.Error != nil {
return result.Error
}
item.ID = existing.ID
item.CreatedAt = existing.CreatedAt
// 如果已有刮削数据,保留刮削字段,防止重新扫描时覆盖刮削结果
if existing.ScrapedAt != nil {
item.ScrapedAt = existing.ScrapedAt
item.ScrapedName = existing.ScrapedName
item.Cover = existing.Cover
item.AlbumName = existing.AlbumName
item.AlbumArtist = existing.AlbumArtist
item.TrackNumber = existing.TrackNumber
item.Duration = existing.Duration
item.Genre = existing.Genre
item.ReleaseDate = existing.ReleaseDate
item.Rating = existing.Rating
item.Plot = existing.Plot
item.Authors = existing.Authors
item.Description = existing.Description
item.Publisher = existing.Publisher
item.ISBN = existing.ISBN
item.ExternalID = existing.ExternalID
}
return db.Save(item).Error
Comment on lines +135 to +156
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CreateOrUpdateMediaItem does not preserve the existing Hidden flag on rescan. Since Hidden is user-managed (via the admin update endpoint), rescans will unintentionally unhide items. Preserve existing.Hidden (and any other user-editable flags) when updating scanned metadata.

Copilot uses AI. Check for mistakes.
}

// UpdateMediaItem 更新媒体条目(仅更新可编辑字段)
func UpdateMediaItem(item *model.MediaItem) error {
return db.Save(item).Error
}

// DeleteMediaItem 硬删除媒体条目(真正从数据库删除)
func DeleteMediaItem(id uint) error {
return db.Unscoped().Delete(&model.MediaItem{}, id).Error
}

// ClearMediaItems 硬删除指定类型的所有媒体条目(真正从数据库删除)
func ClearMediaItems(mediaType model.MediaType) error {
return db.Unscoped().Where("media_type = ?", mediaType).Delete(&model.MediaItem{}).Error
}

// ListAlbums 列出所有专辑(音乐专用)
func ListAlbums(q MediaItemQuery) ([]AlbumInfo, int64, error) {
type albumRow struct {
AlbumName string
AlbumArtist string
Cover string
ReleaseDate string
TrackCount int
}

// 构建基础查询
baseQuery := db.Model(&model.MediaItem{}).
Where("media_type = ?", model.MediaTypeMusic)
if q.Hidden != nil {
baseQuery = baseQuery.Where("hidden = ?", *q.Hidden)
}
if q.Keyword != "" {
like := "%" + q.Keyword + "%"
baseQuery = baseQuery.Where("album_name LIKE ? OR album_artist LIKE ?", like, like)
}

// 统计分组数(用子查询)
var total int64
if err := db.Table("(?) as sub", baseQuery.
Select("album_name, album_artist").
Group("album_name, album_artist")).
Count(&total).Error; err != nil {
return nil, 0, err
}

if q.PageSize <= 0 {
q.PageSize = 20
}
if q.Page <= 0 {
q.Page = 1
}

tx := baseQuery.
Select("album_name, album_artist, MAX(cover) as cover, MAX(release_date) as release_date, COUNT(*) as track_count").
Group("album_name, album_artist").
Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize)

var rows []albumRow
if err := tx.Scan(&rows).Error; err != nil {
return nil, 0, err
}

albums := make([]AlbumInfo, len(rows))
for i, r := range rows {
albums[i] = AlbumInfo{
AlbumName: r.AlbumName,
AlbumArtist: r.AlbumArtist,
Cover: r.Cover,
ReleaseDate: r.ReleaseDate,
TrackCount: r.TrackCount,
}
}
return albums, total, nil
}

// AlbumInfo 专辑信息
type AlbumInfo struct {
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
Cover string `json:"cover"`
ReleaseDate string `json:"release_date"`
TrackCount int `json:"track_count"`
}

// GetAlbumTracks 获取专辑曲目列表
func GetAlbumTracks(albumName, albumArtist string) ([]model.MediaItem, error) {
var items []model.MediaItem
tx := db.Where("media_type = ?", model.MediaTypeMusic)
if albumName != "" {
tx = tx.Where("album_name = ?", albumName)
} else {
// album_name 为空时,查询该艺术家下所有无专辑名的曲目
tx = tx.Where("(album_name = '' OR album_name IS NULL)")
}
if albumArtist != "" {
tx = tx.Where("album_artist = ?", albumArtist)
}
err := tx.Order("track_number asc").Find(&items).Error
return items, err
}

// ListFolderPaths 列出指定媒体类型下的所有文件夹路径(目录浏览模式)
func ListFolderPaths(mediaType model.MediaType) ([]string, error) {
var paths []string
err := db.Model(&model.MediaItem{}).
Where("media_type = ?", mediaType).
Distinct("folder_path").
Pluck("folder_path", &paths).Error
return paths, err
}

// GetUnscrappedItems 获取未刮削或刮削不完整的媒体条目
// 只要 scraped_at 为空,或 cover/scraped_name/description 任一为空,就需要重新刮削
func GetUnscrappedItems(mediaType model.MediaType, limit int) ([]model.MediaItem, error) {
var items []model.MediaItem
err := db.Where(
"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)",
mediaType,
).
Limit(limit).
Find(&items).Error
Comment on lines +270 to +279
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetUnscrappedItems treats description being empty as a signal that an item needs scraping, but the current scrapers populate Plot (and not Description) for TMDB/Discogs/Douban. This will cause already-scraped items to be repeatedly selected for scraping. Align the completeness criteria with the fields actually populated (e.g., check plot instead of description, or remove description from the query).

Copilot uses AI. Check for mistakes.
return items, err
}
Comment on lines +270 to +281
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function name GetUnscrappedItems uses a misspelling (“scrapped” vs “scraped”). Since this is a new API, renaming to GetUnscrapedItems would avoid propagating the typo into other packages and public-facing logs/messages.

Copilot uses AI. Check for mistakes.
Loading