Unify organize ingest pipeline

This commit is contained in:
ShukeBta
2026-06-14 23:17:52 +08:00
parent a623702276
commit 479a06f008
41 changed files with 3075 additions and 728 deletions
+5
View File
@@ -9,6 +9,11 @@ docs/
data/ data/
cache/ cache/
logs/ logs/
verify-data/
verify-cache/
verify-media/
verify-downloads/
.codex-*
.tmp_* .tmp_*
.tmp-deploy-* .tmp-deploy-*
.tmp-deploy-data/ .tmp-deploy-data/
+1
View File
@@ -64,6 +64,7 @@ config.yaml
# Runtime / local-only artifacts (清理补充) # Runtime / local-only artifacts (清理补充)
.tmp-live-backups/ .tmp-live-backups/
.tmp-* .tmp-*
.codex-*
downloads/ downloads/
media/ media/
*.pid *.pid
+9 -7
View File
@@ -71,8 +71,8 @@ func downloadOrganizeOneHandler(svc *service.Container) gin.HandlerFunc {
// existing OrganizerService for each library that contains those files. // existing OrganizerService for each library that contains those files.
func downloadOrganizeAllHandler(svc *service.Container) gin.HandlerFunc { func downloadOrganizeAllHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// Walk each library and re-organize. The OrganizerService is // Walk each library through the unified organize pipeline so rename,
// idempotent so this is safe to run repeatedly. // scan, scrape and task reporting stay identical to manual organize.
libs, err := svc.Repo.Library.List(c.Request.Context()) libs, err := svc.Repo.Library.List(c.Request.Context())
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -80,15 +80,17 @@ func downloadOrganizeAllHandler(svc *service.Container) gin.HandlerFunc {
} }
results := make([]any, 0, len(libs)) results := make([]any, 0, len(libs))
for _, l := range libs { for _, l := range libs {
res, err := svc.Organizer.OrganizeLibrary(c.Request.Context(), l.ID) resp, err := organizePipeline(svc).Run(c.Request.Context(), service.OrganizePipelineRequest{
Scope: service.OrganizeScopeLibrary,
Trigger: service.OrganizeTriggerManual,
TaskName: "批量整理媒体库:" + l.Name,
LibraryID: l.ID,
})
if err != nil { if err != nil {
results = append(results, gin.H{"library": l.Name, "error": err.Error()}) results = append(results, gin.H{"library": l.Name, "error": err.Error()})
continue continue
} }
if svc.Scan != nil && res != nil && !res.DryRun { results = append(results, gin.H{"library": l.Name, "result": resp.Result})
res.Scans, res.Scrapes = scanAndScrapeAfterOrganize(c, svc, res.DestPath, l.ID, nil)
}
results = append(results, gin.H{"library": l.Name, "result": res})
} }
c.JSON(http.StatusOK, gin.H{"results": results}) c.JSON(http.StatusOK, gin.H{"results": results})
} }
+7 -5
View File
@@ -129,15 +129,17 @@ func organizeBulkHandler(svc *service.Container) gin.HandlerFunc {
} }
out := make([]any, 0, len(libs)) out := make([]any, 0, len(libs))
for _, l := range libs { for _, l := range libs {
res, err := svc.Organizer.OrganizeLibrary(c.Request.Context(), l.ID) resp, err := organizePipeline(svc).Run(c.Request.Context(), service.OrganizePipelineRequest{
Scope: service.OrganizeScopeLibrary,
Trigger: service.OrganizeTriggerManual,
TaskName: "批量整理媒体库:" + l.Name,
LibraryID: l.ID,
})
if err != nil { if err != nil {
out = append(out, gin.H{"library": l.Name, "error": err.Error()}) out = append(out, gin.H{"library": l.Name, "error": err.Error()})
continue continue
} }
if svc.Scan != nil && res != nil && !res.DryRun { out = append(out, gin.H{"library": l.Name, "result": resp.Result})
res.Scans, res.Scrapes = scanAndScrapeAfterOrganize(c, svc, res.DestPath, l.ID, nil)
}
out = append(out, gin.H{"library": l.Name, "result": res})
} }
c.JSON(http.StatusOK, gin.H{"results": out}) c.JSON(http.StatusOK, gin.H{"results": out})
} }
+35 -69
View File
@@ -57,22 +57,18 @@ func organizeMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
var req organizeReq var req organizeReq
_ = c.ShouldBindJSON(&req) _ = c.ShouldBindJSON(&req)
opts := organizeOptionsFromReq(req) runReq := organizePipelineRequestFromReq(req, service.OrganizeScopeMedia, "手动整理媒体")
task := startOrganizeHTTPTask(svc, "手动整理媒体", opts) runReq.MediaID = c.Param("id")
dst, err := svc.Organizer.OrganizeMediaWithOptions(c.Request.Context(), c.Param("id"), opts) resp, err := organizePipeline(svc).Run(c.Request.Context(), runReq)
if err != nil { if err != nil {
finishHTTPTask(task, err, "organize", "手动整理媒体失败", nil, nil)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
payload := gin.H{"path": dst} payload := gin.H{"path": resp.Path}
if organizeScanAfter(req.ScanAfter) && !req.DryRun && svc.Scan != nil { if resp.Result != nil {
updateHTTPTask(task, "scan_scrape", "正在扫描入库并按设置刮削", nil, nil) payload["scans"] = resp.Result.Scans
scans, scrapes := scanAndScrapeAfterOrganize(c, svc, dst, strings.TrimSpace(req.LibraryID), req.ScrapeAfter) payload["scrapes"] = resp.Result.Scrapes
payload["scans"] = scans
payload["scrapes"] = scrapes
} }
finishHTTPTask(task, nil, "completed", "手动整理媒体结束", nil, nil)
c.JSON(http.StatusOK, payload) c.JSON(http.StatusOK, payload)
} }
} }
@@ -81,21 +77,14 @@ func organizeLibraryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
var req organizeReq var req organizeReq
_ = c.ShouldBindJSON(&req) _ = c.ShouldBindJSON(&req)
opts := organizeOptionsFromReq(req) runReq := organizePipelineRequestFromReq(req, service.OrganizeScopeLibrary, "手动整理媒体库")
task := startOrganizeHTTPTask(svc, "手动整理媒体库", opts) runReq.LibraryID = c.Param("id")
res, err := svc.Organizer.OrganizeLibraryWithOptions(c.Request.Context(), c.Param("id"), opts) resp, err := organizePipeline(svc).Run(c.Request.Context(), runReq)
if err != nil { if err != nil {
finishHTTPTask(task, err, "organize", "手动整理媒体库失败", nil, nil)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
details := service.OrganizeTaskDetails(res, 8) c.JSON(http.StatusOK, resp.Result)
if organizeScanAfter(req.ScanAfter) && !req.DryRun && svc.Scan != nil {
updateHTTPTask(task, "scan_scrape", "正在扫描入库并按设置刮削", service.OrganizeTaskMetrics(res), details)
res.Scans, res.Scrapes = scanAndScrapeAfterOrganize(c, svc, res.DestPath, c.Param("id"), req.ScrapeAfter)
}
finishHTTPTask(task, nil, "completed", "手动整理媒体库结束", service.OrganizeTaskMetrics(res), details)
c.JSON(http.StatusOK, res)
} }
} }
@@ -113,63 +102,40 @@ func organizeDirectoryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
var req organizeReq var req organizeReq
_ = c.ShouldBindJSON(&req) _ = c.ShouldBindJSON(&req)
opts := organizeOptionsFromReq(req) runReq := organizePipelineRequestFromReq(req, service.OrganizeScopeDirectory, "手动整理入库")
task := startOrganizeHTTPTask(svc, "手动整理入库", opts) runReq.PreferredLibraryID = strings.TrimSpace(req.LibraryID)
res, err := svc.Organizer.OrganizeDirectory(c.Request.Context(), opts) resp, err := organizePipeline(svc).Run(c.Request.Context(), runReq)
if err != nil { if err != nil {
finishHTTPTask(task, err, "organize", "手动整理入库失败", nil, nil)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
details := service.OrganizeTaskDetails(res, 8) c.JSON(http.StatusOK, resp.Result)
updateHTTPTask(task, "organize", "手动整理完成,准备扫描入库", service.OrganizeTaskMetrics(res), details)
if organizeScanAfter(req.ScanAfter) && !req.DryRun && svc.Scan != nil {
updateHTTPTask(task, "scan_scrape", "正在扫描入库并按设置刮削", service.OrganizeTaskMetrics(res), details)
res.Scans, res.Scrapes = scanAndScrapeAfterOrganize(c, svc, res.DestPath, strings.TrimSpace(req.LibraryID), req.ScrapeAfter)
}
finishHTTPTask(task, nil, "completed", "手动整理入库结束", service.OrganizeTaskMetrics(res), details)
c.JSON(http.StatusOK, res)
} }
} }
func startOrganizeHTTPTask(svc *service.Container, name string, opts service.OrganizeOptions) *service.TaskHandle { func organizePipelineRequestFromReq(req organizeReq, scope service.OrganizeScope, taskName string) service.OrganizePipelineRequest {
if svc == nil || svc.Tasks == nil { dest := strings.TrimSpace(req.DestPath)
return nil if dest == "" {
dest = strings.TrimSpace(req.TargetPath)
} }
message := "正在整理/重命名/入库" return service.OrganizePipelineRequest{
if opts.DryRun { Scope: scope,
message = "正在预览整理/重命名" Trigger: service.OrganizeTriggerManual,
TaskName: taskName,
SourcePath: strings.TrimSpace(req.SourcePath),
DestPath: dest,
TransferMode: strings.TrimSpace(req.TransferMode),
MediaType: strings.TrimSpace(req.MediaType),
MediaCategory: strings.TrimSpace(req.MediaCategory),
ScanAfter: req.ScanAfter,
ScrapeAfter: req.ScrapeAfter,
DryRun: req.DryRun,
} }
return svc.Tasks.Start(service.TaskKindOrganize, name, service.TaskUpdate{
Stage: "organize",
SourcePath: opts.SourcePath,
DestPath: opts.DestPath,
Message: message,
})
} }
func updateHTTPTask(task *service.TaskHandle, stage, message string, metrics map[string]int64, details []string) { func organizePipeline(svc *service.Container) *service.OrganizePipelineService {
if task == nil { if svc.OrganizePipeline != nil {
return return svc.OrganizePipeline
} }
task.Update(service.TaskUpdate{Stage: stage, Message: message, Metrics: metrics, Details: details}) return service.NewOrganizePipelineService(svc.Log, svc.Repo, svc.Organizer, svc.Scan, svc.Tasks)
}
func finishHTTPTask(task *service.TaskHandle, err error, stage, message string, metrics map[string]int64, details []string) {
if task == nil {
return
}
task.Finish(err, service.TaskUpdate{Stage: stage, Message: message, Metrics: metrics, Details: details})
}
func scanAndScrapeAfterOrganize(c *gin.Context, svc *service.Container, destRoot, preferredLibraryID string, scrapeOverride *bool) ([]service.OrganizeScanSummary, []service.OrganizeScrapeSummary) {
scrapeAfter := service.OrganizeScrapeAfterEnabled(c.Request.Context(), svc.Repo)
if scrapeOverride != nil {
scrapeAfter = *scrapeOverride
}
return svc.Scan.ScanAndScrapeLibrariesForPath(c.Request.Context(), destRoot, preferredLibraryID, scrapeAfter)
}
func organizeScanAfter(value *bool) bool {
return value == nil || *value
} }
+1
View File
@@ -91,6 +91,7 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc *
// Subscriptions. // Subscriptions.
authed.GET("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), listSubscriptionsHandler(svc)) authed.GET("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), listSubscriptionsHandler(svc))
authed.GET("/subscriptions/history", requirePermission(svc, "can_manage_subscriptions"), listSubscriptionHistoryHandler(svc))
authed.POST("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), createSubscriptionHandler(svc)) authed.POST("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), createSubscriptionHandler(svc))
authed.DELETE("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), deleteSubscriptionHandler(svc)) authed.DELETE("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), deleteSubscriptionHandler(svc))
authed.POST("/subscriptions/:id/run", requirePermission(svc, "can_manage_subscriptions"), runSubscriptionHandler(svc)) authed.POST("/subscriptions/:id/run", requirePermission(svc, "can_manage_subscriptions"), runSubscriptionHandler(svc))
+12
View File
@@ -98,6 +98,18 @@ func listSubscriptionsHandler(svc *service.Container) gin.HandlerFunc {
} }
} }
func listSubscriptionHistoryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
items, err := svc.Subscription.History(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
service.EnrichSubscriptionProgress(c.Request.Context(), svc.Repo, items)
c.JSON(http.StatusOK, gin.H{"items": items})
}
}
func deleteSubscriptionHandler(svc *service.Container) gin.HandlerFunc { func deleteSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if err := svc.Subscription.Delete(c.Request.Context(), c.Param("id")); err != nil { if err := svc.Subscription.Delete(c.Request.Context(), c.Param("id")); err != nil {
-24
View File
@@ -76,30 +76,6 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
{"key": "ffprobe.max_concurrent", "type": "number", "label": "FFprobe 最大并发"}, {"key": "ffprobe.max_concurrent", "type": "number", "label": "FFprobe 最大并发"},
}, },
}, },
{
"key": "organize",
"label": "整理 & 刮削",
"items": []gin.H{
{"key": "organize.auto", "type": "toggle", "label": "整理源目录定时自动整理"},
{"key": "organizer.auto_after_download", "type": "toggle"},
{"key": "organize.scrape_after", "type": "toggle", "label": "整理后自动刮削"},
{"key": "downloads.smart_classify", "type": "toggle"},
{"key": "organizer.smart_classify", "type": "toggle"},
{"key": "organize.source_dir", "type": "text", "label": "整理源目录"},
{"key": "organize.target_dir", "type": "text", "label": "整理目的地目录"},
{"key": "organize.transfer_mode", "type": "select", "label": "默认转移方式"},
{"key": "organize.interval_seconds", "type": "number", "label": "自动整理间隔秒数"},
{"key": "organize.keep_seeding", "type": "toggle", "label": "保种"},
{"key": "organize.movie_format", "type": "text"},
{"key": "organize.tv_format", "type": "text"},
{"key": "organize.anime_format", "type": "text"},
{"key": "scrape.auto_on_scan", "type": "toggle"},
{"key": "scrape.providers", "type": "text"},
{"key": "scrape.language", "type": "text"},
{"key": "scrape.delay_min_ms", "type": "number", "label": "刮削最小间隔毫秒"},
{"key": "scrape.delay_max_ms", "type": "number", "label": "刮削最大间隔毫秒"},
},
},
{ {
"key": "cloud-upload", "key": "cloud-upload",
"label": "网盘转存", "label": "网盘转存",
+10
View File
@@ -0,0 +1,10 @@
package handler
import "github.com/ShukeBta/MediaStationGo/internal/service"
func finishHTTPTask(task *service.TaskHandle, err error, stage, message string, metrics map[string]int64, details []string) {
if task == nil {
return
}
task.Finish(err, service.TaskUpdate{Stage: stage, Message: message, Metrics: metrics, Details: details})
}
+2
View File
@@ -238,6 +238,8 @@ type Subscription struct {
Priority int `gorm:"default:50" json:"priority,omitempty"` // lower is earlier when schedulers sort later Priority int `gorm:"default:50" json:"priority,omitempty"` // lower is earlier when schedulers sort later
Enabled bool `gorm:"default:true" json:"enabled"` Enabled bool `gorm:"default:true" json:"enabled"`
LastRunAt *time.Time `json:"last_run_at,omitempty"` LastRunAt *time.Time `json:"last_run_at,omitempty"`
ArchivedAt *time.Time `gorm:"index" json:"archived_at,omitempty"`
ArchiveReason string `gorm:"size:255" json:"archive_reason,omitempty"`
DownloadedEpisodes int `gorm:"-" json:"downloaded_episodes,omitempty"` DownloadedEpisodes int `gorm:"-" json:"downloaded_episodes,omitempty"`
LocalMediaCount int `gorm:"-" json:"local_media_count,omitempty"` LocalMediaCount int `gorm:"-" json:"local_media_count,omitempty"`
+22 -2
View File
@@ -809,13 +809,33 @@ func (r *SubscriptionRepository) Create(ctx context.Context, s *model.Subscripti
return r.db.WithContext(ctx).Select("*").Omit("DeletedAt").Create(s).Error return r.db.WithContext(ctx).Select("*").Omit("DeletedAt").Create(s).Error
} }
// List returns all subscription rules. // List returns active subscription rules. Archived rows live in history and are
// intentionally excluded from scheduler polling and the active management list.
func (r *SubscriptionRepository) List(ctx context.Context) ([]model.Subscription, error) { func (r *SubscriptionRepository) List(ctx context.Context) ([]model.Subscription, error) {
var rows []model.Subscription var rows []model.Subscription
err := r.db.WithContext(ctx).Order("created_at desc").Find(&rows).Error err := r.db.WithContext(ctx).Where("archived_at IS NULL").Order("created_at desc").Find(&rows).Error
return rows, err return rows, err
} }
// History returns archived subscription rules.
func (r *SubscriptionRepository) History(ctx context.Context) ([]model.Subscription, error) {
var rows []model.Subscription
err := r.db.WithContext(ctx).Where("archived_at IS NOT NULL").Order("archived_at desc, updated_at desc").Find(&rows).Error
return rows, err
}
// Archive moves a completed subscription out of the active list without
// deleting its rule details, so users can audit completed subscriptions later.
func (r *SubscriptionRepository) Archive(ctx context.Context, id, reason string, archivedAt time.Time) error {
return r.db.WithContext(ctx).Model(&model.Subscription{}).
Where("id = ? AND archived_at IS NULL", id).
Updates(map[string]any{
"enabled": false,
"archived_at": &archivedAt,
"archive_reason": reason,
}).Error
}
// ─── Setting ───────────────────────────────────────────────────────────────── // ─── Setting ─────────────────────────────────────────────────────────────────
// SettingRepository persists key/value preferences. // SettingRepository persists key/value preferences.
+7
View File
@@ -66,6 +66,13 @@ func (s *DownloadClientService) Create(ctx context.Context, in DownloadClientInp
IsDefault: normalized.IsDefault, IsDefault: normalized.IsDefault,
Enabled: normalized.Enabled, Enabled: normalized.Enabled,
} }
if !c.IsDefault && c.Enabled {
if currentDefault, err := s.repo.DownloadClient.FindDefault(ctx); err == nil && currentDefault == nil {
if enabled, err := s.repo.DownloadClient.ListEnabled(ctx); err == nil && len(enabled) == 0 {
c.IsDefault = true
}
}
}
if normalized.IsDefault { if normalized.IsDefault {
_ = s.repo.DownloadClient.ClearDefault(ctx) _ = s.repo.DownloadClient.ClearDefault(ctx)
} }
+25
View File
@@ -63,6 +63,31 @@ func TestDownloadClientCreateNormalizesHostAndClearsDefault(t *testing.T) {
} }
} }
func TestDownloadClientCreateMakesFirstEnabledClientDefault(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.DownloadClient{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := NewDownloadClientService(zap.NewNop(), repos)
client, err := svc.Create(t.Context(), DownloadClientInput{
Name: "qB",
Type: "qbittorrent",
Host: "127.0.0.1:8080",
Enabled: true,
})
if err != nil {
t.Fatal(err)
}
if !client.IsDefault {
t.Fatalf("first enabled client should become default: %#v", client)
}
}
func TestDownloadClientRejectsUnsupportedHostScheme(t *testing.T) { func TestDownloadClientRejectsUnsupportedHostScheme(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil { if err != nil {
+139 -80
View File
@@ -39,14 +39,15 @@ import (
// DownloadService is the single download orchestrator. // DownloadService is the single download orchestrator.
type DownloadService struct { type DownloadService struct {
log *zap.Logger log *zap.Logger
repo *repository.Container repo *repository.Container
hub *Hub hub *Hub
qb *QBitClient qb *QBitClient
organizer *OrganizerService organizer *OrganizerService
scanner *ScannerService organizePipeline *OrganizePipelineService
site *SiteService scanner *ScannerService
tasks *TaskTrackerService site *SiteService
tasks *TaskTrackerService
mu sync.Mutex mu sync.Mutex
stopCh chan struct{} stopCh chan struct{}
@@ -62,6 +63,10 @@ func (d *DownloadService) SetScanner(scanner *ScannerService) {
d.scanner = scanner d.scanner = scanner
} }
func (d *DownloadService) SetOrganizePipeline(pipeline *OrganizePipelineService) {
d.organizePipeline = pipeline
}
func (d *DownloadService) SetTaskTracker(tasks *TaskTrackerService) { func (d *DownloadService) SetTaskTracker(tasks *TaskTrackerService) {
d.tasks = tasks d.tasks = tasks
} }
@@ -203,6 +208,10 @@ func (d *DownloadService) ReloadConfig(ctx context.Context) error {
cfg.BaseURL = strings.TrimRight(c.Host, "/") cfg.BaseURL = strings.TrimRight(c.Host, "/")
cfg.Username = c.Username cfg.Username = c.Username
cfg.Password = c.Password cfg.Password = c.Password
} else if c, err := d.soleEnabledQBitClient(ctx); err == nil && c != nil {
cfg.BaseURL = strings.TrimRight(c.Host, "/")
cfg.Username = c.Username
cfg.Password = c.Password
} }
} }
if d.repo.Setting != nil { if d.repo.Setting != nil {
@@ -228,6 +237,28 @@ func (d *DownloadService) ReloadConfig(ctx context.Context) error {
return nil return nil
} }
func (d *DownloadService) soleEnabledQBitClient(ctx context.Context) (*model.DownloadClient, error) {
if d == nil || d.repo == nil || d.repo.DownloadClient == nil {
return nil, nil
}
rows, err := d.repo.DownloadClient.ListEnabled(ctx)
if err != nil {
return nil, err
}
var selected *model.DownloadClient
for i := range rows {
if rows[i].Type != "qbittorrent" {
continue
}
if selected != nil {
return nil, nil
}
row := rows[i]
selected = &row
}
return selected, nil
}
// AddDownload accepts a magnet URL / HTTP URL and persists a tracking row. // AddDownload accepts a magnet URL / HTTP URL and persists a tracking row.
func (d *DownloadService) AddDownload(ctx context.Context, userID, urlStr, savePath string) (*model.DownloadTask, error) { func (d *DownloadService) AddDownload(ctx context.Context, userID, urlStr, savePath string) (*model.DownloadTask, error) {
return d.AddDownloadWithMeta(ctx, userID, urlStr, savePath, DownloadTaskMeta{}) return d.AddDownloadWithMeta(ctx, userID, urlStr, savePath, DownloadTaskMeta{})
@@ -313,7 +344,7 @@ func (d *DownloadService) resolveDownloadSavePath(ctx context.Context, explicitS
if !autoClassify || category == "" { if !autoClassify || category == "" {
return base, "" return base, ""
} }
return categoryRoot(base, sanitizeFilename(category)), category return downloadSavePathCategoryRoot(base, sanitizeFilename(category)), category
} }
func (d *DownloadService) localMediaAlreadyExists(ctx context.Context, title string) bool { func (d *DownloadService) localMediaAlreadyExists(ctx context.Context, title string) bool {
@@ -738,7 +769,7 @@ func (d *DownloadService) Delete(ctx context.Context, hash string, withFiles boo
if err := d.qb.Delete(ctx, hash, withFiles); err != nil { if err := d.qb.Delete(ctx, hash, withFiles); err != nil {
return err return err
} }
d.markDownloadTaskDeleted(ctx, torrentName) d.markDownloadTaskDeleted(ctx, hash, torrentName)
stateKey := strings.ToLower(hash) stateKey := strings.ToLower(hash)
d.mu.Lock() d.mu.Lock()
delete(d.prevStates, stateKey) delete(d.prevStates, stateKey)
@@ -747,16 +778,28 @@ func (d *DownloadService) Delete(ctx context.Context, hash string, withFiles boo
return nil return nil
} }
func (d *DownloadService) markDownloadTaskDeleted(ctx context.Context, torrentName string) { func (d *DownloadService) markDownloadTaskDeleted(ctx context.Context, hash, torrentName string) {
if d == nil || d.repo == nil || d.repo.DB == nil || strings.TrimSpace(torrentName) == "" { if d == nil || d.repo == nil || d.repo.DB == nil {
return return
} }
rows, err := d.repo.Download.List(ctx) rows, err := d.repo.Download.List(ctx)
if err != nil { if err != nil {
return return
} }
taskByKey := tasksByIdentity(rows) if matched, ok := findDownloadTaskByHash(rows, hash); ok {
matched, ok := findMatchingTaskByIdentity(torrentName, taskByKey) _ = d.repo.DB.WithContext(ctx).Model(&model.DownloadTask{}).
Where("id = ?", matched.ID).
Updates(map[string]any{
"status": "deleted",
"progress": matched.Progress,
}).Error
return
}
if strings.TrimSpace(torrentName) == "" {
return
}
taskByKey := tasksByTorrentIdentity(rows)
matched, ok := findMatchingTaskByTorrentIdentity(torrentName, taskByKey)
if !ok { if !ok {
return return
} }
@@ -768,6 +811,19 @@ func (d *DownloadService) markDownloadTaskDeleted(ctx context.Context, torrentNa
}).Error }).Error
} }
func findDownloadTaskByHash(rows []model.DownloadTask, hash string) (model.DownloadTask, bool) {
hash = strings.ToLower(strings.TrimSpace(hash))
if hash == "" {
return model.DownloadTask{}, false
}
for _, row := range rows {
if strings.Contains(strings.ToLower(row.URL), hash) {
return row, true
}
}
return model.DownloadTask{}, false
}
// RelocateTorrent moves a torrent's data to a new save directory while keeping // RelocateTorrent moves a torrent's data to a new save directory while keeping
// it seeding (qBittorrent performs the physical move and resumes seeding). // it seeding (qBittorrent performs the physical move and resumes seeding).
// 用于「移动 PT 种子文件且转移后继续做种上传」的整盘迁移场景。 // 用于「移动 PT 种子文件且转移后继续做种上传」的整盘迁移场景。
@@ -803,7 +859,7 @@ func (d *DownloadService) poll(ctx context.Context) {
continue continue
} }
rows, _ := d.repo.Download.List(ctx) rows, _ := d.repo.Download.List(ctx)
taskByKey := tasksByIdentity(rows) taskByKey := tasksByTorrentIdentity(rows)
d.processDownloadSnapshot(ctx, live, taskByKey) d.processDownloadSnapshot(ctx, live, taskByKey)
d.hub.Publish("download", map[string]any{"torrents": live}) d.hub.Publish("download", map[string]any{"torrents": live})
} }
@@ -823,6 +879,10 @@ func (d *DownloadService) processDownloadSnapshot(ctx context.Context, live []QB
for _, torrent := range live { for _, torrent := range live {
stateKey := completedTorrentQueueKey(torrent) stateKey := completedTorrentQueueKey(torrent)
complete := torrent.Progress >= 1.0 complete := torrent.Progress >= 1.0
matchedTask, hasTask := findMatchingTaskByTorrentIdentity(torrent.Name, taskByKey)
catchupRecorded := hasTask && d.completedTorrentCatchupRecorded(ctx, torrent)
taskNeedsOrganize := hasTask && !catchupRecorded &&
(downloadTaskNeedsCompletion(matchedTask) || recentlyCompletedTorrent(torrent, time.Now()))
d.syncDownloadTaskProgress(ctx, torrent, taskByKey) d.syncDownloadTaskProgress(ctx, torrent, taskByKey)
if stateKey == "" { if stateKey == "" {
continue continue
@@ -839,11 +899,13 @@ func (d *DownloadService) processDownloadSnapshot(ctx context.Context, live []QB
// (onTorrentComplete 内部仍受 organize.auto 开关约束,且 // (onTorrentComplete 内部仍受 organize.auto 开关约束,且
// 整理对已存在的目标文件幂等跳过)。 // 整理对已存在的目标文件幂等跳过)。
d.prevStates[stateKey] = true d.prevStates[stateKey] = true
if recentlyCompletedTorrent(torrent, time.Now()) && !d.completedTorrentCatchupRecorded(ctx, torrent) { if taskNeedsOrganize {
shouldQueue = true shouldQueue = true
} }
case complete && !wasComplete: case complete && !wasComplete:
shouldQueue = true shouldQueue = true
case complete && taskNeedsOrganize:
shouldQueue = true
case complete: case complete:
d.prevStates[stateKey] = true d.prevStates[stateKey] = true
default: default:
@@ -1018,7 +1080,7 @@ func (d *DownloadService) syncDownloadTaskProgress(ctx context.Context, torrent
if d == nil || d.repo == nil || d.repo.DB == nil || strings.TrimSpace(torrent.Name) == "" { if d == nil || d.repo == nil || d.repo.DB == nil || strings.TrimSpace(torrent.Name) == "" {
return return
} }
matched, ok := findMatchingTaskByIdentity(torrent.Name, taskByKey) matched, ok := findMatchingTaskByTorrentIdentity(torrent.Name, taskByKey)
if !ok { if !ok {
return return
} }
@@ -1053,6 +1115,17 @@ func tasksByIdentity(rows []model.DownloadTask) map[string]model.DownloadTask {
return out return out
} }
func tasksByTorrentIdentity(rows []model.DownloadTask) map[string]model.DownloadTask {
out := make(map[string]model.DownloadTask, len(rows))
for _, row := range rows {
key := normalizeTorrentName(row.Title)
if key != "" {
out[key] = row
}
}
return out
}
func findMatchingTaskByIdentity(title string, taskByKey map[string]model.DownloadTask) (model.DownloadTask, bool) { func findMatchingTaskByIdentity(title string, taskByKey map[string]model.DownloadTask) (model.DownloadTask, bool) {
key := downloadTaskIdentityKey(title) key := downloadTaskIdentityKey(title)
if key == "" { if key == "" {
@@ -1069,6 +1142,29 @@ func findMatchingTaskByIdentity(title string, taskByKey map[string]model.Downloa
return model.DownloadTask{}, false return model.DownloadTask{}, false
} }
func findMatchingTaskByTorrentIdentity(title string, taskByKey map[string]model.DownloadTask) (model.DownloadTask, bool) {
key := normalizeTorrentName(title)
if key == "" {
return model.DownloadTask{}, false
}
if row, ok := taskByKey[key]; ok {
return row, true
}
for currentKey, row := range taskByKey {
if strings.Contains(key, currentKey) || strings.Contains(currentKey, key) {
return row, true
}
}
return model.DownloadTask{}, false
}
func downloadTaskNeedsCompletion(task model.DownloadTask) bool {
if task.Progress < 1 {
return true
}
return strings.ToLower(strings.TrimSpace(task.Status)) != "completed"
}
// onTorrentComplete handles a torrent that just finished downloading. // onTorrentComplete handles a torrent that just finished downloading.
// It organizes the completed torrent payload directly. Relying on existing // It organizes the completed torrent payload directly. Relying on existing
// Media rows is too late for freshly-downloaded files: they usually have not // Media rows is too late for freshly-downloaded files: they usually have not
@@ -1109,61 +1205,25 @@ func (d *DownloadService) onTorrentComplete(ctx context.Context, torrent QBitTor
zap.String("name", torrent.Name), zap.String("name", torrent.Name),
zap.String("source", source), zap.String("source", source),
zap.Bool("allow_replace_existing", allowReplace)) zap.Bool("allow_replace_existing", allowReplace))
taskHandle := d.startDownloadOrganizeTask(torrent, source, allowReplace) resWrap, err := d.ensureOrganizePipeline().Run(ctx, OrganizePipelineRequest{
res, err := d.organizer.OrganizeDirectory(ctx, OrganizeOptions{ Scope: OrganizeScopeDirectory,
SourcePath: source, Trigger: OrganizeTriggerDownload,
MediaType: downloadTaskMediaType(taskRow), TaskName: d.downloadOrganizeTaskName(torrent, allowReplace),
MediaCategory: firstNonEmpty(downloadTaskMediaCategory(taskRow), torrent.Category), SourcePath: source,
AllowReplaceExisting: allowReplace, MediaType: downloadTaskMediaType(taskRow),
MediaCategory: firstNonEmpty(downloadTaskMediaCategory(taskRow), torrent.Category),
AllowReplace: allowReplace,
}) })
if err != nil { if err != nil {
if taskHandle != nil {
taskHandle.Finish(err, TaskUpdate{
Stage: "organize",
Message: "下载完成自动整理失败",
})
}
d.log.Error("auto organize completed torrent failed", d.log.Error("auto organize completed torrent failed",
zap.String("hash", torrent.Hash), zap.String("hash", torrent.Hash),
zap.String("source", source), zap.String("source", source),
zap.Error(err)) zap.Error(err))
return return
} }
if taskHandle != nil && res != nil { res := resWrap.Result
taskHandle.Update(TaskUpdate{ if res == nil {
Stage: "organize", res = &OrganizeResult{}
SourcePath: res.SourcePath,
DestPath: res.DestPath,
Message: "下载完成整理已完成,准备扫描入库",
Metrics: OrganizeTaskMetrics(res),
Details: OrganizeTaskDetails(res, 8),
})
}
if d.scanner != nil && res != nil && strings.TrimSpace(res.DestPath) != "" && OrganizeResultNeedsVisibilitySync(res) {
if taskHandle != nil {
taskHandle.Update(TaskUpdate{
Stage: "scan_scrape",
Message: "正在扫描入库并按设置刮削",
Metrics: OrganizeTaskMetrics(res),
Details: OrganizeTaskDetails(res, 8),
})
}
res.Scans, res.Scrapes = d.scanner.ScanAndScrapeLibrariesForPath(ctx, res.DestPath, "", OrganizeScrapeAfterEnabled(ctx, d.repo))
} else if d.log != nil && res != nil && !OrganizeResultNeedsVisibilitySync(res) {
d.log.Info("auto organize completed torrent skipped scan; no destination changes",
zap.String("hash", torrent.Hash),
zap.String("source", source),
zap.Int("organized", res.Organized),
zap.Int("replaced", res.Replaced),
zap.Int("skipped", res.Skipped))
}
if taskHandle != nil {
taskHandle.Finish(nil, TaskUpdate{
Stage: "completed",
Message: "下载完成自动整理入库结束",
Metrics: OrganizeTaskMetrics(res),
Details: OrganizeTaskDetails(res, 8),
})
} }
d.markCompletedTorrentCatchupRecorded(context.Background(), torrent) d.markCompletedTorrentCatchupRecorded(context.Background(), torrent)
d.log.Info("auto organize completed torrent finished", d.log.Info("auto organize completed torrent finished",
@@ -1177,23 +1237,22 @@ func (d *DownloadService) onTorrentComplete(ctx context.Context, torrent QBitTor
zap.Int("errors", len(res.Errors))) zap.Int("errors", len(res.Errors)))
} }
func (d *DownloadService) startDownloadOrganizeTask(torrent QBitTorrent, source string, allowReplace bool) *TaskHandle { func (d *DownloadService) downloadOrganizeTaskName(torrent QBitTorrent, allowReplace bool) string {
if d == nil || d.tasks == nil {
return nil
}
message := "下载完成后自动整理/重命名/入库"
if allowReplace {
message = "下载完成后自动整理/重命名/入库(允许洗版替换)"
}
name := strings.TrimSpace(torrent.Name) name := strings.TrimSpace(torrent.Name)
if name == "" { if name == "" {
name = "下载完成自动整理" name = "下载完成自动整理"
} }
return d.tasks.Start(TaskKindOrganize, name, TaskUpdate{ if allowReplace {
Stage: "organize", name += "(允许洗版)"
SourcePath: source, }
Message: message, return name
}) }
func (d *DownloadService) ensureOrganizePipeline() *OrganizePipelineService {
if d.organizePipeline != nil {
return d.organizePipeline
}
return NewOrganizePipelineService(d.log, d.repo, d.organizer, d.scanner, d.tasks)
} }
func (d *DownloadService) completedTorrentTask(ctx context.Context, torrent QBitTorrent) (*model.DownloadTask, bool) { func (d *DownloadService) completedTorrentTask(ctx context.Context, torrent QBitTorrent) (*model.DownloadTask, bool) {
@@ -1204,12 +1263,12 @@ func (d *DownloadService) completedTorrentTask(ctx context.Context, torrent QBit
if err != nil || len(rows) == 0 { if err != nil || len(rows) == 0 {
return nil, false return nil, false
} }
taskByKey := tasksByIdentity(rows) taskByKey := tasksByTorrentIdentity(rows)
if task, ok := findMatchingTaskByIdentity(torrent.Name, taskByKey); ok { if task, ok := findMatchingTaskByTorrentIdentity(torrent.Name, taskByKey); ok {
return &task, true return &task, true
} }
if strings.TrimSpace(torrent.ContentPath) != "" { if strings.TrimSpace(torrent.ContentPath) != "" {
if task, ok := findMatchingTaskByIdentity(filepath.Base(torrent.ContentPath), taskByKey); ok { if task, ok := findMatchingTaskByTorrentIdentity(filepath.Base(torrent.ContentPath), taskByKey); ok {
return &task, true return &task, true
} }
} }
+254 -3
View File
@@ -88,6 +88,101 @@ func TestSyncDownloadTaskProgressSkipsUnchangedCompletedTask(t *testing.T) {
} }
} }
func TestSyncDownloadTaskProgressMatchesSeasonFolderTorrentName(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.DownloadTask{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
task := &model.DownloadTask{
Source: "qbittorrent",
URL: "magnet:?xt=urn:btih:test",
Title: "The First Jasmine S01E01 1080p TX WEB-DL AAC2.0 H.264-MWeb",
SavePath: "/downloads/未分类",
Status: "queued",
Progress: 0.5,
}
if err := repos.Download.Create(t.Context(), task); err != nil {
t.Fatal(err)
}
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
svc.syncDownloadTaskProgress(t.Context(), QBitTorrent{
Name: "The.First.Jasmine.S01.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb",
Progress: 1,
State: "stalledUP",
}, tasksByTorrentIdentity([]model.DownloadTask{*task}))
var after model.DownloadTask
if err := db.First(&after, "id = ?", task.ID).Error; err != nil {
t.Fatal(err)
}
if after.Status != "completed" || after.Progress != 1 {
t.Fatalf("task completion = %s/%v, want completed/1", after.Status, after.Progress)
}
}
func TestProcessDownloadSnapshotQueuesCompletedPendingTaskOnFirstSnapshot(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.DownloadTask{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
task := &model.DownloadTask{
Source: "qbittorrent",
URL: "magnet:?xt=urn:btih:test",
Title: "Blades of the Guardians S02E01 1080p TX WEB-DL AAC2.0 H.264-MWeb",
SavePath: "/downloads/未分类",
Status: "queued",
Progress: 0,
}
if err := repos.Download.Create(t.Context(), task); err != nil {
t.Fatal(err)
}
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{
Hash: "quickdone",
Name: "Blades.of.the.Guardians.S02.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb",
Progress: 1,
State: "stalledUP",
}}, tasksByTorrentIdentity([]model.DownloadTask{*task}))
if got := len(svc.organizeQueue); got != 1 {
t.Fatalf("queued completed organize jobs = %d, want 1", got)
}
}
func TestProcessDownloadSnapshotSkipsUntrackedCompletedTorrentOnFirstSnapshot(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.DownloadTask{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{
Hash: "historydone",
Name: "Large.History.Pack.2026.1080p",
Progress: 1,
State: "stalledUP",
CompletionOn: time.Now().Unix(),
}}, tasksByTorrentIdentity(nil))
if got := len(svc.organizeQueue); got != 0 {
t.Fatalf("queued untracked historical torrents = %d, want 0", got)
}
}
func TestDownloadCompleteAutoOrganizesContentPath(t *testing.T) { func TestDownloadCompleteAutoOrganizesContentPath(t *testing.T) {
root := t.TempDir() root := t.TempDir()
src := filepath.Join(root, "downloads", "国产剧", "狂飙.S01E01.2023.1080p.mkv") src := filepath.Join(root, "downloads", "国产剧", "狂飙.S01E01.2023.1080p.mkv")
@@ -335,17 +430,31 @@ func TestDownloadPollBaselinesAlreadyCompletedTorrents(t *testing.T) {
func TestDownloadPollCatchesUpRecentlyCompletedTorrents(t *testing.T) { func TestDownloadPollCatchesUpRecentlyCompletedTorrents(t *testing.T) {
repos := newOrganizerTestRepo(t) repos := newOrganizerTestRepo(t)
if err := repos.DB.AutoMigrate(&model.DownloadTask{}); err != nil {
t.Fatal(err)
}
task := &model.DownloadTask{
Source: "qbittorrent",
URL: "magnet:?xt=urn:btih:fresh",
Title: "Fresh Complete S01E01",
SavePath: "/downloads",
Status: "queued",
Progress: 0,
}
if err := repos.Download.Create(t.Context(), task); err != nil {
t.Fatal(err)
}
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
svc.processDownloadSnapshot(t.Context(), []QBitTorrent{ svc.processDownloadSnapshot(t.Context(), []QBitTorrent{
{Hash: "fresh-complete", Name: "Fresh Complete S01E01", Progress: 1, CompletionOn: time.Now().Add(-time.Hour).Unix()}, {Hash: "fresh-complete", Name: "Fresh Complete S01E01", Progress: 1, CompletionOn: time.Now().Add(-time.Hour).Unix()},
{Hash: "stale-complete", Name: "Stale Complete S01E01", Progress: 1, CompletionOn: time.Now().Add(-48 * time.Hour).Unix()}, {Hash: "stale-complete", Name: "Stale Complete S01E01", Progress: 1, CompletionOn: time.Now().Add(-48 * time.Hour).Unix()},
{Hash: "no-timestamp", Name: "No Timestamp S01E01", Progress: 1}, {Hash: "no-timestamp", Name: "No Timestamp S01E01", Progress: 1},
}, nil) }, tasksByTorrentIdentity([]model.DownloadTask{*task}))
// 只有补整理时间窗内完成的种子会被补整理;无 completion_on 的保守跳过。 // 只有补整理时间窗内、且存在本地追踪任务的种子会被补整理;无 completion_on 的保守跳过。
if got := len(svc.organizeQueue); got != 1 { if got := len(svc.organizeQueue); got != 1 {
t.Fatalf("first poll queued %d organize jobs, want 1 (recent completion only)", got) t.Fatalf("first poll queued %d organize jobs, want 1 (recent tracked completion only)", got)
} }
} }
@@ -657,6 +766,65 @@ func TestDeleteMarksMatchingDownloadTaskDeleted(t *testing.T) {
} }
} }
func TestDeleteMarksMagnetTaskDeletedWhenLiveTorrentNameMissing(t *testing.T) {
const hash = "0123456789abcdef0123456789abcdef0123c0de"
var deleteCalls int32
qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v2/auth/login":
_, _ = w.Write([]byte("Ok."))
case "/api/v2/torrents/info":
_, _ = w.Write([]byte(`[]`))
case "/api/v2/torrents/delete":
atomic.AddInt32(&deleteCalls, 1)
_, _ = w.Write([]byte("Ok."))
default:
http.NotFound(w, r)
}
}))
defer qb.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
configureTestDefaultQB(t, repos, qb.URL)
task := &model.DownloadTask{
UserID: "u1",
Source: "qbittorrent",
URL: "magnet:?xt=urn:btih:" + hash + "&dn=Codex.Path.Verify.S01E01.2026",
Title: "Codex Path Verify S01E01 2026",
SavePath: "/downloads/tv",
Status: "queued",
}
if err := repos.Download.Create(t.Context(), task); err != nil {
t.Fatal(err)
}
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
if err := svc.ReloadConfig(t.Context()); err != nil {
t.Fatal(err)
}
if err := svc.Delete(t.Context(), hash, false); err != nil {
t.Fatal(err)
}
if got := atomic.LoadInt32(&deleteCalls); got != 1 {
t.Fatalf("delete calls = %d, want 1", got)
}
var updated model.DownloadTask
if err := db.Where("id = ?", task.ID).First(&updated).Error; err != nil {
t.Fatal(err)
}
if updated.Status != "deleted" {
t.Fatalf("status = %q, want deleted", updated.Status)
}
}
func TestAddDownloadWithMetaSkipsExistingLocalMovieBeforeQBAdd(t *testing.T) { func TestAddDownloadWithMetaSkipsExistingLocalMovieBeforeQBAdd(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil { if err != nil {
@@ -852,6 +1020,37 @@ func TestAddDownloadWithMetaAutoClassifiesSavePathAndQBitCategory(t *testing.T)
} }
} }
func TestDownloadSavePathCategoryRootKeepsWindowsClientSeparators(t *testing.T) {
if got := downloadSavePathCategoryRoot(`F:\downloads`, "国产剧"); got != `F:\downloads\国产剧` {
t.Fatalf("downloadSavePathCategoryRoot() = %q, want Windows qB path", got)
}
if got := downloadSavePathCategoryRoot(`F:\downloads\国产剧`, "国产剧"); got != `F:\downloads\国产剧` {
t.Fatalf("downloadSavePathCategoryRoot() duplicated category: %q", got)
}
if got := downloadSavePathCategoryRoot(`/downloads`, "国产剧"); got != filepath.Join(`/downloads`, "国产剧") {
t.Fatalf("downloadSavePathCategoryRoot() = %q, want local path", got)
}
}
func TestTranslateClientPathMapsWindowsQBitPathToContainerDownloadPath(t *testing.T) {
root := t.TempDir()
containerDownloads := filepath.Join(root, "downloads")
want := filepath.Join(containerDownloads, "国产剧", "Show.S01E01.mkv")
if err := os.MkdirAll(filepath.Dir(want), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(want, []byte("episode"), 0o644); err != nil {
t.Fatal(err)
}
got := translateClientPath(`F:\downloads\国产剧\Show.S01E01.mkv`, map[string]string{
`F:\downloads`: containerDownloads,
})
if got != want {
t.Fatalf("translateClientPath() = %q, want %q", got, want)
}
}
func TestAddDownloadWithMetaCanDisableAutoClassifiedSavePath(t *testing.T) { func TestAddDownloadWithMetaCanDisableAutoClassifiedSavePath(t *testing.T) {
var addCalls int32 var addCalls int32
var gotSavePath string var gotSavePath string
@@ -974,6 +1173,58 @@ func TestReloadConfigDoesNotFallbackToLegacyAfterClientDisabled(t *testing.T) {
} }
} }
func TestReloadConfigUsesSoleEnabledQBitWhenNoExplicitDefault(t *testing.T) {
var addCalls int32
qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v2/auth/login":
_, _ = w.Write([]byte("Ok."))
case "/api/v2/torrents/info":
if atomic.LoadInt32(&addCalls) > 0 {
_, _ = w.Write([]byte(`[{"hash":"sole123","name":"Movie 2026 1080p","state":"downloading","progress":0.1}]`))
return
}
_, _ = w.Write([]byte(`[]`))
case "/api/v2/torrents/add":
atomic.AddInt32(&addCalls, 1)
_, _ = w.Write([]byte("Ok."))
default:
http.NotFound(w, r)
}
}))
defer qb.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
if err := repos.Setting.Set(t.Context(), settingDownloadClientsManaged, "true"); err != nil {
t.Fatal(err)
}
client := &model.DownloadClient{Name: "qB", Type: "qbittorrent", Host: qb.URL, Username: "admin", Password: "admin", IsDefault: false, Enabled: true}
if err := repos.DownloadClient.Create(t.Context(), client); err != nil {
t.Fatal(err)
}
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:abababababababababababababababababababab&dn=Movie+2026+1080p", "/downloads", DownloadTaskMeta{
Title: "Movie 2026 1080p",
})
if err != nil {
t.Fatal(err)
}
if task == nil {
t.Fatal("expected task")
}
if got := atomic.LoadInt32(&addCalls); got != 1 {
t.Fatalf("qb add calls = %d, want 1", got)
}
}
func TestAddDownloadWithMetaFailsClosedWhenNoDownloaderConfigured(t *testing.T) { func TestAddDownloadWithMetaFailsClosedWhenNoDownloaderConfigured(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil { if err != nil {
+1
View File
@@ -28,6 +28,7 @@ var (
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`) patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`) patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`)
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`) patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`)
patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`)
patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`) patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`)
// patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。 // patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`) patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
+139 -15
View File
@@ -8,6 +8,7 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository" "github.com/ShukeBta/MediaStationGo/internal/repository"
"go.uber.org/zap"
) )
var ( var (
@@ -34,12 +35,11 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
languages := normalizeTokens(input.Languages...) languages := normalizeTokens(input.Languages...)
rawText := input.Title + " " + input.Category + " " + strings.Join(input.Genres, " ") rawText := input.Title + " " + input.Category + " " + strings.Join(input.Genres, " ")
text := strings.ToLower(rawText) text := strings.ToLower(rawText)
hasMetadata := len(genres) > 0 || len(countries) > 0 || len(languages) > 0
isChinesePlatform := containsAnyText(text, isChineseByMetadata := hasAny(languages, "ZH", "ZH-CN", "ZH-TW", "CN", "BO", "ZA") || hasAny(countries, "CN", "TW", "HK", "MO")
"iqiyi", "qiyi", "youku", "tencent", "wetv", "mgtv", "mango", "hunantv", "cctv", isChineseByText := containsHan(rawText) || containsAnyText(text, "华语", "国产", "国剧", "国漫")
"bilibili", "bili", "芒果tv", "腾讯视频", "优酷", "爱奇艺", isChinese := isChineseByMetadata || (!hasMetadata && isChineseByText)
)
isChinese := hasAny(languages, "ZH", "ZH-CN", "ZH-TW", "CN") || hasAny(countries, "CN", "TW", "HK", "MO") || containsHan(rawText) || containsAnyText(text, "华语", "国产", "国剧", "国漫") || isChinesePlatform
isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || containsJapaneseKana(rawText) || strings.Contains(text, "日番") isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || containsJapaneseKana(rawText) || strings.Contains(text, "日番")
isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP") || containsKoreanHangul(rawText) isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP") || containsKoreanHangul(rawText)
isEastAsian := isJapanese || isKorean || hasAny(countries, "TH", "IN", "SG") isEastAsian := isJapanese || isKorean || hasAny(countries, "TH", "IN", "SG")
@@ -47,8 +47,7 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
"US", "GB", "UK", "FR", "DE", "CA", "AU", "NZ", "IE", "NL", "SE", "NO", "DK", "US", "GB", "UK", "FR", "DE", "CA", "AU", "NZ", "IE", "NL", "SE", "NO", "DK",
"FI", "ES", "IT", "PT", "AT", "CH", "BE", "RU", "FI", "ES", "IT", "PT", "AT", "CH", "BE", "RU",
) )
isLatinFallback := containsLatin(rawText) && !containsHan(rawText) && !containsJapaneseKana(rawText) && !containsKoreanHangul(rawText) isWestern := isWesternByMetadata
isWestern := isWesternByMetadata || (mediaType == "tv" && isLatinFallback)
hasAnimeText := containsAnyText(text, "动画", "动漫", "番剧", "年番", "国漫", "日番", "bangumi", "anime", "b-global", "ani-one", "crunchyroll") hasAnimeText := containsAnyText(text, "动画", "动漫", "番剧", "年番", "国漫", "日番", "bangumi", "anime", "b-global", "ani-one", "crunchyroll")
hasVarietyText := containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show") hasVarietyText := containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show")
hasDocumentaryText := containsAnyText(text, "纪录", "纪录片", "documentary", "docu", "national geographic", "natgeo") hasDocumentaryText := containsAnyText(text, "纪录", "纪录片", "documentary", "docu", "national geographic", "natgeo")
@@ -74,18 +73,12 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
if isAdultText { if isAdultText {
return categoryName(categories, "adult", "成人") return categoryName(categories, "adult", "成人")
} }
if hasGenre("16", "ANIMATION", "动画", "动漫") { if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText {
return categoryName(categories, "animation_movie", "动画电影") return categoryName(categories, "animation_movie", "动画电影")
} }
if isChinese { if isChinese {
return categoryName(categories, "chinese_movie", "华语电影") return categoryName(categories, "chinese_movie", "华语电影")
} }
if isEastAsian {
return categoryName(categories, "jk_movie", "日韩电影")
}
if isWesternByMetadata {
return categoryName(categories, "euus_movie", "欧美电影")
}
return categoryName(categories, "foreign_movie", "外语电影") return categoryName(categories, "foreign_movie", "外语电影")
case "anime": case "anime":
if isChinese { if isChinese {
@@ -280,6 +273,29 @@ func (o *OrganizerService) classifyMedia(ctx context.Context, m *model.Media, me
func (s *SubscriptionService) classifySubscriptionItem(ctx context.Context, sub *model.Subscription, title, sourceCategory string) (string, string) { func (s *SubscriptionService) classifySubscriptionItem(ctx context.Context, sub *model.Subscription, title, sourceCategory string) (string, string) {
mediaType := normalizeMediaType(sub.MediaType, title+" "+sub.Name+" "+sub.Filter, sourceCategory) mediaType := normalizeMediaType(sub.MediaType, title+" "+sub.Name+" "+sub.Filter, sourceCategory)
category := strings.TrimSpace(sub.MediaCategory) category := strings.TrimSpace(sub.MediaCategory)
if category == "" {
if match := s.lookupSubscriptionMetadata(ctx, mediaType, title, sub); match != nil {
category = classifyMediaCategory(mediaClassifyInput{
MediaType: mediaType,
Title: match.Title + " " + match.OriginalName,
Languages: match.Languages,
Countries: match.Countries,
Genres: match.Genres,
Category: sourceCategory,
}, s.categoryMap())
if s != nil && s.log != nil && category != "" {
s.log.Info("subscription metadata classified",
zap.String("title", title),
zap.String("matched_title", match.Title),
zap.String("media_type", mediaType),
zap.String("media_category", category),
zap.Int("tmdb_id", match.TMDbID),
zap.Int("bangumi_id", match.BangumiID),
zap.String("douban_id", match.DoubanID),
zap.String("thetvdb_id", match.TheTVDBID))
}
}
}
if category == "" { if category == "" {
category = classifyMediaCategory(mediaClassifyInput{ category = classifyMediaCategory(mediaClassifyInput{
MediaType: mediaType, MediaType: mediaType,
@@ -290,6 +306,78 @@ func (s *SubscriptionService) classifySubscriptionItem(ctx context.Context, sub
return mediaType, category return mediaType, category
} }
func (s *SubscriptionService) lookupSubscriptionMetadata(ctx context.Context, mediaType, title string, sub *model.Subscription) *Match {
if s == nil || s.scraper == nil || !s.scraper.AnyEnabled() {
return nil
}
queries := subscriptionMetadataQueries(title, sub)
if len(queries) == 0 {
return nil
}
for _, libType := range subscriptionMetadataLibraryTypes(mediaType, title) {
lib := &model.Library{Type: libType, Enabled: true}
for _, query := range queries {
cleaned, year := CleanQuery(query)
if cleaned == "" {
cleaned = strings.TrimSpace(query)
}
for _, candidate := range titleCandidates(cleaned) {
if candidate == "" {
continue
}
match := s.scraper.lookup(ctx, lib, candidate, year)
if match == nil || strings.TrimSpace(match.Title) == "" {
continue
}
if !organizeMetadataMatchTrusted(candidate, year, match) {
continue
}
return match
}
}
}
return nil
}
func subscriptionMetadataQueries(title string, sub *model.Subscription) []string {
seen := map[string]struct{}{}
out := make([]string, 0, 3)
add := func(value string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
key := strings.ToLower(value)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
out = append(out, value)
}
add(title)
if sub != nil {
add(sub.Filter)
add(sub.Name)
}
return out
}
func subscriptionMetadataLibraryTypes(mediaType, title string) []string {
switch normalizeMediaType(mediaType, title, "") {
case "movie":
return []string{"movie"}
case "anime":
return []string{"anime", "tv"}
case "tv", "variety":
return []string{"tv", "anime"}
default:
if classifierEpisodeRE.MatchString(title) || classifierSeasonRE.MatchString(title) {
return []string{"tv", "anime"}
}
return []string{"movie", "tv", "anime"}
}
}
func (s *SubscriptionService) categoryMap() map[string]string { func (s *SubscriptionService) categoryMap() map[string]string {
if s == nil || s.cfg == nil || s.cfg.Organizer.Categories == nil { if s == nil || s.cfg == nil || s.cfg.Organizer.Categories == nil {
return nil return nil
@@ -311,7 +399,7 @@ func (s *SubscriptionService) resolveSubscriptionSavePath(ctx context.Context, s
if !s.isSmartClassifyEnabled(ctx) || category == "" { if !s.isSmartClassifyEnabled(ctx) || category == "" {
return base return base
} }
return categoryRoot(base, sanitizeFilename(category)) return downloadSavePathCategoryRoot(base, sanitizeFilename(category))
} }
func (s *SubscriptionService) isSmartClassifyEnabled(ctx context.Context) bool { func (s *SubscriptionService) isSmartClassifyEnabled(ctx context.Context) bool {
@@ -368,3 +456,39 @@ func downloadCategoryMap(organizer *OrganizerService) map[string]string {
} }
return organizer.categoryMap() return organizer.categoryMap()
} }
func downloadSavePathCategoryRoot(root, category string) string {
root = strings.TrimSpace(root)
category = strings.TrimSpace(category)
if root == "" || category == "" {
return root
}
if isWindowsStyleClientPath(root) {
cleanRoot := strings.ReplaceAll(root, "/", `\`)
cleanRoot = strings.TrimRight(cleanRoot, `\`)
if windowsPathBaseEqual(cleanRoot, category) {
return cleanRoot
}
return cleanRoot + `\` + category
}
return categoryRoot(root, category)
}
func isWindowsStyleClientPath(path string) bool {
path = strings.TrimSpace(path)
return (len(path) >= 2 && isASCIIAlpha(path[0]) && path[1] == ':') ||
strings.HasPrefix(path, `\\`)
}
func windowsPathBaseEqual(path, base string) bool {
path = strings.TrimRight(strings.ReplaceAll(strings.TrimSpace(path), "/", `\`), `\`)
base = strings.Trim(strings.TrimSpace(base), `\/`)
if path == "" || base == "" {
return false
}
idx := strings.LastIndex(path, `\`)
if idx >= 0 {
path = path[idx+1:]
}
return strings.EqualFold(path, base)
}
+37 -5
View File
@@ -29,6 +29,37 @@ func TestClassifyMediaCategoryMatchesMoviePilotStyleRules(t *testing.T) {
}, },
want: "动画电影", want: "动画电影",
}, },
{
name: "translated tmdb animation title uses genre before title script",
input: mediaClassifyInput{
MediaType: "movie",
Title: "寻龙记",
Languages: []string{"en"},
Countries: []string{"NL"},
Genres: []string{"16"},
},
want: "动画电影",
},
{
name: "translated western movie title does not become chinese movie",
input: mediaClassifyInput{
MediaType: "movie",
Title: "大雄兔",
Languages: []string{"en"},
Countries: []string{"NL"},
Genres: []string{"Comedy"},
},
want: "外语电影",
},
{
name: "movie animation source category fallback",
input: mediaClassifyInput{
MediaType: "movie",
Title: "Sintel 2010 1080p",
Category: "动画电影",
},
want: "动画电影",
},
{ {
name: "tv variety by genre", name: "tv variety by genre",
input: mediaClassifyInput{ input: mediaClassifyInput{
@@ -82,26 +113,27 @@ func TestClassifyMediaCategoryMatchesMoviePilotStyleRules(t *testing.T) {
want: "国产剧", want: "国产剧",
}, },
{ {
name: "latin tv title without metadata", name: "latin tv title without metadata stays uncategorized",
input: mediaClassifyInput{ input: mediaClassifyInput{
MediaType: "tv", MediaType: "tv",
Title: "The Last of Us S01E01 1080p", Title: "The Last of Us S01E01 1080p",
}, },
want: "欧美剧", want: "未分类",
}, },
{ {
name: "iqiyi romanized chinese drama", name: "platform token alone does not classify romanized drama",
input: mediaClassifyInput{ input: mediaClassifyInput{
MediaType: "tv", MediaType: "tv",
Title: "Motherhood.of.Taihang.S01E01.2026.1080p.iQIYI.WEB-DL", Title: "Motherhood.of.Taihang.S01E01.2026.1080p.iQIYI.WEB-DL",
}, },
want: "国产剧", want: "未分类",
}, },
{ {
name: "youku romanized chinese drama", name: "metadata classifies romanized chinese drama",
input: mediaClassifyInput{ input: mediaClassifyInput{
MediaType: "tv", MediaType: "tv",
Title: "Ashes.to.Crown.S01E15.2160p.YOUKU.WEB-DL", Title: "Ashes.to.Crown.S01E15.2160p.YOUKU.WEB-DL",
Countries: []string{"CN"},
}, },
want: "国产剧", want: "国产剧",
}, },
+173 -1
View File
@@ -1,6 +1,15 @@
package service package service
import "testing" import (
"os"
"path/filepath"
"testing"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
// TestOrganizeNaming locks in the rename pipeline used by OrganizeDirectory: // TestOrganizeNaming locks in the rename pipeline used by OrganizeDirectory:
// CleanQuery (title/year) + ParseEpisode (season/episode) + titleCaseWords. // CleanQuery (title/year) + ParseEpisode (season/episode) + titleCaseWords.
@@ -24,6 +33,7 @@ func TestOrganizeNaming(t *testing.T) {
{"Friends.S03E12.1994.720p.mkv", "Friends", 1994, 3, 12}, {"Friends.S03E12.1994.720p.mkv", "Friends", 1994, 3, 12},
{"Oppenheimer.2023.2160p.UHD.BluRay.mkv", "Oppenheimer", 2023, 0, 0}, {"Oppenheimer.2023.2160p.UHD.BluRay.mkv", "Oppenheimer", 2023, 0, 0},
{"Rocky.IV.1985.1080p.BluRay.mkv", "Rocky IV", 1985, 0, 0}, {"Rocky.IV.1985.1080p.BluRay.mkv", "Rocky IV", 1985, 0, 0},
{"Big.Buck.Bunny.2008.1080p.CodexVerify.mp4", "Big Buck Bunny", 2008, 0, 0},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.file, func(t *testing.T) { t.Run(tc.file, func(t *testing.T) {
@@ -59,3 +69,165 @@ func TestTitleCaseWordsRomanNumerals(t *testing.T) {
} }
} }
} }
func TestOrganizeDirectoryHonorsConfiguredNamingFormats(t *testing.T) {
root := t.TempDir()
dest := filepath.Join(root, "media")
repos := newOrganizerTestRepo(t)
for key, value := range map[string]string{
"organize.movie_format": "Movies/{title} ({year})/{title} [{year}]",
"organize.tv_format": "Series/{title} ({year})/S{season:02}/{title}.S{season:02}E{episode:02}",
"organize.anime_format": "Bangumi/{title}/Season {season:02}/{title} - {episode:03}",
} {
if err := repos.Setting.Set(t.Context(), key, value); err != nil {
t.Fatal(err)
}
}
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
movieSrc := filepath.Join(root, "downloads", "Dune.2021.2160p.mkv")
writeOrgFile(t, movieSrc, "movie")
if _, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
SourcePath: movieSrc,
DestPath: dest,
MediaType: "movie",
TransferMode: TransferCopy,
}); err != nil {
t.Fatalf("organize movie: %v", err)
}
movieWant := filepath.Join(dest, "电影", "Movies", "Dune (2021)", "Dune [2021].mkv")
if _, err := os.Stat(movieWant); err != nil {
t.Fatalf("movie naming format not honored, want %q: %v", movieWant, err)
}
tvSrc := filepath.Join(root, "downloads", "Some.Show.S02E03.2024.mkv")
writeOrgFile(t, tvSrc, "tv")
if _, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
SourcePath: tvSrc,
DestPath: dest,
MediaType: "tv",
TransferMode: TransferCopy,
}); err != nil {
t.Fatalf("organize tv: %v", err)
}
tvWant := filepath.Join(dest, "电视剧", "Series", "Some Show (2024)", "S02", "Some Show.S02E03.mkv")
if _, err := os.Stat(tvWant); err != nil {
t.Fatalf("tv naming format not honored, want %q: %v", tvWant, err)
}
animeSrc := filepath.Join(root, "downloads", "Frieren.S01E01.2023.mkv")
writeOrgFile(t, animeSrc, "anime")
if _, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
SourcePath: animeSrc,
DestPath: dest,
MediaType: "anime",
MediaCategory: "日番",
TransferMode: TransferCopy,
}); err != nil {
t.Fatalf("organize anime: %v", err)
}
animeWant := filepath.Join(dest, "电视剧", "日番", "Bangumi", "Frieren", "Season 01", "Frieren - 001.mkv")
if _, err := os.Stat(animeWant); err != nil {
t.Fatalf("anime naming format not honored, want %q: %v", animeWant, err)
}
}
func TestOrganizeDirectoryHonorsMoviePilotStyleNamingFormat(t *testing.T) {
root := t.TempDir()
dest := filepath.Join(root, "media")
repos := newOrganizerTestRepo(t)
template := "{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{% if episode %} - 第 {{episode}} 集{% endif %}{{fileExt}}"
if err := repos.Setting.Set(t.Context(), "organize.tv_format", template); err != nil {
t.Fatal(err)
}
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
src := filepath.Join(root, "downloads", "Verify.Show.S01E02.2026.mkv")
writeOrgFile(t, src, "episode")
if _, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
SourcePath: src,
DestPath: dest,
MediaType: "tv",
TransferMode: TransferCopy,
}); err != nil {
t.Fatalf("organize tv: %v", err)
}
want := filepath.Join(dest, "电视剧", "Verify Show (2026)", "Season 1", "Verify Show - S01E02 - 第 2 集.mkv")
if _, err := os.Stat(want); err != nil {
t.Fatalf("MoviePilot-style naming format not honored, want %q: %v", want, err)
}
}
func TestOrganizePipelineUsesSameNamingFormat(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "downloads", "Pipeline.Show.S01E02.2026.mkv")
dest := filepath.Join(root, "media")
writeOrgFile(t, src, "episode")
repos := newOrganizerTestRepo(t)
if err := repos.Setting.Set(t.Context(), "organize.tv_format", "{title}/S{season:02}/{title} - EP{episode:02}"); err != nil {
t.Fatal(err)
}
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
pipeline := NewOrganizePipelineService(zap.NewNop(), repos, organizer, nil, nil)
res, err := pipeline.Run(t.Context(), OrganizePipelineRequest{
Scope: OrganizeScopeDirectory,
Trigger: OrganizeTriggerManual,
SourcePath: src,
DestPath: dest,
TransferMode: string(TransferCopy),
MediaType: "tv",
})
if err != nil {
t.Fatalf("pipeline organize: %v", err)
}
if res.Result == nil || res.Result.Organized != 1 {
t.Fatalf("pipeline result = %#v, want organized=1", res)
}
want := filepath.Join(dest, "电视剧", "Pipeline Show", "S01", "Pipeline Show - EP02.mkv")
if _, err := os.Stat(want); err != nil {
t.Fatalf("pipeline did not use configured naming format, want %q: %v", want, err)
}
}
func TestOrganizeMediaHonorsConfiguredNamingFormat(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "incoming")
source := filepath.Join(sourceDir, "Some Show S01E02.mkv")
writeOrgFile(t, source, "episode")
repos := newOrganizerTestRepo(t)
if err := repos.Setting.Set(t.Context(), "organize.tv_format", "{title}/Season {season:02}/{title} - 第{episode:02}集"); err != nil {
t.Fatal(err)
}
lib := model.Library{Name: "TV", Path: root, Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "Some Show",
Path: source,
Container: "mkv",
SeasonNum: 1,
EpisodeNum: 2,
}
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
t.Fatal(err)
}
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
dst, err := organizer.OrganizeMedia(t.Context(), media.ID)
if err != nil {
t.Fatal(err)
}
want := filepath.Join(root, "Some Show", "Season 01", "Some Show - 第02集.mkv")
if dst != want {
t.Fatalf("dst = %q, want %q", dst, want)
}
if _, err := os.Stat(want); err != nil {
t.Fatalf("organized file missing: %v", err)
}
}
+359
View File
@@ -0,0 +1,359 @@
package service
import (
"context"
"errors"
"path/filepath"
"strings"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
type OrganizeScope string
const (
OrganizeScopeDirectory OrganizeScope = "directory"
OrganizeScopeMedia OrganizeScope = "media"
OrganizeScopeLibrary OrganizeScope = "library"
)
type OrganizeTrigger string
const (
OrganizeTriggerManual OrganizeTrigger = "manual"
OrganizeTriggerScheduled OrganizeTrigger = "scheduled"
OrganizeTriggerDownload OrganizeTrigger = "download"
)
// OrganizePipelineRequest is the single service-facing entry point for every
// organize → rename → scan → scrape ingest workflow. Handlers, scheduler jobs
// and download-complete hooks should only decide the trigger/scope/options;
// the pipeline owns the execution order and task reporting.
type OrganizePipelineRequest struct {
Scope OrganizeScope
Trigger OrganizeTrigger
TaskName string
MediaID string
LibraryID string
PreferredLibraryID string
SourcePath string
DestPath string
TransferMode string
MediaType string
MediaCategory string
ScanAfter *bool
ScrapeAfter *bool
DryRun bool
AllowReplace bool
}
type OrganizePipelineResponse struct {
Path string
Result *OrganizeResult
}
type OrganizePipelineService struct {
log *zap.Logger
repo *repository.Container
organizer *OrganizerService
scanner *ScannerService
tasks *TaskTrackerService
}
func NewOrganizePipelineService(log *zap.Logger, repo *repository.Container, organizer *OrganizerService, scanner *ScannerService, tasks *TaskTrackerService) *OrganizePipelineService {
return &OrganizePipelineService{
log: log,
repo: repo,
organizer: organizer,
scanner: scanner,
tasks: tasks,
}
}
func (p *OrganizePipelineService) Run(ctx context.Context, req OrganizePipelineRequest) (*OrganizePipelineResponse, error) {
if p == nil || p.organizer == nil {
return nil, errors.New("organize pipeline unavailable")
}
opts := OrganizeOptions{
SourcePath: strings.TrimSpace(req.SourcePath),
DestPath: strings.TrimSpace(req.DestPath),
MediaType: strings.TrimSpace(req.MediaType),
MediaCategory: strings.TrimSpace(req.MediaCategory),
DryRun: req.DryRun,
AllowReplaceExisting: req.AllowReplace,
}
if mode := strings.TrimSpace(req.TransferMode); mode != "" {
opts.TransferMode = TransferMode(mode)
}
task := p.startTask(ctx, req, opts)
response := &OrganizePipelineResponse{}
res, path, err := p.runOrganize(ctx, req, opts)
if err != nil {
p.finishTask(task, err, "organize", p.failureMessage(req), nil)
return nil, err
}
response.Path = path
response.Result = res
if task != nil {
task.Update(TaskUpdate{
Stage: "organize",
SourcePath: res.SourcePath,
DestPath: firstNonEmpty(res.DestPath, filepath.Dir(path)),
Message: "整理/重命名完成,准备扫描入库",
Metrics: OrganizeTaskMetrics(res),
Details: OrganizeTaskDetails(res, 8),
})
}
if p.shouldScan(req, res) {
if task != nil {
task.Update(TaskUpdate{
Stage: "scan_scrape",
Message: "正在扫描入库并按设置刮削",
Metrics: OrganizeTaskMetrics(res),
Details: OrganizeTaskDetails(res, 8),
})
}
scanRoot := organizeScanRoot(res, path)
if scanRoot == "" && strings.TrimSpace(path) != "" {
scanRoot = filepath.Dir(path)
}
preferredLibraryID := strings.TrimSpace(req.PreferredLibraryID)
if preferredLibraryID == "" && req.Scope == OrganizeScopeLibrary {
preferredLibraryID = strings.TrimSpace(req.LibraryID)
}
res.Scans, res.Scrapes = p.scanner.ScanAndScrapeLibrariesForPath(ctx, scanRoot, preferredLibraryID, p.scrapeAfter(ctx, req))
} else if p.log != nil && res != nil && !req.DryRun {
p.log.Info("organize pipeline skipped scan; no destination changes",
zap.String("trigger", string(req.Trigger)),
zap.String("scope", string(req.Scope)),
zap.String("source", res.SourcePath),
zap.String("dest", res.DestPath),
zap.Int("organized", res.Organized),
zap.Int("replaced", res.Replaced),
zap.Int("skipped", res.Skipped))
}
p.finishTask(task, nil, "completed", p.completedMessage(req), res)
if p.log != nil && res != nil {
p.log.Info("organize pipeline finished",
zap.String("trigger", string(req.Trigger)),
zap.String("scope", string(req.Scope)),
zap.String("source", res.SourcePath),
zap.String("dest", firstNonEmpty(res.DestPath, filepath.Dir(path))),
zap.Int("organized", res.Organized),
zap.Int("replaced", res.Replaced),
zap.Int("skipped", res.Skipped),
zap.Int("scrapes", len(res.Scrapes)),
zap.Int("errors", len(res.Errors)))
}
return response, nil
}
func (p *OrganizePipelineService) runOrganize(ctx context.Context, req OrganizePipelineRequest, opts OrganizeOptions) (*OrganizeResult, string, error) {
switch req.Scope {
case OrganizeScopeMedia:
path, err := p.organizer.OrganizeMediaWithOptions(ctx, strings.TrimSpace(req.MediaID), opts)
if err != nil {
return nil, "", err
}
res := &OrganizeResult{
Organized: 1,
SourcePath: opts.SourcePath,
DestPath: filepath.Dir(path),
DryRun: opts.DryRun,
Items: []OrganizePreviewItem{{
Target: path,
Action: "organize",
}},
}
return res, path, nil
case OrganizeScopeLibrary:
res, err := p.organizer.OrganizeLibraryWithOptions(ctx, strings.TrimSpace(req.LibraryID), opts)
return res, "", err
default:
res, err := p.organizer.OrganizeDirectory(ctx, opts)
return res, "", err
}
}
func (p *OrganizePipelineService) shouldScan(req OrganizePipelineRequest, res *OrganizeResult) bool {
if req.DryRun || p == nil || p.scanner == nil || res == nil {
return false
}
if req.ScanAfter != nil && !*req.ScanAfter {
return false
}
return OrganizeResultNeedsVisibilitySync(res)
}
func organizeScanRoot(res *OrganizeResult, path string) string {
if res == nil {
if strings.TrimSpace(path) == "" {
return ""
}
return filepath.Dir(path)
}
var root string
for _, item := range res.Items {
if !organizeItemNeedsVisibilitySync(item) {
continue
}
target := strings.TrimSpace(item.Target)
if target == "" {
continue
}
dir := filepath.Dir(target)
if root == "" {
root = dir
continue
}
root = commonPathRoot(root, dir)
}
if root != "" {
return root
}
if strings.TrimSpace(path) != "" {
return filepath.Dir(path)
}
return strings.TrimSpace(res.DestPath)
}
func organizeItemNeedsVisibilitySync(item OrganizePreviewItem) bool {
switch item.Action {
case "organize", "replace":
return true
case "skip":
switch item.Reason {
case organizeSkipAlreadyOrganized, organizeSkipTargetExists, "duplicate exists", "target exists":
return true
}
}
return false
}
func commonPathRoot(a, b string) string {
a = filepath.Clean(strings.TrimSpace(a))
b = filepath.Clean(strings.TrimSpace(b))
if a == "" || a == "." {
return b
}
if b == "" || b == "." {
return a
}
if pathWithin(a, b) {
return b
}
if pathWithin(b, a) {
return a
}
for {
parent := filepath.Dir(a)
if parent == a || parent == "." {
return parent
}
if pathWithin(b, parent) {
return parent
}
a = parent
}
}
func (p *OrganizePipelineService) scrapeAfter(ctx context.Context, req OrganizePipelineRequest) bool {
if req.ScrapeAfter != nil {
return *req.ScrapeAfter
}
return OrganizeScrapeAfterEnabled(ctx, p.repo)
}
func (p *OrganizePipelineService) startTask(ctx context.Context, req OrganizePipelineRequest, opts OrganizeOptions) *TaskHandle {
if p == nil || p.tasks == nil {
return nil
}
name := strings.TrimSpace(req.TaskName)
if name == "" {
name = p.defaultTaskName(req)
}
message := "正在整理/重命名/入库"
if req.DryRun {
message = "正在预览整理/重命名"
}
return p.tasks.Start(TaskKindOrganize, name, TaskUpdate{
Stage: "organize",
SourcePath: firstNonEmpty(opts.SourcePath, p.defaultSourcePath(ctx, req)),
DestPath: firstNonEmpty(opts.DestPath, p.defaultDestPath(ctx, req)),
Message: message,
})
}
func (p *OrganizePipelineService) finishTask(task *TaskHandle, err error, stage, message string, res *OrganizeResult) {
if task == nil {
return
}
task.Finish(err, TaskUpdate{
Stage: stage,
Message: message,
Metrics: OrganizeTaskMetrics(res),
Details: OrganizeTaskDetails(res, 8),
})
}
func (p *OrganizePipelineService) defaultTaskName(req OrganizePipelineRequest) string {
switch req.Trigger {
case OrganizeTriggerScheduled:
return "自动整理重命名刮削入库"
case OrganizeTriggerDownload:
return "下载完成自动整理重命名刮削入库"
default:
if req.DryRun {
return "预览整理重命名入库"
}
return "手动整理重命名刮削入库"
}
}
func (p *OrganizePipelineService) failureMessage(req OrganizePipelineRequest) string {
switch req.Trigger {
case OrganizeTriggerScheduled:
return "自动整理重命名入库失败"
case OrganizeTriggerDownload:
return "下载完成自动整理失败"
default:
return "手动整理重命名入库失败"
}
}
func (p *OrganizePipelineService) completedMessage(req OrganizePipelineRequest) string {
switch req.Trigger {
case OrganizeTriggerScheduled:
return "自动整理重命名刮削入库结束"
case OrganizeTriggerDownload:
return "下载完成自动整理入库结束"
default:
return "手动整理重命名刮削入库结束"
}
}
func (p *OrganizePipelineService) defaultSourcePath(ctx context.Context, req OrganizePipelineRequest) string {
if p == nil || p.organizer == nil {
return ""
}
if req.Scope == OrganizeScopeDirectory {
return p.organizer.defaultSourceRoot(ctx, "")
}
return ""
}
func (p *OrganizePipelineService) defaultDestPath(ctx context.Context, req OrganizePipelineRequest) string {
if p == nil || p.organizer == nil {
return ""
}
if req.Scope == OrganizeScopeDirectory {
return p.organizer.defaultDestRoot(ctx, "")
}
return ""
}
@@ -0,0 +1,37 @@
package service
import (
"path/filepath"
"testing"
)
func TestOrganizeScanRootUsesActualOrganizedTarget(t *testing.T) {
target := filepath.Join(string(filepath.Separator), "media", "电影", "动画电影", "Big Buck Bunny (2008)", "Big Buck Bunny (2008).mp4")
res := &OrganizeResult{
DestPath: filepath.Join(string(filepath.Separator), "media"),
Items: []OrganizePreviewItem{{
Target: target,
Action: "organize",
}},
}
want := filepath.Dir(target)
if got := organizeScanRoot(res, ""); got != want {
t.Fatalf("organizeScanRoot() = %q, want %q", got, want)
}
}
func TestOrganizeScanRootUsesCommonAffectedCategoryRoot(t *testing.T) {
root := filepath.Join(string(filepath.Separator), "media", "电影", "动画电影")
res := &OrganizeResult{
DestPath: filepath.Join(string(filepath.Separator), "media"),
Items: []OrganizePreviewItem{
{Target: filepath.Join(root, "Movie A (2026)", "Movie A (2026).mp4"), Action: "organize"},
{Target: filepath.Join(root, "Movie B (2026)", "Movie B (2026).mp4"), Action: "organize"},
},
}
if got := organizeScanRoot(res, ""); got != root {
t.Fatalf("organizeScanRoot() = %q, want %q", got, root)
}
}
+27 -12
View File
@@ -149,21 +149,36 @@ func (o *OrganizerService) OrganizeMediaWithOptions(ctx context.Context, mediaID
var dst string var dst string
if isSeriesLibraryType(lib.Type) { if isSeriesLibraryType(lib.Type) {
// TV: {baseRoot}/[分类]/{Title}/Season XX/{Title} - SxxExx.ext
season := fmt.Sprintf("Season %02d", m.SeasonNum)
epTag := fmt.Sprintf("S%02dE%02d", m.SeasonNum, m.EpisodeNum)
root := o.organizeRoot(baseRoot, lib.Type, category) root := o.organizeRoot(baseRoot, lib.Type, category)
dir := filepath.Join(categoryRoot(root, category), title, season) target, err := o.buildOrganizeTargetPath(ctx, organizeTargetInput{
dst = filepath.Join(dir, fmt.Sprintf("%s - %s%s", title, epTag, ext)) Root: categoryRoot(root, category),
} else { MediaType: lib.Type,
// Movie: {baseRoot}/[分类]/{Title} ({Year})/{Title} ({Year}).ext Category: category,
folder := title Title: title,
if m.Year > 0 { Ext: ext,
folder = fmt.Sprintf("%s (%d)", title, m.Year) Year: m.Year,
Season: m.SeasonNum,
Episode: m.EpisodeNum,
Series: true,
})
if err != nil {
return "", err
} }
dst = target.Path
} else {
root := o.organizeRoot(baseRoot, lib.Type, category) root := o.organizeRoot(baseRoot, lib.Type, category)
dir := filepath.Join(categoryRoot(root, category), folder) target, err := o.buildOrganizeTargetPath(ctx, organizeTargetInput{
dst = filepath.Join(dir, folder+ext) Root: categoryRoot(root, category),
MediaType: lib.Type,
Category: category,
Title: title,
Ext: ext,
Year: m.Year,
})
if err != nil {
return "", err
}
dst = target.Path
} }
// Skip if already in place. // Skip if already in place.
+130 -27
View File
@@ -254,15 +254,23 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo
title = "Unknown" title = "Unknown"
} }
season, episode := ParseEpisode(src) season, episode := ParseEpisode(src)
layout := o.inferOrganizeDirectoryLayout(src, sourceRoot) pathLayout := o.inferOrganizeDirectoryLayout(src, sourceRoot)
if forced := normalizeOrganizeMediaType(mediaTypeOverride); forced != "" { layout := pathLayout
if layout.Category != "" && layout.MediaType != "" && layout.MediaType != forced { forcedType := normalizeOrganizeMediaType(mediaTypeOverride)
inferredType := o.inferMediaTypeForSourceFile(src, title, season, episode)
if forcedType != "" {
if layout.Category != "" && layout.MediaType != "" && layout.MediaType != forcedType {
layout.Category = "" layout.Category = ""
} }
layout.MediaType = forced layout.MediaType = forcedType
} } else if inferredType != "" {
if layout.MediaType == "" { if inferredType == "tv" && layout.MediaType == "movie" {
layout.MediaType = o.inferMediaTypeForSourceFile(src, title, season, episode) // 文件名中明确有季/集信息时,目录名只能作为弱提示;否则
// 下载到错误的“电影/外语电影”等目录会把剧集按电影入库。
layout = organizeDirectoryLayout{MediaType: inferredType}
} else if layout.MediaType == "" {
layout.MediaType = inferredType
}
} }
var metadataMatch *Match var metadataMatch *Match
if match := o.lookupOrganizeMetadata(ctx, src, sourceRoot, layout.MediaType, title, year, season, episode, metadataCache); match != nil { if match := o.lookupOrganizeMetadata(ctx, src, sourceRoot, layout.MediaType, title, year, season, episode, metadataCache); match != nil {
@@ -277,8 +285,19 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo
} }
if category := strings.TrimSpace(mediaCategoryOverride); category != "" { if category := strings.TrimSpace(mediaCategoryOverride); category != "" {
layout.Category = sanitizeFilename(category) layout.Category = sanitizeFilename(category)
} else if layout.Category == "" { } else if category := o.smartClassifySourceFile(ctx, src, sourceRoot, layout.MediaType, title, parsedTitle, metadataMatch); category != "" {
layout.Category = o.smartClassifySourceFile(ctx, src, sourceRoot, layout.MediaType, title, parsedTitle) // MoviePilot 的分类策略以识别后的元数据为主,下载/源目录只作为
// 兜底提示。这里即使源目录已有二级分类,也允许 TMDb/Bangumi/NFO
// 识别结果修正到真正的分类,避免错误目录导致错误入库。
layout.Category = category
}
if forcedType == "" {
if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(layout.Category); impliedType != "" {
layout.Category = normalizedCategory
if layout.MediaType == "" || layout.MediaType == "tv" || layout.MediaType == "anime" || pathLayout.Category != layout.Category {
layout.MediaType = impliedType
}
}
} }
layoutRoot, matchedLibrary := o.organizeLibraryRootForLayout(ctx, destRoot, layout.MediaType, layout.Category) layoutRoot, matchedLibrary := o.organizeLibraryRootForLayout(ctx, destRoot, layout.MediaType, layout.Category)
if !matchedLibrary && layout.MediaType != "" { if !matchedLibrary && layout.MediaType != "" {
@@ -296,20 +315,23 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo
if layout.MediaType != "" { if layout.MediaType != "" {
isSeries = isSeriesLibraryType(layout.MediaType) && (season > 0 || episode > 0) isSeries = isSeriesLibraryType(layout.MediaType) && (season > 0 || episode > 0)
} }
if isSeries { target, err := o.buildOrganizeTargetPath(ctx, organizeTargetInput{
// TV/动漫/综艺等剧集:{destRoot}/{Title}/Season XX/{Title} - SxxExx.ext Root: layoutRoot,
episodeTag = fmt.Sprintf("S%02dE%02d", season, episode) MediaType: layout.MediaType,
destDir = filepath.Join(layoutRoot, title, fmt.Sprintf("Season %02d", season)) Category: layout.Category,
dst = filepath.Join(destDir, fmt.Sprintf("%s - %s%s", title, episodeTag, ext)) Title: title,
} else { Ext: ext,
// 电影:{destRoot}/{Title} ({Year})/{Title} ({Year}).ext Year: year,
folder := title Season: season,
if year > 0 { Episode: episode,
folder = fmt.Sprintf("%s (%d)", title, year) Series: isSeries,
} })
destDir = filepath.Join(layoutRoot, folder) if err != nil {
dst = filepath.Join(destDir, folder+ext) return err
} }
destDir = target.Dir
dst = target.Path
episodeTag = target.EpisodeTag
// 源文件已经位于目标位置:无需处理。 // 源文件已经位于目标位置:无需处理。
if filepath.Clean(src) == filepath.Clean(dst) { if filepath.Clean(src) == filepath.Clean(dst) {
@@ -493,10 +515,28 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour
} }
} }
match := o.scraper.lookup(ctx, lib, candidate, year) match := o.scraper.lookup(ctx, lib, candidate, year)
if cache != nil {
cache[key] = match
}
if match != nil && strings.TrimSpace(match.Title) != "" { if match != nil && strings.TrimSpace(match.Title) != "" {
if !organizeMetadataMatchTrusted(candidate, year, match) {
if cache != nil {
cache[key] = nil
}
if o.log != nil {
o.log.Warn("organize metadata match rejected before rename",
zap.String("source", src),
zap.String("query", candidate),
zap.String("title", match.Title),
zap.Int("source_year", year),
zap.Int("match_year", match.Year),
zap.Int("tmdb_id", match.TMDbID),
zap.Int("bangumi_id", match.BangumiID),
zap.String("douban_id", match.DoubanID),
zap.String("thetvdb_id", match.TheTVDBID))
}
continue
}
if cache != nil {
cache[key] = match
}
if o.log != nil { if o.log != nil {
o.log.Info("organize metadata matched before rename", o.log.Info("organize metadata matched before rename",
zap.String("source", src), zap.String("source", src),
@@ -510,10 +550,29 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour
} }
return match return match
} }
if cache != nil {
cache[key] = nil
}
} }
return nil return nil
} }
func organizeMetadataMatchTrusted(query string, sourceYear int, match *Match) bool {
if match == nil || strings.TrimSpace(match.Title) == "" {
return false
}
if sourceYear > 0 && match.Year > 0 {
diff := sourceYear - match.Year
if diff < 0 {
diff = -diff
}
if diff > 1 {
return false
}
}
return true
}
func organizeMatchFromLocalMetadata(local *LocalMetadata) *Match { func organizeMatchFromLocalMetadata(local *LocalMetadata) *Match {
if local == nil || strings.TrimSpace(local.Title) == "" { if local == nil || strings.TrimSpace(local.Title) == "" {
return nil return nil
@@ -547,7 +606,7 @@ func organizeMetadataCacheKey(mediaType, query string, year int) string {
return strings.ToLower(strings.TrimSpace(mediaType)) + "|" + fmt.Sprint(year) + "|" + strings.ToLower(strings.TrimSpace(query)) return strings.ToLower(strings.TrimSpace(mediaType)) + "|" + fmt.Sprint(year) + "|" + strings.ToLower(strings.TrimSpace(query))
} }
func (o *OrganizerService) smartClassifySourceFile(ctx context.Context, src, sourceRoot, mediaType, title, parsedTitle string) string { func (o *OrganizerService) smartClassifySourceFile(ctx context.Context, src, sourceRoot, mediaType, title, parsedTitle string, metadataMatch *Match) string {
if o == nil || !o.isSmartClassifyEnabled(ctx) { if o == nil || !o.isSmartClassifyEnabled(ctx) {
return "" return ""
} }
@@ -557,6 +616,20 @@ func (o *OrganizerService) smartClassifySourceFile(ctx context.Context, src, sou
Title: strings.Join([]string{title, parsedTitle, filepath.Base(src)}, " "), Title: strings.Join([]string{title, parsedTitle, filepath.Base(src)}, " "),
Category: strings.Join(organizeDirectoryCategoryCandidates(src, sourceRoot), " "), Category: strings.Join(organizeDirectoryCategoryCandidates(src, sourceRoot), " "),
} }
if metadataMatch != nil {
input.Title = strings.Join([]string{
metadataMatch.OriginalName,
title,
parsedTitle,
filepath.Base(src),
}, " ")
input.Languages = metadataMatch.Languages
input.Countries = metadataMatch.Countries
input.Genres = metadataMatch.Genres
if metadataMatch.NSFW {
input.MediaType = "adult"
}
}
if meta, err := ReadLocalMetadata(src, sourceRoot, seriesLike); err == nil && meta != nil && meta.HasNFO { if meta, err := ReadLocalMetadata(src, sourceRoot, seriesLike); err == nil && meta != nil && meta.HasNFO {
input.Title = strings.Join([]string{meta.Title, meta.OriginalName, title, parsedTitle, filepath.Base(src)}, " ") input.Title = strings.Join([]string{meta.Title, meta.OriginalName, title, parsedTitle, filepath.Base(src)}, " ")
input.Languages = parseCommaList(meta.Languages) input.Languages = parseCommaList(meta.Languages)
@@ -595,7 +668,9 @@ func organizeDirectoryCategoryCandidates(src, sourceRoot string) []string {
} }
cleanSourceRoot := filepath.Clean(sourceRoot) cleanSourceRoot := filepath.Clean(sourceRoot)
add(filepath.Base(cleanSourceRoot)) for _, part := range organizePathNameParts(cleanSourceRoot) {
add(part)
}
rel, err := filepath.Rel(cleanSourceRoot, filepath.Clean(src)) rel, err := filepath.Rel(cleanSourceRoot, filepath.Clean(src))
if err != nil || rel == "." || strings.HasPrefix(rel, "..") { if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
return out return out
@@ -610,6 +685,34 @@ func organizeDirectoryCategoryCandidates(src, sourceRoot string) []string {
return out return out
} }
func organizePathNameParts(path string) []string {
clean := filepath.Clean(strings.TrimSpace(path))
if clean == "" || clean == "." {
return nil
}
volume := filepath.VolumeName(clean)
if volume != "" {
clean = strings.TrimPrefix(clean, volume)
}
clean = strings.Trim(clean, string(os.PathSeparator))
if clean == "" {
base := filepath.Base(filepath.Clean(path))
if base == "." || base == string(os.PathSeparator) {
return nil
}
return []string{base}
}
parts := strings.Split(clean, string(os.PathSeparator))
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" && part != "." {
out = append(out, part)
}
}
return out
}
func (o *OrganizerService) mediaTypeForDirectoryCategory(name string) (string, string) { func (o *OrganizerService) mediaTypeForDirectoryCategory(name string) (string, string) {
key := strings.ToLower(strings.TrimSpace(name)) key := strings.ToLower(strings.TrimSpace(name))
if key == "" { if key == "" {
+1 -1
View File
@@ -694,7 +694,7 @@ func TestOrganizeDirectorySmartClassifiesUncategorizedSources(t *testing.T) {
filepath.Join(dest, "电影", "华语电影", "流浪地球2 (2023)", "流浪地球2 (2023).mkv"), filepath.Join(dest, "电影", "华语电影", "流浪地球2 (2023)", "流浪地球2 (2023).mkv"),
filepath.Join(dest, "电影", "外语电影", "Dune (2021)", "Dune (2021).mkv"), filepath.Join(dest, "电影", "外语电影", "Dune (2021)", "Dune (2021).mkv"),
filepath.Join(dest, "电视剧", "国产剧", "狂飙", "Season 01", "狂飙 - S01E01.mkv"), filepath.Join(dest, "电视剧", "国产剧", "狂飙", "Season 01", "狂飙 - S01E01.mkv"),
filepath.Join(dest, "电视剧", "欧美剧", "The Last Of Us", "Season 01", "The Last Of Us - S01E01.mkv"), filepath.Join(dest, "电视剧", "未分类", "The Last Of Us", "Season 01", "The Last Of Us - S01E01.mkv"),
} { } {
if _, err := os.Stat(want); err != nil { if _, err := os.Stat(want); err != nil {
t.Fatalf("expected smart classified file at %q: %v; items=%+v", want, err, res.Items) t.Fatalf("expected smart classified file at %q: %v; items=%+v", want, err, res.Items)
+320
View File
@@ -6,6 +6,7 @@ import (
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"go.uber.org/zap" "go.uber.org/zap"
@@ -105,6 +106,325 @@ func TestOrganizeDirectoryUsesScraperMatchBeforeRename(t *testing.T) {
} }
} }
func TestOrganizeDirectoryClassifiesScraperMatchBeforeRename(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/search/movie":
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 45745,
"title": "寻龙记",
"original_title": "Sintel",
"original_language": "en",
"genre_ids": []int{16, 14},
"release_date": "2010-09-27",
"vote_average": 7.4,
}},
})
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
repos := newOrganizerTestRepo(t)
cfg := &config.Config{}
cfg.Organizer.SmartClassify = true
cfg.Secrets.TMDbAPIKey = "test-key"
cfg.Secrets.TMDbAPIProxy = upstream.URL
scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop()))
root := t.TempDir()
src := filepath.Join(root, "downloads")
dest := filepath.Join(root, "media")
sourceFile := filepath.Join(src, "Sintel.2010.1080p.CodexVerify.mp4")
writeOrgFile(t, sourceFile, "movie")
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
organizer.SetScraper(scraper)
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
SourcePath: src,
DestPath: dest,
TransferMode: TransferCopy,
MediaType: "movie",
})
if err != nil {
t.Fatalf("organize directory: %v", err)
}
want := filepath.Join(dest, "电影", "动画电影", "寻龙记 (2010)", "寻龙记 (2010).mp4")
if res.Organized != 1 {
t.Fatalf("organized = %d, want 1; items=%#v errors=%#v", res.Organized, res.Items, res.Errors)
}
if _, err := os.Stat(want); err != nil {
t.Fatalf("organized movie should use metadata category path %q: %v; items=%#v", want, err, res.Items)
}
if len(res.Items) != 1 || res.Items[0].Category != "动画电影" {
t.Fatalf("organize category = %#v, want 动画电影", res.Items)
}
}
func TestOrganizeDirectoryMetadataCategoryOverridesDownloadFolder(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/search/tv":
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 12345,
"name": "间谍过家家",
"original_name": "SPY×FAMILY",
"original_language": "ja",
"origin_country": []string{"JP"},
"genre_ids": []int{16, 35},
"first_air_date": "2022-04-09",
"vote_average": 8.6,
}},
})
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
repos := newOrganizerTestRepo(t)
cfg := &config.Config{}
cfg.Organizer.SmartClassify = true
cfg.Secrets.TMDbAPIKey = "test-key"
cfg.Secrets.TMDbAPIProxy = upstream.URL
scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop()))
root := t.TempDir()
srcRoot := filepath.Join(root, "downloads")
dest := filepath.Join(root, "media")
sourceFile := filepath.Join(srcRoot, "国产剧", "Spy.x.Family.S01E01.2022.1080p.mkv")
writeOrgFile(t, sourceFile, "episode")
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
organizer.SetScraper(scraper)
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
SourcePath: srcRoot,
DestPath: dest,
TransferMode: TransferCopy,
})
if err != nil {
t.Fatalf("organize directory: %v", err)
}
want := filepath.Join(dest, "电视剧", "日番", "间谍过家家", "Season 01", "间谍过家家 - S01E01.mkv")
if res.Organized != 1 {
t.Fatalf("organized = %d, want 1; items=%#v errors=%#v", res.Organized, res.Items, res.Errors)
}
if _, err := os.Stat(want); err != nil {
t.Fatalf("metadata category should override wrong source folder at %q: %v; items=%#v", want, err, res.Items)
}
if len(res.Items) != 1 || res.Items[0].Category != "日番" || res.Items[0].MediaType != "anime" {
t.Fatalf("organize metadata category/type = %#v, want 日番/anime", res.Items)
}
}
func TestOrganizeDirectoryDoesNotScrapeByDownloadCategoryFolder(t *testing.T) {
var queries []string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/search/tv" {
http.NotFound(w, r)
return
}
query := r.URL.Query().Get("query")
queries = append(queries, query)
switch {
case query == "国产剧":
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 843248,
"name": "高达 G之复国运动 剧场版III 来自宇宙的遗产",
"first_air_date": "2021-07-22",
}},
})
case strings.EqualFold(query, "ashes to crown"):
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 289271,
"name": "翘楚",
"original_name": "Ashes to Crown",
"original_language": "zh",
"origin_country": []string{"CN"},
"genre_ids": []int{18},
"first_air_date": "2026-06-01",
}},
})
default:
_ = json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{}})
}
}))
defer upstream.Close()
repos := newOrganizerTestRepo(t)
cfg := &config.Config{}
cfg.Organizer.SmartClassify = true
cfg.Secrets.TMDbAPIKey = "test-key"
cfg.Secrets.TMDbAPIProxy = upstream.URL
scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop()))
root := t.TempDir()
dest := filepath.Join(root, "media")
sourceFile := filepath.Join(root, "downloads", "国产剧", "Ashes.to.Crown.S01.1080p.YOUKU.WEB-DL.AAC2.0.H.264-MWeb", "Ashes.to.Crown.S01E06.1080p.YOUKU.WEB-DL.AAC2.0.H.264-MWeb.mkv")
writeOrgFile(t, sourceFile, "episode")
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
organizer.SetScraper(scraper)
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
SourcePath: sourceFile,
DestPath: dest,
TransferMode: TransferCopy,
MediaType: "tv",
MediaCategory: "国产剧",
AllowReplaceExisting: false,
})
if err != nil {
t.Fatalf("organize directory: %v", err)
}
want := filepath.Join(dest, "电视剧", "国产剧", "翘楚", "Season 01", "翘楚 - S01E06.mkv")
if res.Organized != 1 {
t.Fatalf("organized = %d, want 1; items=%#v errors=%#v queries=%#v", res.Organized, res.Items, res.Errors, queries)
}
if _, err := os.Stat(want); err != nil {
t.Fatalf("organized file should use release title metadata, not category query, at %q: %v; items=%#v queries=%#v", want, err, res.Items, queries)
}
if len(queries) == 0 || queries[0] == "国产剧" {
t.Fatalf("first scrape query = %#v, want release title before category folder", queries)
}
if len(res.Items) != 1 || res.Items[0].Title != "翘楚" || res.Items[0].Category != "国产剧" {
t.Fatalf("organize item = %#v, want title 翘楚 in category 国产剧", res.Items)
}
}
func TestOrganizeDirectoryEpisodeMarkerOverridesMovieSourceFolder(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/search/tv":
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 100088,
"name": "The Last of Us",
"original_language": "en",
"origin_country": []string{"US"},
"genre_ids": []int{18},
"first_air_date": "2023-01-15",
"vote_average": 8.7,
}},
})
case r.URL.Path == "/search/movie":
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 999,
"title": "Wrong Movie",
"release_date": "2023-01-01",
}},
})
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
repos := newOrganizerTestRepo(t)
cfg := &config.Config{}
cfg.Organizer.SmartClassify = true
cfg.Secrets.TMDbAPIKey = "test-key"
cfg.Secrets.TMDbAPIProxy = upstream.URL
scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop()))
root := t.TempDir()
srcRoot := filepath.Join(root, "downloads")
dest := filepath.Join(root, "media")
sourceFile := filepath.Join(srcRoot, "外语电影", "The.Last.of.Us.S01E01.2023.1080p.mkv")
writeOrgFile(t, sourceFile, "episode")
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
organizer.SetScraper(scraper)
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
SourcePath: srcRoot,
DestPath: dest,
TransferMode: TransferCopy,
})
if err != nil {
t.Fatalf("organize directory: %v", err)
}
want := filepath.Join(dest, "电视剧", "欧美剧", "The Last of Us", "Season 01", "The Last of Us - S01E01.mkv")
if res.Organized != 1 {
t.Fatalf("organized = %d, want 1; items=%#v errors=%#v", res.Organized, res.Items, res.Errors)
}
if _, err := os.Stat(want); err != nil {
t.Fatalf("episode marker should force TV organize path %q: %v; items=%#v", want, err, res.Items)
}
if len(res.Items) != 1 || res.Items[0].Category != "欧美剧" || res.Items[0].MediaType != "tv" {
t.Fatalf("organize episode category/type = %#v, want 欧美剧/tv", res.Items)
}
}
func TestOrganizeDirectoryRejectsWrongYearScraperRename(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/search/tv":
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 999,
"name": "Parade of Stars Auto Show",
"first_air_date": "1952-01-01",
}},
})
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
repos := newOrganizerTestRepo(t)
cfg := &config.Config{}
cfg.Secrets.TMDbAPIKey = "test-key"
cfg.Secrets.TMDbAPIProxy = upstream.URL
scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop()))
root := t.TempDir()
src := filepath.Join(root, "downloads")
dest := filepath.Join(root, "media")
sourceFile := filepath.Join(src, "Auto.Show.S01E03.2026.1080p.mkv")
writeOrgFile(t, sourceFile, "episode")
secondSourceFile := filepath.Join(src, "Auto.Show.S01E04.2026.1080p.mkv")
writeOrgFile(t, secondSourceFile, "episode")
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
organizer.SetScraper(scraper)
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
SourcePath: src,
DestPath: dest,
TransferMode: TransferCopy,
MediaType: "tv",
})
if err != nil {
t.Fatalf("organize directory: %v", err)
}
rejected := filepath.Join(dest, "电视剧", "Parade of Stars Auto Show", "Season 01", "Parade of Stars Auto Show - S01E03.mkv")
if _, err := os.Stat(rejected); err == nil {
t.Fatalf("wrong-year metadata match should not rename to %q", rejected)
}
want := filepath.Join(dest, "电视剧", "Auto Show", "Season 01", "Auto Show - S01E03.mkv")
if res.Organized != 2 {
t.Fatalf("organized = %d, want 2; items=%#v errors=%#v", res.Organized, res.Items, res.Errors)
}
if _, err := os.Stat(want); err != nil {
t.Fatalf("organize should keep parsed title at %q: %v; items=%#v", want, err, res.Items)
}
secondWant := filepath.Join(dest, "电视剧", "Auto Show", "Season 01", "Auto Show - S01E04.mkv")
if _, err := os.Stat(secondWant); err != nil {
t.Fatalf("organize should not reuse rejected cached match at %q: %v; items=%#v", secondWant, err, res.Items)
}
}
func TestOrganizeDirectoryDedupsByExternalIDBeforeRename(t *testing.T) { func TestOrganizeDirectoryDedupsByExternalIDBeforeRename(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t) scraper, repos, closeServer := newTestScraper(t)
defer closeServer() defer closeServer()
+278
View File
@@ -0,0 +1,278 @@
package service
import (
"context"
"fmt"
"path/filepath"
"regexp"
"strconv"
"strings"
)
type organizeTargetInput struct {
Root string
MediaType string
Category string
Title string
Ext string
Year int
Season int
Episode int
Series bool
}
type organizeTargetPath struct {
Dir string
Path string
EpisodeTag string
}
func (o *OrganizerService) buildOrganizeTargetPath(ctx context.Context, in organizeTargetInput) (organizeTargetPath, error) {
root := filepath.Clean(strings.TrimSpace(in.Root))
if root == "" || root == "." {
return organizeTargetPath{}, fmt.Errorf("organize target root required")
}
title := sanitizeFilename(strings.TrimSpace(in.Title))
if title == "" {
title = "Unknown"
}
ext := strings.TrimSpace(in.Ext)
if ext != "" && !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
episodeTag := ""
if in.Series {
season := in.Season
if season <= 0 {
season = 1
}
episode := in.Episode
if episode <= 0 {
episode = 1
}
episodeTag = fmt.Sprintf("S%02dE%02d", season, episode)
}
template := strings.TrimSpace(o.organizeNamingFormat(ctx, in.MediaType, in.Series))
var rel string
if template == "" {
rel = defaultOrganizeRelativePath(title, ext, in.Year, in.Season, in.Episode, in.Series)
} else {
rel = renderOrganizeNamingTemplate(template, organizeNamingData{
Title: title,
Year: in.Year,
Season: in.Season,
Episode: in.Episode,
Ext: strings.TrimPrefix(ext, "."),
FileExt: ext,
Category: sanitizeFilename(in.Category),
MediaType: normalizeOrganizeMediaType(in.MediaType),
EpisodeTag: episodeTag,
})
rel = cleanOrganizeRelativePath(rel)
if rel == "" {
rel = defaultOrganizeRelativePath(title, ext, in.Year, in.Season, in.Episode, in.Series)
}
if ext != "" && !strings.EqualFold(filepath.Ext(rel), ext) {
rel += ext
}
}
dst := filepath.Join(root, rel)
return organizeTargetPath{
Dir: filepath.Dir(dst),
Path: dst,
EpisodeTag: episodeTag,
}, nil
}
func (o *OrganizerService) organizeNamingFormat(ctx context.Context, mediaType string, series bool) string {
if o == nil || o.repo == nil || o.repo.Setting == nil {
return ""
}
key := "organize.movie_format"
if series {
if normalizeOrganizeMediaType(mediaType) == "anime" {
key = "organize.anime_format"
} else {
key = "organize.tv_format"
}
}
value, err := o.repo.Setting.Get(ctx, key)
if err != nil {
return ""
}
return strings.TrimSpace(value)
}
func defaultOrganizeRelativePath(title, ext string, year, season, episode int, series bool) string {
if series {
if season <= 0 {
season = 1
}
if episode <= 0 {
episode = 1
}
episodeTag := fmt.Sprintf("S%02dE%02d", season, episode)
return filepath.Join(title, fmt.Sprintf("Season %02d", season), fmt.Sprintf("%s - %s%s", title, episodeTag, ext))
}
folder := title
if year > 0 {
folder = fmt.Sprintf("%s (%d)", title, year)
}
return filepath.Join(folder, folder+ext)
}
type organizeNamingData struct {
Title string
Year int
Season int
Episode int
Ext string
FileExt string
Category string
MediaType string
EpisodeTag string
}
var organizeTemplateTokenRE = regexp.MustCompile(`\{([A-Za-z_]+)(?::([^}]+))?\}`)
var organizeTemplateMustacheRE = regexp.MustCompile(`\{\{\s*([A-Za-z_]+)(?::([^}]+))?\s*\}\}`)
var organizeTemplateIfRE = regexp.MustCompile(`(?s)\{%\s*if\s+([A-Za-z_]+)\s*%\}(.*?)\{%\s*endif\s*%\}`)
func renderOrganizeNamingTemplate(template string, data organizeNamingData) string {
template = renderOrganizeConditionals(template, data)
template = organizeTemplateMustacheRE.ReplaceAllStringFunc(template, func(token string) string {
matches := organizeTemplateMustacheRE.FindStringSubmatch(token)
if len(matches) == 0 {
return token
}
format := ""
if len(matches) > 2 {
format = matches[2]
}
return organizeTemplateValue(matches[1], format, data, token)
})
return organizeTemplateTokenRE.ReplaceAllStringFunc(template, func(token string) string {
matches := organizeTemplateTokenRE.FindStringSubmatch(token)
if len(matches) == 0 {
return token
}
format := ""
if len(matches) > 2 {
format = matches[2]
}
return organizeTemplateValue(matches[1], format, data, token)
})
}
func renderOrganizeConditionals(template string, data organizeNamingData) string {
for {
next := organizeTemplateIfRE.ReplaceAllStringFunc(template, func(token string) string {
matches := organizeTemplateIfRE.FindStringSubmatch(token)
if len(matches) < 3 {
return token
}
if organizeTemplateTruthy(matches[1], data) {
return matches[2]
}
return ""
})
if next == template {
return next
}
template = next
}
}
func organizeTemplateTruthy(name string, data organizeNamingData) bool {
switch strings.ToLower(strings.TrimSpace(name)) {
case "title", "name":
return data.Title != ""
case "year":
return data.Year > 0
case "season":
return data.Season > 0
case "episode", "ep":
return data.Episode > 0
case "ext", "extension":
return data.Ext != ""
case "fileext", "file_ext":
return data.FileExt != ""
case "category":
return data.Category != ""
case "type", "media_type":
return data.MediaType != ""
case "episode_tag", "episodetag", "season_episode":
return data.EpisodeTag != ""
case "part", "videoformat", "video_format":
return false
default:
return false
}
}
func organizeTemplateValue(name, format string, data organizeNamingData, fallback string) string {
switch strings.ToLower(strings.TrimSpace(name)) {
case "title", "name":
return data.Title
case "year":
if data.Year <= 0 {
return ""
}
return strconv.Itoa(data.Year)
case "season":
return formatOrganizeNumber(data.Season, format, 1)
case "episode", "ep":
return formatOrganizeNumber(data.Episode, format, 1)
case "ext", "extension":
return data.Ext
case "fileext", "file_ext":
return data.FileExt
case "category":
return data.Category
case "type", "media_type":
return data.MediaType
case "episode_tag", "episodetag", "season_episode":
return data.EpisodeTag
case "part", "videoformat", "video_format":
return ""
default:
return fallback
}
}
func formatOrganizeNumber(value int, format string, fallback int) string {
if value <= 0 {
value = fallback
}
format = strings.TrimSpace(strings.TrimSuffix(format, "d"))
if strings.HasPrefix(format, "0") {
width, err := strconv.Atoi(strings.TrimPrefix(format, "0"))
if err == nil && width > 0 {
return fmt.Sprintf("%0*d", width, value)
}
}
return strconv.Itoa(value)
}
func cleanOrganizeRelativePath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
path = strings.ReplaceAll(path, "\\", "/")
parts := strings.Split(path, "/")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = sanitizeFilename(strings.TrimSpace(part))
part = strings.Trim(part, ". ")
if part == "" || part == "." || part == ".." {
continue
}
out = append(out, part)
}
if len(out) == 0 {
return ""
}
return filepath.Join(out...)
}
+9 -2
View File
@@ -17,10 +17,17 @@ func translateClientPath(clientPath string, mappings map[string]string) string {
if _, err := os.Stat(clean); err == nil { if _, err := os.Stat(clean); err == nil {
return clean return clean
} }
normalizedClean := cleanPathForVolumeMapping(clientPath)
if normalizedClean != "" && normalizedClean != "." {
direct := filepath.Clean(filepath.FromSlash(normalizedClean))
if _, err := os.Stat(direct); err == nil {
return direct
}
}
// 尝试路径映射 // 尝试路径映射
cleanForMatch := filepath.ToSlash(clean) cleanForMatch := strings.TrimRight(normalizedClean, "/")
for clientPrefix, localPrefix := range mappings { for clientPrefix, localPrefix := range mappings {
prefix := strings.TrimRight(filepath.ToSlash(filepath.Clean(clientPrefix)), "/") prefix := strings.TrimRight(cleanPathForVolumeMapping(clientPrefix), "/")
if prefix == "" || prefix == "." { if prefix == "" || prefix == "." {
continue continue
} }
+1 -1
View File
@@ -181,7 +181,7 @@ func (q *QBitClient) addTorrentLocked(ctx context.Context, magnetOrURL string, t
if hash := torrentInfoHash(torrentData); hash != "" { if hash := torrentInfoHash(torrentData); hash != "" {
if _, ok := beforeHashes[hash]; ok { if _, ok := beforeHashes[hash]; ok {
q.log.Info("qbittorrent: torrent already exists", zap.String("hash", hash), zap.String("name", torrentName)) q.log.Info("qbittorrent: torrent already exists", zap.String("hash", hash), zap.String("name", torrentName))
return nil return ErrDownloadAlreadyExists
} }
} }
} }
+4 -3
View File
@@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"errors"
"io" "io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
@@ -187,7 +188,7 @@ func TestQBitAddTorrentUploadsFetchedTorrentFile(t *testing.T) {
} }
} }
func TestQBitAddTorrentFileTreatsExistingInfoHashAsSuccess(t *testing.T) { func TestQBitAddTorrentFileReturnsDedupForExistingInfoHash(t *testing.T) {
torrentData := []byte("d4:infod4:name7:fixtureee") torrentData := []byte("d4:infod4:name7:fixtureee")
hash := torrentInfoHash(torrentData) hash := torrentInfoHash(torrentData)
if hash == "" { if hash == "" {
@@ -215,8 +216,8 @@ func TestQBitAddTorrentFileTreatsExistingInfoHashAsSuccess(t *testing.T) {
Password: "adminadmin", Password: "adminadmin",
}) })
if err := client.AddTorrentFile(context.Background(), torrentData, "fixture.torrent", ""); err != nil { if err := client.AddTorrentFile(context.Background(), torrentData, "fixture.torrent", ""); !errors.Is(err, ErrDownloadAlreadyExists) {
t.Fatalf("expected existing torrent to be accepted: %v", err) t.Fatalf("err = %v, want ErrDownloadAlreadyExists", err)
} }
if addCalled.Load() { if addCalled.Load() {
t.Fatal("expected qbit add to be skipped for existing infohash") t.Fatal("expected qbit add to be skipped for existing infohash")
+34 -54
View File
@@ -40,16 +40,17 @@ import (
// SchedulerService runs the periodic jobs. // SchedulerService runs the periodic jobs.
type SchedulerService struct { type SchedulerService struct {
log *zap.Logger log *zap.Logger
repo *repository.Container repo *repository.Container
scanner *ScannerService scanner *ScannerService
transcoder *TranscoderService transcoder *TranscoderService
organizer *OrganizerService organizer *OrganizerService
storageCfg *StorageConfigService organizePipeline *OrganizePipelineService
hub *Hub storageCfg *StorageConfigService
tasks *TaskTrackerService hub *Hub
cacheDir string tasks *TaskTrackerService
now func() time.Time cacheDir string
now func() time.Time
mu sync.Mutex mu sync.Mutex
stopCh chan struct{} stopCh chan struct{}
@@ -65,6 +66,10 @@ func (s *SchedulerService) SetTaskTracker(tasks *TaskTrackerService) {
s.tasks = tasks s.tasks = tasks
} }
func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService) {
s.organizePipeline = pipeline
}
// scheduledJob is one recurring task. // scheduledJob is one recurring task.
type scheduledJob struct { type scheduledJob struct {
name string name string
@@ -552,53 +557,21 @@ func (s *SchedulerService) jobOrganizeSource(ctx context.Context) error {
if s.organizer == nil || (!manual && !s.autoOrganizeSourceEnabled(ctx)) { if s.organizer == nil || (!manual && !s.autoOrganizeSourceEnabled(ctx)) {
return nil return nil
} }
task := s.startScheduledOrganizeTask(ctx, manual) taskName := "自动整理重命名刮削入库"
res, err := s.organizer.OrganizeDirectory(ctx, OrganizeOptions{}) if manual {
taskName = "手动触发自动整理重命名刮削入库"
}
resWrap, err := s.ensureOrganizePipeline().Run(ctx, OrganizePipelineRequest{
Scope: OrganizeScopeDirectory,
Trigger: OrganizeTriggerScheduled,
TaskName: taskName,
})
if err != nil { if err != nil {
if task != nil {
task.Finish(err, TaskUpdate{
Stage: "organize",
Message: "自动整理入库失败",
})
}
return err return err
} }
if task != nil && res != nil { res := resWrap.Result
task.Update(TaskUpdate{ if res == nil {
Stage: "organize", res = &OrganizeResult{}
SourcePath: res.SourcePath,
DestPath: res.DestPath,
Message: "自动整理完成,准备扫描入库",
Metrics: OrganizeTaskMetrics(res),
Details: OrganizeTaskDetails(res, 8),
})
}
if s.scanner != nil && res != nil && strings.TrimSpace(res.DestPath) != "" && OrganizeResultNeedsVisibilitySync(res) {
if task != nil {
task.Update(TaskUpdate{
Stage: "scan_scrape",
Message: "正在扫描入库并按设置刮削",
Metrics: OrganizeTaskMetrics(res),
Details: OrganizeTaskDetails(res, 8),
})
}
res.Scans, res.Scrapes = s.scanner.ScanAndScrapeLibrariesForPath(ctx, res.DestPath, "", OrganizeScrapeAfterEnabled(ctx, s.repo))
} else if s.log != nil && res != nil && !OrganizeResultNeedsVisibilitySync(res) {
s.log.Info("scheduled source organize skipped scan; no destination changes",
zap.String("source", res.SourcePath),
zap.String("dest", res.DestPath),
zap.Int("organized", res.Organized),
zap.Int("replaced", res.Replaced),
zap.Int("skipped", res.Skipped),
)
}
if task != nil {
task.Finish(nil, TaskUpdate{
Stage: "completed",
Message: "自动整理入库结束",
Metrics: OrganizeTaskMetrics(res),
Details: OrganizeTaskDetails(res, 8),
})
} }
if s.log != nil && res != nil { if s.log != nil && res != nil {
s.log.Info("scheduled source organize finished", s.log.Info("scheduled source organize finished",
@@ -614,6 +587,13 @@ func (s *SchedulerService) jobOrganizeSource(ctx context.Context) error {
return nil return nil
} }
func (s *SchedulerService) ensureOrganizePipeline() *OrganizePipelineService {
if s.organizePipeline != nil {
return s.organizePipeline
}
return NewOrganizePipelineService(s.log, s.repo, s.organizer, s.scanner, s.tasks)
}
func (s *SchedulerService) startScheduledOrganizeTask(ctx context.Context, manual bool) *TaskHandle { func (s *SchedulerService) startScheduledOrganizeTask(ctx context.Context, manual bool) *TaskHandle {
if s == nil || s.tasks == nil { if s == nil || s.tasks == nil {
return nil return nil
+122 -6
View File
@@ -105,6 +105,17 @@ var noiseTokenSet = func() map[string]struct{} {
return set return set
}() }()
var releaseBoundaryTokenSet = map[string]struct{}{
"1080p": {}, "2160p": {}, "4k": {}, "720p": {}, "480p": {}, "uhd": {}, "fhd": {},
"bd": {}, "bdrip": {}, "brrip": {}, "dvd": {}, "dvdrip": {}, "hdtv": {}, "pdtv": {},
"webdl": {}, "hdrip": {}, "bluray": {}, "webrip": {}, "web": {}, "remux": {},
"x264": {}, "x265": {}, "h264": {}, "h265": {}, "hevc": {}, "avc": {},
}
var strictSeasonFolderPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)^(?:s|season)\.?\s*(\d{1,2})$`),
regexp.MustCompile(`^第\s*([0-9一二三四五六七八九十百零两]+)\s*季$`),
}
// bracketedTag matches "[anything]", "(anything)" or "{anything}" segments. // bracketedTag matches "[anything]", "(anything)" or "{anything}" segments.
var bracketedTag = regexp.MustCompile(`[\[\(\{][^\]\)\}]*[\]\)\}]`) var bracketedTag = regexp.MustCompile(`[\[\(\{][^\]\)\}]*[\]\)\}]`)
var multiWordNoise = []*regexp.Regexp{ var multiWordNoise = []*regexp.Regexp{
@@ -142,6 +153,7 @@ func CleanQuery(raw string) (title string, year int) {
lower = patCN.ReplaceAllString(lower, " ") lower = patCN.ReplaceAllString(lower, " ")
// 去掉中文季/部标记(如「第二季」「第2部」),避免残留在标题里既污染 // 去掉中文季/部标记(如「第二季」「第2部」),避免残留在标题里既污染
// 搜索查询又导致整理后的目录名重复季信息。 // 搜索查询又导致整理后的目录名重复季信息。
lower = patSeasonOnly.ReplaceAllString(lower, " ")
lower = patCNSeason.ReplaceAllString(lower, " ") lower = patCNSeason.ReplaceAllString(lower, " ")
for _, pat := range multiWordNoise { for _, pat := range multiWordNoise {
@@ -153,8 +165,15 @@ func CleanQuery(raw string) (title string, year int) {
// 拆分后丢掉过短(≤1)且全为 ASCII 数字 / 字母的"碎片",避免 // 拆分后丢掉过短(≤1)且全为 ASCII 数字 / 字母的"碎片",避免
// 「2」「0」「v」之类残留干扰 TMDb 搜索。中文字符不算碎片。 // 「2」「0」「v」之类残留干扰 TMDb 搜索。中文字符不算碎片。
out := make([]string, 0, 8) out := make([]string, 0, 8)
seenReleaseBoundary := false
for _, w := range strings.Fields(lower) { for _, w := range strings.Fields(lower) {
if _, ok := noiseTokenSet[w]; ok { if _, ok := noiseTokenSet[w]; ok {
if _, boundary := releaseBoundaryTokenSet[w]; boundary {
seenReleaseBoundary = true
}
continue
}
if seenReleaseBoundary && isASCIIWord(w) {
continue continue
} }
if len(w) <= 1 { if len(w) <= 1 {
@@ -169,6 +188,22 @@ func CleanQuery(raw string) (title string, year int) {
return title, year return title, year
} }
func isASCIIWord(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r >= 128 {
return false
}
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
continue
}
return false
}
return true
}
// EnrichOne runs the provider chain for a single media row. // EnrichOne runs the provider chain for a single media row.
func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error { func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
lib, err := s.repo.Library.FindByID(ctx, m.LibraryID) lib, err := s.repo.Library.FindByID(ctx, m.LibraryID)
@@ -188,10 +223,7 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
} }
} }
year := m.Year year := mediaYearHint(m)
if year == 0 {
_, year = CleanQuery(filepath.Base(m.Path))
}
if s.adult != nil && s.adult.Enabled() { if s.adult != nil && s.adult.Enabled() {
if code := firstText(localAdultCode(local), AdultCodeFromMediaPath(m.Path), normalizeAdultCode(m.OriginalName), normalizeAdultCode(m.Title)); code != "" { if code := firstText(localAdultCode(local), AdultCodeFromMediaPath(m.Path), normalizeAdultCode(m.OriginalName), normalizeAdultCode(m.Title)); code != "" {
@@ -208,8 +240,25 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
var query string var query string
match := (*Match)(nil) match := (*Match)(nil)
for _, candidate := range candidates { for _, candidate := range candidates {
match = s.lookup(ctx, lib, candidate, year)
query = candidate query = candidate
candidateMatch := s.lookup(ctx, lib, candidate, year)
if candidateMatch == nil {
continue
}
if !organizeMetadataMatchTrusted(candidate, year, candidateMatch) {
s.log.Warn("metadata scrape match rejected",
zap.String("media_id", m.ID),
zap.String("query", candidate),
zap.String("title", candidateMatch.Title),
zap.Int("source_year", year),
zap.Int("match_year", candidateMatch.Year),
zap.Int("tmdb_id", candidateMatch.TMDbID),
zap.Int("bangumi_id", candidateMatch.BangumiID),
zap.String("douban_id", candidateMatch.DoubanID),
zap.String("thetvdb_id", candidateMatch.TheTVDBID))
continue
}
match = candidateMatch
if match != nil { if match != nil {
break break
} }
@@ -242,6 +291,31 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
return s.applyProviderMatch(ctx, m, lib, match) return s.applyProviderMatch(ctx, m, lib, match)
} }
func mediaYearHint(m *model.Media) int {
if m == nil {
return 0
}
if m.Year > 0 {
return m.Year
}
if _, year := CleanQuery(filepath.Base(m.Path)); year > 0 {
return year
}
return yearFromText(m.Path)
}
func yearFromText(raw string) int {
if raw == "" {
return 0
}
matches := yearPattern.FindStringSubmatch(strings.ToLower(raw))
if len(matches) < 2 {
return 0
}
year, _ := strconv.Atoi(matches[1])
return year
}
func localAdultCode(local *LocalMetadata) string { func localAdultCode(local *LocalMetadata) string {
if local == nil { if local == nil {
return "" return ""
@@ -489,7 +563,7 @@ func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string {
func seriesFolderTitle(mediaPath, libraryRoot string) string { func seriesFolderTitle(mediaPath, libraryRoot string) string {
dir := filepath.Dir(mediaPath) dir := filepath.Dir(mediaPath)
if seasonFromDir(filepath.Base(dir)) > 0 { if strictSeasonFolder(filepath.Base(dir)) > 0 {
dir = filepath.Dir(dir) dir = filepath.Dir(dir)
} }
if libraryRoot != "" && samePath(dir, filepath.Clean(libraryRoot)) { if libraryRoot != "" && samePath(dir, filepath.Clean(libraryRoot)) {
@@ -499,9 +573,51 @@ func seriesFolderTitle(mediaPath, libraryRoot string) string {
if base == "." || base == string(filepath.Separator) { if base == "." || base == string(filepath.Separator) {
return "" return ""
} }
if isGenericMediaCategoryFolder(base) {
return ""
}
return base return base
} }
func isGenericMediaCategoryFolder(name string) bool {
key := strings.ToLower(strings.TrimSpace(name))
key = strings.Trim(key, `\/`)
switch key {
case "",
"电影", "movies", "movie",
"电视剧", "剧集", "tv", "shows", "series",
"动漫", "动画", "anime", "bangumi",
"国产剧", "国剧", "大陆剧", "国产电视剧",
"欧美剧", "欧美电视剧",
"日韩剧", "日剧", "韩剧",
"华语电影", "国产电影", "大陆电影",
"外语电影", "欧美电影", "日韩电影",
"动画电影", "动漫电影",
"国漫", "国产动漫", "日番", "日漫", "日本动漫", "日本动画",
"综艺", "真人秀",
"纪录片", "纪录",
"儿童", "少儿",
"成人", "番号", "9kg",
"未分类", "uncategorized":
return true
default:
return false
}
}
func strictSeasonFolder(name string) int {
name = strings.TrimSpace(name)
if name == "" {
return 0
}
for _, pattern := range strictSeasonFolderPatterns {
if m := pattern.FindStringSubmatch(name); len(m) == 2 {
return mustAtoi(m[1])
}
}
return 0
}
func seasonFromDir(name string) int { func seasonFromDir(name string) int {
if m := patSeasonFolder.FindStringSubmatch(name); len(m) >= 3 { if m := patSeasonFolder.FindStringSubmatch(name); len(m) >= 3 {
for _, group := range m[1:] { for _, group := range m[1:] {
+87
View File
@@ -71,6 +71,30 @@ func TestScrapeQueryCandidatesPreferSeriesFolderAndCJKTitle(t *testing.T) {
} }
} }
func TestScrapeQueryCandidatesSkipCategoryFolderAsSeriesTitle(t *testing.T) {
lib := &model.Library{
Path: `/downloads`,
Type: "tv",
}
media := &model.Media{
Title: "Ashes To Crown",
Path: `/downloads/国产剧/Ashes.to.Crown.S01E06.1080p.WEB-DL.mkv`,
SeasonNum: 1,
EpisodeNum: 6,
}
got := scrapeQueryCandidates(media, lib)
if len(got) == 0 {
t.Fatal("scrapeQueryCandidates returned no candidates")
}
if got[0] == "国产剧" {
t.Fatalf("first query candidate = %q, category folders must not be used as title candidates: %#v", got[0], got)
}
if !strings.EqualFold(got[0], "Ashes To Crown") {
t.Fatalf("first query candidate = %q, want release title; all candidates=%#v", got[0], got)
}
}
func TestEnrichOneWritesTMDbIDColumn(t *testing.T) { func TestEnrichOneWritesTMDbIDColumn(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t) scraper, repos, closeServer := newTestScraper(t)
defer closeServer() defer closeServer()
@@ -108,6 +132,69 @@ func TestEnrichOneWritesTMDbIDColumn(t *testing.T) {
} }
} }
func TestEnrichOneRejectsWrongYearMatchFromSeriesFolder(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/search/tv":
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 999,
"name": "Parade of Stars Auto Show",
"first_air_date": "1952-01-01",
}},
})
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.TMDbAPIKey = "test-key"
cfg.Secrets.TMDbAPIProxy = upstream.URL
log := zap.NewNop()
scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
root := t.TempDir()
mediaPath := filepath.Join(root, "Auto Show (2026)", "Season 1", "Auto Show - S01E03 - 第 3 集.mkv")
lib := model.Library{Name: "剧集", Path: root, Type: "tv", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "auto show",
Path: mediaPath,
SeasonNum: 1,
EpisodeNum: 3,
ScrapeStatus: "pending",
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
if err := scraper.EnrichOne(t.Context(), &media); err != nil {
t.Fatal(err)
}
var got model.Media
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
t.Fatal(err)
}
if got.ScrapeStatus != "no_match" || got.Title != "auto show" || got.Year != 0 || got.TMDbID != 0 {
t.Fatalf("wrong-year scrape should be rejected, got status=%q title=%q year=%d tmdb=%d", got.ScrapeStatus, got.Title, got.Year, got.TMDbID)
}
}
func TestEnrichOnePrefersLocalMetadataWithoutProvider(t *testing.T) { func TestEnrichOnePrefersLocalMetadataWithoutProvider(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil { if err != nil {
+118 -112
View File
@@ -18,61 +18,62 @@ import (
// Container 持有在启动时初始化的每个服务。Handler 接收指向它的指针并选择相关字段。 // Container 持有在启动时初始化的每个服务。Handler 接收指向它的指针并选择相关字段。
type Container struct { type Container struct {
Cfg *config.Config Cfg *config.Config
Log *zap.Logger Log *zap.Logger
Repo *repository.Container Repo *repository.Container
WSHub *Hub WSHub *Hub
SSEHub *SSEHub SSEHub *SSEHub
Tasks *TaskTrackerService Tasks *TaskTrackerService
Auth *AuthService Auth *AuthService
Media *MediaService Media *MediaService
Scan *ScannerService Scan *ScannerService
Stream *StreamService Stream *StreamService
Transcoder *TranscoderService Transcoder *TranscoderService
FFprobe *FFprobeService FFprobe *FFprobeService
TMDb *TMDbProvider TMDb *TMDbProvider
Bangumi *BangumiProvider Bangumi *BangumiProvider
TheTVDB *TheTVDBProvider TheTVDB *TheTVDBProvider
Fanart *FanartProvider Fanart *FanartProvider
Scraper *ScraperService Scraper *ScraperService
Discover *DiscoverService Discover *DiscoverService
Playback *PlaybackService Playback *PlaybackService
ImageProxy *ImageProxy ImageProxy *ImageProxy
Watcher *WatcherService Watcher *WatcherService
Downloads *DownloadService Downloads *DownloadService
Subscription *SubscriptionService Subscription *SubscriptionService
Subtitle *SubtitleService Subtitle *SubtitleService
Stats *StatsService Stats *StatsService
Profile *ProfileService Profile *ProfileService
Audit *AuditService Audit *AuditService
NFO *NFOService NFO *NFOService
AI *AIService AI *AIService
APIConfig *APIConfigService APIConfig *APIConfigService
Crypto *CryptoService Crypto *CryptoService
Duplicate *DuplicateService Duplicate *DuplicateService
FileManager *FileManagerService FileManager *FileManagerService
DLNA *DLNAService DLNA *DLNAService
Scheduler *SchedulerService Scheduler *SchedulerService
Storage *StorageService Storage *StorageService
Emby *EmbyService Emby *EmbyService
Backup *BackupService Backup *BackupService
Notifier *NotifierService Notifier *NotifierService
NotifyChannels *NotifyChannelService NotifyChannels *NotifyChannelService
TelegramBot *TelegramBotService TelegramBot *TelegramBotService
PlayProfiles *PlayProfileService PlayProfiles *PlayProfileService
Permissions *PermissionService Permissions *PermissionService
StorageCfg *StorageConfigService StorageCfg *StorageConfigService
STRM *STRMService STRM *STRMService
DownloadClients *DownloadClientService DownloadClients *DownloadClientService
Assistant *AssistantService Assistant *AssistantService
Organizer *OrganizerService Organizer *OrganizerService
Douban *DoubanProvider OrganizePipeline *OrganizePipelineService
Token *TokenService Douban *DoubanProvider
ApiConfig *ApiConfigService Token *TokenService
DownloadMgr *DownloadManager ApiConfig *ApiConfigService
Notify *NotifyService DownloadMgr *DownloadManager
Site *SiteService Notify *NotifyService
Device *DeviceService Site *SiteService
Device *DeviceService
stopCtx context.Context stopCtx context.Context
stopCancel context.CancelFunc stopCancel context.CancelFunc
@@ -107,6 +108,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
discover := NewDiscoverService(log, tmdb) discover := NewDiscoverService(log, tmdb)
transcoder := NewTranscoderService(cfg, log, repos, hub) transcoder := NewTranscoderService(cfg, log, repos, hub)
scanner := NewScannerService(cfg, log, repos, hub, probe, scraper) scanner := NewScannerService(cfg, log, repos, hub, probe, scraper)
organizePipeline := NewOrganizePipelineService(log, repos, organizer, scanner, tasks)
watcher := NewWatcherService(log, repos, scanner) watcher := NewWatcherService(log, repos, scanner)
nfo := NewNFOService(log, repos) nfo := NewNFOService(log, repos)
ai := NewAIService(cfg, log, apiConfig) ai := NewAIService(cfg, log, apiConfig)
@@ -128,6 +130,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
assistant := NewAssistantService(log, repos, ai) assistant := NewAssistantService(log, repos, ai)
scheduler := NewSchedulerService(log, repos, scanner, transcoder, organizer, storageCfg, hub, cfg.Cache.CacheDir) scheduler := NewSchedulerService(log, repos, scanner, transcoder, organizer, storageCfg, hub, cfg.Cache.CacheDir)
scheduler.SetTaskTracker(tasks) scheduler.SetTaskTracker(tasks)
scheduler.SetOrganizePipeline(organizePipeline)
// 初始化认证相关服务 // 初始化认证相关服务
tokenSvc := NewTokenService(cfg, log, repos) tokenSvc := NewTokenService(cfg, log, repos)
@@ -151,7 +154,9 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
downloads := NewDownloadService(log, repos, hub, organizer, siteSvc) downloads := NewDownloadService(log, repos, hub, organizer, siteSvc)
downloads.SetScanner(scanner) downloads.SetScanner(scanner)
downloads.SetTaskTracker(tasks) downloads.SetTaskTracker(tasks)
downloads.SetOrganizePipeline(organizePipeline)
subscription := NewSubscriptionService(cfg, log, repos, downloads, siteSvc, hub) subscription := NewSubscriptionService(cfg, log, repos, downloads, siteSvc, hub)
subscription.SetScraper(scraper)
// 让图片代理把媒体库根目录视为可读的本地图片位置:海报/封面等 // 让图片代理把媒体库根目录视为可读的本地图片位置:海报/封面等
// sidecar 资源就存放在这些(用户自定义、任意)目录下,否则会被 // sidecar 资源就存放在这些(用户自定义、任意)目录下,否则会被
@@ -175,63 +180,64 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
return &Container{ return &Container{
Cfg: cfg, Cfg: cfg,
Log: log, Log: log,
Repo: repos, Repo: repos,
WSHub: hub, WSHub: hub,
SSEHub: sseHub, SSEHub: sseHub,
Tasks: tasks, Tasks: tasks,
Auth: authSvc, Auth: authSvc,
Media: NewMediaService(cfg, log, repos), Media: NewMediaService(cfg, log, repos),
Scan: scanner, Scan: scanner,
Stream: NewStreamService(cfg, log, repos, transcoder), Stream: NewStreamService(cfg, log, repos, transcoder),
Transcoder: transcoder, Transcoder: transcoder,
FFprobe: probe, FFprobe: probe,
TMDb: tmdb, TMDb: tmdb,
Bangumi: bangumi, Bangumi: bangumi,
TheTVDB: thetvdb, TheTVDB: thetvdb,
Fanart: fanart, Fanart: fanart,
Scraper: scraper, Scraper: scraper,
Discover: discover, Discover: discover,
Playback: NewPlaybackService(log, repos), Playback: NewPlaybackService(log, repos),
ImageProxy: imageProxy, ImageProxy: imageProxy,
Watcher: watcher, Watcher: watcher,
Downloads: downloads, Downloads: downloads,
Subscription: subscription, Subscription: subscription,
Subtitle: NewSubtitleService(log, repos), Subtitle: NewSubtitleService(log, repos),
Stats: NewStatsService(log, repos), Stats: NewStatsService(log, repos),
Profile: NewProfileService(log, repos), Profile: NewProfileService(log, repos),
Audit: NewAuditService(log, repos), Audit: NewAuditService(log, repos),
NFO: nfo, NFO: nfo,
AI: ai, AI: ai,
APIConfig: apiConfig, APIConfig: apiConfig,
Crypto: crypto, Crypto: crypto,
Duplicate: duplicate, Duplicate: duplicate,
FileManager: filemanager, FileManager: filemanager,
DLNA: dlna, DLNA: dlna,
Scheduler: scheduler, Scheduler: scheduler,
Storage: storage, Storage: storage,
Emby: emby, Emby: emby,
Backup: backup, Backup: backup,
Notifier: notifier, Notifier: notifier,
NotifyChannels: notifyChannels, NotifyChannels: notifyChannels,
TelegramBot: telegramBot, TelegramBot: telegramBot,
PlayProfiles: playProfiles, PlayProfiles: playProfiles,
Permissions: permissions, Permissions: permissions,
StorageCfg: storageCfg, StorageCfg: storageCfg,
STRM: strmSvc, STRM: strmSvc,
DownloadClients: downloadClients, DownloadClients: downloadClients,
Assistant: assistant, Assistant: assistant,
Organizer: organizer, Organizer: organizer,
Douban: douban, OrganizePipeline: organizePipeline,
Token: tokenSvc, Douban: douban,
ApiConfig: apiConfigSvc, Token: tokenSvc,
DownloadMgr: downloadMgr, ApiConfig: apiConfigSvc,
Notify: notifySvc, DownloadMgr: downloadMgr,
Site: siteSvc, Notify: notifySvc,
Device: deviceSvc, Site: siteSvc,
stopCtx: ctx, Device: deviceSvc,
stopCancel: cancel, stopCtx: ctx,
stopCancel: cancel,
} }
} }
+103
View File
@@ -32,6 +32,7 @@ type SubscriptionService struct {
repo *repository.Container repo *repository.Container
downloads *DownloadService downloads *DownloadService
site *SiteService site *SiteService
scraper *ScraperService
hub *Hub hub *Hub
stop chan struct{} stop chan struct{}
} }
@@ -49,6 +50,10 @@ func NewSubscriptionService(cfg *config.Config, log *zap.Logger, repo *repositor
} }
} }
func (s *SubscriptionService) SetScraper(scraper *ScraperService) {
s.scraper = scraper
}
// Start runs the polling loop in the background. // Start runs the polling loop in the background.
func (s *SubscriptionService) Start(ctx context.Context) { func (s *SubscriptionService) Start(ctx context.Context) {
go s.loop(ctx) go s.loop(ctx)
@@ -114,6 +119,11 @@ func (s *SubscriptionService) List(ctx context.Context) ([]model.Subscription, e
return s.repo.Subscription.List(ctx) return s.repo.Subscription.List(ctx)
} }
// History returns completed/archived subscription rules.
func (s *SubscriptionService) History(ctx context.Context) ([]model.Subscription, error) {
return s.repo.Subscription.History(ctx)
}
// Delete removes a subscription. // Delete removes a subscription.
func (s *SubscriptionService) Delete(ctx context.Context, id string) error { func (s *SubscriptionService) Delete(ctx context.Context, id string) error {
return s.repo.DB.Where("id = ?", id).Delete(&model.Subscription{}).Error return s.repo.DB.Where("id = ?", id).Delete(&model.Subscription{}).Error
@@ -126,6 +136,9 @@ func (s *SubscriptionService) RunNow(ctx context.Context, id string) (int, error
if err := s.repo.DB.Where("id = ?", id).First(&sub).Error; err != nil { if err := s.repo.DB.Where("id = ?", id).First(&sub).Error; err != nil {
return 0, err return 0, err
} }
if sub.ArchivedAt != nil {
return 0, nil
}
return s.runOne(ctx, &sub) return s.runOne(ctx, &sub)
} }
@@ -243,6 +256,7 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio
seen = append(seen, guid) seen = append(seen, guid)
seenSet[guid] = struct{}{} seenSet[guid] = struct{}{}
} }
avail = s.finalizePendingAvailability(sub, avail)
// Remember the last 200 GUIDs so the seen set doesn't grow forever. // Remember the last 200 GUIDs so the seen set doesn't grow forever.
if len(seen) > 200 { if len(seen) > 200 {
seen = seen[len(seen)-200:] seen = seen[len(seen)-200:]
@@ -251,6 +265,7 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio
now := time.Now() now := time.Now()
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error _ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error
_ = s.archiveCompletedSubscription(ctx, sub, avail)
if queued > 0 { if queued > 0 {
s.hub.Publish("subscription", map[string]any{ s.hub.Publish("subscription", map[string]any{
"id": sub.ID, "id": sub.ID,
@@ -300,6 +315,7 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
item := candidate.Item item := candidate.Item
mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, item.Title, item.Category) mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, item.Title, item.Category)
if s.shouldSkipExistingTorrent(ctx, mediaType, candidate) { if s.shouldSkipExistingTorrent(ctx, mediaType, candidate) {
addAvailabilityTitle(item.Title, availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)), &availability)
seen = append(seen, candidate.GUID) seen = append(seen, candidate.GUID)
seenSet[candidate.GUID] = struct{}{} seenSet[candidate.GUID] = struct{}{}
continue continue
@@ -307,6 +323,7 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
realURL := s.site.ResolveDownloadURL(ctx, candidate.Download) realURL := s.site.ResolveDownloadURL(ctx, candidate.Download)
savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory) savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory)
if s.downloadPathHasCandidate(ctx, sub, candidate.Item.Title, savePath) { if s.downloadPathHasCandidate(ctx, sub, candidate.Item.Title, savePath) {
addAvailabilityTitle(item.Title, availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)), &availability)
seen = append(seen, candidate.GUID) seen = append(seen, candidate.GUID)
seenSet[candidate.GUID] = struct{}{} seenSet[candidate.GUID] = struct{}{}
continue continue
@@ -322,6 +339,7 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
AllowExistingLibrary: sub.WashEnabled, AllowExistingLibrary: sub.WashEnabled,
}); err != nil { }); err != nil {
if IsDownloadDedupError(err) { if IsDownloadDedupError(err) {
addAvailabilityTitle(item.Title, availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)), &availability)
seen = append(seen, candidate.GUID) seen = append(seen, candidate.GUID)
seenSet[candidate.GUID] = struct{}{} seenSet[candidate.GUID] = struct{}{}
continue continue
@@ -338,16 +356,19 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
continue continue
} }
queued++ queued++
addAvailabilityTitle(item.Title, availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)), &availability)
resources = append(resources, item.Title) resources = append(resources, item.Title)
seen = append(seen, candidate.GUID) seen = append(seen, candidate.GUID)
seenSet[candidate.GUID] = struct{}{} seenSet[candidate.GUID] = struct{}{}
} }
availability = s.finalizePendingAvailability(sub, availability)
if len(seen) > 200 { if len(seen) > 200 {
seen = seen[len(seen)-200:] seen = seen[len(seen)-200:]
} }
_ = s.repo.Setting.Set(ctx, guidKey, strings.Join(seen, "\n")) _ = s.repo.Setting.Set(ctx, guidKey, strings.Join(seen, "\n"))
now := time.Now() now := time.Now()
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error _ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error
_ = s.archiveCompletedSubscription(ctx, sub, availability)
if queued > 0 { if queued > 0 {
s.hub.Publish("subscription", map[string]any{ s.hub.Publish("subscription", map[string]any{
"id": sub.ID, "id": sub.ID,
@@ -364,6 +385,88 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
return 0, nil return 0, nil
} }
func (s *SubscriptionService) archiveCompletedSubscription(ctx context.Context, sub *model.Subscription, availability LocalAvailability) error {
if s == nil || s.repo == nil || s.repo.Subscription == nil || sub == nil {
return nil
}
if !subscriptionShouldArchive(sub, availability) {
return nil
}
now := time.Now()
reason := subscriptionArchiveReason(sub, availability)
if err := s.repo.Subscription.Archive(ctx, sub.ID, reason, now); err != nil {
return err
}
sub.Enabled = false
sub.ArchivedAt = &now
sub.ArchiveReason = reason
if s.log != nil {
s.log.Info("subscription completed, moved to history",
zap.String("id", sub.ID),
zap.String("name", sub.Name),
zap.String("reason", reason))
}
if s.hub != nil {
s.hub.Publish("subscription", map[string]any{
"id": sub.ID,
"name": sub.Name,
"archived": true,
"reason": reason,
})
}
return nil
}
func subscriptionShouldArchive(sub *model.Subscription, availability LocalAvailability) bool {
if sub == nil || sub.WashEnabled || sub.ArchivedAt != nil {
return false
}
mediaType := strings.ToLower(strings.TrimSpace(sub.MediaType))
if !isSubscriptionSeriesType(mediaType) {
return availability.InLibrary || availability.LocalMediaCount > 0 || availability.DownloadedEpisodes > 0
}
if availability.HasSeriesPack {
return true
}
total := sub.TotalEpisodes
if total <= 0 {
total = availability.TotalEpisodes
}
if total > 0 {
return availability.DownloadedEpisodes >= total && len(availability.MissingEpisodes) == 0
}
return subscriptionLooksSingleEpisode(sub) && availability.DownloadedEpisodes > 0
}
func subscriptionArchiveReason(sub *model.Subscription, availability LocalAvailability) string {
if sub != nil && sub.WashEnabled {
return ""
}
if availability.HasSeriesPack {
return "整季资源已加入下载/入库"
}
if availability.TotalEpisodes > 0 {
return fmt.Sprintf("订阅完成:%d/%d", availability.DownloadedEpisodes, availability.TotalEpisodes)
}
if availability.DownloadedEpisodes > 0 {
return "单集订阅已加入下载/入库"
}
return "订阅媒体已加入下载/入库"
}
func subscriptionLooksSingleEpisode(sub *model.Subscription) bool {
if sub == nil {
return false
}
for _, value := range []string{sub.Name, sub.Filter} {
_, episode := ParseEpisode(value)
if episode > 0 {
return true
}
}
return false
}
func (s *SubscriptionService) shouldSkipExistingTorrent(ctx context.Context, mediaType string, candidate siteSearchCandidate) bool { func (s *SubscriptionService) shouldSkipExistingTorrent(ctx context.Context, mediaType string, candidate siteSearchCandidate) bool {
if s == nil || s.downloads == nil { if s == nil || s.downloads == nil {
return false return false
+178
View File
@@ -433,6 +433,184 @@ func TestSubscriptionPendingDownloadAvailabilityIncludesLiveQBTorrents(t *testin
} }
} }
func TestSubscriptionRunOneArchivesCompletedMovieRSS(t *testing.T) {
rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
_, _ = w.Write([]byte(`<?xml version="1.0"?>
<rss><channel>
<item>
<title>Dune 2021 1080p WEB-DL</title>
<guid>dune-1080-web</guid>
<link>magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd&amp;dn=Dune+2021+1080p+WEB-DL</link>
</item>
</channel></rss>`))
}))
defer rss.Close()
var addCalls int32
var added bool
qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v2/auth/login":
_, _ = w.Write([]byte("Ok."))
case "/api/v2/torrents/info":
if added {
_, _ = w.Write([]byte(`[{"hash":"dunehash","name":"Dune 2021 1080p WEB-DL","state":"downloading","progress":0.1}]`))
return
}
_, _ = w.Write([]byte(`[]`))
case "/api/v2/torrents/add":
added = true
atomic.AddInt32(&addCalls, 1)
_, _ = w.Write([]byte("Ok."))
default:
http.NotFound(w, r)
}
}))
defer qb.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
configureTestDefaultQB(t, repos, qb.URL)
downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop()))
sub := &model.Subscription{
Name: "Dune 自动订阅",
FeedURL: rss.URL,
Filter: "Dune 2021",
MediaType: "movie",
SavePath: "/downloads/movies",
}
if err := repos.Subscription.Create(t.Context(), sub); err != nil {
t.Fatal(err)
}
queued, err := svc.runOne(t.Context(), sub)
if err != nil {
t.Fatal(err)
}
if queued != 1 {
t.Fatalf("queued = %d, want 1", queued)
}
if got := atomic.LoadInt32(&addCalls); got != 1 {
t.Fatalf("qb add calls = %d, want 1", got)
}
active, err := repos.Subscription.List(t.Context())
if err != nil {
t.Fatal(err)
}
if len(active) != 0 {
t.Fatalf("active subscriptions = %d, want 0 after completion", len(active))
}
history, err := repos.Subscription.History(t.Context())
if err != nil {
t.Fatal(err)
}
if len(history) != 1 || history[0].ArchivedAt == nil {
t.Fatalf("history = %#v, want one archived subscription", history)
}
}
func TestSubscriptionArchiveCompletedSingleEpisodeTV(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Subscription{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop()))
sub := &model.Subscription{
Name: "Some Show S01E01 自动订阅",
FeedURL: "site-search://search?keyword=Some%20Show%20S01E01",
Filter: "Some Show S01E01",
MediaType: "tv",
Enabled: true,
}
if err := repos.Subscription.Create(t.Context(), sub); err != nil {
t.Fatal(err)
}
err = svc.archiveCompletedSubscription(t.Context(), sub, LocalAvailability{
DownloadedEpisodes: 1,
LocalMediaCount: 1,
InLibrary: true,
ExistingEpisodeKeys: map[string]struct{}{
episodeKey(1, 1): {},
},
})
if err != nil {
t.Fatal(err)
}
active, err := repos.Subscription.List(t.Context())
if err != nil {
t.Fatal(err)
}
if len(active) != 0 {
t.Fatalf("active subscriptions = %d, want 0", len(active))
}
history, err := repos.Subscription.History(t.Context())
if err != nil {
t.Fatal(err)
}
if len(history) != 1 || history[0].ArchiveReason == "" {
t.Fatalf("history = %#v, want archived single episode", history)
}
}
func TestSubscriptionArchiveKeepsWashSubscriptionActive(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Subscription{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop()))
sub := &model.Subscription{
Name: "Dune 自动订阅",
FeedURL: "site-search://search?keyword=Dune",
Filter: "Dune 2021",
MediaType: "movie",
WashEnabled: true,
Enabled: true,
}
if err := repos.Subscription.Create(t.Context(), sub); err != nil {
t.Fatal(err)
}
err = svc.archiveCompletedSubscription(t.Context(), sub, LocalAvailability{
DownloadedEpisodes: 1,
LocalMediaCount: 1,
InLibrary: true,
})
if err != nil {
t.Fatal(err)
}
active, err := repos.Subscription.List(t.Context())
if err != nil {
t.Fatal(err)
}
if len(active) != 1 {
t.Fatalf("active subscriptions = %d, want wash subscription to stay active", len(active))
}
history, err := repos.Subscription.History(t.Context())
if err != nil {
t.Fatal(err)
}
if len(history) != 0 {
t.Fatalf("history subscriptions = %d, want 0", len(history))
}
}
func TestSubscriptionRunOneDeduplicatesDuplicateRSSGUIDInSameFeed(t *testing.T) { func TestSubscriptionRunOneDeduplicatesDuplicateRSSGUIDInSameFeed(t *testing.T) {
rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml") w.Header().Set("Content-Type", "application/rss+xml")
+61 -23
View File
@@ -22,6 +22,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"time" "time"
"go.uber.org/zap" "go.uber.org/zap"
@@ -166,13 +167,16 @@ func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int)
u := base + "/search/movie?" + q.Encode() u := base + "/search/movie?" + q.Encode()
type result struct { type result struct {
ID int `json:"id"` ID int `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Overview string `json:"overview"` OriginalTitle string `json:"original_title"`
PosterPath string `json:"poster_path"` OriginalLanguage string `json:"original_language"`
BackdropPath string `json:"backdrop_path"` Overview string `json:"overview"`
ReleaseDate string `json:"release_date"` PosterPath string `json:"poster_path"`
VoteAverage float32 `json:"vote_average"` BackdropPath string `json:"backdrop_path"`
ReleaseDate string `json:"release_date"`
VoteAverage float32 `json:"vote_average"`
GenreIDs []int `json:"genre_ids"`
} }
type page struct { type page struct {
Results []result `json:"results"` Results []result `json:"results"`
@@ -187,10 +191,13 @@ func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int)
} }
r := p.Results[0] r := p.Results[0]
m := &Match{ m := &Match{
TMDbID: r.ID, TMDbID: r.ID,
Title: r.Title, Title: r.Title,
Overview: r.Overview, OriginalName: r.OriginalTitle,
Rating: r.VoteAverage, Overview: r.Overview,
Rating: r.VoteAverage,
Languages: nonEmptyStrings(r.OriginalLanguage),
Genres: genreIDStrings(r.GenreIDs),
} }
if r.PosterPath != "" { if r.PosterPath != "" {
m.PosterURL = t.imgCDN + "/w500" + r.PosterPath m.PosterURL = t.imgCDN + "/w500" + r.PosterPath
@@ -228,14 +235,17 @@ func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*M
u := base + "/search/tv?" + q.Encode() u := base + "/search/tv?" + q.Encode()
type result struct { type result struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
OriginalName string `json:"original_name"` OriginalName string `json:"original_name"`
Overview string `json:"overview"` OriginalLanguage string `json:"original_language"`
PosterPath string `json:"poster_path"` OriginCountry []string `json:"origin_country"`
BackdropPath string `json:"backdrop_path"` Overview string `json:"overview"`
FirstAirDate string `json:"first_air_date"` PosterPath string `json:"poster_path"`
VoteAverage float32 `json:"vote_average"` BackdropPath string `json:"backdrop_path"`
FirstAirDate string `json:"first_air_date"`
VoteAverage float32 `json:"vote_average"`
GenreIDs []int `json:"genre_ids"`
} }
type page struct { type page struct {
Results []result `json:"results"` Results []result `json:"results"`
@@ -250,10 +260,14 @@ func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*M
} }
r := p.Results[0] r := p.Results[0]
m := &Match{ m := &Match{
TMDbID: r.ID, TMDbID: r.ID,
Title: r.Name, Title: r.Name,
Overview: r.Overview, OriginalName: r.OriginalName,
Rating: r.VoteAverage, Overview: r.Overview,
Rating: r.VoteAverage,
Languages: nonEmptyStrings(r.OriginalLanguage),
Countries: deduplicate(r.OriginCountry),
Genres: genreIDStrings(r.GenreIDs),
} }
if m.Title == "" { if m.Title == "" {
m.Title = r.OriginalName m.Title = r.OriginalName
@@ -438,3 +452,27 @@ func deduplicate(s []string) []string {
} }
return out return out
} }
func nonEmptyStrings(values ...string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
out = append(out, value)
}
}
return out
}
func genreIDStrings(ids []int) []string {
if len(ids) == 0 {
return nil
}
out := make([]string, 0, len(ids))
for _, id := range ids {
if id > 0 {
out = append(out, fmt.Sprint(id))
}
}
return out
}
+3
View File
@@ -5,6 +5,9 @@ export const subscriptionsAPI = {
list: () => list: () =>
api.get<{ items: Subscription[] }>('/subscriptions').then((r) => r.data.items), api.get<{ items: Subscription[] }>('/subscriptions').then((r) => r.data.items),
history: () =>
api.get<{ items: Subscription[] }>('/subscriptions/history').then((r) => r.data.items),
create: (input: { create: (input: {
name: string name: string
feed_url: string feed_url: string
+274 -91
View File
@@ -58,32 +58,67 @@ type AutoOrganizeConfig = {
enabled: string enabled: string
afterDownload: string afterDownload: string
scrapeAfter: string scrapeAfter: string
downloadSmartClassify: string
smartClassify: string
sourceDir: string sourceDir: string
targetDir: string targetDir: string
transferMode: string transferMode: string
intervalSeconds: string intervalSeconds: string
keepSeeding: string
movieFormat: string
tvFormat: string
animeFormat: string
scrapeAutoOnScan: string
scrapeProviders: string
scrapeLanguage: string
scrapeDelayMinMs: string
scrapeDelayMaxMs: string
} }
const AUTO_ORGANIZE_DEFAULTS: AutoOrganizeConfig = { const AUTO_ORGANIZE_DEFAULTS: AutoOrganizeConfig = {
enabled: 'false', enabled: 'false',
afterDownload: 'false', afterDownload: 'false',
scrapeAfter: 'true', scrapeAfter: 'true',
downloadSmartClassify: 'true',
smartClassify: 'true',
sourceDir: '', sourceDir: '',
targetDir: '', targetDir: '',
transferMode: 'hardlink', transferMode: 'hardlink',
intervalSeconds: '300', intervalSeconds: '300',
keepSeeding: 'true',
movieFormat: '{title} ({year})/{title} ({year})',
tvFormat: '{title} ({year})/Season {season:02}/{title} S{season:02}E{episode:02}',
animeFormat: '{title}/Season {season:02}/{title} S{season:02}E{episode:02}',
scrapeAutoOnScan: 'false',
scrapeProviders: 'tmdb,douban,bangumi,thetvdb,fanart',
scrapeLanguage: 'zh-CN',
scrapeDelayMinMs: '250',
scrapeDelayMaxMs: '500',
} }
const AUTO_ORGANIZE_KEYS: Record<keyof AutoOrganizeConfig, string> = { const AUTO_ORGANIZE_KEYS: Record<keyof AutoOrganizeConfig, string> = {
enabled: 'organize.auto', enabled: 'organize.auto',
afterDownload: 'organizer.auto_after_download', afterDownload: 'organizer.auto_after_download',
scrapeAfter: 'organize.scrape_after', scrapeAfter: 'organize.scrape_after',
downloadSmartClassify: 'downloads.smart_classify',
smartClassify: 'organizer.smart_classify',
sourceDir: 'organize.source_dir', sourceDir: 'organize.source_dir',
targetDir: 'organize.target_dir', targetDir: 'organize.target_dir',
transferMode: 'organize.transfer_mode', transferMode: 'organize.transfer_mode',
intervalSeconds: 'organize.interval_seconds', intervalSeconds: 'organize.interval_seconds',
keepSeeding: 'organize.keep_seeding',
movieFormat: 'organize.movie_format',
tvFormat: 'organize.tv_format',
animeFormat: 'organize.anime_format',
scrapeAutoOnScan: 'scrape.auto_on_scan',
scrapeProviders: 'scrape.providers',
scrapeLanguage: 'scrape.language',
scrapeDelayMinMs: 'scrape.delay_min_ms',
scrapeDelayMaxMs: 'scrape.delay_max_ms',
} }
type AutoOrganizeTab = 'basic' | 'naming' | 'scrape'
function settingIndex(rows: Setting[]): Record<string, string> { function settingIndex(rows: Setting[]): Record<string, string> {
const out: Record<string, string> = {} const out: Record<string, string> = {}
for (const row of rows) out[row.key] = row.value for (const row of rows) out[row.key] = row.value
@@ -96,10 +131,21 @@ function mergeAutoOrganizeSettings(rows: Setting[]): AutoOrganizeConfig {
enabled: idx[AUTO_ORGANIZE_KEYS.enabled] ?? AUTO_ORGANIZE_DEFAULTS.enabled, enabled: idx[AUTO_ORGANIZE_KEYS.enabled] ?? AUTO_ORGANIZE_DEFAULTS.enabled,
afterDownload: idx[AUTO_ORGANIZE_KEYS.afterDownload] ?? AUTO_ORGANIZE_DEFAULTS.afterDownload, afterDownload: idx[AUTO_ORGANIZE_KEYS.afterDownload] ?? AUTO_ORGANIZE_DEFAULTS.afterDownload,
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,
smartClassify: idx[AUTO_ORGANIZE_KEYS.smartClassify] ?? AUTO_ORGANIZE_DEFAULTS.smartClassify,
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,
intervalSeconds: idx[AUTO_ORGANIZE_KEYS.intervalSeconds] ?? AUTO_ORGANIZE_DEFAULTS.intervalSeconds, intervalSeconds: idx[AUTO_ORGANIZE_KEYS.intervalSeconds] ?? AUTO_ORGANIZE_DEFAULTS.intervalSeconds,
keepSeeding: idx[AUTO_ORGANIZE_KEYS.keepSeeding] ?? AUTO_ORGANIZE_DEFAULTS.keepSeeding,
movieFormat: idx[AUTO_ORGANIZE_KEYS.movieFormat] ?? AUTO_ORGANIZE_DEFAULTS.movieFormat,
tvFormat: idx[AUTO_ORGANIZE_KEYS.tvFormat] ?? AUTO_ORGANIZE_DEFAULTS.tvFormat,
animeFormat: idx[AUTO_ORGANIZE_KEYS.animeFormat] ?? AUTO_ORGANIZE_DEFAULTS.animeFormat,
scrapeAutoOnScan: idx[AUTO_ORGANIZE_KEYS.scrapeAutoOnScan] ?? AUTO_ORGANIZE_DEFAULTS.scrapeAutoOnScan,
scrapeProviders: idx[AUTO_ORGANIZE_KEYS.scrapeProviders] ?? AUTO_ORGANIZE_DEFAULTS.scrapeProviders,
scrapeLanguage: idx[AUTO_ORGANIZE_KEYS.scrapeLanguage] ?? AUTO_ORGANIZE_DEFAULTS.scrapeLanguage,
scrapeDelayMinMs: idx[AUTO_ORGANIZE_KEYS.scrapeDelayMinMs] ?? AUTO_ORGANIZE_DEFAULTS.scrapeDelayMinMs,
scrapeDelayMaxMs: idx[AUTO_ORGANIZE_KEYS.scrapeDelayMaxMs] ?? AUTO_ORGANIZE_DEFAULTS.scrapeDelayMaxMs,
} }
} }
@@ -144,6 +190,7 @@ export function FileManagerPage() {
const [autoSaving, setAutoSaving] = useState(false) const [autoSaving, setAutoSaving] = useState(false)
const [autoRunning, setAutoRunning] = useState(false) const [autoRunning, setAutoRunning] = useState(false)
const [autoLoading, setAutoLoading] = useState(true) const [autoLoading, setAutoLoading] = useState(true)
const [autoTab, setAutoTab] = useState<AutoOrganizeTab>('basic')
const currentDir = useMemo(() => { const currentDir = useMemo(() => {
if (data?.path) return data.path if (data?.path) return data.path
@@ -213,6 +260,7 @@ export function FileManagerPage() {
const changeAutoConfig = (key: keyof AutoOrganizeConfig, value: string) => { const changeAutoConfig = (key: keyof AutoOrganizeConfig, value: string) => {
setAutoConfig((current) => ({ ...current, [key]: value })) setAutoConfig((current) => ({ ...current, [key]: value }))
if (key === 'scrapeAfter') setScrapeAfter(settingOn(value))
setAutoDirty(true) setAutoDirty(true)
} }
@@ -223,10 +271,10 @@ export function FileManagerPage() {
await adminAPI.updateSetting(AUTO_ORGANIZE_KEYS[key], autoConfig[key] ?? '') await adminAPI.updateSetting(AUTO_ORGANIZE_KEYS[key], autoConfig[key] ?? '')
} }
setAutoDirty(false) setAutoDirty(false)
toast.success('自动整理设置已保存') toast.success('整理入库设置已保存')
return true return true
} catch (err: unknown) { } catch (err: unknown) {
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存自动整理设置失败') toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存整理入库设置失败')
return false return false
} finally { } finally {
setAutoSaving(false) setAutoSaving(false)
@@ -447,99 +495,234 @@ export function FileManagerPage() {
</div> </div>
</div> </div>
<div className="grid gap-3 lg:grid-cols-[1fr_1fr_150px_140px]"> <div className="flex flex-wrap gap-2 rounded-2xl border border-gray-200 bg-gray-50 p-1">
<label className="space-y-1"> {[
<span className="text-xs text-ink-50">整理源目录(待整理 / 下载目录)</span> ['basic', '基础设置'],
<div className="flex gap-2"> ['naming', '命名规则'],
<input ['scrape', '刮削联动'],
className="input-base w-full" ].map(([key, label]) => (
placeholder="例如 F:\\downloads 或 /downloads" <button
value={autoConfig.sourceDir} key={key}
onChange={(event) => changeAutoConfig('sourceDir', event.target.value)} type="button"
/> className={
<button autoTab === key
type="button" ? 'rounded-xl bg-white px-3 py-1.5 text-xs font-semibold text-brand-500 shadow-sm'
className="rounded-xl border border-gray-200 px-3 text-xs text-ink-100 hover:border-primary-400/40" : 'rounded-xl px-3 py-1.5 text-xs text-ink-100 hover:bg-white/70'
disabled={!currentDir} }
onClick={() => changeAutoConfig('sourceDir', currentDir)} onClick={() => setAutoTab(key as AutoOrganizeTab)}
>
当前
</button>
</div>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">整理目的地目录(媒体库根目录)</span>
<div className="flex gap-2">
<input
className="input-base w-full"
placeholder="例如 F:\\media 或 /media"
value={autoConfig.targetDir}
onChange={(event) => changeAutoConfig('targetDir', event.target.value)}
/>
<button
type="button"
className="rounded-xl border border-gray-200 px-3 text-xs text-ink-100 hover:border-primary-400/40"
disabled={!currentDir}
onClick={() => changeAutoConfig('targetDir', currentDir)}
>
当前
</button>
</div>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">默认整理方式</span>
<select
className="input-base w-full"
value={autoConfig.transferMode}
onChange={(event) => changeAutoConfig('transferMode', event.target.value)}
> >
<option value="hardlink">硬链接</option> {label}
<option value="move">移动</option> </button>
<option value="copy">复制</option> ))}
<option value="symlink">软链接</option> <span className="ml-auto self-center px-2 text-xs text-sand-500">
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">检查间隔(秒)</span>
<input
type="number"
min={60}
className="input-base w-full"
value={autoConfig.intervalSeconds}
onChange={(event) => changeAutoConfig('intervalSeconds', event.target.value)}
/>
</label>
</div>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.enabled)}
onChange={(event) => changeAutoConfig('enabled', event.target.checked ? 'true' : 'false')}
/>
整理源目录定时自动整理
</label>
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.afterDownload)}
onChange={(event) => changeAutoConfig('afterDownload', event.target.checked ? 'true' : 'false')}
/>
qB 下载完成后自动整理
</label>
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.scrapeAfter)}
onChange={(event) => changeAutoConfig('scrapeAfter', event.target.checked ? 'true' : 'false')}
/>
整理后自动刮削
</label>
<span className="text-xs text-sand-500">
{autoDirty ? '有未保存设置' : '设置已同步'} · 定时任务名:organize_source {autoDirty ? '有未保存设置' : '设置已同步'} · 定时任务名:organize_source
</span> </span>
</div> </div>
{autoTab === 'basic' && (
<>
<div className="grid gap-3 lg:grid-cols-[1fr_1fr_150px_140px]">
<label className="space-y-1">
<span className="text-xs text-ink-50">整理源目录(待整理 / 下载目录)</span>
<div className="flex gap-2">
<input
className="input-base w-full"
placeholder="例如 F:\\downloads 或 /downloads"
value={autoConfig.sourceDir}
onChange={(event) => changeAutoConfig('sourceDir', event.target.value)}
/>
<button
type="button"
className="rounded-xl border border-gray-200 px-3 text-xs text-ink-100 hover:border-primary-400/40"
disabled={!currentDir}
onClick={() => changeAutoConfig('sourceDir', currentDir)}
>
当前
</button>
</div>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">整理目的地目录(媒体库根目录)</span>
<div className="flex gap-2">
<input
className="input-base w-full"
placeholder="例如 F:\\media 或 /media"
value={autoConfig.targetDir}
onChange={(event) => changeAutoConfig('targetDir', event.target.value)}
/>
<button
type="button"
className="rounded-xl border border-gray-200 px-3 text-xs text-ink-100 hover:border-primary-400/40"
disabled={!currentDir}
onClick={() => changeAutoConfig('targetDir', currentDir)}
>
当前
</button>
</div>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">默认整理方式</span>
<select
className="input-base w-full"
value={autoConfig.transferMode}
onChange={(event) => changeAutoConfig('transferMode', event.target.value)}
>
<option value="hardlink">硬链接</option>
<option value="move">移动</option>
<option value="copy">复制</option>
<option value="symlink">软链接</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">检查间隔(秒)</span>
<input
type="number"
min={60}
className="input-base w-full"
value={autoConfig.intervalSeconds}
onChange={(event) => changeAutoConfig('intervalSeconds', event.target.value)}
/>
</label>
</div>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.enabled)}
onChange={(event) => changeAutoConfig('enabled', event.target.checked ? 'true' : 'false')}
/>
整理源目录定时自动整理
</label>
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.afterDownload)}
onChange={(event) => changeAutoConfig('afterDownload', event.target.checked ? 'true' : 'false')}
/>
qB 下载完成后自动整理
</label>
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.downloadSmartClassify)}
onChange={(event) => changeAutoConfig('downloadSmartClassify', event.target.checked ? 'true' : 'false')}
/>
下载器智能分类
</label>
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.smartClassify)}
onChange={(event) => changeAutoConfig('smartClassify', event.target.checked ? 'true' : 'false')}
/>
智能分类到子库
</label>
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.keepSeeding)}
onChange={(event) => changeAutoConfig('keepSeeding', event.target.checked ? 'true' : 'false')}
/>
保种
</label>
</div>
</>
)}
{autoTab === 'naming' && (
<div className="grid gap-3">
<label className="space-y-1">
<span className="text-xs text-ink-50">电影命名格式</span>
<input
className="input-base w-full font-mono text-xs"
value={autoConfig.movieFormat}
onChange={(event) => changeAutoConfig('movieFormat', event.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">剧集命名格式</span>
<input
className="input-base w-full font-mono text-xs"
value={autoConfig.tvFormat}
onChange={(event) => changeAutoConfig('tvFormat', event.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">动漫命名格式</span>
<input
className="input-base w-full font-mono text-xs"
value={autoConfig.animeFormat}
onChange={(event) => changeAutoConfig('animeFormat', event.target.value)}
/>
</label>
<p className="text-xs text-sand-500">
可用占位符:{'{title}'} {'{year}'} {'{season}'} {'{season:02}'} {'{episode}'} {'{episode:02}'} {'{category}'}。扩展名会自动补齐。
</p>
</div>
)}
{autoTab === 'scrape' && (
<>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.scrapeAfter)}
onChange={(event) => changeAutoConfig('scrapeAfter', event.target.checked ? 'true' : 'false')}
/>
整理后自动刮削
</label>
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(autoConfig.scrapeAutoOnScan)}
onChange={(event) => changeAutoConfig('scrapeAutoOnScan', event.target.checked ? 'true' : 'false')}
/>
扫描后自动刮削
</label>
</div>
<div className="grid gap-3 lg:grid-cols-[1fr_160px_160px_160px]">
<label className="space-y-1">
<span className="text-xs text-ink-50">刮削源优先级</span>
<input
className="input-base w-full"
placeholder="tmdb,douban,bangumi,thetvdb,fanart"
value={autoConfig.scrapeProviders}
onChange={(event) => changeAutoConfig('scrapeProviders', event.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">首选语言</span>
<input
className="input-base w-full"
value={autoConfig.scrapeLanguage}
onChange={(event) => changeAutoConfig('scrapeLanguage', event.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">最小间隔 ms</span>
<input
type="number"
min={0}
className="input-base w-full"
value={autoConfig.scrapeDelayMinMs}
onChange={(event) => changeAutoConfig('scrapeDelayMinMs', event.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">最大间隔 ms</span>
<input
type="number"
min={0}
className="input-base w-full"
value={autoConfig.scrapeDelayMaxMs}
onChange={(event) => changeAutoConfig('scrapeDelayMaxMs', event.target.value)}
/>
</label>
</div>
</>
)}
</section> </section>
{data?.path && ( {data?.path && (
+4 -181
View File
@@ -1,13 +1,13 @@
import { FormEvent, useEffect, useState } from 'react' import { FormEvent, useEffect, useState } from 'react'
import { FolderOpen, Loader2, Save, SettingsIcon } from 'lucide-react' import { Loader2, Save, SettingsIcon } from 'lucide-react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { adminAPI } from '../api/admin' import { adminAPI } from '../api/admin'
import { libraryAPI } from '../api/library' import { libraryAPI } from '../api/library'
import type { Library, Setting } from '../types' import type { Library, Setting } from '../types'
// SettingsPage replaces the Vue SettingsView's General / Organize / // SettingsPage replaces the Vue SettingsView's curated runtime settings.
// Scrape / Adult tabs. The Go backend stores settings as a single // The Go backend stores settings as a single
// key/value table; we group the most useful keys client-side and let // key/value table; we group the most useful keys client-side and let
// the operator edit them with typed widgets (select / toggle / input). // the operator edit them with typed widgets (select / toggle / input).
// //
@@ -156,145 +156,6 @@ const GROUPS: SettingGroup[] = [
}, },
], ],
}, },
{
key: 'organize',
label: '整理 & 刮削',
description: '媒体文件命名 + 自动刮削 + 整理目标',
items: [
{
key: 'organize.auto',
label: '整理源目录定时自动整理',
type: 'toggle',
hint: '开启后后台会按下方间隔递归扫描「整理源目录」,自动整理到「整理目的地目录」并扫描入库。默认关闭,避免无意中频繁读盘。',
defaultValue: 'false',
},
{
key: 'organizer.auto_after_download',
label: '下载完成后自动整理入库',
type: 'toggle',
hint: '开启后 qB 下载完成时,系统会优先使用种子的 content_path 整理该文件/目录,并在整理完成后扫描目标媒体库。',
},
{
key: 'organize.scrape_after',
label: '整理后自动刮削',
type: 'toggle',
hint: '开启后,手动/自动整理完成并扫描入库后,会立即触发 TMDb/豆瓣/Bangumi/JavBus/JavDB 等元数据刮削。需要先配置可用刮削源。',
defaultValue: 'false',
},
{
key: 'downloads.smart_classify',
label: '下载器智能分类',
type: 'toggle',
hint: '订阅下载和站点搜索下载未指定保存路径时,自动按媒体类型/分类写入 qB 保存目录与 qB 分类(如:/downloads/国产剧、/downloads/综艺)。',
defaultValue: 'true',
},
{
key: 'organizer.smart_classify',
label: '启用智能分类',
type: 'toggle',
hint: '整理/入库时根据元数据(语言/国家/类型)自动分类到媒体库子目录(如:华语电影、欧美剧、日番)',
},
{
key: 'organize.source_dir',
label: '整理源目录(待整理)',
type: 'text',
hint: '从该目录读取待整理文件;留空则默认整理整个媒体库(媒体库路径)。',
placeholder: '/mnt/downloads',
},
{
key: 'organize.target_dir',
label: '整理目的地目录',
type: 'text',
hint: '整理后输出到该目录;留空则默认整理到各媒体库对应路径(见下方参考)。与「源目录」相互独立。',
placeholder: '/mnt/media/organized',
},
{
key: 'organize.transfer_mode',
label: '默认转移方式',
type: 'select',
hint: '移动会删除源文件;复制/硬链接/软链接保留源文件,PT 做种不中断。硬链接同盘零额外占用。',
options: [
{ value: 'move', label: '移动(删除源文件)' },
{ value: 'copy', label: '复制(保留源文件)' },
{ value: 'hardlink', label: '硬链接(保留源,做种不中断,不占双倍空间)' },
{ value: 'symlink', label: '软链接(符号链接,保留源)' },
],
},
{
key: 'organize.interval_seconds',
label: '自动整理间隔秒数',
type: 'number',
hint: '仅在「整理源目录定时自动整理」开启后生效;最小 60 秒,建议 300 秒或更高。',
defaultValue: '300',
placeholder: '300',
},
{
key: 'organize.keep_seeding',
label: '保种(整理后继续做种上传)',
type: 'toggle',
hint: '开启后即使选择「移动」也会自动改用硬链接保留源文件,确保 qBittorrent 继续做种。硬链接要求源和目标在同一文件系统;失败时会提示,不再静默复制占用双倍空间。',
},
{
key: 'organize.movie_format',
label: '电影命名格式',
type: 'text',
hint: '例: {title} ({year})/{title} ({year})',
placeholder: '{title} ({year})/{title} ({year})',
},
{
key: 'organize.tv_format',
label: '剧集命名格式',
type: 'text',
placeholder: '{title} ({year})/Season {season}/{title} S{season:02}E{episode:02}',
},
{
key: 'organize.anime_format',
label: '动漫命名格式',
type: 'text',
placeholder: '{title}/Season {season}/{title} S{season:02}E{episode:02}',
},
{
key: 'scrape.auto_on_scan',
label: '扫描后自动刮削',
type: 'toggle',
},
{
key: 'scrape.providers',
label: '刮削源优先级',
type: 'text',
hint: '逗号分隔: tmdb,bangumi,thetvdb,fanart',
placeholder: 'tmdb,bangumi,thetvdb,fanart',
},
{
key: 'scrape.language',
label: '刮削首选语言',
type: 'text',
placeholder: 'zh-CN',
},
{
key: 'scrape.delay_min_ms',
label: '刮削最小间隔毫秒',
type: 'number',
hint: '参考 nowen-video 的随机节流策略。批量刮削时两条媒体之间会随机等待,避免 TMDb / Bangumi / JavBus / JavDB 等源请求过快。',
defaultValue: '250',
placeholder: '250',
},
{
key: 'scrape.delay_max_ms',
label: '刮削最大间隔毫秒',
type: 'number',
hint: '如遇到站点限速、超时或 403,可提高到 2000-5000;填 0 可关闭批量刮削间隔。',
defaultValue: '500',
placeholder: '500',
},
{
key: 'scan.periodic_enabled',
label: '周期性整库重扫',
type: 'toggle',
hint: '默认关闭。文件新增/变更由实时监听增量入库,无需定时全量重扫。开启会每 60 分钟重扫整库,频繁读盘会损伤硬盘,一般无需开启。',
},
],
},
{ {
key: 'cloud-upload', key: 'cloud-upload',
label: '网盘转存', label: '网盘转存',
@@ -499,7 +360,7 @@ export function SettingsPage() {
<div> <div>
<h1 className="font-display text-3xl font-bold text-ink-600">系统设置</h1> <h1 className="font-display text-3xl font-bold text-ink-600">系统设置</h1>
<p className="text-sm text-ink-50"> <p className="text-sm text-ink-50">
按分组编辑转码 / 整理 / 刮削 / 下载器等关键配置 按分组编辑转码 / 网盘转存 / Adult 等关键配置
</p> </p>
</div> </div>
</div> </div>
@@ -554,44 +415,6 @@ export function SettingsPage() {
</div> </div>
</form> </form>
)} )}
{/* 整理 tab 时显示各媒体库默认路径 */}
{!loading && activeGroup === 'organize' && libraries.length > 0 && (
<div className="glass-panel">
<div className="mb-3 flex items-center gap-2 text-sm text-ink-100">
<FolderOpen size={16} className="text-brand-500" />
<span>默认整理路径参考(未设目的地目录时按媒体库归类)</span>
</div>
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">媒体库</th>
<th>类型</th>
<th>路径</th>
<th>整理后示例</th>
</tr>
</thead>
<tbody>
{libraries.map((lib) => (
<tr key={lib.id} className="border-t border-gray-200">
<td className="py-2 font-medium text-ink-600">{lib.name}</td>
<td className="text-ink-50">
{lib.type === 'movie' ? '电影' : lib.type === 'tv' ? '电视剧' : lib.type === 'anime' ? '动漫' : '音乐'}
</td>
<td className="font-mono text-xs text-ink-50">{lib.path}</td>
<td className="font-mono text-[11px] text-sand-500">
{lib.type === 'movie'
? `${lib.path}/片名 (2024)/片名 (2024).mkv`
: lib.type === 'tv' || lib.type === 'anime'
? `${lib.path}/剧名/Season 01/剧名 - S01E01.mkv`
: lib.path}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div> </div>
) )
} }
+44 -4
View File
@@ -1,6 +1,6 @@
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 { CalendarClock, CheckCircle2, Film, Pencil, Play, Plus, Save, ShieldCheck, Trash2 } from 'lucide-react' import { Archive, CalendarClock, CheckCircle2, Film, Pencil, Play, Plus, Save, ShieldCheck, Trash2 } from 'lucide-react'
import { subscriptionsAPI } from '../api/subscriptions' import { subscriptionsAPI } from '../api/subscriptions'
import { imageURL } from '../api/client' import { imageURL } from '../api/client'
@@ -9,6 +9,7 @@ import type { Subscription } from '../types'
export function SubscriptionsPage() { export function SubscriptionsPage() {
const [items, setItems] = useState<Subscription[]>([]) const [items, setItems] = useState<Subscription[]>([])
const [historyItems, setHistoryItems] = useState<Subscription[]>([])
const [name, setName] = useState('') const [name, setName] = useState('')
const [feed, setFeed] = useState('') const [feed, setFeed] = useState('')
const [filter, setFilter] = useState('') const [filter, setFilter] = useState('')
@@ -28,9 +29,11 @@ export function SubscriptionsPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const refresh = () => const refresh = () =>
subscriptionsAPI Promise.all([subscriptionsAPI.list(), subscriptionsAPI.history()])
.list() .then(([active, history]) => {
.then(setItems) setItems(active)
setHistoryItems(history)
})
.finally(() => setLoading(false)) .finally(() => setLoading(false))
useEffect(() => { useEffect(() => {
@@ -311,6 +314,43 @@ export function SubscriptionsPage() {
))} ))}
</div> </div>
)} )}
{historyItems.length > 0 && (
<section className="space-y-3">
<div className="flex items-center gap-2">
<Archive size={18} className="text-brand-500" />
<h2 className="font-display text-xl font-semibold text-ink-600">订阅历史</h2>
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-ink-50">{historyItems.length} 条</span>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{historyItems.map((subscription) => (
<article key={subscription.id} className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="flex gap-3">
<div className="h-20 w-14 flex-shrink-0 overflow-hidden rounded-xl bg-primary-400/10">
{subscription.poster_url ? (
<img src={imageURL(subscription.poster_url)} alt={subscription.name} className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center text-brand-500">
<Film size={18} />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate font-semibold text-ink-600" title={subscription.name}>{subscription.name}</h3>
<p className="mt-1 text-xs text-ink-50">
{subscription.archive_reason || '订阅已完成'}
</p>
<p className="mt-2 text-xs text-ink-100">
{subscription.archived_at ? new Date(subscription.archived_at).toLocaleString() : '完成时间未知'}
</p>
<p className="mt-1 text-xs text-ink-50">{subscriptionProgressLabel(subscription)}</p>
</div>
</div>
</article>
))}
</div>
</section>
)}
</div> </div>
) )
} }
+2
View File
@@ -206,6 +206,8 @@ export interface Subscription {
priority?: number priority?: number
enabled: boolean enabled: boolean
last_run_at?: string last_run_at?: string
archived_at?: string
archive_reason?: string
created_at: string created_at: string
updated_at: string updated_at: string
} }