mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 04:56:37 +08:00
@@ -120,8 +120,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|specials?|sp|ova|oad|extra|extras|第\s*[0-9一二三四五六七八九十百零两]+\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|ova|oad|extra|extras|特别篇|特別篇|番外|特典)|\s*第\s*[0-9一二三四五六七八九十百零两]+\s*季)\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*$`)
|
embyYearSuffixRE = regexp.MustCompile(`\s*[\((\[]\d{4}[\))\]]\s*$`)
|
||||||
embyEpisodeTitleRE = regexp.MustCompile(`(?i)\s*[-_ ]*s\d{1,2}e\d{1,3}.*$`)
|
embyEpisodeTitleRE = regexp.MustCompile(`(?i)\s*[-_ ]*s\d{1,2}e\d{1,3}.*$`)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 {
|
if err := q.Find(&rows).Error; err != nil || len(rows) == 0 {
|
||||||
return []model.Media{*m}
|
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)
|
rows = e.collapseExactPathRows(rows)
|
||||||
sort.SliceStable(rows, func(i, j int) bool {
|
sort.SliceStable(rows, func(i, j int) bool {
|
||||||
if rows[i].ID == m.ID {
|
if rows[i].ID == m.ID {
|
||||||
@@ -97,11 +108,28 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
|
|||||||
if libraryGroup == "" {
|
if libraryGroup == "" {
|
||||||
libraryGroup = strings.TrimSpace(m.LibraryID)
|
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 {
|
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 {
|
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))
|
title := strings.ToLower(strings.TrimSpace(m.Title))
|
||||||
if title == "" {
|
if title == "" {
|
||||||
@@ -110,7 +138,7 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
|
|||||||
if title == "" {
|
if title == "" {
|
||||||
return ""
|
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 {
|
func preferMediaVersion(candidate, current model.Media) bool {
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ func embyLikelyEpisodicPathSQL() (string, []any) {
|
|||||||
patterns := []string{
|
patterns := []string{
|
||||||
"%/season %/%", "%/season.%/%", "%/season-%/%", "%/season_%/%",
|
"%/season %/%", "%/season.%/%", "%/season-%/%", "%/season_%/%",
|
||||||
"%/s0%/%", "%/s1%/%", "%/s2%/%", "%/s3%/%", "%/s4%/%", "%/s5%/%", "%/s6%/%", "%/s7%/%", "%/s8%/%", "%/s9%/%",
|
"%/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
|
return false
|
||||||
}
|
}
|
||||||
for _, marker := range []string{
|
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/",
|
||||||
"/电视剧/", "/剧集/", "/连续剧/", "/短剧/", "/国产剧/", "/国剧/", "/大陆剧/", "/华语剧/", "/国产电视剧/", "/大陆电视剧/", "/华语电视剧/", "/欧美剧/", "/欧美电视剧/", "/美剧/", "/英剧/", "/日韩剧/", "/日韩电视剧/", "/日剧/", "/韩剧/", "/港剧/", "/台剧/", "/港台剧/", "/泰剧/",
|
"/电视剧/", "/剧集/", "/连续剧/", "/短剧/", "/国产剧/", "/国剧/", "/大陆剧/", "/华语剧/", "/国产电视剧/", "/大陆电视剧/", "/华语电视剧/", "/欧美剧/", "/欧美电视剧/", "/美剧/", "/英剧/", "/日韩剧/", "/日韩电视剧/", "/日剧/", "/韩剧/", "/港剧/", "/台剧/", "/港台剧/", "/泰剧/",
|
||||||
"/日番/", "/国漫/", "/番剧/", "/动漫/", "/特别篇/", "/特別篇/", "/番外/", "/特典/",
|
"/日番/", "/国漫/", "/番剧/", "/动漫/", "/特别篇/", "/特別篇/", "/番外/", "/特典/",
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const pollutedEpisodeCleanupSettingKey = "media.polluted_episode_cleanup_v2_done
|
|||||||
|
|
||||||
// seasonFolderTailRE 去掉路径末尾的「季文件夹 + 文件名」,得到整剧目录(show_dir)。
|
// seasonFolderTailRE 去掉路径末尾的「季文件夹 + 文件名」,得到整剧目录(show_dir)。
|
||||||
// 例: /tv/国漫/遮天 (2023)/Season 01/遮天 - S01E01.mkv → /tv/国漫/遮天 (2023)
|
// 例: /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 从单集路径推出整剧目录, 作为「同一部剧」的聚合键。
|
// showDirFromEpisodePath 从单集路径推出整剧目录, 作为「同一部剧」的聚合键。
|
||||||
// 若没有季文件夹, 则退而去掉文件名取其父目录。
|
// 若没有季文件夹, 则退而去掉文件名取其父目录。
|
||||||
|
|||||||
@@ -27,13 +27,14 @@ var (
|
|||||||
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
|
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
|
||||||
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
||||||
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
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*[集话話期]`)
|
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
||||||
patCNRange = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[-~–—]\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*[\[\(._-]|$)`)
|
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*季`)
|
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._-]|$)`)
|
patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`)
|
||||||
patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`)
|
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]|$)`)
|
patSeasonEpisodeZero = regexp.MustCompile(`(?i)s0*([1-9]\d?)e0+(?:[^0-9]|$)`)
|
||||||
// patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
|
// patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
|
||||||
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
|
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
|
||||||
@@ -57,6 +58,9 @@ func ParseEpisode(path string) (season, episode int) {
|
|||||||
episode = mustAtoi(m[2])
|
episode = mustAtoi(m[2])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if m := patSpecialEpisode.FindStringSubmatch(name); len(m) >= 2 {
|
||||||
|
return 0, mustAtoi(m[1])
|
||||||
|
}
|
||||||
if m := patEP.FindStringSubmatch(name); len(m) >= 2 {
|
if m := patEP.FindStringSubmatch(name); len(m) >= 2 {
|
||||||
var found bool
|
var found bool
|
||||||
season, found = seasonFromParents(path)
|
season, found = seasonFromParents(path)
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ func TestParseEpisode(t *testing.T) {
|
|||||||
{`剧集/Specials/剧集 - 02.mkv`, 0, 2},
|
{`剧集/Specials/剧集 - 02.mkv`, 0, 2},
|
||||||
{`剧集/特别篇/03.mkv`, 0, 3},
|
{`剧集/特别篇/03.mkv`, 0, 3},
|
||||||
{`剧集/剧集 - S00E04.mkv`, 0, 4},
|
{`剧集/剧集 - 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},
|
{"Movie.2020.1080p.mkv", 0, 0},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"github.com/truewhile/MeBox/internal/model"
|
"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)$`)
|
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*`)
|
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*`)
|
seriesBraceRE = regexp.MustCompile(`(?i)\s*\{(?:tmdb|tmdbid|douban|bangumi|bgm|thetvdb|tvdb)[\s:=#-]*[a-z0-9_-]+\}\s*`)
|
||||||
seriesSpacerRE = regexp.MustCompile(`[\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|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)$`)
|
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*|extras?\s*\d*|bonus(?:es)?\s*\d*|omake\s*\d*)\s*[\]))】]?$`)
|
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*[\]))】]?$`)
|
seriesSpecialCJKRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|画像特典)(?:\s*第?\s*[0-9一二三四五六七八九十百零两]+(?:[集话話期])?)?\s*[\]))】]?$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
func normalizeSeriesTitle(value string) string {
|
func normalizeSeriesTitle(value string) string {
|
||||||
|
|||||||
@@ -408,18 +408,17 @@ func TestGroupMediaSeriesCardsKeepsTheatricalMovieWithTVSeries(t *testing.T) {
|
|||||||
episode := model.Media{
|
episode := model.Media{
|
||||||
Base: model.Base{ID: "episode"},
|
Base: model.Base{ID: "episode"},
|
||||||
LibraryID: "anime",
|
LibraryID: "anime",
|
||||||
Title: "超人高校生们",
|
Title: "摇曳露营△",
|
||||||
Path: `/media/动漫/超人高校生们/Season 01/超人高校生们.S01E01.mkv`,
|
Path: `/media/动漫/摇曳露营△ (2018)/Season 01/摇曳露营△.S01E01.mkv`,
|
||||||
SeasonNum: 1,
|
SeasonNum: 1,
|
||||||
EpisodeNum: 1,
|
EpisodeNum: 1,
|
||||||
}
|
}
|
||||||
theatrical := model.Media{
|
theatrical := model.Media{
|
||||||
Base: model.Base{ID: "theatrical"},
|
Base: model.Base{ID: "theatrical"},
|
||||||
LibraryID: "anime",
|
LibraryID: "anime",
|
||||||
Title: "超人高校生们",
|
Title: "摇曳露营△ 剧场版",
|
||||||
Path: `/media/动漫/超人高校生们/剧场版/超人高校生们 剧场版.mkv`,
|
Path: `/media/动漫/摇曳露营△ (2018)/摇曳露营△ 剧场版 (2022)/Eiga.Yurukyan.2022.Bluray.mkv`,
|
||||||
SeasonNum: 1,
|
TMDbID: 566466,
|
||||||
EpisodeNum: 1,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cards := groupMediaSeriesCards([]model.Media{episode, theatrical})
|
cards := groupMediaSeriesCards([]model.Media{episode, theatrical})
|
||||||
@@ -539,4 +538,3 @@ func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) {
|
|||||||
t.Fatalf("ListMediaEpisodes got %#v, want exactly m1", eps)
|
t.Fatalf("ListMediaEpisodes got %#v, want exactly m1", eps)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -145,16 +145,32 @@ func mediaVersionGroupKey(m model.Media) string {
|
|||||||
return fmt.Sprintf("embyremote:%s", m.ID)
|
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 {
|
switch {
|
||||||
case m.TMDbID > 0:
|
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:
|
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) != "":
|
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) != "":
|
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)
|
title := firstNonEmpty(m.OriginalName, m.Title)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
@@ -166,37 +182,61 @@ func mediaVersionGroupKey(m model.Media) string {
|
|||||||
}
|
}
|
||||||
return strings.Join([]string{
|
return strings.Join([]string{
|
||||||
"episode",
|
"episode",
|
||||||
strings.ToLower(strings.TrimSpace(m.LibraryID)),
|
kind,
|
||||||
|
libKey,
|
||||||
title,
|
title,
|
||||||
fmt.Sprintf("%d:%d", m.SeasonNum, m.EpisodeNum),
|
fmt.Sprintf("%d:%d", season, episode),
|
||||||
}, "|")
|
}, "|")
|
||||||
}
|
}
|
||||||
|
|
||||||
libKey := strings.ToLower(strings.TrimSpace(m.LibraryID))
|
if specialKind != "" && specialKind != mediaSpecialTheatrical {
|
||||||
if libKey == "" {
|
return mediaVersionStemGroupKey(m, libKey)
|
||||||
libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case m.TMDbID > 0:
|
case m.TMDbID > 0:
|
||||||
if libKey != "" {
|
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)
|
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)
|
return fmt.Sprintf("tmdb:%d", m.TMDbID)
|
||||||
case m.BangumiID > 0:
|
case m.BangumiID > 0:
|
||||||
if libKey != "" {
|
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)
|
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)
|
return fmt.Sprintf("bangumi:%d", m.BangumiID)
|
||||||
case strings.TrimSpace(m.DoubanID) != "":
|
case strings.TrimSpace(m.DoubanID) != "":
|
||||||
if libKey != "" {
|
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)))
|
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))
|
return "douban:" + strings.ToLower(strings.TrimSpace(m.DoubanID))
|
||||||
case strings.TrimSpace(m.TheTVDBID) != "":
|
case strings.TrimSpace(m.TheTVDBID) != "":
|
||||||
if libKey != "" {
|
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)))
|
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))
|
return "thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
func TestGroupMediaVersionsMergesMovieEncodingVariants(t *testing.T) {
|
||||||
hd := model.Media{
|
hd := model.Media{
|
||||||
LibraryID: "movies",
|
LibraryID: "movies",
|
||||||
|
|||||||
@@ -93,7 +93,11 @@ func (s *ScannerService) readLocalScanMetadata(lib *model.Library, root *model.L
|
|||||||
if root != nil && strings.TrimSpace(root.Path) != "" {
|
if root != nil && strings.TrimSpace(root.Path) != "" {
|
||||||
rootPath = 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 {
|
if err != nil {
|
||||||
s.log.Warn("read local metadata failed", zap.String("path", path), zap.Error(err))
|
s.log.Warn("read local metadata failed", zap.String("path", path), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
`<tvshow><title>错误的剧集标题</title><tmdbid>76075</tmdbid><year>2018</year></tvshow>`,
|
||||||
|
), 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(
|
||||||
|
`<movie><title>摇曳露营△ 剧场版</title><tmdbid>566466</tmdbid><year>2022</year></movie>`,
|
||||||
|
), 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) {
|
func TestScanLibraryDoesNotMarkArtworkOnlyAsMatched(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
mediaPath := filepath.Join(root, "SSIS-001-CD1.mp4")
|
mediaPath := filepath.Join(root, "SSIS-001-CD1.mp4")
|
||||||
|
|||||||
@@ -77,6 +77,14 @@ func TestMediaLooksLikeTheatricalFeatureIgnoresStaleEpisodeIdentity(t *testing.T
|
|||||||
t.Fatal("theatrical path should override stale persisted season/episode fields")
|
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{
|
realEpisode := &model.Media{
|
||||||
Title: "剧场版制作幕后",
|
Title: "剧场版制作幕后",
|
||||||
Path: `/media/anime/超人高校生们/Season 01/超人高校生们.S01E01.mkv`,
|
Path: `/media/anime/超人高校生们/Season 01/超人高校生们.S01E01.mkv`,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ var (
|
|||||||
genericEpisodeWordsRE = regexp.MustCompile(`^\s*第\s*[集期话話]\s*$`)
|
genericEpisodeWordsRE = regexp.MustCompile(`^\s*第\s*[集期话話]\s*$`)
|
||||||
episodeReleaseTitleTagRE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e\d{1,3}(?:[\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)`)
|
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*[部篇]?)?|电影版|電影版|动画电影|動畫電影`)
|
theatricalNoiseRE = regexp.MustCompile(`(?i)(?:剧场版|劇場版)\s*(?:第?\s*\d{1,3}\s*[部篇]?)?|电影版|電影版|动画电影|動畫電影`)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ func mediaLooksLikeTheatricalFeature(m *model.Media) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
text := m.Title + " " + pathBaseSlash(m.Path)
|
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 {
|
func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var namedTheatricalFolderRE = regexp.MustCompile(`(?i)(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版)`)
|
||||||
|
|
||||||
func mediaFolderTitle(mediaPath, libraryRoot string) string {
|
func mediaFolderTitle(mediaPath, libraryRoot string) string {
|
||||||
dir := parentSlashPath(mediaPath)
|
dir := parentSlashPath(mediaPath)
|
||||||
@@ -13,10 +18,10 @@ func mediaFolderTitle(mediaPath, libraryRoot string) string {
|
|||||||
if base == "" || base == "." {
|
if base == "" || base == "." {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) || isTheatricalFolder(base) {
|
if isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) || isTheatricalFolder(base) {
|
||||||
dir = parentSlashPath(dir)
|
dir = parentSlashPath(dir)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if isMediaCollectionFolder(base) {
|
if isMediaCollectionFolder(base) {
|
||||||
dir = parentSlashPath(dir)
|
dir = parentSlashPath(dir)
|
||||||
continue
|
continue
|
||||||
@@ -123,14 +128,30 @@ func seriesFolderTitle(mediaPath, libraryRoot string) string {
|
|||||||
func isTheatricalFolder(name string) bool {
|
func isTheatricalFolder(name string) bool {
|
||||||
key := strings.ToLower(strings.TrimSpace(name))
|
key := strings.ToLower(strings.TrimSpace(name))
|
||||||
key = strings.Trim(key, `\/`)
|
key = strings.Trim(key, `\/`)
|
||||||
|
if namedTheatricalFolderRE.MatchString(key) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
switch key {
|
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
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
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 {
|
func libraryRootTitle(libraryRoot string) string {
|
||||||
base := ""
|
base := ""
|
||||||
if info, ok := ParseCloudLibraryMount(libraryRoot); ok {
|
if info, ok := ParseCloudLibraryMount(libraryRoot); ok {
|
||||||
|
|||||||
@@ -4,7 +4,21 @@ import { Play, Search } from 'lucide-react'
|
|||||||
import { imageURL } from '../api/client'
|
import { imageURL } from '../api/client'
|
||||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||||
import type { Media } from '../types'
|
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'
|
import { formatSize } from './libraryPageModel'
|
||||||
|
|
||||||
type SeasonGroup = {
|
type SeasonGroup = {
|
||||||
@@ -135,9 +149,21 @@ export function LibrarySeriesEpisodes({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function seasonLabel(season: number): string {
|
function seasonLabel(season: number): string {
|
||||||
if (season === 0) return '特别篇'
|
switch (season) {
|
||||||
if (season === THEATRICAL_SEASON) return '剧场版'
|
case 0: return '特别篇'
|
||||||
return `第 ${season} 季`
|
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 {
|
function episodeDisplayTitle(ep: Media, siblings: Media[]): string {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function seriesSourceRoot(episodes: Media[]): string {
|
|||||||
if (!firstPath) return ''
|
if (!firstPath) return ''
|
||||||
const dir = dirname(firstPath)
|
const dir = dirname(firstPath)
|
||||||
const base = basename(dir)
|
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 dirname(dir)
|
||||||
}
|
}
|
||||||
return dir
|
return dir
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
|
|
||||||
import type { Media } from '../types'
|
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 = {
|
type SeasonEpisodes = {
|
||||||
season: number
|
season: number
|
||||||
@@ -47,9 +63,9 @@ export function useLibrarySeriesSelection({
|
|||||||
: sourceItems.filter((m) => getSeriesKey(m) === selectedSeries.key)
|
: sourceItems.filter((m) => getSeriesKey(m) === selectedSeries.key)
|
||||||
const seasons = new Map<number, Media[]>()
|
const seasons = new Map<number, Media[]>()
|
||||||
for (const ep of eps) {
|
for (const ep of eps) {
|
||||||
const s = isTheatricalFeature(ep)
|
const specialSection = specialSectionForMedia(ep)
|
||||||
? THEATRICAL_SEASON
|
const s = specialSection ??
|
||||||
: ep.episode_num > 0 ? (ep.season_num ?? 0) : (ep.season_num || 1)
|
(ep.episode_num > 0 ? (ep.season_num ?? 0) : (ep.season_num || 1))
|
||||||
if (!seasons.has(s)) seasons.set(s, [])
|
if (!seasons.has(s)) seasons.set(s, [])
|
||||||
seasons.get(s)!.push(ep)
|
seasons.get(s)!.push(ep)
|
||||||
}
|
}
|
||||||
@@ -132,7 +148,19 @@ export function useLibrarySeriesSelection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function seasonSortOrder(season: number): number {
|
function seasonSortOrder(season: number): number {
|
||||||
if (season === 0) return -2
|
switch (season) {
|
||||||
if (season === THEATRICAL_SEASON) return -1
|
case 0: return 0
|
||||||
return season
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,16 @@ export type SeriesCard = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const THEATRICAL_SEASON = -1
|
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 {
|
export function getSeriesKey(media: Media): string {
|
||||||
return compactSeriesKey(getSeriesRawKey(media))
|
return compactSeriesKey(getSeriesRawKey(media))
|
||||||
@@ -92,16 +102,19 @@ export function isEpisodeLike(media: Media): boolean {
|
|||||||
// 剧集类目录名(电视剧/动漫及其二级分类)。媒体路径落在这些目录下时, 即便
|
// 剧集类目录名(电视剧/动漫及其二级分类)。媒体路径落在这些目录下时, 即便
|
||||||
// 季集号未识别出来, 也应按剧集对待, 跳转到 /library 分类视图而非 /media 单页。
|
// 季集号未识别出来, 也应按剧集对待, 跳转到 /library 分类视图而非 /media 单页。
|
||||||
const EPISODIC_PATH_RE =
|
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 =
|
const THEATRICAL_TITLE_RE =
|
||||||
/(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版|\bthe\s+movie\b|\bmovie\s*\d{1,2}\b)/i
|
/(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版|\bthe\s+movie\b|\bmovie\s*\d{1,2}\b)/i
|
||||||
|
|
||||||
|
const THEATRICAL_FOLDER_NAME_RE =
|
||||||
|
/(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版)/i
|
||||||
|
|
||||||
const THEATRICAL_FOLDER_RE =
|
const THEATRICAL_FOLDER_RE =
|
||||||
/[\\/](?:剧场版|劇場版|动画电影|動畫電影)[\\/]/
|
/[\\/][^\\/]*(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版)[^\\/]*[\\/]/
|
||||||
|
|
||||||
const SEASON_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 {
|
export function pathLooksEpisodic(media: Media): boolean {
|
||||||
const path = (media.path || media.display_library_path || media.library_path || '')
|
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)
|
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 {
|
export function isSeriesCard(card: SeriesCard): boolean {
|
||||||
return (
|
return (
|
||||||
card.count > 1 ||
|
card.count > 1 ||
|
||||||
@@ -170,10 +207,10 @@ function normalizeTitle(value?: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SERIES_SPECIAL_CODE_RE =
|
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 =
|
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 {
|
function normalizePathSeriesTitle(value?: string): string {
|
||||||
const title = normalizeTitle(value)
|
const title = normalizeTitle(value)
|
||||||
@@ -271,16 +308,20 @@ function seriesDirectoryNameFromPath(path?: string): string {
|
|||||||
if (parts.length < 2) return ''
|
if (parts.length < 2) return ''
|
||||||
let dirIndex = parts.length - 2
|
let dirIndex = parts.length - 2
|
||||||
const lastPart = parts[parts.length - 1]
|
const lastPart = parts[parts.length - 1]
|
||||||
if (!seriesPathPartLooksLikeFile(lastPart) && !SEASON_FOLDER_RE.test(lastPart)) {
|
if (!seriesPathPartLooksLikeFile(lastPart) && !isSeriesContainerFolder(lastPart)) {
|
||||||
dirIndex = parts.length - 1
|
dirIndex = parts.length - 1
|
||||||
}
|
}
|
||||||
while (dirIndex >= 0 && SEASON_FOLDER_RE.test(parts[dirIndex])) {
|
while (dirIndex >= 0 && isSeriesContainerFolder(parts[dirIndex])) {
|
||||||
dirIndex -= 1
|
dirIndex -= 1
|
||||||
}
|
}
|
||||||
if (dirIndex < 0) return ''
|
if (dirIndex < 0) return ''
|
||||||
return parts[dirIndex]
|
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 {
|
function seriesExternalIDFromPath(path?: string): string {
|
||||||
const part = seriesDirectoryNameFromPath(path)
|
const part = seriesDirectoryNameFromPath(path)
|
||||||
if (!part) return ''
|
if (!part) return ''
|
||||||
|
|||||||
Reference in New Issue
Block a user