From 8d79c7c378e40e3fee4681cbfde8ec9d252b5fce Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 18:02:39 +0100 Subject: [PATCH] fix(parse): pack titles and degenerate ranges COMPLETE before the season tag was weak junk, so it stayed in the title and exact-title matching failed silently (#135). It now closes the boundary wherever it lands. S01-S01 produced no claim because the dash-range branch required last > season; a degenerate range is one season's pack, matching the rule match_episode lifted. --- crates/arr-core/src/matching.rs | 34 +++++++++++++++++++++++++++--- crates/arr-parse/src/episodes.rs | 7 ++++--- crates/arr-parse/src/markers.rs | 6 +++--- crates/arr-parse/tests/parse.rs | 36 ++++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 9 deletions(-) diff --git a/crates/arr-core/src/matching.rs b/crates/arr-core/src/matching.rs index 16dc44e..50455f8 100644 --- a/crates/arr-core/src/matching.rs +++ b/crates/arr-core/src/matching.rs @@ -621,6 +621,10 @@ mod tests { "Fallout.S01.1080p.BluRay.x264-GROUP", MatchShape::SeasonPack, ), + ( + "Fallout.S01-S01.1080p.BluRay.x264-GROUP", + MatchShape::SeasonPack, + ), ]; for (name, shape) in cases { assert_eq!( @@ -639,6 +643,32 @@ mod tests { } } + /// `COMPLETE` before the season tag used to stay in the title and + /// break the exact-title comparison (#135). + #[test] + fn a_pack_marker_before_the_tag_still_matches() { + let wanted = WantedEpisode { + id: EpisodeId(10), + tmdb_id: None, + imdb_id: None, + title: "Bluey".to_owned(), + season: 1, + episode: 3, + }; + assert_eq!( + match_episode( + &wanted, + &ReleaseIds::default(), + &arr_parse::parse("Bluey.COMPLETE.S01.1080p.WEB-DL-GROUP"), + ), + Some(EpisodeMatch { + episode: EpisodeId(10), + kind: MatchKind::TitleAndYear, + shape: MatchShape::SeasonPack, + }) + ); + } + /// Punctuation, case and accents in the series title do not block the /// match. #[test] @@ -808,9 +838,7 @@ mod tests { } } - /// `S01-S01` does not parse as a claim (`arr-parse` takes only a real - /// range), but the lifted rule is what a degenerate range would mean: - /// one season's pack, not a multi-season one. + /// A degenerate range means one season's pack, not a multi-season one. #[test] fn a_degenerate_season_range_is_this_seasons_pack() { use super::{is_single_episode, is_this_seasons_pack}; diff --git a/crates/arr-parse/src/episodes.rs b/crates/arr-parse/src/episodes.rs index 629ee54..0b475a9 100644 --- a/crates/arr-parse/src/episodes.rs +++ b/crates/arr-parse/src/episodes.rs @@ -36,7 +36,7 @@ pub(crate) fn tag_of(token: &str) -> Option { .or_else(|| date_tag(token)) } -/// `S01`, `S01-S03`, `S01E02`, `S01E02E03`, `S01E02-E04`. +/// `S01`, `S01-S03`, `S01-S01`, `S01E02`, `S01E02E03`, `S01E02-E04`. fn season_tag(token: &str) -> Option { let rest = token.strip_prefix('s')?; let (season, mut rest) = digits(rest); @@ -55,7 +55,8 @@ fn season_tag(token: &str) -> Option { return None; } let last: u32 = last.parse().ok()?; - return (last > season).then_some(EpisodeClaim::Seasons { + // A degenerate range (`S01-S01`) is one season's pack, not nothing. + return (last >= season).then_some(EpisodeClaim::Seasons { first: season, last, }); @@ -148,7 +149,7 @@ fn season_words(token: &str) -> Option { return None; } let last: u32 = last.parse().ok()?; - (last > first).then_some(EpisodeClaim::Seasons { first, last }) + (last >= first).then_some(EpisodeClaim::Seasons { first, last }) } /// Splits a leading run of ASCII digits off a token. diff --git a/crates/arr-parse/src/markers.rs b/crates/arr-parse/src/markers.rs index 3ccbe00..4724239 100644 --- a/crates/arr-parse/src/markers.rs +++ b/crates/arr-parse/src/markers.rs @@ -135,9 +135,9 @@ fn classify_exact(t: &str) -> Option<(Marker, Strength)> { // "Legendado" claims pt-BR *subtitles* over original audio — it must // NOT become a PtBr audio claim or main-root originals get rejected. "proper" | "repack" | "internal" | "hybrid" | "10bit" | "8bit" | "hi10p" | "amzn" - | "dsnp" | "atvp" | "hmax" | "pcok" | "legendado" => (Marker::Junk, Strong), - "nf" | "hulu" | "max" | "itunes" | "cr" | "complete" | "sample" | "subbed" | "subs" - | "sub" | "mkv" | "mp4" | "avi" | "www" | "com" | "net" | "org" | "retail" | "readnfo" => { + | "dsnp" | "atvp" | "hmax" | "pcok" | "legendado" | "complete" => (Marker::Junk, Strong), + "nf" | "hulu" | "max" | "itunes" | "cr" | "sample" | "subbed" | "subs" | "sub" | "mkv" + | "mp4" | "avi" | "www" | "com" | "net" | "org" | "retail" | "readnfo" => { (Marker::Junk, Weak) } _ => return None, diff --git a/crates/arr-parse/tests/parse.rs b/crates/arr-parse/tests/parse.rs index 1078ca6..1be678f 100644 --- a/crates/arr-parse/tests/parse.rs +++ b/crates/arr-parse/tests/parse.rs @@ -307,6 +307,42 @@ fn cases() -> Vec { ..NameClaims::default() }, }, + Case { + // `COMPLETE` before the tag closes the title like any strong + // marker, wherever the group puts it. + name: "Bluey.COMPLETE.S01.1080p.WEB-DL", + want: NameClaims { + title: s("Bluey"), + resolution: Some(Resolution::P1080), + source: Some(Source::WebDl), + episode: Some(EpisodeClaim::Season { season: 1 }), + ..NameClaims::default() + }, + }, + Case { + // The trailing placement already worked; it keeps working. + name: "Bluey.S01.COMPLETE.1080p.WEB-DL", + want: NameClaims { + title: s("Bluey"), + resolution: Some(Resolution::P1080), + source: Some(Source::WebDl), + episode: Some(EpisodeClaim::Season { season: 1 }), + ..NameClaims::default() + }, + }, + Case { + // A degenerate range is one season's pack. + name: "Fallout.S01-S01.1080p.BluRay.x264-GROUP", + want: NameClaims { + title: s("Fallout"), + resolution: Some(Resolution::P1080), + source: Some(Source::BluRay), + codec: Some(Codec::X264), + group: s("GROUP"), + episode: Some(EpisodeClaim::Seasons { first: 1, last: 1 }), + ..NameClaims::default() + }, + }, Case { name: "Movie.2019.4K.HDR.DV.2160p.BDRemux.Ita.Eng.x265-NAHOM", want: NameClaims {