From 6be3409e3e6c285b5722c5627a7721e7f5b0a600 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:49:26 +0800 Subject: [PATCH] fix: improve discover and series special handling --- internal/handler/discover_extra.go | 49 ++++++++++++++++++++++--- internal/handler/discover_extra_test.go | 30 +++++++++++++++ internal/service/episode_parser.go | 2 +- internal/service/manual_scrape_test.go | 4 +- internal/service/media_series.go | 4 +- internal/service/media_series_test.go | 38 +++++++++++++++++++ web/src/utils/groupSeries.ts | 6 +-- 7 files changed, 120 insertions(+), 13 deletions(-) diff --git a/internal/handler/discover_extra.go b/internal/handler/discover_extra.go index 68e4e22..6b9271b 100644 --- a/internal/handler/discover_extra.go +++ b/internal/handler/discover_extra.go @@ -40,10 +40,7 @@ var discoverSectionCatalog = []discoverSectionDef{ func discoverSectionsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { sections := make([]gin.H, 0, len(discoverSectionCatalog)) - for _, section := range discoverSectionCatalog { - if !discoverProviderEnabled(c.Request.Context(), svc, section.Provider) { - continue - } + for _, section := range enabledDiscoverSections(c.Request.Context(), svc) { sections = append(sections, gin.H{"key": section.Key, "label": section.Label, "provider": section.Provider}) } c.JSON(http.StatusOK, gin.H{"sections": sections}) @@ -56,11 +53,18 @@ func discoverSectionsHandler(svc *service.Container) gin.HandlerFunc { // the page. func discoverFeedHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - keys := strings.Split(c.DefaultQuery("sections", "tmdb_trending_day,tmdb_popular_movie,douban_hot_movie,bangumi_calendar"), ",") + rawSections := c.Query("sections") + if strings.TrimSpace(rawSections) == "" { + rawSections = strings.Join(defaultDiscoverSectionKeys(c.Request.Context(), svc), ",") + } + keys := strings.Split(rawSections, ",") out := gin.H{} artworkItems := []service.ExternalMediaResult{} for _, raw := range keys { k := strings.TrimSpace(raw) + if k == "" { + continue + } if provider := discoverSectionProvider(k); provider != "" && !discoverProviderEnabled(c.Request.Context(), svc, provider) { out[k] = []service.ExternalMediaResult{} continue @@ -78,6 +82,41 @@ func discoverFeedHandler(svc *service.Container) gin.HandlerFunc { } } +func enabledDiscoverSections(ctx context.Context, svc *service.Container) []discoverSectionDef { + sections := make([]discoverSectionDef, 0, len(discoverSectionCatalog)) + for _, section := range discoverSectionCatalog { + if !discoverProviderEnabled(ctx, svc, section.Provider) { + continue + } + sections = append(sections, section) + } + return sections +} + +func defaultDiscoverSectionKeys(ctx context.Context, svc *service.Container) []string { + preferred := []string{"tmdb_trending_day", "douban_hot_movie", "douban_hot_tv", "bangumi_calendar"} + enabled := map[string]struct{}{} + for _, section := range enabledDiscoverSections(ctx, svc) { + enabled[section.Key] = struct{}{} + } + out := make([]string, 0, len(preferred)) + for _, key := range preferred { + if _, ok := enabled[key]; ok { + out = append(out, key) + } + } + if len(out) > 0 { + return out + } + for _, section := range enabledDiscoverSections(ctx, svc) { + out = append(out, section.Key) + if len(out) >= 4 { + break + } + } + return out +} + func discoverSectionProvider(key string) string { for _, section := range discoverSectionCatalog { if section.Key == key { diff --git a/internal/handler/discover_extra_test.go b/internal/handler/discover_extra_test.go index 352d74f..1c7524e 100644 --- a/internal/handler/discover_extra_test.go +++ b/internal/handler/discover_extra_test.go @@ -35,3 +35,33 @@ func TestDiscoverProviderEnabledHonorsAPIConfigToggle(t *testing.T) { t.Fatal("missing API config should keep discover provider available") } } + +func TestDefaultDiscoverSectionKeysSkipDisabledProviders(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.APIConfig{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + apiConfig := service.NewAPIConfigService(zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())) + disabled := false + for _, provider := range []string{"douban", "bangumi"} { + if _, err := apiConfig.Update(t.Context(), provider, service.APIConfigPatch{Enabled: &disabled}); err != nil { + t.Fatal(err) + } + } + svc := &service.Container{APIConfig: apiConfig} + + keys := defaultDiscoverSectionKeys(t.Context(), svc) + for _, key := range keys { + switch discoverSectionProvider(key) { + case "douban", "bangumi": + t.Fatalf("disabled provider key %q should not be selected by default; keys=%v", key, keys) + } + } + if len(keys) == 0 { + t.Fatal("default keys should keep enabled providers") + } +} diff --git a/internal/service/episode_parser.go b/internal/service/episode_parser.go index 8b1bb22..75e3489 100644 --- a/internal/service/episode_parser.go +++ b/internal/service/episode_parser.go @@ -30,7 +30,7 @@ var ( patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`) patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`) patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`) - patSpecialSeason = regexp.MustCompile(`(?i)^(?:s0+|season[\s._-]*0+|specials?|sp|ova|oad|extra|extras|番外|特别篇|特別篇|特典)$`) + patSpecialSeason = regexp.MustCompile(`(?i)^(?:s0+|season[\s._-]*0+|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|番外篇?|特别篇|特別篇|特典|外传|外傳|总集篇|總集篇)$`) // patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。 patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`) ) diff --git a/internal/service/manual_scrape_test.go b/internal/service/manual_scrape_test.go index 4b045c0..b07b3f6 100644 --- a/internal/service/manual_scrape_test.go +++ b/internal/service/manual_scrape_test.go @@ -237,7 +237,7 @@ func TestManualSearchReturnsMovieFallbackForTVTypedTMDbSearch(t *testing.T) { } } -func TestManualSearchTMDbNumericIDTriesMovieAndTVNamespaces(t *testing.T) { +func TestManualSearchAllProvidersTMDbNumericIDTriesMovieAndTVNamespaces(t *testing.T) { var paths []string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.URL.Path) @@ -284,7 +284,7 @@ func TestManualSearchTMDbNumericIDTriesMovieAndTVNamespaces(t *testing.T) { t.Fatal(err) } - results, err := scraper.ManualSearch(t.Context(), &media, "12345", "tmdb", "movie") + results, err := scraper.ManualSearch(t.Context(), &media, "12345", "all", "movie") if err != nil { t.Fatal(err) } diff --git a/internal/service/media_series.go b/internal/service/media_series.go index 2d716e7..a853d63 100644 --- a/internal/service/media_series.go +++ b/internal/service/media_series.go @@ -107,7 +107,7 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard { return cards } -var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|国产剧|欧美剧|日韩剧|日剧|韩剧|综艺|纪录片|动漫|番剧|国漫|日番|儿童|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|specials?|sp|ova|oad|extra|extras|特别篇|特別篇|番外|特典)[\\/]`) +var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|国产剧|欧美剧|日韩剧|日剧|韩剧|综艺|纪录片|动漫|番剧|国漫|日番|儿童|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]`) func mediaSeriesKey(media model.Media) string { return compactSeriesKey(mediaSeriesRawKey(media)) @@ -173,7 +173,7 @@ var ( seriesIDRE = regexp.MustCompile(`(?i)\s*\[(?:tmdb|tmdbid)[=-]\d+\]\s*`) seriesBraceRE = regexp.MustCompile(`(?i)\s*\{(?:tmdb|tmdbid|douban|bangumi|bgm|thetvdb|tvdb)[\s:=#-]*[a-z0-9_-]+\}\s*`) seriesSpacerRE = regexp.MustCompile(`[\s._-]+`) - seriesSpecialSuffixRE = regexp.MustCompile(`(?i)(?:\s+(?:specials?|sp|ova|oad|extra|extras)|\s*(?:特别篇|特別篇|番外|特典|外传|外傳))$`) + seriesSpecialSuffixRE = regexp.MustCompile(`(?i)(?:\s+(?:s0+|season 0+|special episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake)|\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇))$`) ) func normalizeSeriesTitle(value string) string { diff --git a/internal/service/media_series_test.go b/internal/service/media_series_test.go index 77c42dd..c0adb5b 100644 --- a/internal/service/media_series_test.go +++ b/internal/service/media_series_test.go @@ -61,3 +61,41 @@ func TestMediaSeriesKeyCollapsesSpecialTitleSuffix(t *testing.T) { t.Fatalf("chinese special key=%q, want main key=%q", got, want) } } + +func TestMediaSeriesKeyCollapsesSeasonZeroAndSpecialAliases(t *testing.T) { + main := model.Media{ + LibraryID: "lib-anime", + Path: `cloud://openlist/动漫/日番/宝可梦 (1997) {tmdb-60572}/Season 1/宝可梦.S01E01.mkv`, + SeasonNum: 1, + EpisodeNum: 1, + } + seasonZero := model.Media{ + LibraryID: "lib-anime", + Path: `cloud://openlist/动漫/日番/宝可梦 (1997) {tmdb-60572}/Season 0/宝可梦.S00E34.mkv`, + SeasonNum: 0, + EpisodeNum: 34, + } + specialEpisode := model.Media{ + LibraryID: "lib-anime", + Path: `cloud://openlist/动漫/日番/宝可梦 Special Episode/宝可梦.SP01.mkv`, + SeasonNum: 0, + EpisodeNum: 1, + } + extraEpisode := model.Media{ + LibraryID: "lib-anime", + Path: `cloud://openlist/动漫/日番/宝可梦 番外篇/宝可梦.SP02.mkv`, + SeasonNum: 0, + EpisodeNum: 2, + } + + want := mediaSeriesKey(main) + for name, item := range map[string]model.Media{ + "season zero": seasonZero, + "special episode": specialEpisode, + "番外篇": extraEpisode, + } { + if got := mediaSeriesKey(item); got != want { + t.Fatalf("%s key=%q, want main key=%q", name, got, want) + } + } +} diff --git a/web/src/utils/groupSeries.ts b/web/src/utils/groupSeries.ts index 6ec3453..89f2e53 100644 --- a/web/src/utils/groupSeries.ts +++ b/web/src/utils/groupSeries.ts @@ -78,10 +78,10 @@ export function isEpisodeLike(media: Media): boolean { // 剧集类目录名(电视剧/动漫及其二级分类)。媒体路径落在这些目录下时, 即便 // 季集号未识别出来, 也应按剧集对待, 跳转到 /library 分类视图而非 /media 单页。 const EPISODIC_PATH_RE = - /[\\/](?:电视剧|剧集|国产剧|欧美剧|日韩剧|日剧|韩剧|综艺|纪录片|动漫|番剧|国漫|日番|儿童|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|specials?|sp|ova|oad|extra|extras|特别篇|特別篇|番外|特典)[\\/]/i + /[\\/](?:电视剧|剧集|国产剧|欧美剧|日韩剧|日剧|韩剧|综艺|纪录片|动漫|番剧|国漫|日番|儿童|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]/i const SEASON_FOLDER_RE = - /^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*\d{1,2}\s*季|specials?|sp|ova|oad|extra|extras|特别篇|特別篇|番外|特典)$/i + /^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*[0-9一二三四五六七八九十百零两]+\s*季|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)$/i function pathLooksEpisodic(media: Media): boolean { const path = (media.path || media.display_library_path || media.library_path || '') @@ -114,7 +114,7 @@ function normalizeTitle(value?: string): string { } const SERIES_SPECIAL_SUFFIX_RE = - /(?:\s+(?:specials?|sp|ova|oad|extra|extras)|\s*(?:特别篇|特別篇|番外|特典|外传|外傳))$/i + /(?:\s+(?:s0+|season 0+|special episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake)|\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇))$/i function normalizePathSeriesTitle(value?: string): string { const title = normalizeTitle(value)