avatarr_parser/quality/parser.rs
1// Ported from Sonarr v4.0.17.2952 (97e85a90):
2// src/NzbDrone.Core/Parser/QualityParser.cs
3
4use once_cell::sync::Lazy;
5use regex::Regex;
6
7/// Source-detection regex constants ported from `QualityParser.cs`.
8///
9/// These mirror the C# `private static readonly Regex` declarations at lines
10/// 17 to 66 of `QualityParser.cs`. The Rust `regex` crate is RE2-based and
11/// does not support backreferences or lookarounds; where a C# pattern uses
12/// either, the constant here is a relaxed superset and a sibling helper
13/// function applies the missing constraint in code post-match. Each such
14/// case is documented inline above the affected `Lazy<Regex>` declaration.
15pub mod regexes {
16 use super::{Lazy, Regex};
17
18 /// Source-class detector. Ported from `QualityParser.cs:17-30`.
19 ///
20 /// The C# pattern uses three negative lookarounds inside the `bluray` and
21 /// `webdl` branches:
22 ///
23 /// 1. `BD(?!$)` (bluray branch). Bare `BD` only counts when not at end of
24 /// string. Rust workaround: relaxed to `BD`; `match_source` rejects a
25 /// `bluray` match whose entire captured text is `BD` and ends at the
26 /// input boundary.
27 /// 2. `(?-i:WEB)$` (webdl branch). Case-sensitive uppercase `WEB` at end
28 /// of input. Rust regex DOES support `(?-i:...)` flag-disable groups,
29 /// so this is ported verbatim.
30 /// 3. `(?:AMZN|NF|DP)[. -]WEB[. -](?!Rip)` (webdl branch). Provider tag
31 /// followed by `WEB` and a separator that is not `Rip`. Rust workaround:
32 /// drops the `(?!Rip)` and wraps the provider alternative in a nested
33 /// named sub-capture `(?P<provider_web>...)`. `match_source` then gates
34 /// the post-match `(?!Rip)` filter on `provider_web.is_some()`,
35 /// structurally identifying which alternation branch fired rather than
36 /// relying on alternation order or prefix heuristics.
37 ///
38 /// Callers reach a fully-correct match through [`super::match_source`];
39 /// the raw `SOURCE_REGEX` is exported for tests that want to assert which
40 /// branch the alternation hit (named groups: `bluray`, `webdl`, `webrip`,
41 /// `hdtv`, `bdrip`, `brrip`, `dvd`, `dsr`, `pdtv`, `sdtv`, `tvrip`).
42 pub static SOURCE_REGEX: Lazy<Regex> = Lazy::new(|| {
43 Regex::new(
44 r"(?ix)
45 \b(?:
46 (?P<bluray>BluRay|Blu-Ray|HD-?DVD|BDMux|BD)|
47 (?P<webdl>
48 WEB[-_.\x20]DL(?:mux)?|WEBDL|AmazonHD|AmazonSD|iTunesHD|MaxdomeHD|NetflixU?HD|WebHD|HBOMaxHD|DisneyHD|
49 [.\x20]WEB[.\x20](?:[xh][ .]?26[45]|AVC|HEVC|DDP?5[. ]1)|
50 [.\x20](?-i:WEB)$|
51 (?:720|1080|2160)p[-.\x20]WEB[-.\x20]|
52 [-.\x20]WEB[-.\x20](?:720|1080|2160)p|
53 \b\s/\sWEB\s/\s\b|
54 (?P<provider_web>(?:AMZN|NF|DP)[.\x20-]WEB[.\x20-])
55 )|
56 (?P<webrip>WebRip|Web-Rip|WEBMux)|
57 (?P<hdtv>HDTV)|
58 (?P<bdrip>BDRip|BDLight)|
59 (?P<brrip>BRRip)|
60 (?P<dvd>DVD|DVDRip|NTSC|PAL|xvidvd)|
61 (?P<dsr>WS[-_.\x20]DSR|DSR)|
62 (?P<pdtv>PDTV)|
63 (?P<sdtv>SDTV)|
64 (?P<tvrip>TVRip)
65 )(?:\b|$|[\x20.])",
66 )
67 .expect("SOURCE_REGEX must compile")
68 });
69
70 /// `RawHD` / `Raw-HD` / `Raw_HD` / `Raw.HD` / `Raw HD` detector. Ported
71 /// from `QualityParser.cs:32-33`.
72 pub static RAW_HD_REGEX: Lazy<Regex> = Lazy::new(|| {
73 Regex::new(r"(?i)\b(?P<rawhd>RawHD|Raw[-_. ]HD)\b").expect("RAW_HD_REGEX must compile")
74 });
75
76 /// `MPEG2` / `MPEG-2` / `MPEG_2` / `MPEG.2` / `MPEG 2` detector. Ported
77 /// from `QualityParser.cs:35`. Note: the C# pattern is **case-sensitive**
78 /// (no `RegexOptions.IgnoreCase`), so we omit the `(?i)` flag here.
79 pub static MPEG2_REGEX: Lazy<Regex> =
80 Lazy::new(|| Regex::new(r"\b(?P<mpeg2>MPEG[-_. ]?2)\b").expect("MPEG2_REGEX must compile"));
81
82 /// Remux detector. Ported from `QualityParser.cs:66`.
83 ///
84 /// The C# pattern declares the same group name `(?<remux>...)` twice in an
85 /// alternation. .NET allows that; Rust's `regex` crate rejects duplicate
86 /// names within a single pattern. To preserve identical match semantics
87 /// while staying compilable, the second branch's group is renamed
88 /// `remux_post`. Callers should treat a hit on EITHER `remux` or
89 /// `remux_post` as a positive Remux signal; `super::match_remux` exposes
90 /// that contract directly.
91 pub static REMUX_REGEX: Lazy<Regex> = Lazy::new(|| {
92 Regex::new(
93 r"(?i)(?:[_. ]|\d{4}p-|\bHybrid-)(?P<remux>(?:(BD|UHD)[-_. ]?)?Remux)\b|(?P<remux_post>(?:(BD|UHD)[-_. ]?)?Remux[_. ]\d{4}p)",
94 )
95 .expect("REMUX_REGEX must compile")
96 });
97
98 /// Anime-Bluray detector. Ported from `QualityParser.cs:61`.
99 ///
100 /// The C# pattern uses paired lookarounds for the bare `bd` branch:
101 /// `(?<=[-_. (\[])bd(?=[-_. )\]])`. Rust's `regex` crate does not support
102 /// lookarounds, so the constant here is the relaxed superset
103 /// `bd(?:720|1080|2160)|bd`. Callers must reach a fully-correct match
104 /// through [`super::matches_anime_bluray`], which applies the surround
105 /// check in code on bare `bd` candidates.
106 pub static ANIME_BLURAY_REGEX: Lazy<Regex> = Lazy::new(|| {
107 Regex::new(r"(?i)bd(?:720|1080|2160)|bd").expect("ANIME_BLURAY_REGEX must compile")
108 });
109
110 /// Anime-WEB-DL detector. Ported from `QualityParser.cs:62`.
111 pub static ANIME_WEBDL_REGEX: Lazy<Regex> = Lazy::new(|| {
112 Regex::new(r"(?i)\[WEB\]|[\[(]WEB[ .]").expect("ANIME_WEBDL_REGEX must compile")
113 });
114
115 /// Resolution detector. Ported from `QualityParser.cs:49-50`.
116 ///
117 /// Named groups: `R360p`, `R480p`, `R540p`, `R576p`, `R720p`, `R1080p`,
118 /// `R2160p`. Case-insensitive (`IgnoreCase` in C# → `(?i)` flag here).
119 ///
120 /// Cross-checked against C#: every alternative branch, including the
121 /// `4kto1080p` downscaled-UHD token on the `R1080p` branch, is reproduced
122 /// verbatim. The one place this port deliberately differs from the m50
123 /// plan-spec draft is the `R2160p` branch's 4K alternatives: the plan
124 /// drafted `4kto2160p`, but C# actually carries
125 /// `4k[-_. ](?:UHD|HEVC|BD|H265)|(?:UHD|HEVC|BD|H265)[-_. ]4k`. C# is
126 /// authoritative per the m50 standing rules, so the port mirrors C#.
127 /// The Rust `regex` crate accepts the pattern as-is, with every named
128 /// group unique and no lookarounds.
129 pub static RESOLUTION_REGEX: Lazy<Regex> = Lazy::new(|| {
130 Regex::new(
131 r"(?i)\b(?:(?P<R360p>360p)|(?P<R480p>480p|480i|640x480|848x480)|(?P<R540p>540p)|(?P<R576p>576p)|(?P<R720p>720p|1280x720|960p)|(?P<R1080p>1080p|1920x1080|1440p|FHD|1080i|4kto1080p)|(?P<R2160p>2160p|3840x2160|4k[-_. ](?:UHD|HEVC|BD|H265)|(?:UHD|HEVC|BD|H265)[-_. ]4k))\b",
132 )
133 .expect("RESOLUTION_REGEX must compile")
134 });
135
136 /// Alternative resolution detector for releases that omit a numeric
137 /// resolution token. Ported from `QualityParser.cs:53-54`.
138 ///
139 /// The C# pattern declares `(?<R2160p>...)` twice in an alternation
140 /// (`(?<R2160p>UHD)\b|(?<R2160p>\[4K\])`). .NET allows duplicate group
141 /// names and merges their captures; Rust's `regex` crate rejects this. To
142 /// preserve identical match semantics while staying compilable, the second
143 /// branch is renamed `R2160p_alt`. Callers should treat a hit on EITHER
144 /// `R2160p` or `R2160p_alt` as a positive 2160p signal;
145 /// [`super::matches_alternative_resolution`] coalesces both branches into
146 /// a single boolean.
147 pub static ALTERNATIVE_RESOLUTION_REGEX: Lazy<Regex> = Lazy::new(|| {
148 Regex::new(r"(?i)\b(?P<R2160p>UHD)\b|(?P<R2160p_alt>\[4K\])")
149 .expect("ALTERNATIVE_RESOLUTION_REGEX must compile")
150 });
151
152 /// Codec detector. Ported from `QualityParser.cs:56-57`.
153 ///
154 /// Named groups: `x264`, `h264`, `xvidhd`, `xvid`, `divx`. Case-insensitive
155 /// (`IgnoreCase` in C# → `(?i)` flag here). Note that the C# pattern uses
156 /// the literal `Xvid` (no hyphen variant), so this port matches `Xvid` and
157 /// `xvid` (case-insensitive) but NOT `X-vid`. The plan-spec drafted
158 /// `X-?vid`; C# is authoritative per the m50 standing rules, so we keep
159 /// the C# form.
160 pub static CODEC_REGEX: Lazy<Regex> = Lazy::new(|| {
161 Regex::new(
162 r"(?i)\b(?:(?P<x264>x264)|(?P<h264>h264)|(?P<xvidhd>XvidHD)|(?P<xvid>Xvid)|(?P<divx>divx))\b",
163 )
164 .expect("CODEC_REGEX must compile")
165 });
166
167 /// `HD-TV` / `SD-TV` alternative-form detector. Ported from
168 /// `QualityParser.cs:59`. Named groups: `hdtv`, `sdtv`. Case-insensitive.
169 pub static OTHER_SOURCE_REGEX: Lazy<Regex> = Lazy::new(|| {
170 Regex::new(r"(?i)(?P<hdtv>HD[-_. ]TV)|(?P<sdtv>SD[-_. ]TV)")
171 .expect("OTHER_SOURCE_REGEX must compile")
172 });
173
174 /// `hr-ws` (high-def PDTV) detector. Ported from `QualityParser.cs:64`.
175 /// Case-insensitive.
176 pub static HIGH_DEF_PDTV_REGEX: Lazy<Regex> =
177 Lazy::new(|| Regex::new(r"(?i)hr[-_. ]ws").expect("HIGH_DEF_PDTV_REGEX must compile"));
178
179 /// PROPER detector. Ported from `QualityParser.cs:37-38`.
180 /// Named group: `proper`. Case-insensitive.
181 pub static PROPER_REGEX: Lazy<Regex> =
182 Lazy::new(|| Regex::new(r"(?i)\b(?P<proper>proper)\b").expect("PROPER_REGEX must compile"));
183
184 /// REPACK / RERIP detector. Ported from `QualityParser.cs:40-41`. Matches
185 /// `repack`, `repack1`, `repack2`, ..., `rerip`, `rerip1`, `rerip2`, etc.
186 /// Named group: `repack`. Case-insensitive.
187 pub static REPACK_REGEX: Lazy<Regex> = Lazy::new(|| {
188 Regex::new(r"(?i)\b(?P<repack>repack\d?|rerip\d?)\b").expect("REPACK_REGEX must compile")
189 });
190
191 /// Explicit version-marker detector. Ported from `QualityParser.cs:43-44`.
192 ///
193 /// The C# pattern declares the same `<version>` named group on FIVE
194 /// alternation branches:
195 ///
196 /// 1. `\d[-._ ]?v(?<version>\d)[-._ ]` (e.g. `1v2.`, `01-v2_`)
197 /// 2. `\[v(?<version>\d)\]` (e.g. `[v2]`)
198 /// 3. `repack(?<version>\d)` (e.g. `repack2`)
199 /// 4. `rerip(?<version>\d)` (e.g. `rerip3`)
200 /// 5. `(?:480|576|720|1080|2160)p[._ ]v(?<version>\d)` (e.g. `1080p.v2`)
201 ///
202 /// .NET allows duplicate names and merges captures; Rust's `regex` crate
203 /// rejects them. We rename branches 2-5 to `version2`, `version3`,
204 /// `version4`, `version5`. Callers should walk all five names and pick
205 /// the first hit:
206 ///
207 /// ```ignore
208 /// caps.name("version")
209 /// .or_else(|| caps.name("version2"))
210 /// .or_else(|| caps.name("version3"))
211 /// .or_else(|| caps.name("version4"))
212 /// .or_else(|| caps.name("version5"))
213 /// ```
214 ///
215 /// T6's modifier cascade owns that walk; the helper is intentionally not
216 /// added in T5 because the cascade has additional decision logic that
217 /// would be split awkwardly. Case-insensitive (`IgnoreCase` in C# → `(?i)`
218 /// flag here).
219 pub static VERSION_REGEX: Lazy<Regex> = Lazy::new(|| {
220 Regex::new(
221 r"(?i)\d[-._ ]?v(?P<version>\d)[-._ ]|\[v(?P<version2>\d)\]|repack(?P<version3>\d)|rerip(?P<version4>\d)|(?:480|576|720|1080|2160)p[._ ]v(?P<version5>\d)",
222 )
223 .expect("VERSION_REGEX must compile")
224 });
225
226 /// REAL detector. Ported from `QualityParser.cs:46-47`.
227 ///
228 /// **Case-sensitive intentionally.** The C# pattern carries
229 /// `RegexOptions.Compiled` only (no `IgnoreCase`), so only uppercase
230 /// `REAL` matches. We omit the `(?i)` flag here to mirror C# faithfully.
231 pub static REAL_REGEX: Lazy<Regex> =
232 Lazy::new(|| Regex::new(r"\b(?P<real>REAL)\b").expect("REAL_REGEX must compile"));
233}
234
235use regexes::*;
236
237/// Source-class signal extracted from a single `SOURCE_REGEX` candidate that
238/// has passed all post-match lookaround filters. Carries which named group
239/// hit (so callers can route to the correct `Quality` variant) plus the
240/// matched substring (for debug logging and round-trip tests).
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub(crate) struct SourceMatch<'a> {
243 pub group: SourceGroup,
244 pub matched: &'a str,
245}
246
247/// Which named alternative inside `SOURCE_REGEX` matched.
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
249pub(crate) enum SourceGroup {
250 Bluray,
251 Webdl,
252 Webrip,
253 Hdtv,
254 Bdrip,
255 Brrip,
256 Dvd,
257 Dsr,
258 Pdtv,
259 Sdtv,
260 Tvrip,
261}
262
263/// Classify a single `SOURCE_REGEX` `Captures` into a `SourceMatch`, or
264/// `None` if the capture fails the C# lookaround filters.
265///
266/// Filters applied (mirroring `QualityParser.cs:17-30`):
267///
268/// 1. `bluray` branch: a bare `BD` that ends at the input boundary is
269/// rejected (`BD(?!$)` in C#).
270/// 2. `webdl` branch: an `(?:AMZN|NF|DP)[. -]WEB[. -]` capture that is
271/// followed in the input by `Rip` (case-insensitive) is rejected
272/// (`(?!Rip)` in C#). Identified structurally via the nested
273/// `provider_web` sub-capture rather than a prefix check on the
274/// outer match. Robust against future alternation reordering.
275fn classify_source_capture<'a>(s: &'a str, caps: ®ex::Captures<'a>) -> Option<SourceMatch<'a>> {
276 let (group, mat) = if let Some(m) = caps.name("bluray") {
277 (SourceGroup::Bluray, m)
278 } else if let Some(m) = caps.name("webdl") {
279 (SourceGroup::Webdl, m)
280 } else if let Some(m) = caps.name("webrip") {
281 (SourceGroup::Webrip, m)
282 } else if let Some(m) = caps.name("hdtv") {
283 (SourceGroup::Hdtv, m)
284 } else if let Some(m) = caps.name("bdrip") {
285 (SourceGroup::Bdrip, m)
286 } else if let Some(m) = caps.name("brrip") {
287 (SourceGroup::Brrip, m)
288 } else if let Some(m) = caps.name("dvd") {
289 (SourceGroup::Dvd, m)
290 } else if let Some(m) = caps.name("dsr") {
291 (SourceGroup::Dsr, m)
292 } else if let Some(m) = caps.name("pdtv") {
293 (SourceGroup::Pdtv, m)
294 } else if let Some(m) = caps.name("sdtv") {
295 (SourceGroup::Sdtv, m)
296 } else if let Some(m) = caps.name("tvrip") {
297 (SourceGroup::Tvrip, m)
298 } else {
299 return None;
300 };
301
302 // Filter 1: bluray bare-BD-at-end-of-input rejection (C# `BD(?!$)`).
303 if group == SourceGroup::Bluray
304 && mat.as_str().eq_ignore_ascii_case("BD")
305 && mat.end() == s.len()
306 {
307 return None;
308 }
309
310 // Filter 2: webdl provider-tag-followed-by-Rip rejection
311 // (C# `(?:AMZN|NF|DP)[. -]WEB[. -](?!Rip)`). Gated on the nested
312 // `provider_web` sub-capture so we apply the (?!Rip) filter only when
313 // that exact alternation branch fired, independent of alternation
314 // order or text-prefix heuristics on the outer match.
315 if group == SourceGroup::Webdl && caps.name("provider_web").is_some() {
316 let tail = &s[mat.end()..];
317 if tail.len() >= 3 && tail.as_bytes()[..3].eq_ignore_ascii_case(b"Rip") {
318 // Consumed-iterator pattern: the webdl branch consumed the WEB token,
319 // so the webrip branch can never fire. Synthesize the Webrip match
320 // to mirror C#'s backtrack semantics.
321 return Some(SourceMatch {
322 group: SourceGroup::Webrip,
323 matched: mat.as_str(),
324 });
325 }
326 }
327
328 Some(SourceMatch {
329 group,
330 matched: mat.as_str(),
331 })
332}
333
334/// Find the first source-class match in `s`, applying the lookahead filters
335/// that Rust regex cannot express directly. Returns `None` if no candidate
336/// satisfies the C# semantics.
337///
338/// See [`classify_source_capture`] for the filter list.
339///
340/// Test-only: the production cascade uses [`match_source_last`] (last-wins
341/// semantics, see C# `QualityParser.cs:115`). This first-match form exists
342/// to pin the C# regex's per-branch alternation behaviour from the test
343/// suite without leaking a never-used helper into the production build.
344#[cfg(test)]
345pub(crate) fn match_source(s: &str) -> Option<SourceMatch<'_>> {
346 for caps in SOURCE_REGEX.captures_iter(s) {
347 if let Some(m) = classify_source_capture(s, &caps) {
348 return Some(m);
349 }
350 }
351 None
352}
353
354/// Find the LAST source-class match in `s`, mirroring C#'s
355/// `sourceMatches.OfType<Match>().LastOrDefault()` at `QualityParser.cs:115`.
356///
357/// The cascade uses last-match-wins semantics: a release like
358/// `Movie.HDTV.Repack.BluRay.x264` classifies as bluray, not hdtv, because
359/// `BluRay` appears AFTER `HDTV` in the string. C# achieves this with
360/// `Regex.Matches(...).OfType<Match>().LastOrDefault()`; we mirror it by
361/// walking every candidate from `captures_iter` and keeping the last that
362/// passes [`classify_source_capture`]'s filters.
363///
364/// Returns `None` only when no candidate in the entire string satisfies the
365/// post-match lookaround filters. A candidate that is rejected by a filter
366/// is skipped; an earlier accepted candidate may still be returned if no
367/// later candidate passes.
368pub(crate) fn match_source_last(s: &str) -> Option<SourceMatch<'_>> {
369 let mut last: Option<SourceMatch<'_>> = None;
370 for caps in SOURCE_REGEX.captures_iter(s) {
371 if let Some(m) = classify_source_capture(s, &caps) {
372 last = Some(m);
373 }
374 }
375 last
376}
377
378/// Anime-Bluray detection respecting C#'s lookaround semantics.
379///
380/// The relaxed `ANIME_BLURAY_REGEX` matches `bd720`/`bd1080`/`bd2160`
381/// directly, and also matches a bare `bd` anywhere in the input. C# only
382/// counts a bare `bd` when it is surrounded by one of `[-_. (\[]` on the left
383/// and one of `[-_. )\]]` on the right. This function applies that surround
384/// check in code, returning `true` on the first candidate that passes.
385///
386/// Bare `bd` requires a separator on both sides. Bare `bd` at start- or
387/// end-of-input does NOT match, matching C#'s paired
388/// `(?<=[-_. (\[])bd(?=[-_. )\]])` lookaround semantics where the
389/// lookbehind/lookahead fails at the input boundary.
390pub(crate) fn matches_anime_bluray(s: &str) -> bool {
391 for m in ANIME_BLURAY_REGEX.find_iter(s) {
392 let matched = m.as_str();
393 if matched.len() == 2 {
394 // Bare `bd`: check the C# surround constraint.
395 let bytes = s.as_bytes();
396 let start = m.start();
397 let end = m.end();
398 // Note: `as_bytes().get(start - 1)` returns a single byte at a UTF-8 boundary.
399 // For multi-byte chars (e.g. 'é'), the read returns a continuation byte
400 // (0x80..=0xBF) which is outside the ASCII separator set, so the surround
401 // check correctly rejects non-ASCII surrounds.
402 let before_ok = start > 0
403 && matches!(
404 bytes.get(start - 1),
405 Some(b'-' | b'_' | b'.' | b' ' | b'(' | b'[')
406 );
407 let after_ok = end < s.len()
408 && matches!(
409 bytes.get(end),
410 Some(b'-' | b'_' | b'.' | b' ' | b')' | b']')
411 );
412 if before_ok && after_ok {
413 return true;
414 }
415 } else {
416 // `bd720` / `bd1080` / `bd2160`: always counts.
417 return true;
418 }
419 }
420 false
421}
422
423/// Remux detector that merges the two same-named C# capture groups
424/// (`remux` and `remux_post` in the Rust port) into a single signal.
425///
426/// Returns `Some(matched_text)` on the first hit, `None` otherwise. Callers
427/// that need positional information can fall back to `REMUX_REGEX` directly;
428/// this helper is the canonical "is this a remux release?" gate.
429pub(crate) fn match_remux(s: &str) -> Option<&str> {
430 for caps in REMUX_REGEX.captures_iter(s) {
431 if let Some(m) = caps.name("remux").or_else(|| caps.name("remux_post")) {
432 return Some(m.as_str());
433 }
434 }
435 None
436}
437
438/// Alternative-resolution detector that merges the two same-named C# capture
439/// groups (`R2160p` and `R2160p_alt` in the Rust port) into a single boolean
440/// signal.
441///
442/// Returns `true` if either branch fires, mirroring C#'s
443/// `(?<R2160p>UHD)\b|(?<R2160p>\[4K\])` semantics where both alternatives are
444/// recorded under the same name.
445pub(crate) fn matches_alternative_resolution(s: &str) -> bool {
446 ALTERNATIVE_RESOLUTION_REGEX
447 .captures_iter(s)
448 .any(|caps| caps.name("R2160p").is_some() || caps.name("R2160p_alt").is_some())
449}
450
451/// Resolution detector mirroring C#'s `ParseResolution`
452/// (`QualityParser.cs:600-651`).
453///
454/// Walks `RESOLUTION_REGEX`'s named groups in C# evaluation order
455/// (R360p, R480p, R540p, R576p, R720p, R1080p, R2160p) and returns the first
456/// hit. If no main-regex group fires, falls back to
457/// [`matches_alternative_resolution`] to detect the `UHD` / `[4K]` tokens that
458/// C#'s `AlternativeResolutionRegex` covers; either alternative-regex branch
459/// counts as `R2160p`. Returns `Resolution::Unknown` if neither regex matches.
460///
461/// **Group-order note.** The C# code checks groups starting from the smallest
462/// resolution upward (R360p first, R2160p last). The named groups in
463/// `RESOLUTION_REGEX` are mutually exclusive within a single capture (an
464/// alternation only fires one branch), so the order does not affect behaviour
465/// for any input the regex can match. We mirror the C# walk order verbatim
466/// to keep the port reviewable against the source.
467fn detect_resolution(name: &str) -> crate::quality::Resolution {
468 use crate::quality::Resolution;
469
470 if let Some(caps) = RESOLUTION_REGEX.captures(name) {
471 if caps.name("R360p").is_some() {
472 return Resolution::R360p;
473 }
474 if caps.name("R480p").is_some() {
475 return Resolution::R480p;
476 }
477 if caps.name("R540p").is_some() {
478 return Resolution::R540p;
479 }
480 if caps.name("R576p").is_some() {
481 return Resolution::R576p;
482 }
483 if caps.name("R720p").is_some() {
484 return Resolution::R720p;
485 }
486 if caps.name("R1080p").is_some() {
487 return Resolution::R1080p;
488 }
489 if caps.name("R2160p").is_some() {
490 return Resolution::R2160p;
491 }
492 }
493
494 if matches_alternative_resolution(name) {
495 return Resolution::R2160p;
496 }
497
498 Resolution::Unknown
499}
500
501/// `Quality.Source` reverse lookup. Mirrors the C# constructor pairs in
502/// `Quality.cs:77-124` where each `Quality` static is paired with a
503/// `QualitySource` argument.
504///
505/// Used by [`parse_quality_name`]'s resolution-only fallback to derive a
506/// `QualitySource` from the extension-derived `Quality`, mirroring C#
507/// `QualityParser.cs:444` (`source = quality.Source`).
508fn quality_source(q: crate::quality::Quality) -> crate::quality::QualitySource {
509 use crate::quality::{Quality, QualitySource};
510 match q {
511 Quality::Unknown => QualitySource::Unknown,
512 Quality::Sdtv => QualitySource::Television,
513 Quality::Hdtv720p => QualitySource::Television,
514 Quality::Hdtv1080p => QualitySource::Television,
515 Quality::Hdtv2160p => QualitySource::Television,
516 Quality::RawHd => QualitySource::TelevisionRaw,
517 Quality::Webdl480p => QualitySource::Web,
518 Quality::Webdl720p => QualitySource::Web,
519 Quality::Webdl1080p => QualitySource::Web,
520 Quality::Webdl2160p => QualitySource::Web,
521 Quality::Webrip480p => QualitySource::WebRip,
522 Quality::Webrip720p => QualitySource::WebRip,
523 Quality::Webrip1080p => QualitySource::WebRip,
524 Quality::Webrip2160p => QualitySource::WebRip,
525 Quality::Dvd => QualitySource::Dvd,
526 Quality::Bluray480p => QualitySource::Bluray,
527 Quality::Bluray576p => QualitySource::Bluray,
528 Quality::Bluray720p => QualitySource::Bluray,
529 Quality::Bluray1080p => QualitySource::Bluray,
530 Quality::Bluray2160p => QualitySource::Bluray,
531 Quality::Bluray1080pRemux => QualitySource::BlurayRaw,
532 Quality::Bluray2160pRemux => QualitySource::BlurayRaw,
533 }
534}
535
536/// File-extension to `Quality` lookup. Ports
537/// `MediaFileExtensions.cs:9-71` + `GetQualityForExtension` (lines 76-84).
538///
539/// Returns `Quality::Unknown` for any extension not in the table OR an empty
540/// extension. The C# wrapper at `QualityParser.cs:82-95` and the inline
541/// extension lookup at `:437-451` both use this; the inline path uses Sonarr's
542/// `string.GetPathExtension()` (`PathExtensions.cs:74-83`), which is a bare
543/// `LastIndexOf('.')` slice (no path-validity checks).
544///
545/// Comparison is ASCII-case-insensitive to match
546/// `StringComparer.OrdinalIgnoreCase` at `MediaFileExtensions.cs:13`.
547fn quality_for_extension(extension: &str) -> crate::quality::Quality {
548 use crate::quality::Quality;
549 // C# MediaFileExtensions.cs:9-71. Pairs are (extension-with-dot, Quality).
550 const TABLE: &[(&str, Quality)] = &[
551 // Unknown
552 (".webm", Quality::Unknown),
553 // SDTV
554 (".m4v", Quality::Sdtv),
555 (".3gp", Quality::Sdtv),
556 (".nsv", Quality::Sdtv),
557 (".ty", Quality::Sdtv),
558 (".strm", Quality::Sdtv),
559 (".rm", Quality::Sdtv),
560 (".rmvb", Quality::Sdtv),
561 (".m3u", Quality::Sdtv),
562 (".ifo", Quality::Sdtv),
563 (".mov", Quality::Sdtv),
564 (".qt", Quality::Sdtv),
565 (".divx", Quality::Sdtv),
566 (".xvid", Quality::Sdtv),
567 (".bivx", Quality::Sdtv),
568 (".nrg", Quality::Sdtv),
569 (".pva", Quality::Sdtv),
570 (".wmv", Quality::Sdtv),
571 (".asf", Quality::Sdtv),
572 (".asx", Quality::Sdtv),
573 (".ogm", Quality::Sdtv),
574 (".ogv", Quality::Sdtv),
575 (".m2v", Quality::Sdtv),
576 (".avi", Quality::Sdtv),
577 (".bin", Quality::Sdtv),
578 (".dat", Quality::Sdtv),
579 (".dvr-ms", Quality::Sdtv),
580 (".mpg", Quality::Sdtv),
581 (".mpeg", Quality::Sdtv),
582 (".mp4", Quality::Sdtv),
583 (".avc", Quality::Sdtv),
584 (".vp3", Quality::Sdtv),
585 (".svq3", Quality::Sdtv),
586 (".nuv", Quality::Sdtv),
587 (".viv", Quality::Sdtv),
588 (".dv", Quality::Sdtv),
589 (".fli", Quality::Sdtv),
590 (".flv", Quality::Sdtv),
591 (".wpl", Quality::Sdtv),
592 // DVD
593 (".img", Quality::Dvd),
594 (".iso", Quality::Dvd),
595 (".vob", Quality::Dvd),
596 // HD
597 (".mkv", Quality::Hdtv720p),
598 (".ts", Quality::Hdtv720p),
599 (".wtv", Quality::Hdtv720p),
600 // Bluray
601 (".m2ts", Quality::Bluray720p),
602 ];
603
604 if extension.is_empty() {
605 return Quality::Unknown;
606 }
607 for (ext, q) in TABLE {
608 if extension.eq_ignore_ascii_case(ext) {
609 return *q;
610 }
611 }
612 Quality::Unknown
613}
614
615/// Sonarr's `string.GetPathExtension()` from
616/// `NzbDrone.Common/Extensions/PathExtensions.cs:74-83`.
617///
618/// Returns the substring from the LAST `.` to end-of-string, INCLUDING the
619/// dot. Returns an empty string if there is no `.` or if the dot is the final
620/// character. This is NOT `Path.GetExtension` semantics; it does no path
621/// validation, so it picks the trailing token from any string regardless of
622/// platform path syntax. The resolution-only fallback at C#
623/// `QualityParser.cs:439` calls this helper, which is why a release name like
624/// `Movie.2020.1080p.x264.mkv` will yield `.mkv` even though the filename
625/// portion has no directory structure.
626fn get_path_extension(path: &str) -> &str {
627 if let Some(idx) = path.rfind('.')
628 && idx + 1 < path.len()
629 {
630 return &path[idx..];
631 }
632 ""
633}
634
635/// ASCII case-insensitive substring search, mirroring .NET's
636/// `string.ContainsIgnoreCase`.
637///
638/// C#'s `ContainsIgnoreCase` is a culture-aware match by default, but the
639/// Sonarr extension method (`Extensions/StringExtensions.cs`) implements it as
640/// `IndexOf(value, StringComparison.OrdinalIgnoreCase) >= 0`. Ordinal-IgnoreCase
641/// folds only the ASCII A-Z / a-z range; non-ASCII code points round-trip
642/// unchanged. The Rust port mirrors that contract via
643/// [`str::eq_ignore_ascii_case`] on byte-aligned windows.
644fn contains_ignore_ascii_case(haystack: &str, needle: &str) -> bool {
645 let n = needle.len();
646 if n == 0 {
647 return true;
648 }
649 let h = haystack.as_bytes();
650 if h.len() < n {
651 return false;
652 }
653 h.windows(n)
654 .any(|w| w.eq_ignore_ascii_case(needle.as_bytes()))
655}
656
657/// Quality-cascade entry-point. Ported from `QualityParser.cs:100-598`
658/// (`ParseQualityName`).
659///
660/// **Duties.**
661///
662/// 1. Normalise: `name.Replace('_', ' ').Trim()` and `name.Trim()` for the raw
663/// side, mirroring C#'s twin `Trim()` calls at `QualityParser.cs:77` and
664/// `QualityParser.cs:102`.
665/// 2. Run `parse_quality_modifiers` against both raw and normalised name to
666/// populate `Revision`. Modifiers that fire also set
667/// `revision_detection_source = Name`.
668/// 3. RawHD short-circuit (C# line 105): return `Quality::RawHd` with
669/// `source_detection_source = resolution_detection_source = Name`.
670/// 4. Source-to-quality cascade (C# lines 114-318): take the LAST source
671/// match, pair with the parsed resolution + codec + remux signals, and
672/// route to a concrete `Quality` variant. Branches:
673///
674/// - `bluray`: codec(xvid|divx) -> 480p, else by-resolution
675/// (2160 / 1080 / 576 / {360, 480, 540}); remux+resolution!=720 -> 1080pRemux;
676/// else 720p.
677/// - `webdl`: by-resolution (2160 / 1080 / 720); raw-name `[WEBDL]` -> 720p;
678/// else 480p.
679/// - `webrip`: by-resolution (2160 / 1080 / 720); else 480p.
680/// - `hdtv`: MPEG2 -> RawHD; else by-resolution; raw-name `[HDTV]` -> 720p;
681/// else SDTV.
682/// - `bdrip` / `brrip`: by-resolution (720 / 1080 / 2160); else 480p.
683/// - `dvd`: DVD always.
684/// - `pdtv` / `sdtv` / `dsr` / `tvrip`: 1080p match -> Hdtv1080p;
685/// 720p match -> Hdtv720p; HighDefPdtv (`hr-ws`) -> Hdtv720p; else SDTV.
686///
687/// Provenance flags (`source_detection_source`, `resolution_detection_source`)
688/// are populated according to the C# rules: `source_detection_source` is set
689/// to `Name` whenever a sourceMatch fires; `resolution_detection_source` is
690/// set to `Name` when the resolution-regex hit (line 122) or, in the
691/// pdtv-cluster branch, when the `ContainsIgnoreCase` substring fallback
692/// fires (line 308's `HighDefPdtvRegex` path).
693///
694/// **Fallthrough cascade (T8).** When no source match fires, the following
695/// fallbacks run in C# order, each returning on a hit:
696///
697/// - **Sourceless remux** (C# 320-347): `remuxMatch && resolution != Unknown`,
698/// dispatched per resolution. SourceDetectionSource is explicitly `Unknown`
699/// here per C# line 322.
700/// - **Anime-bluray** (C# 349-390): `AnimeBlurayRegex` hits, resolution-routed
701/// (480/1080/2160/720 defaults). 480p in this branch maps to `Quality::Dvd`
702/// per C# line 359.
703/// - **Anime-webdl** (C# 392-424): `AnimeWebDlRegex` hits, resolution-routed.
704/// - **Resolution-only** (C# 426-497): `resolution != Unknown`. Derives a
705/// `QualitySource` from `remuxMatch` (`BlurayRaw`) OR from the file
706/// extension via [`quality_for_extension`] + [`quality_source`], then
707/// dispatches via `find_by_source_and_resolution`. Unknown source falls
708/// back to Television-class defaults (Hdtv2160p / Hdtv1080p / Hdtv720p / Sdtv).
709/// - **x264-SDTV** (C# 499-504): bare `x264` with no other signal -> `Sdtv`.
710/// - **Pixel-tag fallbacks** (C# 506-560): literal `848x480`, `1280x720`,
711/// `1920x1080` substrings, optionally combined with `dvd` / `bluray` markers.
712/// - **Bare-bluray-resolution** (C# 562-587): literal `bluray720p` /
713/// `bluray1080p` / `bluray2160p` -> `Bluray720p` / `Bluray1080p` / `Bluray2160p`.
714/// - **`OtherSourceMatch`** (C# 589-595, 653-672): `OTHER_SOURCE_REGEX` hit
715/// (`HD-TV` / `SD-TV` alternative form) -> `Hdtv720p` / `Sdtv`.
716///
717/// **Most callers want [`parse_quality`] instead.** That wrapper adds the
718/// extension-fallback behaviour from `QualityParser.cs:81-95` on top of
719/// `parse_quality_name`'s output. Use `parse_quality_name` directly only
720/// when you specifically want to bypass the extension lookup (e.g.,
721/// text-only parsing, no file path implied). This split mirrors C#'s
722/// `ParseQuality` (public) vs `ParseQualityName` (test-seam) shape.
723pub fn parse_quality_name(name: &str) -> crate::quality::QualityModel {
724 use crate::quality::{
725 Quality, QualityDetectionSource, QualityModel, QualitySource, Resolution,
726 };
727
728 // C# `QualityParser.cs:77` (`name.Trim()` in ParseQuality, the outer
729 // wrapper). The plan delegates this trim to ParseQualityName because the
730 // Rust port has only one entry point. Apply to the raw input so the
731 // `[WEBDL]` / `[HDTV]` substring checks against `name` see the trimmed
732 // string just like C#.
733 let raw = name.trim();
734
735 // C# `QualityParser.cs:102`: `name.Replace('_', ' ').Trim()`.
736 let normalized = raw.replace('_', " ");
737 let normalized = normalized.trim();
738
739 let (revision, revision_detection_source) = parse_quality_modifiers(raw, normalized);
740
741 if RAW_HD_REGEX.is_match(normalized) {
742 return QualityModel {
743 quality: Quality::RawHd,
744 revision,
745 source_detection_source: QualityDetectionSource::Name,
746 resolution_detection_source: QualityDetectionSource::Name,
747 revision_detection_source,
748 };
749 }
750
751 // C# QualityParser.cs:114-118: gather the per-name signals in one pass.
752 // - sourceMatch: LAST sourceMatch wins (line 115's LastOrDefault).
753 // - resolution: ParseResolution, our `detect_resolution` helper.
754 // - codec_*: x264 / xvid / divx / xvidhd / h264 named groups.
755 // - remux_match: REMUX_REGEX hit, gated through `match_remux`.
756 let source_match = match_source_last(normalized);
757 let resolution = detect_resolution(normalized);
758 let codec_caps = CODEC_REGEX.captures(normalized);
759 let codec_xvid = codec_caps.as_ref().and_then(|c| c.name("xvid")).is_some();
760 let codec_divx = codec_caps.as_ref().and_then(|c| c.name("divx")).is_some();
761 let remux_match = match_remux(normalized).is_some();
762
763 // C# QualityParser.cs:120-123: a non-Unknown resolution upgrades the
764 // resolution-detection source to Name, regardless of whether any source
765 // branch ultimately consumes the resolution value.
766 let resolution_detection_source = if resolution != Resolution::Unknown {
767 QualityDetectionSource::Name
768 } else {
769 QualityDetectionSource::Unknown
770 };
771
772 // Helper to materialise a final QualityModel with the per-cascade-arm
773 // detection-source flips applied. Source detection source is set to Name
774 // whenever this helper fires (every caller is inside a sourceMatch arm).
775 let mk = |quality: Quality, resolution_source: QualityDetectionSource| QualityModel {
776 quality,
777 revision,
778 source_detection_source: QualityDetectionSource::Name,
779 resolution_detection_source: resolution_source,
780 revision_detection_source,
781 };
782
783 if let Some(sm) = source_match {
784 match sm.group {
785 // C# QualityParser.cs:129-173. Bluray.
786 SourceGroup::Bluray => {
787 if codec_xvid || codec_divx {
788 // Line 131-135: codec downgrade.
789 return mk(Quality::Bluray480p, resolution_detection_source);
790 }
791 match resolution {
792 // Line 137-142: 2160p (with remux variant).
793 Resolution::R2160p => {
794 let q = if remux_match {
795 Quality::Bluray2160pRemux
796 } else {
797 Quality::Bluray2160p
798 };
799 return mk(q, resolution_detection_source);
800 }
801 // Line 144-148: 1080p (with remux variant).
802 Resolution::R1080p => {
803 let q = if remux_match {
804 Quality::Bluray1080pRemux
805 } else {
806 Quality::Bluray1080p
807 };
808 return mk(q, resolution_detection_source);
809 }
810 // Line 150-154: 576p.
811 Resolution::R576p => {
812 return mk(Quality::Bluray576p, resolution_detection_source);
813 }
814 // Line 156-161: 360p / 480p / 540p collapse to 480p.
815 Resolution::R360p | Resolution::R480p | Resolution::R540p => {
816 return mk(Quality::Bluray480p, resolution_detection_source);
817 }
818 // Line 165 explicit comment: "Treat a remux without a
819 // source as 1080p, not 720p. 720p remux should fallback
820 // as 720p BluRay." So the R720p arm ignores `remux_match`
821 // and routes to Bluray720p. The R720p arm in C# is the
822 // implicit fall-through after the resolution checks fail
823 // to match anything except R720p; we make that explicit.
824 Resolution::R720p => {
825 return mk(Quality::Bluray720p, resolution_detection_source);
826 }
827 // Line 165-169 + 171-172: Unknown resolution falls
828 // through to the remux-fallback (1080pRemux) when remux
829 // fired, otherwise the C# implicit default of Bluray720p.
830 Resolution::Unknown => {
831 if remux_match {
832 return mk(Quality::Bluray1080pRemux, resolution_detection_source);
833 }
834 return mk(Quality::Bluray720p, resolution_detection_source);
835 }
836 }
837 }
838
839 // C# QualityParser.cs:175-203. Webdl.
840 SourceGroup::Webdl => match resolution {
841 Resolution::R2160p => return mk(Quality::Webdl2160p, resolution_detection_source),
842 Resolution::R1080p => return mk(Quality::Webdl1080p, resolution_detection_source),
843 Resolution::R720p => return mk(Quality::Webdl720p, resolution_detection_source),
844 _ => {
845 // Line 195-199: raw-name `[WEBDL]` substring -> 720p.
846 if raw.contains("[WEBDL]") {
847 return mk(Quality::Webdl720p, resolution_detection_source);
848 }
849 return mk(Quality::Webdl480p, resolution_detection_source);
850 }
851 },
852
853 // C# QualityParser.cs:205-227. Webrip.
854 SourceGroup::Webrip => match resolution {
855 Resolution::R2160p => return mk(Quality::Webrip2160p, resolution_detection_source),
856 Resolution::R1080p => return mk(Quality::Webrip1080p, resolution_detection_source),
857 Resolution::R720p => return mk(Quality::Webrip720p, resolution_detection_source),
858 _ => return mk(Quality::Webrip480p, resolution_detection_source),
859 },
860
861 // C# QualityParser.cs:229-263. Hdtv.
862 SourceGroup::Hdtv => {
863 // Line 231-234: MPEG2 short-circuit. C#'s MPEG2_REGEX is
864 // case-sensitive (verified at T4) and runs against
865 // normalizedName.
866 if MPEG2_REGEX.is_match(normalized) {
867 return mk(Quality::RawHd, resolution_detection_source);
868 }
869 match resolution {
870 Resolution::R2160p => {
871 return mk(Quality::Hdtv2160p, resolution_detection_source);
872 }
873 Resolution::R1080p => {
874 return mk(Quality::Hdtv1080p, resolution_detection_source);
875 }
876 Resolution::R720p => return mk(Quality::Hdtv720p, resolution_detection_source),
877 _ => {
878 // Line 255-259: raw-name `[HDTV]` substring -> 720p.
879 if raw.contains("[HDTV]") {
880 return mk(Quality::Hdtv720p, resolution_detection_source);
881 }
882 return mk(Quality::Sdtv, resolution_detection_source);
883 }
884 }
885 }
886
887 // C# QualityParser.cs:265-283. BDRip / BRRip.
888 SourceGroup::Bdrip | SourceGroup::Brrip => match resolution {
889 Resolution::R720p => return mk(Quality::Bluray720p, resolution_detection_source),
890 Resolution::R1080p => return mk(Quality::Bluray1080p, resolution_detection_source),
891 Resolution::R2160p => return mk(Quality::Bluray2160p, resolution_detection_source),
892 _ => return mk(Quality::Bluray480p, resolution_detection_source),
893 },
894
895 // C# QualityParser.cs:285-289. DVD source.
896 SourceGroup::Dvd => return mk(Quality::Dvd, resolution_detection_source),
897
898 // C# QualityParser.cs:291-317. PDTV / SDTV / DSR / TVRip cluster.
899 SourceGroup::Pdtv | SourceGroup::Sdtv | SourceGroup::Dsr | SourceGroup::Tvrip => {
900 // Line 296-300: 1080p (regex hit OR substring fallback).
901 if resolution == Resolution::R1080p
902 || contains_ignore_ascii_case(normalized, "1080p")
903 {
904 return mk(Quality::Hdtv1080p, resolution_detection_source);
905 }
906 // Line 302-306: 720p (regex hit OR substring fallback).
907 if resolution == Resolution::R720p || contains_ignore_ascii_case(normalized, "720p")
908 {
909 return mk(Quality::Hdtv720p, resolution_detection_source);
910 }
911 // Line 308-313: HighDefPdtv (hr-ws). C# explicitly sets
912 // ResolutionDetectionSource = Name on this branch even though
913 // the resolution regex did NOT fire.
914 if HIGH_DEF_PDTV_REGEX.is_match(normalized) {
915 return mk(Quality::Hdtv720p, QualityDetectionSource::Name);
916 }
917 return mk(Quality::Sdtv, resolution_detection_source);
918 }
919 }
920 }
921
922 // ---------------------------------------------------------------------
923 // T8 fallthrough cascade (C# QualityParser.cs:320-595).
924 // ---------------------------------------------------------------------
925 //
926 // From here down, source_match is None (every source-arm above returned).
927 // The C# control flow runs the following blocks in order; each returns
928 // on a hit. We reuse the modifier+resolution_detection_source values
929 // gathered above.
930
931 // Builder for branches that flip source_detection_source to Name (anime,
932 // pixel-tag, bare-bluray, OtherSourceMatch). Identical to the T7 `mk`
933 // closure; defined here to avoid borrowing the T7 closure across the
934 // T8 boundary.
935 let mk_named = |quality: Quality, resolution_source: QualityDetectionSource| QualityModel {
936 quality,
937 revision,
938 source_detection_source: QualityDetectionSource::Name,
939 resolution_detection_source: resolution_source,
940 revision_detection_source,
941 };
942
943 // C# QualityParser.cs:320-347: sourceless remux. Explicit C# behaviour
944 // (line 322): `result.SourceDetectionSource = QualityDetectionSource.Unknown;`
945 // even though we matched a remux signal. Resolutions outside
946 // {480p, 720p, 1080p, 2160p} (e.g. 360p, 540p, 576p) fall through to the
947 // anime / resolution-only branches.
948 if source_match.is_none() && remux_match && resolution != Resolution::Unknown {
949 let q = match resolution {
950 Resolution::R480p => Some(Quality::Bluray480p),
951 Resolution::R720p => Some(Quality::Bluray720p),
952 Resolution::R2160p => Some(Quality::Bluray2160pRemux),
953 Resolution::R1080p => Some(Quality::Bluray1080pRemux),
954 _ => None,
955 };
956 if let Some(quality) = q {
957 return QualityModel {
958 quality,
959 revision,
960 // C# line 322 hard-codes Unknown here.
961 source_detection_source: QualityDetectionSource::Unknown,
962 resolution_detection_source,
963 revision_detection_source,
964 };
965 }
966 }
967
968 // C# QualityParser.cs:349-390: anime-bluray. Matches before the
969 // resolution-only fallback because C#'s control flow checks anime
970 // detection first (at the same depth as sourceless-remux). NB: the
971 // 480p substring path collapses to `Quality::Dvd`, NOT a Bluray480p
972 // variant, intentionally per C# line 359.
973 if matches_anime_bluray(normalized) {
974 // Anime-bluray treats the substring "480p" as equivalent to a 480p
975 // resolution token even when the resolution regex didn't fire.
976 // The substring path explicitly flips ResolutionDetectionSource to
977 // Name (C# lines 358 / 366 / 374).
978 let resolution_source_anime = QualityDetectionSource::Name;
979
980 if matches!(
981 resolution,
982 Resolution::R360p | Resolution::R480p | Resolution::R540p | Resolution::R576p
983 ) || contains_ignore_ascii_case(normalized, "480p")
984 {
985 return mk_named(Quality::Dvd, resolution_source_anime);
986 }
987
988 if resolution == Resolution::R1080p || contains_ignore_ascii_case(normalized, "1080p") {
989 let q = if remux_match {
990 Quality::Bluray1080pRemux
991 } else {
992 Quality::Bluray1080p
993 };
994 return mk_named(q, resolution_source_anime);
995 }
996
997 if resolution == Resolution::R2160p || contains_ignore_ascii_case(normalized, "2160p") {
998 let q = if remux_match {
999 Quality::Bluray2160pRemux
1000 } else {
1001 Quality::Bluray2160p
1002 };
1003 return mk_named(q, resolution_source_anime);
1004 }
1005
1006 // C# line 382-386: remux without a 720p resolution (incl. Unknown)
1007 // collapses to 1080pRemux. R720p with remux falls through to the
1008 // 720p default below.
1009 if remux_match && resolution != Resolution::R720p {
1010 return mk_named(Quality::Bluray1080pRemux, resolution_detection_source);
1011 }
1012
1013 // C# line 388: anime-bluray default is 720p. Resolution detection
1014 // source carries through whatever the regex hit: if R720p fired the
1015 // regex, it's Name; else Unknown.
1016 return mk_named(Quality::Bluray720p, resolution_detection_source);
1017 }
1018
1019 // C# QualityParser.cs:392-424: anime-webdl. Substring "480p" / "1080p" /
1020 // "2160p" promotes the resolution-detection source to Name even when the
1021 // regex itself didn't fire (C# lines 400 / 408 / 416).
1022 if ANIME_WEBDL_REGEX.is_match(normalized) {
1023 let resolution_source_anime = QualityDetectionSource::Name;
1024
1025 if matches!(
1026 resolution,
1027 Resolution::R360p | Resolution::R480p | Resolution::R540p | Resolution::R576p
1028 ) || contains_ignore_ascii_case(normalized, "480p")
1029 {
1030 return mk_named(Quality::Webdl480p, resolution_source_anime);
1031 }
1032
1033 if resolution == Resolution::R1080p || contains_ignore_ascii_case(normalized, "1080p") {
1034 return mk_named(Quality::Webdl1080p, resolution_source_anime);
1035 }
1036
1037 if resolution == Resolution::R2160p || contains_ignore_ascii_case(normalized, "2160p") {
1038 return mk_named(Quality::Webdl2160p, resolution_source_anime);
1039 }
1040
1041 // C# line 422: anime-webdl default is 720p.
1042 return mk_named(Quality::Webdl720p, resolution_detection_source);
1043 }
1044
1045 // C# QualityParser.cs:426-497: resolution-only fallback. Derives a
1046 // QualitySource from EITHER remuxMatch (BlurayRaw) OR the file extension
1047 // via MediaFileExtensions, then uses find_by_source_and_resolution. When
1048 // the derived source is Unknown, falls back to the Television-class
1049 // default for each resolution.
1050 if resolution != Resolution::Unknown {
1051 // C# line 428 + 432-451. `source` defaults to Unknown; `remuxMatch`
1052 // sets it to BlurayRaw (and flips SourceDetectionSource to Name);
1053 // otherwise the extension lookup may set it via the path's last
1054 // dotted token.
1055 let mut derived_source = QualitySource::Unknown;
1056 let mut source_detection = QualityDetectionSource::Unknown;
1057
1058 if remux_match {
1059 derived_source = QualitySource::BlurayRaw;
1060 source_detection = QualityDetectionSource::Name;
1061 } else {
1062 // C# line 437-450 calls Sonarr's GetPathExtension
1063 // (LastIndexOf('.')), distinct from Path.GetExtension. The C#
1064 // version wraps the call in try/catch over ArgumentException,
1065 // but our Rust port's get_path_extension is total (no exceptions);
1066 // we read the extension verbatim and rely on
1067 // quality_for_extension to return Unknown for empty / unrecognised
1068 // inputs.
1069 //
1070 // C# uses `name` (raw) here, NOT `normalizedName`. For path-like
1071 // inputs that contain underscores (e.g. extension `.dvr_ms`, but
1072 // the dictionary actually carries `.dvr-ms`), the `_ -> ` `
1073 // normalisation could turn extensions into multi-token strings.
1074 // We mirror C# faithfully and read from `raw`.
1075 let ext = get_path_extension(raw);
1076 let from_ext = quality_for_extension(ext);
1077 if from_ext != Quality::Unknown {
1078 derived_source = quality_source(from_ext);
1079 source_detection = QualityDetectionSource::Extension;
1080 }
1081 }
1082
1083 // Build the result with the resolution-arm-specific source/resolution
1084 // detection-source flips.
1085 let mk_resfb = |quality: Quality| QualityModel {
1086 quality,
1087 revision,
1088 source_detection_source: source_detection,
1089 // C# lines 455 / 466 / 477 / 489: ResolutionDetectionSource
1090 // explicitly set to Name in every resolution-only return.
1091 resolution_detection_source: QualityDetectionSource::Name,
1092 revision_detection_source,
1093 };
1094
1095 let q = match resolution {
1096 Resolution::R2160p => {
1097 if derived_source == QualitySource::Unknown {
1098 Quality::Hdtv2160p
1099 } else {
1100 crate::quality::finder::find_by_source_and_resolution(derived_source, 2160)
1101 }
1102 }
1103 Resolution::R1080p => {
1104 if derived_source == QualitySource::Unknown {
1105 Quality::Hdtv1080p
1106 } else {
1107 crate::quality::finder::find_by_source_and_resolution(derived_source, 1080)
1108 }
1109 }
1110 Resolution::R720p => {
1111 if derived_source == QualitySource::Unknown {
1112 Quality::Hdtv720p
1113 } else {
1114 crate::quality::finder::find_by_source_and_resolution(derived_source, 720)
1115 }
1116 }
1117 Resolution::R360p | Resolution::R480p | Resolution::R540p | Resolution::R576p => {
1118 if derived_source == QualitySource::Unknown {
1119 Quality::Sdtv
1120 } else {
1121 crate::quality::finder::find_by_source_and_resolution(derived_source, 480)
1122 }
1123 }
1124 // Resolution::Unknown is excluded by the outer `if`.
1125 Resolution::Unknown => unreachable!("resolution != Unknown gated by outer if"),
1126 };
1127
1128 return mk_resfb(q);
1129 }
1130
1131 // C# QualityParser.cs:499-504: x264 codec with no other signal -> SDTV.
1132 // The codec_caps were already gathered above. This branch does NOT flip
1133 // any detection source to Name; per C# line 501 only Quality is set.
1134 if codec_caps.as_ref().and_then(|c| c.name("x264")).is_some() {
1135 return QualityModel {
1136 quality: Quality::Sdtv,
1137 revision,
1138 source_detection_source: QualityDetectionSource::Unknown,
1139 resolution_detection_source,
1140 revision_detection_source,
1141 };
1142 }
1143
1144 // C# QualityParser.cs:506-526: 848x480 pixel tag. NB: C# uses
1145 // case-sensitive `Contains("dvd")` here (line 510) and case-insensitive
1146 // `ContainsIgnoreCase("bluray")` (line 515). We mirror that asymmetry.
1147 if normalized.contains("848x480") {
1148 if normalized.contains("dvd") {
1149 return mk_named(Quality::Dvd, QualityDetectionSource::Name);
1150 }
1151 if contains_ignore_ascii_case(normalized, "bluray") {
1152 return mk_named(Quality::Bluray480p, QualityDetectionSource::Name);
1153 }
1154 // Bare 848x480 with no source marker: only the resolution detection
1155 // source flips to Name; the quality is SDTV. C# line 522 doesn't
1156 // set SourceDetectionSource on this default-arm.
1157 return QualityModel {
1158 quality: Quality::Sdtv,
1159 revision,
1160 source_detection_source: QualityDetectionSource::Unknown,
1161 resolution_detection_source: QualityDetectionSource::Name,
1162 revision_detection_source,
1163 };
1164 }
1165
1166 // C# QualityParser.cs:528-543: 1280x720 pixel tag. Both C# checks are
1167 // case-insensitive (line 528 + 532).
1168 if contains_ignore_ascii_case(normalized, "1280x720") {
1169 if contains_ignore_ascii_case(normalized, "bluray") {
1170 return mk_named(Quality::Bluray720p, QualityDetectionSource::Name);
1171 }
1172 return QualityModel {
1173 quality: Quality::Hdtv720p,
1174 revision,
1175 source_detection_source: QualityDetectionSource::Unknown,
1176 resolution_detection_source: QualityDetectionSource::Name,
1177 revision_detection_source,
1178 };
1179 }
1180
1181 // C# QualityParser.cs:545-560: 1920x1080 pixel tag. Same shape as 1280x720.
1182 if contains_ignore_ascii_case(normalized, "1920x1080") {
1183 if contains_ignore_ascii_case(normalized, "bluray") {
1184 return mk_named(Quality::Bluray1080p, QualityDetectionSource::Name);
1185 }
1186 return QualityModel {
1187 quality: Quality::Hdtv1080p,
1188 revision,
1189 source_detection_source: QualityDetectionSource::Unknown,
1190 resolution_detection_source: QualityDetectionSource::Name,
1191 revision_detection_source,
1192 };
1193 }
1194
1195 // C# QualityParser.cs:562-587: bare bluray-resolution tokens. The literal
1196 // "bluray720p" / "bluray1080p" / "bluray2160p" without separators bypasses
1197 // SOURCE_REGEX (which expects word boundaries around `BluRay`).
1198 if contains_ignore_ascii_case(normalized, "bluray720p") {
1199 return mk_named(Quality::Bluray720p, QualityDetectionSource::Name);
1200 }
1201 if contains_ignore_ascii_case(normalized, "bluray1080p") {
1202 return mk_named(Quality::Bluray1080p, QualityDetectionSource::Name);
1203 }
1204 if contains_ignore_ascii_case(normalized, "bluray2160p") {
1205 return mk_named(Quality::Bluray2160p, QualityDetectionSource::Name);
1206 }
1207
1208 // C# QualityParser.cs:589-595 + 653-672: OtherSourceMatch. HD-TV / SD-TV
1209 // alternative-form via OTHER_SOURCE_REGEX. Only Quality + source-detection
1210 // are set; resolution-detection-source carries through from above.
1211 if let Some(caps) = OTHER_SOURCE_REGEX.captures(normalized) {
1212 let q = if caps.name("sdtv").is_some() {
1213 Some(Quality::Sdtv)
1214 } else if caps.name("hdtv").is_some() {
1215 Some(Quality::Hdtv720p)
1216 } else {
1217 None
1218 };
1219 if let Some(quality) = q {
1220 return QualityModel {
1221 quality,
1222 revision,
1223 source_detection_source: QualityDetectionSource::Name,
1224 resolution_detection_source,
1225 revision_detection_source,
1226 };
1227 }
1228 }
1229
1230 // Carry-out: nothing matched. Return the default with the parsed revision
1231 // and any resolution-detection-source flag we accumulated.
1232 QualityModel {
1233 revision,
1234 revision_detection_source,
1235 resolution_detection_source,
1236 ..Default::default()
1237 }
1238}
1239
1240/// **Canonical entry-point** for parsing a release name into a
1241/// [`QualityModel`](crate::quality::QualityModel). Ported from
1242/// `QualityParser.cs:68-98` (`ParseQuality`). Use [`parse_quality_name`]
1243/// only if you need to bypass the extension lookup.
1244///
1245/// Wraps [`parse_quality_name`] with the C# extension fallback at lines
1246/// 81-95: when the inner cascade returns `Quality::Unknown`, look up the file
1247/// extension via [`quality_for_extension`] and overlay the result. The C#
1248/// branch sets `SourceDetectionSource` and `ResolutionDetectionSource` to
1249/// `Extension` BEFORE inspecting the returned Quality (lines 87-88), so even
1250/// a recognised-but-`Unknown` extension (e.g. `.webm`) flips both flags.
1251///
1252/// C# guards this branch with `!name.ContainsInvalidPathChars()`. The Rust
1253/// port mirrors that guard via [`contains_invalid_path_chars`], which is the
1254/// `Path::GetInvalidPathChars()` set: NUL plus the C0 control range
1255/// `\x00-\x1F`. Cross-platform, no Windows-only chars (matches the C#
1256/// behaviour where `Path.GetInvalidPathChars` excludes the per-platform
1257/// "additional" chars like `<>:"/\?*`).
1258pub fn parse_quality(name: &str) -> crate::quality::QualityModel {
1259 use crate::quality::{Quality, QualityDetectionSource};
1260
1261 let trimmed = name.trim();
1262 let mut result = parse_quality_name(trimmed);
1263
1264 if result.quality == Quality::Unknown && !contains_invalid_path_chars(trimmed) {
1265 let ext = get_path_extension(trimmed);
1266 // C# QualityParser.cs:87-88: detection sources are set BEFORE the
1267 // dictionary lookup, so even an Unknown-mapped extension (e.g.
1268 // `.webm`) flips both flags. Mirror that ordering.
1269 result.source_detection_source = QualityDetectionSource::Extension;
1270 result.resolution_detection_source = QualityDetectionSource::Extension;
1271 result.quality = quality_for_extension(ext);
1272 }
1273
1274 result
1275}
1276
1277/// Mirrors `string.ContainsInvalidPathChars` from
1278/// `NzbDrone.Common/Extensions/PathExtensions.cs:184-192`. Returns true if
1279/// the string contains any character that .NET's
1280/// `Path.GetInvalidPathChars()` lists.
1281///
1282/// On both .NET Framework and .NET Core, `Path.GetInvalidPathChars()` returns
1283/// `{ '\0', '\x01' .. '\x1F' }` (NUL plus the C0 control range). It does NOT
1284/// include the per-platform "additional" characters (`<>:"/\?*` on Windows)
1285/// because those are reserved for `Path.GetInvalidFileNameChars()`. Mirroring
1286/// the C# behaviour means we accept release names containing `:` or `/` etc.
1287/// even though those would be invalid in a real filesystem path.
1288fn contains_invalid_path_chars(text: &str) -> bool {
1289 text.bytes().any(|b| b == 0 || (0x01..=0x1F).contains(&b))
1290}
1291
1292/// Modifier-parsing helper. Ported from `QualityParser.cs:675-711`.
1293///
1294/// Returns `(revision, revision_detection_source)`. The detection source is
1295/// `Name` if any of the four C# modifier branches fire (Version, Proper,
1296/// Repack, Real); otherwise `Unknown`.
1297///
1298/// Order of operations (mirrors C#):
1299///
1300/// 1. **Version regex** (line 679): on hit, set `version = N`.
1301/// 2. **Proper regex** (line 687): on hit, set `version = (versionMatch ? N : 1) + 1`.
1302/// Equivalently: if Version did not fire, version is 2; if it did, version
1303/// is the captured N + 1. This is **assignment**, not increment, so a
1304/// second Proper hit does not bump again.
1305/// 3. **Repack regex** (line 693): on hit, same assignment as Proper, plus
1306/// `is_repack = true`. If both Proper and Repack fire, the Repack branch
1307/// overrides the Proper version (both compute the same value, so the
1308/// practical effect is just the `is_repack` flag).
1309/// 4. **Real regex** (line 702): set `real = matches.Count` (count, not
1310/// flag). Uses the **raw** input `name`, not the normalised one. The
1311/// other three branches use `normalizedName`. This is intentional in C#
1312/// and we preserve it.
1313///
1314/// Plan-spec drift notes (resolved in favour of C#):
1315///
1316/// - The plan-spec drafted Proper as `version += 1` (increment). C# uses
1317/// assignment with a Version-aware fallback. Ported per C#.
1318/// - The plan-spec ordered the checks Proper -> Repack -> Version -> Real.
1319/// C# orders them Version -> Proper -> Repack -> Real. Ported per C#.
1320/// - The plan-spec modelled `Real` as `bool`. C# stores it as `int` with
1321/// `Matches.Count` semantics. The Rust port carries `Revision.real: u32`;
1322/// this helper writes the count.
1323fn parse_quality_modifiers(
1324 name: &str,
1325 normalized_name: &str,
1326) -> (
1327 crate::quality::Revision,
1328 crate::quality::QualityDetectionSource,
1329) {
1330 use crate::quality::{QualityDetectionSource, Revision};
1331
1332 let mut rev = Revision::default();
1333 let mut detection = QualityDetectionSource::Unknown;
1334
1335 // Step 1: Version regex (C# line 679, run against normalizedName).
1336 // Walks all 5 named groups (`version` through `version5`) per the
1337 // VERSION_REGEX doc-comment, picking the first that captured. The captured
1338 // text is a single ASCII digit, so `parse::<u32>()` cannot widen overflow.
1339 let version_caps = VERSION_REGEX.captures(normalized_name);
1340 let version_value: Option<u32> = version_caps.as_ref().and_then(|caps| {
1341 caps.name("version")
1342 .or_else(|| caps.name("version2"))
1343 .or_else(|| caps.name("version3"))
1344 .or_else(|| caps.name("version4"))
1345 .or_else(|| caps.name("version5"))
1346 .and_then(|m| m.as_str().parse().ok())
1347 });
1348 if let Some(v) = version_value {
1349 rev.version = v;
1350 detection = QualityDetectionSource::Name;
1351 }
1352
1353 // Step 2: Proper regex (C# line 687, run against normalizedName).
1354 // C#: `result.Revision.Version = versionRegexResult.Success ? Convert.ToInt32(...) + 1 : 2;`
1355 // -- assignment, not increment. The fallback when Version did NOT fire is
1356 // the literal 2 (NOT the current `rev.version + 1`).
1357 if PROPER_REGEX.is_match(normalized_name) {
1358 rev.version = match version_value {
1359 Some(v) => v + 1,
1360 None => 2,
1361 };
1362 detection = QualityDetectionSource::Name;
1363 }
1364
1365 // Step 3: Repack regex (C# line 693, run against normalizedName).
1366 // Same assignment semantics as Proper, plus IsRepack = true.
1367 if REPACK_REGEX.is_match(normalized_name) {
1368 rev.version = match version_value {
1369 Some(v) => v + 1,
1370 None => 2,
1371 };
1372 rev.is_repack = true;
1373 detection = QualityDetectionSource::Name;
1374 }
1375
1376 // Step 4: Real regex (C# line 702, run against the RAW name -- not
1377 // normalizedName -- because that is what C# does. Real is case-sensitive
1378 // (REAL_REGEX has no `(?i)` flag). The C# code uses `Matches(name).Count`,
1379 // which we mirror via `find_iter().count()`.
1380 let real_count = REAL_REGEX.find_iter(name).count();
1381 if real_count > 0 {
1382 // C# stores the raw count; we cap at `u32::MAX` defensively because
1383 // `usize` is wider on 64-bit hosts. In practice the count is bounded
1384 // by input length, which is bounded by Sonarr's release-name limit
1385 // (well under 4 billion).
1386 rev.real = u32::try_from(real_count).unwrap_or(u32::MAX);
1387 detection = QualityDetectionSource::Name;
1388 }
1389
1390 (rev, detection)
1391}
1392
1393#[cfg(test)]
1394mod tests {
1395 use super::regexes::*;
1396 use super::{
1397 SourceGroup, detect_resolution, match_remux, match_source, match_source_last,
1398 matches_alternative_resolution, matches_anime_bluray, parse_quality, parse_quality_name,
1399 };
1400 use crate::quality::{Quality, QualityDetectionSource, Resolution};
1401
1402 // Source-regex direct hits.
1403
1404 #[test]
1405 fn source_regex_detects_bluray() {
1406 let m = SOURCE_REGEX
1407 .captures("Movie.2020.1080p.BluRay.x264")
1408 .unwrap();
1409 assert!(m.name("bluray").is_some());
1410 }
1411
1412 #[test]
1413 fn source_regex_detects_webdl() {
1414 let m = SOURCE_REGEX
1415 .captures("Show.S01E01.WEB-DL.AAC.x264")
1416 .unwrap();
1417 assert!(m.name("webdl").is_some());
1418 }
1419
1420 #[test]
1421 fn source_regex_detects_webrip() {
1422 let m = SOURCE_REGEX.captures("Show.S01E01.WEBRip.x264").unwrap();
1423 assert!(m.name("webrip").is_some());
1424 }
1425
1426 #[test]
1427 fn source_regex_detects_hdtv() {
1428 let m = SOURCE_REGEX.captures("Show.S01E01.HDTV.x264").unwrap();
1429 assert!(m.name("hdtv").is_some());
1430 }
1431
1432 #[test]
1433 fn source_regex_detects_dvd() {
1434 let m = SOURCE_REGEX.captures("Movie.2010.DVDRip.XviD").unwrap();
1435 assert!(m.name("dvd").is_some());
1436 }
1437
1438 // Source helper: lookaround workarounds.
1439
1440 #[test]
1441 fn match_source_rejects_bare_bd_at_end_of_string() {
1442 // C# `BD(?!$)`. Bare `BD` at end of input must NOT match the bluray branch.
1443 // The release name "Show.BD" should fall through to other branches or yield None.
1444 // We craft an input where the only `BD`-shaped token is at the very end.
1445 let result = match_source("Show.S01E01.BD");
1446 // Either no match or a non-bluray match is acceptable; the bluray reject is the gate.
1447 if let Some(found) = result {
1448 assert_ne!(
1449 found.group,
1450 SourceGroup::Bluray,
1451 "bare BD at EOL should not match bluray; got matched={:?}",
1452 found.matched
1453 );
1454 }
1455 }
1456
1457 #[test]
1458 fn match_source_accepts_bd_when_followed_by_more() {
1459 // `BD` mid-string is fine.
1460 let m = match_source("Show.BD.x264.somegroup").expect("should match bluray");
1461 assert_eq!(m.group, SourceGroup::Bluray);
1462 // Case-exact: input is uppercase BD; assert the regex returned the literal "BD"
1463 // rather than any normalised text. Catches accidental case-folding regressions.
1464 assert_eq!(m.matched, "BD");
1465 }
1466
1467 #[test]
1468 fn match_source_accepts_bdmux_at_eol() {
1469 // `BDMux` is its own alternative and is not constrained by the BD-not-at-EOL rule.
1470 let m = match_source("Show.BDMux").expect("BDMux at EOL must match bluray");
1471 assert_eq!(m.group, SourceGroup::Bluray);
1472 }
1473
1474 #[test]
1475 fn match_source_rejects_amzn_web_rip() {
1476 // C# `(?:AMZN|NF|DP)[. -]WEB[. -](?!Rip)`. AMZN.WEB.Rip must NOT match webdl.
1477 let result = match_source("Show.S01E01.AMZN.WEB.Rip.x264");
1478 if let Some(found) = result {
1479 // Should fall through to webrip, not webdl.
1480 assert_ne!(
1481 found.group,
1482 SourceGroup::Webdl,
1483 "AMZN.WEB.Rip must not be classified as webdl; got matched={:?}",
1484 found.matched
1485 );
1486 }
1487 }
1488
1489 #[test]
1490 fn match_source_accepts_amzn_web_dl() {
1491 // AMZN.WEB-DL must classify as webdl. C# semantics: the leftmost-match
1492 // wins, so on this input the (?:AMZN|NF|DP)[. -]WEB[. -] provider branch
1493 // fires first (it starts at `AMZN`, earlier than where WEB-DL begins),
1494 // and its (?!Rip) post-filter passes because the next char after the
1495 // provider match is `D`, not `R`. The end result (webdl group) is what
1496 // callers care about; this test pins both.
1497 let m = match_source("Show.S01E01.AMZN.WEB-DL.x264").expect("AMZN.WEB-DL must match webdl");
1498 assert_eq!(m.group, SourceGroup::Webdl);
1499 // Pin which branch fires. If the alternation order or anchoring ever
1500 // shifts so that WEB[-_. ]DL claims the match instead, this catches it.
1501 assert_eq!(
1502 m.matched.to_ascii_uppercase(),
1503 "AMZN.WEB-",
1504 "expected provider_web branch (AMZN.WEB-), got `{}`",
1505 m.matched
1506 );
1507 }
1508
1509 #[test]
1510 fn match_source_accepts_amzn_web_when_not_followed_by_rip() {
1511 // AMZN.WEB.h264 must match the (?:AMZN|NF|DP)[. -]WEB[. -] branch since
1512 // the trailing chars are not Rip.
1513 let m = match_source("Show.S01E01.AMZN.WEB.h264").expect("AMZN.WEB.h264 must match webdl");
1514 assert_eq!(m.group, SourceGroup::Webdl);
1515 }
1516
1517 #[test]
1518 fn source_regex_uppercase_web_at_eol_matches_webdl() {
1519 // The C# `[. ](?-i:WEB)$` branch matches an uppercase `WEB` at end of input.
1520 // `(?-i:...)` is supported natively by Rust regex, so this is a verbatim port.
1521 let m = SOURCE_REGEX.captures("Show.S01E01.WEB").unwrap();
1522 assert!(
1523 m.name("webdl").is_some(),
1524 "uppercase WEB at EOL must match webdl"
1525 );
1526 }
1527
1528 #[test]
1529 fn source_regex_lowercase_web_at_eol_does_not_match_via_eol_branch() {
1530 // Verifies that `(?-i:WEB)$` is in fact case-sensitive in our port.
1531 // `Show.S01E01.web` has no other webdl-matching token, so the only path
1532 // would be the EOL branch, which must reject lowercase.
1533 let result = SOURCE_REGEX.captures("Show.S01E01.web");
1534 // Either no match, or a non-webdl branch (none should hit `web` though).
1535 if let Some(c) = result {
1536 assert!(
1537 c.name("webdl").is_none(),
1538 "lowercase web at EOL must not match webdl"
1539 );
1540 }
1541 }
1542
1543 // RawHD.
1544
1545 #[test]
1546 fn raw_hd_regex_detects_rawhd() {
1547 assert!(RAW_HD_REGEX.is_match("Show.S01E01.RawHD.MPEG2"));
1548 }
1549
1550 #[test]
1551 fn raw_hd_regex_detects_raw_hyphen_hd() {
1552 assert!(RAW_HD_REGEX.is_match("Show.S01E01.Raw-HD"));
1553 }
1554
1555 #[test]
1556 fn raw_hd_regex_does_not_match_unrelated() {
1557 assert!(!RAW_HD_REGEX.is_match("Show.S01E01.RAWisCool"));
1558 }
1559
1560 // MPEG2.
1561
1562 #[test]
1563 fn mpeg2_regex_detects_mpeg2() {
1564 assert!(MPEG2_REGEX.is_match("Movie.MPEG2.x264"));
1565 }
1566
1567 #[test]
1568 fn mpeg2_regex_detects_mpeg_dash_2() {
1569 assert!(MPEG2_REGEX.is_match("Movie.MPEG-2.x264"));
1570 }
1571
1572 #[test]
1573 fn mpeg2_regex_is_case_sensitive() {
1574 // C# uses no IgnoreCase flag on this pattern, so `mpeg2` (lowercase) must NOT match.
1575 assert!(!MPEG2_REGEX.is_match("Movie.mpeg2.x264"));
1576 }
1577
1578 // Remux.
1579
1580 #[test]
1581 fn remux_regex_detects_uhd_remux() {
1582 assert!(REMUX_REGEX.is_match("Movie.2020.UHD.Remux.2160p"));
1583 }
1584
1585 #[test]
1586 fn remux_regex_detects_bd_remux_prefix() {
1587 // `_Remux` form: `\d{4}p-Remux` matches via `\d{4}p-` prefix.
1588 assert!(REMUX_REGEX.is_match("Movie.2020.1080p-Remux"));
1589 }
1590
1591 #[test]
1592 fn remux_regex_detects_remux_then_resolution() {
1593 // `Remux_2160p` form: matches the second alternative.
1594 assert!(REMUX_REGEX.is_match("Movie.2020.Remux_2160p"));
1595 }
1596
1597 #[test]
1598 fn match_remux_returns_text() {
1599 let s = "Movie.2020.UHD.Remux.2160p";
1600 let got = match_remux(s).expect("must match");
1601 assert!(got.to_ascii_lowercase().contains("remux"));
1602 }
1603
1604 // Anime Bluray (lookaround workaround).
1605
1606 #[test]
1607 fn anime_bluray_regex_detects_bd_with_resolution() {
1608 assert!(matches_anime_bluray("[group] Show - 01 [BD1080p]"));
1609 }
1610
1611 #[test]
1612 fn anime_bluray_regex_detects_bd_in_brackets() {
1613 // `[BD]` -> bare `bd` surrounded by `[` and `]`.
1614 assert!(matches_anime_bluray("[group] Show - 01 [BD]"));
1615 }
1616
1617 #[test]
1618 fn anime_bluray_regex_detects_bd_with_space() {
1619 // `[BD 1080p]` -> bare `bd` surrounded by `[` and ` `.
1620 assert!(matches_anime_bluray("[group] Show - 01 [BD 1080p]"));
1621 }
1622
1623 #[test]
1624 fn anime_bluray_regex_does_not_match_substring() {
1625 // Without surround chars, bare `bd` inside a word must not count.
1626 assert!(!matches_anime_bluray("Some.abdef.movie"));
1627 }
1628
1629 #[test]
1630 fn anime_bluray_regex_does_not_match_bd_then_letter() {
1631 // `bdrip` has surround `.` left but `r` right, must not match the bare-bd branch.
1632 assert!(!matches_anime_bluray("Show.bdrip"));
1633 }
1634
1635 #[test]
1636 fn anime_bluray_regex_rejects_bare_bd_at_sol() {
1637 // C#'s (?<=[-_. (\[])bd... lookbehind cannot match at start-of-input.
1638 assert!(!matches_anime_bluray("bd"));
1639 assert!(!matches_anime_bluray("bd.x264"));
1640 }
1641
1642 #[test]
1643 fn anime_bluray_regex_rejects_bare_bd_at_eol() {
1644 // C#'s ...bd(?=[-_. )\]]) lookahead cannot match at end-of-input.
1645 assert!(!matches_anime_bluray("show.bd"));
1646 }
1647
1648 #[test]
1649 fn anime_bluray_regex_accepts_bd1080_at_boundaries() {
1650 // bd720/bd1080/bd2160 are the lookaround-free branch; boundaries are fine.
1651 assert!(matches_anime_bluray("bd1080"));
1652 assert!(matches_anime_bluray("bd1080.mkv"));
1653 }
1654
1655 // Anime WebDL.
1656
1657 #[test]
1658 fn anime_webdl_regex_detects_web_brackets() {
1659 assert!(ANIME_WEBDL_REGEX.is_match("[group] Show - 01 [WEB] [1080p]"));
1660 }
1661
1662 #[test]
1663 fn anime_webdl_regex_detects_paren_web_dot() {
1664 assert!(ANIME_WEBDL_REGEX.is_match("(WEB.1080p) Show"));
1665 }
1666
1667 #[test]
1668 fn anime_webdl_regex_does_not_match_bare_web() {
1669 assert!(!ANIME_WEBDL_REGEX.is_match("Show.WEB.1080p"));
1670 }
1671
1672 // Resolution.
1673
1674 #[test]
1675 fn resolution_regex_detects_2160p() {
1676 let m = RESOLUTION_REGEX.captures("Movie.2160p.HDR").unwrap();
1677 assert!(m.name("R2160p").is_some());
1678 }
1679
1680 #[test]
1681 fn resolution_regex_detects_1280x720_as_720p() {
1682 let m = RESOLUTION_REGEX
1683 .captures("[group] Show - 01 (1280x720)")
1684 .unwrap();
1685 assert!(m.name("R720p").is_some());
1686 }
1687
1688 #[test]
1689 fn resolution_regex_detects_uhd_4k_form() {
1690 // C# 4K branch: `(?:UHD|HEVC|BD|H265)[-_. ]4k`. `UHD-4k` must hit R2160p.
1691 let m = RESOLUTION_REGEX.captures("Movie.2020.UHD-4k.x265").unwrap();
1692 assert!(m.name("R2160p").is_some());
1693 }
1694
1695 #[test]
1696 fn resolution_regex_detects_4k_uhd_form() {
1697 // C# 4K branch: `4k[-_. ](?:UHD|HEVC|BD|H265)`. `4k-UHD` must hit R2160p.
1698 let m = RESOLUTION_REGEX.captures("Movie.2020.4k-UHD.x265").unwrap();
1699 assert!(m.name("R2160p").is_some());
1700 }
1701
1702 #[test]
1703 fn resolution_regex_detects_480p() {
1704 let m = RESOLUTION_REGEX.captures("Show.S01E01.480p.x264").unwrap();
1705 assert!(m.name("R480p").is_some());
1706 }
1707
1708 #[test]
1709 fn resolution_regex_detects_1080p_fhd() {
1710 let m = RESOLUTION_REGEX.captures("Movie.2020.FHD.x264").unwrap();
1711 assert!(m.name("R1080p").is_some());
1712 }
1713
1714 #[test]
1715 fn resolution_regex_detects_4kto1080p_form() {
1716 // C# QualityParser.cs:49 - R1080p group includes `4kto1080p` for
1717 // downscaled-UHD releases. Regression guard against the T5-original
1718 // miss that dropped this token while correcting the R2160p 4K branches.
1719 let m = RESOLUTION_REGEX.captures("Movie.4kto1080p.x264").unwrap();
1720 assert!(
1721 m.name("R1080p").is_some(),
1722 "expected R1080p branch to match `4kto1080p`"
1723 );
1724 }
1725
1726 // Alternative resolution (duplicate-group workaround).
1727
1728 #[test]
1729 fn alternative_resolution_regex_matches_uhd() {
1730 assert!(matches_alternative_resolution("Movie.UHD.Bluray"));
1731 }
1732
1733 #[test]
1734 fn alternative_resolution_regex_matches_bracket_4k() {
1735 assert!(matches_alternative_resolution("Movie [4K] Bluray"));
1736 }
1737
1738 #[test]
1739 fn alternative_resolution_regex_does_not_match_unrelated() {
1740 assert!(!matches_alternative_resolution("Movie.1080p.Bluray"));
1741 }
1742
1743 #[test]
1744 fn alternative_resolution_regex_named_groups_are_distinct() {
1745 // Direct regex assertion: C# uses the same `<R2160p>` name twice; we
1746 // renamed the second to `R2160p_alt` to satisfy Rust regex's no-dup
1747 // rule. Confirm both names exist and fire on their respective inputs.
1748 let uhd = ALTERNATIVE_RESOLUTION_REGEX
1749 .captures("Movie.UHD.x264")
1750 .unwrap();
1751 assert!(uhd.name("R2160p").is_some());
1752 assert!(uhd.name("R2160p_alt").is_none());
1753
1754 let four_k = ALTERNATIVE_RESOLUTION_REGEX
1755 .captures("Movie [4K] x264")
1756 .unwrap();
1757 assert!(four_k.name("R2160p").is_none());
1758 assert!(four_k.name("R2160p_alt").is_some());
1759 }
1760
1761 // Codec.
1762
1763 #[test]
1764 fn codec_regex_detects_xvid() {
1765 let m = CODEC_REGEX.captures("Show.S01E01.Xvid.AC3").unwrap();
1766 assert!(m.name("xvid").is_some());
1767 }
1768
1769 #[test]
1770 fn codec_regex_does_not_match_x_hyphen_vid() {
1771 // Plan-spec drafted `X-?vid`; C# QualityParser.cs:56-57 is `Xvid`
1772 // literal. A future relaxation back to the hyphenated form would
1773 // silently widen matching. Mirror of the resolution_regex_detects_4kto1080p_form
1774 // regression-guard pattern.
1775 assert!(CODEC_REGEX.captures("Movie.X-vid.AC3").is_none());
1776 }
1777
1778 #[test]
1779 fn codec_regex_detects_x264() {
1780 let m = CODEC_REGEX.captures("Movie.1080p.x264.AC3").unwrap();
1781 assert!(m.name("x264").is_some());
1782 }
1783
1784 #[test]
1785 fn codec_regex_detects_h264_case_insensitive() {
1786 let m = CODEC_REGEX.captures("Movie.1080p.H264.AC3").unwrap();
1787 assert!(m.name("h264").is_some());
1788 }
1789
1790 #[test]
1791 fn codec_regex_detects_xvidhd() {
1792 let m = CODEC_REGEX.captures("Movie.XvidHD.AC3").unwrap();
1793 // XvidHD must hit the xvidhd group, not xvid (alternation order matters).
1794 assert!(m.name("xvidhd").is_some());
1795 assert!(m.name("xvid").is_none());
1796 }
1797
1798 #[test]
1799 fn codec_regex_detects_divx() {
1800 let m = CODEC_REGEX.captures("Movie.divx.AC3").unwrap();
1801 assert!(m.name("divx").is_some());
1802 }
1803
1804 // Other source.
1805
1806 #[test]
1807 fn other_source_regex_detects_hd_tv() {
1808 let m = OTHER_SOURCE_REGEX.captures("Show.HD-TV.x264").unwrap();
1809 assert!(m.name("hdtv").is_some());
1810 }
1811
1812 #[test]
1813 fn other_source_regex_detects_sd_tv() {
1814 let m = OTHER_SOURCE_REGEX.captures("Show.SD.TV.x264").unwrap();
1815 assert!(m.name("sdtv").is_some());
1816 }
1817
1818 // High-def PDTV.
1819
1820 #[test]
1821 fn high_def_pdtv_regex_detects_hr_ws() {
1822 assert!(HIGH_DEF_PDTV_REGEX.is_match("Show.S01E01.hr-ws.x264"));
1823 }
1824
1825 #[test]
1826 fn high_def_pdtv_regex_does_not_match_unrelated() {
1827 assert!(!HIGH_DEF_PDTV_REGEX.is_match("Show.S01E01.hrws.x264"));
1828 }
1829
1830 // Proper.
1831
1832 #[test]
1833 fn proper_regex_detects_proper() {
1834 assert!(PROPER_REGEX.is_match("Show PROPER 720p"));
1835 }
1836
1837 #[test]
1838 fn proper_regex_detects_proper_lowercase() {
1839 // C# uses IgnoreCase; lowercase must match too.
1840 assert!(PROPER_REGEX.is_match("Show.proper.720p"));
1841 }
1842
1843 // Repack.
1844
1845 #[test]
1846 fn repack_regex_detects_repack() {
1847 assert!(REPACK_REGEX.is_match("Show REPACK2 1080p"));
1848 }
1849
1850 #[test]
1851 fn repack_regex_detects_rerip() {
1852 assert!(REPACK_REGEX.is_match("Show.RERIP.1080p"));
1853 }
1854
1855 #[test]
1856 fn repack_regex_detects_bare_repack() {
1857 // No trailing digit is also valid: `repack\d?` makes the digit optional.
1858 assert!(REPACK_REGEX.is_match("Show.REPACK.1080p"));
1859 }
1860
1861 // Version.
1862
1863 #[test]
1864 fn version_regex_detects_v2() {
1865 // Rust regex does not allow duplicate names, so we renamed the C#
1866 // alternation branches to version/version2/version3/version4/version5.
1867 // For the `1080p.v2` form, the fifth branch (`version5`) fires.
1868 let m = VERSION_REGEX.captures("Show 1080p v2").unwrap();
1869 let version = m
1870 .name("version")
1871 .or_else(|| m.name("version2"))
1872 .or_else(|| m.name("version3"))
1873 .or_else(|| m.name("version4"))
1874 .or_else(|| m.name("version5"))
1875 .expect("at least one named version group must capture");
1876 assert_eq!(version.as_str(), "2");
1877 }
1878
1879 #[test]
1880 fn version_regex_detects_bracket_v3() {
1881 // `[v3]` form fires the second branch (version2 in our port).
1882 let m = VERSION_REGEX.captures("Show [v3] 1080p").unwrap();
1883 assert!(m.name("version2").is_some());
1884 assert_eq!(m.name("version2").unwrap().as_str(), "3");
1885 }
1886
1887 #[test]
1888 fn version_regex_detects_repack_with_digit() {
1889 // `repack2` form fires the third branch (version3 in our port).
1890 let m = VERSION_REGEX.captures("Show.repack2.1080p").unwrap();
1891 assert!(m.name("version3").is_some());
1892 assert_eq!(m.name("version3").unwrap().as_str(), "2");
1893 }
1894
1895 #[test]
1896 fn version_regex_detects_rerip_with_digit() {
1897 // `rerip3` form fires the fourth branch (version4 in our port).
1898 let m = VERSION_REGEX.captures("Show.rerip3.1080p").unwrap();
1899 assert!(m.name("version4").is_some());
1900 assert_eq!(m.name("version4").unwrap().as_str(), "3");
1901 }
1902
1903 #[test]
1904 fn version_regex_detects_digit_v_digit_form() {
1905 // `01-v2_` form fires the first branch (version in our port).
1906 // Pattern: `\d[-._ ]?v(?<version>\d)[-._ ]`.
1907 let m = VERSION_REGEX.captures("Show 01-v2_1080p").unwrap();
1908 assert!(m.name("version").is_some());
1909 assert_eq!(m.name("version").unwrap().as_str(), "2");
1910 }
1911
1912 // Real (case-sensitive).
1913
1914 #[test]
1915 fn real_regex_detects_real() {
1916 assert!(REAL_REGEX.is_match("Show REAL PROPER 1080p"));
1917 }
1918
1919 #[test]
1920 fn real_regex_is_case_sensitive() {
1921 // C# `RealRegex` carries no IgnoreCase flag, so lowercase `real` must
1922 // NOT match. This gates the absence of the `(?i)` flag in our port.
1923 assert!(!REAL_REGEX.is_match("show real proper"));
1924 assert!(!REAL_REGEX.is_match("Show Real Proper"));
1925 }
1926
1927 // T6 cascade entry-point + modifier parsing.
1928 //
1929 // Cross-checked against `QualityParser.cs:100-112` (ParseQualityName) and
1930 // `QualityParser.cs:675-711` (ParseQualityModifiers). Plan-spec versus C#
1931 // discrepancies are noted in `parse_quality_modifiers` itself; the tests
1932 // below pin C# semantics (the source of truth).
1933
1934 #[test]
1935 fn parses_proper_increments_revision() {
1936 // C# (line 689): proper sets version = match? +1 : 2 (no version match
1937 // here, so version becomes 2).
1938 let m = parse_quality_name("Show S01E01 PROPER HDTV XviD");
1939 assert_eq!(m.revision.version, 2);
1940 }
1941
1942 #[test]
1943 fn parses_repack_sets_is_repack() {
1944 // C# (line 696): repack sets IsRepack = true. version becomes 2 via the
1945 // same fallback as proper.
1946 let m = parse_quality_name("Show.2010.REPACK.1080p.WEB");
1947 assert!(m.revision.is_repack);
1948 assert_eq!(m.revision.version, 2);
1949 }
1950
1951 #[test]
1952 fn parses_real_sets_real_flag() {
1953 // C# (line 706): Real = realRegexResult.Count, so for one REAL match
1954 // the count is 1. The PROPER on this input bumps version to 2 (the
1955 // REAL itself does not change version).
1956 let m = parse_quality_name("Show.REAL.PROPER.1080p");
1957 assert_eq!(m.revision.real, 1);
1958 assert_eq!(m.revision.version, 2);
1959 }
1960
1961 #[test]
1962 fn parses_explicit_version_2() {
1963 // C# (line 683): VersionRegex sets version = N. The 5th VERSION_REGEX
1964 // branch fires for the `1080p.v2` form.
1965 let m = parse_quality_name("Show.S01E01.1080p.v2");
1966 assert_eq!(m.revision.version, 2);
1967 }
1968
1969 #[test]
1970 fn raw_hd_short_circuits() {
1971 // C# (line 105): if RawHDRegex matches, set Quality = RAWHD and return
1972 // immediately. The cascade bails before considering source/resolution.
1973 let m = parse_quality_name("Show.S01E01.RawHD");
1974 assert_eq!(m.quality, Quality::RawHd);
1975 }
1976
1977 // Extra coverage on T6's load-bearing edges.
1978
1979 #[test]
1980 fn parse_quality_name_default_when_no_modifiers_or_rawhd() {
1981 // No modifiers, no RawHD: T6 returns the default QualityModel with
1982 // version=1 and Quality::Unknown. T7's source-to-quality cascade is
1983 // what flips Quality based on source/resolution; T6 must not.
1984 let m = parse_quality_name("Show.S01E01.MysteryFormat");
1985 assert_eq!(m.quality, Quality::Unknown);
1986 assert_eq!(m.revision.version, 1);
1987 assert_eq!(m.revision.real, 0);
1988 assert!(!m.revision.is_repack);
1989 }
1990
1991 #[test]
1992 fn parse_quality_name_empty_input_returns_default() {
1993 // C# ParseQuality:72 short-circuits on IsNullOrWhiteSpace. The Rust
1994 // port handles empty input via natural cascade fallthrough (no source
1995 // match, no resolution, no modifiers -> default QualityModel).
1996 // Pins the graceful-empty behaviour against a future "validate
1997 // input non-empty" change that might panic.
1998 let m = parse_quality_name("");
1999 assert_eq!(m.quality, Quality::Unknown);
2000 assert_eq!(m.revision.version, 1);
2001 assert_eq!(m.revision.real, 0);
2002 assert!(!m.revision.is_repack);
2003 }
2004
2005 #[test]
2006 fn parse_quality_name_normalizes_underscores() {
2007 // C# (line 102): name.Replace('_', ' '). PROPER sandwiched in
2008 // underscores must still classify as a proper. Our `\b(?<proper>proper)\b`
2009 // also matches across `_` boundaries (regex `\b` treats underscore as a
2010 // word char, so `_PROPER_` does NOT have a word boundary), which is
2011 // exactly why C# normalises first.
2012 let m = parse_quality_name("Show_S01E01_PROPER_HDTV");
2013 assert_eq!(m.revision.version, 2);
2014 }
2015
2016 #[test]
2017 fn parse_quality_name_real_uses_raw_input_not_normalized() {
2018 // C# (line 702): RealRegex.Matches(name). The raw input is used, not
2019 // normalizedName. Real is case-sensitive uppercase. Underscores around
2020 // REAL are fine because the regex uses \b boundaries with word-char
2021 // semantics (`_` is a word char, so `_REAL_` has no \b, but that is
2022 // NOT a problem here because the literal `REAL` token is bordered by
2023 // other separators in real-world inputs).
2024 let m = parse_quality_name("Show.REAL.S01E01.1080p");
2025 assert_eq!(m.revision.real, 1);
2026 }
2027
2028 #[test]
2029 fn parse_quality_name_real_underscore_bordered_does_not_match() {
2030 // C# uses RealRegex.Matches(name) on the RAW input. `_REAL_` has no \b
2031 // boundary on either side because `_` is a word-char in the regex
2032 // engine. If a future refactor accidentally switched Real to scan the
2033 // normalized name (` REAL `), the boundary would fire and this would
2034 // flip from 0 to 1. This test gates that drift; the existing
2035 // parse_quality_name_real_uses_raw_input_not_normalized test does NOT
2036 // discriminate (its input produces identical results raw and normalized).
2037 let m = parse_quality_name("Show_S01E01_REAL_1080p");
2038 assert_eq!(m.revision.real, 0);
2039 }
2040
2041 #[test]
2042 fn parse_quality_name_real_lowercase_is_ignored() {
2043 // C# REAL_REGEX is case-sensitive (no IgnoreCase flag). lowercase
2044 // `real` must not bump the counter.
2045 let m = parse_quality_name("Show.real.S01E01.1080p");
2046 assert_eq!(m.revision.real, 0);
2047 }
2048
2049 #[test]
2050 fn parse_quality_name_multi_real_counts() {
2051 // C# `Real = realRegexResult.Count`. Two REAL tokens => Real = 2.
2052 // Verifies the u32 vs bool decision: a `bool` would lose this signal.
2053 let m = parse_quality_name("Show.REAL.REAL.1080p");
2054 assert_eq!(m.revision.real, 2);
2055 }
2056
2057 #[test]
2058 fn parse_quality_name_repack_with_digit_takes_version() {
2059 // C# (line 695): when version regex AND repack both match, the version
2060 // value is `versionRegexResult.Groups["version"].Value + 1`. For
2061 // `repack2`, the version regex captures "2", and the repack branch
2062 // sets version = 2 + 1 = 3.
2063 let m = parse_quality_name("Show.repack2.1080p");
2064 assert_eq!(m.revision.version, 3);
2065 assert!(m.revision.is_repack);
2066 }
2067
2068 #[test]
2069 fn parse_quality_name_proper_with_digit_v2_takes_version() {
2070 // C# (line 689): version=2, proper bumps to 3.
2071 let m = parse_quality_name("Show.PROPER.1080p.v2");
2072 assert_eq!(m.revision.version, 3);
2073 }
2074
2075 #[test]
2076 fn raw_hd_short_circuits_populates_detection_sources() {
2077 // C# (lines 107-109): when RawHD matches, source + resolution detection
2078 // sources are set to Name. Revision detection source is set if any
2079 // modifier fired (none here, so it stays Unknown).
2080 let m = parse_quality_name("Show.S01E01.RawHD");
2081 assert_eq!(
2082 m.source_detection_source,
2083 crate::quality::QualityDetectionSource::Name
2084 );
2085 assert_eq!(
2086 m.resolution_detection_source,
2087 crate::quality::QualityDetectionSource::Name
2088 );
2089 assert_eq!(
2090 m.revision_detection_source,
2091 crate::quality::QualityDetectionSource::Unknown
2092 );
2093 }
2094
2095 #[test]
2096 fn raw_hd_short_circuits_with_proper_sets_revision_detection_source() {
2097 // C# (line 690): proper sets RevisionDetectionSource = Name. Even
2098 // through a RawHD short-circuit, the revision-detection source must
2099 // travel along because ParseQualityModifiers ran first.
2100 let m = parse_quality_name("Show.RawHD.PROPER");
2101 assert_eq!(m.quality, Quality::RawHd);
2102 assert_eq!(m.revision.version, 2);
2103 assert_eq!(
2104 m.revision_detection_source,
2105 crate::quality::QualityDetectionSource::Name
2106 );
2107 }
2108
2109 // T7 detect_resolution helper.
2110
2111 #[test]
2112 fn detect_resolution_returns_2160p_for_2160p_token() {
2113 assert_eq!(
2114 detect_resolution("Movie.2020.2160p.BluRay"),
2115 Resolution::R2160p
2116 );
2117 }
2118
2119 #[test]
2120 fn detect_resolution_returns_2160p_for_uhd_token() {
2121 // Falls back to ALTERNATIVE_RESOLUTION_REGEX when the main
2122 // RESOLUTION_REGEX would not capture (no numeric resolution token).
2123 assert_eq!(
2124 detect_resolution("Movie.2020.UHD.BluRay"),
2125 Resolution::R2160p
2126 );
2127 }
2128
2129 #[test]
2130 fn detect_resolution_returns_2160p_for_bracket_4k() {
2131 assert_eq!(
2132 detect_resolution("Movie.2020 [4K] BluRay"),
2133 Resolution::R2160p
2134 );
2135 }
2136
2137 #[test]
2138 fn detect_resolution_returns_1080p_for_1080p_token() {
2139 assert_eq!(
2140 detect_resolution("Movie.2020.1080p.WEB-DL"),
2141 Resolution::R1080p
2142 );
2143 }
2144
2145 #[test]
2146 fn detect_resolution_returns_720p_for_1280x720_token() {
2147 // C# RESOLUTION_REGEX folds 1280x720 into R720p.
2148 assert_eq!(detect_resolution("Show.S01E01.1280x720"), Resolution::R720p);
2149 }
2150
2151 #[test]
2152 fn detect_resolution_returns_480p_for_480p_token() {
2153 assert_eq!(
2154 detect_resolution("Show.S01E01.480p.HDTV"),
2155 Resolution::R480p
2156 );
2157 }
2158
2159 #[test]
2160 fn detect_resolution_returns_540p_for_540p_token() {
2161 assert_eq!(
2162 detect_resolution("Show.S01E01.540p.HDTV"),
2163 Resolution::R540p
2164 );
2165 }
2166
2167 #[test]
2168 fn detect_resolution_returns_576p_for_576p_token() {
2169 assert_eq!(
2170 detect_resolution("Show.S01E01.576p.HDTV"),
2171 Resolution::R576p
2172 );
2173 }
2174
2175 #[test]
2176 fn detect_resolution_returns_360p_for_360p_token() {
2177 assert_eq!(
2178 detect_resolution("Show.S01E01.360p.HDTV"),
2179 Resolution::R360p
2180 );
2181 }
2182
2183 #[test]
2184 fn detect_resolution_returns_unknown_when_no_token() {
2185 assert_eq!(
2186 detect_resolution("Show.S01E01.HDTV.x264"),
2187 Resolution::Unknown
2188 );
2189 }
2190
2191 // T7 match_source_last (LastOrDefault semantics).
2192
2193 #[test]
2194 fn match_source_last_returns_last_when_two_distinct_sources() {
2195 // C# `sourceMatches.OfType<Match>().LastOrDefault()`: HDTV at the
2196 // start, BluRay at the end -> last match is BluRay.
2197 let m = match_source_last("Show.HDTV.Repack.1080p.BluRay.x264").expect("BluRay must match");
2198 assert_eq!(m.group, SourceGroup::Bluray);
2199 }
2200
2201 #[test]
2202 fn match_source_last_returns_first_when_only_one_source() {
2203 // Single sourceMatch -> first == last; trivially exercised.
2204 let m = match_source_last("Movie.2020.1080p.BluRay.x264").expect("BluRay must match");
2205 assert_eq!(m.group, SourceGroup::Bluray);
2206 }
2207
2208 #[test]
2209 fn match_source_last_skips_filtered_candidates() {
2210 // C# `BD(?!$)` rejects bare BD at end. If the only candidate is
2211 // filtered, return None.
2212 assert!(match_source_last("Show.S01E01.BD").is_none());
2213 }
2214
2215 // T7 cascade: bluray arm.
2216
2217 #[test]
2218 fn bluray_2160p() {
2219 // Plan-required test #1.
2220 assert_eq!(
2221 parse_quality_name("Movie.2020.2160p.BluRay.x265").quality,
2222 Quality::Bluray2160p
2223 );
2224 }
2225
2226 #[test]
2227 fn bluray_1080p_remux() {
2228 // Plan-required test #2.
2229 assert_eq!(
2230 parse_quality_name("Movie.2020.1080p.BluRay.Remux.AVC").quality,
2231 Quality::Bluray1080pRemux
2232 );
2233 }
2234
2235 #[test]
2236 fn bluray_720p() {
2237 // Plan-required test #3.
2238 assert_eq!(
2239 parse_quality_name("Movie.720p.BluRay.x264").quality,
2240 Quality::Bluray720p
2241 );
2242 }
2243
2244 #[test]
2245 fn bluray_576p() {
2246 // C# QualityParser.cs:150-154 covers 576p as its own bucket.
2247 assert_eq!(
2248 parse_quality_name("Movie.576p.BluRay.x264").quality,
2249 Quality::Bluray576p
2250 );
2251 }
2252
2253 #[test]
2254 fn bluray_480p_via_xvid_codec() {
2255 // C# QualityParser.cs:131-135: a bluray release with xvid OR divx
2256 // codec is downgraded to 480p regardless of resolution.
2257 assert_eq!(
2258 parse_quality_name("Movie.2020.BluRay.Xvid.AC3").quality,
2259 Quality::Bluray480p
2260 );
2261 }
2262
2263 #[test]
2264 fn bluray_480p_via_divx_codec() {
2265 // C# QualityParser.cs:131-135: divx co-equal with xvid.
2266 assert_eq!(
2267 parse_quality_name("Movie.2020.BluRay.divx.AC3").quality,
2268 Quality::Bluray480p
2269 );
2270 }
2271
2272 #[test]
2273 fn bluray_480p_via_low_resolution() {
2274 // C# QualityParser.cs:156-161: 360p / 480p / 540p collapse to 480p.
2275 assert_eq!(
2276 parse_quality_name("Movie.2020.480p.BluRay.x264").quality,
2277 Quality::Bluray480p
2278 );
2279 assert_eq!(
2280 parse_quality_name("Movie.2020.540p.BluRay.x264").quality,
2281 Quality::Bluray480p
2282 );
2283 }
2284
2285 #[test]
2286 fn bluray_2160p_remux_via_2160p_token_and_remux() {
2287 // C# QualityParser.cs:139.
2288 assert_eq!(
2289 parse_quality_name("Movie.2020.2160p.BluRay.Remux.HDR.x265").quality,
2290 Quality::Bluray2160pRemux
2291 );
2292 }
2293
2294 #[test]
2295 fn bluray_remux_unknown_resolution_falls_back_to_1080p_remux() {
2296 // C# QualityParser.cs:165-169: remux without a 720p resolution falls
2297 // back to 1080pRemux. Unknown resolution + remux + bluray -> 1080pRemux.
2298 assert_eq!(
2299 parse_quality_name("Movie.BluRay.Remux.x264").quality,
2300 Quality::Bluray1080pRemux
2301 );
2302 }
2303
2304 #[test]
2305 fn bluray_720p_with_remux_stays_720p() {
2306 // C# QualityParser.cs:165 explicit comment: "720p remux should
2307 // fallback as 720p BluRay". Pin that the 720p arm ignores the remux
2308 // signal.
2309 assert_eq!(
2310 parse_quality_name("Movie.720p.BluRay.Remux.x264").quality,
2311 Quality::Bluray720p
2312 );
2313 }
2314
2315 #[test]
2316 fn bluray_unknown_resolution_no_remux_falls_back_to_720p() {
2317 // C# QualityParser.cs:171-172: implicit default at the bottom of the
2318 // bluray arm is Bluray720p when no resolution token, no remux, no
2319 // codec downgrade hits. Pins the Resolution::Unknown + remux=false
2320 // -> Bluray720p path. The 720p-with-remux carve-out test
2321 // (bluray_720p_with_remux_stays_720p) covers the symmetric case;
2322 // this completes the matrix.
2323 assert_eq!(
2324 parse_quality_name("Movie.BluRay.x264").quality,
2325 Quality::Bluray720p
2326 );
2327 }
2328
2329 // T7 cascade: webdl arm.
2330
2331 #[test]
2332 fn webdl_1080p() {
2333 // Plan-required test #4.
2334 assert_eq!(
2335 parse_quality_name("Movie.2020.1080p.WEB-DL.x264").quality,
2336 Quality::Webdl1080p
2337 );
2338 }
2339
2340 #[test]
2341 fn webdl_720p_itunes() {
2342 // Plan-required test #5. iTunesHD is a webdl-branch alternative.
2343 assert_eq!(
2344 parse_quality_name("Movie.720p.iTunesHD.AVC").quality,
2345 Quality::Webdl720p
2346 );
2347 }
2348
2349 #[test]
2350 fn webdl_2160p() {
2351 // C# QualityParser.cs:177-180.
2352 assert_eq!(
2353 parse_quality_name("Movie.2020.2160p.WEB-DL.x265").quality,
2354 Quality::Webdl2160p
2355 );
2356 }
2357
2358 #[test]
2359 fn webdl_480p_default() {
2360 // C# QualityParser.cs:201-202: any webdl with no R720p / R1080p /
2361 // R2160p resolution match falls back to 480p.
2362 assert_eq!(
2363 parse_quality_name("Movie.2020.WEB-DL.AAC.x264").quality,
2364 Quality::Webdl480p
2365 );
2366 }
2367
2368 #[test]
2369 fn webdl_720p_via_bracket_marker() {
2370 // C# QualityParser.cs:195-199: raw-name `[WEBDL]` substring forces
2371 // 720p when no resolution match is present. The substring is checked
2372 // against the RAW input, NOT the normalised name (underscores become
2373 // spaces in normalised, but the literal bracket text is identical).
2374 assert_eq!(
2375 parse_quality_name("Movie.[WEBDL].WEB-DL.AC3").quality,
2376 Quality::Webdl720p
2377 );
2378 }
2379
2380 // T7 cascade: webrip arm.
2381
2382 #[test]
2383 fn webrip_2160p() {
2384 // Plan-required test #6.
2385 assert_eq!(
2386 parse_quality_name("Movie.2160p.WebRip.x265").quality,
2387 Quality::Webrip2160p
2388 );
2389 }
2390
2391 #[test]
2392 fn webrip_1080p() {
2393 // C# QualityParser.cs:213-216.
2394 assert_eq!(
2395 parse_quality_name("Movie.2020.1080p.WEBRip.x264").quality,
2396 Quality::Webrip1080p
2397 );
2398 }
2399
2400 #[test]
2401 fn webrip_720p() {
2402 // C# QualityParser.cs:219-222.
2403 assert_eq!(
2404 parse_quality_name("Show.S01E01.720p.WEBRip.x264").quality,
2405 Quality::Webrip720p
2406 );
2407 }
2408
2409 #[test]
2410 fn webrip_480p_default() {
2411 // C# QualityParser.cs:225-226: webrip with no resolution match -> 480p.
2412 assert_eq!(
2413 parse_quality_name("Show.S01E01.WEBRip.x264").quality,
2414 Quality::Webrip480p
2415 );
2416 }
2417
2418 // T7 cascade: hdtv arm.
2419
2420 #[test]
2421 fn hdtv_720p() {
2422 // Plan-required test #7.
2423 assert_eq!(
2424 parse_quality_name("Show.S01E01.720p.HDTV.x264").quality,
2425 Quality::Hdtv720p
2426 );
2427 }
2428
2429 #[test]
2430 fn hdtv_1080p() {
2431 // Plan-required test #8.
2432 assert_eq!(
2433 parse_quality_name("Show.S01E01.1080p.HDTV.x264").quality,
2434 Quality::Hdtv1080p
2435 );
2436 }
2437
2438 #[test]
2439 fn hdtv_2160p() {
2440 // C# QualityParser.cs:237-240.
2441 assert_eq!(
2442 parse_quality_name("Show.S01E01.2160p.HDTV.x265").quality,
2443 Quality::Hdtv2160p
2444 );
2445 }
2446
2447 #[test]
2448 fn hdtv_mpeg2_short_circuits_to_rawhd() {
2449 // C# QualityParser.cs:231-234: when the source is HDTV and the
2450 // normalized name contains MPEG2 (case-sensitive), the cascade
2451 // short-circuits to RAWHD even with a 1080p token in the input.
2452 assert_eq!(
2453 parse_quality_name("Show.S01E01.1080p.HDTV.MPEG2").quality,
2454 Quality::RawHd
2455 );
2456 }
2457
2458 #[test]
2459 fn hdtv_mpeg2_lowercase_does_not_short_circuit() {
2460 // C#'s MPEG2_REGEX is case-sensitive (no IgnoreCase flag): a
2461 // lowercase `mpeg2` token must NOT trigger the short-circuit.
2462 // Matches the modifier behaviour pinned at T4 (mpeg2_regex_is_case_sensitive).
2463 assert_eq!(
2464 parse_quality_name("Show.S01E01.1080p.HDTV.mpeg2.x264").quality,
2465 Quality::Hdtv1080p
2466 );
2467 }
2468
2469 #[test]
2470 fn hdtv_720p_via_bracket_marker() {
2471 // C# QualityParser.cs:255-259: raw-name `[HDTV]` substring forces
2472 // 720p when no resolution match is present.
2473 assert_eq!(
2474 parse_quality_name("Show.S01E01.[HDTV].x264").quality,
2475 Quality::Hdtv720p
2476 );
2477 }
2478
2479 // T7 cascade: dvd arm.
2480
2481 #[test]
2482 fn dvd() {
2483 // Plan-required test #9. DVD source always maps to DVD regardless of
2484 // resolution.
2485 assert_eq!(
2486 parse_quality_name("Movie.2010.DVDRip.XviD-AAC").quality,
2487 Quality::Dvd
2488 );
2489 }
2490
2491 #[test]
2492 fn dvd_with_explicit_dvd_token() {
2493 // The bare `DVD` token also fires the dvd branch.
2494 assert_eq!(
2495 parse_quality_name("Movie.2010.DVD.XviD-AAC").quality,
2496 Quality::Dvd
2497 );
2498 }
2499
2500 #[test]
2501 fn dvd_ntsc_token() {
2502 // C# QualityParser.cs:25 includes NTSC in the dvd alternation.
2503 assert_eq!(
2504 parse_quality_name("Movie.2010.NTSC.XviD-AAC").quality,
2505 Quality::Dvd
2506 );
2507 }
2508
2509 // T7 cascade: bdrip / brrip (sub-bluray) arms.
2510
2511 #[test]
2512 fn bdrip_720p_maps_to_bluray720p() {
2513 // C# QualityParser.cs:265-272.
2514 assert_eq!(
2515 parse_quality_name("Movie.720p.BDRip.x264").quality,
2516 Quality::Bluray720p
2517 );
2518 }
2519
2520 #[test]
2521 fn bdrip_1080p_maps_to_bluray1080p() {
2522 assert_eq!(
2523 parse_quality_name("Movie.1080p.BDRip.x264").quality,
2524 Quality::Bluray1080p
2525 );
2526 }
2527
2528 #[test]
2529 fn bdrip_default_maps_to_bluray480p() {
2530 // C# QualityParser.cs:280-281: BDRip default is 480p.
2531 assert_eq!(
2532 parse_quality_name("Movie.BDRip.x264").quality,
2533 Quality::Bluray480p
2534 );
2535 }
2536
2537 #[test]
2538 fn brrip_2160p_maps_to_bluray2160p() {
2539 // C# QualityParser.cs:276-278.
2540 assert_eq!(
2541 parse_quality_name("Movie.2160p.BRRip.x265").quality,
2542 Quality::Bluray2160p
2543 );
2544 }
2545
2546 // T7 cascade: pdtv / sdtv / dsr / tvrip arm.
2547
2548 #[test]
2549 fn sdtv_default() {
2550 // Plan-required test #10. HDTV with no resolution token -> SDTV.
2551 // (The hdtv arm hits the no-resolution fallback at C# line 261.)
2552 assert_eq!(
2553 parse_quality_name("Show.S01E01.HDTV.x264").quality,
2554 Quality::Sdtv
2555 );
2556 }
2557
2558 #[test]
2559 fn pdtv_no_resolution_maps_to_sdtv() {
2560 // C# QualityParser.cs:315-316.
2561 assert_eq!(
2562 parse_quality_name("Show.S01E01.PDTV.x264").quality,
2563 Quality::Sdtv
2564 );
2565 }
2566
2567 #[test]
2568 fn pdtv_1080p_maps_to_hdtv1080p() {
2569 // C# QualityParser.cs:296-300.
2570 assert_eq!(
2571 parse_quality_name("Show.S01E01.1080p.PDTV.x264").quality,
2572 Quality::Hdtv1080p
2573 );
2574 }
2575
2576 #[test]
2577 fn pdtv_720p_maps_to_hdtv720p() {
2578 // C# QualityParser.cs:302-306.
2579 assert_eq!(
2580 parse_quality_name("Show.S01E01.720p.PDTV.x264").quality,
2581 Quality::Hdtv720p
2582 );
2583 }
2584
2585 #[test]
2586 fn pdtv_hr_ws_maps_to_hdtv720p() {
2587 // C# QualityParser.cs:308-312: HighDefPdtvRegex (`hr-ws`) on a pdtv
2588 // source promotes to Hdtv720p AND flips the resolution-detection
2589 // source to Name even though no resolution-regex group fired.
2590 let m = parse_quality_name("Show.S01E01.PDTV.hr-ws.x264");
2591 assert_eq!(m.quality, Quality::Hdtv720p);
2592 assert_eq!(m.resolution_detection_source, QualityDetectionSource::Name);
2593 }
2594
2595 #[test]
2596 fn sdtv_branch_with_explicit_sdtv_token() {
2597 // The bare `SDTV` token routes into the pdtv-cluster arm.
2598 assert_eq!(
2599 parse_quality_name("Show.S01E01.SDTV.x264").quality,
2600 Quality::Sdtv
2601 );
2602 }
2603
2604 #[test]
2605 fn dsr_branch_default_sdtv() {
2606 // C# QualityParser.cs:293-294: DSR routes through the pdtv-cluster
2607 // arm and falls back to SDTV without a resolution match.
2608 assert_eq!(
2609 parse_quality_name("Show.S01E01.DSR.x264").quality,
2610 Quality::Sdtv
2611 );
2612 }
2613
2614 #[test]
2615 fn tvrip_branch_default_sdtv() {
2616 // C# QualityParser.cs:294: TVRip in the pdtv-cluster arm.
2617 assert_eq!(
2618 parse_quality_name("Show.S01E01.TVRip.x264").quality,
2619 Quality::Sdtv
2620 );
2621 }
2622
2623 // T7 cascade: cross-arm semantics.
2624
2625 #[test]
2626 fn cascade_uses_last_source_match() {
2627 // C# QualityParser.cs:115 LastOrDefault: HDTV appears first, BluRay
2628 // appears later -> classification is bluray, not hdtv. This is the
2629 // single most important cross-arm guarantee in the cascade.
2630 let m = parse_quality_name("Show.HDTV.Repack.1080p.BluRay.x264");
2631 assert_eq!(m.quality, Quality::Bluray1080p);
2632 }
2633
2634 #[test]
2635 fn cascade_populates_source_detection_when_source_fires() {
2636 // Any sourceMatch sets source_detection_source = Name (C# line 127).
2637 let m = parse_quality_name("Movie.1080p.BluRay.x264");
2638 assert_eq!(m.source_detection_source, QualityDetectionSource::Name);
2639 }
2640
2641 #[test]
2642 fn cascade_populates_resolution_detection_when_resolution_fires() {
2643 // C# QualityParser.cs:120-123: a non-Unknown resolution flips
2644 // resolution_detection_source to Name regardless of source-arm.
2645 let m = parse_quality_name("Movie.1080p.BluRay.x264");
2646 assert_eq!(m.resolution_detection_source, QualityDetectionSource::Name);
2647 }
2648
2649 #[test]
2650 fn cascade_leaves_resolution_detection_unknown_when_no_resolution() {
2651 // No resolution token -> resolution_detection_source stays Unknown
2652 // even though the source arm fires.
2653 let m = parse_quality_name("Movie.WEB-DL.x264");
2654 assert_eq!(m.source_detection_source, QualityDetectionSource::Name);
2655 assert_eq!(
2656 m.resolution_detection_source,
2657 QualityDetectionSource::Unknown
2658 );
2659 }
2660
2661 #[test]
2662 fn cascade_falls_back_to_unknown_when_no_source() {
2663 // A name with no source, no resolution, no anime markers, no codec, no
2664 // pixel tag, no bare-bluray token, and no OtherSourceMatch hit (no
2665 // recognised file extension either) returns Quality::Unknown.
2666 let m = parse_quality_name("Show.S01E01.MysteryFormat");
2667 assert_eq!(m.quality, Quality::Unknown);
2668 }
2669
2670 #[test]
2671 fn cascade_resolution_only_1080p_maps_to_hdtv1080p() {
2672 // T8 resolution-only fallback. C# QualityParser.cs:464-473: with no
2673 // source match, `Movie.2020.1080p.x264` derives source from extension
2674 // (`.x264` is unknown -> QualitySource::Unknown), then routes via
2675 // line 468's `source == Unknown` branch to Quality::Hdtv1080p.
2676 let m = parse_quality_name("Movie.2020.1080p.x264");
2677 assert_eq!(m.quality, Quality::Hdtv1080p);
2678 // Resolution detection flips to Name because the resolution regex
2679 // fired (line 122 + line 466).
2680 assert_eq!(m.resolution_detection_source, QualityDetectionSource::Name);
2681 }
2682
2683 #[test]
2684 fn cascade_underscore_normalised_for_source_match() {
2685 // C# normalises `_` -> ` ` before sourceMatch. A release like
2686 // `Movie_1080p_BluRay_x264` must classify as bluray-1080p just like
2687 // its dotted twin.
2688 let m = parse_quality_name("Movie_2020_1080p_BluRay_x264");
2689 assert_eq!(m.quality, Quality::Bluray1080p);
2690 }
2691
2692 #[test]
2693 fn cascade_trims_outer_whitespace() {
2694 // C# QualityParser.cs:77 trims the raw input before the pipeline.
2695 // Leading/trailing whitespace must NOT change classification.
2696 let m = parse_quality_name(" Movie.2020.1080p.BluRay.x264 ");
2697 assert_eq!(m.quality, Quality::Bluray1080p);
2698 }
2699
2700 #[test]
2701 fn cascade_revision_carries_through_arm() {
2702 // PROPER bumps version to 2; the cascade must preserve the
2703 // revision through the bluray arm.
2704 let m = parse_quality_name("Movie.2020.1080p.BluRay.PROPER.x264");
2705 assert_eq!(m.quality, Quality::Bluray1080p);
2706 assert_eq!(m.revision.version, 2);
2707 assert_eq!(m.revision_detection_source, QualityDetectionSource::Name);
2708 }
2709
2710 // T8 cascade: sourceless-remux (C# QualityParser.cs:320-347).
2711
2712 #[test]
2713 fn sourceless_remux_2160p_maps_to_bluray2160p_remux() {
2714 // C# QualityParser.cs:336-340: REMUX + 2160p without a source token
2715 // routes through the sourceless-remux block to Bluray2160pRemux.
2716 let m = parse_quality_name("Movie.2020.2160p.Remux.HDR.x265");
2717 assert_eq!(m.quality, Quality::Bluray2160pRemux);
2718 // Line 322: SourceDetectionSource is explicitly set to Unknown for
2719 // sourceless remux even though we matched something.
2720 assert_eq!(m.source_detection_source, QualityDetectionSource::Unknown);
2721 assert_eq!(m.resolution_detection_source, QualityDetectionSource::Name);
2722 }
2723
2724 #[test]
2725 fn sourceless_remux_1080p_maps_to_bluray1080p_remux() {
2726 // C# QualityParser.cs:342-346.
2727 let m = parse_quality_name("Movie.2020.1080p.Remux.AVC.DTS-HD");
2728 assert_eq!(m.quality, Quality::Bluray1080pRemux);
2729 assert_eq!(m.source_detection_source, QualityDetectionSource::Unknown);
2730 }
2731
2732 #[test]
2733 fn sourceless_remux_720p_maps_to_bluray720p() {
2734 // C# QualityParser.cs:330-334. The 720p remux without source is
2735 // explicitly Bluray720p, NOT Bluray720pRemux (which doesn't exist).
2736 let m = parse_quality_name("Movie.2020.720p.Remux.AVC");
2737 assert_eq!(m.quality, Quality::Bluray720p);
2738 }
2739
2740 #[test]
2741 fn sourceless_remux_480p_maps_to_bluray480p() {
2742 // C# QualityParser.cs:324-328.
2743 let m = parse_quality_name("Movie.2020.480p.Remux.x264");
2744 assert_eq!(m.quality, Quality::Bluray480p);
2745 }
2746
2747 // T8 cascade: anime-bluray (C# QualityParser.cs:349-390).
2748
2749 #[test]
2750 fn anime_bluray_720p() {
2751 // Plan-required test #1. C# QualityParser.cs:388: anime-bluray with
2752 // unknown resolution falls through to default Bluray720p.
2753 //
2754 // NB: token form `[BD720p]` (no space) matches ANIME_BLURAY_REGEX's
2755 // `bd720` branch but NOT SOURCE_REGEX's bluray group (which requires
2756 // a word-boundary or space/period after `BD`). A name with `[BD 720p]`
2757 // (with space) would short-circuit through the T7 bluray arm instead.
2758 let m = parse_quality_name("[group] Show - 01 [BD720p]");
2759 assert_eq!(m.quality, Quality::Bluray720p);
2760 assert_eq!(m.source_detection_source, QualityDetectionSource::Name);
2761 }
2762
2763 #[test]
2764 fn anime_bluray_1080p() {
2765 // C# QualityParser.cs:364-369. Token form `[BD1080p]` matches
2766 // ANIME_BLURAY_REGEX's `bd1080` branch.
2767 let m = parse_quality_name("[group] Show - 01 [BD1080p]");
2768 assert_eq!(m.quality, Quality::Bluray1080p);
2769 }
2770
2771 #[test]
2772 fn anime_bluray_2160p() {
2773 // C# QualityParser.cs:372-377. Token form `[BD2160p]`.
2774 let m = parse_quality_name("[group] Show - 01 [BD2160p]");
2775 assert_eq!(m.quality, Quality::Bluray2160p);
2776 }
2777
2778 #[test]
2779 fn anime_bluray_480p_maps_to_dvd() {
2780 // C# QualityParser.cs:354-362: anime-bluray at 360p / 480p / 540p /
2781 // 576p (or substring "480p") is intentionally collapsed to Quality::Dvd.
2782 //
2783 // Constructing a name that triggers this branch is delicate. Both
2784 // `bd720`/`bd1080`/`bd2160` literal forms AND bare `bd` with separator
2785 // surround can match ANIME_BLURAY_REGEX, but bare-bd with separator
2786 // surround ALSO matches SOURCE_REGEX's bluray group (because
2787 // separator chars like `[` `]` `(` `)` `-` `.` ` ` give a `\b` or
2788 // `[ .]` after `bd`). The only way to fire anime-bluray without
2789 // SOURCE_REGEX hijacking is via a `bd<digits>` literal (e.g. `bd720`)
2790 // because no `\b|$|[ .]` follows `bd` in that case (digits are word
2791 // chars).
2792 //
2793 // So we use `bd720` to fire ANIME_BLURAY_REGEX, plus a separate
2794 // `480p` token to drive the 480p anime-bluray sub-branch. The 720p
2795 // in `bd720` does NOT register as a resolution because RESOLUTION_REGEX
2796 // expects `\b720p\b` (and the regex needs `bd` to be separated from
2797 // `720` by a word boundary, which it isn't).
2798 let m = parse_quality_name("Show.480p.bd720.AC3");
2799 assert_eq!(m.quality, Quality::Dvd);
2800 assert_eq!(m.resolution_detection_source, QualityDetectionSource::Name);
2801 }
2802
2803 #[test]
2804 fn anime_bluray_remux_no_resolution_falls_back_to_1080p_remux() {
2805 // C# QualityParser.cs:382-386: anime-bluray + remux + resolution !=
2806 // R720p (incl. Unknown) -> Bluray1080pRemux. Use the surround-bd
2807 // form to match anime-bluray without firing SOURCE_REGEX's bluray
2808 // group.
2809 let m = parse_quality_name("[group] Show.(bd).Remux");
2810 assert_eq!(m.quality, Quality::Bluray1080pRemux);
2811 }
2812
2813 #[test]
2814 fn anime_bluray_1080p_remux_combo() {
2815 // C# QualityParser.cs:367 with remux: Bluray1080pRemux. Use the
2816 // surround-bd form + a 1080p resolution token.
2817 let m = parse_quality_name("[group] Show.1080p.(bd).Remux");
2818 assert_eq!(m.quality, Quality::Bluray1080pRemux);
2819 }
2820
2821 #[test]
2822 fn anime_bluray_unknown_resolution_no_remux_defaults_720p() {
2823 // C# QualityParser.cs:388: anime-bluray with no resolution and no
2824 // remux defaults to Bluray720p. Surround-bd form.
2825 let m = parse_quality_name("[group] Show.(bd).x264");
2826 assert_eq!(m.quality, Quality::Bluray720p);
2827 }
2828
2829 // T8 cascade: anime-webdl (C# QualityParser.cs:392-424).
2830
2831 #[test]
2832 fn anime_webdl_1080p() {
2833 // Plan-required test #2. C# QualityParser.cs:406-411.
2834 let m = parse_quality_name("[group] Show - 01 [WEB][1080p]");
2835 assert_eq!(m.quality, Quality::Webdl1080p);
2836 assert_eq!(m.source_detection_source, QualityDetectionSource::Name);
2837 }
2838
2839 #[test]
2840 fn anime_webdl_2160p() {
2841 // C# QualityParser.cs:414-420.
2842 let m = parse_quality_name("[group] Show - 01 [WEB][2160p]");
2843 assert_eq!(m.quality, Quality::Webdl2160p);
2844 }
2845
2846 #[test]
2847 fn anime_webdl_480p() {
2848 // C# QualityParser.cs:396-404: 480p / 540p / 576p / substring "480p"
2849 // -> Webdl480p.
2850 let m = parse_quality_name("[group] Show - 01 [WEB][480p]");
2851 assert_eq!(m.quality, Quality::Webdl480p);
2852 }
2853
2854 #[test]
2855 fn anime_webdl_unknown_resolution_defaults_720p() {
2856 // C# QualityParser.cs:422: anime-webdl with no resolution defaults
2857 // to Webdl720p.
2858 let m = parse_quality_name("[group] Show - 01 (WEB.)");
2859 assert_eq!(m.quality, Quality::Webdl720p);
2860 }
2861
2862 // T8 cascade: resolution-only fallback (C# QualityParser.cs:426-497).
2863
2864 #[test]
2865 fn resolution_only_2160p_maps_to_hdtv2160p() {
2866 // C# QualityParser.cs:453-461. No source, 2160p resolution, no remux,
2867 // unknown extension -> Hdtv2160p.
2868 let m = parse_quality_name("Movie.2020.2160p.x265");
2869 assert_eq!(m.quality, Quality::Hdtv2160p);
2870 }
2871
2872 #[test]
2873 fn resolution_only_720p_maps_to_hdtv720p() {
2874 // C# QualityParser.cs:475-484.
2875 let m = parse_quality_name("Movie.2020.720p.x264");
2876 assert_eq!(m.quality, Quality::Hdtv720p);
2877 }
2878
2879 #[test]
2880 fn resolution_only_480p_maps_to_sdtv() {
2881 // C# QualityParser.cs:486-496. 360 / 480 / 540 / 576 -> SDTV when
2882 // source is Unknown.
2883 let m = parse_quality_name("Movie.2020.480p.x264");
2884 assert_eq!(m.quality, Quality::Sdtv);
2885 }
2886
2887 #[test]
2888 fn resolution_only_with_mkv_extension_promotes_via_television_source() {
2889 // C# QualityParser.cs:437-451: extension `.mkv` resolves to Quality
2890 // HDTV720p (Television source). resolution-only at 1080p with
2891 // Television source returns find_by_source_and_resolution(Television,
2892 // 1080) = Quality::Hdtv1080p. The detection-source flag is Extension
2893 // for the source, Name for the resolution.
2894 let m = parse_quality_name("Movie.2020.1080p.x264.mkv");
2895 assert_eq!(m.quality, Quality::Hdtv1080p);
2896 assert_eq!(m.source_detection_source, QualityDetectionSource::Extension);
2897 assert_eq!(m.resolution_detection_source, QualityDetectionSource::Name);
2898 }
2899
2900 #[test]
2901 fn resolution_only_with_iso_extension_promotes_to_dvd_at_480p() {
2902 // `.iso` -> Quality::Dvd (source = Dvd). At 480p, exact match in
2903 // ALL[] yields Dvd directly.
2904 let m = parse_quality_name("Movie.2020.480p.x264.iso");
2905 assert_eq!(m.quality, Quality::Dvd);
2906 assert_eq!(m.source_detection_source, QualityDetectionSource::Extension);
2907 }
2908
2909 // T8 cascade: x264-SDTV fallback (C# QualityParser.cs:499-504).
2910
2911 #[test]
2912 fn x264_codec_alone_maps_to_sdtv() {
2913 // C# QualityParser.cs:499-503: x264 codec with no source, no
2914 // resolution, no anime, no remux -> SDTV.
2915 let m = parse_quality_name("Movie.x264.AC3");
2916 assert_eq!(m.quality, Quality::Sdtv);
2917 }
2918
2919 // T8 cascade: pixel-tag fallbacks (C# QualityParser.cs:506-560).
2920 //
2921 // RESOLUTION_REGEX already matches 848x480 / 1280x720 / 1920x1080 with
2922 // word boundaries, so these pixel-tag blocks only fire when the regex
2923 // boundary check fails. To exercise them, we glue the pixel tag against
2924 // surrounding text WITHOUT word boundaries.
2925
2926 #[test]
2927 fn pixel_tag_848x480_with_dvd_token_no_word_boundary() {
2928 // C# QualityParser.cs:506-513: literal "848x480" + "dvd" -> DVD,
2929 // case-sensitive on "dvd" per C# `Contains("dvd")`.
2930 let m = parse_quality_name("Movie848x480dvd");
2931 assert_eq!(m.quality, Quality::Dvd);
2932 assert_eq!(m.source_detection_source, QualityDetectionSource::Name);
2933 assert_eq!(m.resolution_detection_source, QualityDetectionSource::Name);
2934 }
2935
2936 #[test]
2937 fn pixel_tag_848x480_with_bluray_token_no_word_boundary() {
2938 // C# QualityParser.cs:515-519: literal "848x480" + "bluray"
2939 // (case-insensitive) -> Bluray480p.
2940 let m = parse_quality_name("Movie848x480bluray");
2941 assert_eq!(m.quality, Quality::Bluray480p);
2942 }
2943
2944 #[test]
2945 fn pixel_tag_848x480_default_sdtv() {
2946 // C# QualityParser.cs:520-523: bare "848x480" without dvd/bluray
2947 // marker -> SDTV.
2948 let m = parse_quality_name("Movie848x480extra");
2949 assert_eq!(m.quality, Quality::Sdtv);
2950 }
2951
2952 #[test]
2953 fn pixel_tag_1280x720_default_hdtv720p() {
2954 // C# QualityParser.cs:528-543: bare "1280x720" without bluray ->
2955 // Hdtv720p.
2956 let m = parse_quality_name("Movie1280x720extra");
2957 assert_eq!(m.quality, Quality::Hdtv720p);
2958 }
2959
2960 #[test]
2961 fn pixel_tag_1280x720_with_bluray_token() {
2962 // C# QualityParser.cs:532-536.
2963 let m = parse_quality_name("Movie1280x720bluray");
2964 assert_eq!(m.quality, Quality::Bluray720p);
2965 }
2966
2967 #[test]
2968 fn pixel_tag_1920x1080_default_hdtv1080p() {
2969 // C# QualityParser.cs:545-560.
2970 let m = parse_quality_name("Movie1920x1080extra");
2971 assert_eq!(m.quality, Quality::Hdtv1080p);
2972 }
2973
2974 #[test]
2975 fn pixel_tag_1920x1080_with_bluray_token() {
2976 // C# QualityParser.cs:549-553.
2977 let m = parse_quality_name("Movie1920x1080bluray");
2978 assert_eq!(m.quality, Quality::Bluray1080p);
2979 }
2980
2981 // T8 cascade: bare bluray720p/1080p/2160p tokens (C# QualityParser.cs:562-587).
2982
2983 #[test]
2984 fn bare_bluray720p_token() {
2985 // C# QualityParser.cs:562-569. The literal "bluray720p" without
2986 // separators bypasses SOURCE_REGEX (which requires `\b|$|[ .]` after
2987 // `BluRay`) and RESOLUTION_REGEX (which requires `\b...720p\b`).
2988 // Also avoid any x264/h264 codec token: C# line 499 short-circuits
2989 // x264 to SDTV BEFORE this branch (`bluray720p` runs at line 562).
2990 let m = parse_quality_name("Movie.bluray720p.AC3");
2991 assert_eq!(m.quality, Quality::Bluray720p);
2992 assert_eq!(m.source_detection_source, QualityDetectionSource::Name);
2993 assert_eq!(m.resolution_detection_source, QualityDetectionSource::Name);
2994 }
2995
2996 #[test]
2997 fn bare_bluray1080p_token() {
2998 // C# QualityParser.cs:571-578. Same x264-gating note as the 720p test.
2999 let m = parse_quality_name("Movie.bluray1080p.AC3");
3000 assert_eq!(m.quality, Quality::Bluray1080p);
3001 }
3002
3003 #[test]
3004 fn bare_bluray2160p_token() {
3005 // C# QualityParser.cs:580-587. Same x264-gating note.
3006 let m = parse_quality_name("Movie.bluray2160p.AC3");
3007 assert_eq!(m.quality, Quality::Bluray2160p);
3008 }
3009
3010 // T8 cascade: OtherSourceMatch (C# QualityParser.cs:589-595 + 653-672).
3011
3012 #[test]
3013 fn other_source_hd_tv_maps_to_hdtv720p() {
3014 // C# QualityParser.cs:589-594 + 666-670. OTHER_SOURCE_REGEX matches
3015 // "HD TV" / "HD-TV" / "HD_TV" / "HD.TV" -> Hdtv720p when nothing else
3016 // matched. Avoid x264 codec because C# line 499 short-circuits to
3017 // SDTV BEFORE the OtherSourceMatch path.
3018 let m = parse_quality_name("Show.HD-TV.AC3.foo");
3019 assert_eq!(m.quality, Quality::Hdtv720p);
3020 assert_eq!(m.source_detection_source, QualityDetectionSource::Name);
3021 }
3022
3023 #[test]
3024 fn other_source_sd_tv_maps_to_sdtv() {
3025 // C# QualityParser.cs:589-594 + 661-664. Same x264-gating note.
3026 let m = parse_quality_name("Show.SD-TV.AC3.foo");
3027 assert_eq!(m.quality, Quality::Sdtv);
3028 }
3029
3030 // T8: extension fallback (C# QualityParser.cs:81-95).
3031
3032 #[test]
3033 fn extension_fallback_mkv_to_hdtv720p() {
3034 // C# QualityParser.cs:81-95: when ParseQualityName returns Unknown,
3035 // the outer ParseQuality wrapper looks up the file extension. `.mkv`
3036 // -> HDTV720p in MediaFileExtensions.
3037 let m = parse_quality("Show.S01E01.mkv");
3038 assert_eq!(m.quality, Quality::Hdtv720p);
3039 assert_eq!(m.source_detection_source, QualityDetectionSource::Extension);
3040 assert_eq!(
3041 m.resolution_detection_source,
3042 QualityDetectionSource::Extension
3043 );
3044 }
3045
3046 #[test]
3047 fn extension_fallback_mp4_to_sdtv() {
3048 // `.mp4` is mapped to Quality::SDTV in MediaFileExtensions.
3049 let m = parse_quality("Show.S01E01.mp4");
3050 assert_eq!(m.quality, Quality::Sdtv);
3051 assert_eq!(m.source_detection_source, QualityDetectionSource::Extension);
3052 }
3053
3054 #[test]
3055 fn extension_fallback_iso_to_dvd() {
3056 // `.iso` is mapped to Quality::DVD in MediaFileExtensions.
3057 let m = parse_quality("Show.S01E01.iso");
3058 assert_eq!(m.quality, Quality::Dvd);
3059 }
3060
3061 #[test]
3062 fn extension_fallback_m2ts_to_bluray720p() {
3063 // `.m2ts` is mapped to Quality::Bluray720p in MediaFileExtensions.
3064 let m = parse_quality("Show.S01E01.m2ts");
3065 assert_eq!(m.quality, Quality::Bluray720p);
3066 }
3067
3068 #[test]
3069 fn extension_fallback_webm_stays_unknown() {
3070 // C# MediaFileExtensions.cs:16: `.webm` -> Quality::Unknown. The
3071 // dictionary contains the key but maps to Unknown. SourceDetectionSource
3072 // and ResolutionDetectionSource are still flipped to Extension because
3073 // the C# branch sets them BEFORE inspecting the returned Quality.
3074 let m = parse_quality("Show.S01E01.webm");
3075 assert_eq!(m.quality, Quality::Unknown);
3076 assert_eq!(m.source_detection_source, QualityDetectionSource::Extension);
3077 }
3078
3079 #[test]
3080 fn extension_fallback_unknown_extension_stays_unknown() {
3081 // C# MediaFileExtensions.cs:76-83: an extension not in the dictionary
3082 // returns Quality::Unknown but the source/resolution detection
3083 // sources are still flipped to Extension (line 87-88 set them before
3084 // `GetQualityForExtension` returns Unknown). Verified against C#.
3085 let m = parse_quality("Show.S01E01.xyz");
3086 assert_eq!(m.quality, Quality::Unknown);
3087 assert_eq!(m.source_detection_source, QualityDetectionSource::Extension);
3088 }
3089
3090 #[test]
3091 fn extension_fallback_does_not_overwrite_resolved_quality() {
3092 // The extension fallback only fires when ParseQualityName returned
3093 // Quality::Unknown. A name that already classifies (e.g. has BluRay)
3094 // must NOT have its quality overwritten by the extension table.
3095 let m = parse_quality("Movie.2020.1080p.BluRay.x264.mp4");
3096 assert_eq!(m.quality, Quality::Bluray1080p);
3097 // Source detection comes from the BluRay regex hit, not the extension.
3098 assert_eq!(m.source_detection_source, QualityDetectionSource::Name);
3099 }
3100}