fix: improve discover and series special handling

This commit is contained in:
ShukeBta
2026-06-24 14:49:26 +08:00
parent 3957349dc8
commit 6be3409e3e
7 changed files with 120 additions and 13 deletions
+44 -5
View File
@@ -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 {
+30
View File
@@ -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")
}
}
+1 -1
View File
@@ -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*[季部]`)
)
+2 -2
View File
@@ -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)
}
+2 -2
View File
@@ -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 {
+38
View File
@@ -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)
}
}
}
+3 -3
View File
@@ -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)