support multiple roots per library

This commit is contained in:
ShukeBta
2026-06-26 22:18:26 +08:00
parent bdc3a169f7
commit 5ef831f830
25 changed files with 1382 additions and 171 deletions
+108
View File
@@ -1,6 +1,9 @@
package database package database
import ( import (
"path/filepath"
"strings"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/model"
@@ -20,6 +23,9 @@ func AutoMigrate(db *gorm.DB) error {
if err := ensurePerformanceIndexes(db); err != nil { if err := ensurePerformanceIndexes(db); err != nil {
return err return err
} }
if err := ensureLibraryRootsCompatibility(db); err != nil {
return err
}
if isSQLite(db) { if isSQLite(db) {
return ensureMediaSearchIndex(db) return ensureMediaSearchIndex(db)
} }
@@ -52,6 +58,7 @@ func ensurePerformanceIndexes(db *gorm.DB) error {
statements := []string{ statements := []string{
`CREATE INDEX IF NOT EXISTS idx_media_library_created_active ON media(library_id, created_at DESC) WHERE deleted_at IS NULL`, `CREATE INDEX IF NOT EXISTS idx_media_library_created_active ON media(library_id, created_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_episode_active ON media(library_id, season_num, episode_num, created_at DESC) WHERE deleted_at IS NULL`, `CREATE INDEX IF NOT EXISTS idx_media_library_episode_active ON media(library_id, season_num, episode_num, created_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_root_active ON media(library_id, library_root_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_series_active ON media(series_id, season_num, episode_num) WHERE deleted_at IS NULL`, `CREATE INDEX IF NOT EXISTS idx_media_series_active ON media(series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_favorites_user_media_active ON favorites(user_id, media_id) WHERE deleted_at IS NULL`, `CREATE INDEX IF NOT EXISTS idx_favorites_user_media_active ON favorites(user_id, media_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_playback_histories_user_media_active ON playback_histories(user_id, media_id, watched_at DESC) WHERE deleted_at IS NULL`, `CREATE INDEX IF NOT EXISTS idx_playback_histories_user_media_active ON playback_histories(user_id, media_id, watched_at DESC) WHERE deleted_at IS NULL`,
@@ -77,6 +84,107 @@ func ensurePerformanceIndexes(db *gorm.DB) error {
return nil return nil
} }
func ensureLibraryRootsCompatibility(db *gorm.DB) error {
var libraries []model.Library
if err := db.Find(&libraries).Error; err != nil {
return err
}
for _, lib := range libraries {
if strings.TrimSpace(lib.Path) == "" {
continue
}
var count int64
if err := db.Model(&model.LibraryRoot{}).Where("library_id = ?", lib.ID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
continue
}
root := model.LibraryRoot{
LibraryID: lib.ID,
Name: firstLibraryRootLabel(lib.Path),
Path: lib.Path,
Enabled: lib.Enabled,
SortOrder: 0,
}
if err := db.Create(&root).Error; err != nil {
return err
}
if err := backfillLibraryRootMedia(db, lib, root); err != nil {
return err
}
}
return nil
}
func backfillLibraryRootMedia(db *gorm.DB, lib model.Library, root model.LibraryRoot) error {
var rows []model.Media
if err := db.Unscoped().
Model(&model.Media{}).
Select("id", "path").
Where("library_id = ? AND (library_root_id = '' OR library_root_id IS NULL)", lib.ID).
Find(&rows).Error; err != nil {
return err
}
rootPath := strings.TrimSpace(root.Path)
for _, row := range rows {
rel, ok := relativePathWithinRoot(row.Path, rootPath)
if !ok {
continue
}
if err := db.Unscoped().Model(&model.Media{}).Where("id = ?", row.ID).Updates(map[string]any{
"library_root_id": root.ID,
"relative_path": rel,
}).Error; err != nil {
return err
}
}
return nil
}
func relativePathWithinRoot(pathValue, root string) (string, bool) {
pathValue = strings.TrimSpace(pathValue)
root = strings.TrimSpace(root)
if pathValue == "" || root == "" {
return "", false
}
if strings.HasPrefix(strings.ToLower(root), "cloud://") || strings.HasPrefix(strings.ToLower(pathValue), "cloud://") {
prefix := strings.TrimRight(root, "/") + "/"
if strings.EqualFold(pathValue, root) {
return "", true
}
if strings.HasPrefix(strings.ToLower(pathValue), strings.ToLower(prefix)) {
return strings.TrimPrefix(pathValue, prefix), true
}
return "", false
}
cleanPath := filepath.Clean(pathValue)
cleanRoot := filepath.Clean(root)
rel, err := filepath.Rel(cleanRoot, cleanPath)
if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
return "", false
}
return rel, true
}
func firstLibraryRootLabel(pathValue string) string {
pathValue = strings.TrimSpace(pathValue)
if pathValue == "" {
return ""
}
if strings.HasPrefix(strings.ToLower(pathValue), "cloud://") {
parts := strings.Split(strings.Trim(pathValue, "/"), "/")
if len(parts) > 0 {
return parts[len(parts)-1]
}
}
base := filepath.Base(filepath.Clean(pathValue))
if base == "." || base == string(filepath.Separator) {
return pathValue
}
return base
}
// mediaSearchIndexSchemaVersion identifies the physical FTS index layout. // mediaSearchIndexSchemaVersion identifies the physical FTS index layout.
// v2 aligns FTS rowids with media rowids and keeps the index current with // v2 aligns FTS rowids with media rowids and keeps the index current with
// triggers. // triggers.
+109 -4
View File
@@ -15,9 +15,11 @@ import (
) )
type createLibraryReq struct { type createLibraryReq struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
Path string `json:"path" binding:"required"` Path string `json:"path"`
Type string `json:"type"` Paths []string `json:"paths"`
Roots []service.LibraryRootInput `json:"roots"`
Type string `json:"type"`
} }
func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
@@ -54,7 +56,16 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
l, err := svc.Media.CreateLibrary(c.Request.Context(), req.Name, req.Path, req.Type) roots := req.Roots
if len(roots) == 0 {
for _, path := range req.Paths {
roots = append(roots, service.LibraryRootInput{Path: path})
}
}
if len(roots) == 0 && strings.TrimSpace(req.Path) != "" {
roots = append(roots, service.LibraryRootInput{Path: req.Path})
}
l, err := svc.Media.CreateLibraryWithRoots(c.Request.Context(), req.Name, req.Type, roots)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -67,6 +78,66 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
} }
} }
func listLibraryRootsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
roots, err := svc.Media.ListLibraryRoots(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, roots)
}
}
func createLibraryRootHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req service.LibraryRootInput
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
root, err := svc.Media.AddLibraryRoot(c.Request.Context(), c.Param("id"), req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
go func() { _ = svc.Watcher.Refresh(context.Background()) }()
c.JSON(http.StatusCreated, root)
}
}
func updateLibraryRootHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req service.LibraryRootInput
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
root, err := svc.Media.UpdateLibraryRoot(c.Request.Context(), c.Param("id"), c.Param("root_id"), req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if root == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "library root not found"})
return
}
go func() { _ = svc.Watcher.Refresh(context.Background()) }()
c.JSON(http.StatusOK, root)
}
}
func deleteLibraryRootHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Media.DeleteLibraryRoot(c.Request.Context(), c.Param("id"), c.Param("root_id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
go func() { _ = svc.Watcher.Refresh(context.Background()) }()
c.Status(http.StatusNoContent)
}
}
func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc { func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
@@ -155,6 +226,40 @@ func scanLibraryHandler(svc *service.Container) gin.HandlerFunc {
} }
} }
func scanLibraryRootHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
rootID := c.Param("root_id")
finishScan, ok := svc.Scan.TryBeginLocalScan(id + ":" + rootID)
if !ok {
c.JSON(http.StatusAccepted, gin.H{
"library_id": id,
"queued": true,
"already_running": true,
"message": "该路径正在后台扫描,请在任务面板查看进度",
"estimate_message": "页面关闭不会中断扫描",
})
return
}
task := startScanHTTPTask(svc, "手动扫描媒体库路径", id, rootID)
go func(libraryID, libraryRootID string, task *service.TaskHandle, finish func()) {
defer finish()
res, err := svc.Scan.ScanLibraryRoot(context.Background(), libraryID, libraryRootID)
if err != nil {
finishHTTPTask(task, err, "scan", "手动扫描路径失败", scanTaskMetrics(res), scanTaskDetails(res, 20))
return
}
finishHTTPTask(task, nil, "completed", "手动扫描路径结束", scanTaskMetrics(res), scanTaskDetails(res, 20))
}(id, rootID, task, finishScan)
c.JSON(http.StatusAccepted, gin.H{
"library_id": id,
"queued": true,
"message": "媒体库路径扫描已在后台运行,页面关闭不会中断",
"estimate_message": "可在右上角任务面板查看扫描进度",
})
}
}
func startScanHTTPTask(svc *service.Container, name, libraryName, path string) *service.TaskHandle { func startScanHTTPTask(svc *service.Container, name, libraryName, path string) *service.TaskHandle {
if svc == nil || svc.Tasks == nil { if svc == nil || svc.Tasks == nil {
return nil return nil
@@ -24,6 +24,11 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
authed.GET("/libraries", listLibrariesHandler(svc)) authed.GET("/libraries", listLibrariesHandler(svc))
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc)) authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc)) authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
authed.GET("/libraries/:id/roots", middleware.AdminRequired(), listLibraryRootsHandler(svc))
authed.POST("/libraries/:id/roots", middleware.AdminRequired(), createLibraryRootHandler(svc))
authed.PATCH("/libraries/:id/roots/:root_id", middleware.AdminRequired(), updateLibraryRootHandler(svc))
authed.DELETE("/libraries/:id/roots/:root_id", middleware.AdminRequired(), deleteLibraryRootHandler(svc))
authed.POST("/libraries/:id/roots/:root_id/scan", middleware.AdminRequired(), scanLibraryRootHandler(svc))
authed.POST("/libraries/:id/scan", middleware.AdminRequired(), scanLibraryHandler(svc)) authed.POST("/libraries/:id/scan", middleware.AdminRequired(), scanLibraryHandler(svc))
authed.POST("/libraries/:id/scrape", middleware.AdminRequired(), scrapeLibraryHandler(svc)) authed.POST("/libraries/:id/scrape", middleware.AdminRequired(), scrapeLibraryHandler(svc))
+48 -34
View File
@@ -59,47 +59,60 @@ type User struct {
RealtimeDeviceCount int `gorm:"-" json:"realtime_device_count,omitempty"` RealtimeDeviceCount int `gorm:"-" json:"realtime_device_count,omitempty"`
} }
// Library 表示用户定义的媒体根目录。 // Library 表示一个逻辑媒体库。Path 保留为兼容字段,指向第一条 LibraryRoot。
type Library struct { type Library struct {
Base Base
Name string `gorm:"size:128;not null" json:"name"` Name string `gorm:"size:128;not null" json:"name"`
Path string `gorm:"size:1024;not null" json:"path"` Path string `gorm:"size:1024;not null" json:"path"`
Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music
Enabled bool `gorm:"default:true" json:"enabled"` Enabled bool `gorm:"default:true" json:"enabled"`
Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"`
}
// LibraryRoot 是逻辑媒体库下的一条真实物理/挂载路径。
type LibraryRoot struct {
Base
LibraryID string `gorm:"index;size:36;not null" json:"library_id"`
Name string `gorm:"size:128" json:"name,omitempty"`
Path string `gorm:"size:1024;not null" json:"path"`
Enabled bool `gorm:"default:true" json:"enabled"`
SortOrder int `gorm:"default:0" json:"sort_order"`
} }
// Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。 // Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。
type Media struct { type Media struct {
Base Base
LibraryID string `gorm:"index;size:36" json:"library_id"` LibraryID string `gorm:"index;size:36" json:"library_id"`
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"` LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
Title string `gorm:"size:255;not null" json:"title"` SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
OriginalName string `gorm:"size:255" json:"original_name,omitempty"` Title string `gorm:"size:255;not null" json:"title"`
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"` OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"` EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
SizeBytes int64 `json:"size_bytes"` Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"`
DurationSec int `json:"duration_sec"` RelativePath string `gorm:"size:1024" json:"relative_path,omitempty"`
Width int `json:"width"` SizeBytes int64 `json:"size_bytes"`
Height int `json:"height"` DurationSec int `json:"duration_sec"`
VideoCodec string `gorm:"size:32" json:"video_codec,omitempty"` Width int `json:"width"`
AudioCodec string `gorm:"size:32" json:"audio_codec,omitempty"` Height int `json:"height"`
Container string `gorm:"size:128" json:"container,omitempty"` VideoCodec string `gorm:"size:32" json:"video_codec,omitempty"`
PosterURL string `gorm:"size:1024" json:"poster_url,omitempty"` AudioCodec string `gorm:"size:32" json:"audio_codec,omitempty"`
BackdropURL string `gorm:"size:1024" json:"backdrop_url,omitempty"` Container string `gorm:"size:128" json:"container,omitempty"`
Overview string `gorm:"type:text" json:"overview,omitempty"` PosterURL string `gorm:"size:1024" json:"poster_url,omitempty"`
Rating float32 `json:"rating"` BackdropURL string `gorm:"size:1024" json:"backdrop_url,omitempty"`
Year int `json:"year"` Overview string `gorm:"type:text" json:"overview,omitempty"`
SeasonNum int `json:"season_num"` Rating float32 `json:"rating"`
EpisodeNum int `json:"episode_num"` Year int `json:"year"`
ScrapeStatus string `gorm:"size:16;default:pending" json:"scrape_status"` SeasonNum int `json:"season_num"`
TMDbID int `json:"tmdb_id"` EpisodeNum int `json:"episode_num"`
BangumiID int `json:"bangumi_id"` ScrapeStatus string `gorm:"size:16;default:pending" json:"scrape_status"`
DoubanID string `gorm:"column:douban_id;size:32" json:"douban_id,omitempty"` TMDbID int `json:"tmdb_id"`
TheTVDBID string `gorm:"column:thetvdb_id;size:64" json:"thetvdb_id,omitempty"` BangumiID int `json:"bangumi_id"`
Languages string `gorm:"size:64" json:"languages,omitempty"` // 逗号分隔的 ISO 639-1 代码,如 "zh,en" DoubanID string `gorm:"column:douban_id;size:32" json:"douban_id,omitempty"`
Countries string `gorm:"size:128" json:"countries,omitempty"` // 逗号分隔的 ISO 3166-1,如 "CN,US" TheTVDBID string `gorm:"column:thetvdb_id;size:64" json:"thetvdb_id,omitempty"`
Genres string `gorm:"type:text" json:"genres,omitempty"` // 逗号分隔的类型名,如 "Action,Animation" Languages string `gorm:"size:64" json:"languages,omitempty"` // 逗号分隔的 ISO 639-1 代码,如 "zh,en"
NSFW bool `gorm:"default:false" json:"nsfw"` Countries string `gorm:"size:128" json:"countries,omitempty"` // 逗号分隔的 ISO 3166-1,如 "CN,US"
Genres string `gorm:"type:text" json:"genres,omitempty"` // 逗号分隔的类型名,如 "Action,Animation"
NSFW bool `gorm:"default:false" json:"nsfw"`
// STRMURL is the indirection target for .strm files: when present the // STRMURL is the indirection target for .strm files: when present the
// stream handler redirects to it instead of opening the local file. // stream handler redirects to it instead of opening the local file.
@@ -352,6 +365,7 @@ func AllModels() []interface{} {
return []interface{}{ return []interface{}{
&User{}, &User{},
&Library{}, &Library{},
&LibraryRoot{},
&Series{}, &Series{},
&Media{}, &Media{},
&PlaybackHistory{}, &PlaybackHistory{},
+96 -2
View File
@@ -3,6 +3,7 @@ package repository
import ( import (
"context" "context"
"errors" "errors"
"strings"
"gorm.io/gorm" "gorm.io/gorm"
@@ -17,17 +18,51 @@ func (r *LibraryRepository) Create(ctx context.Context, l *model.Library) error
return r.db.WithContext(ctx).Create(l).Error return r.db.WithContext(ctx).Create(l).Error
} }
func (r *LibraryRepository) CreateWithRoots(ctx context.Context, l *model.Library, roots []model.LibraryRoot) error {
if !r.hasLibraryRootsTable() {
return r.Create(ctx, l)
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(l).Error; err != nil {
return err
}
for i := range roots {
roots[i].LibraryID = l.ID
if roots[i].SortOrder == 0 {
roots[i].SortOrder = i
}
if err := tx.Create(&roots[i]).Error; err != nil {
return err
}
}
l.Roots = roots
return nil
})
}
// List returns all enabled+disabled libraries. // List returns all enabled+disabled libraries.
func (r *LibraryRepository) List(ctx context.Context) ([]model.Library, error) { func (r *LibraryRepository) List(ctx context.Context) ([]model.Library, error) {
var ls []model.Library var ls []model.Library
err := r.db.WithContext(ctx).Order("created_at asc").Find(&ls).Error q := r.db.WithContext(ctx).Order("created_at asc")
if r.hasLibraryRootsTable() {
q = q.Preload("Roots", func(db *gorm.DB) *gorm.DB {
return db.Order("sort_order asc, created_at asc")
})
}
err := q.Find(&ls).Error
return ls, err return ls, err
} }
// FindByID returns the library, or (nil, nil) when missing. // FindByID returns the library, or (nil, nil) when missing.
func (r *LibraryRepository) FindByID(ctx context.Context, id string) (*model.Library, error) { func (r *LibraryRepository) FindByID(ctx context.Context, id string) (*model.Library, error) {
var l model.Library var l model.Library
err := r.db.WithContext(ctx).Where("id = ?", id).First(&l).Error q := r.db.WithContext(ctx).Where("id = ?", id)
if r.hasLibraryRootsTable() {
q = q.Preload("Roots", func(db *gorm.DB) *gorm.DB {
return db.Order("sort_order asc, created_at asc")
})
}
err := q.First(&l).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil return nil, nil
} }
@@ -42,3 +77,62 @@ func (r *LibraryRepository) FindByID(ctx context.Context, id string) (*model.Lib
func (r *LibraryRepository) Delete(ctx context.Context, id string) error { func (r *LibraryRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&model.Library{}, "id = ?", id).Error return r.db.WithContext(ctx).Delete(&model.Library{}, "id = ?", id).Error
} }
func (r *LibraryRepository) ListRoots(ctx context.Context, libraryID string) ([]model.LibraryRoot, error) {
if !r.hasLibraryRootsTable() {
return nil, nil
}
var roots []model.LibraryRoot
err := r.db.WithContext(ctx).
Where("library_id = ?", libraryID).
Order("sort_order asc, created_at asc").
Find(&roots).Error
return roots, err
}
func (r *LibraryRepository) FindRootByID(ctx context.Context, libraryID, rootID string) (*model.LibraryRoot, error) {
if !r.hasLibraryRootsTable() {
return nil, nil
}
var root model.LibraryRoot
q := r.db.WithContext(ctx).Where("id = ?", rootID)
if strings.TrimSpace(libraryID) != "" {
q = q.Where("library_id = ?", libraryID)
}
err := q.First(&root).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &root, nil
}
func (r *LibraryRepository) CreateRoot(ctx context.Context, root *model.LibraryRoot) error {
if !r.hasLibraryRootsTable() {
return nil
}
return r.db.WithContext(ctx).Create(root).Error
}
func (r *LibraryRepository) UpdateRoot(ctx context.Context, root *model.LibraryRoot, updates map[string]any) error {
if !r.hasLibraryRootsTable() {
return nil
}
if root == nil || strings.TrimSpace(root.ID) == "" || len(updates) == 0 {
return nil
}
return r.db.WithContext(ctx).Model(&model.LibraryRoot{}).Where("id = ?", root.ID).Updates(updates).Error
}
func (r *LibraryRepository) DeleteRoot(ctx context.Context, libraryID, rootID string) error {
if !r.hasLibraryRootsTable() {
return nil
}
return r.db.WithContext(ctx).Where("library_id = ?", libraryID).Delete(&model.LibraryRoot{}, "id = ?", rootID).Error
}
func (r *LibraryRepository) hasLibraryRootsTable() bool {
return r != nil && r.db != nil && r.db.Migrator().HasTable(&model.LibraryRoot{})
}
+12
View File
@@ -223,6 +223,12 @@ func addMediaPlacementUpdates(updates map[string]any, existing, incoming model.M
updates["library_id"] = incoming.LibraryID updates["library_id"] = incoming.LibraryID
} }
} }
if incoming.LibraryRootID != "" && incoming.LibraryRootID != existing.LibraryRootID {
updates["library_root_id"] = incoming.LibraryRootID
}
if incoming.RelativePath != "" && incoming.RelativePath != existing.RelativePath {
updates["relative_path"] = incoming.RelativePath
}
seasonChanged := (incoming.SeasonNum > 0 || incoming.EpisodeNum > 0) && existing.SeasonNum != incoming.SeasonNum seasonChanged := (incoming.SeasonNum > 0 || incoming.EpisodeNum > 0) && existing.SeasonNum != incoming.SeasonNum
episodeChanged := incoming.EpisodeNum > 0 && existing.EpisodeNum != incoming.EpisodeNum episodeChanged := incoming.EpisodeNum > 0 && existing.EpisodeNum != incoming.EpisodeNum
if seasonChanged { if seasonChanged {
@@ -355,6 +361,12 @@ func (r *MediaRepository) DeleteByLibrary(ctx context.Context, libraryID string)
return r.db.WithContext(ctx).Where("library_id = ?", libraryID).Delete(&model.Media{}).Error return r.db.WithContext(ctx).Where("library_id = ?", libraryID).Delete(&model.Media{}).Error
} }
func (r *MediaRepository) DeleteByLibraryRoot(ctx context.Context, libraryID, rootID string) error {
return r.db.WithContext(ctx).
Where("library_id = ? AND library_root_id = ?", libraryID, rootID).
Delete(&model.Media{}).Error
}
// PurgeByLibrary permanently removes media tied to a library. Used for virtual // PurgeByLibrary permanently removes media tied to a library. Used for virtual
// cloud mounts where "remove mount" must not populate the recycle bin. // cloud mounts where "remove mount" must not populate the recycle bin.
func (r *MediaRepository) PurgeByLibrary(ctx context.Context, libraryID string) error { func (r *MediaRepository) PurgeByLibrary(ctx context.Context, libraryID string) error {
+2 -2
View File
@@ -87,7 +87,7 @@ func TestTerminalDeviceLimitDeduplicatesAppsOnSameDevice(t *testing.T) {
dev.RecordLogin(ctx, u.ID, login.id, login.name, login.client, "1.2.3.4") dev.RecordLogin(ctx, u.ID, login.id, login.name, login.client, "1.2.3.4")
now = now.Add(time.Second) now = now.Add(time.Second)
} }
count, err := repos.UserDevice.CountActiveClients(ctx, u.ID, time.Now().Add(-24*time.Hour)) count, err := repos.UserDevice.CountActiveClients(ctx, u.ID, now.Add(-24*time.Hour))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -118,7 +118,7 @@ func TestTerminalDeviceLimitDeduplicatesAppsOnSameDevice(t *testing.T) {
dev.RecordLogin(ctx, u.ID, "tablet", "iPad", "Infuse", "1.2.3.4") dev.RecordLogin(ctx, u.ID, "tablet", "iPad", "Infuse", "1.2.3.4")
dev.RecordLogin(ctx, u.ID, "pc", "Windows PC", "Browser", "1.2.3.4") dev.RecordLogin(ctx, u.ID, "pc", "Windows PC", "Browser", "1.2.3.4")
count, err = repos.UserDevice.CountActiveClients(ctx, u.ID, time.Now().Add(-24*time.Hour)) count, err = repos.UserDevice.CountActiveClients(ctx, u.ID, now.Add(-24*time.Hour))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+202 -6
View File
@@ -4,6 +4,9 @@ package service
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"path/filepath"
"strings"
"time" "time"
"go.uber.org/zap" "go.uber.org/zap"
@@ -31,6 +34,14 @@ type MediaVisibility struct {
const maxMediaSearchLimit = 50000 const maxMediaSearchLimit = 50000
const maxMediaSearchPageSize = 2000 const maxMediaSearchPageSize = 2000
type LibraryRootInput struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Path string `json:"path"`
Enabled *bool `json:"enabled,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
}
func (v MediaVisibility) Allows(media *model.Media) bool { func (v MediaVisibility) Allows(media *model.Media) bool {
if media == nil { if media == nil {
return false return false
@@ -68,27 +79,210 @@ func (s *MediaService) SetRuntimeCache(cache *RuntimeCacheService) *MediaService
// CreateLibrary persists a library after validating that its path exists. // CreateLibrary persists a library after validating that its path exists.
func (s *MediaService) CreateLibrary(ctx context.Context, name, path, kind string) (*model.Library, error) { func (s *MediaService) CreateLibrary(ctx context.Context, name, path, kind string) (*model.Library, error) {
if name == "" || path == "" { return s.CreateLibraryWithRoots(ctx, name, kind, []LibraryRootInput{{Path: path}})
return nil, errors.New("name and path required") }
func (s *MediaService) CreateLibraryWithRoots(ctx context.Context, name, kind string, inputs []LibraryRootInput) (*model.Library, error) {
if strings.TrimSpace(name) == "" {
return nil, errors.New("name required")
} }
abs, err := resolveAccessibleLibraryPath(path) roots, err := normalizeLibraryRootInputs(inputs, true)
if err != nil { if err != nil {
return nil, err return nil, err
} }
kind = inferLibraryKind(name, abs, kind) kind = inferLibraryKind(name, roots[0].Path, kind)
lib := &model.Library{Name: name, Path: abs, Type: kind, Enabled: true} lib := &model.Library{Name: strings.TrimSpace(name), Path: roots[0].Path, Type: kind, Enabled: true}
if err := s.repo.Library.Create(ctx, lib); err != nil { if err := s.repo.Library.CreateWithRoots(ctx, lib, roots); err != nil {
return nil, err return nil, err
} }
s.invalidateMediaCache(ctx) s.invalidateMediaCache(ctx)
return lib, nil return lib, nil
} }
func normalizeLibraryRootInputs(inputs []LibraryRootInput, requirePath bool) ([]model.LibraryRoot, error) {
roots := make([]model.LibraryRoot, 0, len(inputs))
seen := map[string]struct{}{}
for i, input := range inputs {
rawPath := strings.TrimSpace(input.Path)
if rawPath == "" {
if requirePath {
return nil, errors.New("at least one path required")
}
continue
}
abs, err := resolveAccessibleLibraryPath(rawPath)
if err != nil {
return nil, err
}
key := strings.ToLower(filepath.Clean(abs))
if _, ok := seen[key]; ok {
return nil, fmt.Errorf("duplicate library path: %s", abs)
}
seen[key] = struct{}{}
enabled := true
if input.Enabled != nil {
enabled = *input.Enabled
}
roots = append(roots, model.LibraryRoot{
Name: strings.TrimSpace(input.Name),
Path: abs,
Enabled: enabled,
})
if input.SortOrder != nil {
roots[len(roots)-1].SortOrder = *input.SortOrder
} else {
roots[len(roots)-1].SortOrder = i
}
}
if len(roots) == 0 && requirePath {
return nil, errors.New("at least one path required")
}
return roots, nil
}
// ListLibraries returns every library configured on the server. // ListLibraries returns every library configured on the server.
func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, error) { func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, error) {
return s.repo.Library.List(ctx) return s.repo.Library.List(ctx)
} }
func (s *MediaService) ListLibraryRoots(ctx context.Context, libraryID string) ([]model.LibraryRoot, error) {
if err := s.ensureLibraryRoots(ctx, libraryID); err != nil {
return nil, err
}
return s.repo.Library.ListRoots(ctx, libraryID)
}
func (s *MediaService) AddLibraryRoot(ctx context.Context, libraryID string, input LibraryRootInput) (*model.LibraryRoot, error) {
lib, err := s.repo.Library.FindByID(ctx, libraryID)
if err != nil {
return nil, err
}
if lib == nil {
return nil, errors.New("library not found")
}
roots, err := normalizeLibraryRootInputs([]LibraryRootInput{input}, true)
if err != nil {
return nil, err
}
root := roots[0]
root.LibraryID = libraryID
if err := s.ensureLibraryRootPathUnique(ctx, libraryID, "", root.Path); err != nil {
return nil, err
}
if root.SortOrder == 0 {
existing, _ := s.repo.Library.ListRoots(ctx, libraryID)
root.SortOrder = len(existing)
}
if err := s.repo.Library.CreateRoot(ctx, &root); err != nil {
return nil, err
}
if strings.TrimSpace(lib.Path) == "" {
_ = s.repo.DB.WithContext(ctx).Model(&model.Library{}).Where("id = ?", libraryID).Update("path", root.Path).Error
}
return &root, nil
}
func (s *MediaService) UpdateLibraryRoot(ctx context.Context, libraryID, rootID string, input LibraryRootInput) (*model.LibraryRoot, error) {
root, err := s.repo.Library.FindRootByID(ctx, libraryID, rootID)
if err != nil || root == nil {
return root, err
}
updates := map[string]any{}
if input.Name != "" {
updates["name"] = strings.TrimSpace(input.Name)
}
if strings.TrimSpace(input.Path) != "" {
roots, err := normalizeLibraryRootInputs([]LibraryRootInput{input}, true)
if err != nil {
return nil, err
}
if err := s.ensureLibraryRootPathUnique(ctx, libraryID, rootID, roots[0].Path); err != nil {
return nil, err
}
updates["path"] = roots[0].Path
root.Path = roots[0].Path
}
if input.Enabled != nil {
updates["enabled"] = *input.Enabled
root.Enabled = *input.Enabled
}
if input.SortOrder != nil {
updates["sort_order"] = *input.SortOrder
root.SortOrder = *input.SortOrder
}
if err := s.repo.Library.UpdateRoot(ctx, root, updates); err != nil {
return nil, err
}
if err := s.syncLibraryPrimaryRoot(ctx, libraryID); err != nil {
return nil, err
}
return s.repo.Library.FindRootByID(ctx, libraryID, rootID)
}
func (s *MediaService) DeleteLibraryRoot(ctx context.Context, libraryID, rootID string) error {
root, err := s.repo.Library.FindRootByID(ctx, libraryID, rootID)
if err != nil {
return err
}
if root == nil {
return errors.New("library root not found")
}
roots, err := s.repo.Library.ListRoots(ctx, libraryID)
if err != nil {
return err
}
if len(roots) <= 1 {
return errors.New("library must keep at least one path")
}
if err := s.repo.Media.DeleteByLibraryRoot(ctx, libraryID, rootID); err != nil {
return err
}
if err := s.repo.Library.DeleteRoot(ctx, libraryID, rootID); err != nil {
return err
}
return s.syncLibraryPrimaryRoot(ctx, libraryID)
}
func (s *MediaService) ensureLibraryRoots(ctx context.Context, libraryID string) error {
lib, err := s.repo.Library.FindByID(ctx, libraryID)
if err != nil || lib == nil || len(lib.Roots) > 0 || strings.TrimSpace(lib.Path) == "" {
return err
}
root := &model.LibraryRoot{
LibraryID: libraryID,
Name: filepath.Base(filepath.Clean(lib.Path)),
Path: lib.Path,
Enabled: lib.Enabled,
SortOrder: 0,
}
return s.repo.Library.CreateRoot(ctx, root)
}
func (s *MediaService) syncLibraryPrimaryRoot(ctx context.Context, libraryID string) error {
roots, err := s.repo.Library.ListRoots(ctx, libraryID)
if err != nil || len(roots) == 0 {
return err
}
return s.repo.DB.WithContext(ctx).Model(&model.Library{}).Where("id = ?", libraryID).Update("path", roots[0].Path).Error
}
func (s *MediaService) ensureLibraryRootPathUnique(ctx context.Context, libraryID, exceptRootID, pathValue string) error {
roots, err := s.repo.Library.ListRoots(ctx, libraryID)
if err != nil {
return err
}
key := strings.ToLower(filepath.Clean(strings.TrimSpace(pathValue)))
for _, existing := range roots {
if existing.ID == exceptRootID {
continue
}
if strings.ToLower(filepath.Clean(strings.TrimSpace(existing.Path))) == key {
return fmt.Errorf("duplicate library path: %s", pathValue)
}
}
return nil
}
// DeleteLibrary removes a library and its media rows. The on-disk files are // DeleteLibrary removes a library and its media rows. The on-disk files are
// left untouched. // left untouched.
func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error { func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error {
@@ -101,6 +295,7 @@ func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error {
if err := s.repo.Media.PurgeByLibrary(ctx, id); err != nil { if err := s.repo.Media.PurgeByLibrary(ctx, id); err != nil {
return err return err
} }
_ = s.repo.DB.WithContext(ctx).Where("library_id = ?", id).Delete(&model.LibraryRoot{}).Error
err := s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.Library{}).Error err := s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.Library{}).Error
if err == nil { if err == nil {
s.invalidateMediaCache(ctx) s.invalidateMediaCache(ctx)
@@ -108,6 +303,7 @@ func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error {
return err return err
} }
} }
_ = s.repo.DB.WithContext(ctx).Where("library_id = ?", id).Delete(&model.LibraryRoot{}).Error
if err := s.repo.Media.DeleteByLibrary(ctx, id); err != nil { if err := s.repo.Media.DeleteByLibrary(ctx, id); err != nil {
return err return err
} }
@@ -215,6 +215,45 @@ func TestOrganizeDirectoryCreatesMissingCategoryLibraryForVisibility(t *testing.
} }
} }
func TestOrganizeDirectoryCanDisableAutoAddLibrary(t *testing.T) {
root := t.TempDir()
srcRoot := filepath.Join(root, "downloads")
dest := filepath.Join(root, "media")
source := filepath.Join(srcRoot, "Some.Show.S01E01.2026.1080p.mkv")
writeOrgFile(t, source, "episode")
repos := newOrganizerTestRepo(t)
if err := repos.Setting.Set(t.Context(), "organize.auto_add_library", "false"); err != nil {
t.Fatal(err)
}
org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{
SourcePath: srcRoot,
DestPath: dest,
MediaType: "tv",
MediaCategory: "未分类",
TransferMode: TransferCopy,
})
if err != nil {
t.Fatalf("organize missing category with auto-add disabled: %v", err)
}
if res.Organized != 1 || len(res.Items) != 1 {
t.Fatalf("result = %+v, want one organized item", res)
}
want := filepath.Join(dest, "电视剧", "未分类", "Some Show", "Season 01", "Some Show - S01E01.mkv")
if _, err := os.Stat(want); err != nil {
t.Fatalf("expected organized file at %q: %v", want, err)
}
var count int64
if err := repos.DB.Model(&model.Library{}).Where("path = ?", filepath.Join(dest, "电视剧", "未分类")).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("auto-add disabled should not create library, got %d rows", count)
}
}
func TestOrganizeDirectorySmartClassifiesUncategorizedSources(t *testing.T) { func TestOrganizeDirectorySmartClassifiesUncategorizedSources(t *testing.T) {
root := t.TempDir() root := t.TempDir()
src := filepath.Join(root, "downloads") src := filepath.Join(root, "downloads")
@@ -218,6 +218,15 @@ func (o *OrganizerService) ensureOrganizeLibraryForRoot(ctx context.Context, roo
if strings.TrimSpace(category) == "" && hasContainingLibrary { if strings.TrimSpace(category) == "" && hasContainingLibrary {
return containingLibrary, true return containingLibrary, true
} }
if !o.autoAddLibraryEnabled(ctx) {
if o.log != nil {
o.log.Debug("organize skipped missing library auto-create",
zap.String("path", root),
zap.String("media_type", mediaType),
zap.String("category", category))
}
return model.Library{}, false
}
name := strings.TrimSpace(category) name := strings.TrimSpace(category)
if name == "" { if name == "" {
name = filepath.Base(root) name = filepath.Base(root)
@@ -231,7 +240,12 @@ func (o *OrganizerService) ensureOrganizeLibraryForRoot(ctx context.Context, roo
Type: organizeLibraryModelType(mediaType), Type: organizeLibraryModelType(mediaType),
Enabled: true, Enabled: true,
} }
if err := o.repo.Library.Create(ctx, &lib); err != nil { if err := o.repo.Library.CreateWithRoots(ctx, &lib, []model.LibraryRoot{{
Name: name,
Path: root,
Enabled: true,
SortOrder: 0,
}}); err != nil {
if o.log != nil { if o.log != nil {
o.log.Warn("organize auto-create library failed", o.log.Warn("organize auto-create library failed",
zap.String("path", root), zap.String("path", root),
+11
View File
@@ -73,3 +73,14 @@ func (o *OrganizerService) keepSeedingEnabled(ctx context.Context) bool {
} }
return v == "true" || v == "1" || v == "on" return v == "true" || v == "1" || v == "on"
} }
func (o *OrganizerService) autoAddLibraryEnabled(ctx context.Context) bool {
if o == nil || o.repo == nil || o.repo.Setting == nil {
return true
}
v, err := o.repo.Setting.Get(ctx, "organize.auto_add_library")
if err != nil || strings.TrimSpace(v) == "" {
return true
}
return parseBoolSetting(v, true)
}
+30 -28
View File
@@ -263,32 +263,34 @@ type existingCloudMedia struct {
} }
type existingLocalMedia struct { type existingLocalMedia struct {
Title string LibraryRootID string
OriginalName string RelativePath string
EpisodeTitle string Title string
SizeBytes int64 OriginalName string
DurationSec int EpisodeTitle string
Width int SizeBytes int64
Height int DurationSec int
VideoCodec string Width int
AudioCodec string Height int
Container string VideoCodec string
STRMURL string AudioCodec string
FileID string Container string
PosterURL string STRMURL string
BackdropURL string FileID string
Overview string PosterURL string
Year int BackdropURL string
Rating float32 Overview string
TMDbID int Year int
BangumiID int Rating float32
DoubanID string TMDbID int
TheTVDBID string BangumiID int
SeasonNum int DoubanID string
EpisodeNum int TheTVDBID string
Genres string SeasonNum int
Countries string EpisodeNum int
Languages string Genres string
NSFW bool Countries string
ScrapeStatus string Languages string
NSFW bool
ScrapeStatus string
} }
+31 -29
View File
@@ -67,7 +67,7 @@ func (s *ScannerService) existingLocalMediaSnapshot(ctx context.Context, library
var rows []model.Media var rows []model.Media
if err := s.repo.DB.WithContext(ctx). if err := s.repo.DB.WithContext(ctx).
Model(&model.Media{}). Model(&model.Media{}).
Select("path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "strm_url", "file_id", "poster_url", "backdrop_url", "overview", "year", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "genres", "countries", "languages", "nsfw", "scrape_status"). Select("path", "library_root_id", "relative_path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "strm_url", "file_id", "poster_url", "backdrop_url", "overview", "year", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "genres", "countries", "languages", "nsfw", "scrape_status").
Where("library_id = ? AND path NOT LIKE ?", libraryID, "cloud://%"). Where("library_id = ? AND path NOT LIKE ?", libraryID, "cloud://%").
Find(&rows).Error; err != nil { Find(&rows).Error; err != nil {
return nil, err return nil, err
@@ -78,34 +78,36 @@ func (s *ScannerService) existingLocalMediaSnapshot(ctx context.Context, library
continue continue
} }
snapshot[filepath.Clean(row.Path)] = existingLocalMedia{ snapshot[filepath.Clean(row.Path)] = existingLocalMedia{
Title: row.Title, LibraryRootID: row.LibraryRootID,
OriginalName: row.OriginalName, RelativePath: row.RelativePath,
EpisodeTitle: row.EpisodeTitle, Title: row.Title,
SizeBytes: row.SizeBytes, OriginalName: row.OriginalName,
DurationSec: row.DurationSec, EpisodeTitle: row.EpisodeTitle,
Width: row.Width, SizeBytes: row.SizeBytes,
Height: row.Height, DurationSec: row.DurationSec,
VideoCodec: row.VideoCodec, Width: row.Width,
AudioCodec: row.AudioCodec, Height: row.Height,
Container: row.Container, VideoCodec: row.VideoCodec,
STRMURL: row.STRMURL, AudioCodec: row.AudioCodec,
FileID: row.FileID, Container: row.Container,
PosterURL: row.PosterURL, STRMURL: row.STRMURL,
BackdropURL: row.BackdropURL, FileID: row.FileID,
Overview: row.Overview, PosterURL: row.PosterURL,
Year: row.Year, BackdropURL: row.BackdropURL,
Rating: row.Rating, Overview: row.Overview,
TMDbID: row.TMDbID, Year: row.Year,
BangumiID: row.BangumiID, Rating: row.Rating,
DoubanID: row.DoubanID, TMDbID: row.TMDbID,
TheTVDBID: row.TheTVDBID, BangumiID: row.BangumiID,
SeasonNum: row.SeasonNum, DoubanID: row.DoubanID,
EpisodeNum: row.EpisodeNum, TheTVDBID: row.TheTVDBID,
Genres: row.Genres, SeasonNum: row.SeasonNum,
Countries: row.Countries, EpisodeNum: row.EpisodeNum,
Languages: row.Languages, Genres: row.Genres,
NSFW: row.NSFW, Countries: row.Countries,
ScrapeStatus: row.ScrapeStatus, Languages: row.Languages,
NSFW: row.NSFW,
ScrapeStatus: row.ScrapeStatus,
} }
} }
return snapshot, nil return snapshot, nil
@@ -0,0 +1,90 @@
package service
import (
"os"
"path/filepath"
"testing"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
func TestScanLibraryScansMultipleRootsAndPrunesPerRoot(t *testing.T) {
rootA := t.TempDir()
rootB := t.TempDir()
fileA := filepath.Join(rootA, "电影A.2024.mkv")
fileB := filepath.Join(rootB, "电影B.2025.mkv")
writeTestFile(t, fileA, "a")
writeTestFile(t, fileB, "b")
db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}, &model.Setting{})
repos := repository.New(db)
lib := &model.Library{Name: "电影", Path: rootA, Type: "movie", Enabled: true}
roots := []model.LibraryRoot{
{Name: "硬盘1", Path: rootA, Enabled: true, SortOrder: 0},
{Name: "硬盘2", Path: rootB, Enabled: true, SortOrder: 1},
}
if err := repos.Library.CreateWithRoots(t.Context(), lib, roots); err != nil {
t.Fatal(err)
}
scanner := NewScannerService(nil, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
res, err := scanner.ScanLibrary(t.Context(), lib.ID)
if err != nil {
t.Fatalf("scan multiple roots: %v", err)
}
if res.Added != 2 {
t.Fatalf("added = %d, want 2", res.Added)
}
var rows []model.Media
if err := db.Order("path asc").Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 2 {
t.Fatalf("media rows = %d, want 2", len(rows))
}
for _, row := range rows {
if row.LibraryRootID == "" {
t.Fatalf("media %q missing library_root_id", row.Path)
}
if row.RelativePath == "" {
t.Fatalf("media %q missing relative_path", row.Path)
}
}
removeTestPath(t, fileA)
removeTestPath(t, rootB)
res, err = scanner.ScanLibrary(t.Context(), lib.ID)
if err != nil {
t.Fatalf("scan with one offline root should continue: %v", err)
}
if res.Removed != 1 {
t.Fatalf("removed = %d, want only vanished file from accessible root removed", res.Removed)
}
rows = nil
if err := db.Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Path != fileB {
t.Fatalf("remaining rows = %#v, want offline root media preserved", rows)
}
}
func writeTestFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func removeTestPath(t *testing.T, path string) {
t.Helper()
if err := os.RemoveAll(path); err != nil {
t.Fatal(err)
}
}
+21 -13
View File
@@ -13,7 +13,7 @@ import (
// ingestFile upserts a single media file. seenInodes dedups hardlinks within a // ingestFile upserts a single media file. seenInodes dedups hardlinks within a
// single scan; pass a fresh map for one-off ingests. It mutates res counters. // single scan; pass a fresh map for one-off ingests. It mutates res counters.
func (s *ScannerService) ingestFile(ctx context.Context, lib *model.Library, path string, size int64, seenInodes map[string]string, existingMedia map[string]existingLocalMedia, writeBatch *localMediaWriteBatch, res *ScanResult) { func (s *ScannerService) ingestFile(ctx context.Context, lib *model.Library, root *model.LibraryRoot, path string, size int64, seenInodes map[string]string, existingMedia map[string]existingLocalMedia, writeBatch *localMediaWriteBatch, res *ScanResult) {
res.Visited++ res.Visited++
ext := strings.ToLower(filepath.Ext(path)) ext := strings.ToLower(filepath.Ext(path))
cleanPath := filepath.Clean(path) cleanPath := filepath.Clean(path)
@@ -24,9 +24,10 @@ func (s *ScannerService) ingestFile(ctx context.Context, lib *model.Library, pat
} }
parsedSeason, parsedEpisode := ParseEpisode(path) parsedSeason, parsedEpisode := ParseEpisode(path)
localMeta := s.readLocalScanMetadata(lib, path, parsedSeason, parsedEpisode) localMeta := s.readLocalScanMetadata(lib, root, path, parsedSeason, parsedEpisode)
media := s.buildLocalScanMedia(localScanMediaInput{ media := s.buildLocalScanMedia(localScanMediaInput{
lib: lib, lib: lib,
root: root,
path: path, path: path,
ext: ext, ext: ext,
fileID: fileID, fileID: fileID,
@@ -87,8 +88,12 @@ func (s *ScannerService) recordLocalFileIdentity(ctx context.Context, path strin
return fileID, false return fileID, false
} }
func (s *ScannerService) readLocalScanMetadata(lib *model.Library, path string, parsedSeason, parsedEpisode int) *LocalMetadata { func (s *ScannerService) readLocalScanMetadata(lib *model.Library, root *model.LibraryRoot, path string, parsedSeason, parsedEpisode int) *LocalMetadata {
localMeta, err := ReadLocalMetadata(path, lib.Path, librarySupportsSeasons(lib) || parsedSeason > 0 || parsedEpisode > 0) rootPath := lib.Path
if root != nil && strings.TrimSpace(root.Path) != "" {
rootPath = root.Path
}
localMeta, err := ReadLocalMetadata(path, rootPath, librarySupportsSeasons(lib) || parsedSeason > 0 || parsedEpisode > 0)
if err != nil { if err != nil {
s.log.Warn("read local metadata failed", zap.String("path", path), zap.Error(err)) s.log.Warn("read local metadata failed", zap.String("path", path), zap.Error(err))
} }
@@ -120,6 +125,7 @@ func (s *ScannerService) localMediaScanState(in localMediaScanStateInput) (bool,
type localScanMediaInput struct { type localScanMediaInput struct {
lib *model.Library lib *model.Library
root *model.LibraryRoot
path string path string
ext string ext string
fileID string fileID string
@@ -136,15 +142,17 @@ func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Medi
} }
media := &model.Media{ media := &model.Media{
LibraryID: in.lib.ID, LibraryID: in.lib.ID,
Title: title, LibraryRootID: libraryRootID(in.root),
Year: year, RelativePath: localRelativePath(in.path, in.root),
Path: in.path, Title: title,
SizeBytes: in.size, Year: year,
Container: strings.TrimPrefix(in.ext, "."), Path: in.path,
FileID: in.fileID, SizeBytes: in.size,
SeasonNum: in.parsedSeason, Container: strings.TrimPrefix(in.ext, "."),
EpisodeNum: in.parsedEpisode, FileID: in.fileID,
SeasonNum: in.parsedSeason,
EpisodeNum: in.parsedEpisode,
} }
if in.ext == ".strm" { if in.ext == ".strm" {
media.Container = "strm" media.Container = "strm"
@@ -194,6 +194,12 @@ func localDerivedMetadataNeedsRefresh(existing existingLocalMedia, incoming *mod
if incoming == nil { if incoming == nil {
return false return false
} }
if incoming.LibraryRootID != "" && incoming.LibraryRootID != existing.LibraryRootID {
return true
}
if incoming.RelativePath != "" && incoming.RelativePath != existing.RelativePath {
return true
}
return scanDerivedMetadataNeedsRefresh(scanDerivedMetadata{ return scanDerivedMetadataNeedsRefresh(scanDerivedMetadata{
Title: existing.Title, Title: existing.Title,
ScrapeStatus: existing.ScrapeStatus, ScrapeStatus: existing.ScrapeStatus,
+46
View File
@@ -58,6 +58,52 @@ func (s *ScannerService) pruneMissingMedia(ctx context.Context, libraryID string
return s.deleteMediaByIDs(ctx, stale, false) return s.deleteMediaByIDs(ctx, stale, false)
} }
func (s *ScannerService) pruneMissingMediaForRoot(ctx context.Context, libraryID, rootID, rootPath string, seen map[string]struct{}) (int64, error) {
var rows []struct {
ID string
Path string
LibraryRootID string
}
q := s.repo.DB.WithContext(ctx).
Model(&model.Media{}).
Select("id, path, library_root_id").
Where("library_id = ? AND path NOT LIKE ?", libraryID, "cloud://%")
if strings.TrimSpace(rootID) != "" {
q = q.Where("library_root_id = ? OR library_root_id = '' OR library_root_id IS NULL", rootID)
}
if err := q.Find(&rows).Error; err != nil {
return 0, err
}
stale := make([]string, 0)
for _, row := range rows {
if row.Path == "" {
continue
}
if row.LibraryRootID == "" && !pathBelongsToRoot(row.Path, rootPath) {
continue
}
if _, ok := seen[filepath.Clean(row.Path)]; ok {
continue
}
if _, err := os.Stat(row.Path); err == nil {
continue
} else if !os.IsNotExist(err) {
continue
}
stale = append(stale, row.ID)
}
return s.deleteMediaByIDs(ctx, stale, false)
}
func pathBelongsToRoot(pathValue, rootPath string) bool {
pathValue = filepath.Clean(strings.TrimSpace(pathValue))
rootPath = filepath.Clean(strings.TrimSpace(rootPath))
if pathValue == "" || rootPath == "" || pathValue == "." || rootPath == "." {
return false
}
return strings.EqualFold(pathValue, rootPath) || pathWithin(pathValue, rootPath)
}
// deleteMediaByIDs removes media rows in fixed-size batches so each write // deleteMediaByIDs removes media rows in fixed-size batches so each write
// transaction stays short and the global write gate is released frequently. // transaction stays short and the global write gate is released frequently.
func (s *ScannerService) deleteMediaByIDs(ctx context.Context, ids []string, hard bool) (int64, error) { func (s *ScannerService) deleteMediaByIDs(ctx context.Context, ids []string, hard bool) (int64, error) {
+122
View File
@@ -0,0 +1,122 @@
package service
import (
"context"
"errors"
"path/filepath"
"strings"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func (s *ScannerService) localLibraryScanRoots(ctx context.Context, lib *model.Library) ([]model.LibraryRoot, error) {
if lib == nil {
return nil, errors.New("library not found")
}
roots := append([]model.LibraryRoot(nil), lib.Roots...)
if len(roots) == 0 && s != nil && s.repo != nil && s.repo.Library != nil {
var err error
roots, err = s.repo.Library.ListRoots(ctx, lib.ID)
if err != nil {
return nil, err
}
}
if len(roots) == 0 && strings.TrimSpace(lib.Path) != "" {
roots = []model.LibraryRoot{{
LibraryID: lib.ID,
Path: lib.Path,
Enabled: lib.Enabled,
}}
}
out := roots[:0]
for _, root := range roots {
if !root.Enabled || strings.TrimSpace(root.Path) == "" {
continue
}
if _, ok := ParseCloudLibraryMount(root.Path); ok {
continue
}
out = append(out, root)
}
return out, nil
}
func (s *ScannerService) localLibraryRootForPath(ctx context.Context, lib *model.Library, pathValue string) (*model.LibraryRoot, error) {
roots, err := s.localLibraryScanRoots(ctx, lib)
if err != nil {
return nil, err
}
cleanPath := filepath.Clean(strings.TrimSpace(pathValue))
for i := range roots {
rootPath := filepath.Clean(strings.TrimSpace(roots[i].Path))
if sameLibraryPath(cleanPath, rootPath) || pathWithin(cleanPath, rootPath) {
return &roots[i], nil
}
}
if len(roots) > 0 {
return &roots[0], nil
}
return nil, errors.New("library has no enabled paths")
}
func (s *ScannerService) resolveLocalLibraryRootPath(ctx context.Context, lib *model.Library, root *model.LibraryRoot) error {
if root == nil || strings.TrimSpace(root.Path) == "" {
return nil
}
resolved, err := resolveAccessibleLibraryPath(root.Path)
if err != nil {
return err
}
if sameLibraryPath(resolved, root.Path) {
root.Path = filepath.Clean(root.Path)
return nil
}
if s.repo != nil && s.repo.DB != nil && strings.TrimSpace(root.ID) != "" {
if updateErr := s.repo.DB.WithContext(ctx).Model(&model.LibraryRoot{}).Where("id = ?", root.ID).Update("path", resolved).Error; updateErr != nil && s.log != nil {
s.log.Warn("update mapped library root path failed",
zap.String("library_id", lib.ID),
zap.String("root_id", root.ID),
zap.String("from", root.Path),
zap.String("to", resolved),
zap.Error(updateErr))
}
}
if lib != nil {
if strings.TrimSpace(root.ID) == "" {
_ = s.repo.DB.WithContext(ctx).Model(&model.Library{}).Where("id = ?", lib.ID).Update("path", resolved).Error
lib.Path = resolved
} else if roots, err := s.repo.Library.ListRoots(ctx, lib.ID); err == nil && len(roots) > 0 && roots[0].ID == root.ID {
_ = s.repo.DB.WithContext(ctx).Model(&model.Library{}).Where("id = ?", lib.ID).Update("path", resolved).Error
lib.Path = resolved
}
}
if s.log != nil {
s.log.Info("mapped library root path for scan",
zap.String("library_id", lib.ID),
zap.String("root_id", root.ID),
zap.String("from", root.Path),
zap.String("to", resolved))
}
root.Path = resolved
return nil
}
func libraryRootID(root *model.LibraryRoot) string {
if root == nil {
return ""
}
return strings.TrimSpace(root.ID)
}
func localRelativePath(pathValue string, root *model.LibraryRoot) string {
if root == nil || strings.TrimSpace(root.Path) == "" || strings.TrimSpace(pathValue) == "" {
return ""
}
rel, err := filepath.Rel(filepath.Clean(root.Path), filepath.Clean(pathValue))
if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
return ""
}
return rel
}
+98 -21
View File
@@ -19,6 +19,24 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
return s.scanLibrary(ctx, libraryID, true) return s.scanLibrary(ctx, libraryID, true)
} }
func (s *ScannerService) ScanLibraryRoot(ctx context.Context, libraryID, rootID string) (*ScanResult, error) {
lib, err := s.repo.Library.FindByID(ctx, libraryID)
if err != nil {
return nil, err
}
if lib == nil {
return nil, errors.New("library not found")
}
root, err := s.repo.Library.FindRootByID(ctx, libraryID, rootID)
if err != nil {
return nil, err
}
if root == nil {
return nil, errors.New("library root not found")
}
return s.scanLocalLibraryRoot(ctx, lib, root, true)
}
// ScanLibraryWithoutAutoScrape walks a library without kicking off online // ScanLibraryWithoutAutoScrape walks a library without kicking off online
// metadata enrichment. Cloud mounts can contain very large trees; keeping mount // metadata enrichment. Cloud mounts can contain very large trees; keeping mount
// scans import-only prevents scraper bursts from overwhelming small NAS boxes. // scans import-only prevents scraper bursts from overwhelming small NAS boxes.
@@ -55,9 +73,6 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto
if mount, ok := ParseCloudLibraryMount(lib.Path); ok { if mount, ok := ParseCloudLibraryMount(lib.Path); ok {
return s.scanMountedCloudLibrary(ctx, lib, mount, autoScrape) return s.scanMountedCloudLibrary(ctx, lib, mount, autoScrape)
} }
if err := s.resolveLocalLibraryPath(ctx, lib); err != nil {
return &ScanResult{LibraryID: lib.ID}, err
}
res := &ScanResult{LibraryID: lib.ID} res := &ScanResult{LibraryID: lib.ID}
writeBatch := newLocalMediaWriteBatch(s, ctx, res, 100) writeBatch := newLocalMediaWriteBatch(s, ctx, res, 100)
existingMedia, err := s.existingLocalMediaSnapshot(ctx, lib.ID) existingMedia, err := s.existingLocalMediaSnapshot(ctx, lib.ID)
@@ -66,27 +81,85 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto
existingMedia = nil existingMedia = nil
} }
seen, walkErr := s.scanLocalLibraryFiles(ctx, lib, existingMedia, writeBatch, res) roots, err := s.localLibraryScanRoots(ctx, lib)
writeBatch.Flush()
if walkErr != nil {
addScanError(res, lib.Path, walkErr)
if res.Added+res.Updated > 0 {
s.invalidateMediaCache(ctx)
}
return res, walkErr
}
removed, err := s.pruneMissingMedia(ctx, lib.ID, seen)
if err != nil { if err != nil {
s.log.Warn("prune missing media failed", zap.String("library_id", lib.ID), zap.Error(err)) return res, err
} else { }
res.Removed = removed if len(roots) == 0 {
return res, errors.New("library has no enabled paths")
}
var scanErr error
scannedRoots := 0
for i := range roots {
root := roots[i]
if err := s.resolveLocalLibraryRootPath(ctx, lib, &root); err != nil {
addScanError(res, root.Path, err)
s.log.Warn("library root scan skipped",
zap.String("library_id", lib.ID),
zap.String("root_id", root.ID),
zap.String("path", root.Path),
zap.Error(err))
if scanErr == nil {
scanErr = err
}
continue
}
seen, walkErr := s.scanLocalLibraryFiles(ctx, lib, &root, existingMedia, writeBatch, res)
if walkErr != nil {
addScanError(res, root.Path, walkErr)
if scanErr == nil {
scanErr = walkErr
}
continue
}
scannedRoots++
removed, err := s.pruneMissingMediaForRoot(ctx, lib.ID, root.ID, root.Path, seen)
if err != nil {
s.log.Warn("prune missing media failed", zap.String("library_id", lib.ID), zap.String("root_id", root.ID), zap.Error(err))
} else {
res.Removed += removed
}
}
writeBatch.Flush()
if scanErr != nil && scannedRoots == 0 {
return res, scanErr
} }
s.finishLocalLibraryScan(ctx, lib, res, autoScrape) s.finishLocalLibraryScan(ctx, lib, res, autoScrape)
return res, nil return res, nil
} }
func (s *ScannerService) scanLocalLibraryFiles(ctx context.Context, lib *model.Library, existingMedia map[string]existingLocalMedia, writeBatch *localMediaWriteBatch, res *ScanResult) (map[string]struct{}, error) { func (s *ScannerService) scanLocalLibraryRoot(ctx context.Context, lib *model.Library, root *model.LibraryRoot, autoScrape bool) (*ScanResult, error) {
res := &ScanResult{LibraryID: lib.ID}
if root == nil || !root.Enabled {
return res, errors.New("library root disabled or not found")
}
if err := s.resolveLocalLibraryRootPath(ctx, lib, root); err != nil {
return res, err
}
writeBatch := newLocalMediaWriteBatch(s, ctx, res, 100)
existingMedia, err := s.existingLocalMediaSnapshot(ctx, lib.ID)
if err != nil {
s.log.Warn("load existing local media snapshot failed", zap.String("library_id", lib.ID), zap.Error(err))
existingMedia = nil
}
seen, walkErr := s.scanLocalLibraryFiles(ctx, lib, root, existingMedia, writeBatch, res)
writeBatch.Flush()
if walkErr != nil {
addScanError(res, root.Path, walkErr)
return res, walkErr
}
removed, err := s.pruneMissingMediaForRoot(ctx, lib.ID, root.ID, root.Path, seen)
if err != nil {
s.log.Warn("prune missing media failed", zap.String("library_id", lib.ID), zap.String("root_id", root.ID), zap.Error(err))
} else {
res.Removed = removed
}
s.finishLocalLibraryScan(ctx, lib, res, autoScrape)
return res, nil
}
func (s *ScannerService) scanLocalLibraryFiles(ctx context.Context, lib *model.Library, root *model.LibraryRoot, existingMedia map[string]existingLocalMedia, writeBatch *localMediaWriteBatch, res *ScanResult) (map[string]struct{}, error) {
seen := make(map[string]struct{}) seen := make(map[string]struct{})
seenInodes := existingLocalMediaFileIDs(existingMedia) seenInodes := existingLocalMediaFileIDs(existingMedia)
walkFn := func(path string, info walkInfo) error { walkFn := func(path string, info walkInfo) error {
@@ -103,10 +176,10 @@ func (s *ScannerService) scanLocalLibraryFiles(ctx context.Context, lib *model.L
return nil return nil
} }
seen[filepath.Clean(path)] = struct{}{} seen[filepath.Clean(path)] = struct{}{}
s.ingestFile(ctx, lib, path, info.size, seenInodes, existingMedia, writeBatch, res) s.ingestFile(ctx, lib, root, path, info.size, seenInodes, existingMedia, writeBatch, res)
return nil return nil
} }
return seen, walk(lib.Path, walkFn) return seen, walk(root.Path, walkFn)
} }
func existingLocalMediaFileIDs(existingMedia map[string]existingLocalMedia) map[string]string { func existingLocalMediaFileIDs(existingMedia map[string]existingLocalMedia) map[string]string {
@@ -234,7 +307,11 @@ func (s *ScannerService) IngestPath(ctx context.Context, libraryID, path string)
if err != nil || lib == nil { if err != nil || lib == nil {
return false, err return false, err
} }
if err := s.resolveLocalLibraryPath(ctx, lib); err != nil { root, err := s.localLibraryRootForPath(ctx, lib, path)
if err != nil || root == nil {
return false, err
}
if err := s.resolveLocalLibraryRootPath(ctx, lib, root); err != nil {
return false, err return false, err
} }
fi, err := os.Stat(path) fi, err := os.Stat(path)
@@ -246,7 +323,7 @@ func (s *ScannerService) IngestPath(ctx context.Context, libraryID, path string)
return false, nil return false, nil
} }
res := &ScanResult{LibraryID: lib.ID} res := &ScanResult{LibraryID: lib.ID}
s.ingestFile(ctx, lib, path, fi.Size(), make(map[string]string), nil, nil, res) s.ingestFile(ctx, lib, root, path, fi.Size(), make(map[string]string), nil, nil, res)
if res.Added+res.Updated > 0 { if res.Added+res.Updated > 0 {
s.invalidateMediaCache(ctx) s.invalidateMediaCache(ctx)
} }
+23 -12
View File
@@ -22,6 +22,7 @@ import (
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository" "github.com/ShukeBta/MediaStationGo/internal/repository"
) )
@@ -99,19 +100,29 @@ func (w *WatcherService) Refresh(ctx context.Context) error {
if !l.Enabled { if !l.Enabled {
continue continue
} }
if _, _, ok := parseCloudLibraryPath(l.Path); ok { roots := l.Roots
continue if len(roots) == 0 && l.Path != "" {
roots = []model.LibraryRoot{{LibraryID: l.ID, Path: l.Path, Enabled: true}}
} }
watchRoot, info, err := resolveAccessibleMappedPath(l.Path) for _, root := range roots {
if err != nil || !info.IsDir() { if !root.Enabled {
w.log.Warn("watch path inaccessible", continue
zap.String("path", l.Path), }
zap.String("library_id", l.ID), if _, _, ok := parseCloudLibraryPath(root.Path); ok {
zap.Error(err)) continue
continue }
} watchRoot, info, err := resolveAccessibleMappedPath(root.Path)
for _, dir := range listDirsForWatch(watchRoot) { if err != nil || !info.IsDir() {
current[dir] = l.ID w.log.Warn("watch path inaccessible",
zap.String("path", root.Path),
zap.String("library_id", l.ID),
zap.String("root_id", root.ID),
zap.Error(err))
continue
}
for _, dir := range listDirsForWatch(watchRoot) {
current[dir] = l.ID
}
} }
} }
// Remove disappeared paths. // Remove disappeared paths.
+24 -1
View File
@@ -1,5 +1,5 @@
import { api, BATCH_REQUEST_TIMEOUT, LONG_REQUEST_TIMEOUT } from './client' import { api, BATCH_REQUEST_TIMEOUT, LONG_REQUEST_TIMEOUT } from './client'
import type { Library, Media, ScanResult } from '../types' import type { Library, LibraryRoot, Media, ScanResult } from '../types'
import type { SeriesCard } from '../utils/groupSeries' import type { SeriesCard } from '../utils/groupSeries'
export interface MediaPage { export interface MediaPage {
@@ -23,6 +23,13 @@ export interface SeriesPage {
page_size: number page_size: number
} }
export interface LibraryRootInput {
name?: string
path: string
enabled?: boolean
sort_order?: number
}
export interface ManualScrapeCandidate { export interface ManualScrapeCandidate {
source: string source: string
media_type?: string media_type?: string
@@ -83,11 +90,27 @@ export const libraryAPI = {
create: (name: string, path: string, type: string) => create: (name: string, path: string, type: string) =>
api.post<Library>('/libraries', { name, path, type }).then((r) => r.data), api.post<Library>('/libraries', { name, path, type }).then((r) => r.data),
createWithRoots: (name: string, type: string, roots: LibraryRootInput[]) =>
api.post<Library>('/libraries', { name, type, roots }).then((r) => r.data),
remove: (id: string) => api.delete(`/libraries/${id}`).then((r) => r.data), remove: (id: string) => api.delete(`/libraries/${id}`).then((r) => r.data),
listRoots: (id: string) => api.get<LibraryRoot[]>(`/libraries/${id}/roots`).then((r) => r.data),
addRoot: (id: string, root: LibraryRootInput) =>
api.post<LibraryRoot>(`/libraries/${id}/roots`, root).then((r) => r.data),
updateRoot: (id: string, rootID: string, root: Partial<LibraryRootInput>) =>
api.patch<LibraryRoot>(`/libraries/${id}/roots/${rootID}`, root).then((r) => r.data),
removeRoot: (id: string, rootID: string) => api.delete(`/libraries/${id}/roots/${rootID}`).then((r) => r.data),
scan: (id: string) => scan: (id: string) =>
api.post<ScanResult>(`/libraries/${id}/scan`, null, { timeout: BATCH_REQUEST_TIMEOUT }).then((r) => r.data), api.post<ScanResult>(`/libraries/${id}/scan`, null, { timeout: BATCH_REQUEST_TIMEOUT }).then((r) => r.data),
scanRoot: (id: string, rootID: string) =>
api.post<ScanResult>(`/libraries/${id}/roots/${rootID}/scan`, null, { timeout: BATCH_REQUEST_TIMEOUT }).then((r) => r.data),
scrape: (id: string, options?: ScrapeOptions) => scrape: (id: string, options?: ScrapeOptions) =>
api.post(`/libraries/${id}/scrape`, options ?? null, { timeout: BATCH_REQUEST_TIMEOUT }).then((r) => r.data), api.post(`/libraries/${id}/scrape`, options ?? null, { timeout: BATCH_REQUEST_TIMEOUT }).then((r) => r.data),
+225 -18
View File
@@ -1,16 +1,22 @@
import { FormEvent, useEffect, useState } from 'react' import { FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { Trash2 } from 'lucide-react' import { Plus, RefreshCw, Save, Trash2 } from 'lucide-react'
import { libraryAPI } from '../api/library' import { libraryAPI, type LibraryRootInput } from '../api/library'
import type { Library } from '../types' import type { Library, LibraryRoot } from '../types'
import { confirmAction } from '../components/confirmAction' import { confirmAction } from '../components/confirmAction'
type RootDraft = LibraryRootInput
const emptyRootDraft = (): RootDraft => ({ name: '', path: '', enabled: true })
export function AdminLibraryPanel() { export function AdminLibraryPanel() {
const [libs, setLibs] = useState<Library[]>([]) const [libs, setLibs] = useState<Library[]>([])
const [name, setName] = useState('') const [name, setName] = useState('')
const [path, setPath] = useState('') const [roots, setRoots] = useState<RootDraft[]>([emptyRootDraft()])
const [type, setType] = useState('movie') const [type, setType] = useState('movie')
const [newRootByLibrary, setNewRootByLibrary] = useState<Record<string, RootDraft>>({})
const [rootDrafts, setRootDrafts] = useState<Record<string, RootDraft>>({})
const refresh = () => libraryAPI.list({ includeHidden: true }).then(setLibs) const refresh = () => libraryAPI.list({ includeHidden: true }).then(setLibs)
useEffect(() => { useEffect(() => {
@@ -20,10 +26,17 @@ export function AdminLibraryPanel() {
const handleCreate = async (e: FormEvent) => { const handleCreate = async (e: FormEvent) => {
e.preventDefault() e.preventDefault()
try { try {
await libraryAPI.create(name, path, type) const payload = roots
.map((root, index) => ({ ...root, path: root.path.trim(), name: root.name?.trim(), sort_order: index }))
.filter((root) => root.path)
if (payload.length === 0) {
toast.error('请至少填写一个路径')
return
}
await libraryAPI.createWithRoots(name, type, payload)
toast.success('媒体库已创建') toast.success('媒体库已创建')
setName('') setName('')
setPath('') setRoots([emptyRootDraft()])
await refresh() await refresh()
} catch (err: unknown) { } catch (err: unknown) {
const msg = const msg =
@@ -33,6 +46,72 @@ export function AdminLibraryPanel() {
} }
} }
const updateCreateRoot = (index: number, patch: Partial<RootDraft>) => {
setRoots((prev) => prev.map((root, i) => (i === index ? { ...root, ...patch } : root)))
}
const addCreateRoot = () => setRoots((prev) => [...prev, emptyRootDraft()])
const removeCreateRoot = (index: number) => {
setRoots((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index)))
}
const newRootDraft = (libraryID: string) => newRootByLibrary[libraryID] ?? emptyRootDraft()
const setNewRootDraft = (libraryID: string, patch: Partial<RootDraft>) => {
setNewRootByLibrary((prev) => ({ ...prev, [libraryID]: { ...newRootDraft(libraryID), ...patch } }))
}
const addLibraryRoot = async (libraryID: string) => {
const draft = newRootDraft(libraryID)
if (!draft.path?.trim()) {
toast.error('请填写路径')
return
}
await libraryAPI.addRoot(libraryID, { ...draft, path: draft.path.trim(), name: draft.name?.trim() })
setNewRootByLibrary((prev) => ({ ...prev, [libraryID]: emptyRootDraft() }))
toast.success('路径已添加')
await refresh()
}
const rootDraftKey = (libraryID: string, rootID: string) => `${libraryID}:${rootID}`
const editableRootDraft = (libraryID: string, root: LibraryRoot): RootDraft => {
const key = rootDraftKey(libraryID, root.id)
return rootDrafts[key] ?? {
name: root.name ?? '',
path: root.path,
enabled: root.enabled,
sort_order: root.sort_order,
}
}
const setEditableRootDraft = (libraryID: string, root: LibraryRoot, patch: Partial<RootDraft>) => {
const key = rootDraftKey(libraryID, root.id)
setRootDrafts((prev) => ({ ...prev, [key]: { ...editableRootDraft(libraryID, root), ...patch } }))
}
const saveLibraryRoot = async (libraryID: string, root: LibraryRoot) => {
const draft = editableRootDraft(libraryID, root)
if (!draft.path?.trim()) {
toast.error('请填写路径')
return
}
await libraryAPI.updateRoot(libraryID, root.id, {
name: draft.name?.trim(),
path: draft.path.trim(),
enabled: draft.enabled,
sort_order: draft.sort_order,
})
setRootDrafts((prev) => {
const next = { ...prev }
delete next[rootDraftKey(libraryID, root.id)]
return next
})
toast.success('路径已保存')
await refresh()
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<form onSubmit={handleCreate} className="glass-panel grid gap-3 md:grid-cols-4"> <form onSubmit={handleCreate} className="glass-panel grid gap-3 md:grid-cols-4">
@@ -43,17 +122,6 @@ export function AdminLibraryPanel() {
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
/> />
<input
required
className="input-base md:col-span-2"
placeholder="容器路径,如 /media/电视剧/国产剧"
value={path}
onChange={(e) => setPath(e.target.value)}
/>
<p className="md:col-span-4 -mt-2 text-xs text-sand-500">
Docker 部署时请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧;如果误填 NAS
宿主机路径,系统会尝试按 compose 挂载自动转换。
</p>
<select className="input-base" value={type} onChange={(e) => setType(e.target.value)}> <select className="input-base" value={type} onChange={(e) => setType(e.target.value)}>
<option value="movie">电影</option> <option value="movie">电影</option>
<option value="tv">电视剧</option> <option value="tv">电视剧</option>
@@ -61,6 +129,41 @@ export function AdminLibraryPanel() {
<option value="anime">动漫</option> <option value="anime">动漫</option>
<option value="music">音乐</option> <option value="music">音乐</option>
</select> </select>
<div className="md:col-span-4 space-y-2">
{roots.map((root, index) => (
<div key={index} className="grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto]">
<input
className="input-base"
placeholder="路径名称"
value={root.name ?? ''}
onChange={(e) => updateCreateRoot(index, { name: e.target.value })}
/>
<input
required={index === 0}
className="input-base"
placeholder="容器路径,如 /media/电视剧/国产剧"
value={root.path}
onChange={(e) => updateCreateRoot(index, { path: e.target.value })}
/>
<button
type="button"
className="rounded-lg border border-red-400/40 px-3 text-red-400 hover:bg-red-400/10 disabled:opacity-40"
disabled={roots.length <= 1}
onClick={() => removeCreateRoot(index)}
title="删除路径"
>
<Trash2 size={16} />
</button>
</div>
))}
<button type="button" className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm" onClick={addCreateRoot}>
<Plus size={16} /> 添加路径
</button>
</div>
<p className="md:col-span-4 -mt-2 text-xs text-sand-500">
Docker 部署时请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧;如果误填 NAS
宿主机路径,系统会尝试按 compose 挂载自动转换。
</p>
<button type="submit" className="neon-button md:col-span-4"> <button type="submit" className="neon-button md:col-span-4">
新建媒体库 新建媒体库
</button> </button>
@@ -80,7 +183,111 @@ export function AdminLibraryPanel() {
{libs.map((l) => ( {libs.map((l) => (
<tr key={l.id} className="border-t border-gray-200"> <tr key={l.id} className="border-t border-gray-200">
<td className="py-2 text-ink-600">{l.name}</td> <td className="py-2 text-ink-600">{l.name}</td>
<td className="text-ink-100">{l.path}</td> <td className="text-ink-100">
<div className="space-y-2">
{(l.roots?.length
? l.roots
: [{
id: '',
library_id: l.id,
path: l.path,
enabled: l.enabled,
name: '',
sort_order: 0,
created_at: l.created_at,
updated_at: l.updated_at,
}]).map((root) => (
<div key={root.id || root.path} className="rounded border border-gray-200/70 p-2">
<div className="grid gap-2 xl:grid-cols-[minmax(120px,0.8fr)_minmax(220px,2fr)_auto]">
{root.id ? (
<>
<input
className="input-base"
placeholder="路径名称"
value={editableRootDraft(l.id, root).name ?? ''}
onChange={(e) => setEditableRootDraft(l.id, root, { name: e.target.value })}
/>
<input
className="input-base"
placeholder="真实路径"
value={editableRootDraft(l.id, root).path}
onChange={(e) => setEditableRootDraft(l.id, root, { path: e.target.value })}
/>
</>
) : (
<span className="min-w-0 break-all xl:col-span-2">{root.name ? `${root.name}:${root.path}` : root.path}</span>
)}
<div className="flex flex-wrap items-center gap-2">
{root.id && (
<button
className="rounded border border-primary-400/40 p-1 text-brand-500 hover:bg-primary-400/10"
title="保存路径"
onClick={() => saveLibraryRoot(l.id, root)}
>
<Save size={14} />
</button>
)}
<button
className="rounded border border-primary-400/40 p-1 text-brand-500 hover:bg-primary-400/10"
title="扫描路径"
onClick={async () => {
if (!root.id) return
await libraryAPI.scanRoot(l.id, root.id)
toast.success('路径扫描已加入后台任务')
}}
>
<RefreshCw size={14} />
</button>
{root.id && (
<button
className="rounded border border-gray-300 px-2 py-1 text-xs"
onClick={async () => {
const enabled = !editableRootDraft(l.id, root).enabled
setEditableRootDraft(l.id, root, { enabled })
await libraryAPI.updateRoot(l.id, root.id, { enabled })
await refresh()
}}
>
{editableRootDraft(l.id, root).enabled ? '启用' : '禁用'}
</button>
)}
{root.id && (
<button
className="rounded border border-red-400/40 p-1 text-red-400 hover:bg-red-400/10"
title="删除路径"
onClick={async () => {
if (!(await confirmAction({ title: '删除媒体库路径', message: `确定删除「${root.path}」?`, confirmText: '删除' }))) return
await libraryAPI.removeRoot(l.id, root.id)
toast.success('路径已删除')
await refresh()
}}
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
</div>
))}
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto]">
<input
className="input-base"
placeholder="路径名称"
value={newRootDraft(l.id).name ?? ''}
onChange={(e) => setNewRootDraft(l.id, { name: e.target.value })}
/>
<input
className="input-base"
placeholder="新增路径"
value={newRootDraft(l.id).path}
onChange={(e) => setNewRootDraft(l.id, { path: e.target.value })}
/>
<button className="rounded-lg border px-3 py-2 text-sm" onClick={() => addLibraryRoot(l.id)}>
<Plus size={14} />
</button>
</div>
</div>
</td>
<td className="text-ink-100">{l.type}</td> <td className="text-ink-100">{l.type}</td>
<td className="space-x-2 py-2 text-right"> <td className="space-x-2 py-2 text-right">
<button <button
@@ -97,6 +97,7 @@ export function AutoOrganizeBasicTab({
<BooleanSetting config={config} settingKey="afterDownload" label="qB 下载完成后自动整理" onConfigChange={onConfigChange} /> <BooleanSetting config={config} settingKey="afterDownload" label="qB 下载完成后自动整理" onConfigChange={onConfigChange} />
<BooleanSetting config={config} settingKey="downloadSmartClassify" label="下载器智能分类" onConfigChange={onConfigChange} /> <BooleanSetting config={config} settingKey="downloadSmartClassify" label="下载器智能分类" onConfigChange={onConfigChange} />
<BooleanSetting config={config} settingKey="smartClassify" label="智能分类到子库" onConfigChange={onConfigChange} /> <BooleanSetting config={config} settingKey="smartClassify" label="智能分类到子库" onConfigChange={onConfigChange} />
<BooleanSetting config={config} settingKey="autoAddLibrary" label="自动注册目的地媒体库" onConfigChange={onConfigChange} />
<BooleanSetting config={config} settingKey="keepSeeding" label="保种" onConfigChange={onConfigChange} /> <BooleanSetting config={config} settingKey="keepSeeding" label="保种" onConfigChange={onConfigChange} />
</div> </div>
</> </>
+4
View File
@@ -6,6 +6,7 @@ export type AutoOrganizeConfig = {
scrapeAfter: string scrapeAfter: string
downloadSmartClassify: string downloadSmartClassify: string
smartClassify: string smartClassify: string
autoAddLibrary: string
sourceDir: string sourceDir: string
targetDir: string targetDir: string
transferMode: string transferMode: string
@@ -27,6 +28,7 @@ export const AUTO_ORGANIZE_DEFAULTS: AutoOrganizeConfig = {
scrapeAfter: 'true', scrapeAfter: 'true',
downloadSmartClassify: 'true', downloadSmartClassify: 'true',
smartClassify: 'true', smartClassify: 'true',
autoAddLibrary: 'true',
sourceDir: '', sourceDir: '',
targetDir: '', targetDir: '',
transferMode: 'hardlink', transferMode: 'hardlink',
@@ -48,6 +50,7 @@ export const AUTO_ORGANIZE_KEYS: Record<keyof AutoOrganizeConfig, string> = {
scrapeAfter: 'organize.scrape_after', scrapeAfter: 'organize.scrape_after',
downloadSmartClassify: 'downloads.smart_classify', downloadSmartClassify: 'downloads.smart_classify',
smartClassify: 'organizer.smart_classify', smartClassify: 'organizer.smart_classify',
autoAddLibrary: 'organize.auto_add_library',
sourceDir: 'organize.source_dir', sourceDir: 'organize.source_dir',
targetDir: 'organize.target_dir', targetDir: 'organize.target_dir',
transferMode: 'organize.transfer_mode', transferMode: 'organize.transfer_mode',
@@ -73,6 +76,7 @@ export function mergeAutoOrganizeSettings(rows: Setting[]): AutoOrganizeConfig {
scrapeAfter: idx[AUTO_ORGANIZE_KEYS.scrapeAfter] ?? AUTO_ORGANIZE_DEFAULTS.scrapeAfter, scrapeAfter: idx[AUTO_ORGANIZE_KEYS.scrapeAfter] ?? AUTO_ORGANIZE_DEFAULTS.scrapeAfter,
downloadSmartClassify: idx[AUTO_ORGANIZE_KEYS.downloadSmartClassify] ?? AUTO_ORGANIZE_DEFAULTS.downloadSmartClassify, downloadSmartClassify: idx[AUTO_ORGANIZE_KEYS.downloadSmartClassify] ?? AUTO_ORGANIZE_DEFAULTS.downloadSmartClassify,
smartClassify: idx[AUTO_ORGANIZE_KEYS.smartClassify] ?? AUTO_ORGANIZE_DEFAULTS.smartClassify, smartClassify: idx[AUTO_ORGANIZE_KEYS.smartClassify] ?? AUTO_ORGANIZE_DEFAULTS.smartClassify,
autoAddLibrary: idx[AUTO_ORGANIZE_KEYS.autoAddLibrary] ?? AUTO_ORGANIZE_DEFAULTS.autoAddLibrary,
sourceDir: idx[AUTO_ORGANIZE_KEYS.sourceDir] ?? AUTO_ORGANIZE_DEFAULTS.sourceDir, sourceDir: idx[AUTO_ORGANIZE_KEYS.sourceDir] ?? AUTO_ORGANIZE_DEFAULTS.sourceDir,
targetDir: idx[AUTO_ORGANIZE_KEYS.targetDir] ?? AUTO_ORGANIZE_DEFAULTS.targetDir, targetDir: idx[AUTO_ORGANIZE_KEYS.targetDir] ?? AUTO_ORGANIZE_DEFAULTS.targetDir,
transferMode: idx[AUTO_ORGANIZE_KEYS.transferMode] ?? AUTO_ORGANIZE_DEFAULTS.transferMode, transferMode: idx[AUTO_ORGANIZE_KEYS.transferMode] ?? AUTO_ORGANIZE_DEFAULTS.transferMode,
+14
View File
@@ -95,12 +95,24 @@ export interface LoginResponse {
} }
// Library // Library
export interface LibraryRoot {
id: string
library_id: string
name?: string
path: string
enabled: boolean
sort_order: number
created_at: string
updated_at: string
}
export interface Library { export interface Library {
id: string id: string
name: string name: string
path: string path: string
type: string type: string
enabled: boolean enabled: boolean
roots?: LibraryRoot[]
created_at: string created_at: string
updated_at: string updated_at: string
} }
@@ -108,6 +120,7 @@ export interface Library {
export interface Media { export interface Media {
id: string id: string
library_id: string library_id: string
library_root_id?: string
library_name?: string library_name?: string
library_path?: string library_path?: string
display_library_id?: string display_library_id?: string
@@ -118,6 +131,7 @@ export interface Media {
original_name?: string original_name?: string
episode_title?: string episode_title?: string
path: string path: string
relative_path?: string
size_bytes: number size_bytes: number
duration_sec: number duration_sec: number
width: number width: number