diff --git a/internal/config/config.go b/internal/config/config.go index faa05d2..1c6f862 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -250,6 +250,7 @@ func setDefaults(v *viper.Viper) { v.SetDefault("downloads.smart_classify", true) v.SetDefault("organizer.smart_classify", false) v.SetDefault("organizer.auto_after_download", false) + v.SetDefault("organize.scrape_after", false) v.SetDefault("organizer.categories.chinese_movie", "华语电影") v.SetDefault("organizer.categories.animation_movie", "动画电影") v.SetDefault("organizer.categories.foreign_movie", "外语电影") diff --git a/internal/handler/downloads_extra.go b/internal/handler/downloads_extra.go index bab3c83..e9810d7 100644 --- a/internal/handler/downloads_extra.go +++ b/internal/handler/downloads_extra.go @@ -85,6 +85,9 @@ func downloadOrganizeAllHandler(svc *service.Container) gin.HandlerFunc { 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}) } c.JSON(http.StatusOK, gin.H{"results": results}) diff --git a/internal/handler/media_favorite.go b/internal/handler/media_favorite.go index 34083f4..3d29fde 100644 --- a/internal/handler/media_favorite.go +++ b/internal/handler/media_favorite.go @@ -134,6 +134,9 @@ func organizeBulkHandler(svc *service.Container) gin.HandlerFunc { 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}) } c.JSON(http.StatusOK, gin.H{"results": out}) diff --git a/internal/handler/organizer.go b/internal/handler/organizer.go index 8e871a5..38ff239 100644 --- a/internal/handler/organizer.go +++ b/internal/handler/organizer.go @@ -21,6 +21,7 @@ type organizeReq struct { TransferMode string `json:"transfer_mode"` MediaType string `json:"media_type"` ScanAfter bool `json:"scan_after"` + ScrapeAfter *bool `json:"scrape_after"` LibraryID string `json:"library_id"` DryRun bool `json:"dry_run"` } @@ -52,24 +53,37 @@ func organizeOptionsFromReq(req organizeReq) service.OrganizeOptions { func organizeMediaHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - opts := bindOrganizeOptions(c) + var req organizeReq + _ = c.ShouldBindJSON(&req) + opts := organizeOptionsFromReq(req) dst, err := svc.Organizer.OrganizeMediaWithOptions(c.Request.Context(), c.Param("id"), opts) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"path": dst}) + payload := gin.H{"path": dst} + if req.ScanAfter && !req.DryRun && svc.Scan != nil { + scans, scrapes := scanAndScrapeAfterOrganize(c, svc, dst, strings.TrimSpace(req.LibraryID), req.ScrapeAfter) + payload["scans"] = scans + payload["scrapes"] = scrapes + } + c.JSON(http.StatusOK, payload) } } func organizeLibraryHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - opts := bindOrganizeOptions(c) + var req organizeReq + _ = c.ShouldBindJSON(&req) + opts := organizeOptionsFromReq(req) res, err := svc.Organizer.OrganizeLibraryWithOptions(c.Request.Context(), c.Param("id"), opts) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + if req.ScanAfter && !req.DryRun && svc.Scan != nil { + res.Scans, res.Scrapes = scanAndScrapeAfterOrganize(c, svc, res.DestPath, c.Param("id"), req.ScrapeAfter) + } c.JSON(http.StatusOK, res) } } @@ -95,8 +109,16 @@ func organizeDirectoryHandler(svc *service.Container) gin.HandlerFunc { return } if req.ScanAfter && !req.DryRun && svc.Scan != nil { - res.Scans = svc.Scan.ScanLibrariesForPath(c.Request.Context(), res.DestPath, strings.TrimSpace(req.LibraryID)) + res.Scans, res.Scrapes = scanAndScrapeAfterOrganize(c, svc, res.DestPath, strings.TrimSpace(req.LibraryID), req.ScrapeAfter) } c.JSON(http.StatusOK, res) } } + +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) +} diff --git a/internal/handler/system_extra.go b/internal/handler/system_extra.go index b97ebf5..849ca88 100644 --- a/internal/handler/system_extra.go +++ b/internal/handler/system_extra.go @@ -80,6 +80,7 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc { "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": "整理源目录"}, diff --git a/internal/service/downloads.go b/internal/service/downloads.go index aff6e96..b7dc8b7 100644 --- a/internal/service/downloads.go +++ b/internal/service/downloads.go @@ -881,7 +881,7 @@ func (d *DownloadService) onTorrentComplete(ctx context.Context, torrent QBitTor return } if d.scanner != nil && res != nil && strings.TrimSpace(res.DestPath) != "" { - res.Scans = d.scanner.ScanLibrariesForPath(ctx, res.DestPath, "") + res.Scans, res.Scrapes = d.scanner.ScanAndScrapeLibrariesForPath(ctx, res.DestPath, "", OrganizeScrapeAfterEnabled(ctx, d.repo)) } d.log.Info("auto organize completed torrent finished", zap.String("hash", torrent.Hash), @@ -890,6 +890,7 @@ func (d *DownloadService) onTorrentComplete(ctx context.Context, torrent QBitTor 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))) } diff --git a/internal/service/media_classifier.go b/internal/service/media_classifier.go index 1994fde..b6de33b 100644 --- a/internal/service/media_classifier.go +++ b/internal/service/media_classifier.go @@ -31,20 +31,30 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin genres := normalizeTokens(input.Genres...) countries := normalizeTokens(input.Countries...) languages := normalizeTokens(input.Languages...) - text := strings.ToLower(input.Title + " " + input.Category + " " + strings.Join(input.Genres, " ")) + rawText := input.Title + " " + input.Category + " " + strings.Join(input.Genres, " ") + text := strings.ToLower(rawText) - isChinese := hasAny(languages, "ZH", "ZH-CN", "ZH-TW", "CN") || hasAny(countries, "CN", "TW", "HK", "MO") - isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || strings.Contains(text, "日番") - isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP") + isChinese := hasAny(languages, "ZH", "ZH-CN", "ZH-TW", "CN") || hasAny(countries, "CN", "TW", "HK", "MO") || containsHan(rawText) || containsAnyText(text, "华语", "国产", "国剧", "国漫") + isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || containsJapaneseKana(rawText) || strings.Contains(text, "日番") + isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP") || containsKoreanHangul(rawText) isEastAsian := isJapanese || isKorean || hasAny(countries, "TH", "IN", "SG") - isWestern := hasAny(countries, + isWesternByMetadata := hasAny(countries, "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) + hasAnimeText := containsAnyText(text, "动画", "动漫", "番剧", "年番", "国漫", "日番", "bangumi", "anime") hasGenre := func(values ...string) bool { for _, value := range values { - if hasAny(genres, strings.ToUpper(value)) || strings.Contains(text, strings.ToLower(value)) { + if hasAny(genres, strings.ToUpper(value)) { + return true + } + if isDigits(value) { + continue + } + if strings.Contains(text, strings.ToLower(value)) { return true } } @@ -62,7 +72,7 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin if isEastAsian { return categoryName(categories, "jk_movie", "日韩电影") } - if isWestern { + if isWesternByMetadata { return categoryName(categories, "euus_movie", "欧美电影") } return categoryName(categories, "foreign_movie", "外语电影") @@ -83,7 +93,7 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin if hasGenre("10762", "KIDS", "儿童") { return categoryName(categories, "children", "儿童") } - if hasGenre("16", "ANIMATION", "动画", "动漫") { + if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText { if isChinese { return categoryName(categories, "cn_anime", "国漫") } @@ -99,6 +109,8 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin return categoryName(categories, "euus_tv", "欧美剧") } return categoryName(categories, "uncategorized_tv", "未分类") + case "adult": + return categoryName(categories, "adult", "成人") } return "" } @@ -158,6 +170,63 @@ func hasAny(values map[string]struct{}, needles ...string) bool { return false } +func containsAnyText(text string, needles ...string) bool { + for _, needle := range needles { + if strings.Contains(text, strings.ToLower(needle)) { + return true + } + } + return false +} + +func containsHan(text string) bool { + for _, r := range text { + if r >= '\u4e00' && r <= '\u9fff' { + return true + } + } + return false +} + +func containsJapaneseKana(text string) bool { + for _, r := range text { + if (r >= '\u3040' && r <= '\u30ff') || (r >= '\u31f0' && r <= '\u31ff') { + return true + } + } + return false +} + +func containsKoreanHangul(text string) bool { + for _, r := range text { + if (r >= '\uac00' && r <= '\ud7af') || (r >= '\u1100' && r <= '\u11ff') || (r >= '\u3130' && r <= '\u318f') { + return true + } + } + return false +} + +func containsLatin(text string) bool { + for _, r := range text { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { + return true + } + } + return false +} + +func isDigits(text string) bool { + if text == "" { + return false + } + for _, r := range text { + if r < '0' || r > '9' { + return false + } + } + return true +} + func categoryName(categories map[string]string, key, fallback string) string { if categories != nil { if name := strings.TrimSpace(categories[key]); name != "" { diff --git a/internal/service/media_classifier_test.go b/internal/service/media_classifier_test.go index d301546..aac4d21 100644 --- a/internal/service/media_classifier_test.go +++ b/internal/service/media_classifier_test.go @@ -57,6 +57,38 @@ func TestClassifyMediaCategoryMatchesMoviePilotStyleRules(t *testing.T) { }, want: "纪录片", }, + { + name: "chinese movie title without metadata", + input: mediaClassifyInput{ + MediaType: "movie", + Title: "流浪地球2 2023 2160p", + }, + want: "华语电影", + }, + { + name: "latin movie title without metadata", + input: mediaClassifyInput{ + MediaType: "movie", + Title: "Dune 2021 2160p", + }, + want: "外语电影", + }, + { + name: "chinese tv title without metadata", + input: mediaClassifyInput{ + MediaType: "tv", + Title: "狂飙 S01E01 1080p", + }, + want: "国产剧", + }, + { + name: "latin tv title without metadata", + input: mediaClassifyInput{ + MediaType: "tv", + Title: "The Last of Us S01E01 1080p", + }, + want: "欧美剧", + }, } for _, tt := range tests { diff --git a/internal/service/organizer.go b/internal/service/organizer.go index 05bff60..3e426d0 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -49,15 +49,16 @@ func (o *OrganizerService) SetProbe(p *FFprobeService) { o.probe = p } // OrganizeResult reports what happened. type OrganizeResult struct { - Organized int `json:"organized"` - Skipped int `json:"skipped"` - Replaced int `json:"replaced,omitempty"` - Errors []string `json:"errors,omitempty"` - SourcePath string `json:"source_path,omitempty"` - DestPath string `json:"dest_path,omitempty"` - DryRun bool `json:"dry_run,omitempty"` - Items []OrganizePreviewItem `json:"items,omitempty"` - Scans []OrganizeScanSummary `json:"scans,omitempty"` + Organized int `json:"organized"` + Skipped int `json:"skipped"` + Replaced int `json:"replaced,omitempty"` + Errors []string `json:"errors,omitempty"` + SourcePath string `json:"source_path,omitempty"` + DestPath string `json:"dest_path,omitempty"` + DryRun bool `json:"dry_run,omitempty"` + Items []OrganizePreviewItem `json:"items,omitempty"` + Scans []OrganizeScanSummary `json:"scans,omitempty"` + Scrapes []OrganizeScrapeSummary `json:"scrapes,omitempty"` } type OrganizePreviewItem struct { @@ -288,7 +289,7 @@ func (o *OrganizerService) OrganizeLibraryWithOptions(ctx context.Context, libra sourceRoot := o.resolveSourceRoot(ctx, lib, opts.SourcePath) // 目的地目录:已位于该根下的文件视为已整理;受 dest_path 覆盖与设置影响。 baseRoot := o.resolveBaseRoot(ctx, lib, opts.DestPath) - res := &OrganizeResult{} + res := &OrganizeResult{SourcePath: sourceRoot, DestPath: baseRoot, DryRun: opts.DryRun} for i := range rows { // 不在源目录内的文件跳过(不属于本次「从源目录整理」的范围)。 if !pathWithin(rows[i].Path, sourceRoot) { diff --git a/internal/service/organizer_directory.go b/internal/service/organizer_directory.go index 50f69cd..1c70e22 100644 --- a/internal/service/organizer_directory.go +++ b/internal/service/organizer_directory.go @@ -201,6 +201,9 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo if layout.MediaType == "" { layout.MediaType = o.inferMediaTypeForSourceFile(src, title, season, episode) } + if layout.Category == "" { + layout.Category = o.smartClassifySourceFile(ctx, src, sourceRoot, layout.MediaType, title, parsedTitle) + } layoutRoot := destRoot if layout.MediaType != "" { layoutRoot = o.organizeRoot(destRoot, layout.MediaType, layout.Category) @@ -338,6 +341,28 @@ func (o *OrganizerService) inferMediaTypeForSourceFile(src, title string, season return normalizeMediaType("", title, src) } +func (o *OrganizerService) smartClassifySourceFile(ctx context.Context, src, sourceRoot, mediaType, title, parsedTitle string) string { + if o == nil || !o.isSmartClassifyEnabled(ctx) { + return "" + } + seriesLike := isSeriesLibraryType(mediaType) + input := mediaClassifyInput{ + MediaType: mediaType, + Title: strings.Join([]string{title, parsedTitle, filepath.Base(src)}, " "), + Category: strings.Join(organizeDirectoryCategoryCandidates(src, sourceRoot), " "), + } + 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) + input.Countries = parseCommaList(meta.Countries) + input.Genres = parseCommaList(meta.Genres) + if meta.NSFW { + input.MediaType = "adult" + } + } + return sanitizeFilename(classifyMediaCategory(input, o.categoryMap())) +} + func (o *OrganizerService) inferOrganizeDirectoryLayout(src, sourceRoot string) organizeDirectoryLayout { for _, name := range organizeDirectoryCategoryCandidates(src, sourceRoot) { if mediaType, category := o.mediaTypeForDirectoryCategory(name); mediaType != "" && category != "" { diff --git a/internal/service/organizer_directory_test.go b/internal/service/organizer_directory_test.go index a018c05..376e5cf 100644 --- a/internal/service/organizer_directory_test.go +++ b/internal/service/organizer_directory_test.go @@ -427,6 +427,78 @@ func TestOrganizeDirectoryUsesDownloadCategoryLayout(t *testing.T) { } } +func TestOrganizeDirectorySmartClassifiesUncategorizedSources(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "downloads") + dest := filepath.Join(root, "media") + writeOrgFile(t, filepath.Join(src, "流浪地球2.2023.2160p.WEB-DL.mkv"), "cn-movie") + writeOrgFile(t, filepath.Join(src, "Dune.2021.2160p.WEB-DL.mkv"), "foreign-movie") + writeOrgFile(t, filepath.Join(src, "狂飙.S01E01.2023.1080p.WEB-DL.mkv"), "cn-tv") + writeOrgFile(t, filepath.Join(src, "The.Last.of.Us.S01E01.2023.1080p.WEB-DL.mkv"), "western-tv") + + repos := newOrganizerTestRepo(t) + if err := repos.Setting.Set(t.Context(), "organizer.smart_classify", "true"); err != nil { + t.Fatal(err) + } + org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) + res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: src, + DestPath: dest, + TransferMode: TransferCopy, + }) + if err != nil { + t.Fatalf("organize directory: %v", err) + } + if res.Organized != 4 { + t.Fatalf("organized = %d, want 4; result=%+v", res.Organized, res) + } + + for _, want := range []string{ + 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"), + } { + if _, err := os.Stat(want); err != nil { + t.Fatalf("expected smart classified file at %q: %v; items=%+v", want, err, res.Items) + } + } +} + +func TestOrganizeDirectorySmartClassifiesWithLocalNFO(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "downloads") + dest := filepath.Join(root, "media") + writeOrgFile(t, filepath.Join(src, "Some.Show.S01E01.2024.1080p.mkv"), "jp-anime") + writeOrgFile(t, filepath.Join(src, "tvshow.nfo"), ` + Some Show + Animation + JP + ja +`) + + repos := newOrganizerTestRepo(t) + if err := repos.Setting.Set(t.Context(), "organizer.smart_classify", "true"); err != nil { + t.Fatal(err) + } + org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) + res, err := org.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: src, + DestPath: dest, + TransferMode: TransferCopy, + }) + if err != nil { + t.Fatalf("organize directory: %v", err) + } + if res.Organized != 1 { + t.Fatalf("organized = %d, want 1", res.Organized) + } + want := filepath.Join(dest, "电视剧", "日番", "Some Show", "Season 01", "Some Show - S01E01.mkv") + if _, err := os.Stat(want); err != nil { + t.Fatalf("expected NFO classified episode at %q: %v", want, err) + } +} + func TestOrganizeDirectoryScanAfterRecursesNestedDownloadFolders(t *testing.T) { root := t.TempDir() src := filepath.Join(root, "downloads") diff --git a/internal/service/organizer_scan.go b/internal/service/organizer_scan.go index 62ecd8a..360029f 100644 --- a/internal/service/organizer_scan.go +++ b/internal/service/organizer_scan.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" ) // OrganizeScanSummary reports a library scan triggered after directory @@ -20,18 +21,53 @@ type OrganizeScanSummary struct { Error string `json:"error,omitempty"` } +// OrganizeScrapeSummary reports metadata enrichment triggered after organize. +type OrganizeScrapeSummary struct { + LibraryID string `json:"library_id"` + Name string `json:"name"` + Path string `json:"path"` + Matched int `json:"matched"` + Skipped bool `json:"skipped,omitempty"` + Reason string `json:"reason,omitempty"` + Error string `json:"error,omitempty"` +} + +// OrganizeScrapeAfterEnabled decides whether organize workflows should run +// metadata scraping after scanning organized files into libraries. +func OrganizeScrapeAfterEnabled(ctx context.Context, repo *repository.Container) bool { + if repo == nil || repo.Setting == nil { + return false + } + if value, err := repo.Setting.Get(ctx, "organize.scrape_after"); err == nil && strings.TrimSpace(value) != "" { + return parseBoolSetting(value, false) + } + if value, err := repo.Setting.Get(ctx, "scrape.auto_on_scan"); err == nil && strings.TrimSpace(value) != "" { + return parseBoolSetting(value, false) + } + return false +} + // ScanLibrariesForPath recursively scans libraries affected by an organize // destination. If preferredLibraryID is set, only that library is scanned. // Otherwise every enabled library whose path intersects destRoot is scanned; // if no path can be matched, we fall back to all enabled libraries to preserve // the old "scan all after ingest" UI behavior. func (s *ScannerService) ScanLibrariesForPath(ctx context.Context, destRoot, preferredLibraryID string) []OrganizeScanSummary { + scans, _ := s.ScanAndScrapeLibrariesForPath(ctx, destRoot, preferredLibraryID, false) + return scans +} + +// ScanAndScrapeLibrariesForPath scans the affected organize target libraries, +// then optionally runs the scraper synchronously so manual/automatic organize +// can provide deterministic "整理 + 入库 + 刮削" behavior instead of relying on +// a background scan hook that may or may not be enabled. +func (s *ScannerService) ScanAndScrapeLibrariesForPath(ctx context.Context, destRoot, preferredLibraryID string, scrapeAfter bool) ([]OrganizeScanSummary, []OrganizeScrapeSummary) { if s == nil || s.repo == nil || s.repo.Library == nil { - return nil + return nil, nil } libraries, err := s.repo.Library.List(ctx) if err != nil { - return []OrganizeScanSummary{{Error: err.Error()}} + return []OrganizeScanSummary{{Error: err.Error()}}, nil } targets := selectOrganizeScanTargets(libraries, destRoot, preferredLibraryID) out := make([]OrganizeScanSummary, 0, len(targets)) @@ -41,7 +77,7 @@ func (s *ScannerService) ScanLibrariesForPath(ctx context.Context, destRoot, pre Name: lib.Name, Path: lib.Path, } - res, err := s.ScanLibrary(ctx, lib.ID) + res, err := s.scanLibrary(ctx, lib.ID, !scrapeAfter) if err != nil { summary.Error = err.Error() out = append(out, summary) @@ -53,6 +89,52 @@ func (s *ScannerService) ScanLibrariesForPath(ctx context.Context, destRoot, pre summary.Removed = res.Removed out = append(out, summary) } + return out, s.scrapeOrganizeTargets(ctx, targets, scrapeAfter) +} + +func (s *ScannerService) scrapeOrganizeTargets(ctx context.Context, targets []model.Library, scrapeAfter bool) []OrganizeScrapeSummary { + if len(targets) == 0 || !scrapeAfter { + return nil + } + out := make([]OrganizeScrapeSummary, 0, len(targets)) + if s.scraper == nil { + for _, lib := range targets { + out = append(out, OrganizeScrapeSummary{ + LibraryID: lib.ID, + Name: lib.Name, + Path: lib.Path, + Skipped: true, + Reason: "scraper unavailable", + }) + } + return out + } + if !s.scraper.AnyEnabled() { + for _, lib := range targets { + out = append(out, OrganizeScrapeSummary{ + LibraryID: lib.ID, + Name: lib.Name, + Path: lib.Path, + Skipped: true, + Reason: "no scraper provider enabled", + }) + } + return out + } + for _, lib := range targets { + summary := OrganizeScrapeSummary{ + LibraryID: lib.ID, + Name: lib.Name, + Path: lib.Path, + } + matched, err := s.scraper.EnrichLibrary(ctx, lib.ID) + if err != nil { + summary.Error = err.Error() + } else { + summary.Matched = matched + } + out = append(out, summary) + } return out } diff --git a/internal/service/organizer_scrape_test.go b/internal/service/organizer_scrape_test.go new file mode 100644 index 0000000..07a852c --- /dev/null +++ b/internal/service/organizer_scrape_test.go @@ -0,0 +1,70 @@ +package service + +import ( + "os" + "path/filepath" + "testing" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func TestOrganizeDirectoryScanAndScrapeAfter(t *testing.T) { + scraper, repos, closeServer := newTestScraper(t) + defer closeServer() + if err := repos.DB.AutoMigrate(&model.Setting{}); err != nil { + t.Fatal(err) + } + + root := t.TempDir() + src := filepath.Join(root, "downloads") + dest := filepath.Join(root, "media") + sourceFile := filepath.Join(src, "Spy.x.Family.S01E01.2022.1080p.mkv") + writeOrgFile(t, sourceFile, "episode") + + lib := model.Library{ + Name: "剧集", + Path: filepath.Join(dest, "电视剧"), + Type: "tv", + Enabled: true, + } + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + + organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) + res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: src, + DestPath: dest, + TransferMode: TransferCopy, + MediaType: "tv", + }) + if err != nil { + t.Fatalf("organize directory: %v", err) + } + if res.Organized != 1 { + t.Fatalf("organized = %d, want 1", res.Organized) + } + + scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, scraper) + scans, scrapes := scanner.ScanAndScrapeLibrariesForPath(t.Context(), res.DestPath, "", true) + if len(scans) != 1 || scans[0].Added != 1 { + t.Fatalf("scans = %#v, want one scan with added=1", scans) + } + if len(scrapes) != 1 || scrapes[0].Matched != 1 || scrapes[0].Error != "" || scrapes[0].Skipped { + t.Fatalf("scrapes = %#v, want one successful matched scrape", scrapes) + } + + var media model.Media + if err := repos.DB.Where("path LIKE ?", "%Spy Family - S01E01.mkv").First(&media).Error; err != nil { + t.Fatal(err) + } + if media.ScrapeStatus != "matched" || media.TMDbID != 12345 { + t.Fatalf("media scrape status=%q tmdb=%d, want matched/12345", media.ScrapeStatus, media.TMDbID) + } + if _, err := os.Stat(media.Path); err != nil { + t.Fatalf("organized file missing at %q: %v", media.Path, err) + } +} diff --git a/internal/service/scanner.go b/internal/service/scanner.go index 3842004..f52bdf4 100644 --- a/internal/service/scanner.go +++ b/internal/service/scanner.go @@ -80,6 +80,10 @@ type ScanResult struct { // ScanLibrary walks the library root and persists discovered media files. func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*ScanResult, error) { + return s.scanLibrary(ctx, libraryID, true) +} + +func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, autoScrape bool) (*ScanResult, error) { lib, err := s.repo.Library.FindByID(ctx, libraryID) if err != nil || lib == nil { return nil, err @@ -124,7 +128,7 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc // Online enrichment is opt-in. Local NFO is always consumed first during // the scan, and matched rows are excluded from EnrichLibrary's pending set. - if s.scraper != nil && s.scraper.AnyEnabled() && s.autoScrapeEnabled(ctx) { + if autoScrape && s.scraper != nil && s.scraper.AnyEnabled() && s.autoScrapeEnabled(ctx) { go func(libID string) { if _, err := s.scraper.EnrichLibrary(context.Background(), libID); err != nil { s.log.Warn("scraper enrich failed", zap.Error(err)) diff --git a/internal/service/scheduler.go b/internal/service/scheduler.go index db5c161..7890a49 100644 --- a/internal/service/scheduler.go +++ b/internal/service/scheduler.go @@ -255,7 +255,7 @@ func (s *SchedulerService) jobOrganizeSource(ctx context.Context) error { return err } if s.scanner != nil && res != nil && strings.TrimSpace(res.DestPath) != "" { - res.Scans = s.scanner.ScanLibrariesForPath(ctx, res.DestPath, "") + res.Scans, res.Scrapes = s.scanner.ScanAndScrapeLibrariesForPath(ctx, res.DestPath, "", OrganizeScrapeAfterEnabled(ctx, s.repo)) } if s.log != nil && res != nil { s.log.Info("scheduled source organize finished", @@ -264,6 +264,7 @@ func (s *SchedulerService) jobOrganizeSource(ctx context.Context) error { 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)), ) } diff --git a/web/src/api/tools.ts b/web/src/api/tools.ts index fe863ca..297e83e 100644 --- a/web/src/api/tools.ts +++ b/web/src/api/tools.ts @@ -12,6 +12,7 @@ export interface OrganizeOverrides { transfer_mode?: string media_type?: string scan_after?: boolean + scrape_after?: boolean library_id?: string dry_run?: boolean } @@ -76,6 +77,15 @@ export const toolsAPI = { removed: number error?: string }> + scrapes?: Array<{ + library_id: string + name: string + path: string + matched: number + skipped?: boolean + reason?: string + error?: string + }> }>( '/admin/organize/source', opts, diff --git a/web/src/pages/FileManagerPage.tsx b/web/src/pages/FileManagerPage.tsx index fc94efe..cfc2ded 100644 --- a/web/src/pages/FileManagerPage.tsx +++ b/web/src/pages/FileManagerPage.tsx @@ -45,9 +45,19 @@ function formatScanSummary(scans: Array<{ name: string; added: number; updated: return ` · 扫描 ${ok.length}/${scans.length} 个库 · 新入库 ${added} · 更新 ${updated} · 访问 ${visited}` } +function formatScrapeSummary(scrapes: Array<{ name: string; matched: number; skipped?: boolean; reason?: string; error?: string }>): string { + if (scrapes.length === 0) return '' + const ok = scrapes.filter((scrape) => !scrape.error && !scrape.skipped) + const skipped = scrapes.filter((scrape) => scrape.skipped).length + const matched = ok.reduce((sum, scrape) => sum + (scrape.matched ?? 0), 0) + if (ok.length === 0 && skipped > 0) return ` · 刮削跳过 ${skipped} 个库` + return ` · 刮削 ${ok.length}/${scrapes.length} 个库 · 匹配 ${matched}${skipped ? ` · 跳过 ${skipped}` : ''}` +} + type AutoOrganizeConfig = { enabled: string afterDownload: string + scrapeAfter: string sourceDir: string targetDir: string transferMode: string @@ -57,6 +67,7 @@ type AutoOrganizeConfig = { const AUTO_ORGANIZE_DEFAULTS: AutoOrganizeConfig = { enabled: 'false', afterDownload: 'false', + scrapeAfter: 'false', sourceDir: '', targetDir: '', transferMode: 'hardlink', @@ -66,6 +77,7 @@ const AUTO_ORGANIZE_DEFAULTS: AutoOrganizeConfig = { const AUTO_ORGANIZE_KEYS: Record = { enabled: 'organize.auto', afterDownload: 'organizer.auto_after_download', + scrapeAfter: 'organize.scrape_after', sourceDir: 'organize.source_dir', targetDir: 'organize.target_dir', transferMode: 'organize.transfer_mode', @@ -83,6 +95,7 @@ function mergeAutoOrganizeSettings(rows: Setting[]): AutoOrganizeConfig { return { 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, 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, @@ -114,6 +127,7 @@ export function FileManagerPage() { const [organizeTransferMode, setOrganizeTransferMode] = useState('hardlink') const [organizeMediaType, setOrganizeMediaType] = useState('auto') const [scanAfter, setScanAfter] = useState(true) + const [scrapeAfter, setScrapeAfter] = useState(false) const [organizeBusy, setOrganizeBusy] = useState('') const [previewItems, setPreviewItems] = useState { - setAutoConfig(mergeAutoOrganizeSettings(rows)) + const nextConfig = mergeAutoOrganizeSettings(rows) + setAutoConfig(nextConfig) + setScrapeAfter(settingOn(nextConfig.scrapeAfter)) setAutoDirty(false) }) .catch(() => undefined) @@ -309,7 +325,7 @@ export function FileManagerPage() { if (!dryRun) { const ok = await confirmAction({ title: '确认整理入库', - message: `来源:${organizeSource}\n目标:${organizeDestPath}\n方式:${organizeTransferMode}${scanAfter ? '\n整理完成后会扫描入库。' : ''}`, + message: `来源:${organizeSource}\n目标:${organizeDestPath}\n方式:${organizeTransferMode}${scanAfter ? '\n整理完成后会扫描入库。' : ''}${scanAfter && scrapeAfter ? '\n扫描后会自动刮削。' : ''}`, confirmText: '开始整理', }) if (!ok) return @@ -322,6 +338,7 @@ export function FileManagerPage() { transfer_mode: organizeTransferMode, media_type: organizeMediaType === 'auto' ? undefined : organizeMediaType, scan_after: !dryRun && scanAfter, + scrape_after: !dryRun && scanAfter && scrapeAfter, library_id: !dryRun && scanAfter && organizeLibraryID ? organizeLibraryID : undefined, dry_run: dryRun, }) @@ -340,7 +357,8 @@ export function FileManagerPage() { return } const scanText = scanAfter ? formatScanSummary(result.scans ?? []) : '' - toast.success(`整理完成:新增 ${result.organized} · 替换 ${replaced} · 跳过 ${result.skipped}${scanText}`) + const scrapeText = scanAfter && scrapeAfter ? formatScrapeSummary(result.scrapes ?? []) : '' + toast.success(`整理完成:新增 ${result.organized} · 替换 ${replaced} · 跳过 ${result.skipped}${scanText}${scrapeText}`) refresh() } catch (err: unknown) { toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '整理失败') @@ -498,6 +516,14 @@ export function FileManagerPage() { /> qB 下载完成后自动整理 + {autoDirty ? '有未保存设置' : '设置已同步'} · 定时任务名:organize_source @@ -568,6 +594,15 @@ export function FileManagerPage() { setScanAfter(event.target.checked)} /> 整理后扫描入库 +