From 5c6fe917421c5fabd9b685e527ce7448d98912c6 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:16:12 +0800 Subject: [PATCH] fix(organize): repair classification and forced rescrape --- internal/service/cloud_path_repair_test.go | 64 ++++++++++++++ internal/service/cloud_path_rescrape.go | 13 ++- internal/service/media_classifier.go | 37 +++++--- internal/service/media_classifier_test.go | 87 +++++++++++++++++++ .../service/organizer_classification_test.go | 18 ++++ internal/service/organizer_classify.go | 32 +++++-- .../organizer_directory_source_plan.go | 4 +- .../organizer_library_classification.go | 21 +++++ internal/service/organizer_media.go | 56 +++++++++++- .../service/organizer_reclassify_cloud.go | 2 +- .../service/organizer_reclassify_scanned.go | 2 +- internal/service/organizer_scrape_test.go | 40 +++++++++ internal/service/scraper.go | 22 ++++- internal/service/scraper_options.go | 1 + internal/service/scraper_test.go | 41 +++++++++ internal/service/scraper_test_helpers_test.go | 10 +++ 16 files changed, 416 insertions(+), 34 deletions(-) diff --git a/internal/service/cloud_path_repair_test.go b/internal/service/cloud_path_repair_test.go index 7ede847..9d9aae7 100644 --- a/internal/service/cloud_path_repair_test.go +++ b/internal/service/cloud_path_repair_test.go @@ -1,14 +1,75 @@ package service import ( + "os" + "path/filepath" "testing" "go.uber.org/zap" + "github.com/ShukeBta/MediaStationGo/internal/config" "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/repository" ) +func TestRepairAndRescrapeLibraryForceRematchesThenReclassifies(t *testing.T) { + scraper, repos, closeServer := newTestScraper(t) + defer closeServer() + + root := t.TempDir() + wrongRoot := filepath.Join(root, "media", "电视剧", "国产剧") + mediaPath := filepath.Join(wrongRoot, "Spy Family", "Season 01", "Spy Family - S01E01.mkv") + if err := os.MkdirAll(filepath.Dir(mediaPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(mediaPath, []byte("episode"), 0o644); err != nil { + t.Fatal(err) + } + lib := model.Library{Name: "国产剧", Path: wrongRoot, Type: "tv", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + media := model.Media{ + LibraryID: lib.ID, + Title: "错误旧匹配", + Path: mediaPath, + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 999, + Countries: "CN", + Languages: "zh", + Genres: "Drama", + ScrapeStatus: "matched", + } + if err := repos.DB.Create(&media).Error; err != nil { + t.Fatal(err) + } + + cfg := &config.Config{} + cfg.Organizer.SmartClassify = true + organizer := NewOrganizerService(cfg, zap.NewNop(), repos) + organizer.SetScraper(scraper) + container := &Container{Cfg: cfg, Log: zap.NewNop(), Repo: repos, Scraper: scraper, Organizer: organizer} + result, err := container.RepairAndRescrapeLibrary(t.Context(), lib.ID) + if err != nil { + t.Fatalf("repair and rescrape: %v", err) + } + if result.Reclassified != 1 { + t.Fatalf("result=%+v, want one corrected classification", result) + } + want := filepath.Join(root, "media", "动漫", "日番", "间谍过家家", "Season 01", "间谍过家家 - S01E01.mkv") + if _, err := os.Stat(want); err != nil { + t.Fatalf("corrected media missing at %q: %v", want, err) + } + var got model.Media + if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil { + t.Fatal(err) + } + if got.TMDbID != 12345 || got.Countries != "JP" || got.Path != want { + t.Fatalf("repaired media=%#v, want rematched Japanese anime at corrected path", got) + } +} + func TestRepairRescrapeOptionsDefaultSkipsEpisodeArtwork(t *testing.T) { options := repairRescrapeOptions() if !options.RetryNoMatch { @@ -17,6 +78,9 @@ func TestRepairRescrapeOptionsDefaultSkipsEpisodeArtwork(t *testing.T) { if !options.IncludeMatched { t.Fatal("repair rescrape should refresh already matched rows") } + if !options.ForceRematch { + t.Fatal("repair rescrape should ignore stale external IDs and rematch by path/title") + } if options.EpisodeArtwork == nil { t.Fatal("repair rescrape should set an explicit episode artwork option") } diff --git a/internal/service/cloud_path_rescrape.go b/internal/service/cloud_path_rescrape.go index 6c601ba..0aacb0a 100644 --- a/internal/service/cloud_path_rescrape.go +++ b/internal/service/cloud_path_rescrape.go @@ -59,11 +59,12 @@ func (c *Container) resetEpisodicMatchedForRescrape(ctx context.Context, library // 行的 scrape_status 重置为 pending),随后逐个媒体库重刮(含 no_match 重试), // 让此前因空 ID / 脏 ID 无法刮削的媒体重新匹配到正确数据。 func repairRescrapeOptions(values ...ScrapeOptions) ScrapeOptions { - options := ScrapeOptions{RetryNoMatch: true, IncludeMatched: true} + options := ScrapeOptions{RetryNoMatch: true, IncludeMatched: true, ForceRematch: true} if len(values) > 0 { options = values[0] options.RetryNoMatch = true options.IncludeMatched = true + options.ForceRematch = true } if options.EpisodeArtwork == nil { episodeArtwork := false @@ -130,6 +131,7 @@ func (c *Container) RepairAndRescrapeAllLibraries(ctx context.Context, options . if reclassifyResult != nil { result.Reclassified = reclassifyResult.Reclassified result.Errors += len(reclassifyResult.Errors) + c.invalidateRepairReclassifyCache(ctx, reclassifyResult.Reclassified) } } if c.Log != nil { @@ -191,6 +193,7 @@ func (c *Container) RepairAndRescrapeLibrary(ctx context.Context, libraryID stri if reclassifyResult != nil { result.Reclassified = reclassifyResult.Reclassified result.Errors += len(reclassifyResult.Errors) + c.invalidateRepairReclassifyCache(ctx, reclassifyResult.Reclassified) } } if c.Log != nil { @@ -204,3 +207,11 @@ func (c *Container) RepairAndRescrapeLibrary(ctx context.Context, libraryID stri } return result, nil } + +func (c *Container) invalidateRepairReclassifyCache(ctx context.Context, changed int) { + if c == nil || c.Cache == nil || changed <= 0 { + return + } + c.Cache.DeletePrefix(ctx, "media:") + c.Cache.DeletePrefix(ctx, "stats:") +} diff --git a/internal/service/media_classifier.go b/internal/service/media_classifier.go index e605d02..56645a5 100644 --- a/internal/service/media_classifier.go +++ b/internal/service/media_classifier.go @@ -37,29 +37,35 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin categoryText := strings.ToLower(input.Category) rawText := rawTitleText + " " + input.Category text := strings.ToLower(rawText) - hasMetadata := len(genres) > 0 || len(countries) > 0 || len(languages) > 0 + hasRegionMetadata := len(countries) > 0 || len(languages) > 0 + hasMetadata := len(genres) > 0 || hasRegionMetadata + contentText := strings.ToLower(rawTitleText) + if !hasMetadata { + contentText += " " + categoryText + } sourceHint := sourceCategoryHint(input.Category, mediaType, categories) + animeSourceHint := sourceCategoryHint(input.Category, "anime", categories) isChineseByMetadata := hasAny(languages, "ZH", "ZH-CN", "ZH-TW", "CN", "BO", "ZA") || hasAny(countries, "CN", "TW", "HK", "MO") isChineseByText := containsHan(rawTitleText) || containsAnyText(strings.ToLower(rawTitleText), "华语", "国产", "国剧", "国漫") isChineseByCategory := containsAnyText(categoryText, "华语", "国产", "国剧", "大陆剧", "国产电视剧", "国产电影", "国漫", "国产动漫", "国产动画") isChinese := isChineseByMetadata || (!hasMetadata && isChineseByText) isChineseAnime := isChineseByMetadata || (!hasMetadata && containsAnyText(text, "华语", "国产", "国漫", "國漫", "国创", "国产动漫", "国产动画")) - isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || containsJapaneseKana(rawTitleText) || (!hasMetadata && strings.Contains(text, "日番")) - isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP") || containsKoreanHangul(rawTitleText) || (!hasMetadata && containsAnyText(categoryText, "韩漫", "韩国动漫", "韩国动画")) + isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || (!hasRegionMetadata && (containsJapaneseKana(rawTitleText) || strings.Contains(text, "日番"))) + isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP") || (!hasRegionMetadata && (containsKoreanHangul(rawTitleText) || containsAnyText(categoryText, "韩漫", "韩国动漫", "韩国动画"))) isEastAsianByCategory := containsAnyText(categoryText, "日韩剧", "日剧", "韩剧", "日韩电影") - isEastAsian := isJapanese || isKorean || hasAny(countries, "TH", "IN", "SG") || (!hasMetadata && isEastAsianByCategory) + isEastAsian := isJapanese || isKorean || hasAny(countries, "TH", "IN", "SG") || (!hasRegionMetadata && isEastAsianByCategory) isWesternByMetadata := hasAny(countries, "US", "GB", "UK", "FR", "DE", "CA", "AU", "NZ", "IE", "NL", "SE", "NO", "DK", "FI", "ES", "IT", "PT", "AT", "CH", "BE", "RU", - ) + ) || hasAny(languages, "EN", "FR", "DE", "ES", "IT", "PT", "NL", "SV", "NO", "DA", "FI", "RU") isWesternByCategory := containsAnyText(categoryText, "欧美剧", "欧美电视剧", "美剧", "英剧", "欧美电影", "外语电影") - isWestern := isWesternByMetadata || (!hasMetadata && isWesternByCategory) + isWestern := isWesternByMetadata || (!hasRegionMetadata && isWesternByCategory) isUSAnime := hasAny(countries, "US") - 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") - hasConcertText := containsAnyText(text, "演唱会", "音乐会", "concert", "live concert") + hasAnimeText := containsAnyText(contentText, "动画", "动漫", "番剧", "年番", "国漫", "日番", "韩漫", "美漫", "bangumi", "anime", "b-global", "ani-one", "crunchyroll") + hasVarietyText := containsAnyText(contentText, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show") + hasDocumentaryText := containsAnyText(contentText, "纪录", "纪录片", "documentary", "docu", "national geographic", "natgeo") + hasConcertText := containsAnyText(contentText, "演唱会", "音乐会", "concert", "live concert") isAdultText := containsAnyText(text, "adult", "nsfw", "成人", "番号", "jav", "9kg", "uncensored", "无码", "有码") || classifierJAVCodeRE.MatchString(strings.ToUpper(rawText)) hasGenre := func(values ...string) bool { @@ -70,7 +76,7 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin if isDigits(value) { continue } - if strings.Contains(text, strings.ToLower(value)) { + if strings.Contains(contentText, strings.ToLower(value)) { return true } } @@ -89,6 +95,9 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin if isUSAnime || (!hasMetadata && containsAnyText(categoryText, "美漫", "欧美动漫", "欧美动画", "西方动画")) { return categoryName(categories, "us_anime", "美漫") } + if !hasRegionMetadata && animeSourceHint != "" { + return animeSourceHint + } return categoryName(categories, "other_anime", "其他") } @@ -106,7 +115,7 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText { return categoryName(categories, "animation_movie", "动画电影") } - if !hasMetadata && sourceHint != "" { + if !hasRegionMetadata && sourceHint != "" { return sourceHint } if isChinese { @@ -120,7 +129,7 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin if isAdultText { return categoryName(categories, "adult", "成人") } - if !hasMetadata && sourceHint != "" { + if !hasRegionMetadata && sourceHint != "" { return sourceHint } return animeCategory() @@ -144,7 +153,7 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin if hasGenre("10764", "10767", "REALITY", "TALK", "综艺", "真人秀", "脱口秀") || hasVarietyText { return categoryName(categories, "variety", "综艺") } - if !hasMetadata && sourceHint != "" { + if !hasRegionMetadata && sourceHint != "" { return sourceHint } if isChinese || (!hasMetadata && isChineseByCategory) { diff --git a/internal/service/media_classifier_test.go b/internal/service/media_classifier_test.go index 76025f8..d317998 100644 --- a/internal/service/media_classifier_test.go +++ b/internal/service/media_classifier_test.go @@ -161,6 +161,36 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) { }, want: "国产剧", }, + { + name: "genre-only metadata keeps domestic source region", + input: mediaClassifyInput{ + MediaType: "tv", + Title: "Archives The Nanyang Mystery", + Genres: []string{"Drama"}, + Category: "downloads 国产剧", + }, + want: "国产剧", + }, + { + name: "genre-only metadata keeps western source region", + input: mediaClassifyInput{ + MediaType: "movie", + Title: "Unknown International Feature", + Genres: []string{"Drama"}, + Category: "downloads 欧美电影", + }, + want: "欧美电影", + }, + { + name: "english language metadata classifies western tv without country", + input: mediaClassifyInput{ + MediaType: "tv", + Title: "The Last of Us", + Languages: []string{"en"}, + Genres: []string{"Drama"}, + }, + want: "欧美剧", + }, { name: "gala is variety", input: mediaClassifyInput{ @@ -196,6 +226,30 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) { }, want: "日番", }, + { + name: "live action metadata overrides wrong anime source folder", + input: mediaClassifyInput{ + MediaType: "tv", + Title: "The Last of Us", + Countries: []string{"US"}, + Languages: []string{"en"}, + Genres: []string{"Drama"}, + Category: "downloads 动漫 日番", + }, + want: "欧美剧", + }, + { + name: "drama metadata overrides wrong documentary source folder", + input: mediaClassifyInput{ + MediaType: "tv", + Title: "人世间", + Countries: []string{"CN"}, + Languages: []string{"zh"}, + Genres: []string{"Drama"}, + Category: "downloads 纪录片", + }, + want: "国产剧", + }, { name: "western anime metadata uses us anime category", input: mediaClassifyInput{ @@ -333,6 +387,39 @@ func TestNormalizeMediaTypeAcceptsChineseLibraryTypes(t *testing.T) { } } +func TestMergeLocalClassificationMetadataKeepsScraperRegionWhenNFOIsSparse(t *testing.T) { + input := mediaClassifyInput{ + MediaType: "tv", + Title: "SPY FAMILY", + Languages: []string{"ja"}, + Countries: []string{"JP"}, + Genres: []string{"16"}, + } + mergeLocalClassificationMetadata(&input, &LocalMetadata{ + Title: "间谍过家家 第 1 集", + HasNFO: true, + }, "Spy Family", "episode.mkv") + + if got := classifyMediaCategory(input, nil); got != "日番" { + t.Fatalf("category=%q, want 日番 after sparse NFO merge; input=%#v", got, input) + } + if len(input.Countries) != 1 || input.Countries[0] != "JP" || len(input.Genres) != 1 || input.Genres[0] != "16" { + t.Fatalf("sparse NFO erased scraper metadata: %#v", input) + } +} + +func TestReconcileOrganizeCategoryMediaTypeKeepsAmbiguousDocumentaryMovie(t *testing.T) { + if got := reconcileOrganizeCategoryMediaType("movie", "tv"); got != "movie" { + t.Fatalf("ambiguous documentary type=%q, want movie", got) + } + if got := reconcileOrganizeCategoryMediaType("tv", "anime"); got != "anime" { + t.Fatalf("TV animation subtype=%q, want anime", got) + } + if got := reconcileOrganizeCategoryMediaType("movie", "adult"); got != "adult" { + t.Fatalf("adult override=%q, want adult", got) + } +} + func TestNormalizeMediaTypeDoesNotTreatReleaseTokensAsTV(t *testing.T) { tests := []string{ "They Will Kill You 2026 1080p HDTV x264", diff --git a/internal/service/organizer_classification_test.go b/internal/service/organizer_classification_test.go index 7d6a412..12b84b9 100644 --- a/internal/service/organizer_classification_test.go +++ b/internal/service/organizer_classification_test.go @@ -73,6 +73,24 @@ func TestOrganizeDirectoryClassifiesScraperMatchBeforeRename(t *testing.T) { } } +func TestOrganizeSourceCategoryKeepsDocumentaryMovieType(t *testing.T) { + cfg := &config.Config{} + cfg.Organizer.SmartClassify = true + organizer := NewOrganizerService(cfg, zap.NewNop(), newOrganizerTestRepo(t)) + layout := organizer.applyOrganizeSourceCategory( + t.Context(), + organizeSourceFileRequest{Source: filepath.Join(t.TempDir(), "Free.Solo.2018.mkv"), SourceRoot: t.TempDir()}, + organizeDirectoryLayout{MediaType: "movie"}, + organizeDirectoryLayout{MediaType: "movie"}, + "", + organizeSourceIdentity{Title: "Free Solo", ParsedTitle: "Free Solo", Year: 2018}, + &Match{Title: "徒手攀岩", MediaType: "movie", Countries: []string{"US"}, Languages: []string{"en"}, Genres: []string{"99"}}, + ) + if layout.MediaType != "movie" || layout.Category != "纪录片" { + t.Fatalf("documentary layout=%#v, want movie/纪录片", layout) + } +} + func TestOrganizeDirectoryMetadataCategoryOverridesDownloadFolder(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/internal/service/organizer_classify.go b/internal/service/organizer_classify.go index 985ef21..6b33882 100644 --- a/internal/service/organizer_classify.go +++ b/internal/service/organizer_classify.go @@ -60,17 +60,35 @@ func (o *OrganizerService) smartClassifySourceFile(ctx context.Context, src, sou } } 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" - } + mergeLocalClassificationMetadata(&input, meta, title, parsedTitle, filepath.Base(src)) } return sanitizeFilename(classifyMediaCategory(input, o.categoryMap())) } +// mergeLocalClassificationMetadata lets a local NFO refine scraper metadata +// without erasing fields that the NFO omitted. Episode NFO files commonly +// contain only a title/episode number; replacing countries/languages/genres +// with those empty values caused the organizer to lose a correct scraper +// region and fall back to the wrong directory category. +func mergeLocalClassificationMetadata(input *mediaClassifyInput, meta *LocalMetadata, titleParts ...string) { + if input == nil || meta == nil { + return + } + input.Title = strings.Join(append([]string{meta.Title, meta.OriginalName}, titleParts...), " ") + if values := parseCommaList(meta.Languages); len(values) > 0 { + input.Languages = values + } + if values := parseCommaList(meta.Countries); len(values) > 0 { + input.Countries = values + } + if values := parseCommaList(meta.Genres); len(values) > 0 { + input.Genres = values + } + if meta.NSFW { + input.MediaType = "adult" + } +} + // parseCommaList splits a comma-separated string into trimmed non-empty values. func parseCommaList(s string) []string { if s == "" { diff --git a/internal/service/organizer_directory_source_plan.go b/internal/service/organizer_directory_source_plan.go index bef8be2..d77a05c 100644 --- a/internal/service/organizer_directory_source_plan.go +++ b/internal/service/organizer_directory_source_plan.go @@ -187,9 +187,7 @@ func (o *OrganizerService) applyOrganizeSourceCategory( if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(layout.Category); impliedType != "" { layout.Category = normalizedCategory if forcedType == "" { - if layout.MediaType == "" || layout.MediaType == "tv" || layout.MediaType == "anime" || pathLayout.Category != layout.Category { - layout.MediaType = impliedType - } + layout.MediaType = reconcileOrganizeCategoryMediaType(layout.MediaType, impliedType) } } return layout diff --git a/internal/service/organizer_library_classification.go b/internal/service/organizer_library_classification.go index 37ef809..e3012f6 100644 --- a/internal/service/organizer_library_classification.go +++ b/internal/service/organizer_library_classification.go @@ -112,6 +112,27 @@ func organizeLibraryTypeScore(mediaType, libraryType string) int { return 0 } +// reconcileOrganizeCategoryMediaType applies category-derived type changes +// conservatively. Some category labels are intentionally shared by movies +// and episodic media (notably "纪录片"). A category lookup therefore must not +// turn an already-known movie into TV merely because the shared label was +// registered last. Only subtype refinements and the explicit adult category +// are allowed to replace a known type. +func reconcileOrganizeCategoryMediaType(current, implied string) string { + current = normalizeOrganizeMediaType(current) + implied = normalizeOrganizeMediaType(implied) + if current == "" || current == implied { + return implied + } + if implied == "adult" { + return implied + } + if current == "tv" && (implied == "anime" || implied == "variety") { + return implied + } + return current +} + func normalizeOrganizeCategoryKey(value string) string { value = strings.ToLower(strings.TrimSpace(value)) value = strings.ReplaceAll(value, " ", "") diff --git a/internal/service/organizer_media.go b/internal/service/organizer_media.go index b6dc799..7449c16 100644 --- a/internal/service/organizer_media.go +++ b/internal/service/organizer_media.go @@ -35,8 +35,22 @@ func (o *OrganizerService) resolveOrganizeMediaRequest(ctx context.Context, medi return organizeMediaRequest{}, errors.New("media not found") } lib, err := o.repo.Library.FindByID(ctx, m.LibraryID) - if err != nil || lib == nil { - return organizeMediaRequest{}, errors.New("library not found") + if err != nil { + return organizeMediaRequest{}, err + } + if lib == nil { + lib = o.findOrganizeLibraryForMediaPath(ctx, m.Path) + if lib == nil { + return organizeMediaRequest{}, errors.New("library not found") + } + // Historical reclassification/library deletion bugs could leave media + // rows pointing at a removed library. Repair the ownership before the + // scrape-driven rename so the rename does not fail permanently. + m.LibraryID = lib.ID + if err := o.repo.DB.WithContext(ctx).Model(&model.Media{}). + Where("id = ?", m.ID).Update("library_id", lib.ID).Error; err != nil { + return organizeMediaRequest{}, err + } } if _, ok := ParseCloudLibraryMount(lib.Path); ok { return organizeMediaRequest{}, errors.New("local organize cannot use cloud libraries directly; use external storage scan/mount for cloud media or enable cloud transfer to write to cloud") @@ -69,6 +83,42 @@ func (o *OrganizerService) resolveOrganizeMediaRequest(ctx context.Context, medi }, nil } +func (o *OrganizerService) findOrganizeLibraryForMediaPath(ctx context.Context, mediaPath string) *model.Library { + if o == nil || o.repo == nil || o.repo.Library == nil || strings.TrimSpace(mediaPath) == "" { + return nil + } + libraries, err := o.repo.Library.List(ctx) + if err != nil { + return nil + } + var best *model.Library + bestLen := -1 + for i := range libraries { + lib := &libraries[i] + if !lib.Enabled { + continue + } + paths := []string{lib.Path} + for _, root := range lib.Roots { + if root.Enabled { + paths = append(paths, root.Path) + } + } + for _, rootPath := range paths { + if strings.TrimSpace(rootPath) == "" || !pathWithin(mediaPath, rootPath) { + continue + } + if n := len(filepath.Clean(rootPath)); n > bestLen { + candidate := *lib + candidate.Path = rootPath + best = &candidate + bestLen = n + } + } + } + return best +} + func (o *OrganizerService) buildOrganizeMediaDestination(ctx context.Context, req organizeMediaRequest) (organizeMediaDestination, error) { m := req.media lib := req.library @@ -88,7 +138,7 @@ func (o *OrganizerService) buildOrganizeMediaDestination(ctx context.Context, re if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(category); impliedType != "" { category = normalizedCategory if normalizeOrganizeMediaType(req.mediaType) == "" { - mediaType = impliedType + mediaType = reconcileOrganizeCategoryMediaType(mediaType, impliedType) } } root := o.organizeRoot(req.baseRoot, mediaType, category) diff --git a/internal/service/organizer_reclassify_cloud.go b/internal/service/organizer_reclassify_cloud.go index 4fb2bbf..4a5b480 100644 --- a/internal/service/organizer_reclassify_cloud.go +++ b/internal/service/organizer_reclassify_cloud.go @@ -33,7 +33,7 @@ func (o *OrganizerService) reclassifyCloudScannedMedia(ctx context.Context, medi return false, nil } if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(category); impliedType != "" { - mediaType = impliedType + mediaType = reconcileOrganizeCategoryMediaType(mediaType, impliedType) category = normalizedCategory } if mediaType == "" { diff --git a/internal/service/organizer_reclassify_scanned.go b/internal/service/organizer_reclassify_scanned.go index 7f3af80..745efd0 100644 --- a/internal/service/organizer_reclassify_scanned.go +++ b/internal/service/organizer_reclassify_scanned.go @@ -159,7 +159,7 @@ func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media mod if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(category); impliedType != "" { category = normalizedCategory if !explicitType { - mediaType = impliedType + mediaType = reconcileOrganizeCategoryMediaType(mediaType, impliedType) } } if explicitCategory && overrideCategory != "" { diff --git a/internal/service/organizer_scrape_test.go b/internal/service/organizer_scrape_test.go index b8dbdb2..a9a853c 100644 --- a/internal/service/organizer_scrape_test.go +++ b/internal/service/organizer_scrape_test.go @@ -496,3 +496,43 @@ func TestOrganizeScrapeAfterEnabledDefaultsOn(t *testing.T) { t.Fatalf("explicit organize.scrape_after=false should be respected") } } + +func TestSyncMediaPathWithMetadataRepairsOrphanedLibraryReference(t *testing.T) { + root := t.TempDir() + libRoot := filepath.Join(root, "media", "电影") + source := filepath.Join(libRoot, "Old Name", "Old Name.mkv") + writeOrgFile(t, source, "movie") + + repos := newOrganizerTestRepo(t) + lib := model.Library{Name: "电影", Path: libRoot, Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + media := model.Media{ + LibraryID: "deleted-library", + Title: "New Name", + Year: 2026, + Path: source, + Container: "mkv", + } + if err := repos.Media.Upsert(t.Context(), &media); err != nil { + t.Fatal(err) + } + + organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) + dst, err := organizer.SyncMediaPathWithMetadata(t.Context(), media.ID, OrganizeOptions{DestPath: libRoot}) + if err != nil { + t.Fatalf("sync orphaned media: %v", err) + } + want := filepath.Join(libRoot, "New Name (2026)", "New Name (2026).mkv") + if dst != want { + t.Fatalf("dst=%q, want %q", dst, want) + } + var got model.Media + if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil { + t.Fatal(err) + } + if got.LibraryID != lib.ID || got.Path != want { + t.Fatalf("media library/path=%q/%q, want %q/%q", got.LibraryID, got.Path, lib.ID, want) + } +} diff --git a/internal/service/scraper.go b/internal/service/scraper.go index e424932..509ef22 100644 --- a/internal/service/scraper.go +++ b/internal/service/scraper.go @@ -55,10 +55,12 @@ func (s *ScraperService) EnrichOneWithOptions(ctx context.Context, m *model.Medi } } - if match := s.matchFromMediaExternalIDs(ctx, m, lib); match != nil { - s.applyFanartArtwork(ctx, match) - mergeLocalMetadataIntoMatch(match, local) - return s.applyProviderMatchWithOptions(ctx, m, lib, match, options) + if !options.ForceRematch { + if match := s.matchFromMediaExternalIDs(ctx, m, lib); match != nil { + s.applyFanartArtwork(ctx, match) + mergeLocalMetadataIntoMatch(match, local) + return s.applyProviderMatchWithOptions(ctx, m, lib, match, options) + } } candidates := scrapeQueryCandidatesWithRecognition(ctx, s.repo, m, lib) @@ -128,6 +130,18 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m "year": match.Year, "scrape_status": "matched", } + if options.ForceRematch { + updates["original_name"] = strings.TrimSpace(match.OriginalName) + updates["release_date"] = strings.TrimSpace(match.ReleaseDate) + updates["tm_db_id"] = match.TMDbID + updates["bangumi_id"] = match.BangumiID + updates["douban_id"] = strings.TrimSpace(match.DoubanID) + updates["thetvdb_id"] = strings.TrimSpace(match.TheTVDBID) + updates["genres"] = strings.Join(match.Genres, ",") + updates["countries"] = strings.Join(match.Countries, ",") + updates["languages"] = strings.Join(match.Languages, ",") + updates["nsfw"] = match.NSFW + } if match.ReleaseDate != "" { updates["release_date"] = match.ReleaseDate } diff --git a/internal/service/scraper_options.go b/internal/service/scraper_options.go index 4615417..d69fe82 100644 --- a/internal/service/scraper_options.go +++ b/internal/service/scraper_options.go @@ -6,6 +6,7 @@ type ScrapeOptions struct { RefreshWeakMatched bool EpisodeArtwork *bool DeferEpisodeDetails bool + ForceRematch bool } func (o ScrapeOptions) episodeArtworkEnabled() bool { diff --git a/internal/service/scraper_test.go b/internal/service/scraper_test.go index ecac96d..f9bb5ad 100644 --- a/internal/service/scraper_test.go +++ b/internal/service/scraper_test.go @@ -77,6 +77,47 @@ func TestEnrichOneUsesExistingTMDbIDForCloudMedia(t *testing.T) { } } +func TestEnrichOneForceRematchReplacesStaleExternalIDsAndTaxonomy(t *testing.T) { + scraper, repos, closeServer := newTestScraper(t) + defer closeServer() + + lib := model.Library{Name: "国产剧", Path: t.TempDir(), Type: "tv", Enabled: true} + if err := repos.DB.Create(&lib).Error; err != nil { + t.Fatal(err) + } + media := model.Media{ + LibraryID: lib.ID, + Title: "错误旧匹配", + Path: filepath.Join(lib.Path, "Spy Family", "Season 01", "Spy Family - S01E01.mkv"), + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 999, + BangumiID: 88, + DoubanID: "stale", + Countries: "CN", + Languages: "zh", + Genres: "Drama", + ScrapeStatus: "matched", + } + if err := repos.DB.Create(&media).Error; err != nil { + t.Fatal(err) + } + + if err := scraper.EnrichOneWithOptions(t.Context(), &media, ScrapeOptions{ForceRematch: true}); 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.TMDbID != 12345 || got.BangumiID != 0 || got.DoubanID != "" { + t.Fatalf("external ids after forced rematch=%d/%d/%q, want 12345/0/empty", got.TMDbID, got.BangumiID, got.DoubanID) + } + if got.Title != "间谍过家家" || got.Countries != "JP" || got.Languages != "ja" || !strings.Contains(got.Genres, "Animation") { + t.Fatalf("forced rematch metadata=%#v, want corrected Japanese animation metadata", got) + } +} + func TestEnrichOneWritesTMDbIDColumn(t *testing.T) { scraper, repos, closeServer := newTestScraper(t) defer closeServer() diff --git a/internal/service/scraper_test_helpers_test.go b/internal/service/scraper_test_helpers_test.go index 673815a..d07eec4 100644 --- a/internal/service/scraper_test_helpers_test.go +++ b/internal/service/scraper_test_helpers_test.go @@ -70,6 +70,16 @@ func newTestScraper(t *testing.T) (*ScraperService, *repository.Container, func( "name": "Animation", }}, }) + case strings.HasPrefix(r.URL.Path, "/tv/999"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 999, + "name": "错误旧匹配", + "first_air_date": "2020-01-01", + "origin_country": []string{"CN"}, + "genres": []map[string]any{{ + "name": "Drama", + }}, + }) default: http.NotFound(w, r) }