From 5ef831f83062fb3787b4b84786c374b8c4007173 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:18:26 +0800 Subject: [PATCH] support multiple roots per library --- internal/database/schema_migration.go | 108 ++++++++ internal/handler/media.go | 113 +++++++- internal/handler/routes_authenticated_core.go | 5 + internal/model/model.go | 82 +++--- internal/repository/library_repository.go | 98 ++++++- internal/repository/media_repository.go | 12 + internal/service/bot_device_policy_test.go | 4 +- internal/service/media.go | 208 ++++++++++++++- ...organizer_directory_classification_test.go | 39 +++ .../service/organizer_directory_libraries.go | 16 +- internal/service/organizer_settings.go | 11 + internal/service/scanner.go | 58 +++-- internal/service/scanner_existing_media.go | 60 ++--- .../service/scanner_library_roots_test.go | 90 +++++++ internal/service/scanner_local_ingest.go | 34 ++- internal/service/scanner_metadata_refresh.go | 6 + internal/service/scanner_prune.go | 46 ++++ internal/service/scanner_roots.go | 122 +++++++++ internal/service/scanner_scan.go | 119 +++++++-- internal/service/watcher.go | 35 ++- web/src/api/library.ts | 25 +- web/src/pages/AdminLibraryPanel.tsx | 243 ++++++++++++++++-- web/src/pages/AutoOrganizeSettingsTabs.tsx | 1 + web/src/pages/autoOrganizeModel.ts | 4 + web/src/types/index.ts | 14 + 25 files changed, 1382 insertions(+), 171 deletions(-) create mode 100644 internal/service/scanner_library_roots_test.go create mode 100644 internal/service/scanner_roots.go diff --git a/internal/database/schema_migration.go b/internal/database/schema_migration.go index cbd9845..08236ca 100644 --- a/internal/database/schema_migration.go +++ b/internal/database/schema_migration.go @@ -1,6 +1,9 @@ package database import ( + "path/filepath" + "strings" + "gorm.io/gorm" "github.com/ShukeBta/MediaStationGo/internal/model" @@ -20,6 +23,9 @@ func AutoMigrate(db *gorm.DB) error { if err := ensurePerformanceIndexes(db); err != nil { return err } + if err := ensureLibraryRootsCompatibility(db); err != nil { + return err + } if isSQLite(db) { return ensureMediaSearchIndex(db) } @@ -52,6 +58,7 @@ func ensurePerformanceIndexes(db *gorm.DB) error { 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_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_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`, @@ -77,6 +84,107 @@ func ensurePerformanceIndexes(db *gorm.DB) error { 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. // v2 aligns FTS rowids with media rowids and keeps the index current with // triggers. diff --git a/internal/handler/media.go b/internal/handler/media.go index af9a174..a686b5e 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -15,9 +15,11 @@ import ( ) type createLibraryReq struct { - Name string `json:"name" binding:"required"` - Path string `json:"path" binding:"required"` - Type string `json:"type"` + Name string `json:"name" binding:"required"` + Path string `json:"path"` + Paths []string `json:"paths"` + Roots []service.LibraryRootInput `json:"roots"` + Type string `json:"type"` } 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()}) 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 { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 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 { return func(c *gin.Context) { 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 { if svc == nil || svc.Tasks == nil { return nil diff --git a/internal/handler/routes_authenticated_core.go b/internal/handler/routes_authenticated_core.go index ecc99b8..964b65a 100644 --- a/internal/handler/routes_authenticated_core.go +++ b/internal/handler/routes_authenticated_core.go @@ -24,6 +24,11 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container authed.GET("/libraries", listLibrariesHandler(svc)) authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(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/scrape", middleware.AdminRequired(), scrapeLibraryHandler(svc)) diff --git a/internal/model/model.go b/internal/model/model.go index f34a1c5..a9d8169 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -59,47 +59,60 @@ type User struct { RealtimeDeviceCount int `gorm:"-" json:"realtime_device_count,omitempty"` } -// Library 表示用户定义的媒体根目录。 +// Library 表示一个逻辑媒体库。Path 保留为兼容字段,指向第一条 LibraryRoot。 type Library struct { Base - Name string `gorm:"size:128;not null" json:"name"` - Path string `gorm:"size:1024;not null" json:"path"` - Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music - Enabled bool `gorm:"default:true" json:"enabled"` + Name string `gorm:"size:128;not null" json:"name"` + Path string `gorm:"size:1024;not null" json:"path"` + Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music + 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 == ""。 type Media struct { Base - LibraryID string `gorm:"index;size:36" json:"library_id"` - SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"` - Title string `gorm:"size:255;not null" json:"title"` - OriginalName string `gorm:"size:255" json:"original_name,omitempty"` - EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"` - Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"` - SizeBytes int64 `json:"size_bytes"` - DurationSec int `json:"duration_sec"` - Width int `json:"width"` - Height int `json:"height"` - VideoCodec string `gorm:"size:32" json:"video_codec,omitempty"` - AudioCodec string `gorm:"size:32" json:"audio_codec,omitempty"` - Container string `gorm:"size:128" json:"container,omitempty"` - PosterURL string `gorm:"size:1024" json:"poster_url,omitempty"` - BackdropURL string `gorm:"size:1024" json:"backdrop_url,omitempty"` - Overview string `gorm:"type:text" json:"overview,omitempty"` - Rating float32 `json:"rating"` - Year int `json:"year"` - SeasonNum int `json:"season_num"` - EpisodeNum int `json:"episode_num"` - ScrapeStatus string `gorm:"size:16;default:pending" json:"scrape_status"` - TMDbID int `json:"tmdb_id"` - BangumiID int `json:"bangumi_id"` - DoubanID string `gorm:"column:douban_id;size:32" json:"douban_id,omitempty"` - TheTVDBID string `gorm:"column:thetvdb_id;size:64" json:"thetvdb_id,omitempty"` - Languages string `gorm:"size:64" json:"languages,omitempty"` // 逗号分隔的 ISO 639-1 代码,如 "zh,en" - 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"` + LibraryID string `gorm:"index;size:36" json:"library_id"` + LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"` + SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"` + Title string `gorm:"size:255;not null" json:"title"` + OriginalName string `gorm:"size:255" json:"original_name,omitempty"` + EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"` + Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"` + RelativePath string `gorm:"size:1024" json:"relative_path,omitempty"` + SizeBytes int64 `json:"size_bytes"` + DurationSec int `json:"duration_sec"` + Width int `json:"width"` + Height int `json:"height"` + VideoCodec string `gorm:"size:32" json:"video_codec,omitempty"` + AudioCodec string `gorm:"size:32" json:"audio_codec,omitempty"` + Container string `gorm:"size:128" json:"container,omitempty"` + PosterURL string `gorm:"size:1024" json:"poster_url,omitempty"` + BackdropURL string `gorm:"size:1024" json:"backdrop_url,omitempty"` + Overview string `gorm:"type:text" json:"overview,omitempty"` + Rating float32 `json:"rating"` + Year int `json:"year"` + SeasonNum int `json:"season_num"` + EpisodeNum int `json:"episode_num"` + ScrapeStatus string `gorm:"size:16;default:pending" json:"scrape_status"` + TMDbID int `json:"tmdb_id"` + BangumiID int `json:"bangumi_id"` + DoubanID string `gorm:"column:douban_id;size:32" json:"douban_id,omitempty"` + TheTVDBID string `gorm:"column:thetvdb_id;size:64" json:"thetvdb_id,omitempty"` + Languages string `gorm:"size:64" json:"languages,omitempty"` // 逗号分隔的 ISO 639-1 代码,如 "zh,en" + 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 // stream handler redirects to it instead of opening the local file. @@ -352,6 +365,7 @@ func AllModels() []interface{} { return []interface{}{ &User{}, &Library{}, + &LibraryRoot{}, &Series{}, &Media{}, &PlaybackHistory{}, diff --git a/internal/repository/library_repository.go b/internal/repository/library_repository.go index e2a3bd3..987a553 100644 --- a/internal/repository/library_repository.go +++ b/internal/repository/library_repository.go @@ -3,6 +3,7 @@ package repository import ( "context" "errors" + "strings" "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 } +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. func (r *LibraryRepository) List(ctx context.Context) ([]model.Library, error) { 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 } // FindByID returns the library, or (nil, nil) when missing. func (r *LibraryRepository) FindByID(ctx context.Context, id string) (*model.Library, error) { 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) { 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 { 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{}) +} diff --git a/internal/repository/media_repository.go b/internal/repository/media_repository.go index 886ee1d..fd25a55 100644 --- a/internal/repository/media_repository.go +++ b/internal/repository/media_repository.go @@ -223,6 +223,12 @@ func addMediaPlacementUpdates(updates map[string]any, existing, incoming model.M 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 episodeChanged := incoming.EpisodeNum > 0 && existing.EpisodeNum != incoming.EpisodeNum 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 } +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 // cloud mounts where "remove mount" must not populate the recycle bin. func (r *MediaRepository) PurgeByLibrary(ctx context.Context, libraryID string) error { diff --git a/internal/service/bot_device_policy_test.go b/internal/service/bot_device_policy_test.go index bd312d5..2b43673 100644 --- a/internal/service/bot_device_policy_test.go +++ b/internal/service/bot_device_policy_test.go @@ -87,7 +87,7 @@ func TestTerminalDeviceLimitDeduplicatesAppsOnSameDevice(t *testing.T) { dev.RecordLogin(ctx, u.ID, login.id, login.name, login.client, "1.2.3.4") 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 { 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, "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 { t.Fatal(err) } diff --git a/internal/service/media.go b/internal/service/media.go index cad94b8..1840e47 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -4,6 +4,9 @@ package service import ( "context" "errors" + "fmt" + "path/filepath" + "strings" "time" "go.uber.org/zap" @@ -31,6 +34,14 @@ type MediaVisibility struct { const maxMediaSearchLimit = 50000 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 { if media == nil { return false @@ -68,27 +79,210 @@ func (s *MediaService) SetRuntimeCache(cache *RuntimeCacheService) *MediaService // CreateLibrary persists a library after validating that its path exists. func (s *MediaService) CreateLibrary(ctx context.Context, name, path, kind string) (*model.Library, error) { - if name == "" || path == "" { - return nil, errors.New("name and path required") + return s.CreateLibraryWithRoots(ctx, name, kind, []LibraryRootInput{{Path: path}}) +} + +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 { return nil, err } - kind = inferLibraryKind(name, abs, kind) - lib := &model.Library{Name: name, Path: abs, Type: kind, Enabled: true} - if err := s.repo.Library.Create(ctx, lib); err != nil { + kind = inferLibraryKind(name, roots[0].Path, kind) + lib := &model.Library{Name: strings.TrimSpace(name), Path: roots[0].Path, Type: kind, Enabled: true} + if err := s.repo.Library.CreateWithRoots(ctx, lib, roots); err != nil { return nil, err } s.invalidateMediaCache(ctx) 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. func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, error) { 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 // left untouched. 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 { 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 if err == nil { s.invalidateMediaCache(ctx) @@ -108,6 +303,7 @@ func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error { 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 { return err } diff --git a/internal/service/organizer_directory_classification_test.go b/internal/service/organizer_directory_classification_test.go index a0fa75b..24c702a 100644 --- a/internal/service/organizer_directory_classification_test.go +++ b/internal/service/organizer_directory_classification_test.go @@ -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) { root := t.TempDir() src := filepath.Join(root, "downloads") diff --git a/internal/service/organizer_directory_libraries.go b/internal/service/organizer_directory_libraries.go index 19d30b7..cc6790d 100644 --- a/internal/service/organizer_directory_libraries.go +++ b/internal/service/organizer_directory_libraries.go @@ -218,6 +218,15 @@ func (o *OrganizerService) ensureOrganizeLibraryForRoot(ctx context.Context, roo if strings.TrimSpace(category) == "" && hasContainingLibrary { 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) if name == "" { name = filepath.Base(root) @@ -231,7 +240,12 @@ func (o *OrganizerService) ensureOrganizeLibraryForRoot(ctx context.Context, roo Type: organizeLibraryModelType(mediaType), 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 { o.log.Warn("organize auto-create library failed", zap.String("path", root), diff --git a/internal/service/organizer_settings.go b/internal/service/organizer_settings.go index 338af5e..2fade57 100644 --- a/internal/service/organizer_settings.go +++ b/internal/service/organizer_settings.go @@ -73,3 +73,14 @@ func (o *OrganizerService) keepSeedingEnabled(ctx context.Context) bool { } 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) +} diff --git a/internal/service/scanner.go b/internal/service/scanner.go index 683bcaa..fb877e1 100644 --- a/internal/service/scanner.go +++ b/internal/service/scanner.go @@ -263,32 +263,34 @@ type existingCloudMedia struct { } type existingLocalMedia struct { - Title string - OriginalName string - EpisodeTitle string - SizeBytes int64 - DurationSec int - Width int - Height int - VideoCodec string - AudioCodec string - Container string - STRMURL string - FileID string - PosterURL string - BackdropURL string - Overview string - Year int - Rating float32 - TMDbID int - BangumiID int - DoubanID string - TheTVDBID string - SeasonNum int - EpisodeNum int - Genres string - Countries string - Languages string - NSFW bool - ScrapeStatus string + LibraryRootID string + RelativePath string + Title string + OriginalName string + EpisodeTitle string + SizeBytes int64 + DurationSec int + Width int + Height int + VideoCodec string + AudioCodec string + Container string + STRMURL string + FileID string + PosterURL string + BackdropURL string + Overview string + Year int + Rating float32 + TMDbID int + BangumiID int + DoubanID string + TheTVDBID string + SeasonNum int + EpisodeNum int + Genres string + Countries string + Languages string + NSFW bool + ScrapeStatus string } diff --git a/internal/service/scanner_existing_media.go b/internal/service/scanner_existing_media.go index 0419a01..ba59022 100644 --- a/internal/service/scanner_existing_media.go +++ b/internal/service/scanner_existing_media.go @@ -67,7 +67,7 @@ func (s *ScannerService) existingLocalMediaSnapshot(ctx context.Context, library var rows []model.Media if err := s.repo.DB.WithContext(ctx). 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://%"). Find(&rows).Error; err != nil { return nil, err @@ -78,34 +78,36 @@ func (s *ScannerService) existingLocalMediaSnapshot(ctx context.Context, library continue } snapshot[filepath.Clean(row.Path)] = existingLocalMedia{ - Title: row.Title, - OriginalName: row.OriginalName, - EpisodeTitle: row.EpisodeTitle, - SizeBytes: row.SizeBytes, - DurationSec: row.DurationSec, - Width: row.Width, - Height: row.Height, - VideoCodec: row.VideoCodec, - AudioCodec: row.AudioCodec, - Container: row.Container, - STRMURL: row.STRMURL, - FileID: row.FileID, - PosterURL: row.PosterURL, - BackdropURL: row.BackdropURL, - Overview: row.Overview, - Year: row.Year, - Rating: row.Rating, - TMDbID: row.TMDbID, - BangumiID: row.BangumiID, - DoubanID: row.DoubanID, - TheTVDBID: row.TheTVDBID, - SeasonNum: row.SeasonNum, - EpisodeNum: row.EpisodeNum, - Genres: row.Genres, - Countries: row.Countries, - Languages: row.Languages, - NSFW: row.NSFW, - ScrapeStatus: row.ScrapeStatus, + LibraryRootID: row.LibraryRootID, + RelativePath: row.RelativePath, + Title: row.Title, + OriginalName: row.OriginalName, + EpisodeTitle: row.EpisodeTitle, + SizeBytes: row.SizeBytes, + DurationSec: row.DurationSec, + Width: row.Width, + Height: row.Height, + VideoCodec: row.VideoCodec, + AudioCodec: row.AudioCodec, + Container: row.Container, + STRMURL: row.STRMURL, + FileID: row.FileID, + PosterURL: row.PosterURL, + BackdropURL: row.BackdropURL, + Overview: row.Overview, + Year: row.Year, + Rating: row.Rating, + TMDbID: row.TMDbID, + BangumiID: row.BangumiID, + DoubanID: row.DoubanID, + TheTVDBID: row.TheTVDBID, + SeasonNum: row.SeasonNum, + EpisodeNum: row.EpisodeNum, + Genres: row.Genres, + Countries: row.Countries, + Languages: row.Languages, + NSFW: row.NSFW, + ScrapeStatus: row.ScrapeStatus, } } return snapshot, nil diff --git a/internal/service/scanner_library_roots_test.go b/internal/service/scanner_library_roots_test.go new file mode 100644 index 0000000..a5c2ba9 --- /dev/null +++ b/internal/service/scanner_library_roots_test.go @@ -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) + } +} diff --git a/internal/service/scanner_local_ingest.go b/internal/service/scanner_local_ingest.go index 63a92bf..542fc0a 100644 --- a/internal/service/scanner_local_ingest.go +++ b/internal/service/scanner_local_ingest.go @@ -13,7 +13,7 @@ import ( // 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. -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++ ext := strings.ToLower(filepath.Ext(path)) cleanPath := filepath.Clean(path) @@ -24,9 +24,10 @@ func (s *ScannerService) ingestFile(ctx context.Context, lib *model.Library, pat } parsedSeason, parsedEpisode := ParseEpisode(path) - localMeta := s.readLocalScanMetadata(lib, path, parsedSeason, parsedEpisode) + localMeta := s.readLocalScanMetadata(lib, root, path, parsedSeason, parsedEpisode) media := s.buildLocalScanMedia(localScanMediaInput{ lib: lib, + root: root, path: path, ext: ext, fileID: fileID, @@ -87,8 +88,12 @@ func (s *ScannerService) recordLocalFileIdentity(ctx context.Context, path strin return fileID, false } -func (s *ScannerService) readLocalScanMetadata(lib *model.Library, path string, parsedSeason, parsedEpisode int) *LocalMetadata { - localMeta, err := ReadLocalMetadata(path, lib.Path, librarySupportsSeasons(lib) || parsedSeason > 0 || parsedEpisode > 0) +func (s *ScannerService) readLocalScanMetadata(lib *model.Library, root *model.LibraryRoot, path string, parsedSeason, parsedEpisode int) *LocalMetadata { + 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 { 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 { lib *model.Library + root *model.LibraryRoot path string ext string fileID string @@ -136,15 +142,17 @@ func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Medi } media := &model.Media{ - LibraryID: in.lib.ID, - Title: title, - Year: year, - Path: in.path, - SizeBytes: in.size, - Container: strings.TrimPrefix(in.ext, "."), - FileID: in.fileID, - SeasonNum: in.parsedSeason, - EpisodeNum: in.parsedEpisode, + LibraryID: in.lib.ID, + LibraryRootID: libraryRootID(in.root), + RelativePath: localRelativePath(in.path, in.root), + Title: title, + Year: year, + Path: in.path, + SizeBytes: in.size, + Container: strings.TrimPrefix(in.ext, "."), + FileID: in.fileID, + SeasonNum: in.parsedSeason, + EpisodeNum: in.parsedEpisode, } if in.ext == ".strm" { media.Container = "strm" diff --git a/internal/service/scanner_metadata_refresh.go b/internal/service/scanner_metadata_refresh.go index 30a68b3..146b4b0 100644 --- a/internal/service/scanner_metadata_refresh.go +++ b/internal/service/scanner_metadata_refresh.go @@ -194,6 +194,12 @@ func localDerivedMetadataNeedsRefresh(existing existingLocalMedia, incoming *mod if incoming == nil { return false } + if incoming.LibraryRootID != "" && incoming.LibraryRootID != existing.LibraryRootID { + return true + } + if incoming.RelativePath != "" && incoming.RelativePath != existing.RelativePath { + return true + } return scanDerivedMetadataNeedsRefresh(scanDerivedMetadata{ Title: existing.Title, ScrapeStatus: existing.ScrapeStatus, diff --git a/internal/service/scanner_prune.go b/internal/service/scanner_prune.go index 0e29e19..e460fdc 100644 --- a/internal/service/scanner_prune.go +++ b/internal/service/scanner_prune.go @@ -58,6 +58,52 @@ func (s *ScannerService) pruneMissingMedia(ctx context.Context, libraryID string 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 // transaction stays short and the global write gate is released frequently. func (s *ScannerService) deleteMediaByIDs(ctx context.Context, ids []string, hard bool) (int64, error) { diff --git a/internal/service/scanner_roots.go b/internal/service/scanner_roots.go new file mode 100644 index 0000000..2a18d67 --- /dev/null +++ b/internal/service/scanner_roots.go @@ -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 +} diff --git a/internal/service/scanner_scan.go b/internal/service/scanner_scan.go index 87ebb66..fe72495 100644 --- a/internal/service/scanner_scan.go +++ b/internal/service/scanner_scan.go @@ -19,6 +19,24 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc 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 // metadata enrichment. Cloud mounts can contain very large trees; keeping mount // 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 { 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} writeBatch := newLocalMediaWriteBatch(s, ctx, res, 100) existingMedia, err := s.existingLocalMediaSnapshot(ctx, lib.ID) @@ -66,27 +81,85 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto existingMedia = nil } - seen, walkErr := s.scanLocalLibraryFiles(ctx, lib, existingMedia, writeBatch, res) - 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) + roots, err := s.localLibraryScanRoots(ctx, lib) if err != nil { - s.log.Warn("prune missing media failed", zap.String("library_id", lib.ID), zap.Error(err)) - } else { - res.Removed = removed + return res, err + } + 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) 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{}) seenInodes := existingLocalMediaFileIDs(existingMedia) walkFn := func(path string, info walkInfo) error { @@ -103,10 +176,10 @@ func (s *ScannerService) scanLocalLibraryFiles(ctx context.Context, lib *model.L return nil } 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 seen, walk(lib.Path, walkFn) + return seen, walk(root.Path, walkFn) } 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 { 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 } fi, err := os.Stat(path) @@ -246,7 +323,7 @@ func (s *ScannerService) IngestPath(ctx context.Context, libraryID, path string) return false, nil } 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 { s.invalidateMediaCache(ctx) } diff --git a/internal/service/watcher.go b/internal/service/watcher.go index 80b433b..7a37bfc 100644 --- a/internal/service/watcher.go +++ b/internal/service/watcher.go @@ -22,6 +22,7 @@ import ( "github.com/fsnotify/fsnotify" "go.uber.org/zap" + "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/repository" ) @@ -99,19 +100,29 @@ func (w *WatcherService) Refresh(ctx context.Context) error { if !l.Enabled { continue } - if _, _, ok := parseCloudLibraryPath(l.Path); ok { - continue + roots := l.Roots + if len(roots) == 0 && l.Path != "" { + roots = []model.LibraryRoot{{LibraryID: l.ID, Path: l.Path, Enabled: true}} } - watchRoot, info, err := resolveAccessibleMappedPath(l.Path) - if err != nil || !info.IsDir() { - w.log.Warn("watch path inaccessible", - zap.String("path", l.Path), - zap.String("library_id", l.ID), - zap.Error(err)) - continue - } - for _, dir := range listDirsForWatch(watchRoot) { - current[dir] = l.ID + for _, root := range roots { + if !root.Enabled { + continue + } + if _, _, ok := parseCloudLibraryPath(root.Path); ok { + continue + } + watchRoot, info, err := resolveAccessibleMappedPath(root.Path) + if err != nil || !info.IsDir() { + 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. diff --git a/web/src/api/library.ts b/web/src/api/library.ts index f359e49..e013cb8 100644 --- a/web/src/api/library.ts +++ b/web/src/api/library.ts @@ -1,5 +1,5 @@ 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' export interface MediaPage { @@ -23,6 +23,13 @@ export interface SeriesPage { page_size: number } +export interface LibraryRootInput { + name?: string + path: string + enabled?: boolean + sort_order?: number +} + export interface ManualScrapeCandidate { source: string media_type?: string @@ -83,11 +90,27 @@ export const libraryAPI = { create: (name: string, path: string, type: string) => api.post('/libraries', { name, path, type }).then((r) => r.data), + createWithRoots: (name: string, type: string, roots: LibraryRootInput[]) => + api.post('/libraries', { name, type, roots }).then((r) => r.data), + remove: (id: string) => api.delete(`/libraries/${id}`).then((r) => r.data), + listRoots: (id: string) => api.get(`/libraries/${id}/roots`).then((r) => r.data), + + addRoot: (id: string, root: LibraryRootInput) => + api.post(`/libraries/${id}/roots`, root).then((r) => r.data), + + updateRoot: (id: string, rootID: string, root: Partial) => + api.patch(`/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) => api.post(`/libraries/${id}/scan`, null, { timeout: BATCH_REQUEST_TIMEOUT }).then((r) => r.data), + scanRoot: (id: string, rootID: string) => + api.post(`/libraries/${id}/roots/${rootID}/scan`, null, { timeout: BATCH_REQUEST_TIMEOUT }).then((r) => r.data), + scrape: (id: string, options?: ScrapeOptions) => api.post(`/libraries/${id}/scrape`, options ?? null, { timeout: BATCH_REQUEST_TIMEOUT }).then((r) => r.data), diff --git a/web/src/pages/AdminLibraryPanel.tsx b/web/src/pages/AdminLibraryPanel.tsx index 6718ed3..4ef627a 100644 --- a/web/src/pages/AdminLibraryPanel.tsx +++ b/web/src/pages/AdminLibraryPanel.tsx @@ -1,16 +1,22 @@ import { FormEvent, useEffect, useState } from 'react' 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 type { Library } from '../types' +import { libraryAPI, type LibraryRootInput } from '../api/library' +import type { Library, LibraryRoot } from '../types' import { confirmAction } from '../components/confirmAction' +type RootDraft = LibraryRootInput + +const emptyRootDraft = (): RootDraft => ({ name: '', path: '', enabled: true }) + export function AdminLibraryPanel() { const [libs, setLibs] = useState([]) const [name, setName] = useState('') - const [path, setPath] = useState('') + const [roots, setRoots] = useState([emptyRootDraft()]) const [type, setType] = useState('movie') + const [newRootByLibrary, setNewRootByLibrary] = useState>({}) + const [rootDrafts, setRootDrafts] = useState>({}) const refresh = () => libraryAPI.list({ includeHidden: true }).then(setLibs) useEffect(() => { @@ -20,10 +26,17 @@ export function AdminLibraryPanel() { const handleCreate = async (e: FormEvent) => { e.preventDefault() 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('媒体库已创建') setName('') - setPath('') + setRoots([emptyRootDraft()]) await refresh() } catch (err: unknown) { const msg = @@ -33,6 +46,72 @@ export function AdminLibraryPanel() { } } + const updateCreateRoot = (index: number, patch: Partial) => { + 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) => { + 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) => { + 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 (
@@ -43,17 +122,6 @@ export function AdminLibraryPanel() { value={name} onChange={(e) => setName(e.target.value)} /> - setPath(e.target.value)} - /> -

- Docker 部署时请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧;如果误填 NAS - 宿主机路径,系统会尝试按 compose 挂载自动转换。 -

+
+ {roots.map((root, index) => ( +
+ updateCreateRoot(index, { name: e.target.value })} + /> + updateCreateRoot(index, { path: e.target.value })} + /> + +
+ ))} + +
+

