diff --git a/internal/config/defaults.go b/internal/config/defaults.go index eb997ea..7992379 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -81,6 +81,7 @@ func setDefaults(v *viper.Viper) { v.SetDefault("organizer.categories.domestic_tv", "国产剧") v.SetDefault("organizer.categories.euus_tv", "欧美剧") v.SetDefault("organizer.categories.jk_tv", "日韩剧") + v.SetDefault("organizer.categories.unclassified_tv", "未分类") v.SetDefault("organizer.categories.jp_anime", "日番") v.SetDefault("organizer.categories.cn_anime", "国漫") v.SetDefault("organizer.categories.kr_anime", "韩漫") diff --git a/internal/handler/media_scan.go b/internal/handler/media_scan.go index 09976b9..286f256 100644 --- a/internal/handler/media_scan.go +++ b/internal/handler/media_scan.go @@ -23,6 +23,24 @@ func scanLibraryHandler(svc *service.Container) gin.HandlerFunc { return } if _, ok := service.ParseCloudLibraryMount(lib.Path); ok { + status, started, startErr := svc.Scan.StartCloudLibraryScan(id, true) + if startErr != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": startErr.Error()}) + return + } + if !started { + c.JSON(http.StatusAccepted, gin.H{ + "library_id": id, + "queued": true, + "cloud": true, + "already_running": true, + "stage": status.Stage, + "state": status.State, + "message": "该云盘媒体库正在后台扫描,请在任务面板查看进度", + "estimate_message": "页面关闭不会中断扫描", + }) + return + } task := startScanHTTPTask(svc, "云盘扫描队列", lib.Name, lib.Path) if svc.WSHub != nil { svc.WSHub.Publish("scan", gin.H{ @@ -34,7 +52,6 @@ func scanLibraryHandler(svc *service.Container) gin.HandlerFunc { "estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度", }) } - _, _, _ = svc.Scan.StartCloudLibraryScan(id, true) finishHTTPTask(task, nil, "queued", "云盘扫描已加入后台队列", map[string]int64{"queued": 1}, nil) c.JSON(http.StatusAccepted, gin.H{ "library_id": id, diff --git a/internal/handler/media_test.go b/internal/handler/media_test.go index 2b5de0d..792576e 100644 --- a/internal/handler/media_test.go +++ b/internal/handler/media_test.go @@ -293,6 +293,41 @@ func TestListLibrarySeriesDoesNotTruncateLargeEpisodeLibraries(t *testing.T) { } } +func TestScanLibraryHandlerSurfacesCloudQueueStartFailure(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Library{}, &model.LibraryRoot{}, &model.Media{}, &model.Setting{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + lib := model.Library{Name: "旧夸克云盘", Path: service.BuildCloudLibraryPath(service.LegacyQuarkProvider, "archive", "archive"), Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + log := zap.NewNop() + svc := &service.Container{ + Log: log, + Repo: repos, + Scan: service.NewScannerService(&config.Config{}, log, repos, service.NewHub(log), nil, nil), + } + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: lib.ID}} + c.Request = httptest.NewRequest(http.MethodPost, "/api/libraries/"+lib.ID+"/scan", nil) + + scanLibraryHandler(svc)(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s, want bad request", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "deprecated") { + t.Fatalf("body=%s, want cloud queue start error", w.Body.String()) + } +} + func TestScrapeOptionsFromRequestPreservesEpisodeImagesFalse(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() diff --git a/internal/service/media_classifier.go b/internal/service/media_classifier.go index 6f9e6f1..e605d02 100644 --- a/internal/service/media_classifier.go +++ b/internal/service/media_classifier.go @@ -130,6 +130,11 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin if isAdultText { return categoryName(categories, "adult", "成人") } + // Match MoviePilot's ordered category rules: animation categories are + // evaluated before documentary / children / variety and region rules. + if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText { + return animeCategory() + } if hasGenre("99", "DOCUMENTARY", "纪录", "纪录片") || hasDocumentaryText { return categoryName(categories, "documentary", "纪录片") } @@ -139,9 +144,6 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin if hasGenre("10764", "10767", "REALITY", "TALK", "综艺", "真人秀", "脱口秀") || hasVarietyText { return categoryName(categories, "variety", "综艺") } - if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText { - return animeCategory() - } if !hasMetadata && sourceHint != "" { return sourceHint } @@ -154,7 +156,7 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin if isWestern { return categoryName(categories, "euus_tv", "欧美剧") } - return categoryName(categories, "euus_tv", "欧美剧") + return categoryName(categories, "unclassified_tv", "未分类") case "adult": return categoryName(categories, "adult", "成人") } @@ -213,6 +215,10 @@ func (o *OrganizerService) categoryMap() map[string]string { return o.cfg.Organizer.Categories } +func isUnclassifiedTVCategory(category string, categories map[string]string) bool { + return strings.EqualFold(strings.TrimSpace(category), categoryName(categories, "unclassified_tv", "未分类")) +} + func (o *OrganizerService) classifyMedia(ctx context.Context, m *model.Media, mediaType string) string { if m == nil { return "" diff --git a/internal/service/media_classifier_source_hints.go b/internal/service/media_classifier_source_hints.go index ae4c599..7b217d9 100644 --- a/internal/service/media_classifier_source_hints.go +++ b/internal/service/media_classifier_source_hints.go @@ -19,6 +19,7 @@ var sourceCategoryHints = []sourceCategoryHintDef{ {Key: "domestic_tv", Fallback: "国产剧", MediaType: "tv"}, {Key: "euus_tv", Fallback: "欧美剧", MediaType: "tv"}, {Key: "jk_tv", Fallback: "日韩剧", MediaType: "tv", Aliases: []string{"日剧", "韩剧", "泰剧"}}, + {Key: "unclassified_tv", Fallback: "未分类", MediaType: "tv", Aliases: []string{"uncategorized"}}, {Key: "cn_anime", Fallback: "国漫", MediaType: "anime"}, {Key: "jp_anime", Fallback: "日番", MediaType: "anime"}, {Key: "kr_anime", Fallback: "韩漫", MediaType: "anime"}, @@ -27,7 +28,6 @@ var sourceCategoryHints = []sourceCategoryHintDef{ {Key: "variety", Fallback: "综艺", MediaType: "variety"}, {Key: "documentary", Fallback: "纪录片", MediaType: "tv"}, {Key: "children", Fallback: "儿童", MediaType: "tv"}, - {Key: "euus_tv", Fallback: "欧美剧", MediaType: "tv", Aliases: []string{"未分类", "uncategorized"}}, {Key: "adult", Fallback: "成人", MediaType: "adult", Aliases: []string{"9KG", "番号", "JAV", "adult", "nsfw"}}, } diff --git a/internal/service/media_classifier_test.go b/internal/service/media_classifier_test.go index 4c2de44..76025f8 100644 --- a/internal/service/media_classifier_test.go +++ b/internal/service/media_classifier_test.go @@ -110,12 +110,12 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) { want: "国产剧", }, { - name: "latin tv title without metadata falls back to western tv", + name: "latin tv title without metadata stays unclassified", input: mediaClassifyInput{ MediaType: "tv", Title: "The Last of Us S01E01 1080p", }, - want: "欧美剧", + want: "未分类", }, { name: "latin tv keeps explicit western source category", @@ -127,13 +127,13 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) { want: "欧美剧", }, { - name: "generic tv folder is not treated as chinese category", + name: "generic tv folder stays unclassified without metadata", input: mediaClassifyInput{ MediaType: "tv", Title: "The Last of Us S01E01 1080p", Category: "downloads 电视剧", }, - want: "欧美剧", + want: "未分类", }, { name: "gala title overrides wrong western source category", @@ -145,12 +145,12 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) { want: "综艺", }, { - name: "platform token alone does not classify romanized drama", + name: "platform token alone leaves romanized drama unclassified", input: mediaClassifyInput{ MediaType: "tv", Title: "Motherhood.of.Taihang.S01E01.2026.1080p.iQIYI.WEB-DL", }, - want: "欧美剧", + want: "未分类", }, { name: "metadata classifies romanized chinese drama", @@ -217,6 +217,17 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) { }, want: "美漫", }, + { + name: "tv animation takes priority over children like MoviePilot rules", + input: mediaClassifyInput{ + MediaType: "tv", + Title: "Gourd Brothers", + Countries: []string{"CN"}, + Languages: []string{"zh"}, + Genres: []string{"16", "10762"}, + }, + want: "国漫", + }, { name: "western anime legacy source category maps to us anime without metadata", input: mediaClassifyInput{ diff --git a/internal/service/media_collection_folder.go b/internal/service/media_collection_folder.go new file mode 100644 index 0000000..3ae01fd --- /dev/null +++ b/internal/service/media_collection_folder.go @@ -0,0 +1,20 @@ +package service + +import ( + "regexp" + "strings" +) + +var ( + mediaCollectionEnglishRE = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:collection|anthology|trilogy|quadrilogy|tetralogy|saga|box[\s._-]*set)(?:[\s._-]|$)|(?:complete|all)[\s._-]*(?:\d+[\s._-]*)?(?:movies?|films?|collection|series|saga)(?:[\s._-]|$)|\d+[\s._-]*films?[\s._-]*collection`) + mediaCollectionChineseRE = regexp.MustCompile(`(?:合集|全集|套装|全套|系列合集|电影系列|全\s*\d+\s*部)`) +) + +func isMediaCollectionFolder(name string) bool { + name = strings.TrimSpace(name) + return name != "" && (mediaCollectionEnglishRE.MatchString(name) || mediaCollectionChineseRE.MatchString(name)) +} + +func mediaParentLooksLikeCollection(path string) bool { + return isMediaCollectionFolder(pathBaseSlash(parentSlashPath(path))) +} diff --git a/internal/service/media_series.go b/internal/service/media_series.go index 9773a43..9290c15 100644 --- a/internal/service/media_series.go +++ b/internal/service/media_series.go @@ -57,8 +57,9 @@ func (s *MediaService) ListLibrarySeriesEpisodes(ctx context.Context, libraryID, return nil, err } out := make([]model.Media, 0) + resolver := newMediaSeriesKeyResolver(rows) for _, row := range rows { - if mediaSeriesKey(row) == key { + if resolver.key(row) == key { out = append(out, row) } } @@ -101,8 +102,9 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard { } groups := make([]seriesCardGroup, 0) byKey := make(map[string]int, len(items)) + resolver := newMediaSeriesKeyResolver(items) for _, item := range items { - key := mediaSeriesKey(item) + key := resolver.key(item) if key == "" { continue } @@ -112,7 +114,12 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard { group.latest = latest } card := &group.card - card.Count++ + // A shared external ID means duplicate encodes/locations for movies, + // not multiple episodes. Keep a single movie card without presenting + // its versions as an "N episodes" collection. + if mediaLooksEpisodicForGrouping(item) || mediaLooksEpisodicForGrouping(card.LinkMedia) { + card.Count++ + } if betterSeriesLinkMedia(item, card.LinkMedia) { card.LinkMedia = item } diff --git a/internal/service/media_series_key.go b/internal/service/media_series_key.go index 6822fbb..efe7c93 100644 --- a/internal/service/media_series_key.go +++ b/internal/service/media_series_key.go @@ -25,16 +25,16 @@ func mediaSeriesRawKey(media model.Media) string { return seriesFingerprint("library-path-id", mediaTargetLibraryID(media), idKey) } if media.TMDbID > 0 { - return fmt.Sprintf("tmdb:%d", media.TMDbID) + return seriesFingerprint("episodic-external", fmt.Sprintf("tmdb:%d", media.TMDbID)) } if media.BangumiID > 0 { - return fmt.Sprintf("bgm:%d", media.BangumiID) + return seriesFingerprint("episodic-external", fmt.Sprintf("bgm:%d", media.BangumiID)) } if strings.TrimSpace(media.DoubanID) != "" { - return "douban:" + strings.TrimSpace(media.DoubanID) + return seriesFingerprint("episodic-external", "douban:"+strings.TrimSpace(media.DoubanID)) } if strings.TrimSpace(media.TheTVDBID) != "" { - return "thetvdb:" + strings.TrimSpace(media.TheTVDBID) + return seriesFingerprint("episodic-external", "thetvdb:"+strings.TrimSpace(media.TheTVDBID)) } if strings.TrimSpace(media.SeriesID) != "" { return "series:" + strings.TrimSpace(media.SeriesID) @@ -45,12 +45,12 @@ func mediaSeriesRawKey(media model.Media) string { return "series:" + strings.TrimSpace(media.SeriesID) } if media.TMDbID > 0 { - return fmt.Sprintf("tmdb:%d", media.TMDbID) + return seriesFingerprint("movie-external", fmt.Sprintf("tmdb:%d", media.TMDbID)) } if media.BangumiID > 0 { - return fmt.Sprintf("bgm:%d", media.BangumiID) + return seriesFingerprint("movie-external", fmt.Sprintf("bgm:%d", media.BangumiID)) } - if fromPath != "" { + if fromPath != "" && !mediaParentLooksLikeCollection(media.Path) { return seriesFingerprint("library-path", media.LibraryID, fromPath) } return seriesFingerprint("library-title", media.LibraryID, normalizeSeriesTitle(media.Title)) diff --git a/internal/service/media_series_resolver.go b/internal/service/media_series_resolver.go new file mode 100644 index 0000000..c57451f --- /dev/null +++ b/internal/service/media_series_resolver.go @@ -0,0 +1,90 @@ +package service + +import ( + "fmt" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +type mediaSeriesKeyResolver struct { + pathCounts map[string]int + externalCounts map[string]int + titleCounts map[string]int +} + +func newMediaSeriesKeyResolver(items []model.Media) mediaSeriesKeyResolver { + resolver := mediaSeriesKeyResolver{ + pathCounts: make(map[string]int), + externalCounts: make(map[string]int), + titleCounts: make(map[string]int), + } + for _, item := range items { + if !mediaLooksEpisodicForGrouping(item) { + continue + } + if key := mediaSeriesRawKey(item); strings.HasPrefix(key, "library-path") { + resolver.pathCounts[key]++ + } + if key := repeatedSeriesExternalKey(item); key != "" { + resolver.externalCounts[key]++ + } + if key := repeatedSeriesTitleKey(item); key != "" { + resolver.titleCounts[key]++ + } + } + return resolver +} + +func (r mediaSeriesKeyResolver) key(media model.Media) string { + if mediaLooksEpisodicForGrouping(media) { + if key := mediaSeriesRawKey(media); strings.HasPrefix(key, "library-path") && r.pathCounts[key] > 1 { + return compactSeriesKey(key) + } + if key := repeatedSeriesExternalKey(media); key != "" && r.externalCounts[key] > 1 { + return compactSeriesKey(key) + } + if key := repeatedSeriesTitleKey(media); key != "" && r.titleCounts[key] > 1 { + return compactSeriesKey(key) + } + } + return mediaSeriesKey(media) +} + +func mediaLooksEpisodicForGrouping(media model.Media) bool { + return media.SeasonNum > 0 || media.EpisodeNum > 0 || + episodicPathRE.MatchString(media.Path+" "+media.DisplayLibraryPath+" "+media.LibraryPath) +} + +func repeatedSeriesExternalKey(media model.Media) string { + identity := "" + switch { + case media.TMDbID > 0: + identity = fmt.Sprintf("tmdb:%d", media.TMDbID) + case media.BangumiID > 0: + identity = fmt.Sprintf("bgm:%d", media.BangumiID) + case strings.TrimSpace(media.DoubanID) != "": + identity = "douban:" + strings.TrimSpace(media.DoubanID) + case strings.TrimSpace(media.TheTVDBID) != "": + identity = "thetvdb:" + strings.TrimSpace(media.TheTVDBID) + } + if identity == "" { + return "" + } + return seriesFingerprint("library-external", mediaTargetLibraryID(media), identity) +} + +func repeatedSeriesTitleKey(media model.Media) string { + if !strings.EqualFold(strings.TrimSpace(media.ScrapeStatus), "matched") { + return "" + } + title := strings.TrimSpace(firstNonEmpty(media.Title, media.OriginalName)) + if title == "" || unsafeAutomaticEpisodeQuery(title) || organizeMediaTitleLooksLikeRelease(title) { + return "" + } + title = normalizeSeriesTitle(title) + if title == "" { + return "" + } + return seriesFingerprint("library-title-year", mediaTargetLibraryID(media), title, fmt.Sprint(media.Year)) +} diff --git a/internal/service/media_series_test.go b/internal/service/media_series_test.go index 86ad538..eb9577b 100644 --- a/internal/service/media_series_test.go +++ b/internal/service/media_series_test.go @@ -344,3 +344,82 @@ func TestMediaSeriesKeyUsesSeriesDirectoryExternalID(t *testing.T) { t.Fatalf("episode filename tmdb id should not split clean folder key=%q, want %q", got, want) } } + +func TestGroupMediaSeriesCardsMergesPollutedEpisodeFoldersBySharedShowID(t *testing.T) { + items := []model.Media{ + { + LibraryID: "lib-variety", + Title: "脱口秀和Ta的朋友们", + Path: `F:\media\电视剧\综艺\脱口秀和Ta的朋友们 第01期\Season 01\show.S01E01.mkv`, + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 260001, + ScrapeStatus: "matched", + }, + { + LibraryID: "lib-variety", + Title: "脱口秀和Ta的朋友们", + Path: `F:\media\电视剧\综艺\脱口秀和Ta的朋友们 第02期\Season 01\show.S01E02.mkv`, + SeasonNum: 1, + EpisodeNum: 2, + TMDbID: 260001, + ScrapeStatus: "matched", + }, + } + + cards := groupMediaSeriesCards(items) + if len(cards) != 1 || cards[0].Count != 2 { + t.Fatalf("cards=%#v, want one show card with two episodes", cards) + } +} + +func TestGroupMediaSeriesCardsKeepsMovieVersionsAsOneMovie(t *testing.T) { + items := []model.Media{ + { + Base: model.Base{ID: "movie-copy-a"}, + LibraryID: "foreign-movies", + Title: "杀的就是你", + Path: `F:\media\电影\外语电影\They Will Kill You (2026)\movie-a.mkv`, + TMDbID: 1292695, + }, + { + Base: model.Base{ID: "movie-copy-b"}, + LibraryID: "western-movies", + Title: "杀的就是你", + Path: `F:\media\电影\欧美电影\They Will Kill You (2026)\movie-b.mkv`, + TMDbID: 1292695, + }, + } + + cards := groupMediaSeriesCards(items) + if len(cards) != 1 { + t.Fatalf("cards=%#v, want duplicate movie locations folded into one card", cards) + } + if cards[0].Count != 1 { + t.Fatalf("movie card count=%d, want 1 so versions are not shown as episodes", cards[0].Count) + } +} + +func TestGroupMediaSeriesCardsDoesNotCollideMovieAndTVExternalIDs(t *testing.T) { + movie := model.Media{ + Base: model.Base{ID: "movie"}, + LibraryID: "movies", + Title: "同号电影", + Path: `/media/电影/同号电影 (2026)/movie.mkv`, + TMDbID: 12345, + } + episode := model.Media{ + Base: model.Base{ID: "episode"}, + LibraryID: "tv", + Title: "同号剧集", + Path: `/media/tv/同号剧集/episode.mkv`, + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 12345, + } + + cards := groupMediaSeriesCards([]model.Media{movie, episode}) + if len(cards) != 2 { + t.Fatalf("cards=%#v, want movie and TV item kept separate despite equal numeric TMDb IDs", cards) + } +} diff --git a/internal/service/metadata_match_trust.go b/internal/service/metadata_match_trust.go index ec8827b..d5ec105 100644 --- a/internal/service/metadata_match_trust.go +++ b/internal/service/metadata_match_trust.go @@ -15,7 +15,10 @@ func automaticMetadataTitleTrusted(query string, match *Match) bool { if queryKey == "" || match == nil { return false } - for _, title := range []string{match.Title, match.OriginalName} { + titles := make([]string, 0, 2+len(match.Aliases)) + titles = append(titles, match.Title, match.OriginalName) + titles = append(titles, match.Aliases...) + for _, title := range titles { titleKey := metadataTrustKey(title) if titleKey == "" { continue @@ -90,8 +93,15 @@ func metadataTrustLocalizedSearchKeyword(queryKey string, match *Match) bool { if match == nil || !metadataMatchHasExternalID(match) { return false } - searchKey := metadataTrustKey(match.SearchKeyword) - return searchKey != "" && searchKey == queryKey && metadataTrustStrongCJKQuery(queryKey) + if !metadataTrustStrongCJKQuery(queryKey) { + return false + } + for _, alias := range match.Aliases { + if aliasKey := metadataTrustKey(alias); aliasKey != "" && aliasKey == queryKey { + return true + } + } + return false } func preferLocalizedSearchTitle(query string, match *Match) { diff --git a/internal/service/organize_naming_test.go b/internal/service/organize_naming_test.go index 3bb6c8f..5eb8b47 100644 --- a/internal/service/organize_naming_test.go +++ b/internal/service/organize_naming_test.go @@ -35,6 +35,7 @@ func TestOrganizeNaming(t *testing.T) { {"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}, + {"Pressure.2026.2155p.iT.WEB-DL.HEVC.mkv", "Pressure", 2026, 0, 0}, } for _, tc := range cases { t.Run(tc.file, func(t *testing.T) { diff --git a/internal/service/organizer_directory_classification_test.go b/internal/service/organizer_directory_classification_test.go index c4263b5..e9550e1 100644 --- a/internal/service/organizer_directory_classification_test.go +++ b/internal/service/organizer_directory_classification_test.go @@ -324,7 +324,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_directory_layout.go b/internal/service/organizer_directory_layout.go index d6594d2..1d38c73 100644 --- a/internal/service/organizer_directory_layout.go +++ b/internal/service/organizer_directory_layout.go @@ -156,8 +156,8 @@ func (o *OrganizerService) directoryCategoryTypes() map[string]organizeDirectory addConfigured("variety", "综艺", "variety") addConfigured("documentary", "纪录片", "tv") addConfigured("children", "儿童", "tv") - addAlias("未分类", "euus_tv", "欧美剧", "tv") - addAlias("uncategorized", "euus_tv", "欧美剧", "tv") + addConfigured("unclassified_tv", "未分类", "tv") + addAlias("uncategorized", "unclassified_tv", "未分类", "tv") addConfigured("adult", "成人", "adult") addAlias("9KG", "adult", "成人", "adult") addAlias("番号", "adult", "成人", "adult") diff --git a/internal/service/organizer_library_classification.go b/internal/service/organizer_library_classification.go index d23a6ee..37ef809 100644 --- a/internal/service/organizer_library_classification.go +++ b/internal/service/organizer_library_classification.go @@ -68,6 +68,8 @@ func (o *OrganizerService) organizeCategoryAliases(mediaType, category string) m add("纪录片", categoryName(categories, "documentary", "纪录片")) case normalizeOrganizeCategoryKey(categoryName(categories, "children", "儿童")), "儿童", "少儿": add("儿童", categoryName(categories, "children", "儿童")) + case normalizeOrganizeCategoryKey(categoryName(categories, "unclassified_tv", "未分类")), "未分类", "uncategorized": + add("未分类", "uncategorized", categoryName(categories, "unclassified_tv", "未分类")) case normalizeOrganizeCategoryKey(categoryName(categories, "chinese_movie", "华语电影")), "华语电影", "国产电影", "大陆电影": add("华语电影", categoryName(categories, "chinese_movie", "华语电影")) case normalizeOrganizeCategoryKey(categoryName(categories, "euus_movie", "欧美电影")), "欧美电影", "外语电影", "外国电影": diff --git a/internal/service/organizer_localized_title_test.go b/internal/service/organizer_localized_title_test.go new file mode 100644 index 0000000..215cee1 --- /dev/null +++ b/internal/service/organizer_localized_title_test.go @@ -0,0 +1,100 @@ +package service + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func TestOrganizeDirectoryUsesTMDbChineseAlternativeTitle(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case strings.HasPrefix(r.URL.Path, "/search/tv"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{{ + "id": 7583, + "name": "The Rookie", + "original_name": "The Rookie", + "original_language": "en", + "first_air_date": "2007-03-26", + }}, + }) + case r.URL.Path == "/tv/7583": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 7583, + "name": "The Rookie", + "original_name": "The Rookie", + "original_language": "en", + "first_air_date": "2007-03-26", + "origin_country": []string{"US"}, + "alternative_titles": map[string]any{ + "results": []map[string]any{{ + "iso_3166_1": "CN", + "title": "菜鸟老警", + }}, + }, + }) + default: + http.NotFound(w, r) + } + })) + defer upstream.Close() + + db, err := gorm.Open(sqlite.Open(":memory:"), &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() + tmdb := NewTMDbProvider(cfg, log, nil) + scraper := NewScraperService(cfg, log, repos, tmdb, nil, nil, nil, NewHub(log)) + + root := t.TempDir() + src := filepath.Join(root, "downloads") + dest := filepath.Join(root, "media") + sourceFile := filepath.Join(src, "The.Rookie.2007.S04E01.1080p.WEB-DL.mkv") + writeOrgFile(t, sourceFile, "episode") + + organizer := NewOrganizerService(cfg, log, repos) + organizer.SetScraper(scraper) + result, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: src, + DestPath: dest, + TransferMode: TransferCopy, + MediaType: "tv", + }) + if err != nil { + t.Fatalf("organize directory: %v", err) + } + + want := filepath.Join(dest, "电视剧", "菜鸟老警", "Season 04", "菜鸟老警 - S04E01.mkv") + if _, err := os.Stat(want); err != nil { + t.Fatalf("organized file should use TMDb Chinese alternative title %q: %v; items=%#v", want, err, result.Items) + } + var stored model.Media + if err := repos.DB.First(&stored, "path = ?", want).Error; err != nil { + t.Fatal(err) + } + if stored.Title != "菜鸟老警" || stored.OriginalName != "The Rookie" || stored.TMDbID != 7583 { + t.Fatalf("stored title=%q original=%q tmdb=%d, want localized Chinese metadata", stored.Title, stored.OriginalName, stored.TMDbID) + } +} diff --git a/internal/service/organizer_reclassify_scanned.go b/internal/service/organizer_reclassify_scanned.go index 0cfa710..7f3af80 100644 --- a/internal/service/organizer_reclassify_scanned.go +++ b/internal/service/organizer_reclassify_scanned.go @@ -147,6 +147,11 @@ func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media mod category := overrideCategory if category == "" { category = o.classifyMedia(ctx, &media, mediaType) + if isUnclassifiedTVCategory(category, o.categoryMap()) && !mediaHasReliableCategoryMetadata(media) { + if existingType, existingCategory := o.mediaTypeForDirectoryCategory(firstNonEmpty(lib.Name, filepath.Base(lib.Path))); existingType != "" && sourceCategoryCompatible(mediaType, existingType) { + category = existingCategory + } + } } if category == "" { return false, nil diff --git a/internal/service/scraper.go b/internal/service/scraper.go index 3aed197..e424932 100644 --- a/internal/service/scraper.go +++ b/internal/service/scraper.go @@ -93,8 +93,10 @@ func (s *ScraperService) EnrichOneWithOptions(ctx context.Context, m *model.Medi if local != nil && !local.PathHint { return s.applyLocalMetadataMatch(ctx, m, local) } - _ = s.repo.DB.Model(&model.Media{}).Where("id = ?", m.ID). - Update("scrape_status", "no_match").Error + if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", m.ID). + Update("scrape_status", "no_match").Error; err != nil { + return err + } s.invalidateMediaCache(ctx) s.log.Info("metadata scrape no match", zap.String("media_id", m.ID), diff --git a/internal/service/scraper_alias_match_test.go b/internal/service/scraper_alias_match_test.go new file mode 100644 index 0000000..ad3bf38 --- /dev/null +++ b/internal/service/scraper_alias_match_test.go @@ -0,0 +1,133 @@ +package service + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func TestEnrichOneUsesAlternateLanguageTitleAndKeepsLocalizedMetadata(t *testing.T) { + 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 + } + switch r.URL.Query().Get("language") { + case "en-US": + _ = json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{ + { + "id": 292696, "name": "The First Jasmine", "original_name": "The First Jasmine", + "first_air_date": "2026-01-01", "origin_country": []string{"CN"}, + }, + }}) + default: + _ = json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{ + { + "id": 999001, "name": "First Love", "original_name": "First Love", + "first_air_date": "2026-01-01", "origin_country": []string{"US"}, + }, + { + "id": 292696, "name": "莫离", "original_name": "莫离", + "first_air_date": "2026-01-01", "origin_country": []string{"CN"}, + "poster_path": "/jasmine.jpg", + }, + }}) + } + })) + defer upstream.Close() + + repos := newOrganizerTestRepo(t) + 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)) + + lib := model.Library{Name: "未分类", Path: `/media/电视剧/未分类`, Type: "tv", Enabled: true} + if err := repos.DB.Create(&lib).Error; err != nil { + t.Fatal(err) + } + media := model.Media{ + LibraryID: lib.ID, + Title: "The First Jasmine", + Path: `/media/电视剧/未分类/The.First.Jasmine.S01E01.2026.mkv`, + Year: 2026, + SeasonNum: 1, + EpisodeNum: 1, + 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) + } + got, err := repos.Media.FindByID(t.Context(), media.ID) + if err != nil || got == nil { + t.Fatalf("load media: %v", err) + } + if got.TMDbID != 292696 || got.Title != "莫离" || got.ScrapeStatus != "matched" { + t.Fatalf("matched media=%+v, want localized correct TMDb result", got) + } + if got.SeasonNum != 1 || got.EpisodeNum != 1 { + t.Fatalf("episode markers changed after TV match: season=%d episode=%d", got.SeasonNum, got.EpisodeNum) + } +} + +func TestEnrichOneDoesNotFallbackTVEpisodeToMovie(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": []any{}}) + case "/search/movie": + _ = json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{ + {"id": 880001, "title": "Unsettled Case", "original_title": "Unsettled Case", "release_date": "2026-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 + log := zap.NewNop() + scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log)) + + lib := model.Library{Name: "欧美剧", Path: `/media/电视剧/欧美剧`, Type: "tv", Enabled: true} + if err := repos.DB.Create(&lib).Error; err != nil { + t.Fatal(err) + } + media := model.Media{ + LibraryID: lib.ID, Title: "Unsettled Case", Path: `/media/电视剧/欧美剧/Unsettled.Case.S01E04.mkv`, + SeasonNum: 1, EpisodeNum: 4, 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) + } + got, err := repos.Media.FindByID(t.Context(), media.ID) + if err != nil || got == nil { + t.Fatalf("load media: %v", err) + } + if got.ScrapeStatus != "no_match" || got.TMDbID != 0 { + t.Fatalf("TV episode incorrectly accepted movie metadata: %+v", got) + } + if got.SeasonNum != 1 || got.EpisodeNum != 4 { + t.Fatalf("TV episode collection state was cleared: season=%d episode=%d", got.SeasonNum, got.EpisodeNum) + } +} diff --git a/internal/service/scraper_candidate_selection.go b/internal/service/scraper_candidate_selection.go new file mode 100644 index 0000000..ef0b5ee --- /dev/null +++ b/internal/service/scraper_candidate_selection.go @@ -0,0 +1,244 @@ +package service + +import ( + "context" + "strings" + + "go.uber.org/zap" +) + +func (s *ScraperService) lookupAutomaticTMDb(ctx context.Context, kind, query string, year int) *Match { + var ( + candidates []*Match + err error + mediaLabel string + ) + if isTVMetadataKind(kind) { + mediaLabel = "tv" + candidates, err = s.tmdb.SearchTVCandidates(ctx, query, year) + } else { + mediaLabel = "movie" + candidates, err = s.tmdb.SearchMovieCandidates(ctx, query, year) + } + if err != nil { + s.log.Debug("tmdb "+mediaLabel+" search failed", zap.String("query", query), zap.Error(err)) + return nil + } + if match := bestAutomaticMetadataMatch(query, year, kind, candidates); match != nil { + return s.localizeAutomaticTMDbMatch(ctx, kind, query, match) + } + if !queryNeedsEnglishTMDbFallback(query) { + return nil + } + + var alternate []*Match + if isTVMetadataKind(kind) { + alternate, err = s.tmdb.searchTVCandidates(ctx, query, year, "en-US") + } else { + alternate, err = s.tmdb.searchMovieCandidates(ctx, query, year, "en-US") + } + if err != nil { + s.log.Debug("tmdb "+mediaLabel+" alternate-language search failed", zap.String("query", query), zap.Error(err)) + return nil + } + match := bestAutomaticMetadataMatch(query, year, kind, mergeTMDbLanguageCandidates(candidates, alternate)) + return s.localizeAutomaticTMDbMatch(ctx, kind, query, match) +} + +func (s *ScraperService) localizeAutomaticTMDbMatch(ctx context.Context, kind, query string, match *Match) *Match { + if s == nil || s.tmdb == nil || match == nil || match.TMDbID <= 0 || !metadataTitleNeedsChineseLocalization(match) { + return match + } + var ( + localized *Match + err error + ) + if isTVMetadataKind(kind) { + localized, err = s.tmdb.GetTVMatch(ctx, match.TMDbID) + } else { + localized, err = s.tmdb.GetMovieMatch(ctx, match.TMDbID) + } + if err != nil || localized == nil { + if err != nil && s.log != nil { + s.log.Debug("tmdb localized title lookup failed", + zap.String("query", query), + zap.Int("tmdb_id", match.TMDbID), + zap.Error(err)) + } + return match + } + mergeAutomaticTMDbLocalizedMatch(localized, match) + localized.SearchKeyword = query + localized.Aliases = appendMetadataAliases(localized.Aliases, + match.Title, match.OriginalName) + localized.Aliases = appendMetadataAliases(localized.Aliases, match.Aliases...) + return localized +} + +func mergeAutomaticTMDbLocalizedMatch(localized, search *Match) { + if localized == nil || search == nil { + return + } + if strings.TrimSpace(localized.OriginalName) == "" { + localized.OriginalName = strings.TrimSpace(firstNonEmpty(search.OriginalName, search.Title)) + } + localized.Overview = firstNonEmpty(localized.Overview, search.Overview) + localized.PosterURL = firstNonEmpty(localized.PosterURL, search.PosterURL) + localized.BackdropURL = firstNonEmpty(localized.BackdropURL, search.BackdropURL) + localized.MediaType = firstNonEmpty(localized.MediaType, search.MediaType) + if localized.Year <= 0 { + localized.Year = search.Year + } + if localized.ReleaseDate == "" { + localized.ReleaseDate = search.ReleaseDate + } + if localized.Rating <= 0 { + localized.Rating = search.Rating + } + if len(localized.Languages) == 0 { + localized.Languages = append([]string(nil), search.Languages...) + } + if len(localized.Countries) == 0 { + localized.Countries = append([]string(nil), search.Countries...) + } + if len(localized.Genres) == 0 { + localized.Genres = append([]string(nil), search.Genres...) + } +} + +func bestAutomaticMetadataMatch(query string, year int, expectedType string, candidates []*Match) *Match { + bestScore := -1 + var best *Match + for index, candidate := range candidates { + if candidate == nil || !metadataMatchCompatibleWithType(expectedType, candidate) { + continue + } + if !organizeMetadataMatchTrusted(query, year, candidate) { + continue + } + score := automaticMetadataMatchScore(query, year, candidate) - index + if score > bestScore { + bestScore = score + best = candidate + } + } + return best +} + +func automaticMetadataMatchScore(query string, year int, match *Match) int { + queryKey := metadataTrustKey(query) + score := 0 + titles := make([]string, 0, 2+len(match.Aliases)) + titles = append(titles, match.Title, match.OriginalName) + titles = append(titles, match.Aliases...) + for _, title := range titles { + titleKey := metadataTrustKey(title) + switch { + case titleKey != "" && titleKey == queryKey: + if score < 1000 { + score = 1000 + } + case metadataTrustTokenOverlap(queryKey, titleKey): + if score < 700 { + score = 700 + } + } + } + if year > 0 && match.Year > 0 { + diff := year - match.Year + if diff < 0 { + diff = -diff + } + switch diff { + case 0: + score += 200 + case 1: + score += 100 + } + } + if metadataMatchHasExternalID(match) { + score += 20 + } + if strings.TrimSpace(match.PosterURL) != "" { + score += 5 + } + return score +} + +func metadataMatchCompatibleWithType(expectedType string, match *Match) bool { + if match == nil { + return false + } + expectedType = normalizeOrganizeMediaType(expectedType) + matchType := normalizeOrganizeMediaType(match.MediaType) + if expectedType == "" || matchType == "" { + return true + } + switch expectedType { + case "tv", "anime", "variety": + return matchType == "tv" || matchType == "anime" || matchType == "variety" + case "movie", "adult": + return matchType == "movie" || matchType == "adult" + default: + return expectedType == matchType + } +} + +func queryNeedsEnglishTMDbFallback(query string) bool { + for _, r := range query { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { + return true + } + } + return false +} + +func mergeTMDbLanguageCandidates(primary, alternate []*Match) []*Match { + if len(primary) == 0 { + return alternate + } + byID := make(map[int]*Match, len(primary)) + out := append([]*Match(nil), primary...) + for _, candidate := range primary { + if candidate != nil && candidate.TMDbID > 0 { + byID[candidate.TMDbID] = candidate + } + } + for _, candidate := range alternate { + if candidate == nil { + continue + } + if localized := byID[candidate.TMDbID]; localized != nil { + localized.Aliases = appendMetadataAliases(localized.Aliases, + candidate.Title, candidate.OriginalName) + localized.Aliases = appendMetadataAliases(localized.Aliases, candidate.Aliases...) + continue + } + out = append(out, candidate) + } + return out +} + +func appendMetadataAliases(existing []string, values ...string) []string { + seen := make(map[string]struct{}, len(existing)+len(values)) + out := make([]string, 0, len(existing)+len(values)) + add := func(value string) { + value = strings.TrimSpace(value) + key := metadataTrustKey(value) + if value == "" || key == "" { + return + } + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + out = append(out, value) + } + for _, value := range existing { + add(value) + } + for _, value := range values { + add(value) + } + return out +} diff --git a/internal/service/scraper_library.go b/internal/service/scraper_library.go index 37d6e92..5d71af3 100644 --- a/internal/service/scraper_library.go +++ b/internal/service/scraper_library.go @@ -18,40 +18,34 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media * if lib != nil { kind = lib.Type } - if mediaIsEpisodic(media, lib) { + // An explicit movie lookup is used to repair filenames whose release year + // was previously polluted into S01E20x. Do not let the dirty path override + // that caller-supplied media type; TV/anime libraries still force TV below. + explicitEpisode := media != nil && (media.SeasonNum > 0 || media.EpisodeNum > 0) + if (normalizeOrganizeMediaType(kind) != "movie" || explicitEpisode) && mediaIsEpisodic(media, lib) { kind = "tv" } if s.tmdb != nil && s.tmdb.Enabled() { - // anime / tv 先用 TMDb /search/tv(剧名通常是 TV 类目)。 - if kind == "anime" || kind == "tv" || kind == "variety" || kind == "show" || kind == "shows" { - if m, err := s.tmdb.SearchTV(ctx, query, year); err == nil && m != nil { - return m - } else if err != nil { - s.log.Debug("tmdb tv search failed", zap.String("query", query), zap.Error(err)) - } - } - if m, err := s.tmdb.SearchMovie(ctx, query, year); err == nil && m != nil { - return m - } else if err != nil { - s.log.Debug("tmdb movie search failed", zap.String("query", query), zap.Error(err)) + if match := s.lookupAutomaticTMDb(ctx, kind, query, year); match != nil { + return match } } if s.douban != nil && s.douban.Enabled() { - if m, err := s.douban.SearchMatch(ctx, query); err == nil && m != nil { + if m, err := s.douban.SearchMatch(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithType(kind, m) { return m } else if err != nil { s.log.Debug("douban search failed", zap.String("query", query), zap.Error(err)) } } if s.bangumi != nil && s.bangumi.Enabled() { - if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil { + if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithType(kind, m) { return m } else if err != nil { s.log.Debug("bangumi search failed", zap.String("query", query), zap.Error(err)) } } if (kind == "anime" || kind == "tv" || kind == "variety" || kind == "show" || kind == "shows") && s.thetvdb != nil && s.thetvdb.Enabled() { - if m, err := s.thetvdb.SearchSeries(ctx, query); err == nil && m != nil { + if m, err := s.thetvdb.SearchSeries(ctx, query); err == nil && m != nil && metadataMatchCompatibleWithType(kind, m) { return m } else if err != nil { s.log.Debug("thetvdb search failed", zap.String("query", query), zap.Error(err)) @@ -60,6 +54,15 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media * return nil } +func isTVMetadataKind(kind string) bool { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "anime", "tv", "variety", "show", "shows": + return true + default: + return false + } +} + // EnrichLibrary runs the provider chain for every pending media in a library. // When retryNoMatch is true it also retries rows previously marked no_match, // which is the expected behaviour for a manual "重新刮削" action. Scanner-driven diff --git a/internal/service/scraper_query_clean.go b/internal/service/scraper_query_clean.go index d39fdca..5c3ce1f 100644 --- a/internal/service/scraper_query_clean.go +++ b/internal/service/scraper_query_clean.go @@ -55,6 +55,8 @@ var releaseBoundaryTokenSet = map[string]struct{}{ "x264": {}, "x265": {}, "h264": {}, "h265": {}, "h266": {}, "hevc": {}, "avc": {}, "av1": {}, "vvc": {}, } +var dynamicReleaseBoundaryTokenRE = regexp.MustCompile(`(?i)^(?:\d{3,4}p|\d{2,3}fps)$`) + // bracketedTag matches "[anything]", "(anything)" or "{anything}" segments. var bracketedTag = regexp.MustCompile(`[\[\(\{][^\]\)\}]*[\]\)\}]`) var multiWordNoise = []*regexp.Regexp{ @@ -105,6 +107,10 @@ func CleanQuery(raw string) (title string, year int) { out := make([]string, 0, 8) seenReleaseBoundary := false for _, w := range strings.Fields(lower) { + if dynamicReleaseBoundaryTokenRE.MatchString(w) { + seenReleaseBoundary = true + continue + } if _, ok := noiseTokenSet[w]; ok { if _, boundary := releaseBoundaryTokenSet[w]; boundary { seenReleaseBoundary = true diff --git a/internal/service/scraper_query_movie_test.go b/internal/service/scraper_query_movie_test.go index 32789b6..b98caae 100644 --- a/internal/service/scraper_query_movie_test.go +++ b/internal/service/scraper_query_movie_test.go @@ -60,6 +60,29 @@ func TestScrapeQueryCandidatesUseMovieLibraryRootWhenMountedAtMovieFolder(t *tes } } +func TestScrapeQueryCandidatesDoNotUseMovieCollectionFolderAsTitle(t *testing.T) { + lib := &model.Library{Path: `/media/movies`, Type: "movie"} + media := &model.Media{ + Title: "The Hunger Games Catching Fire", + Year: 2013, + Path: `/media/movies/The.Hunger.Games.Complete.4-Film.Collection/` + + `The.Hunger.Games.Catching.Fire.2013.2160p.mkv`, + } + + got := scrapeQueryCandidates(media, lib) + if len(got) == 0 { + t.Fatal("scrapeQueryCandidates returned no candidates") + } + if got[0] != "the hunger games catching fire" { + t.Fatalf("first query candidate = %q, want individual movie title; all candidates=%#v", got[0], got) + } + for _, candidate := range got { + if strings.Contains(strings.ToLower(candidate), "complete 4 film collection") { + t.Fatalf("movie collection folder leaked into scrape candidates: %#v", got) + } + } +} + func TestEnrichOneUsesMovieFolderWhenFilenameIsGeneric(t *testing.T) { var queries []string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/service/scraper_query_paths.go b/internal/service/scraper_query_paths.go index 390c6db..f28ccda 100644 --- a/internal/service/scraper_query_paths.go +++ b/internal/service/scraper_query_paths.go @@ -17,6 +17,10 @@ func mediaFolderTitle(mediaPath, libraryRoot string) string { dir = parentSlashPath(dir) continue } + if isMediaCollectionFolder(base) { + dir = parentSlashPath(dir) + continue + } if isGenericMediaCategoryFolder(base) { return "" } @@ -114,7 +118,7 @@ func libraryRootTitle(libraryRoot string) string { } else { base = pathBaseSlash(libraryRoot) } - if base == "" || base == "." || isGenericMediaCategoryFolder(base) || isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) { + if base == "" || base == "." || isGenericMediaCategoryFolder(base) || isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) || isMediaCollectionFolder(base) { return "" } return base diff --git a/internal/service/scraper_query_trust_test.go b/internal/service/scraper_query_trust_test.go index af53a76..fc9e96e 100644 --- a/internal/service/scraper_query_trust_test.go +++ b/internal/service/scraper_query_trust_test.go @@ -53,7 +53,7 @@ func TestOrganizeMetadataRejectsLooseChineseOriginEnglishAlias(t *testing.T) { } } -func TestOrganizeMetadataTrustsLocalizedSearchKeyword(t *testing.T) { +func TestOrganizeMetadataRejectsUnverifiedLocalizedSearchKeyword(t *testing.T) { match := &Match{ Title: "Monarch: Legacy of Monsters", OriginalName: "Monarch: Legacy of Monsters", @@ -61,12 +61,21 @@ func TestOrganizeMetadataTrustsLocalizedSearchKeyword(t *testing.T) { TMDbID: 202411, SearchKeyword: "帝王计划:怪兽遗产", } - if !organizeMetadataMatchTrusted("帝王计划:怪兽遗产", 2023, match) { - t.Fatal("localized TMDb search keyword should be trusted even when returned title is not localized") + if organizeMetadataMatchTrusted("帝王计划:怪兽遗产", 2023, match) { + t.Fatal("echoing the search keyword must not make an unrelated first result trustworthy") } - preferLocalizedSearchTitle("帝王计划:怪兽遗产", match) - if match.Title != "帝王计划:怪兽遗产" || match.OriginalName != "Monarch: Legacy of Monsters" { - t.Fatalf("localized title not preserved: title=%q original=%q", match.Title, match.OriginalName) +} + +func TestOrganizeMetadataTrustsProviderReturnedAlias(t *testing.T) { + match := &Match{ + Title: "镖人", + OriginalName: "镖人", + Aliases: []string{"Blades of the Guardians"}, + Year: 2023, + TMDbID: 107463, + } + if !organizeMetadataMatchTrusted("blades of the guardians", 2023, match) { + t.Fatal("a title returned by another TMDb language response should be trusted as an alias") } } diff --git a/internal/service/scraper_test.go b/internal/service/scraper_test.go index 7dd54ed..ecac96d 100644 --- a/internal/service/scraper_test.go +++ b/internal/service/scraper_test.go @@ -1,13 +1,49 @@ package service import ( + "errors" "path/filepath" "strings" "testing" "github.com/ShukeBta/MediaStationGo/internal/model" + "gorm.io/gorm" ) +func TestEnrichOneReturnsNoMatchPersistenceError(t *testing.T) { + scraper, repos, closeServer := newTestScraper(t) + defer closeServer() + + lib := model.Library{Name: "电影", Path: t.TempDir(), Type: "movie", Enabled: true} + if err := repos.DB.Create(&lib).Error; err != nil { + t.Fatal(err) + } + media := model.Media{ + LibraryID: lib.ID, + Title: "Definitely Missing Metadata Candidate", + Path: filepath.Join(lib.Path, "missing.mkv"), + ScrapeStatus: "pending", + } + if err := repos.DB.Create(&media).Error; err != nil { + t.Fatal(err) + } + + wantErr := errors.New("forced no-match update failure") + callbackName := "test:fail-no-match-update" + if err := repos.DB.Callback().Update().Before("gorm:update").Register(callbackName, func(tx *gorm.DB) { + if tx.Statement.Table == "media" { + tx.AddError(wantErr) + } + }); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = repos.DB.Callback().Update().Remove(callbackName) }) + + if err := scraper.EnrichOne(t.Context(), &media); !errors.Is(err, wantErr) { + t.Fatalf("EnrichOne() error=%v, want %v", err, wantErr) + } +} + func TestEnrichOneUsesExistingTMDbIDForCloudMedia(t *testing.T) { scraper, repos, closeServer := newTestScraper(t) defer closeServer() diff --git a/internal/service/tmdb_chinese_title.go b/internal/service/tmdb_chinese_title.go new file mode 100644 index 0000000..08bc4db --- /dev/null +++ b/internal/service/tmdb_chinese_title.go @@ -0,0 +1,84 @@ +package service + +import "strings" + +type tmdbAlternativeTitle struct { + Country string `json:"iso_3166_1"` + Title string `json:"title"` +} + +type tmdbTranslation struct { + Country string `json:"iso_3166_1"` + Language string `json:"iso_639_1"` + Data struct { + Title string `json:"title"` + Name string `json:"name"` + } `json:"data"` +} + +func applyTMDbChineseTitle(match *Match, alternatives []tmdbAlternativeTitle, translations []tmdbTranslation) { + if match == nil { + return + } + aliases := make([]string, 0, 2+len(alternatives)+len(translations)) + aliases = append(aliases, match.Title, match.OriginalName) + for _, alternative := range alternatives { + aliases = append(aliases, alternative.Title) + } + for _, translation := range translations { + aliases = append(aliases, firstNonEmpty(translation.Data.Title, translation.Data.Name)) + } + match.Aliases = appendMetadataAliases(match.Aliases, aliases...) + + if !metadataTitleNeedsChineseLocalization(match) { + return + } + localized := preferredTMDbChineseTitle(alternatives, translations) + if localized == "" { + return + } + if strings.TrimSpace(match.OriginalName) == "" { + match.OriginalName = strings.TrimSpace(match.Title) + } + match.Title = localized +} + +func preferredTMDbChineseTitle(alternatives []tmdbAlternativeTitle, translations []tmdbTranslation) string { + for _, country := range []string{"CN", "SG", "HK", "TW"} { + for _, alternative := range alternatives { + if strings.EqualFold(strings.TrimSpace(alternative.Country), country) && chineseTitleCandidate(alternative.Title) { + return strings.TrimSpace(alternative.Title) + } + } + for _, translation := range translations { + if !strings.EqualFold(strings.TrimSpace(translation.Language), "zh") || + !strings.EqualFold(strings.TrimSpace(translation.Country), country) { + continue + } + if title := strings.TrimSpace(firstNonEmpty(translation.Data.Title, translation.Data.Name)); chineseTitleCandidate(title) { + return title + } + } + } + return "" +} + +func metadataTitleNeedsChineseLocalization(match *Match) bool { + if match == nil || strings.TrimSpace(match.Title) == "" { + return true + } + if !containsCJK(match.Title) { + return true + } + for _, r := range match.Title { + if (r >= '\u3040' && r <= '\u30ff') || (r >= '\uac00' && r <= '\ud7af') { + return true + } + } + return false +} + +func chineseTitleCandidate(title string) bool { + title = strings.TrimSpace(title) + return title != "" && containsCJK(title) +} diff --git a/internal/service/tmdb_chinese_title_test.go b/internal/service/tmdb_chinese_title_test.go new file mode 100644 index 0000000..fb2ea12 --- /dev/null +++ b/internal/service/tmdb_chinese_title_test.go @@ -0,0 +1,75 @@ +package service + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/config" +) + +func TestApplyTMDbChineseTitlePrefersMainlandAlternative(t *testing.T) { + match := &Match{Title: "The Rookie", OriginalName: "The Rookie"} + applyTMDbChineseTitle(match, []tmdbAlternativeTitle{ + {Country: "TW", Title: "菜鳥新移民"}, + {Country: "CN", Title: "菜鸟老警"}, + }, nil) + + if match.Title != "菜鸟老警" || match.OriginalName != "The Rookie" { + t.Fatalf("title=%q original=%q, want mainland Chinese title with original preserved", match.Title, match.OriginalName) + } +} + +func TestApplyTMDbChineseTitleFallsBackToSingaporeTranslation(t *testing.T) { + match := &Match{Title: "English Title", OriginalName: "English Title"} + translation := tmdbTranslation{Country: "SG", Language: "zh"} + translation.Data.Name = "新加坡中文名" + applyTMDbChineseTitle(match, nil, []tmdbTranslation{translation}) + + if match.Title != "新加坡中文名" { + t.Fatalf("title=%q, want Singapore Chinese translation", match.Title) + } +} + +func TestGetMovieMatchUsesChineseAlternativeTitle(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/movie/1292695" { + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("append_to_response"); !strings.Contains(got, "alternative_titles") { + t.Fatalf("append_to_response=%q, want alternative_titles", got) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 1292695, + "title": "They Will Kill You", + "original_title": "They Will Kill You", + "original_language": "en", + "release_date": "2026-03-27", + "alternative_titles": map[string]any{ + "titles": []map[string]any{{ + "iso_3166_1": "CN", + "title": "杀的就是你", + }}, + }, + }) + })) + defer upstream.Close() + + cfg := &config.Config{} + cfg.Secrets.TMDbAPIKey = "test-key" + cfg.Secrets.TMDbAPIProxy = upstream.URL + provider := NewTMDbProvider(cfg, zap.NewNop(), nil) + match, err := provider.GetMovieMatch(t.Context(), 1292695) + if err != nil { + t.Fatal(err) + } + if match == nil || match.Title != "杀的就是你" || match.OriginalName != "They Will Kill You" { + t.Fatalf("match=%#v, want Chinese movie title with English original", match) + } +} diff --git a/internal/service/tmdb_match.go b/internal/service/tmdb_match.go index 7ae130f..533ecdf 100644 --- a/internal/service/tmdb_match.go +++ b/internal/service/tmdb_match.go @@ -18,6 +18,7 @@ func (t *TMDbProvider) GetMovieMatch(ctx context.Context, tmdbID int) (*Match, e q := url.Values{} q.Set("api_key", apiKey) q.Set("language", "zh-CN") + q.Set("append_to_response", "alternative_titles,translations") u := base + "/movie/" + fmt.Sprint(tmdbID) + "?" + q.Encode() var r struct { ID int `json:"id"` @@ -38,6 +39,12 @@ func (t *TMDbProvider) GetMovieMatch(ctx context.Context, tmdbID int) (*Match, e SpokenLanguages []struct { Iso639_1 string `json:"iso_639_1"` } `json:"spoken_languages"` + AlternativeTitles struct { + Titles []tmdbAlternativeTitle `json:"titles"` + } `json:"alternative_titles"` + Translations struct { + Translations []tmdbTranslation `json:"translations"` + } `json:"translations"` } if err := t.getJSON(ctx, u, &r); err != nil { return nil, err @@ -54,6 +61,7 @@ func (t *TMDbProvider) GetMovieMatch(ctx context.Context, tmdbID int) (*Match, e if m.Title == "" { m.Title = r.OriginalTitle } + applyTMDbChineseTitle(m, r.AlternativeTitles.Titles, r.Translations.Translations) if r.PosterPath != "" { m.PosterURL = t.imgCDN + "/w500" + r.PosterPath } @@ -91,6 +99,7 @@ func (t *TMDbProvider) GetTVMatch(ctx context.Context, tmdbID int) (*Match, erro q := url.Values{} q.Set("api_key", apiKey) q.Set("language", "zh-CN") + q.Set("append_to_response", "alternative_titles,translations") u := base + "/tv/" + fmt.Sprint(tmdbID) + "?" + q.Encode() var r struct { ID int `json:"id"` @@ -109,6 +118,12 @@ func (t *TMDbProvider) GetTVMatch(ctx context.Context, tmdbID int) (*Match, erro SpokenLanguages []struct { Iso639_1 string `json:"iso_639_1"` } `json:"spoken_languages"` + AlternativeTitles struct { + Results []tmdbAlternativeTitle `json:"results"` + } `json:"alternative_titles"` + Translations struct { + Translations []tmdbTranslation `json:"translations"` + } `json:"translations"` } if err := t.getJSON(ctx, u, &r); err != nil { return nil, err @@ -126,6 +141,7 @@ func (t *TMDbProvider) GetTVMatch(ctx context.Context, tmdbID int) (*Match, erro if m.Title == "" { m.Title = r.OriginalName } + applyTMDbChineseTitle(m, r.AlternativeTitles.Results, r.Translations.Translations) if r.PosterPath != "" { m.PosterURL = t.imgCDN + "/w500" + r.PosterPath } diff --git a/internal/service/tmdb_search.go b/internal/service/tmdb_search.go index 16f2e5c..44e19dd 100644 --- a/internal/service/tmdb_search.go +++ b/internal/service/tmdb_search.go @@ -44,10 +44,13 @@ func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int) return matches[0], nil } -// SearchMovieCandidates returns the first TMDb result page as manual-scrape -// candidates. Automatic scrape still uses SearchMovie's first-result behavior, -// while manual correction can show alternatives when the top result is wrong. +// SearchMovieCandidates returns the first localized TMDb result page. Manual +// scrape exposes the full page; automatic scrape ranks the same candidates. func (t *TMDbProvider) SearchMovieCandidates(ctx context.Context, query string, year int) ([]*Match, error) { + return t.searchMovieCandidates(ctx, query, year, "zh-CN") +} + +func (t *TMDbProvider) searchMovieCandidates(ctx context.Context, query string, year int, language string) ([]*Match, error) { if query == "" { return nil, errors.New("empty query") } @@ -61,7 +64,7 @@ func (t *TMDbProvider) SearchMovieCandidates(ctx context.Context, query string, q := url.Values{} q.Set("api_key", apiKey) q.Set("query", query) - q.Set("language", "zh-CN") + q.Set("language", language) q.Set("include_adult", "false") if year > 0 { q.Set("year", fmt.Sprintf("%d", year)) @@ -124,6 +127,10 @@ func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*M // SearchTVCandidates returns the first TMDb TV result page for manual scrape. func (t *TMDbProvider) SearchTVCandidates(ctx context.Context, query string, year int) ([]*Match, error) { + return t.searchTVCandidates(ctx, query, year, "zh-CN") +} + +func (t *TMDbProvider) searchTVCandidates(ctx context.Context, query string, year int, language string) ([]*Match, error) { if query == "" { return nil, errors.New("empty query") } @@ -137,7 +144,7 @@ func (t *TMDbProvider) SearchTVCandidates(ctx context.Context, query string, yea q := url.Values{} q.Set("api_key", apiKey) q.Set("query", query) - q.Set("language", "zh-CN") + q.Set("language", language) q.Set("include_adult", "false") if year > 0 { q.Set("first_air_date_year", fmt.Sprintf("%d", year)) diff --git a/internal/service/tmdb_types.go b/internal/service/tmdb_types.go index d9b8581..bbd8822 100644 --- a/internal/service/tmdb_types.go +++ b/internal/service/tmdb_types.go @@ -20,6 +20,7 @@ type Match struct { Languages []string `json:"languages,omitempty"` Countries []string `json:"countries,omitempty"` Genres []string `json:"genres,omitempty"` + Aliases []string `json:"aliases,omitempty"` NSFW bool `json:"nsfw,omitempty"` SearchKeyword string `json:"-"` } diff --git a/web/src/utils/groupSeries.ts b/web/src/utils/groupSeries.ts index b185822..68168fb 100644 --- a/web/src/utils/groupSeries.ts +++ b/web/src/utils/groupSeries.ts @@ -44,17 +44,19 @@ function getSeriesRawKey(media: Media): string { const pathID = seriesExternalIDFromPath(media.path) if (pathID) return seriesFingerprint('library-path-id', targetLibraryID(media), pathID) // 无路径剧名(扁平目录)时才退而用外部 id;此时各集若共享整剧 id 仍能合并。 - if (media.tmdb_id && media.tmdb_id > 0) return `tmdb:${media.tmdb_id}` - if (media.bangumi_id && media.bangumi_id > 0) return `bgm:${media.bangumi_id}` - if (media.douban_id) return `douban:${media.douban_id}` - if (media.thetvdb_id) return `thetvdb:${media.thetvdb_id}` + if (media.tmdb_id && media.tmdb_id > 0) return seriesFingerprint('episodic-external', `tmdb:${media.tmdb_id}`) + if (media.bangumi_id && media.bangumi_id > 0) return seriesFingerprint('episodic-external', `bgm:${media.bangumi_id}`) + if (media.douban_id) return seriesFingerprint('episodic-external', `douban:${media.douban_id}`) + if (media.thetvdb_id) return seriesFingerprint('episodic-external', `thetvdb:${media.thetvdb_id}`) if (media.series_id) return `series:${media.series_id}` return seriesFingerprint('library-title', targetLibraryID(media), normalizeTitle(seriesTitle(media))) } if (media.series_id) return `series:${media.series_id}` - if (media.tmdb_id && media.tmdb_id > 0) return `tmdb:${media.tmdb_id}` - if (media.bangumi_id && media.bangumi_id > 0) return `bgm:${media.bangumi_id}` - if (fromPath) return seriesFingerprint('library-path', media.library_id, fromPath) + if (media.tmdb_id && media.tmdb_id > 0) return seriesFingerprint('movie-external', `tmdb:${media.tmdb_id}`) + if (media.bangumi_id && media.bangumi_id > 0) return seriesFingerprint('movie-external', `bgm:${media.bangumi_id}`) + if (fromPath && !mediaParentLooksLikeCollection(media.path)) { + return seriesFingerprint('library-path', media.library_id, fromPath) + } return seriesFingerprint('library-title', media.library_id, normalizeTitle(media.title)) } @@ -250,16 +252,43 @@ function seriesPathPartLooksLikeFile(part: string): boolean { export function groupSeries(items: Media[] = []): SeriesCard[] { const safeItems = Array.isArray(items) ? items : [] + const pathCounts = new Map() + const externalCounts = new Map() + const titleCounts = new Map() + for (const media of safeItems) { + if (!media || (!isEpisodeLike(media) && !pathLooksEpisodic(media))) continue + const pathKey = getSeriesRawKey(media) + if (pathKey.startsWith('library-path')) { + pathCounts.set(pathKey, (pathCounts.get(pathKey) ?? 0) + 1) + } + const externalKey = repeatedSeriesExternalRawKey(media) + if (externalKey) externalCounts.set(externalKey, (externalCounts.get(externalKey) ?? 0) + 1) + const titleKey = repeatedSeriesTitleRawKey(media) + if (titleKey) titleCounts.set(titleKey, (titleCounts.get(titleKey) ?? 0) + 1) + } const groups = new Map() for (const m of safeItems) { if (!m) continue - const key = getSeriesKey(m) + const pathKey = getSeriesRawKey(m) + const externalKey = repeatedSeriesExternalRawKey(m) + const titleKey = repeatedSeriesTitleRawKey(m) + const key = pathKey.startsWith('library-path') && (pathCounts.get(pathKey) ?? 0) > 1 + ? compactSeriesKey(pathKey) + : externalKey && (externalCounts.get(externalKey) ?? 0) > 1 + ? compactSeriesKey(externalKey) + : titleKey && (titleCounts.get(titleKey) ?? 0) > 1 + ? compactSeriesKey(titleKey) + : getSeriesKey(m) const g = groups.get(key) if (!g) { groups.set(key, { key, rep: m, linkMedia: m, count: 1 }) } else { - g.count += 1 + // Repeated movie IDs represent alternate locations/encodes, not + // episodes. Fold the versions but keep the card in movie mode. + if (isEpisodeLike(m) || pathLooksEpisodic(m) || isEpisodeLike(g.linkMedia) || pathLooksEpisodic(g.linkMedia)) { + g.count += 1 + } if (betterSeriesLinkMedia(m, g.linkMedia)) { g.linkMedia = m } @@ -277,6 +306,33 @@ export function groupSeries(items: Media[] = []): SeriesCard[] { return Array.from(groups.values()) } +function repeatedSeriesExternalRawKey(media: Media): string { + if (!isEpisodeLike(media) && !pathLooksEpisodic(media)) return '' + let identity = '' + if (media.tmdb_id && media.tmdb_id > 0) identity = `tmdb:${media.tmdb_id}` + else if (media.bangumi_id && media.bangumi_id > 0) identity = `bgm:${media.bangumi_id}` + else if (media.douban_id) identity = `douban:${media.douban_id}` + else if (media.thetvdb_id) identity = `thetvdb:${media.thetvdb_id}` + return identity ? seriesFingerprint('library-external', targetLibraryID(media), identity) : '' +} + +function repeatedSeriesTitleRawKey(media: Media): string { + if (!isEpisodeLike(media) && !pathLooksEpisodic(media)) return '' + if ((media.scrape_status ?? '').trim().toLowerCase() !== 'matched') return '' + const title = (media.title || media.original_name || '').trim() + if (!title || unsafeEpisodeTitle(title)) return '' + const normalized = normalizeTitle(title) + return normalized + ? seriesFingerprint('library-title-year', targetLibraryID(media), normalized, String(media.year || 0)) + : '' +} + +function mediaParentLooksLikeCollection(path?: string): boolean { + const part = seriesDirectoryNameFromPath(path) + if (!part) return false + return /(?:^|[\s._-])(?:collection|anthology|trilogy|quadrilogy|tetralogy|saga|box[\s._-]*set)(?:[\s._-]|$)|(?:complete|all)[\s._-]*(?:\d+[\s._-]*)?(?:movies?|films?|collection|series|saga)(?:[\s._-]|$)|\d+[\s._-]*films?[\s._-]*collection|合集|全集|套装|全套|系列合集|电影系列|全\s*\d+\s*部/i.test(part) +} + export function seriesCardLink(card: SeriesCard): string { if (isSeriesCard(card)) { return `/library/${targetLibraryID(card.linkMedia)}?series=${encodeURIComponent(card.key)}`