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.
+107 -2
View File
@@ -16,7 +16,9 @@ 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"`
Paths []string `json:"paths"`
Roots []service.LibraryRootInput `json:"roots"`
Type string `json:"type"` Type string `json:"type"`
} }
@@ -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))
+15 -1
View File
@@ -59,24 +59,37 @@ 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"`
LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"` SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
Title string `gorm:"size:255;not null" json:"title"` Title string `gorm:"size:255;not null" json:"title"`
OriginalName string `gorm:"size:255" json:"original_name,omitempty"` OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"` EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"` Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"`
RelativePath string `gorm:"size:1024" json:"relative_path,omitempty"`
SizeBytes int64 `json:"size_bytes"` SizeBytes int64 `json:"size_bytes"`
DurationSec int `json:"duration_sec"` DurationSec int `json:"duration_sec"`
Width int `json:"width"` Width int `json:"width"`
@@ -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)
}
+2
View File
@@ -263,6 +263,8 @@ type existingCloudMedia struct {
} }
type existingLocalMedia struct { type existingLocalMedia struct {
LibraryRootID string
RelativePath string
Title string Title string
OriginalName string OriginalName string
EpisodeTitle string EpisodeTitle string
+3 -1
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,6 +78,8 @@ func (s *ScannerService) existingLocalMediaSnapshot(ctx context.Context, library
continue continue
} }
snapshot[filepath.Clean(row.Path)] = existingLocalMedia{ snapshot[filepath.Clean(row.Path)] = existingLocalMedia{
LibraryRootID: row.LibraryRootID,
RelativePath: row.RelativePath,
Title: row.Title, Title: row.Title,
OriginalName: row.OriginalName, OriginalName: row.OriginalName,
EpisodeTitle: row.EpisodeTitle, EpisodeTitle: row.EpisodeTitle,
@@ -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)
}
}
+12 -4
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
@@ -137,6 +143,8 @@ func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Medi
media := &model.Media{ media := &model.Media{
LibraryID: in.lib.ID, LibraryID: in.lib.ID,
LibraryRootID: libraryRootID(in.root),
RelativePath: localRelativePath(in.path, in.root),
Title: title, Title: title,
Year: year, Year: year,
Path: in.path, Path: in.path,
@@ -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
}
+97 -20
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
}
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 { } else {
res.Removed = removed 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)
} }
+14 -3
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,14 +100,23 @@ 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
if len(roots) == 0 && l.Path != "" {
roots = []model.LibraryRoot{{LibraryID: l.ID, Path: l.Path, Enabled: true}}
}
for _, root := range roots {
if !root.Enabled {
continue continue
} }
watchRoot, info, err := resolveAccessibleMappedPath(l.Path) if _, _, ok := parseCloudLibraryPath(root.Path); ok {
continue
}
watchRoot, info, err := resolveAccessibleMappedPath(root.Path)
if err != nil || !info.IsDir() { if err != nil || !info.IsDir() {
w.log.Warn("watch path inaccessible", w.log.Warn("watch path inaccessible",
zap.String("path", l.Path), zap.String("path", root.Path),
zap.String("library_id", l.ID), zap.String("library_id", l.ID),
zap.String("root_id", root.ID),
zap.Error(err)) zap.Error(err))
continue continue
} }
@@ -114,6 +124,7 @@ func (w *WatcherService) Refresh(ctx context.Context) error {
current[dir] = l.ID current[dir] = l.ID
} }
} }
}
// Remove disappeared paths. // Remove disappeared paths.
for path := range w.watched { for path := range w.watched {
if _, ok := current[path]; !ok { if _, ok := current[path]; !ok {
+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