+ Docker 部署时请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧;如果误填 NAS + 宿主机路径,系统会尝试按 compose 挂载自动转换。 +

@@ -80,7 +183,111 @@ export function AdminLibraryPanel() { {libs.map((l) => ( {l.name} - {l.path} + +
+ {(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) => ( +
+
+ {root.id ? ( + <> + setEditableRootDraft(l.id, root, { name: e.target.value })} + /> + setEditableRootDraft(l.id, root, { path: e.target.value })} + /> + + ) : ( + {root.name ? `${root.name}:${root.path}` : root.path} + )} +
+ {root.id && ( + + )} + + {root.id && ( + + )} + {root.id && ( + + )} +
+
+
+ ))} +
+ setNewRootDraft(l.id, { name: e.target.value })} + /> + setNewRootDraft(l.id, { path: e.target.value })} + /> + +
+
+ {l.type}
diff --git a/web/src/pages/autoOrganizeModel.ts b/web/src/pages/autoOrganizeModel.ts index 9b268ed..5d35db8 100644 --- a/web/src/pages/autoOrganizeModel.ts +++ b/web/src/pages/autoOrganizeModel.ts @@ -6,6 +6,7 @@ export type AutoOrganizeConfig = { scrapeAfter: string downloadSmartClassify: string smartClassify: string + autoAddLibrary: string sourceDir: string targetDir: string transferMode: string @@ -27,6 +28,7 @@ export const AUTO_ORGANIZE_DEFAULTS: AutoOrganizeConfig = { scrapeAfter: 'true', downloadSmartClassify: 'true', smartClassify: 'true', + autoAddLibrary: 'true', sourceDir: '', targetDir: '', transferMode: 'hardlink', @@ -48,6 +50,7 @@ export const AUTO_ORGANIZE_KEYS: Record = { scrapeAfter: 'organize.scrape_after', downloadSmartClassify: 'downloads.smart_classify', smartClassify: 'organizer.smart_classify', + autoAddLibrary: 'organize.auto_add_library', sourceDir: 'organize.source_dir', targetDir: 'organize.target_dir', transferMode: 'organize.transfer_mode', @@ -73,6 +76,7 @@ export function mergeAutoOrganizeSettings(rows: Setting[]): AutoOrganizeConfig { scrapeAfter: idx[AUTO_ORGANIZE_KEYS.scrapeAfter] ?? AUTO_ORGANIZE_DEFAULTS.scrapeAfter, downloadSmartClassify: idx[AUTO_ORGANIZE_KEYS.downloadSmartClassify] ?? AUTO_ORGANIZE_DEFAULTS.downloadSmartClassify, 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, targetDir: idx[AUTO_ORGANIZE_KEYS.targetDir] ?? AUTO_ORGANIZE_DEFAULTS.targetDir, transferMode: idx[AUTO_ORGANIZE_KEYS.transferMode] ?? AUTO_ORGANIZE_DEFAULTS.transferMode, diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 88fcfe7..2b73606 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -95,12 +95,24 @@ export interface LoginResponse { } // 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 { id: string name: string path: string type: string enabled: boolean + roots?: LibraryRoot[] created_at: string updated_at: string } @@ -108,6 +120,7 @@ export interface Library { export interface Media { id: string library_id: string + library_root_id?: string library_name?: string library_path?: string display_library_id?: string @@ -118,6 +131,7 @@ export interface Media { original_name?: string episode_title?: string path: string + relative_path?: string size_bytes: number duration_sec: number width: number