From 75fed9532ce96c1d588cfbb2942cc41d1ac0491e Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:35:13 +0800 Subject: [PATCH] refactor media classifier helpers --- internal/service/download_classifier.go | 83 ++++ internal/service/media_classifier.go | 403 ------------------ internal/service/media_classifier_helpers.go | 93 ++++ .../service/media_classifier_source_hints.go | 77 ++++ internal/service/subscription_classifier.go | 172 ++++++++ 5 files changed, 425 insertions(+), 403 deletions(-) create mode 100644 internal/service/download_classifier.go create mode 100644 internal/service/media_classifier_helpers.go create mode 100644 internal/service/media_classifier_source_hints.go create mode 100644 internal/service/subscription_classifier.go diff --git a/internal/service/download_classifier.go b/internal/service/download_classifier.go new file mode 100644 index 0000000..03a5e85 --- /dev/null +++ b/internal/service/download_classifier.go @@ -0,0 +1,83 @@ +package service + +import ( + "context" + "os" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func downloadDefaultSaveRoot(ctx context.Context, repo *repository.Container) string { + if repo != nil && repo.Setting != nil { + if base, _ := repo.Setting.Get(ctx, "qbittorrent.savepath"); strings.TrimSpace(base) != "" { + return strings.TrimSpace(base) + } + } + for _, key := range []string{"MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "MEDIASTATION_DOWNLOAD_DIR"} { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + } + return "" +} + +func downloadSmartClassifyEnabled(ctx context.Context, repo *repository.Container, organizer *OrganizerService) bool { + if repo != nil && repo.Setting != nil { + val, err := repo.Setting.Get(ctx, DownloadSmartClassifySettingKey) + if err == nil && val != "" { + return parseBoolSetting(val, true) + } + val, err = repo.Setting.Get(ctx, "organizer.smart_classify") + if err == nil && parseBoolSetting(val, false) { + return true + } + } + if organizer != nil && organizer.cfg != nil && organizer.cfg.Organizer.SmartClassify { + return true + } + return true +} + +func downloadCategoryMap(organizer *OrganizerService) map[string]string { + if organizer == nil { + return nil + } + return organizer.categoryMap() +} + +func downloadSavePathCategoryRoot(root, category string) string { + root = strings.TrimSpace(root) + category = strings.TrimSpace(category) + if root == "" || category == "" { + return root + } + if isWindowsStyleClientPath(root) { + cleanRoot := strings.ReplaceAll(root, "/", `\`) + cleanRoot = strings.TrimRight(cleanRoot, `\`) + if windowsPathBaseEqual(cleanRoot, category) { + return cleanRoot + } + return cleanRoot + `\` + category + } + return categoryRoot(root, category) +} + +func isWindowsStyleClientPath(path string) bool { + path = strings.TrimSpace(path) + return (len(path) >= 2 && isASCIIAlpha(path[0]) && path[1] == ':') || + strings.HasPrefix(path, `\\`) +} + +func windowsPathBaseEqual(path, base string) bool { + path = strings.TrimRight(strings.ReplaceAll(strings.TrimSpace(path), "/", `\`), `\`) + base = strings.Trim(strings.TrimSpace(base), `\/`) + if path == "" || base == "" { + return false + } + idx := strings.LastIndex(path, `\`) + if idx >= 0 { + path = path[idx+1:] + } + return strings.EqualFold(path, base) +} diff --git a/internal/service/media_classifier.go b/internal/service/media_classifier.go index f703419..71cee3b 100644 --- a/internal/service/media_classifier.go +++ b/internal/service/media_classifier.go @@ -2,13 +2,10 @@ package service import ( "context" - "os" "regexp" "strings" "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" - "go.uber.org/zap" ) var ( @@ -195,170 +192,6 @@ func normalizeMediaType(mediaType, title, category string) string { } } -func normalizeTokens(values ...string) map[string]struct{} { - out := make(map[string]struct{}) - for _, value := range values { - for _, part := range strings.FieldsFunc(value, func(r rune) bool { - return r == ',' || r == '/' || r == '|' || r == ';' - }) { - part = strings.ToUpper(strings.TrimSpace(part)) - if part != "" { - out[part] = struct{}{} - } - } - } - return out -} - -func hasAny(values map[string]struct{}, needles ...string) bool { - for _, needle := range needles { - if _, ok := values[strings.ToUpper(needle)]; ok { - return true - } - } - return false -} - -func containsAnyText(text string, needles ...string) bool { - for _, needle := range needles { - if strings.Contains(text, strings.ToLower(needle)) { - return true - } - } - return false -} - -func containsHan(text string) bool { - for _, r := range text { - if r >= '\u4e00' && r <= '\u9fff' { - return true - } - } - return false -} - -func containsJapaneseKana(text string) bool { - for _, r := range text { - if (r >= '\u3040' && r <= '\u30ff') || (r >= '\u31f0' && r <= '\u31ff') { - return true - } - } - return false -} - -func containsKoreanHangul(text string) bool { - for _, r := range text { - if (r >= '\uac00' && r <= '\ud7af') || (r >= '\u1100' && r <= '\u11ff') || (r >= '\u3130' && r <= '\u318f') { - return true - } - } - return false -} - -func containsLatin(text string) bool { - for _, r := range text { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { - return true - } - } - return false -} - -func isDigits(text string) bool { - if text == "" { - return false - } - for _, r := range text { - if r < '0' || r > '9' { - return false - } - } - return true -} - -func categoryName(categories map[string]string, key, fallback string) string { - if categories != nil { - if name := strings.TrimSpace(categories[key]); name != "" { - return name - } - } - 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: "euus_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 @@ -383,239 +216,3 @@ func (o *OrganizerService) classifyMedia(ctx context.Context, m *model.Media, me Genres: parseCommaList(m.Genres), }, o.categoryMap()) } - -func (s *SubscriptionService) classifySubscriptionItem(ctx context.Context, sub *model.Subscription, title, sourceCategory string) (string, string) { - mediaType := normalizeMediaType(sub.MediaType, title+" "+sub.Name+" "+sub.Filter, sourceCategory) - category := strings.TrimSpace(sub.MediaCategory) - if category == "" { - if match := s.lookupSubscriptionMetadata(ctx, mediaType, title, sub); match != nil { - category = classifyMediaCategory(mediaClassifyInput{ - MediaType: mediaType, - Title: match.Title + " " + match.OriginalName, - Languages: match.Languages, - Countries: match.Countries, - Genres: match.Genres, - Category: sourceCategory, - }, s.categoryMap()) - if s != nil && s.log != nil && category != "" { - s.log.Info("subscription metadata classified", - zap.String("title", title), - zap.String("matched_title", match.Title), - zap.String("media_type", mediaType), - zap.String("media_category", category), - zap.Int("tmdb_id", match.TMDbID), - zap.Int("bangumi_id", match.BangumiID), - zap.String("douban_id", match.DoubanID), - zap.String("thetvdb_id", match.TheTVDBID)) - } - } - } - if category == "" { - category = classifyMediaCategory(mediaClassifyInput{ - MediaType: mediaType, - Title: title + " " + sub.Name + " " + sub.Filter, - Category: sourceCategory, - }, s.categoryMap()) - } - return mediaType, category -} - -func (s *SubscriptionService) lookupSubscriptionMetadata(ctx context.Context, mediaType, title string, sub *model.Subscription) *Match { - if s == nil || s.scraper == nil || !s.scraper.AnyEnabled() { - return nil - } - queries := subscriptionMetadataQueries(title, sub) - if len(queries) == 0 { - return nil - } - for _, libType := range subscriptionMetadataLibraryTypes(mediaType, title) { - lib := &model.Library{Type: libType, Enabled: true} - for _, query := range queries { - cleaned, year := CleanQuery(query) - if cleaned == "" { - cleaned = strings.TrimSpace(query) - } - for _, candidate := range titleCandidates(cleaned) { - if candidate == "" { - continue - } - match := s.scraper.lookup(ctx, lib, nil, candidate, year) - if match == nil || strings.TrimSpace(match.Title) == "" { - continue - } - if !organizeMetadataMatchTrusted(candidate, year, match) { - continue - } - return match - } - } - } - return nil -} - -func subscriptionMetadataQueries(title string, sub *model.Subscription) []string { - seen := map[string]struct{}{} - out := make([]string, 0, 3) - add := func(value string) { - value = strings.TrimSpace(value) - if value == "" { - return - } - key := strings.ToLower(value) - if _, ok := seen[key]; ok { - return - } - seen[key] = struct{}{} - out = append(out, value) - } - add(title) - if sub != nil { - add(sub.Filter) - add(sub.Name) - } - return out -} - -func subscriptionMetadataLibraryTypes(mediaType, title string) []string { - if strings.TrimSpace(mediaType) == "" { - text := strings.ToLower(title) - switch { - case classifierEpisodeRE.MatchString(text) || classifierSeasonRE.MatchString(text): - return []string{"tv", "anime", "movie"} - case containsAnyText(text, "动漫", "动画", "anime", "bangumi"): - return []string{"anime", "tv", "movie"} - case containsAnyText(text, "电影", "movie", "film"): - return []string{"movie", "tv", "anime"} - default: - return []string{"tv", "movie", "anime"} - } - } - switch normalizeMediaType(mediaType, title, "") { - case "movie": - return []string{"movie"} - case "anime": - return []string{"anime", "tv"} - case "tv", "variety": - return []string{"tv", "anime"} - default: - if classifierEpisodeRE.MatchString(title) || classifierSeasonRE.MatchString(title) { - return []string{"tv", "anime"} - } - return []string{"movie", "tv", "anime"} - } -} - -func (s *SubscriptionService) categoryMap() map[string]string { - if s == nil || s.cfg == nil || s.cfg.Organizer.Categories == nil { - return nil - } - return s.cfg.Organizer.Categories -} - -func (s *SubscriptionService) resolveSubscriptionSavePath(ctx context.Context, sub *model.Subscription, mediaType, category string) string { - if sub == nil { - return "" - } - base := strings.TrimSpace(sub.SavePath) - if base == "" { - base = downloadDefaultSaveRoot(ctx, s.repo) - } - if base == "" { - return "" - } - if !s.isSmartClassifyEnabled(ctx) || category == "" { - return base - } - return downloadSavePathCategoryRoot(base, sanitizeFilename(category)) -} - -func (s *SubscriptionService) isSmartClassifyEnabled(ctx context.Context) bool { - if s != nil && s.repo != nil && s.repo.Setting != nil { - val, err := s.repo.Setting.Get(ctx, DownloadSmartClassifySettingKey) - if err == nil && val != "" { - return parseBoolSetting(val, true) - } - val, err = s.repo.Setting.Get(ctx, "organizer.smart_classify") - if err == nil && parseBoolSetting(val, false) { - return true - } - } - if s != nil && s.cfg != nil && s.cfg.Organizer.SmartClassify { - return true - } - return true -} - -func downloadDefaultSaveRoot(ctx context.Context, repo *repository.Container) string { - if repo != nil && repo.Setting != nil { - if base, _ := repo.Setting.Get(ctx, "qbittorrent.savepath"); strings.TrimSpace(base) != "" { - return strings.TrimSpace(base) - } - } - for _, key := range []string{"MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "MEDIASTATION_DOWNLOAD_DIR"} { - if value := strings.TrimSpace(os.Getenv(key)); value != "" { - return value - } - } - return "" -} - -func downloadSmartClassifyEnabled(ctx context.Context, repo *repository.Container, organizer *OrganizerService) bool { - if repo != nil && repo.Setting != nil { - val, err := repo.Setting.Get(ctx, DownloadSmartClassifySettingKey) - if err == nil && val != "" { - return parseBoolSetting(val, true) - } - val, err = repo.Setting.Get(ctx, "organizer.smart_classify") - if err == nil && parseBoolSetting(val, false) { - return true - } - } - if organizer != nil && organizer.cfg != nil && organizer.cfg.Organizer.SmartClassify { - return true - } - return true -} - -func downloadCategoryMap(organizer *OrganizerService) map[string]string { - if organizer == nil { - return nil - } - return organizer.categoryMap() -} - -func downloadSavePathCategoryRoot(root, category string) string { - root = strings.TrimSpace(root) - category = strings.TrimSpace(category) - if root == "" || category == "" { - return root - } - if isWindowsStyleClientPath(root) { - cleanRoot := strings.ReplaceAll(root, "/", `\`) - cleanRoot = strings.TrimRight(cleanRoot, `\`) - if windowsPathBaseEqual(cleanRoot, category) { - return cleanRoot - } - return cleanRoot + `\` + category - } - return categoryRoot(root, category) -} - -func isWindowsStyleClientPath(path string) bool { - path = strings.TrimSpace(path) - return (len(path) >= 2 && isASCIIAlpha(path[0]) && path[1] == ':') || - strings.HasPrefix(path, `\\`) -} - -func windowsPathBaseEqual(path, base string) bool { - path = strings.TrimRight(strings.ReplaceAll(strings.TrimSpace(path), "/", `\`), `\`) - base = strings.Trim(strings.TrimSpace(base), `\/`) - if path == "" || base == "" { - return false - } - idx := strings.LastIndex(path, `\`) - if idx >= 0 { - path = path[idx+1:] - } - return strings.EqualFold(path, base) -} diff --git a/internal/service/media_classifier_helpers.go b/internal/service/media_classifier_helpers.go new file mode 100644 index 0000000..4820c80 --- /dev/null +++ b/internal/service/media_classifier_helpers.go @@ -0,0 +1,93 @@ +package service + +import "strings" + +func normalizeTokens(values ...string) map[string]struct{} { + out := make(map[string]struct{}) + for _, value := range values { + for _, part := range strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || r == '/' || r == '|' || r == ';' + }) { + part = strings.ToUpper(strings.TrimSpace(part)) + if part != "" { + out[part] = struct{}{} + } + } + } + return out +} + +func hasAny(values map[string]struct{}, needles ...string) bool { + for _, needle := range needles { + if _, ok := values[strings.ToUpper(needle)]; ok { + return true + } + } + return false +} + +func containsAnyText(text string, needles ...string) bool { + for _, needle := range needles { + if strings.Contains(text, strings.ToLower(needle)) { + return true + } + } + return false +} + +func containsHan(text string) bool { + for _, r := range text { + if r >= '\u4e00' && r <= '\u9fff' { + return true + } + } + return false +} + +func containsJapaneseKana(text string) bool { + for _, r := range text { + if (r >= '\u3040' && r <= '\u30ff') || (r >= '\u31f0' && r <= '\u31ff') { + return true + } + } + return false +} + +func containsKoreanHangul(text string) bool { + for _, r := range text { + if (r >= '\uac00' && r <= '\ud7af') || (r >= '\u1100' && r <= '\u11ff') || (r >= '\u3130' && r <= '\u318f') { + return true + } + } + return false +} + +func containsLatin(text string) bool { + for _, r := range text { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { + return true + } + } + return false +} + +func isDigits(text string) bool { + if text == "" { + return false + } + for _, r := range text { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func categoryName(categories map[string]string, key, fallback string) string { + if categories != nil { + if name := strings.TrimSpace(categories[key]); name != "" { + return name + } + } + return fallback +} diff --git a/internal/service/media_classifier_source_hints.go b/internal/service/media_classifier_source_hints.go new file mode 100644 index 0000000..6e7e482 --- /dev/null +++ b/internal/service/media_classifier_source_hints.go @@ -0,0 +1,77 @@ +package service + +import "strings" + +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: "euus_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 +} diff --git a/internal/service/subscription_classifier.go b/internal/service/subscription_classifier.go new file mode 100644 index 0000000..523df18 --- /dev/null +++ b/internal/service/subscription_classifier.go @@ -0,0 +1,172 @@ +package service + +import ( + "context" + "strings" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func (s *SubscriptionService) classifySubscriptionItem(ctx context.Context, sub *model.Subscription, title, sourceCategory string) (string, string) { + mediaType := normalizeMediaType(sub.MediaType, title+" "+sub.Name+" "+sub.Filter, sourceCategory) + category := strings.TrimSpace(sub.MediaCategory) + if category == "" { + if match := s.lookupSubscriptionMetadata(ctx, mediaType, title, sub); match != nil { + category = classifyMediaCategory(mediaClassifyInput{ + MediaType: mediaType, + Title: match.Title + " " + match.OriginalName, + Languages: match.Languages, + Countries: match.Countries, + Genres: match.Genres, + Category: sourceCategory, + }, s.categoryMap()) + if s != nil && s.log != nil && category != "" { + s.log.Info("subscription metadata classified", + zap.String("title", title), + zap.String("matched_title", match.Title), + zap.String("media_type", mediaType), + zap.String("media_category", category), + zap.Int("tmdb_id", match.TMDbID), + zap.Int("bangumi_id", match.BangumiID), + zap.String("douban_id", match.DoubanID), + zap.String("thetvdb_id", match.TheTVDBID)) + } + } + } + if category == "" { + category = classifyMediaCategory(mediaClassifyInput{ + MediaType: mediaType, + Title: title + " " + sub.Name + " " + sub.Filter, + Category: sourceCategory, + }, s.categoryMap()) + } + return mediaType, category +} + +func (s *SubscriptionService) lookupSubscriptionMetadata(ctx context.Context, mediaType, title string, sub *model.Subscription) *Match { + if s == nil || s.scraper == nil || !s.scraper.AnyEnabled() { + return nil + } + queries := subscriptionMetadataQueries(title, sub) + if len(queries) == 0 { + return nil + } + for _, libType := range subscriptionMetadataLibraryTypes(mediaType, title) { + lib := &model.Library{Type: libType, Enabled: true} + for _, query := range queries { + cleaned, year := CleanQuery(query) + if cleaned == "" { + cleaned = strings.TrimSpace(query) + } + for _, candidate := range titleCandidates(cleaned) { + if candidate == "" { + continue + } + match := s.scraper.lookup(ctx, lib, nil, candidate, year) + if match == nil || strings.TrimSpace(match.Title) == "" { + continue + } + if !organizeMetadataMatchTrusted(candidate, year, match) { + continue + } + return match + } + } + } + return nil +} + +func subscriptionMetadataQueries(title string, sub *model.Subscription) []string { + seen := map[string]struct{}{} + out := make([]string, 0, 3) + add := func(value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + key := strings.ToLower(value) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + out = append(out, value) + } + add(title) + if sub != nil { + add(sub.Filter) + add(sub.Name) + } + return out +} + +func subscriptionMetadataLibraryTypes(mediaType, title string) []string { + if strings.TrimSpace(mediaType) == "" { + text := strings.ToLower(title) + switch { + case classifierEpisodeRE.MatchString(text) || classifierSeasonRE.MatchString(text): + return []string{"tv", "anime", "movie"} + case containsAnyText(text, "动漫", "动画", "anime", "bangumi"): + return []string{"anime", "tv", "movie"} + case containsAnyText(text, "电影", "movie", "film"): + return []string{"movie", "tv", "anime"} + default: + return []string{"tv", "movie", "anime"} + } + } + switch normalizeMediaType(mediaType, title, "") { + case "movie": + return []string{"movie"} + case "anime": + return []string{"anime", "tv"} + case "tv", "variety": + return []string{"tv", "anime"} + default: + if classifierEpisodeRE.MatchString(title) || classifierSeasonRE.MatchString(title) { + return []string{"tv", "anime"} + } + return []string{"movie", "tv", "anime"} + } +} + +func (s *SubscriptionService) categoryMap() map[string]string { + if s == nil || s.cfg == nil || s.cfg.Organizer.Categories == nil { + return nil + } + return s.cfg.Organizer.Categories +} + +func (s *SubscriptionService) resolveSubscriptionSavePath(ctx context.Context, sub *model.Subscription, mediaType, category string) string { + if sub == nil { + return "" + } + base := strings.TrimSpace(sub.SavePath) + if base == "" { + base = downloadDefaultSaveRoot(ctx, s.repo) + } + if base == "" { + return "" + } + if !s.isSmartClassifyEnabled(ctx) || category == "" { + return base + } + return downloadSavePathCategoryRoot(base, sanitizeFilename(category)) +} + +func (s *SubscriptionService) isSmartClassifyEnabled(ctx context.Context) bool { + if s != nil && s.repo != nil && s.repo.Setting != nil { + val, err := s.repo.Setting.Get(ctx, DownloadSmartClassifySettingKey) + if err == nil && val != "" { + return parseBoolSetting(val, true) + } + val, err = s.repo.Setting.Get(ctx, "organizer.smart_classify") + if err == nil && parseBoolSetting(val, false) { + return true + } + } + if s != nil && s.cfg != nil && s.cfg.Organizer.SmartClassify { + return true + } + return true +}