From 479a06f00829eddbdff74699d4da13ede01b6354 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:17:52 +0800 Subject: [PATCH] Unify organize ingest pipeline --- .dockerignore | 5 + .gitignore | 1 + internal/handler/downloads_extra.go | 16 +- internal/handler/media_favorite.go | 12 +- internal/handler/organizer.go | 104 ++---- internal/handler/routes_authenticated.go | 1 + internal/handler/subscriptions.go | 12 + internal/handler/system_extra.go | 24 -- internal/handler/task_helpers.go | 10 + internal/model/model.go | 2 + internal/repository/repository.go | 24 +- internal/service/download_clients.go | 7 + internal/service/download_clients_test.go | 25 ++ internal/service/downloads.go | 219 +++++++---- internal/service/downloads_test.go | 257 ++++++++++++- internal/service/episode_parser.go | 1 + internal/service/media_classifier.go | 154 +++++++- internal/service/media_classifier_test.go | 42 ++- internal/service/organize_naming_test.go | 174 ++++++++- internal/service/organize_pipeline.go | 359 ++++++++++++++++++ internal/service/organize_pipeline_test.go | 37 ++ internal/service/organizer.go | 39 +- internal/service/organizer_directory.go | 157 ++++++-- internal/service/organizer_directory_test.go | 2 +- internal/service/organizer_scrape_test.go | 320 ++++++++++++++++ internal/service/organizer_target.go | 278 ++++++++++++++ internal/service/path_translation.go | 11 +- internal/service/qbittorrent.go | 2 +- internal/service/qbittorrent_test.go | 7 +- internal/service/scheduler.go | 88 ++--- internal/service/scraper.go | 128 ++++++- internal/service/scraper_test.go | 87 +++++ internal/service/service.go | 230 ++++++------ internal/service/subscription.go | 103 ++++++ internal/service/subscription_test.go | 178 +++++++++ internal/service/tmdb.go | 84 +++-- web/src/api/subscriptions.ts | 3 + web/src/pages/FileManagerPage.tsx | 365 ++++++++++++++----- web/src/pages/SettingsPage.tsx | 185 +--------- web/src/pages/SubscriptionsPage.tsx | 48 ++- web/src/types/index.ts | 2 + 41 files changed, 3075 insertions(+), 728 deletions(-) create mode 100644 internal/handler/task_helpers.go create mode 100644 internal/service/organize_pipeline.go create mode 100644 internal/service/organize_pipeline_test.go create mode 100644 internal/service/organizer_target.go diff --git a/.dockerignore b/.dockerignore index 0d8457d..b56ac1e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,6 +9,11 @@ docs/ data/ cache/ logs/ +verify-data/ +verify-cache/ +verify-media/ +verify-downloads/ +.codex-* .tmp_* .tmp-deploy-* .tmp-deploy-data/ diff --git a/.gitignore b/.gitignore index fbb7b7d..f27f7ea 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ config.yaml # Runtime / local-only artifacts (清理补充) .tmp-live-backups/ .tmp-* +.codex-* downloads/ media/ *.pid diff --git a/internal/handler/downloads_extra.go b/internal/handler/downloads_extra.go index e9810d7..9946cda 100644 --- a/internal/handler/downloads_extra.go +++ b/internal/handler/downloads_extra.go @@ -71,8 +71,8 @@ func downloadOrganizeOneHandler(svc *service.Container) gin.HandlerFunc { // existing OrganizerService for each library that contains those files. func downloadOrganizeAllHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - // Walk each library and re-organize. The OrganizerService is - // idempotent so this is safe to run repeatedly. + // Walk each library through the unified organize pipeline so rename, + // scan, scrape and task reporting stay identical to manual organize. libs, err := svc.Repo.Library.List(c.Request.Context()) if err != nil { 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)) 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 { results = append(results, gin.H{"library": l.Name, "error": err.Error()}) continue } - if svc.Scan != nil && res != nil && !res.DryRun { - res.Scans, res.Scrapes = scanAndScrapeAfterOrganize(c, svc, res.DestPath, l.ID, nil) - } - results = append(results, gin.H{"library": l.Name, "result": res}) + results = append(results, gin.H{"library": l.Name, "result": resp.Result}) } c.JSON(http.StatusOK, gin.H{"results": results}) } diff --git a/internal/handler/media_favorite.go b/internal/handler/media_favorite.go index 3d29fde..3ef9782 100644 --- a/internal/handler/media_favorite.go +++ b/internal/handler/media_favorite.go @@ -129,15 +129,17 @@ func organizeBulkHandler(svc *service.Container) gin.HandlerFunc { } out := make([]any, 0, len(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 { out = append(out, gin.H{"library": l.Name, "error": err.Error()}) continue } - if svc.Scan != nil && res != nil && !res.DryRun { - res.Scans, res.Scrapes = scanAndScrapeAfterOrganize(c, svc, res.DestPath, l.ID, nil) - } - out = append(out, gin.H{"library": l.Name, "result": res}) + out = append(out, gin.H{"library": l.Name, "result": resp.Result}) } c.JSON(http.StatusOK, gin.H{"results": out}) } diff --git a/internal/handler/organizer.go b/internal/handler/organizer.go index e569479..0ebf58e 100644 --- a/internal/handler/organizer.go +++ b/internal/handler/organizer.go @@ -57,22 +57,18 @@ func organizeMediaHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { var req organizeReq _ = c.ShouldBindJSON(&req) - opts := organizeOptionsFromReq(req) - task := startOrganizeHTTPTask(svc, "手动整理媒体", opts) - dst, err := svc.Organizer.OrganizeMediaWithOptions(c.Request.Context(), c.Param("id"), opts) + runReq := organizePipelineRequestFromReq(req, service.OrganizeScopeMedia, "手动整理媒体") + runReq.MediaID = c.Param("id") + resp, err := organizePipeline(svc).Run(c.Request.Context(), runReq) if err != nil { - finishHTTPTask(task, err, "organize", "手动整理媒体失败", nil, nil) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - payload := gin.H{"path": dst} - if organizeScanAfter(req.ScanAfter) && !req.DryRun && svc.Scan != nil { - updateHTTPTask(task, "scan_scrape", "正在扫描入库并按设置刮削", nil, nil) - scans, scrapes := scanAndScrapeAfterOrganize(c, svc, dst, strings.TrimSpace(req.LibraryID), req.ScrapeAfter) - payload["scans"] = scans - payload["scrapes"] = scrapes + payload := gin.H{"path": resp.Path} + if resp.Result != nil { + payload["scans"] = resp.Result.Scans + payload["scrapes"] = resp.Result.Scrapes } - finishHTTPTask(task, nil, "completed", "手动整理媒体结束", nil, nil) c.JSON(http.StatusOK, payload) } } @@ -81,21 +77,14 @@ func organizeLibraryHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { var req organizeReq _ = c.ShouldBindJSON(&req) - opts := organizeOptionsFromReq(req) - task := startOrganizeHTTPTask(svc, "手动整理媒体库", opts) - res, err := svc.Organizer.OrganizeLibraryWithOptions(c.Request.Context(), c.Param("id"), opts) + runReq := organizePipelineRequestFromReq(req, service.OrganizeScopeLibrary, "手动整理媒体库") + runReq.LibraryID = c.Param("id") + resp, err := organizePipeline(svc).Run(c.Request.Context(), runReq) if err != nil { - finishHTTPTask(task, err, "organize", "手动整理媒体库失败", nil, nil) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - details := service.OrganizeTaskDetails(res, 8) - 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) + c.JSON(http.StatusOK, resp.Result) } } @@ -113,63 +102,40 @@ func organizeDirectoryHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { var req organizeReq _ = c.ShouldBindJSON(&req) - opts := organizeOptionsFromReq(req) - task := startOrganizeHTTPTask(svc, "手动整理入库", opts) - res, err := svc.Organizer.OrganizeDirectory(c.Request.Context(), opts) + runReq := organizePipelineRequestFromReq(req, service.OrganizeScopeDirectory, "手动整理入库") + runReq.PreferredLibraryID = strings.TrimSpace(req.LibraryID) + resp, err := organizePipeline(svc).Run(c.Request.Context(), runReq) if err != nil { - finishHTTPTask(task, err, "organize", "手动整理入库失败", nil, nil) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - details := service.OrganizeTaskDetails(res, 8) - 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) + c.JSON(http.StatusOK, resp.Result) } } -func startOrganizeHTTPTask(svc *service.Container, name string, opts service.OrganizeOptions) *service.TaskHandle { - if svc == nil || svc.Tasks == nil { - return nil +func organizePipelineRequestFromReq(req organizeReq, scope service.OrganizeScope, taskName string) service.OrganizePipelineRequest { + dest := strings.TrimSpace(req.DestPath) + if dest == "" { + dest = strings.TrimSpace(req.TargetPath) } - message := "正在整理/重命名/入库" - if opts.DryRun { - message = "正在预览整理/重命名" + return service.OrganizePipelineRequest{ + Scope: scope, + 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) { - if task == nil { - return +func organizePipeline(svc *service.Container) *service.OrganizePipelineService { + if svc.OrganizePipeline != nil { + return svc.OrganizePipeline } - task.Update(service.TaskUpdate{Stage: stage, Message: message, Metrics: metrics, Details: details}) -} - -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 + return service.NewOrganizePipelineService(svc.Log, svc.Repo, svc.Organizer, svc.Scan, svc.Tasks) } diff --git a/internal/handler/routes_authenticated.go b/internal/handler/routes_authenticated.go index b127f80..f615b98 100644 --- a/internal/handler/routes_authenticated.go +++ b/internal/handler/routes_authenticated.go @@ -91,6 +91,7 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc * // Subscriptions. 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.DELETE("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), deleteSubscriptionHandler(svc)) authed.POST("/subscriptions/:id/run", requirePermission(svc, "can_manage_subscriptions"), runSubscriptionHandler(svc)) diff --git a/internal/handler/subscriptions.go b/internal/handler/subscriptions.go index 7747608..112b129 100644 --- a/internal/handler/subscriptions.go +++ b/internal/handler/subscriptions.go @@ -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 { return func(c *gin.Context) { if err := svc.Subscription.Delete(c.Request.Context(), c.Param("id")); err != nil { diff --git a/internal/handler/system_extra.go b/internal/handler/system_extra.go index be5ee8e..40a91cd 100644 --- a/internal/handler/system_extra.go +++ b/internal/handler/system_extra.go @@ -76,30 +76,6 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc { {"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", "label": "网盘转存", diff --git a/internal/handler/task_helpers.go b/internal/handler/task_helpers.go new file mode 100644 index 0000000..33eed7b --- /dev/null +++ b/internal/handler/task_helpers.go @@ -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}) +} diff --git a/internal/model/model.go b/internal/model/model.go index 9424fe2..f002cf4 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -238,6 +238,8 @@ type Subscription struct { Priority int `gorm:"default:50" json:"priority,omitempty"` // lower is earlier when schedulers sort later Enabled bool `gorm:"default:true" json:"enabled"` 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"` LocalMediaCount int `gorm:"-" json:"local_media_count,omitempty"` diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 88fa625..2a0293e 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -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 } -// 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) { 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 } +// 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 ───────────────────────────────────────────────────────────────── // SettingRepository persists key/value preferences. diff --git a/internal/service/download_clients.go b/internal/service/download_clients.go index 0d342bd..64686dd 100644 --- a/internal/service/download_clients.go +++ b/internal/service/download_clients.go @@ -66,6 +66,13 @@ func (s *DownloadClientService) Create(ctx context.Context, in DownloadClientInp IsDefault: normalized.IsDefault, 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 { _ = s.repo.DownloadClient.ClearDefault(ctx) } diff --git a/internal/service/download_clients_test.go b/internal/service/download_clients_test.go index 6db5d1c..7d70349 100644 --- a/internal/service/download_clients_test.go +++ b/internal/service/download_clients_test.go @@ -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) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { diff --git a/internal/service/downloads.go b/internal/service/downloads.go index ff0cba1..edda905 100644 --- a/internal/service/downloads.go +++ b/internal/service/downloads.go @@ -39,14 +39,15 @@ import ( // DownloadService is the single download orchestrator. type DownloadService struct { - log *zap.Logger - repo *repository.Container - hub *Hub - qb *QBitClient - organizer *OrganizerService - scanner *ScannerService - site *SiteService - tasks *TaskTrackerService + log *zap.Logger + repo *repository.Container + hub *Hub + qb *QBitClient + organizer *OrganizerService + organizePipeline *OrganizePipelineService + scanner *ScannerService + site *SiteService + tasks *TaskTrackerService mu sync.Mutex stopCh chan struct{} @@ -62,6 +63,10 @@ func (d *DownloadService) SetScanner(scanner *ScannerService) { d.scanner = scanner } +func (d *DownloadService) SetOrganizePipeline(pipeline *OrganizePipelineService) { + d.organizePipeline = pipeline +} + func (d *DownloadService) SetTaskTracker(tasks *TaskTrackerService) { d.tasks = tasks } @@ -203,6 +208,10 @@ func (d *DownloadService) ReloadConfig(ctx context.Context) error { cfg.BaseURL = strings.TrimRight(c.Host, "/") cfg.Username = c.Username 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 { @@ -228,6 +237,28 @@ func (d *DownloadService) ReloadConfig(ctx context.Context) error { 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. func (d *DownloadService) AddDownload(ctx context.Context, userID, urlStr, savePath string) (*model.DownloadTask, error) { return d.AddDownloadWithMeta(ctx, userID, urlStr, savePath, DownloadTaskMeta{}) @@ -313,7 +344,7 @@ func (d *DownloadService) resolveDownloadSavePath(ctx context.Context, explicitS if !autoClassify || category == "" { return base, "" } - return categoryRoot(base, sanitizeFilename(category)), category + return downloadSavePathCategoryRoot(base, sanitizeFilename(category)), category } 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 { return err } - d.markDownloadTaskDeleted(ctx, torrentName) + d.markDownloadTaskDeleted(ctx, hash, torrentName) stateKey := strings.ToLower(hash) d.mu.Lock() delete(d.prevStates, stateKey) @@ -747,16 +778,28 @@ func (d *DownloadService) Delete(ctx context.Context, hash string, withFiles boo return nil } -func (d *DownloadService) markDownloadTaskDeleted(ctx context.Context, torrentName string) { - if d == nil || d.repo == nil || d.repo.DB == nil || strings.TrimSpace(torrentName) == "" { +func (d *DownloadService) markDownloadTaskDeleted(ctx context.Context, hash, torrentName string) { + if d == nil || d.repo == nil || d.repo.DB == nil { return } rows, err := d.repo.Download.List(ctx) if err != nil { return } - taskByKey := tasksByIdentity(rows) - matched, ok := findMatchingTaskByIdentity(torrentName, taskByKey) + if matched, ok := findDownloadTaskByHash(rows, hash); ok { + _ = 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 { return } @@ -768,6 +811,19 @@ func (d *DownloadService) markDownloadTaskDeleted(ctx context.Context, torrentNa }).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 // it seeding (qBittorrent performs the physical move and resumes seeding). // 用于「移动 PT 种子文件且转移后继续做种上传」的整盘迁移场景。 @@ -803,7 +859,7 @@ func (d *DownloadService) poll(ctx context.Context) { continue } rows, _ := d.repo.Download.List(ctx) - taskByKey := tasksByIdentity(rows) + taskByKey := tasksByTorrentIdentity(rows) d.processDownloadSnapshot(ctx, live, taskByKey) 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 { stateKey := completedTorrentQueueKey(torrent) 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) if stateKey == "" { continue @@ -839,11 +899,13 @@ func (d *DownloadService) processDownloadSnapshot(ctx context.Context, live []QB // (onTorrentComplete 内部仍受 organize.auto 开关约束,且 // 整理对已存在的目标文件幂等跳过)。 d.prevStates[stateKey] = true - if recentlyCompletedTorrent(torrent, time.Now()) && !d.completedTorrentCatchupRecorded(ctx, torrent) { + if taskNeedsOrganize { shouldQueue = true } case complete && !wasComplete: shouldQueue = true + case complete && taskNeedsOrganize: + shouldQueue = true case complete: d.prevStates[stateKey] = true 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) == "" { return } - matched, ok := findMatchingTaskByIdentity(torrent.Name, taskByKey) + matched, ok := findMatchingTaskByTorrentIdentity(torrent.Name, taskByKey) if !ok { return } @@ -1053,6 +1115,17 @@ func tasksByIdentity(rows []model.DownloadTask) map[string]model.DownloadTask { 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) { key := downloadTaskIdentityKey(title) if key == "" { @@ -1069,6 +1142,29 @@ func findMatchingTaskByIdentity(title string, taskByKey map[string]model.Downloa 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. // It organizes the completed torrent payload directly. Relying on existing // 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("source", source), zap.Bool("allow_replace_existing", allowReplace)) - taskHandle := d.startDownloadOrganizeTask(torrent, source, allowReplace) - res, err := d.organizer.OrganizeDirectory(ctx, OrganizeOptions{ - SourcePath: source, - MediaType: downloadTaskMediaType(taskRow), - MediaCategory: firstNonEmpty(downloadTaskMediaCategory(taskRow), torrent.Category), - AllowReplaceExisting: allowReplace, + resWrap, err := d.ensureOrganizePipeline().Run(ctx, OrganizePipelineRequest{ + Scope: OrganizeScopeDirectory, + Trigger: OrganizeTriggerDownload, + TaskName: d.downloadOrganizeTaskName(torrent, allowReplace), + SourcePath: source, + MediaType: downloadTaskMediaType(taskRow), + MediaCategory: firstNonEmpty(downloadTaskMediaCategory(taskRow), torrent.Category), + AllowReplace: allowReplace, }) if err != nil { - if taskHandle != nil { - taskHandle.Finish(err, TaskUpdate{ - Stage: "organize", - Message: "下载完成自动整理失败", - }) - } d.log.Error("auto organize completed torrent failed", zap.String("hash", torrent.Hash), zap.String("source", source), zap.Error(err)) return } - if taskHandle != nil && res != nil { - taskHandle.Update(TaskUpdate{ - Stage: "organize", - 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), - }) + res := resWrap.Result + if res == nil { + res = &OrganizeResult{} } d.markCompletedTorrentCatchupRecorded(context.Background(), torrent) 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))) } -func (d *DownloadService) startDownloadOrganizeTask(torrent QBitTorrent, source string, allowReplace bool) *TaskHandle { - if d == nil || d.tasks == nil { - return nil - } - message := "下载完成后自动整理/重命名/入库" - if allowReplace { - message = "下载完成后自动整理/重命名/入库(允许洗版替换)" - } +func (d *DownloadService) downloadOrganizeTaskName(torrent QBitTorrent, allowReplace bool) string { name := strings.TrimSpace(torrent.Name) if name == "" { name = "下载完成自动整理" } - return d.tasks.Start(TaskKindOrganize, name, TaskUpdate{ - Stage: "organize", - SourcePath: source, - Message: message, - }) + if allowReplace { + name += "(允许洗版)" + } + 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) { @@ -1204,12 +1263,12 @@ func (d *DownloadService) completedTorrentTask(ctx context.Context, torrent QBit if err != nil || len(rows) == 0 { return nil, false } - taskByKey := tasksByIdentity(rows) - if task, ok := findMatchingTaskByIdentity(torrent.Name, taskByKey); ok { + taskByKey := tasksByTorrentIdentity(rows) + if task, ok := findMatchingTaskByTorrentIdentity(torrent.Name, taskByKey); ok { return &task, true } 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 } } diff --git a/internal/service/downloads_test.go b/internal/service/downloads_test.go index 7ed04ef..0a498d5 100644 --- a/internal/service/downloads_test.go +++ b/internal/service/downloads_test.go @@ -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) { root := t.TempDir() src := filepath.Join(root, "downloads", "国产剧", "狂飙.S01E01.2023.1080p.mkv") @@ -335,17 +430,31 @@ func TestDownloadPollBaselinesAlreadyCompletedTorrents(t *testing.T) { func TestDownloadPollCatchesUpRecentlyCompletedTorrents(t *testing.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.processDownloadSnapshot(t.Context(), []QBitTorrent{ {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: "no-timestamp", Name: "No Timestamp S01E01", Progress: 1}, - }, nil) + }, tasksByTorrentIdentity([]model.DownloadTask{*task})) - // 只有补整理时间窗内完成的种子会被补整理;无 completion_on 的保守跳过。 + // 只有补整理时间窗内、且存在本地追踪任务的种子会被补整理;无 completion_on 的保守跳过。 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) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) 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) { var addCalls int32 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) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { diff --git a/internal/service/episode_parser.go b/internal/service/episode_parser.go index ff077a4..8404cc4 100644 --- a/internal/service/episode_parser.go +++ b/internal/service/episode_parser.go @@ -28,6 +28,7 @@ var ( patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\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*季`) + patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`) patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`) // patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。 patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`) diff --git a/internal/service/media_classifier.go b/internal/service/media_classifier.go index f949718..8c3daef 100644 --- a/internal/service/media_classifier.go +++ b/internal/service/media_classifier.go @@ -8,6 +8,7 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/repository" + "go.uber.org/zap" ) var ( @@ -34,12 +35,11 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin languages := normalizeTokens(input.Languages...) rawText := input.Title + " " + input.Category + " " + strings.Join(input.Genres, " ") text := strings.ToLower(rawText) + hasMetadata := len(genres) > 0 || len(countries) > 0 || len(languages) > 0 - isChinesePlatform := containsAnyText(text, - "iqiyi", "qiyi", "youku", "tencent", "wetv", "mgtv", "mango", "hunantv", "cctv", - "bilibili", "bili", "芒果tv", "腾讯视频", "优酷", "爱奇艺", - ) - isChinese := hasAny(languages, "ZH", "ZH-CN", "ZH-TW", "CN") || hasAny(countries, "CN", "TW", "HK", "MO") || containsHan(rawText) || containsAnyText(text, "华语", "国产", "国剧", "国漫") || isChinesePlatform + isChineseByMetadata := hasAny(languages, "ZH", "ZH-CN", "ZH-TW", "CN", "BO", "ZA") || hasAny(countries, "CN", "TW", "HK", "MO") + isChineseByText := containsHan(rawText) || containsAnyText(text, "华语", "国产", "国剧", "国漫") + isChinese := isChineseByMetadata || (!hasMetadata && isChineseByText) isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || containsJapaneseKana(rawText) || strings.Contains(text, "日番") isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP") || containsKoreanHangul(rawText) 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", "FI", "ES", "IT", "PT", "AT", "CH", "BE", "RU", ) - isLatinFallback := containsLatin(rawText) && !containsHan(rawText) && !containsJapaneseKana(rawText) && !containsKoreanHangul(rawText) - isWestern := isWesternByMetadata || (mediaType == "tv" && isLatinFallback) + isWestern := isWesternByMetadata hasAnimeText := containsAnyText(text, "动画", "动漫", "番剧", "年番", "国漫", "日番", "bangumi", "anime", "b-global", "ani-one", "crunchyroll") hasVarietyText := containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show") hasDocumentaryText := containsAnyText(text, "纪录", "纪录片", "documentary", "docu", "national geographic", "natgeo") @@ -74,18 +73,12 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin if isAdultText { return categoryName(categories, "adult", "成人") } - if hasGenre("16", "ANIMATION", "动画", "动漫") { + if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText { return categoryName(categories, "animation_movie", "动画电影") } if isChinese { return categoryName(categories, "chinese_movie", "华语电影") } - if isEastAsian { - return categoryName(categories, "jk_movie", "日韩电影") - } - if isWesternByMetadata { - return categoryName(categories, "euus_movie", "欧美电影") - } return categoryName(categories, "foreign_movie", "外语电影") case "anime": 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) { mediaType := normalizeMediaType(sub.MediaType, title+" "+sub.Name+" "+sub.Filter, sourceCategory) 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 == "" { category = classifyMediaCategory(mediaClassifyInput{ MediaType: mediaType, @@ -290,6 +306,78 @@ func (s *SubscriptionService) classifySubscriptionItem(ctx context.Context, sub 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 { if s == nil || s.cfg == nil || s.cfg.Organizer.Categories == nil { return nil @@ -311,7 +399,7 @@ func (s *SubscriptionService) resolveSubscriptionSavePath(ctx context.Context, s if !s.isSmartClassifyEnabled(ctx) || category == "" { return base } - return categoryRoot(base, sanitizeFilename(category)) + return downloadSavePathCategoryRoot(base, sanitizeFilename(category)) } func (s *SubscriptionService) isSmartClassifyEnabled(ctx context.Context) bool { @@ -368,3 +456,39 @@ func downloadCategoryMap(organizer *OrganizerService) map[string]string { } 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) +} diff --git a/internal/service/media_classifier_test.go b/internal/service/media_classifier_test.go index fb74716..5748777 100644 --- a/internal/service/media_classifier_test.go +++ b/internal/service/media_classifier_test.go @@ -29,6 +29,37 @@ func TestClassifyMediaCategoryMatchesMoviePilotStyleRules(t *testing.T) { }, 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", input: mediaClassifyInput{ @@ -82,26 +113,27 @@ func TestClassifyMediaCategoryMatchesMoviePilotStyleRules(t *testing.T) { want: "国产剧", }, { - name: "latin tv title without metadata", + name: "latin tv title without metadata stays uncategorized", input: mediaClassifyInput{ MediaType: "tv", 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{ MediaType: "tv", 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{ MediaType: "tv", Title: "Ashes.to.Crown.S01E15.2160p.YOUKU.WEB-DL", + Countries: []string{"CN"}, }, want: "国产剧", }, diff --git a/internal/service/organize_naming_test.go b/internal/service/organize_naming_test.go index 6e1416f..44f9889 100644 --- a/internal/service/organize_naming_test.go +++ b/internal/service/organize_naming_test.go @@ -1,6 +1,15 @@ 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: // 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}, {"Oppenheimer.2023.2160p.UHD.BluRay.mkv", "Oppenheimer", 2023, 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 { 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) + } +} diff --git a/internal/service/organize_pipeline.go b/internal/service/organize_pipeline.go new file mode 100644 index 0000000..8b0399a --- /dev/null +++ b/internal/service/organize_pipeline.go @@ -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 "" +} diff --git a/internal/service/organize_pipeline_test.go b/internal/service/organize_pipeline_test.go new file mode 100644 index 0000000..0989b31 --- /dev/null +++ b/internal/service/organize_pipeline_test.go @@ -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) + } +} diff --git a/internal/service/organizer.go b/internal/service/organizer.go index 73f6df4..5acd502 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -149,21 +149,36 @@ func (o *OrganizerService) OrganizeMediaWithOptions(ctx context.Context, mediaID var dst string 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) - dir := filepath.Join(categoryRoot(root, category), title, season) - dst = filepath.Join(dir, fmt.Sprintf("%s - %s%s", title, epTag, ext)) - } else { - // Movie: {baseRoot}/[分类]/{Title} ({Year})/{Title} ({Year}).ext - folder := title - if m.Year > 0 { - folder = fmt.Sprintf("%s (%d)", title, m.Year) + target, err := o.buildOrganizeTargetPath(ctx, organizeTargetInput{ + Root: categoryRoot(root, category), + MediaType: lib.Type, + Category: category, + Title: title, + Ext: ext, + 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) - dir := filepath.Join(categoryRoot(root, category), folder) - dst = filepath.Join(dir, folder+ext) + target, err := o.buildOrganizeTargetPath(ctx, organizeTargetInput{ + 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. diff --git a/internal/service/organizer_directory.go b/internal/service/organizer_directory.go index 73f783a..a71dda2 100644 --- a/internal/service/organizer_directory.go +++ b/internal/service/organizer_directory.go @@ -254,15 +254,23 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo title = "Unknown" } season, episode := ParseEpisode(src) - layout := o.inferOrganizeDirectoryLayout(src, sourceRoot) - if forced := normalizeOrganizeMediaType(mediaTypeOverride); forced != "" { - if layout.Category != "" && layout.MediaType != "" && layout.MediaType != forced { + pathLayout := o.inferOrganizeDirectoryLayout(src, sourceRoot) + layout := pathLayout + forcedType := normalizeOrganizeMediaType(mediaTypeOverride) + inferredType := o.inferMediaTypeForSourceFile(src, title, season, episode) + if forcedType != "" { + if layout.Category != "" && layout.MediaType != "" && layout.MediaType != forcedType { layout.Category = "" } - layout.MediaType = forced - } - if layout.MediaType == "" { - layout.MediaType = o.inferMediaTypeForSourceFile(src, title, season, episode) + layout.MediaType = forcedType + } else if inferredType != "" { + if inferredType == "tv" && layout.MediaType == "movie" { + // 文件名中明确有季/集信息时,目录名只能作为弱提示;否则 + // 下载到错误的“电影/外语电影”等目录会把剧集按电影入库。 + layout = organizeDirectoryLayout{MediaType: inferredType} + } else if layout.MediaType == "" { + layout.MediaType = inferredType + } } var metadataMatch *Match 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 != "" { layout.Category = sanitizeFilename(category) - } else if layout.Category == "" { - layout.Category = o.smartClassifySourceFile(ctx, src, sourceRoot, layout.MediaType, title, parsedTitle) + } else if category := o.smartClassifySourceFile(ctx, src, sourceRoot, layout.MediaType, title, parsedTitle, metadataMatch); category != "" { + // 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) if !matchedLibrary && layout.MediaType != "" { @@ -296,20 +315,23 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo if layout.MediaType != "" { isSeries = isSeriesLibraryType(layout.MediaType) && (season > 0 || episode > 0) } - if isSeries { - // TV/动漫/综艺等剧集:{destRoot}/{Title}/Season XX/{Title} - SxxExx.ext - episodeTag = fmt.Sprintf("S%02dE%02d", season, episode) - destDir = filepath.Join(layoutRoot, title, fmt.Sprintf("Season %02d", season)) - dst = filepath.Join(destDir, fmt.Sprintf("%s - %s%s", title, episodeTag, ext)) - } else { - // 电影:{destRoot}/{Title} ({Year})/{Title} ({Year}).ext - folder := title - if year > 0 { - folder = fmt.Sprintf("%s (%d)", title, year) - } - destDir = filepath.Join(layoutRoot, folder) - dst = filepath.Join(destDir, folder+ext) + target, err := o.buildOrganizeTargetPath(ctx, organizeTargetInput{ + Root: layoutRoot, + MediaType: layout.MediaType, + Category: layout.Category, + Title: title, + Ext: ext, + Year: year, + Season: season, + Episode: episode, + Series: isSeries, + }) + if err != nil { + return err } + destDir = target.Dir + dst = target.Path + episodeTag = target.EpisodeTag // 源文件已经位于目标位置:无需处理。 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) - if cache != nil { - cache[key] = match - } 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 { o.log.Info("organize metadata matched before rename", zap.String("source", src), @@ -510,10 +550,29 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour } return match } + if cache != nil { + cache[key] = 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 { if local == nil || strings.TrimSpace(local.Title) == "" { 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)) } -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) { return "" } @@ -557,6 +616,20 @@ func (o *OrganizerService) smartClassifySourceFile(ctx context.Context, src, sou Title: strings.Join([]string{title, parsedTitle, filepath.Base(src)}, " "), 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 { input.Title = strings.Join([]string{meta.Title, meta.OriginalName, title, parsedTitle, filepath.Base(src)}, " ") input.Languages = parseCommaList(meta.Languages) @@ -595,7 +668,9 @@ func organizeDirectoryCategoryCandidates(src, sourceRoot string) []string { } cleanSourceRoot := filepath.Clean(sourceRoot) - add(filepath.Base(cleanSourceRoot)) + for _, part := range organizePathNameParts(cleanSourceRoot) { + add(part) + } rel, err := filepath.Rel(cleanSourceRoot, filepath.Clean(src)) if err != nil || rel == "." || strings.HasPrefix(rel, "..") { return out @@ -610,6 +685,34 @@ func organizeDirectoryCategoryCandidates(src, sourceRoot string) []string { 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) { key := strings.ToLower(strings.TrimSpace(name)) if key == "" { diff --git a/internal/service/organizer_directory_test.go b/internal/service/organizer_directory_test.go index a543c52..2238419 100644 --- a/internal/service/organizer_directory_test.go +++ b/internal/service/organizer_directory_test.go @@ -694,7 +694,7 @@ func TestOrganizeDirectorySmartClassifiesUncategorizedSources(t *testing.T) { filepath.Join(dest, "电影", "华语电影", "流浪地球2 (2023)", "流浪地球2 (2023).mkv"), filepath.Join(dest, "电影", "外语电影", "Dune (2021)", "Dune (2021).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 { t.Fatalf("expected smart classified file at %q: %v; items=%+v", want, err, res.Items) diff --git a/internal/service/organizer_scrape_test.go b/internal/service/organizer_scrape_test.go index c8b10c9..1be849f 100644 --- a/internal/service/organizer_scrape_test.go +++ b/internal/service/organizer_scrape_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "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) { scraper, repos, closeServer := newTestScraper(t) defer closeServer() diff --git a/internal/service/organizer_target.go b/internal/service/organizer_target.go new file mode 100644 index 0000000..397159b --- /dev/null +++ b/internal/service/organizer_target.go @@ -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...) +} diff --git a/internal/service/path_translation.go b/internal/service/path_translation.go index cd22332..60d35c3 100644 --- a/internal/service/path_translation.go +++ b/internal/service/path_translation.go @@ -17,10 +17,17 @@ func translateClientPath(clientPath string, mappings map[string]string) string { if _, err := os.Stat(clean); err == nil { 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 { - prefix := strings.TrimRight(filepath.ToSlash(filepath.Clean(clientPrefix)), "/") + prefix := strings.TrimRight(cleanPathForVolumeMapping(clientPrefix), "/") if prefix == "" || prefix == "." { continue } diff --git a/internal/service/qbittorrent.go b/internal/service/qbittorrent.go index 86df98b..02942d7 100644 --- a/internal/service/qbittorrent.go +++ b/internal/service/qbittorrent.go @@ -181,7 +181,7 @@ func (q *QBitClient) addTorrentLocked(ctx context.Context, magnetOrURL string, t if hash := torrentInfoHash(torrentData); hash != "" { if _, ok := beforeHashes[hash]; ok { q.log.Info("qbittorrent: torrent already exists", zap.String("hash", hash), zap.String("name", torrentName)) - return nil + return ErrDownloadAlreadyExists } } } diff --git a/internal/service/qbittorrent_test.go b/internal/service/qbittorrent_test.go index f7794cd..72cc3fa 100644 --- a/internal/service/qbittorrent_test.go +++ b/internal/service/qbittorrent_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "io" "mime/multipart" "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") hash := torrentInfoHash(torrentData) if hash == "" { @@ -215,8 +216,8 @@ func TestQBitAddTorrentFileTreatsExistingInfoHashAsSuccess(t *testing.T) { Password: "adminadmin", }) - if err := client.AddTorrentFile(context.Background(), torrentData, "fixture.torrent", ""); err != nil { - t.Fatalf("expected existing torrent to be accepted: %v", err) + if err := client.AddTorrentFile(context.Background(), torrentData, "fixture.torrent", ""); !errors.Is(err, ErrDownloadAlreadyExists) { + t.Fatalf("err = %v, want ErrDownloadAlreadyExists", err) } if addCalled.Load() { t.Fatal("expected qbit add to be skipped for existing infohash") diff --git a/internal/service/scheduler.go b/internal/service/scheduler.go index a0967bc..32acc7f 100644 --- a/internal/service/scheduler.go +++ b/internal/service/scheduler.go @@ -40,16 +40,17 @@ import ( // SchedulerService runs the periodic jobs. type SchedulerService struct { - log *zap.Logger - repo *repository.Container - scanner *ScannerService - transcoder *TranscoderService - organizer *OrganizerService - storageCfg *StorageConfigService - hub *Hub - tasks *TaskTrackerService - cacheDir string - now func() time.Time + log *zap.Logger + repo *repository.Container + scanner *ScannerService + transcoder *TranscoderService + organizer *OrganizerService + organizePipeline *OrganizePipelineService + storageCfg *StorageConfigService + hub *Hub + tasks *TaskTrackerService + cacheDir string + now func() time.Time mu sync.Mutex stopCh chan struct{} @@ -65,6 +66,10 @@ func (s *SchedulerService) SetTaskTracker(tasks *TaskTrackerService) { s.tasks = tasks } +func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService) { + s.organizePipeline = pipeline +} + // scheduledJob is one recurring task. type scheduledJob struct { name string @@ -552,53 +557,21 @@ func (s *SchedulerService) jobOrganizeSource(ctx context.Context) error { if s.organizer == nil || (!manual && !s.autoOrganizeSourceEnabled(ctx)) { return nil } - task := s.startScheduledOrganizeTask(ctx, manual) - res, err := s.organizer.OrganizeDirectory(ctx, OrganizeOptions{}) + taskName := "自动整理重命名刮削入库" + if manual { + taskName = "手动触发自动整理重命名刮削入库" + } + resWrap, err := s.ensureOrganizePipeline().Run(ctx, OrganizePipelineRequest{ + Scope: OrganizeScopeDirectory, + Trigger: OrganizeTriggerScheduled, + TaskName: taskName, + }) if err != nil { - if task != nil { - task.Finish(err, TaskUpdate{ - Stage: "organize", - Message: "自动整理入库失败", - }) - } return err } - if task != nil && res != nil { - task.Update(TaskUpdate{ - Stage: "organize", - 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), - }) + res := resWrap.Result + if res == nil { + res = &OrganizeResult{} } if s.log != nil && res != nil { s.log.Info("scheduled source organize finished", @@ -614,6 +587,13 @@ func (s *SchedulerService) jobOrganizeSource(ctx context.Context) error { 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 { if s == nil || s.tasks == nil { return nil diff --git a/internal/service/scraper.go b/internal/service/scraper.go index 7f47875..bad7383 100644 --- a/internal/service/scraper.go +++ b/internal/service/scraper.go @@ -105,6 +105,17 @@ var noiseTokenSet = func() map[string]struct{} { 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. var bracketedTag = regexp.MustCompile(`[\[\(\{][^\]\)\}]*[\]\)\}]`) var multiWordNoise = []*regexp.Regexp{ @@ -142,6 +153,7 @@ func CleanQuery(raw string) (title string, year int) { lower = patCN.ReplaceAllString(lower, " ") // 去掉中文季/部标记(如「第二季」「第2部」),避免残留在标题里既污染 // 搜索查询又导致整理后的目录名重复季信息。 + lower = patSeasonOnly.ReplaceAllString(lower, " ") lower = patCNSeason.ReplaceAllString(lower, " ") for _, pat := range multiWordNoise { @@ -153,8 +165,15 @@ func CleanQuery(raw string) (title string, year int) { // 拆分后丢掉过短(≤1)且全为 ASCII 数字 / 字母的"碎片",避免 // 「2」「0」「v」之类残留干扰 TMDb 搜索。中文字符不算碎片。 out := make([]string, 0, 8) + seenReleaseBoundary := false for _, w := range strings.Fields(lower) { if _, ok := noiseTokenSet[w]; ok { + if _, boundary := releaseBoundaryTokenSet[w]; boundary { + seenReleaseBoundary = true + } + continue + } + if seenReleaseBoundary && isASCIIWord(w) { continue } if len(w) <= 1 { @@ -169,6 +188,22 @@ func CleanQuery(raw string) (title string, year int) { 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. func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error { 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 - if year == 0 { - _, year = CleanQuery(filepath.Base(m.Path)) - } + year := mediaYearHint(m) if s.adult != nil && s.adult.Enabled() { 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 match := (*Match)(nil) for _, candidate := range candidates { - match = s.lookup(ctx, lib, candidate, year) 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 { break } @@ -242,6 +291,31 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error { 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 { if local == nil { return "" @@ -489,7 +563,7 @@ func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string { func seriesFolderTitle(mediaPath, libraryRoot string) string { dir := filepath.Dir(mediaPath) - if seasonFromDir(filepath.Base(dir)) > 0 { + if strictSeasonFolder(filepath.Base(dir)) > 0 { dir = filepath.Dir(dir) } if libraryRoot != "" && samePath(dir, filepath.Clean(libraryRoot)) { @@ -499,9 +573,51 @@ func seriesFolderTitle(mediaPath, libraryRoot string) string { if base == "." || base == string(filepath.Separator) { return "" } + if isGenericMediaCategoryFolder(base) { + return "" + } 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 { if m := patSeasonFolder.FindStringSubmatch(name); len(m) >= 3 { for _, group := range m[1:] { diff --git a/internal/service/scraper_test.go b/internal/service/scraper_test.go index 864e7b8..478869d 100644 --- a/internal/service/scraper_test.go +++ b/internal/service/scraper_test.go @@ -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) { scraper, repos, closeServer := newTestScraper(t) 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) { db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) if err != nil { diff --git a/internal/service/service.go b/internal/service/service.go index 3135dbe..0612159 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -18,61 +18,62 @@ import ( // Container 持有在启动时初始化的每个服务。Handler 接收指向它的指针并选择相关字段。 type Container struct { - Cfg *config.Config - Log *zap.Logger - Repo *repository.Container - WSHub *Hub - SSEHub *SSEHub - Tasks *TaskTrackerService - Auth *AuthService - Media *MediaService - Scan *ScannerService - Stream *StreamService - Transcoder *TranscoderService - FFprobe *FFprobeService - TMDb *TMDbProvider - Bangumi *BangumiProvider - TheTVDB *TheTVDBProvider - Fanart *FanartProvider - Scraper *ScraperService - Discover *DiscoverService - Playback *PlaybackService - ImageProxy *ImageProxy - Watcher *WatcherService - Downloads *DownloadService - Subscription *SubscriptionService - Subtitle *SubtitleService - Stats *StatsService - Profile *ProfileService - Audit *AuditService - NFO *NFOService - AI *AIService - APIConfig *APIConfigService - Crypto *CryptoService - Duplicate *DuplicateService - FileManager *FileManagerService - DLNA *DLNAService - Scheduler *SchedulerService - Storage *StorageService - Emby *EmbyService - Backup *BackupService - Notifier *NotifierService - NotifyChannels *NotifyChannelService - TelegramBot *TelegramBotService - PlayProfiles *PlayProfileService - Permissions *PermissionService - StorageCfg *StorageConfigService - STRM *STRMService - DownloadClients *DownloadClientService - Assistant *AssistantService - Organizer *OrganizerService - Douban *DoubanProvider - Token *TokenService - ApiConfig *ApiConfigService - DownloadMgr *DownloadManager - Notify *NotifyService - Site *SiteService - Device *DeviceService + Cfg *config.Config + Log *zap.Logger + Repo *repository.Container + WSHub *Hub + SSEHub *SSEHub + Tasks *TaskTrackerService + Auth *AuthService + Media *MediaService + Scan *ScannerService + Stream *StreamService + Transcoder *TranscoderService + FFprobe *FFprobeService + TMDb *TMDbProvider + Bangumi *BangumiProvider + TheTVDB *TheTVDBProvider + Fanart *FanartProvider + Scraper *ScraperService + Discover *DiscoverService + Playback *PlaybackService + ImageProxy *ImageProxy + Watcher *WatcherService + Downloads *DownloadService + Subscription *SubscriptionService + Subtitle *SubtitleService + Stats *StatsService + Profile *ProfileService + Audit *AuditService + NFO *NFOService + AI *AIService + APIConfig *APIConfigService + Crypto *CryptoService + Duplicate *DuplicateService + FileManager *FileManagerService + DLNA *DLNAService + Scheduler *SchedulerService + Storage *StorageService + Emby *EmbyService + Backup *BackupService + Notifier *NotifierService + NotifyChannels *NotifyChannelService + TelegramBot *TelegramBotService + PlayProfiles *PlayProfileService + Permissions *PermissionService + StorageCfg *StorageConfigService + STRM *STRMService + DownloadClients *DownloadClientService + Assistant *AssistantService + Organizer *OrganizerService + OrganizePipeline *OrganizePipelineService + Douban *DoubanProvider + Token *TokenService + ApiConfig *ApiConfigService + DownloadMgr *DownloadManager + Notify *NotifyService + Site *SiteService + Device *DeviceService stopCtx context.Context stopCancel context.CancelFunc @@ -107,6 +108,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont discover := NewDiscoverService(log, tmdb) transcoder := NewTranscoderService(cfg, log, repos, hub) scanner := NewScannerService(cfg, log, repos, hub, probe, scraper) + organizePipeline := NewOrganizePipelineService(log, repos, organizer, scanner, tasks) watcher := NewWatcherService(log, repos, scanner) nfo := NewNFOService(log, repos) 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) scheduler := NewSchedulerService(log, repos, scanner, transcoder, organizer, storageCfg, hub, cfg.Cache.CacheDir) scheduler.SetTaskTracker(tasks) + scheduler.SetOrganizePipeline(organizePipeline) // 初始化认证相关服务 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.SetScanner(scanner) downloads.SetTaskTracker(tasks) + downloads.SetOrganizePipeline(organizePipeline) subscription := NewSubscriptionService(cfg, log, repos, downloads, siteSvc, hub) + subscription.SetScraper(scraper) // 让图片代理把媒体库根目录视为可读的本地图片位置:海报/封面等 // sidecar 资源就存放在这些(用户自定义、任意)目录下,否则会被 @@ -175,63 +180,64 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont ctx, cancel := context.WithCancel(context.Background()) return &Container{ - Cfg: cfg, - Log: log, - Repo: repos, - WSHub: hub, - SSEHub: sseHub, - Tasks: tasks, - Auth: authSvc, - Media: NewMediaService(cfg, log, repos), - Scan: scanner, - Stream: NewStreamService(cfg, log, repos, transcoder), - Transcoder: transcoder, - FFprobe: probe, - TMDb: tmdb, - Bangumi: bangumi, - TheTVDB: thetvdb, - Fanart: fanart, - Scraper: scraper, - Discover: discover, - Playback: NewPlaybackService(log, repos), - ImageProxy: imageProxy, - Watcher: watcher, - Downloads: downloads, - Subscription: subscription, - Subtitle: NewSubtitleService(log, repos), - Stats: NewStatsService(log, repos), - Profile: NewProfileService(log, repos), - Audit: NewAuditService(log, repos), - NFO: nfo, - AI: ai, - APIConfig: apiConfig, - Crypto: crypto, - Duplicate: duplicate, - FileManager: filemanager, - DLNA: dlna, - Scheduler: scheduler, - Storage: storage, - Emby: emby, - Backup: backup, - Notifier: notifier, - NotifyChannels: notifyChannels, - TelegramBot: telegramBot, - PlayProfiles: playProfiles, - Permissions: permissions, - StorageCfg: storageCfg, - STRM: strmSvc, - DownloadClients: downloadClients, - Assistant: assistant, - Organizer: organizer, - Douban: douban, - Token: tokenSvc, - ApiConfig: apiConfigSvc, - DownloadMgr: downloadMgr, - Notify: notifySvc, - Site: siteSvc, - Device: deviceSvc, - stopCtx: ctx, - stopCancel: cancel, + Cfg: cfg, + Log: log, + Repo: repos, + WSHub: hub, + SSEHub: sseHub, + Tasks: tasks, + Auth: authSvc, + Media: NewMediaService(cfg, log, repos), + Scan: scanner, + Stream: NewStreamService(cfg, log, repos, transcoder), + Transcoder: transcoder, + FFprobe: probe, + TMDb: tmdb, + Bangumi: bangumi, + TheTVDB: thetvdb, + Fanart: fanart, + Scraper: scraper, + Discover: discover, + Playback: NewPlaybackService(log, repos), + ImageProxy: imageProxy, + Watcher: watcher, + Downloads: downloads, + Subscription: subscription, + Subtitle: NewSubtitleService(log, repos), + Stats: NewStatsService(log, repos), + Profile: NewProfileService(log, repos), + Audit: NewAuditService(log, repos), + NFO: nfo, + AI: ai, + APIConfig: apiConfig, + Crypto: crypto, + Duplicate: duplicate, + FileManager: filemanager, + DLNA: dlna, + Scheduler: scheduler, + Storage: storage, + Emby: emby, + Backup: backup, + Notifier: notifier, + NotifyChannels: notifyChannels, + TelegramBot: telegramBot, + PlayProfiles: playProfiles, + Permissions: permissions, + StorageCfg: storageCfg, + STRM: strmSvc, + DownloadClients: downloadClients, + Assistant: assistant, + Organizer: organizer, + OrganizePipeline: organizePipeline, + Douban: douban, + Token: tokenSvc, + ApiConfig: apiConfigSvc, + DownloadMgr: downloadMgr, + Notify: notifySvc, + Site: siteSvc, + Device: deviceSvc, + stopCtx: ctx, + stopCancel: cancel, } } diff --git a/internal/service/subscription.go b/internal/service/subscription.go index 27579bd..d06f12c 100644 --- a/internal/service/subscription.go +++ b/internal/service/subscription.go @@ -32,6 +32,7 @@ type SubscriptionService struct { repo *repository.Container downloads *DownloadService site *SiteService + scraper *ScraperService hub *Hub 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. func (s *SubscriptionService) Start(ctx context.Context) { go s.loop(ctx) @@ -114,6 +119,11 @@ func (s *SubscriptionService) List(ctx context.Context) ([]model.Subscription, e 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. func (s *SubscriptionService) Delete(ctx context.Context, id string) 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 { return 0, err } + if sub.ArchivedAt != nil { + return 0, nil + } return s.runOne(ctx, &sub) } @@ -243,6 +256,7 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio seen = append(seen, guid) seenSet[guid] = struct{}{} } + avail = s.finalizePendingAvailability(sub, avail) // Remember the last 200 GUIDs so the seen set doesn't grow forever. if 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() _ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error + _ = s.archiveCompletedSubscription(ctx, sub, avail) if queued > 0 { s.hub.Publish("subscription", map[string]any{ "id": sub.ID, @@ -300,6 +315,7 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs item := candidate.Item mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, item.Title, item.Category) if s.shouldSkipExistingTorrent(ctx, mediaType, candidate) { + addAvailabilityTitle(item.Title, availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)), &availability) seen = append(seen, candidate.GUID) seenSet[candidate.GUID] = struct{}{} continue @@ -307,6 +323,7 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs realURL := s.site.ResolveDownloadURL(ctx, candidate.Download) savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory) if s.downloadPathHasCandidate(ctx, sub, candidate.Item.Title, savePath) { + addAvailabilityTitle(item.Title, availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)), &availability) seen = append(seen, candidate.GUID) seenSet[candidate.GUID] = struct{}{} continue @@ -322,6 +339,7 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs AllowExistingLibrary: sub.WashEnabled, }); err != nil { if IsDownloadDedupError(err) { + addAvailabilityTitle(item.Title, availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)), &availability) seen = append(seen, candidate.GUID) seenSet[candidate.GUID] = struct{}{} continue @@ -338,16 +356,19 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs continue } queued++ + addAvailabilityTitle(item.Title, availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)), &availability) resources = append(resources, item.Title) seen = append(seen, candidate.GUID) seenSet[candidate.GUID] = struct{}{} } + availability = s.finalizePendingAvailability(sub, availability) if len(seen) > 200 { seen = seen[len(seen)-200:] } _ = s.repo.Setting.Set(ctx, guidKey, strings.Join(seen, "\n")) now := time.Now() _ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error + _ = s.archiveCompletedSubscription(ctx, sub, availability) if queued > 0 { s.hub.Publish("subscription", map[string]any{ "id": sub.ID, @@ -364,6 +385,88 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs 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 { if s == nil || s.downloads == nil { return false diff --git a/internal/service/subscription_test.go b/internal/service/subscription_test.go index 48d1b6d..d33425a 100644 --- a/internal/service/subscription_test.go +++ b/internal/service/subscription_test.go @@ -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(` + + + Dune 2021 1080p WEB-DL + dune-1080-web + magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd&dn=Dune+2021+1080p+WEB-DL + +`)) + })) + 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) { rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/rss+xml") diff --git a/internal/service/tmdb.go b/internal/service/tmdb.go index f4eac63..f4606ac 100644 --- a/internal/service/tmdb.go +++ b/internal/service/tmdb.go @@ -22,6 +22,7 @@ import ( "fmt" "net/http" "net/url" + "strings" "time" "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() type result struct { - ID int `json:"id"` - Title string `json:"title"` - Overview string `json:"overview"` - PosterPath string `json:"poster_path"` - BackdropPath string `json:"backdrop_path"` - ReleaseDate string `json:"release_date"` - VoteAverage float32 `json:"vote_average"` + ID int `json:"id"` + Title string `json:"title"` + OriginalTitle string `json:"original_title"` + OriginalLanguage string `json:"original_language"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + BackdropPath string `json:"backdrop_path"` + ReleaseDate string `json:"release_date"` + VoteAverage float32 `json:"vote_average"` + GenreIDs []int `json:"genre_ids"` } type page struct { Results []result `json:"results"` @@ -187,10 +191,13 @@ func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int) } r := p.Results[0] m := &Match{ - TMDbID: r.ID, - Title: r.Title, - Overview: r.Overview, - Rating: r.VoteAverage, + TMDbID: r.ID, + Title: r.Title, + OriginalName: r.OriginalTitle, + Overview: r.Overview, + Rating: r.VoteAverage, + Languages: nonEmptyStrings(r.OriginalLanguage), + Genres: genreIDStrings(r.GenreIDs), } if 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() type result struct { - ID int `json:"id"` - Name string `json:"name"` - OriginalName string `json:"original_name"` - Overview string `json:"overview"` - PosterPath string `json:"poster_path"` - BackdropPath string `json:"backdrop_path"` - FirstAirDate string `json:"first_air_date"` - VoteAverage float32 `json:"vote_average"` + ID int `json:"id"` + Name string `json:"name"` + OriginalName string `json:"original_name"` + OriginalLanguage string `json:"original_language"` + OriginCountry []string `json:"origin_country"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + BackdropPath string `json:"backdrop_path"` + FirstAirDate string `json:"first_air_date"` + VoteAverage float32 `json:"vote_average"` + GenreIDs []int `json:"genre_ids"` } type page struct { Results []result `json:"results"` @@ -250,10 +260,14 @@ func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*M } r := p.Results[0] m := &Match{ - TMDbID: r.ID, - Title: r.Name, - Overview: r.Overview, - Rating: r.VoteAverage, + TMDbID: r.ID, + Title: r.Name, + OriginalName: r.OriginalName, + Overview: r.Overview, + Rating: r.VoteAverage, + Languages: nonEmptyStrings(r.OriginalLanguage), + Countries: deduplicate(r.OriginCountry), + Genres: genreIDStrings(r.GenreIDs), } if m.Title == "" { m.Title = r.OriginalName @@ -438,3 +452,27 @@ func deduplicate(s []string) []string { } 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 +} diff --git a/web/src/api/subscriptions.ts b/web/src/api/subscriptions.ts index eaa56af..d424d3e 100644 --- a/web/src/api/subscriptions.ts +++ b/web/src/api/subscriptions.ts @@ -5,6 +5,9 @@ export const subscriptionsAPI = { list: () => api.get<{ items: Subscription[] }>('/subscriptions').then((r) => r.data.items), + history: () => + api.get<{ items: Subscription[] }>('/subscriptions/history').then((r) => r.data.items), + create: (input: { name: string feed_url: string diff --git a/web/src/pages/FileManagerPage.tsx b/web/src/pages/FileManagerPage.tsx index e0b31e2..2330781 100644 --- a/web/src/pages/FileManagerPage.tsx +++ b/web/src/pages/FileManagerPage.tsx @@ -58,32 +58,67 @@ type AutoOrganizeConfig = { enabled: string afterDownload: string scrapeAfter: string + downloadSmartClassify: string + smartClassify: string sourceDir: string targetDir: string transferMode: 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 = { enabled: 'false', afterDownload: 'false', scrapeAfter: 'true', + downloadSmartClassify: 'true', + smartClassify: 'true', sourceDir: '', targetDir: '', transferMode: 'hardlink', 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 = { enabled: 'organize.auto', afterDownload: 'organizer.auto_after_download', scrapeAfter: 'organize.scrape_after', + downloadSmartClassify: 'downloads.smart_classify', + smartClassify: 'organizer.smart_classify', sourceDir: 'organize.source_dir', targetDir: 'organize.target_dir', transferMode: 'organize.transfer_mode', 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 { const out: Record = {} 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, afterDownload: idx[AUTO_ORGANIZE_KEYS.afterDownload] ?? AUTO_ORGANIZE_DEFAULTS.afterDownload, 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, targetDir: idx[AUTO_ORGANIZE_KEYS.targetDir] ?? AUTO_ORGANIZE_DEFAULTS.targetDir, transferMode: idx[AUTO_ORGANIZE_KEYS.transferMode] ?? AUTO_ORGANIZE_DEFAULTS.transferMode, 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 [autoRunning, setAutoRunning] = useState(false) const [autoLoading, setAutoLoading] = useState(true) + const [autoTab, setAutoTab] = useState('basic') const currentDir = useMemo(() => { if (data?.path) return data.path @@ -213,6 +260,7 @@ export function FileManagerPage() { const changeAutoConfig = (key: keyof AutoOrganizeConfig, value: string) => { setAutoConfig((current) => ({ ...current, [key]: value })) + if (key === 'scrapeAfter') setScrapeAfter(settingOn(value)) setAutoDirty(true) } @@ -223,10 +271,10 @@ export function FileManagerPage() { await adminAPI.updateSetting(AUTO_ORGANIZE_KEYS[key], autoConfig[key] ?? '') } setAutoDirty(false) - toast.success('自动整理设置已保存') + toast.success('整理入库设置已保存') return true } 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 } finally { setAutoSaving(false) @@ -447,99 +495,234 @@ export function FileManagerPage() { -
- - - - -
- -
- - - - + {label} + + ))} + {autoDirty ? '有未保存设置' : '设置已同步'} · 定时任务名:organize_source
+ + {autoTab === 'basic' && ( + <> +
+ + + + +
+ +
+ + + + + +
+ + )} + + {autoTab === 'naming' && ( +
+ + + +

+ 可用占位符:{'{title}'} {'{year}'} {'{season}'} {'{season:02}'} {'{episode}'} {'{episode:02}'} {'{category}'}。扩展名会自动补齐。 +

+
+ )} + + {autoTab === 'scrape' && ( + <> +
+ + +
+
+ + + + +
+ + )} {data?.path && ( diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 42c68db..66ad957 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -1,13 +1,13 @@ 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 { adminAPI } from '../api/admin' import { libraryAPI } from '../api/library' import type { Library, Setting } from '../types' -// SettingsPage replaces the Vue SettingsView's General / Organize / -// Scrape / Adult tabs. The Go backend stores settings as a single +// SettingsPage replaces the Vue SettingsView's curated runtime settings. +// The Go backend stores settings as a single // key/value table; we group the most useful keys client-side and let // 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', label: '网盘转存', @@ -499,7 +360,7 @@ export function SettingsPage() {

系统设置

- 按分组编辑转码 / 整理 / 刮削 / 下载器等关键配置 + 按分组编辑转码 / 网盘转存 / Adult 等关键配置

@@ -554,44 +415,6 @@ export function SettingsPage() { )} - - {/* 整理 tab 时显示各媒体库默认路径 */} - {!loading && activeGroup === 'organize' && libraries.length > 0 && ( -
-
- - 默认整理路径参考(未设目的地目录时按媒体库归类) -
- - - - - - - - - - - {libraries.map((lib) => ( - - - - - - - ))} - -
媒体库类型路径整理后示例
{lib.name} - {lib.type === 'movie' ? '电影' : lib.type === 'tv' ? '电视剧' : lib.type === 'anime' ? '动漫' : '音乐'} - {lib.path} - {lib.type === 'movie' - ? `${lib.path}/片名 (2024)/片名 (2024).mkv` - : lib.type === 'tv' || lib.type === 'anime' - ? `${lib.path}/剧名/Season 01/剧名 - S01E01.mkv` - : lib.path} -
-
- )} ) } diff --git a/web/src/pages/SubscriptionsPage.tsx b/web/src/pages/SubscriptionsPage.tsx index 9a8fd50..4338104 100644 --- a/web/src/pages/SubscriptionsPage.tsx +++ b/web/src/pages/SubscriptionsPage.tsx @@ -1,6 +1,6 @@ import { FormEvent, useEffect, useState } from 'react' 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 { imageURL } from '../api/client' @@ -9,6 +9,7 @@ import type { Subscription } from '../types' export function SubscriptionsPage() { const [items, setItems] = useState([]) + const [historyItems, setHistoryItems] = useState([]) const [name, setName] = useState('') const [feed, setFeed] = useState('') const [filter, setFilter] = useState('') @@ -28,9 +29,11 @@ export function SubscriptionsPage() { const [loading, setLoading] = useState(true) const refresh = () => - subscriptionsAPI - .list() - .then(setItems) + Promise.all([subscriptionsAPI.list(), subscriptionsAPI.history()]) + .then(([active, history]) => { + setItems(active) + setHistoryItems(history) + }) .finally(() => setLoading(false)) useEffect(() => { @@ -311,6 +314,43 @@ export function SubscriptionsPage() { ))} )} + + {historyItems.length > 0 && ( +
+
+ +

订阅历史

+ {historyItems.length} 条 +
+
+ {historyItems.map((subscription) => ( +
+
+
+ {subscription.poster_url ? ( + {subscription.name} + ) : ( +
+ +
+ )} +
+
+

{subscription.name}

+

+ {subscription.archive_reason || '订阅已完成'} +

+

+ {subscription.archived_at ? new Date(subscription.archived_at).toLocaleString() : '完成时间未知'} +

+

{subscriptionProgressLabel(subscription)}

+
+
+
+ ))} +
+
+ )} ) } diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 1bd6876..d1712ad 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -206,6 +206,8 @@ export interface Subscription { priority?: number enabled: boolean last_run_at?: string + archived_at?: string + archive_reason?: string created_at: string updated_at: string }