diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index 389469c..3d7d13b 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -120,8 +120,8 @@ const ( ) var ( - embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|specials?|sp|ova|oad|extra|extras|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典)$`) - embySeasonSuffixRE = regexp.MustCompile(`(?i)(?:[\s._-]+(?:season[\s._-]*\d+|s\d+|第\s*[0-9一二三四五六七八九十百零两]+\s*季|specials?|sp|ova|oad|extra|extras|特别篇|特別篇|番外|特典)|\s*第\s*[0-9一二三四五六七八九十百零两]+\s*季)\s*$`) + embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典|画像特典)$`) + embySeasonSuffixRE = regexp.MustCompile(`(?i)(?:[\s._-]+(?:season[\s._-]*\d+|s\d+|第\s*[0-9一二三四五六七八九十百零两]+\s*季|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|特别篇|特別篇|番外|特典|画像特典)|\s*第\s*[0-9一二三四五六七八九十百零两]+\s*季)\s*$`) embyYearSuffixRE = regexp.MustCompile(`\s*[\((\[]\d{4}[\))\]]\s*$`) embyEpisodeTitleRE = regexp.MustCompile(`(?i)\s*[-_ ]*s\d{1,2}e\d{1,3}.*$`) ) diff --git a/internal/service/emby_media_sources.go b/internal/service/emby_media_sources.go index 741a08c..2717a7a 100644 --- a/internal/service/emby_media_sources.go +++ b/internal/service/emby_media_sources.go @@ -55,6 +55,17 @@ func (e *EmbyService) mediaVersionSiblings(ctx context.Context, m *model.Media) if err := q.Find(&rows).Error; err != nil || len(rows) == 0 { return []model.Media{*m} } + targetKey := e.mediaVersionKey(ctx, m) + filtered := rows[:0] + for i := range rows { + if e.mediaVersionKey(ctx, &rows[i]) == targetKey { + filtered = append(filtered, rows[i]) + } + } + rows = filtered + if len(rows) == 0 { + return []model.Media{*m} + } rows = e.collapseExactPathRows(rows) sort.SliceStable(rows, func(i, j int) bool { if rows[i].ID == m.ID { @@ -97,11 +108,28 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin if libraryGroup == "" { libraryGroup = strings.TrimSpace(m.LibraryID) } + kind := mediaSpecialKind(m.Path) + season, episode := m.SeasonNum, m.EpisodeNum + if kind != "" && kind != mediaSpecialTheatrical && episode <= 0 { + if parsedSeason, parsedEpisode := ParseEpisode(m.Path); parsedEpisode > 0 { + season, episode = parsedSeason, parsedEpisode + } + } + kindKey := "" + if kind != "" { + kindKey = "|kind:" + kind + } + if kind != "" && kind != mediaSpecialTheatrical && episode <= 0 { + if stemKey := mediaVersionStemGroupKey(*m, libraryGroup); stemKey != "" { + return stemKey + kindKey + } + return libraryGroup + "|special-item:" + kind + "|id:" + m.ID + } if m.TMDbID > 0 { - return fmt.Sprintf("%s|tmdb:%d|s:%d|e:%d", libraryGroup, m.TMDbID, m.SeasonNum, m.EpisodeNum) + return fmt.Sprintf("%s|tmdb:%d|s:%d|e:%d%s", libraryGroup, m.TMDbID, season, episode, kindKey) } if m.BangumiID > 0 { - return fmt.Sprintf("%s|bangumi:%d|s:%d|e:%d", libraryGroup, m.BangumiID, m.SeasonNum, m.EpisodeNum) + return fmt.Sprintf("%s|bangumi:%d|s:%d|e:%d%s", libraryGroup, m.BangumiID, season, episode, kindKey) } title := strings.ToLower(strings.TrimSpace(m.Title)) if title == "" { @@ -110,7 +138,7 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin if title == "" { return "" } - return fmt.Sprintf("%s|title:%s|y:%d|s:%d|e:%d", libraryGroup, title, m.Year, m.SeasonNum, m.EpisodeNum) + return fmt.Sprintf("%s|title:%s|y:%d|s:%d|e:%d%s", libraryGroup, title, m.Year, season, episode, kindKey) } func preferMediaVersion(candidate, current model.Media) bool { diff --git a/internal/service/emby_movie_items.go b/internal/service/emby_movie_items.go index 2257764..c26797d 100644 --- a/internal/service/emby_movie_items.go +++ b/internal/service/emby_movie_items.go @@ -220,7 +220,7 @@ func embyLikelyEpisodicPathSQL() (string, []any) { patterns := []string{ "%/season %/%", "%/season.%/%", "%/season-%/%", "%/season_%/%", "%/s0%/%", "%/s1%/%", "%/s2%/%", "%/s3%/%", "%/s4%/%", "%/s5%/%", "%/s6%/%", "%/s7%/%", "%/s8%/%", "%/s9%/%", - "%/special/%", "%/specials/%", "%/sp/%", "%/ova/%", "%/oad/%", "%/extra/%", "%/extras/%", + "%/special/%", "%/specials/%", "%/sp/%", "%/ova/%", "%/ovas/%", "%/oad/%", "%/oads/%", "%/ovd/%", "%/ovds/%", "%/ona/%", "%/onas/%", "%/extra/%", "%/extras/%", "%/bonus/%", "%/bonuses/%", "%/omake/%", "%/picture drama/%", "%/ncop/%", "%/nced/%", "%/电视剧/%", "%/剧集/%", "%/连续剧/%", "%/短剧/%", "%/国产剧/%", "%/国剧/%", "%/大陆剧/%", "%/华语剧/%", "%/国产电视剧/%", "%/大陆电视剧/%", "%/华语电视剧/%", "%/欧美剧/%", "%/欧美电视剧/%", "%/美剧/%", "%/英剧/%", "%/日韩剧/%", "%/日韩电视剧/%", "%/日剧/%", "%/韩剧/%", "%/港剧/%", "%/台剧/%", "%/港台剧/%", "%/泰剧/%", "%/日番/%", "%/国漫/%", "%/番剧/%", "%/动漫/%", "%/特别篇/%", "%/特別篇/%", "%/番外/%", "%/特典/%", } @@ -243,7 +243,7 @@ func embyMediaPathLooksEpisodic(path string) bool { return false } for _, marker := range []string{ - "/season ", "/season.", "/season-", "/season_", "/special/", "/specials/", "/sp/", "/ova/", "/oad/", "/extra/", "/extras/", + "/season ", "/season.", "/season-", "/season_", "/special/", "/specials/", "/sp/", "/ova/", "/ovas/", "/oad/", "/oads/", "/ovd/", "/ovds/", "/ona/", "/onas/", "/extra/", "/extras/", "/bonus/", "/bonuses/", "/omake/", "/picture drama/", "/ncop/", "/nced/", "/电视剧/", "/剧集/", "/连续剧/", "/短剧/", "/国产剧/", "/国剧/", "/大陆剧/", "/华语剧/", "/国产电视剧/", "/大陆电视剧/", "/华语电视剧/", "/欧美剧/", "/欧美电视剧/", "/美剧/", "/英剧/", "/日韩剧/", "/日韩电视剧/", "/日剧/", "/韩剧/", "/港剧/", "/台剧/", "/港台剧/", "/泰剧/", "/日番/", "/国漫/", "/番剧/", "/动漫/", "/特别篇/", "/特別篇/", "/番外/", "/特典/", } { diff --git a/internal/service/episode_metadata_cleanup.go b/internal/service/episode_metadata_cleanup.go index bec7a0b..b069b1a 100644 --- a/internal/service/episode_metadata_cleanup.go +++ b/internal/service/episode_metadata_cleanup.go @@ -19,7 +19,7 @@ const pollutedEpisodeCleanupSettingKey = "media.polluted_episode_cleanup_v2_done // seasonFolderTailRE 去掉路径末尾的「季文件夹 + 文件名」,得到整剧目录(show_dir)。 // 例: /tv/国漫/遮天 (2023)/Season 01/遮天 - S01E01.mkv → /tv/国漫/遮天 (2023) -var seasonFolderTailRE = regexp.MustCompile(`(?i)[\\/](?:season[\s._-]*\d+|s\d{1,2}|specials?|sp|ova|oad|extra|extras|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典)[\\/][^\\/]*$`) +var seasonFolderTailRE = regexp.MustCompile(`(?i)[\\/](?:season[\s._-]*\d+|s\d{1,2}|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典|画像特典)[\\/][^\\/]*$`) // showDirFromEpisodePath 从单集路径推出整剧目录, 作为「同一部剧」的聚合键。 // 若没有季文件夹, 则退而去掉文件名取其父目录。 diff --git a/internal/service/episode_parser.go b/internal/service/episode_parser.go index a4988e8..7b39c8f 100644 --- a/internal/service/episode_parser.go +++ b/internal/service/episode_parser.go @@ -27,13 +27,14 @@ var ( patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`) patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`) patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`) + patSpecialEpisode = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:ova|oad|ovd|ona|sp|special(?:[\s._-]*episode)?|extra|bonus|omake)[\s._-]*0*(\d{1,3})(?:[^0-9]|$)`) patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`) patCNRange = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[-~–—]\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`) patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`) 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+|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|番外篇?|特别篇|特別篇|特典|外传|外傳|总集篇|總集篇)$`) + patSpecialSeason = regexp.MustCompile(`(?i)^(?:s0+|season[\s._-]*0+|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|番外篇?|特别篇|特別篇|特典|外传|外傳|总集篇|總集篇|画像特典)$`) patSeasonEpisodeZero = regexp.MustCompile(`(?i)s0*([1-9]\d?)e0+(?:[^0-9]|$)`) // patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。 patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`) @@ -57,6 +58,9 @@ func ParseEpisode(path string) (season, episode int) { episode = mustAtoi(m[2]) return } + if m := patSpecialEpisode.FindStringSubmatch(name); len(m) >= 2 { + return 0, mustAtoi(m[1]) + } if m := patEP.FindStringSubmatch(name); len(m) >= 2 { var found bool season, found = seasonFromParents(path) diff --git a/internal/service/episode_parser_test.go b/internal/service/episode_parser_test.go index 288003d..59af563 100644 --- a/internal/service/episode_parser_test.go +++ b/internal/service/episode_parser_test.go @@ -29,6 +29,10 @@ func TestParseEpisode(t *testing.T) { {`剧集/Specials/剧集 - 02.mkv`, 0, 2}, {`剧集/特别篇/03.mkv`, 0, 3}, {`剧集/剧集 - S00E04.mkv`, 0, 4}, + {`动漫/摇曳露营/OVA/Season 3 [OVA01 [1080p].mkv`, 0, 1}, + {`动漫/示例/OAD/示例.OAD02.mkv`, 0, 2}, + {`动漫/示例/OVD/示例-OVD03.mkv`, 0, 3}, + {`动漫/示例/ONA/示例_ONA04.mkv`, 0, 4}, {"Movie.2020.1080p.mkv", 0, 0}, } for _, tc := range cases { diff --git a/internal/service/media_series_key.go b/internal/service/media_series_key.go index cfa5ce2..ef80c78 100644 --- a/internal/service/media_series_key.go +++ b/internal/service/media_series_key.go @@ -9,7 +9,7 @@ import ( "github.com/truewhile/MeBox/internal/model" ) -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|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]`) +var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典)[\\/]`) var genericMovieTitleRE = regexp.MustCompile(`(?i)^(?:cd\s*\d+|part\s*\d+|disc\s*\d+|disk\s*\d+|dvd\s*\d+|movie|film|video|main|feature|track\s*\d+|preview|sample|trailer|\d{3,4}p|4k|2160p|1080p|720p)$`) @@ -123,9 +123,9 @@ 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._-]+`) - seriesSeasonDirRE = regexp.MustCompile(`(?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|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)$`) - seriesSpecialCodeRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:s0+\s*e?\s*\d+|season\s*0+(?:\s*episode)?\s*\d*|special(?:\s*episode)?s?\s*\d*|sp\s*\d*|ovas?\s*\d*|oads?\s*\d*|extras?\s*\d*|bonus(?:es)?\s*\d*|omake\s*\d*)\s*[\]))】]?$`) - seriesSpecialCJKRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)(?:\s*第?\s*[0-9一二三四五六七八九十百零两]+(?:[集话話期])?)?\s*[\]))】]?$`) + seriesSeasonDirRE = regexp.MustCompile(`(?i)^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*[0-9一二三四五六七八九十百零两]+\s*季|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典)$`) + seriesSpecialCodeRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:s0+\s*e?\s*\d+|season\s*0+(?:\s*episode)?\s*\d*|special(?:\s*episode)?s?\s*\d*|sp\s*\d*|ovas?\s*\d*|oads?\s*\d*|ovds?\s*\d*|onas?\s*\d*|extras?\s*\d*|bonus(?:es)?\s*\d*|omake\s*\d*|picture[\s._-]*drama\s*\d*|ncop\s*\d*|nced\s*\d*)\s*[\]))】]?$`) + seriesSpecialCJKRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典)(?:\s*第?\s*[0-9一二三四五六七八九十百零两]+(?:[集话話期])?)?\s*[\]))】]?$`) ) func normalizeSeriesTitle(value string) string { diff --git a/internal/service/media_series_test.go b/internal/service/media_series_test.go index caf16e5..e7b52d3 100644 --- a/internal/service/media_series_test.go +++ b/internal/service/media_series_test.go @@ -408,18 +408,17 @@ func TestGroupMediaSeriesCardsKeepsTheatricalMovieWithTVSeries(t *testing.T) { episode := model.Media{ Base: model.Base{ID: "episode"}, LibraryID: "anime", - Title: "超人高校生们", - Path: `/media/动漫/超人高校生们/Season 01/超人高校生们.S01E01.mkv`, + Title: "摇曳露营△", + Path: `/media/动漫/摇曳露营△ (2018)/Season 01/摇曳露营△.S01E01.mkv`, SeasonNum: 1, EpisodeNum: 1, } theatrical := model.Media{ - Base: model.Base{ID: "theatrical"}, - LibraryID: "anime", - Title: "超人高校生们", - Path: `/media/动漫/超人高校生们/剧场版/超人高校生们 剧场版.mkv`, - SeasonNum: 1, - EpisodeNum: 1, + Base: model.Base{ID: "theatrical"}, + LibraryID: "anime", + Title: "摇曳露营△ 剧场版", + Path: `/media/动漫/摇曳露营△ (2018)/摇曳露营△ 剧场版 (2022)/Eiga.Yurukyan.2022.Bluray.mkv`, + TMDbID: 566466, } cards := groupMediaSeriesCards([]model.Media{episode, theatrical}) @@ -539,4 +538,3 @@ func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) { t.Fatalf("ListMediaEpisodes got %#v, want exactly m1", eps) } } - diff --git a/internal/service/media_special_kind.go b/internal/service/media_special_kind.go new file mode 100644 index 0000000..298ec51 --- /dev/null +++ b/internal/service/media_special_kind.go @@ -0,0 +1,54 @@ +package service + +import ( + "regexp" + "strings" +) + +const ( + mediaSpecialTheatrical = "theatrical" + mediaSpecialOVA = "ova" + mediaSpecialOAD = "oad" + mediaSpecialOVD = "ovd" + mediaSpecialONA = "ona" + mediaSpecialExtra = "extra" + mediaSpecialBonus = "bonus" + mediaSpecialOmake = "omake" + mediaSpecialPicture = "picture_drama" + mediaSpecialNCOP = "ncop" + mediaSpecialNCED = "nced" + mediaSpecialGeneric = "special" +) + +var mediaSpecialKindPatterns = []struct { + kind string + re *regexp.Regexp +}{ + {mediaSpecialOVA, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])ova(?:s)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)}, + {mediaSpecialOAD, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])oad(?:s)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)}, + {mediaSpecialOVD, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])ovd(?:s)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)}, + {mediaSpecialONA, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])ona(?:s)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)}, + {mediaSpecialPicture, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:picture[\s._-]*drama|画像特典)(?:[^a-z0-9]|$)`)}, + {mediaSpecialNCOP, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])ncop(?:\d+)?(?:[^a-z0-9]|$)`)}, + {mediaSpecialNCED, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])nced(?:\d+)?(?:[^a-z0-9]|$)`)}, + {mediaSpecialExtra, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])extras?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)}, + {mediaSpecialBonus, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])bonus(?:es)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)}, + {mediaSpecialOmake, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])omake(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)`)}, + {mediaSpecialGeneric, regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:special(?:[\s._-]*episodes?)?|specials|sps?)(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇`)}, +} + +func mediaSpecialKind(path string) string { + if pathHasTheatricalFolder(path) || patTheatricalTitle.MatchString(path) { + return mediaSpecialTheatrical + } + for _, part := range strings.FieldsFunc(path, func(r rune) bool { + return r == '/' || r == '\\' + }) { + for _, pattern := range mediaSpecialKindPatterns { + if pattern.re.MatchString(part) { + return pattern.kind + } + } + } + return "" +} diff --git a/internal/service/media_versions.go b/internal/service/media_versions.go index dfc4f90..ecab7ac 100644 --- a/internal/service/media_versions.go +++ b/internal/service/media_versions.go @@ -145,16 +145,32 @@ func mediaVersionGroupKey(m model.Media) string { return fmt.Sprintf("embyremote:%s", m.ID) } - if m.SeasonNum > 0 || m.EpisodeNum > 0 { + libKey := strings.ToLower(strings.TrimSpace(m.LibraryID)) + if libKey == "" { + libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID)) + } + specialKind := mediaSpecialKind(m.Path) + season, episode := m.SeasonNum, m.EpisodeNum + if specialKind != "" && specialKind != mediaSpecialTheatrical && episode <= 0 { + if parsedSeason, parsedEpisode := ParseEpisode(m.Path); parsedEpisode > 0 { + season, episode = parsedSeason, parsedEpisode + } + } + + if season > 0 || episode > 0 { + kind := specialKind + if kind == "" { + kind = "episode" + } switch { case m.TMDbID > 0: - return fmt.Sprintf("episode:tmdb:%d:%d:%d", m.TMDbID, m.SeasonNum, m.EpisodeNum) + return fmt.Sprintf("episode:%s:tmdb:%d:%d:%d", kind, m.TMDbID, season, episode) case m.BangumiID > 0: - return fmt.Sprintf("episode:bangumi:%d:%d:%d", m.BangumiID, m.SeasonNum, m.EpisodeNum) + return fmt.Sprintf("episode:%s:bangumi:%d:%d:%d", kind, m.BangumiID, season, episode) case strings.TrimSpace(m.DoubanID) != "": - return fmt.Sprintf("episode:douban:%s:%d:%d", strings.ToLower(strings.TrimSpace(m.DoubanID)), m.SeasonNum, m.EpisodeNum) + return fmt.Sprintf("episode:%s:douban:%s:%d:%d", kind, strings.ToLower(strings.TrimSpace(m.DoubanID)), season, episode) case strings.TrimSpace(m.TheTVDBID) != "": - return fmt.Sprintf("episode:thetvdb:%s:%d:%d", strings.ToLower(strings.TrimSpace(m.TheTVDBID)), m.SeasonNum, m.EpisodeNum) + return fmt.Sprintf("episode:%s:thetvdb:%s:%d:%d", kind, strings.ToLower(strings.TrimSpace(m.TheTVDBID)), season, episode) } title := firstNonEmpty(m.OriginalName, m.Title) if title == "" { @@ -166,37 +182,61 @@ func mediaVersionGroupKey(m model.Media) string { } return strings.Join([]string{ "episode", - strings.ToLower(strings.TrimSpace(m.LibraryID)), + kind, + libKey, title, - fmt.Sprintf("%d:%d", m.SeasonNum, m.EpisodeNum), + fmt.Sprintf("%d:%d", season, episode), }, "|") } - libKey := strings.ToLower(strings.TrimSpace(m.LibraryID)) - if libKey == "" { - libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID)) + if specialKind != "" && specialKind != mediaSpecialTheatrical { + return mediaVersionStemGroupKey(m, libKey) } switch { case m.TMDbID > 0: if libKey != "" { + if specialKind != "" { + return fmt.Sprintf("movie:%s:%s:tmdb:%d", specialKind, libKey, m.TMDbID) + } return fmt.Sprintf("movie:%s:tmdb:%d", libKey, m.TMDbID) } + if specialKind != "" { + return fmt.Sprintf("movie:%s:tmdb:%d", specialKind, m.TMDbID) + } return fmt.Sprintf("tmdb:%d", m.TMDbID) case m.BangumiID > 0: if libKey != "" { + if specialKind != "" { + return fmt.Sprintf("movie:%s:%s:bangumi:%d", specialKind, libKey, m.BangumiID) + } return fmt.Sprintf("movie:%s:bangumi:%d", libKey, m.BangumiID) } + if specialKind != "" { + return fmt.Sprintf("movie:%s:bangumi:%d", specialKind, m.BangumiID) + } return fmt.Sprintf("bangumi:%d", m.BangumiID) case strings.TrimSpace(m.DoubanID) != "": if libKey != "" { + if specialKind != "" { + return fmt.Sprintf("movie:%s:%s:douban:%s", specialKind, libKey, strings.ToLower(strings.TrimSpace(m.DoubanID))) + } return fmt.Sprintf("movie:%s:douban:%s", libKey, strings.ToLower(strings.TrimSpace(m.DoubanID))) } + if specialKind != "" { + return "movie:" + specialKind + ":douban:" + strings.ToLower(strings.TrimSpace(m.DoubanID)) + } return "douban:" + strings.ToLower(strings.TrimSpace(m.DoubanID)) case strings.TrimSpace(m.TheTVDBID) != "": if libKey != "" { + if specialKind != "" { + return fmt.Sprintf("movie:%s:%s:thetvdb:%s", specialKind, libKey, strings.ToLower(strings.TrimSpace(m.TheTVDBID))) + } return fmt.Sprintf("movie:%s:thetvdb:%s", libKey, strings.ToLower(strings.TrimSpace(m.TheTVDBID))) } + if specialKind != "" { + return "movie:" + specialKind + ":thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID)) + } return "thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID)) } diff --git a/internal/service/media_versions_test.go b/internal/service/media_versions_test.go index b037c32..b5592d5 100644 --- a/internal/service/media_versions_test.go +++ b/internal/service/media_versions_test.go @@ -71,6 +71,73 @@ func TestGroupEpisodeVersionsForDisplayMergesAndKeepsEpisodeOrder(t *testing.T) } } +func TestGroupMediaVersionsSeparatesAnimeSpecialKindsAndNumbers(t *testing.T) { + rows := []model.Media{ + { + Base: model.Base{ID: "movie"}, + LibraryID: "anime", + Title: "摇曳露营△", + Path: "/media/动漫/摇曳露营△/摇曳露营△ 剧场版 (2022)/movie.strm", + TMDbID: 76075, + }, + { + Base: model.Base{ID: "ova-1"}, + LibraryID: "anime", + Title: "摇曳露营△", + Path: "/media/动漫/摇曳露营△/OVA/Yuru Camp OVA01.strm", + TMDbID: 76075, + }, + { + Base: model.Base{ID: "ova-2"}, + LibraryID: "anime", + Title: "摇曳露营△", + Path: "/media/动漫/摇曳露营△/OVA/Yuru Camp OVA02.strm", + TMDbID: 76075, + }, + { + Base: model.Base{ID: "oad-1"}, + LibraryID: "anime", + Title: "摇曳露营△", + Path: "/media/动漫/摇曳露营△/OAD/Yuru Camp OAD01.strm", + TMDbID: 76075, + }, + } + + grouped := groupMediaVersions(rows) + if len(grouped) != 4 { + t.Fatalf("grouped len = %d, want theatrical, OVA01, OVA02 and OAD01 separate: %#v", len(grouped), grouped) + } + for _, item := range grouped { + if len(item.Versions) > 1 { + t.Fatalf("unrelated anime extras were merged as versions: %#v", item.Versions) + } + } +} + +func TestGroupMediaVersionsMergesSameNumberedOVAEncodes(t *testing.T) { + rows := []model.Media{ + { + Base: model.Base{ID: "ova-1-hd"}, + LibraryID: "anime", + Path: "/media/动漫/示例/OVA/Show OVA01 1080p.mkv", + TMDbID: 123, + SizeBytes: 100, + }, + { + Base: model.Base{ID: "ova-1-uhd"}, + LibraryID: "anime", + Path: "/media/动漫/示例/OVA/Show OVA01 2160p.mkv", + TMDbID: 123, + SizeBytes: 200, + }, + } + + grouped := groupMediaVersions(rows) + if len(grouped) != 1 || len(grouped[0].Versions) != 2 { + t.Fatalf("same numbered OVA encodes should be versions: %#v", grouped) + } +} + func TestGroupMediaVersionsMergesMovieEncodingVariants(t *testing.T) { hd := model.Media{ LibraryID: "movies", diff --git a/internal/service/scanner_local_ingest.go b/internal/service/scanner_local_ingest.go index c9f8c31..b855df4 100644 --- a/internal/service/scanner_local_ingest.go +++ b/internal/service/scanner_local_ingest.go @@ -93,7 +93,11 @@ func (s *ScannerService) readLocalScanMetadata(lib *model.Library, root *model.L if root != nil && strings.TrimSpace(root.Path) != "" { rootPath = root.Path } - localMeta, err := ReadLocalMetadata(path, rootPath, librarySupportsSeasons(lib) || parsedSeason > 0 || parsedEpisode > 0) + seriesLike := librarySupportsSeasons(lib) || parsedSeason > 0 || parsedEpisode > 0 + if mediaLooksLikeTheatricalFeature(&model.Media{Path: path}) { + seriesLike = false + } + localMeta, err := ReadLocalMetadata(path, rootPath, seriesLike) if err != nil { s.log.Warn("read local metadata failed", zap.String("path", path), zap.Error(err)) } diff --git a/internal/service/scanner_local_metadata_test.go b/internal/service/scanner_local_metadata_test.go index ee0eef3..25d2fd1 100644 --- a/internal/service/scanner_local_metadata_test.go +++ b/internal/service/scanner_local_metadata_test.go @@ -69,6 +69,49 @@ func TestScanLibraryUsesLocalMetadata(t *testing.T) { } } +func TestScanAnimeTheatricalFolderUsesMovieNFO(t *testing.T) { + root := t.TempDir() + showDir := filepath.Join(root, "摇曳露营△ (2018)") + movieDir := filepath.Join(showDir, "摇曳露营△ 剧场版 (2022)") + if err := os.MkdirAll(movieDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(showDir, "tvshow.nfo"), []byte( + `错误的剧集标题760752018`, + ), 0o644); err != nil { + t.Fatal(err) + } + mediaPath := filepath.Join(movieDir, "Eiga.Yurukyan.2022.Bluray.mkv") + if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(nfoPath(mediaPath), []byte( + `摇曳露营△ 剧场版5664662022`, + ), 0o644); err != nil { + t.Fatal(err) + } + + db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}) + repos := repository.New(db) + lib := model.Library{Name: "动漫", Path: root, Type: "anime", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + + scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil) + if _, err := scanner.ScanLibrary(t.Context(), lib.ID); err != nil { + t.Fatal(err) + } + + var media model.Media + if err := db.First(&media, "path = ?", mediaPath).Error; err != nil { + t.Fatal(err) + } + if media.Title != "摇曳露营△ 剧场版" || media.TMDbID != 566466 || media.Year != 2022 { + t.Fatalf("theatrical movie metadata = title %q tmdb %d year %d", media.Title, media.TMDbID, media.Year) + } +} + func TestScanLibraryDoesNotMarkArtworkOnlyAsMatched(t *testing.T) { root := t.TempDir() mediaPath := filepath.Join(root, "SSIS-001-CD1.mp4") diff --git a/internal/service/scraper_anime_theatrical_test.go b/internal/service/scraper_anime_theatrical_test.go index b4ce67b..16c0fd1 100644 --- a/internal/service/scraper_anime_theatrical_test.go +++ b/internal/service/scraper_anime_theatrical_test.go @@ -77,6 +77,14 @@ func TestMediaLooksLikeTheatricalFeatureIgnoresStaleEpisodeIdentity(t *testing.T t.Fatal("theatrical path should override stale persisted season/episode fields") } + namedFolder := &model.Media{ + Title: "Eiga Yurukyan 2022 Bluray", + Path: `/media/anime/摇曳露营△ (2018)/摇曳露营△ 剧场版 (2022)/Eiga.Yurukyan.2022.Bluray.mkv`, + } + if !mediaLooksLikeTheatricalFeature(namedFolder) { + t.Fatal("a named theatrical folder with a year should be detected from the full path") + } + realEpisode := &model.Media{ Title: "剧场版制作幕后", Path: `/media/anime/超人高校生们/Season 01/超人高校生们.S01E01.mkv`, diff --git a/internal/service/scraper_query.go b/internal/service/scraper_query.go index 2e6d6c4..446c5f7 100644 --- a/internal/service/scraper_query.go +++ b/internal/service/scraper_query.go @@ -16,7 +16,7 @@ var ( genericEpisodeWordsRE = regexp.MustCompile(`^\s*第\s*[集期话話]\s*$`) episodeReleaseTitleTagRE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e\d{1,3}(?:[\s._-]|$)`) patTheatricalTitle = regexp.MustCompile(`(?i)(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版|\bthe\s+movie\b|\bmovie\s*\d{1,2}\b)`) - patTheatricalFolder = regexp.MustCompile(`(?i)[\\/](?:剧场版|劇場版|動畫電影|动画电影)[\\/]`) + patTheatricalFolder = regexp.MustCompile(`(?i)[\\/][^\\/]*(?:剧场版|劇場版|動畫電影|动画电影|电影版|電影版)[^\\/]*[\\/]`) theatricalNoiseRE = regexp.MustCompile(`(?i)(?:剧场版|劇場版)\s*(?:第?\s*\d{1,3}\s*[部篇]?)?|电影版|電影版|动画电影|動畫電影`) ) @@ -48,7 +48,7 @@ func mediaLooksLikeTheatricalFeature(m *model.Media) bool { return false } text := m.Title + " " + pathBaseSlash(m.Path) - return patTheatricalTitle.MatchString(text) || patTheatricalFolder.MatchString(m.Path) + return patTheatricalTitle.MatchString(text) || pathHasTheatricalFolder(m.Path) } func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string { diff --git a/internal/service/scraper_query_paths.go b/internal/service/scraper_query_paths.go index 7053438..5d042ec 100644 --- a/internal/service/scraper_query_paths.go +++ b/internal/service/scraper_query_paths.go @@ -1,6 +1,11 @@ package service -import "strings" +import ( + "regexp" + "strings" +) + +var namedTheatricalFolderRE = regexp.MustCompile(`(?i)(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版)`) func mediaFolderTitle(mediaPath, libraryRoot string) string { dir := parentSlashPath(mediaPath) @@ -13,10 +18,10 @@ func mediaFolderTitle(mediaPath, libraryRoot string) string { if base == "" || base == "." { return "" } - if isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) || isTheatricalFolder(base) { - dir = parentSlashPath(dir) - continue - } + if isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) || isTheatricalFolder(base) { + dir = parentSlashPath(dir) + continue + } if isMediaCollectionFolder(base) { dir = parentSlashPath(dir) continue @@ -123,14 +128,30 @@ func seriesFolderTitle(mediaPath, libraryRoot string) string { func isTheatricalFolder(name string) bool { key := strings.ToLower(strings.TrimSpace(name)) key = strings.Trim(key, `\/`) + if namedTheatricalFolderRE.MatchString(key) { + return true + } switch key { - case "剧场版", "劇場版", "动画电影", "動畫電影", "特别篇", "特別篇", "specials", "sp", "ova", "oad": + case "剧场版", "劇場版", "动画电影", "動畫電影", "特别篇", "特別篇", + "special", "specials", "sp", "ova", "ovas", "oad", "oads", "ovd", "ovds", "ona", "onas", + "extra", "extras", "bonus", "bonuses", "omake", "picture drama", "ncop", "nced", "画像特典": return true default: return false } } +func pathHasTheatricalFolder(path string) bool { + for _, part := range strings.FieldsFunc(path, func(r rune) bool { + return r == '/' || r == '\\' + }) { + if isTheatricalFolder(part) && namedTheatricalFolderRE.MatchString(part) { + return true + } + } + return false +} + func libraryRootTitle(libraryRoot string) string { base := "" if info, ok := ParseCloudLibraryMount(libraryRoot); ok { diff --git a/web/src/pages/LibrarySeriesEpisodes.tsx b/web/src/pages/LibrarySeriesEpisodes.tsx index 6d79818..f3dcf4a 100644 --- a/web/src/pages/LibrarySeriesEpisodes.tsx +++ b/web/src/pages/LibrarySeriesEpisodes.tsx @@ -4,7 +4,21 @@ import { Play, Search } from 'lucide-react' import { imageURL } from '../api/client' import { ExternalPlayerButton } from '../components/ExternalPlayerButton' import type { Media } from '../types' -import { isTheatricalFeature, seriesTitleFromPath, THEATRICAL_SEASON } from '../utils/groupSeries' +import { + BONUS_SEASON, + EXTRA_SEASON, + isTheatricalFeature, + NCED_SEASON, + NCOP_SEASON, + OAD_SEASON, + OMAKE_SEASON, + ONA_SEASON, + OVA_SEASON, + OVD_SEASON, + PICTURE_DRAMA_SEASON, + seriesTitleFromPath, + THEATRICAL_SEASON, +} from '../utils/groupSeries' import { formatSize } from './libraryPageModel' type SeasonGroup = { @@ -135,9 +149,21 @@ export function LibrarySeriesEpisodes({ } function seasonLabel(season: number): string { - if (season === 0) return '特别篇' - if (season === THEATRICAL_SEASON) return '剧场版' - return `第 ${season} 季` + switch (season) { + case 0: return '特别篇' + case THEATRICAL_SEASON: return '剧场版' + case OVA_SEASON: return 'OVA' + case OAD_SEASON: return 'OAD' + case OVD_SEASON: return 'OVD' + case ONA_SEASON: return 'ONA' + case EXTRA_SEASON: return 'Extra' + case BONUS_SEASON: return 'Bonus' + case OMAKE_SEASON: return 'Omake' + case PICTURE_DRAMA_SEASON: return 'Picture Drama' + case NCOP_SEASON: return 'NCOP' + case NCED_SEASON: return 'NCED' + default: return `第 ${season} 季` + } } function episodeDisplayTitle(ep: Media, siblings: Media[]): string { diff --git a/web/src/pages/libraryPageModel.ts b/web/src/pages/libraryPageModel.ts index 8226334..d8e9630 100644 --- a/web/src/pages/libraryPageModel.ts +++ b/web/src/pages/libraryPageModel.ts @@ -15,7 +15,7 @@ export function seriesSourceRoot(episodes: Media[]): string { if (!firstPath) return '' const dir = dirname(firstPath) const base = basename(dir) - if (/^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*\d{1,2}\s*季|specials?|sp|ova|oad|extra|extras|特别篇|特別篇|番外|特典)$/i.test(base)) { + if (/^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*\d{1,2}\s*季|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|特别篇|特別篇|番外|特典|画像特典)$/i.test(base)) { return dirname(dir) } return dir diff --git a/web/src/pages/useLibrarySeriesSelection.ts b/web/src/pages/useLibrarySeriesSelection.ts index c2dc7e7..53528e3 100644 --- a/web/src/pages/useLibrarySeriesSelection.ts +++ b/web/src/pages/useLibrarySeriesSelection.ts @@ -1,7 +1,23 @@ import { useEffect, useMemo } from 'react' import type { Media } from '../types' -import { getSeriesKey, isTheatricalFeature, THEATRICAL_SEASON, type SeriesCard } from '../utils/groupSeries' +import { + BONUS_SEASON, + EXTRA_SEASON, + getSeriesKey, + isTheatricalFeature, + NCED_SEASON, + NCOP_SEASON, + OAD_SEASON, + OMAKE_SEASON, + ONA_SEASON, + OVA_SEASON, + OVD_SEASON, + PICTURE_DRAMA_SEASON, + specialSectionForMedia, + THEATRICAL_SEASON, + type SeriesCard, +} from '../utils/groupSeries' type SeasonEpisodes = { season: number @@ -47,9 +63,9 @@ export function useLibrarySeriesSelection({ : sourceItems.filter((m) => getSeriesKey(m) === selectedSeries.key) const seasons = new Map() for (const ep of eps) { - const s = isTheatricalFeature(ep) - ? THEATRICAL_SEASON - : ep.episode_num > 0 ? (ep.season_num ?? 0) : (ep.season_num || 1) + const specialSection = specialSectionForMedia(ep) + const s = specialSection ?? + (ep.episode_num > 0 ? (ep.season_num ?? 0) : (ep.season_num || 1)) if (!seasons.has(s)) seasons.set(s, []) seasons.get(s)!.push(ep) } @@ -132,7 +148,19 @@ export function useLibrarySeriesSelection({ } function seasonSortOrder(season: number): number { - if (season === 0) return -2 - if (season === THEATRICAL_SEASON) return -1 - return season + switch (season) { + case 0: return 0 + case OVA_SEASON: return 1 + case OAD_SEASON: return 2 + case OVD_SEASON: return 3 + case ONA_SEASON: return 4 + case EXTRA_SEASON: return 5 + case BONUS_SEASON: return 6 + case OMAKE_SEASON: return 7 + case PICTURE_DRAMA_SEASON: return 8 + case NCOP_SEASON: return 9 + case NCED_SEASON: return 10 + case THEATRICAL_SEASON: return 11 + default: return 100 + season + } } diff --git a/web/src/utils/groupSeries.ts b/web/src/utils/groupSeries.ts index 6f8bed7..825f509 100644 --- a/web/src/utils/groupSeries.ts +++ b/web/src/utils/groupSeries.ts @@ -39,6 +39,16 @@ export type SeriesCard = { } export const THEATRICAL_SEASON = -1 +export const OVA_SEASON = -2 +export const OAD_SEASON = -3 +export const OVD_SEASON = -4 +export const ONA_SEASON = -5 +export const EXTRA_SEASON = -6 +export const BONUS_SEASON = -7 +export const OMAKE_SEASON = -8 +export const PICTURE_DRAMA_SEASON = -9 +export const NCOP_SEASON = -10 +export const NCED_SEASON = -11 export function getSeriesKey(media: Media): string { return compactSeriesKey(getSeriesRawKey(media)) @@ -92,16 +102,19 @@ export function isEpisodeLike(media: Media): boolean { // 剧集类目录名(电视剧/动漫及其二级分类)。媒体路径落在这些目录下时, 即便 // 季集号未识别出来, 也应按剧集对待, 跳转到 /library 分类视图而非 /media 单页。 const EPISODIC_PATH_RE = - /[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|anime|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]/i + /[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|anime|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典)[\\/]/i const THEATRICAL_TITLE_RE = /(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版|\bthe\s+movie\b|\bmovie\s*\d{1,2}\b)/i +const THEATRICAL_FOLDER_NAME_RE = + /(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版)/i + const THEATRICAL_FOLDER_RE = - /[\\/](?:剧场版|劇場版|动画电影|動畫電影)[\\/]/ + /[\\/][^\\/]*(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版)[^\\/]*[\\/]/ const SEASON_FOLDER_RE = - /^(?: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 + /^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*[0-9一二三四五六七八九十百零两]+\s*季|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|ovds?|onas?|extras?|bonus(?:es)?|omake|picture[\s._-]*drama|ncop|nced|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典|剧场版|劇場版|动画电影|動畫電影)$/i export function pathLooksEpisodic(media: Media): boolean { const path = (media.path || media.display_library_path || media.library_path || '') @@ -114,6 +127,30 @@ export function isTheatricalFeature(media: Media): boolean { return THEATRICAL_TITLE_RE.test(`${media.title || ''} ${path}`) || THEATRICAL_FOLDER_RE.test(path) } +const SPECIAL_SECTION_PATTERNS: Array<[number, RegExp]> = [ + [OVA_SEASON, /(?:^|[^a-z0-9])ovas?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i], + [OAD_SEASON, /(?:^|[^a-z0-9])oads?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i], + [OVD_SEASON, /(?:^|[^a-z0-9])ovds?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i], + [ONA_SEASON, /(?:^|[^a-z0-9])onas?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i], + [PICTURE_DRAMA_SEASON, /(?:^|[^a-z0-9])(?:picture[\s._-]*drama|画像特典)(?:[^a-z0-9]|$)/i], + [NCOP_SEASON, /(?:^|[^a-z0-9])ncop(?:\d+)?(?:[^a-z0-9]|$)/i], + [NCED_SEASON, /(?:^|[^a-z0-9])nced(?:\d+)?(?:[^a-z0-9]|$)/i], + [EXTRA_SEASON, /(?:^|[^a-z0-9])extras?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i], + [BONUS_SEASON, /(?:^|[^a-z0-9])bonus(?:es)?(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i], + [OMAKE_SEASON, /(?:^|[^a-z0-9])omake(?:[\s._-]*\d+)?(?:[^a-z0-9]|$)/i], +] + +export function specialSectionForMedia(media: Media): number | null { + if (isTheatricalFeature(media)) return THEATRICAL_SEASON + const parts = (media.path || '').split(/[\\/]+/).filter(Boolean) + for (const part of parts) { + for (const [section, pattern] of SPECIAL_SECTION_PATTERNS) { + if (pattern.test(part)) return section + } + } + return null +} + export function isSeriesCard(card: SeriesCard): boolean { return ( card.count > 1 || @@ -170,10 +207,10 @@ function normalizeTitle(value?: string): string { } const SERIES_SPECIAL_CODE_RE = - /\s*[[((【]?\s*(?:s0+\s*e?\s*\d+|season\s*0+(?:\s*episode)?\s*\d*|special(?:\s*episode)?s?\s*\d*|sp\s*\d*|ovas?\s*\d*|oads?\s*\d*|extras?\s*\d*|bonus(?:es)?\s*\d*|omake\s*\d*)\s*[\]))】]?$/i + /\s*[[((【]?\s*(?:s0+\s*e?\s*\d+|season\s*0+(?:\s*episode)?\s*\d*|special(?:\s*episode)?s?\s*\d*|sp\s*\d*|ovas?\s*\d*|oads?\s*\d*|ovds?\s*\d*|onas?\s*\d*|extras?\s*\d*|bonus(?:es)?\s*\d*|omake\s*\d*|picture[\s._-]*drama\s*\d*|ncop\s*\d*|nced\s*\d*)\s*[\]))】]?$/i const SERIES_SPECIAL_CJK_RE = - /\s*[[((【]?\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)(?:\s*第?\s*[0-9一二三四五六七八九十百零两]+(?:[集话話期])?)?\s*[\]))】]?$/i + /\s*[[((【]?\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典)(?:\s*第?\s*[0-9一二三四五六七八九十百零两]+(?:[集话話期])?)?\s*[\]))】]?$/i function normalizePathSeriesTitle(value?: string): string { const title = normalizeTitle(value) @@ -271,16 +308,20 @@ function seriesDirectoryNameFromPath(path?: string): string { if (parts.length < 2) return '' let dirIndex = parts.length - 2 const lastPart = parts[parts.length - 1] - if (!seriesPathPartLooksLikeFile(lastPart) && !SEASON_FOLDER_RE.test(lastPart)) { + if (!seriesPathPartLooksLikeFile(lastPart) && !isSeriesContainerFolder(lastPart)) { dirIndex = parts.length - 1 } - while (dirIndex >= 0 && SEASON_FOLDER_RE.test(parts[dirIndex])) { + while (dirIndex >= 0 && isSeriesContainerFolder(parts[dirIndex])) { dirIndex -= 1 } if (dirIndex < 0) return '' return parts[dirIndex] } +function isSeriesContainerFolder(name: string): boolean { + return SEASON_FOLDER_RE.test(name) || THEATRICAL_FOLDER_NAME_RE.test(name) +} + function seriesExternalIDFromPath(path?: string): string { const part = seriesDirectoryNameFromPath(path) if (!part) return ''