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
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.
+109 -4
View File
@@ -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
@@ -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))
+48 -34
View File
@@ -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{},
+96 -2
View File
@@ -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{})
}
+12
View File
@@ -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 {
+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")
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)
}
+202 -6
View File
@@ -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
}
@@ -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")
@@ -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),
+11
View File
@@ -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)
}
+30 -28
View File
@@ -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
}
+31 -29
View File
@@ -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
@@ -0,0 +1,90 @@
package service
import (
"os"
"path/filepath"
"testing"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
func TestScanLibraryScansMultipleRootsAndPrunesPerRoot(t *testing.T) {
rootA := t.TempDir()
rootB := t.TempDir()
fileA := filepath.Join(rootA, "电影A.2024.mkv")
fileB := filepath.Join(rootB, "电影B.2025.mkv")
writeTestFile(t, fileA, "a")
writeTestFile(t, fileB, "b")
db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}, &model.Setting{})
repos := repository.New(db)
lib := &model.Library{Name: "电影", Path: rootA, Type: "movie", Enabled: true}
roots := []model.LibraryRoot{
{Name: "硬盘1", Path: rootA, Enabled: true, SortOrder: 0},
{Name: "硬盘2", Path: rootB, Enabled: true, SortOrder: 1},
}
if err := repos.Library.CreateWithRoots(t.Context(), lib, roots); err != nil {
t.Fatal(err)
}
scanner := NewScannerService(nil, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
res, err := scanner.ScanLibrary(t.Context(), lib.ID)
if err != nil {
t.Fatalf("scan multiple roots: %v", err)
}
if res.Added != 2 {
t.Fatalf("added = %d, want 2", res.Added)
}
var rows []model.Media
if err := db.Order("path asc").Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 2 {
t.Fatalf("media rows = %d, want 2", len(rows))
}
for _, row := range rows {
if row.LibraryRootID == "" {
t.Fatalf("media %q missing library_root_id", row.Path)
}
if row.RelativePath == "" {
t.Fatalf("media %q missing relative_path", row.Path)
}
}
removeTestPath(t, fileA)
removeTestPath(t, rootB)
res, err = scanner.ScanLibrary(t.Context(), lib.ID)
if err != nil {
t.Fatalf("scan with one offline root should continue: %v", err)
}
if res.Removed != 1 {
t.Fatalf("removed = %d, want only vanished file from accessible root removed", res.Removed)
}
rows = nil
if err := db.Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Path != fileB {
t.Fatalf("remaining rows = %#v, want offline root media preserved", rows)
}
}
func writeTestFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func removeTestPath(t *testing.T, path string) {
t.Helper()
if err := os.RemoveAll(path); err != nil {
t.Fatal(err)
}
}
+21 -13
View File
@@ -13,7 +13,7 @@ import (
// ingestFile upserts a single media file. seenInodes dedups hardlinks within a
// 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"
@@ -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,
+46
View File
@@ -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) {
+122
View File
@@ -0,0 +1,122 @@
package service
import (
"context"
"errors"
"path/filepath"
"strings"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
func (s *ScannerService) localLibraryScanRoots(ctx context.Context, lib *model.Library) ([]model.LibraryRoot, error) {
if lib == nil {
return nil, errors.New("library not found")
}
roots := append([]model.LibraryRoot(nil), lib.Roots...)
if len(roots) == 0 && s != nil && s.repo != nil && s.repo.Library != nil {
var err error
roots, err = s.repo.Library.ListRoots(ctx, lib.ID)
if err != nil {
return nil, err
}
}
if len(roots) == 0 && strings.TrimSpace(lib.Path) != "" {
roots = []model.LibraryRoot{{
LibraryID: lib.ID,
Path: lib.Path,
Enabled: lib.Enabled,
}}
}
out := roots[:0]
for _, root := range roots {
if !root.Enabled || strings.TrimSpace(root.Path) == "" {
continue
}
if _, ok := ParseCloudLibraryMount(root.Path); ok {
continue
}
out = append(out, root)
}
return out, nil
}
func (s *ScannerService) localLibraryRootForPath(ctx context.Context, lib *model.Library, pathValue string) (*model.LibraryRoot, error) {
roots, err := s.localLibraryScanRoots(ctx, lib)
if err != nil {
return nil, err
}
cleanPath := filepath.Clean(strings.TrimSpace(pathValue))
for i := range roots {
rootPath := filepath.Clean(strings.TrimSpace(roots[i].Path))
if sameLibraryPath(cleanPath, rootPath) || pathWithin(cleanPath, rootPath) {
return &roots[i], nil
}
}
if len(roots) > 0 {
return &roots[0], nil
}
return nil, errors.New("library has no enabled paths")
}
func (s *ScannerService) resolveLocalLibraryRootPath(ctx context.Context, lib *model.Library, root *model.LibraryRoot) error {
if root == nil || strings.TrimSpace(root.Path) == "" {
return nil
}
resolved, err := resolveAccessibleLibraryPath(root.Path)
if err != nil {
return err
}
if sameLibraryPath(resolved, root.Path) {
root.Path = filepath.Clean(root.Path)
return nil
}
if s.repo != nil && s.repo.DB != nil && strings.TrimSpace(root.ID) != "" {
if updateErr := s.repo.DB.WithContext(ctx).Model(&model.LibraryRoot{}).Where("id = ?", root.ID).Update("path", resolved).Error; updateErr != nil && s.log != nil {
s.log.Warn("update mapped library root path failed",
zap.String("library_id", lib.ID),
zap.String("root_id", root.ID),
zap.String("from", root.Path),
zap.String("to", resolved),
zap.Error(updateErr))
}
}
if lib != nil {
if strings.TrimSpace(root.ID) == "" {
_ = s.repo.DB.WithContext(ctx).Model(&model.Library{}).Where("id = ?", lib.ID).Update("path", resolved).Error
lib.Path = resolved
} else if roots, err := s.repo.Library.ListRoots(ctx, lib.ID); err == nil && len(roots) > 0 && roots[0].ID == root.ID {
_ = s.repo.DB.WithContext(ctx).Model(&model.Library{}).Where("id = ?", lib.ID).Update("path", resolved).Error
lib.Path = resolved
}
}
if s.log != nil {
s.log.Info("mapped library root path for scan",
zap.String("library_id", lib.ID),
zap.String("root_id", root.ID),
zap.String("from", root.Path),
zap.String("to", resolved))
}
root.Path = resolved
return nil
}
func libraryRootID(root *model.LibraryRoot) string {
if root == nil {
return ""
}
return strings.TrimSpace(root.ID)
}
func localRelativePath(pathValue string, root *model.LibraryRoot) string {
if root == nil || strings.TrimSpace(root.Path) == "" || strings.TrimSpace(pathValue) == "" {
return ""
}
rel, err := filepath.Rel(filepath.Clean(root.Path), filepath.Clean(pathValue))
if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
return ""
}
return rel
}
+98 -21
View File
@@ -19,6 +19,24 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
return s.scanLibrary(ctx, libraryID, true)
}
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)
}
+23 -12
View File
@@ -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.
+24 -1
View File
@@ -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<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),
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) =>
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) =>
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 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<Library[]>([])
const [name, setName] = useState('')
const [path, setPath] = useState('')
const [roots, setRoots] = useState<RootDraft[]>([emptyRootDraft()])
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)
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<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 (
<div className="space-y-6">
<form onSubmit={handleCreate} className="glass-panel grid gap-3 md:grid-cols-4">
@@ -43,17 +122,6 @@ export function AdminLibraryPanel() {
value={name}
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)}>
<option value="movie">电影</option>
<option value="tv">电视剧</option>
@@ -61,6 +129,41 @@ export function AdminLibraryPanel() {
<option value="anime">动漫</option>
<option value="music">音乐</option>
</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>
@@ -80,7 +183,111 @@ export function AdminLibraryPanel() {
{libs.map((l) => (
<tr key={l.id} className="border-t border-gray-200">
<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="space-x-2 py-2 text-right">
<button
@@ -97,6 +97,7 @@ export function AutoOrganizeBasicTab({
<BooleanSetting config={config} settingKey="afterDownload" label="qB 下载完成后自动整理" onConfigChange={onConfigChange} />
<BooleanSetting config={config} settingKey="downloadSmartClassify" 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} />
</div>
</>
+4
View File
@@ -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<keyof AutoOrganizeConfig, string> = {
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,
+14
View File
@@ -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