diff --git a/internal/service/media_classifier.go b/internal/service/media_classifier.go index dab6904..4e67fc3 100644 --- a/internal/service/media_classifier.go +++ b/internal/service/media_classifier.go @@ -33,12 +33,16 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin genres := normalizeTokens(input.Genres...) countries := normalizeTokens(input.Countries...) languages := normalizeTokens(input.Languages...) - rawText := input.Title + " " + input.Category + " " + strings.Join(input.Genres, " ") + rawTitleText := input.Title + " " + strings.Join(input.Genres, " ") + categoryText := strings.ToLower(input.Category) + rawText := rawTitleText + " " + input.Category text := strings.ToLower(rawText) hasMetadata := len(genres) > 0 || len(countries) > 0 || len(languages) > 0 + sourceHint := sourceCategoryHint(input.Category, mediaType, categories) isChineseByMetadata := hasAny(languages, "ZH", "ZH-CN", "ZH-TW", "CN", "BO", "ZA") || hasAny(countries, "CN", "TW", "HK", "MO") - isChineseByText := containsHan(rawText) || containsAnyText(text, "华语", "国产", "国剧", "国漫") + isChineseByText := containsHan(rawTitleText) || containsAnyText(strings.ToLower(rawTitleText), "华语", "国产", "国剧", "国漫") + isChineseByCategory := containsAnyText(categoryText, "华语", "国产", "国剧", "大陆剧", "国产电视剧", "国产电影", "国漫", "国产动漫", "国产动画") isChinese := isChineseByMetadata || (!hasMetadata && isChineseByText) // 动漫的中文译名几乎都是纯汉字(如日本动画「葬送的芙莉莲」),用 containsHan // 判中文会把日本动画误判成国漫。动漫只在有元数据或显式中文标记时才算国漫, @@ -46,12 +50,14 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin isChineseAnime := isChineseByMetadata || (!hasMetadata && containsAnyText(text, "华语", "国产", "国漫", "國漫", "国创", "国产动漫", "国产动画")) isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || containsJapaneseKana(rawText) || strings.Contains(text, "日番") isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP") || containsKoreanHangul(rawText) - isEastAsian := isJapanese || isKorean || hasAny(countries, "TH", "IN", "SG") + isEastAsianByCategory := containsAnyText(categoryText, "日韩剧", "日剧", "韩剧", "日韩电影") + isEastAsian := isJapanese || isKorean || hasAny(countries, "TH", "IN", "SG") || (!hasMetadata && 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", ) - isWestern := isWesternByMetadata + isWesternByCategory := containsAnyText(categoryText, "欧美剧", "欧美电视剧", "美剧", "英剧", "欧美电影", "外语电影") + isWestern := isWesternByMetadata || (!hasMetadata && isWesternByCategory) 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") @@ -80,11 +86,17 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText { return categoryName(categories, "animation_movie", "动画电影") } + if !hasMetadata && sourceHint != "" { + return sourceHint + } if isChinese { return categoryName(categories, "chinese_movie", "华语电影") } return categoryName(categories, "foreign_movie", "外语电影") case "anime": + if !hasMetadata && sourceHint != "" { + return sourceHint + } if isChineseAnime { return categoryName(categories, "cn_anime", "国漫") } @@ -110,7 +122,10 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin } return categoryName(categories, "jp_anime", "日番") } - if isChinese { + if !hasMetadata && sourceHint != "" { + return sourceHint + } + if isChinese || (!hasMetadata && isChineseByCategory) { return categoryName(categories, "domestic_tv", "国产剧") } if isEastAsian { @@ -261,6 +276,79 @@ func categoryName(categories map[string]string, key, fallback string) string { return fallback } +type sourceCategoryHintDef struct { + Key string + Fallback string + MediaType string +} + +var sourceCategoryHints = []sourceCategoryHintDef{ + {Key: "animation_movie", Fallback: "动画电影", MediaType: "movie"}, + {Key: "chinese_movie", Fallback: "华语电影", MediaType: "movie"}, + {Key: "jk_movie", Fallback: "日韩电影", MediaType: "movie"}, + {Key: "euus_movie", Fallback: "欧美电影", MediaType: "movie"}, + {Key: "foreign_movie", Fallback: "外语电影", MediaType: "movie"}, + {Key: "domestic_tv", Fallback: "国产剧", MediaType: "tv"}, + {Key: "euus_tv", Fallback: "欧美剧", MediaType: "tv"}, + {Key: "jk_tv", Fallback: "日韩剧", MediaType: "tv"}, + {Key: "cn_anime", Fallback: "国漫", MediaType: "anime"}, + {Key: "jp_anime", Fallback: "日番", MediaType: "anime"}, + {Key: "variety", Fallback: "综艺", MediaType: "variety"}, + {Key: "documentary", Fallback: "纪录片", MediaType: "tv"}, + {Key: "children", Fallback: "儿童", MediaType: "tv"}, + {Key: "adult", Fallback: "成人", MediaType: "adult"}, + {Key: "adult_9kg", Fallback: "9KG", MediaType: "adult"}, + {Key: "adult_jav", Fallback: "番号", MediaType: "adult"}, +} + +func sourceCategoryHint(category, mediaType string, categories map[string]string) string { + tokens := sourceCategoryTokens(category) + if len(tokens) == 0 { + return "" + } + for _, hint := range sourceCategoryHints { + if !sourceCategoryCompatible(mediaType, hint.MediaType) { + continue + } + for _, name := range []string{hint.Fallback, categoryName(categories, hint.Key, hint.Fallback)} { + if _, ok := tokens[strings.ToLower(strings.TrimSpace(name))]; ok { + return categoryName(categories, hint.Key, hint.Fallback) + } + } + } + return "" +} + +func sourceCategoryTokens(category string) map[string]struct{} { + category = strings.TrimSpace(category) + if category == "" { + return nil + } + normalized := strings.NewReplacer("\\", " ", "/", " ", "|", " ", ",", " ", ";", " ").Replace(category) + out := map[string]struct{}{ + strings.ToLower(category): {}, + } + for _, field := range strings.Fields(normalized) { + out[strings.ToLower(strings.TrimSpace(field))] = struct{}{} + } + return out +} + +func sourceCategoryCompatible(mediaType, categoryMediaType string) bool { + mediaType = strings.ToLower(strings.TrimSpace(mediaType)) + categoryMediaType = strings.ToLower(strings.TrimSpace(categoryMediaType)) + if mediaType == "" || categoryMediaType == "" || mediaType == categoryMediaType { + return true + } + if mediaType == "tv" && (categoryMediaType == "anime" || categoryMediaType == "variety") { + return true + } + if categoryMediaType == "adult" { + return true + } + return false +} + func (o *OrganizerService) categoryMap() map[string]string { if o == nil || o.cfg == nil || o.cfg.Organizer.Categories == nil { return nil diff --git a/internal/service/media_classifier_test.go b/internal/service/media_classifier_test.go index b5164df..3587d62 100644 --- a/internal/service/media_classifier_test.go +++ b/internal/service/media_classifier_test.go @@ -117,6 +117,33 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) { }, want: "未分类", }, + { + name: "latin tv keeps explicit western source category", + input: mediaClassifyInput{ + MediaType: "tv", + Title: "Blades.of.the.Guardians.S02E01.1080p", + Category: "downloads 欧美剧 Blades.of.the.Guardians", + }, + want: "欧美剧", + }, + { + name: "generic tv folder is not treated as chinese category", + input: mediaClassifyInput{ + MediaType: "tv", + Title: "The Last of Us S01E01 1080p", + Category: "downloads 电视剧", + }, + want: "未分类", + }, + { + name: "gala title overrides wrong western source category", + input: mediaClassifyInput{ + MediaType: "tv", + Title: "HNTV Spring Festival Gala 2026 2160p WEB-DL", + Category: "欧美剧", + }, + want: "综艺", + }, { name: "platform token alone does not classify romanized drama", input: mediaClassifyInput{ @@ -179,6 +206,15 @@ func TestClassifyMediaCategoryMatchesSmartRules(t *testing.T) { }, want: "国漫", }, + { + name: "western movie source category remains western movie", + input: mediaClassifyInput{ + MediaType: "movie", + Title: "Dune 2021 2160p", + Category: "downloads 欧美电影", + }, + want: "欧美电影", + }, { name: "jav code is adult", input: mediaClassifyInput{ diff --git a/internal/service/organizer_classification_test.go b/internal/service/organizer_classification_test.go index 4bcfa63..c48d2fd 100644 --- a/internal/service/organizer_classification_test.go +++ b/internal/service/organizer_classification_test.go @@ -130,6 +130,67 @@ func TestOrganizeDirectoryMetadataCategoryOverridesDownloadFolder(t *testing.T) } } +func TestOrganizeDirectoryRejectedMetadataKeepsExplicitWesternSourceCategory(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 + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{{ + "id": 107463, + "name": "镖人", + "original_name": "Biao Ren", + "original_language": "zh", + "origin_country": []string{"CN"}, + "genre_ids": []int{16, 18}, + "first_air_date": "2023-06-01", + "vote_average": 8.0, + }}, + }) + })) + defer upstream.Close() + + repos := newOrganizerTestRepo(t) + cfg := &config.Config{} + cfg.Organizer.SmartClassify = true + cfg.Secrets.TMDbAPIKey = "test-key" + cfg.Secrets.TMDbAPIProxy = upstream.URL + scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop())) + + root := t.TempDir() + srcRoot := filepath.Join(root, "downloads") + dest := filepath.Join(root, "media") + sourceFile := filepath.Join(srcRoot, "欧美剧", "Blades.of.the.Guardians.S02.1080p.TX.WEB-DL", "Blades.of.the.Guardians.S02E01.1080p.TX.WEB-DL.mkv") + writeOrgFile(t, sourceFile, "episode") + + organizer := NewOrganizerService(cfg, zap.NewNop(), repos) + organizer.SetScraper(scraper) + res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: srcRoot, + DestPath: dest, + TransferMode: TransferCopy, + }) + if err != nil { + t.Fatalf("organize directory: %v", err) + } + want := filepath.Join(dest, "电视剧", "欧美剧", "Blades Of The Guardians", "Season 02", "Blades Of The Guardians - S02E01.mkv") + if res.Organized != 1 { + t.Fatalf("organized = %d, want 1; items=%#v errors=%#v", res.Organized, res.Items, res.Errors) + } + if _, err := os.Stat(want); err != nil { + t.Fatalf("rejected metadata should fall back to explicit source category at %q: %v; items=%#v", want, err, res.Items) + } + wrong := filepath.Join(dest, "电视剧", "国产剧", "Blades Of The Guardians") + if _, err := os.Stat(wrong); !os.IsNotExist(err) { + t.Fatalf("rejected metadata must not fall back to domestic category %q, err=%v", wrong, err) + } + if len(res.Items) != 1 || res.Items[0].Category != "欧美剧" || res.Items[0].MediaType != "tv" || res.Items[0].Title != "Blades Of The Guardians" { + t.Fatalf("organize item = %#v, want Blades Of The Guardians in 欧美剧/tv", res.Items) + } +} + func TestOrganizeDirectoryDoesNotScrapeByDownloadCategoryFolder(t *testing.T) { var queries []string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {