mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
support multiple roots per library
This commit is contained in:
@@ -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
@@ -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
@@ -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{},
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user