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