From 9effb1422b0068c8f0a8f12e8e43f3fefce19b47 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:06:28 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/service/episode_parser.go | 106 ++++++++++++++++++++++-- internal/service/episode_parser_test.go | 60 ++++++++++++++ internal/service/local_metadata_read.go | 6 ++ internal/service/local_metadata_test.go | 42 ++++++++++ internal/service/media_versions_test.go | 36 ++++++++ 5 files changed, 245 insertions(+), 5 deletions(-) diff --git a/internal/service/episode_parser.go b/internal/service/episode_parser.go index 7b39c8f..733da41 100644 --- a/internal/service/episode_parser.go +++ b/internal/service/episode_parser.go @@ -6,6 +6,11 @@ // 1x02 / 01x02 // EP02 / E02 // 第2集 / 第02集 +// [01] / [001](字幕组方括号集号,如 [UHA-WINGS][…][01][BDRIP]) +// +// The "1x02" pattern only matches when it is not embedded in a larger number, +// so pixel dimensions such as 1920x1080 / 3840x2160 are never mistaken for +// season×episode (the old pattern read those as S20E108 / S40E216). // // For bare episode markers such as "EP02", the parser also looks at parent // folders like "Season 02" / "S02" / "第2季" before falling back to season 1. @@ -14,6 +19,7 @@ package service import ( + "fmt" "path/filepath" "regexp" "strconv" @@ -22,10 +28,18 @@ import ( ) var ( - patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`) - patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`) - patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`) - patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`) + patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`) + patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`) + patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`) + // patNxE 匹配 1x02 这类季集写法的捕获组,同时用于从标题里剔除季集残留 + // (ReplaceAllString),因此本身不带边界守卫。解析时改用 patNxEGuarded, + // 避免 "1920x1080" 被从中间匹配出 "20x108" 而误判成 S20E108。 + patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`) + patNxEGuarded = regexp.MustCompile(`(?:^|[^0-9])(\d{1,2})x(\d{1,3})(?:[^0-9]|$)`) + // patBracketEpisode 匹配字幕组常见的纯数字方括号集号,如 [01] / [012]。 + // 限定 1-3 位,避免把 [2024] 这类年份当成集号;含字母的 [NCOP]/[1080p] + // 自然不匹配。 + patBracketEpisode = regexp.MustCompile(`\[0*(\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*[集话話期]`) @@ -38,6 +52,8 @@ var ( patSeasonEpisodeZero = regexp.MustCompile(`(?i)s0*([1-9]\d?)e0+(?:[^0-9]|$)`) // patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。 patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`) + // patResolutionDims 匹配 1920x1080 / 3840×2160 这类像素尺寸。 + patResolutionDims = regexp.MustCompile(`(?i)(\d{3,4})\s*[x×]\s*(\d{3,4})`) ) // ParseEpisode tries to extract (season, episode) from an arbitrary filename. @@ -53,11 +69,20 @@ func ParseEpisode(path string) (season, episode int) { episode = mustAtoi(m[2]) return } - if m := patNxE.FindStringSubmatch(name); len(m) == 3 { + if m := patNxEGuarded.FindStringSubmatch(name); len(m) == 3 { season = mustAtoi(m[1]) episode = mustAtoi(m[2]) return } + if m := patBracketEpisode.FindStringSubmatch(name); len(m) == 2 { + var found bool + season, found = seasonFromParents(path) + if !found { + season = 1 + } + episode = mustAtoi(m[1]) + return + } if m := patSpecialEpisode.FindStringSubmatch(name); len(m) >= 2 { return 0, mustAtoi(m[1]) } @@ -98,6 +123,77 @@ func ParseEpisode(path string) (season, episode int) { return 0, 0 } +// resolutionEpisodeArtifact returns the bogus (season, episode) pair the legacy +// `(\d{1,2})x(\d{1,3})` pattern would extract from a WxH pixel-dimension token +// in path — e.g. 1920x1080 -> (20, 108), 3840x2160 -> (40, 216). Reports ok=false +// when the name carries no such token. +// +// MeBox used to persist these pairs into sidecar NFOs, so on rescan the NFO +// would inject the wrong identity back even after the parser was fixed. +func resolutionEpisodeArtifact(path string) (season, episode int, ok bool) { + name := mediaSidecarBase(path) + if name == "" { + return 0, 0, false + } + dims := patResolutionDims.FindStringSubmatch(name) + if len(dims) < 3 { + return 0, 0, false + } + m := patNxE.FindStringSubmatch(dims[1] + "x" + dims[2]) + if len(m) != 3 { + return 0, 0, false + } + return mustAtoi(m[1]), mustAtoi(m[2]), true +} + +// dropResolutionArtifactEpisodeIdentity clears a season/episode pair (and the +// episode title generated from it) that only exists because a resolution token +// was once mistaken for an SxxExx marker. +func dropResolutionArtifactEpisodeIdentity(meta *LocalMetadata, mediaPath string) { + if meta == nil { + return + } + season, episode, ok := resolutionEpisodeArtifact(mediaPath) + if !ok || meta.SeasonNum != season || meta.EpisodeNum != episode { + return + } + // 只有当文件名本身也无法权威地解析出同一季集号时才判定为伪集号。 + // 例如 Show.S20E108.1920x1080.mkv 的 S20E108 是真实标记,必须保留。 + if parsedSeason, parsedEpisode := ParseEpisode(mediaPath); parsedEpisode > 0 && + parsedSeason == meta.SeasonNum && parsedEpisode == meta.EpisodeNum { + return + } + meta.SeasonNum = 0 + meta.EpisodeNum = 0 + if isGeneratedEpisodeTitle(meta.EpisodeTitle, episode) { + meta.EpisodeTitle = "" + } +} + +// isGeneratedEpisodeTitle reports whether title is the default "第 N 集" / +// "Episode N" form auto-written from an episode number rather than a real name. +func isGeneratedEpisodeTitle(title string, episode int) bool { + title = strings.TrimSpace(title) + if title == "" || episode <= 0 { + return false + } + for _, candidate := range []string{ + fmt.Sprintf("第 %d 集", episode), + fmt.Sprintf("第%d集", episode), + fmt.Sprintf("第 %d 话", episode), + fmt.Sprintf("第%d话", episode), + fmt.Sprintf("第 %d 話", episode), + fmt.Sprintf("第%d話", episode), + fmt.Sprintf("Episode %d", episode), + fmt.Sprintf("EP%d", episode), + } { + if strings.EqualFold(title, candidate) { + return true + } + } + return false +} + // onlineEpisodeIdentityFromPath converts the common anime SxxE00 convention // to provider-style specials. For example, S01E00 becomes S00E01 and S02E00 // becomes S00E02. Normal episodes retain their parsed identity. diff --git a/internal/service/episode_parser_test.go b/internal/service/episode_parser_test.go index 59af563..8f23156 100644 --- a/internal/service/episode_parser_test.go +++ b/internal/service/episode_parser_test.go @@ -34,6 +34,15 @@ func TestParseEpisode(t *testing.T) { {`动漫/示例/OVD/示例-OVD03.mkv`, 0, 3}, {`动漫/示例/ONA/示例_ONA04.mkv`, 0, 4}, {"Movie.2020.1080p.mkv", 0, 0}, + // 分辨率不能被当成季集号:1920x1080 曾匹配出 20x108。 + {"Movie.2020.1920x1080.mkv", 0, 0}, + {"1920x1080.mkv", 0, 0}, + {"[Group][Show][02][3840x2160].mkv", 1, 2}, + // 字幕组方括号集号。 + {"[UHA-WINGS][Peter Grill to Kenja no Jikan][01][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm", 1, 1}, + {"[UHA-WINGS][Peter Grill to Kenja no Jikan][12][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm", 1, 12}, + // 方括号里的年份/分辨率不是集号。 + {"[Group][Show][2024][1080p].mkv", 0, 0}, } for _, tc := range cases { t.Run(tc.in, func(t *testing.T) { @@ -92,3 +101,54 @@ func TestOnlineEpisodeIdentityFromPathMapsAnimeEpisodeZeroToSpecials(t *testing. } } } + +func TestResolutionEpisodeArtifact(t *testing.T) { + cases := []struct { + path string + wantSeason int + wantEpisode int + wantOK bool + }{ + {"[UHA-WINGS][Peter Grill to Kenja no Jikan][01][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm", 20, 108, true}, + {"今永纱奈/20170601000150_2,00x_3840x2160_amq-13.strm", 40, 216, true}, + {"Movie.2020.1080p.mkv", 0, 0, false}, + {"Show.S01E02.mkv", 0, 0, false}, + } + for _, tc := range cases { + season, episode, ok := resolutionEpisodeArtifact(tc.path) + if season != tc.wantSeason || episode != tc.wantEpisode || ok != tc.wantOK { + t.Errorf("resolutionEpisodeArtifact(%q) = (%d, %d, %v), want (%d, %d, %v)", + tc.path, season, episode, ok, tc.wantSeason, tc.wantEpisode, tc.wantOK) + } + } +} + +func TestDropResolutionArtifactEpisodeIdentity(t *testing.T) { + // 分辨率伪集号被剔除,并从生成的「第 N 集」标题里清掉。 + polluted := &LocalMetadata{SeasonNum: 20, EpisodeNum: 108, EpisodeTitle: "第 108 集"} + dropResolutionArtifactEpisodeIdentity(polluted, "[G][Show][01][BDRIP 1920x1080 x].strm") + if polluted.SeasonNum != 0 || polluted.EpisodeNum != 0 || polluted.EpisodeTitle != "" { + t.Fatalf("resolution artifact not dropped: %+v", polluted) + } + + // 真实单集号不受影响。 + real := &LocalMetadata{SeasonNum: 1, EpisodeNum: 3, EpisodeTitle: "本地第三集"} + dropResolutionArtifactEpisodeIdentity(real, "Show/S01E03 1920x1080.mkv") + if real.SeasonNum != 1 || real.EpisodeNum != 3 || real.EpisodeTitle != "本地第三集" { + t.Fatalf("real episode identity was modified: %+v", real) + } + + // 名称里没有分辨率时不改动任何值。 + plain := &LocalMetadata{SeasonNum: 20, EpisodeNum: 108} + dropResolutionArtifactEpisodeIdentity(plain, "Show/S20E108.mkv") + if plain.SeasonNum != 20 || plain.EpisodeNum != 108 { + t.Fatalf("unrelated identity was cleared: %+v", plain) + } + + // 文件名里 S20E108 是真实标记时,即使同时含分辨率也必须保留。 + legit := &LocalMetadata{SeasonNum: 20, EpisodeNum: 108} + dropResolutionArtifactEpisodeIdentity(legit, "Show/Show.S20E108.1920x1080.mkv") + if legit.SeasonNum != 20 || legit.EpisodeNum != 108 { + t.Fatalf("legitimate S20E108 identity was cleared: %+v", legit) + } +} diff --git a/internal/service/local_metadata_read.go b/internal/service/local_metadata_read.go index ff91ab9..032d70c 100644 --- a/internal/service/local_metadata_read.go +++ b/internal/service/local_metadata_read.go @@ -23,6 +23,9 @@ func ReadLocalMetadata(mediaPath, libraryRoot string, seriesLike bool) (*LocalMe } meta := metadataFromDoc(doc, filepath.Dir(path), false) mergeArtworkMetadata(meta, mediaPath, filepath.Dir(path)) + // A sidecar written while resolution tokens were misread as SxxExx must not + // reintroduce the bogus season/episode on rescan. + dropResolutionArtifactEpisodeIdentity(meta, mediaPath) return meta, nil } @@ -116,6 +119,9 @@ func readSeriesMetadata(mediaPath, libraryRoot string) (*LocalMetadata, error) { } else { mergeArtworkMetadata(meta, mediaPath, showBaseDir) } + // A sidecar written while resolution tokens were misread as SxxExx must not + // reintroduce the bogus season/episode on rescan. + dropResolutionArtifactEpisodeIdentity(meta, mediaPath) return meta, nil } diff --git a/internal/service/local_metadata_test.go b/internal/service/local_metadata_test.go index c58a24d..7790526 100644 --- a/internal/service/local_metadata_test.go +++ b/internal/service/local_metadata_test.go @@ -6,6 +6,48 @@ import ( "testing" ) +func TestReadLocalMetadataDropsResolutionArtifactEpisode(t *testing.T) { + // 刮削曾把分辨率 1920x1080 误读成 S20E108 并回写进边车 NFO; + // 重扫时必须忽略这个伪季集号,否则旧文件会把错误身份灌回 DB。 + root := t.TempDir() + showDir := filepath.Join(root, "彼得·格里尔的贤者时间") + if err := os.MkdirAll(showDir, 0o755); err != nil { + t.Fatal(err) + } + mediaPath := filepath.Join(showDir, + "[UHA-WINGS][Peter Grill to Kenja no Jikan][01][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm") + if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(nfoPath(mediaPath), []byte(` + + 第 108 集 + 彼得·格里尔的贤者时间 + 20 + 108 + 99080 +`), 0o644); err != nil { + t.Fatal(err) + } + + got, err := ReadLocalMetadata(mediaPath, root, true) + if err != nil { + t.Fatal(err) + } + if got == nil { + t.Fatal("metadata is nil") + } + if got.SeasonNum != 0 || got.EpisodeNum != 0 { + t.Fatalf("resolution artifact season/episode not dropped: s=%d e=%d", got.SeasonNum, got.EpisodeNum) + } + if got.EpisodeTitle != "" { + t.Fatalf("generated episode title not dropped: %q", got.EpisodeTitle) + } + if got.Title != "彼得·格里尔的贤者时间" { + t.Fatalf("series title = %q, want 彼得·格里尔的贤者时间", got.Title) + } +} + func TestReadLocalMovieMetadata(t *testing.T) { dir := t.TempDir() mediaPath := filepath.Join(dir, "Inception.2010.mkv") diff --git a/internal/service/media_versions_test.go b/internal/service/media_versions_test.go index b5592d5..f74fe5d 100644 --- a/internal/service/media_versions_test.go +++ b/internal/service/media_versions_test.go @@ -1,6 +1,7 @@ package service import ( + "fmt" "strings" "testing" "time" @@ -114,6 +115,41 @@ func TestGroupMediaVersionsSeparatesAnimeSpecialKindsAndNumbers(t *testing.T) { } } +func TestGroupMediaVersionsKeepsBracketNumberedEpisodesSeparate(t *testing.T) { + // UHA-WINGS 命名:[组名][标题][01][BDRIP 1920x1080 ...].strm。 + // 曾因 1920x1080 被解析成 S20E108,12 集全部折叠成一集的多个版本。 + rows := make([]model.Media, 0, 12) + for episode := 1; episode <= 12; episode++ { + rows = append(rows, model.Media{ + Base: model.Base{ID: fmt.Sprintf("ep-%02d", episode)}, + LibraryID: "anime", + Title: "彼得·格里尔的贤者时间", + Path: fmt.Sprintf( + "/media/影视库/动漫/彼得·格里尔的贤者时间/[UHA-WINGS][Peter Grill to Kenja no Jikan][%02d][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm", + episode), + TMDbID: 99080, + }) + } + for i := range rows { + season, episode := ParseEpisode(rows[i].Path) + rows[i].SeasonNum = season + rows[i].EpisodeNum = episode + } + + grouped := groupMediaVersions(rows) + if len(grouped) != 12 { + t.Fatalf("grouped len = %d, want 12 distinct episodes: %#v", len(grouped), grouped) + } + for _, item := range grouped { + if len(item.Versions) > 1 { + t.Fatalf("distinct episodes were folded into versions: %#v", item.Versions) + } + if item.SeasonNum != 1 || item.EpisodeNum < 1 || item.EpisodeNum > 12 { + t.Fatalf("unexpected episode identity s=%d e=%d", item.SeasonNum, item.EpisodeNum) + } + } +} + func TestGroupMediaVersionsMergesSameNumberedOVAEncodes(t *testing.T) { rows := []model.Media{ {