Skip to main content

avatarr_parser/release_group/
mod.rs

1// Ported from Sonarr v4.0.17.2952 (97e85a90):
2//   src/NzbDrone.Core/Parser/Parser.cs — ParseReleaseGroup (lines 908-966)
3//
4// The release-group pipeline runs on the ORIGINAL un-preprocessed title
5// (not the cascade-cleaned `simple_title`). It is called post-cascade by
6// `parse_title` and returns the release group as an `Option<String>`.
7//
8// Pipeline order (Sonarr-faithful):
9//   1. Trim + strip file extension
10//   2. Apply Chinese-fansub PreSubstitutionRegex (first match wins)
11//   3. Strip website prefix + torrent tracker suffix
12//   4. Try anime [SubGroup] → return subgroup if matched
13//   5. Apply clean-suffix strip (after anime check)
14//   6. Try exception groups (25 context-bounded + 9 exact) → return if matched
15//   7. Try trailing -GROUP extraction (hand-rolled) → return if matched
16//   8. Return None
17
18use once_cell::sync::Lazy;
19use regex::Regex;
20
21use crate::normalize;
22
23// ---------------------------------------------------------------------------
24// Constants: exception release groups (Sonarr Parser.cs line references)
25// ---------------------------------------------------------------------------
26
27/// Parser.cs:573 — 25 release groups that appear in parenthesised/bracketed
28/// context (e.g. `(Silence)`, `[Joy]`). Requires a boundary char before and
29/// a closing `]` or `)` after.
30const EXCEPTION_GROUPS: &[&str] = &[
31    "Silence",
32    "afm72",
33    "Panda",
34    "Ghost",
35    "MONOLITH",
36    "Tigole",
37    "Joy",
38    "ImE",
39    "UTR",
40    "t3nzin",
41    "Anime Time",
42    "Project Angel",
43    "Hakata Ramen",
44    "HONE",
45    "Vyndros",
46    "SEV",
47    "Garshasp",
48    "Kappa",
49    "Natty",
50    "RCVR",
51    "SAMPA",
52    "YOGI",
53    "r00t",
54    "EDGE2020",
55    "RZeroX",
56];
57
58/// Parser.cs:570 — 9 exact-match release groups that may appear anywhere
59/// in the title (no context-boundary requirement beyond `\b`).
60const EXCEPTION_EXACT_GROUPS: &[&str] = &[
61    "D-Z0N3",
62    "Fight-BB",
63    "VARYG",
64    "E.N.D",
65    "KRaLiMaRKo",
66    "BluDragon",
67    "DarQ",
68    "KCRT",
69    "BEN THE MEN",
70];
71
72/// Regex for exact exception groups. `BEN THE MEN` uses flexible separators
73/// (C#: `BEN[_. ]THE[_. ]MEN`); all others are literal.
74static EXCEPTION_EXACT_REGEX: Lazy<Regex> = Lazy::new(|| {
75    let alts: Vec<String> = EXCEPTION_EXACT_GROUPS
76        .iter()
77        .map(|&g| {
78            if g == "BEN THE MEN" {
79                r"BEN[_. ]THE[_. ]MEN".to_string()
80            } else {
81                regex::escape(g)
82            }
83        })
84        .collect();
85    let pattern = format!(r"(?i)(?P<releasegroup>{})\b", alts.join("|"));
86    Regex::new(&pattern).expect("EXCEPTION_EXACT_REGEX")
87});
88
89/// Parser.cs:563 — invalid release groups (season/episode tag or 8-hex hash).
90static INVALID_RELEASE_GROUP_REGEX: Lazy<Regex> =
91    Lazy::new(|| Regex::new(r"(?i)^([se]\d+|[0-9a-f]{8})$").expect("INVALID_RELEASE_GROUP_REGEX"));
92
93// ---------------------------------------------------------------------------
94// Pre-processing regexes (release-group-specific)
95// ---------------------------------------------------------------------------
96
97/// Parser.cs:20-57 — Chinese fansub normalization regexes.
98/// For release-group extraction, these normalise bracketed anime titles so the
99/// `[SubGroup]` regex at step 4 can find the subgroup in the first bracket.
100/// Order matters: first match wins (break after).
101///
102/// C# lookaheads (`(?=...)`) are dropped — Rust regex does not support
103/// look-around. The remaining pattern constrains matches sufficiently for
104/// release-group extraction (we only need the `[subgroup]` bracket).
105static PRE_SUB_REGEXES: Lazy<Vec<(Regex, &'static str)>> = Lazy::new(|| {
106    vec![
107        // [0] Korean series without season number
108        (
109            Regex::new(r"\.E(\d{2,4})\.\d{6}\.(.*-NEXT)$").expect("PRE_SUB_0"),
110            ".S01E$1.$2",
111        ),
112        // [1] Chinese anime with English + Chinese titles
113        (
114            Regex::new(
115                r"(?i)^\[(?:(?P<subgroup>[^\]]+?)(?:[\x{4e00}-\x{9fcc}]+)?)\]\[(?P<title>[^\]]+?)(?:\s(?P<chinesetitle>[\x{4e00}-\x{9fcc}][^\]]*?))\]\[(?:(?:[\x{4e00}-\x{9fcc}]+?)?(?P<episode>\d{1,4})(?:[\x{4e00}-\x{9fcc}]+?)?)\]",
116            )
117            .expect("PRE_SUB_1"),
118            "[${subgroup}] ${title} - ${episode} - ",
119        ),
120        // [2] LoliHouse/ZERO/Lilith-Raws/Skymoon-Raws/orion origin
121        (
122            Regex::new(
123                r"(?i)^\[(?P<subgroup>[^\]]*?(?:LoliHouse|ZERO|Lilith-Raws|Skymoon-Raws|orion origin)[^\]]*?)\](?P<title>[^\[\]]+?)(?:\s-\s(?P<episode>[0-9-]+)\s*|\[\x{7b2c}?(?P<episode2>[0-9]+(?:-[0-9]+)?)\x{8bdd}?(?:END|\x{5b8c})?\])\[",
124            )
125            .expect("PRE_SUB_2"),
126            "[${subgroup}][${title}][${episode}${episode2}][",
127        ),
128        // [3] Chinese with season number (lookahead dropped)
129        (
130            Regex::new(
131                r"(?i)^\[(?P<subgroup>[^\]]+)\](?:\s?\x{2605}[^\[\s-]+\s?)?\[?(?:(?P<chinesetitle>[^\]]*?)(?:\]\[|\s*[_/\x{00b7}]\s*)){0,2}(?P<title>[^\[\]]+?)(?:\s(?:S?(?:(?:0)(?P<season>\d)|(?P<season2>[1-9]\d))))\]?(?:\[\d{4}\])?\[\x{7b2c}?(?P<episode>[0-9]+(?:-[0-9]+)?)\x{8bdd}?\x{96c6}?(?:\s?END|\x{5b8c}|\s?Fin)?\]",
132            )
133            .expect("PRE_SUB_3"),
134            "[${subgroup}] ${title} S${season}${season2} - ${episode} ",
135        ),
136        // [4] GM-Team releases (lookahead dropped)
137        (
138            Regex::new(
139                r"(?i)^\[(?P<subgroup>[^\]]+)\](?:(?P<chinesub>\[[^\]]*\])+)\[(?P<title>[^\]]+?)\](?P<junk>\[[^\]]+\])*\[(?P<episode>[0-9]+(?:-[0-9]+)?)(?:\sEND|\sFin)?\]",
140            )
141            .expect("PRE_SUB_4"),
142            "[${subgroup}] ${title} - ${episode} ",
143        ),
144    ]
145});
146
147/// Parser.cs:565 — anime release group: leading `[SubGroup]`
148static ANIME_RELEASE_GROUP_REGEX: Lazy<Regex> =
149    Lazy::new(|| Regex::new(r"^\[(?P<subgroup>[^\]]+)\]").expect("ANIME_RELEASE_GROUP_REGEX"));
150
151/// Parser.cs:549 — clean suffixes stripped before the trailing-group search.
152/// These are indexer/scene tags appended after the real release group.
153static CLEAN_RELEASE_GROUP_REGEX: Lazy<Regex> = Lazy::new(|| {
154    Regex::new(
155        r"(?i)(?:-(RP|1|NZBGeek|Obfuscated|Scrambled|sample|Pre|postbot|xpost|Rakuv[a-z0-9]*|WhiteRev|BUYMORE|AsRequested|AlternativeToRequested|GEROV|Z0iDS3N|Chamele0n|4P|4Planet|AlteZachen|RePACKPOST))+$",
156    )
157    .expect("CLEAN_RELEASE_GROUP_REGEX")
158});
159
160/// Resolution tokens. A candidate that IS one of these, or is followed by one
161/// anywhere in the remaining title, gets rejected.
162const RESOLUTION_TOKENS: &[&str] = &["480p", "576p", "720p", "1080p", "2160p"];
163
164// ---------------------------------------------------------------------------
165// Trailing -GROUP extraction (hand-rolled, step 7)
166// ---------------------------------------------------------------------------
167//
168// Sonarr's ReleaseGroupRegex has two branches (ORed with `|`):
169//
170// Branch A: `-(?<releasegroup>[a-z0-9]+(?<part2>-[a-z0-9]+)?
171//            (?!.+?(?:480p|576p|720p|1080p|2160p)))
172//            (?<!(?:WEB-DL|Blu-Ray|480p|576p|720p|1080p|2160p|DTS-HD|
173//            DTS-X|DTS-MA|DTS-ES|-ES|-EN|-CAT|[ ._]\d{4}-\d{2}|-\d{2})
174//            (?:\k<part2>)?)
175//            (?:\b|[-._ ]|$)`
176//
177// Branch B: `[-._ ]\[(?<releasegroup>[a-z0-9]+)\]$`
178//
179// Since Rust regex doesn't support lookaround or backreferences, we hand-roll
180// the matching with explicit checks.
181
182/// Patterns that, when they appear as the full `PREFIX-GROUP` or
183/// `PREFIX-GROUP-PART2` match, indicate a codec/format — not a release group.
184const BLACKLIST_FULL: &[&str] = &["WEB-DL", "Blu-Ray", "DTS-HD", "DTS-X", "DTS-MA", "DTS-ES"];
185
186/// Suffixes that, when they are the entire candidate (single segment after a
187/// dash), indicate a language/format tag — not a release group.
188const BLACKLIST_SINGLE_SEGMENT: &[&str] = &["ES", "EN", "CAT"];
189
190/// A trailing-group candidate found by the scanner.
191struct TrailingCandidate {
192    /// Position of the leading dash in the title.
193    dash_pos: usize,
194    /// End of the full candidate (after optional `-part2`).
195    full_end: usize,
196    /// The candidate text (everything after the leading dash).
197    group: String,
198}
199
200/// Find all `-GROUP` or `-GROUP-PART2` candidates in the title.
201fn find_trailing_group_candidates(title: &str) -> Vec<TrailingCandidate> {
202    let mut candidates = Vec::new();
203    let bytes = title.as_bytes();
204    let len = bytes.len();
205    let mut i = 0;
206
207    while i < len {
208        if bytes[i] == b'-' {
209            let group_start = i + 1;
210            if group_start >= len {
211                break;
212            }
213
214            // Consume first segment: [a-zA-Z0-9]+
215            let mut j = group_start;
216            while j < len && bytes[j].is_ascii_alphanumeric() {
217                j += 1;
218            }
219            if j == group_start {
220                i += 1;
221                continue;
222            }
223
224            let first_end = j;
225
226            // Check for optional second segment: -[a-zA-Z0-9]+
227            let mut full_end = first_end;
228            if j < len && bytes[j] == b'-' {
229                let seg2_start = j + 1;
230                let mut k = seg2_start;
231                while k < len && bytes[k].is_ascii_alphanumeric() {
232                    k += 1;
233                }
234                if k > seg2_start {
235                    full_end = k;
236                }
237            }
238
239            let group_text = title[group_start..full_end].to_string();
240            candidates.push(TrailingCandidate {
241                dash_pos: i,
242                full_end,
243                group: group_text,
244            });
245            i = full_end;
246        } else {
247            i += 1;
248        }
249    }
250
251    candidates
252}
253
254/// Check if the character(s) after the candidate constitute a valid boundary.
255/// Sonarr requires `(?:\b|[-._ ]|$)` after the group.
256///
257/// The boundary definition mirrors .NET's `\b`: a transition between `\w`
258/// (word char) and `\W` (non-word char). Since our candidate scanner only
259/// captures ASCII alphanumeric groups, we need the next character to be
260/// a non-word char. Unlike simple `!is_ascii_alphanumeric()`, we treat
261/// Unicode alphabetic chars (`ë` = `ë`, etc.) as word chars — matching
262/// .NET's `\w` class, which includes accented letters.
263fn has_valid_boundary(title: &str, end: usize) -> bool {
264    if end >= title.len() {
265        return true; // end of string = `$`
266    }
267    // Get the character at `end`. If it's a word char (alphanumeric or
268    // Unicode letter/digit), the boundary fails. Explicit separators
269    // `[-._ ]` always count as boundaries.
270    let remaining = &title[end..];
271    if let Some(ch) = remaining.chars().next() {
272        // Explicit separators always pass
273        if matches!(ch, '-' | '.' | '_' | ' ') {
274            return true;
275        }
276        // Unicode-aware word char check (mirrors .NET \w)
277        // Rejects: accented letters (ë, é, etc.), CJK chars, any
278        // Unicode alphabetic/numeric that isn't an ASCII separator.
279        if ch.is_alphanumeric() {
280            return false;
281        }
282        // Non-alphanumeric, non-separator: comma, bracket, etc.
283        true
284    } else {
285        true // should not happen if end < len, but defensive
286    }
287}
288
289/// Check if a resolution token appears anywhere after `pos` in the title.
290fn resolution_after(title: &str, pos: usize) -> bool {
291    if pos >= title.len() {
292        return false;
293    }
294    let rest = &title[pos..];
295    let rest_lower = rest.to_ascii_lowercase();
296    RESOLUTION_TOKENS.iter().any(|&r| rest_lower.contains(r))
297}
298
299/// Check if the candidate itself IS a resolution token.
300fn is_resolution(candidate: &str) -> bool {
301    RESOLUTION_TOKENS
302        .iter()
303        .any(|&r| r.eq_ignore_ascii_case(candidate))
304}
305
306/// Check if the `PREFIX-CANDIDATE` context (looking backward from the dash)
307/// matches a blacklisted full pattern like `WEB-DL`, `DTS-HD`, etc.
308fn context_is_blacklisted(title: &str, candidate: &TrailingCandidate) -> bool {
309    let dash_pos = candidate.dash_pos;
310
311    // Scan backward from the dash to find the preceding alphanumeric token
312    let mut prefix_start = dash_pos;
313    while prefix_start > 0 && title.as_bytes()[prefix_start - 1].is_ascii_alphanumeric() {
314        prefix_start -= 1;
315    }
316
317    if prefix_start < dash_pos {
318        let full_token = &title[prefix_start..candidate.full_end];
319        for &bl in BLACKLIST_FULL {
320            if bl.eq_ignore_ascii_case(full_token) {
321                return true;
322            }
323        }
324    }
325
326    // Single-segment candidates against the single-segment blacklist
327    // Sonarr: `-ES`, `-EN`, `-CAT`
328    if !candidate.group.contains('-') {
329        for &bl in BLACKLIST_SINGLE_SEGMENT {
330            if bl.eq_ignore_ascii_case(&candidate.group) {
331                return true;
332            }
333        }
334    }
335
336    // Date pattern: `[ ._]\d{4}-\d{2}` — reject YYYY-MM at end.
337    // Also catches YYYY-MM-DD when the candidate is MM-DD (2-segment).
338    static DATE_PATTERN: Lazy<Regex> =
339        Lazy::new(|| Regex::new(r"\d{4}-\d{2}$").expect("DATE_PATTERN"));
340    if DATE_PATTERN.is_match(&title[..candidate.full_end]) {
341        return true;
342    }
343    // Check if the full span ending at `full_end` is part of a YYYY-MM-DD
344    // sequence where our candidate captured the MM-DD portion.
345    static FULL_DATE_PATTERN: Lazy<Regex> =
346        Lazy::new(|| Regex::new(r"\d{4}-\d{1,2}-\d{2}$").expect("FULL_DATE_PATTERN"));
347    if FULL_DATE_PATTERN.is_match(&title[..candidate.full_end]) {
348        return true;
349    }
350
351    // Reject bare 2-digit candidates (e.g. `-02` from a date)
352    // Sonarr: `-\d{2}` in the lookbehind list
353    static TWO_DIGIT: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\d{2}$").expect("TWO_DIGIT"));
354    if TWO_DIGIT.is_match(&candidate.group) {
355        return true;
356    }
357
358    false
359}
360
361/// Trailing `[group]` at end of title: `[-._ ]\[([A-Za-z0-9]+)\]$`
362static TRAILING_BRACKET_GROUP_REGEX: Lazy<Regex> = Lazy::new(|| {
363    Regex::new(r"[-._ ]\[(?P<releasegroup>[a-zA-Z0-9]+)\]$").expect("TRAILING_BRACKET_GROUP_REGEX")
364});
365
366// ---------------------------------------------------------------------------
367// Exception context-bounded search (step 6)
368// ---------------------------------------------------------------------------
369
370/// Regex for the 25 exception groups. Sonarr requires:
371///   - lookbehind `(?<=[._ \[])` — char before must be `.`, ` `, `_`, or `[`
372///   - lookahead `(?=\]|\))` — char after must be `]` or `)`
373///
374/// We include the preceding char in the match and extract only the named group.
375static EXCEPTION_RELEASE_GROUP_REGEX: Lazy<Regex> = Lazy::new(|| {
376    let alts: Vec<String> = EXCEPTION_GROUPS.iter().map(|&g| regex::escape(g)).collect();
377    let pattern = format!(r"(?i)[._ \[](?P<releasegroup>{})[)\]]", alts.join("|"));
378    Regex::new(&pattern).expect("EXCEPTION_RELEASE_GROUP_REGEX")
379});
380
381// ---------------------------------------------------------------------------
382// Public API
383// ---------------------------------------------------------------------------
384
385/// Extract the release group from a release title.
386///
387/// This is a standalone post-cascade function — it operates on the ORIGINAL
388/// un-preprocessed title, not the `simple_title` from the regex cascade.
389///
390/// Returns `None` when no release group is found, the group is purely numeric,
391/// or the group matches the invalid-group pattern (e.g. `S01`, hex hashes).
392pub fn parse_release_group(title: &str) -> Option<String> {
393    // Step 1: Trim + strip file extension
394    let title = title.trim();
395    if title.is_empty() {
396        return None;
397    }
398    let mut title = normalize::remove_file_extension(title);
399
400    // Step 2: Apply Chinese-fansub PreSubstitutionRegex (first match wins)
401    for (re, replacement) in PRE_SUB_REGEXES.iter() {
402        if re.is_match(&title) {
403            title = re.replace(&title, *replacement).into_owned();
404            break;
405        }
406    }
407
408    // Step 3: Strip website prefix + torrent tracker suffix
409    title = crate::episode::regexes::WEBSITE_PREFIX_REGEX
410        .replace(&title, "")
411        .into_owned();
412    title = crate::episode::regexes::CLEAN_TORRENT_SUFFIX_REGEX
413        .replace(&title, "")
414        .into_owned();
415
416    // Step 4: Try anime [SubGroup]
417    if let Some(caps) = ANIME_RELEASE_GROUP_REGEX.captures(&title)
418        && let Some(m) = caps.name("subgroup")
419    {
420        let subgroup = m.as_str().trim();
421        if !subgroup.is_empty() {
422            return Some(subgroup.to_string());
423        }
424    }
425
426    // Step 5: Apply clean-suffix strip (AFTER anime check)
427    title = CLEAN_RELEASE_GROUP_REGEX.replace(&title, "").into_owned();
428
429    // Step 6a: Try exception groups (context-bounded, 25 entries)
430    // Last match wins (Sonarr uses `.OfType<Match>().Last()`)
431    let mut last_exception: Option<String> = None;
432    for caps in EXCEPTION_RELEASE_GROUP_REGEX.captures_iter(&title) {
433        if let Some(m) = caps.name("releasegroup") {
434            last_exception = Some(m.as_str().to_string());
435        }
436    }
437    if let Some(group) = last_exception {
438        return Some(group);
439    }
440
441    // Step 6b: Try exact exception groups (9 entries, no context boundary)
442    let mut last_exact: Option<String> = None;
443    for caps in EXCEPTION_EXACT_REGEX.captures_iter(&title) {
444        if let Some(m) = caps.name("releasegroup") {
445            last_exact = Some(m.as_str().to_string());
446        }
447    }
448    if let Some(group) = last_exact {
449        return Some(group);
450    }
451
452    // Step 7: Try trailing -GROUP extraction (hand-rolled)
453    //
454    // Two sub-strategies (Sonarr ORs them in one regex):
455    //   A) `-GROUP` or `-GROUP-PART2` with validation
456    //   B) Trailing `[group]` at end of title
457
458    // Strategy A: find all `-GROUP(-PART2)?` candidates
459    let candidates = find_trailing_group_candidates(&title);
460
461    // Iterate from last to first (last valid match wins in Sonarr)
462    for candidate in candidates.iter().rev() {
463        let group = &candidate.group;
464
465        // Must be followed by a valid boundary
466        if !has_valid_boundary(&title, candidate.full_end) {
467            continue;
468        }
469
470        // Reject if resolution appears anywhere after the candidate
471        if resolution_after(&title, candidate.full_end) {
472            continue;
473        }
474
475        // Reject if the candidate itself is a resolution token
476        if is_resolution(group) {
477            continue;
478        }
479
480        // Reject if the prefix-candidate context matches a blacklisted pattern
481        if context_is_blacklisted(&title, candidate) {
482            continue;
483        }
484
485        // Reject pure numeric groups
486        if group.parse::<i64>().is_ok() {
487            continue;
488        }
489
490        // Reject invalid pattern (S01, E15, 8-char hex)
491        if INVALID_RELEASE_GROUP_REGEX.is_match(group) {
492            continue;
493        }
494
495        return Some(group.clone());
496    }
497
498    // Strategy B: trailing `[group]` at end of title
499    if let Some(caps) = TRAILING_BRACKET_GROUP_REGEX.captures(&title)
500        && let Some(m) = caps.name("releasegroup")
501    {
502        let group = m.as_str();
503        if group.parse::<i64>().is_err() && !INVALID_RELEASE_GROUP_REGEX.is_match(group) {
504            return Some(group.to_string());
505        }
506    }
507
508    // Step 8: No group found
509    None
510}
511
512// ---------------------------------------------------------------------------
513// Unit tests
514// ---------------------------------------------------------------------------
515
516#[cfg(test)]
517mod unit_tests {
518    use super::*;
519
520    #[test]
521    fn trailing_group_candidate_search() {
522        let cases = find_trailing_group_candidates("Series.S01E01.720p.HDTV.x264-LOL");
523        assert!(
524            cases.iter().any(|c| c.group == "LOL"),
525            "should find LOL, got: {:?}",
526            cases.iter().map(|c| &c.group).collect::<Vec<_>>()
527        );
528
529        let cases2 =
530            find_trailing_group_candidates("SomeShow S01E168 1080p WEB-DL AAC 2.0 x264-Erai-raws");
531        assert!(
532            cases2.iter().any(|c| c.group == "Erai-raws"),
533            "should find Erai-raws with hyphen, got: {:?}",
534            cases2.iter().map(|c| &c.group).collect::<Vec<_>>()
535        );
536    }
537
538    #[test]
539    fn trailing_group_last_match_wins() {
540        let candidates =
541            find_trailing_group_candidates("Series-Title.S01E01.720p-FIRST.HDTV-SECOND");
542        let last = candidates.last().map(|c| c.group.as_str());
543        assert_eq!(last, Some("SECOND"), "last match should win");
544    }
545
546    #[test]
547    fn invalid_release_group_rejection() {
548        assert!(
549            INVALID_RELEASE_GROUP_REGEX.is_match("S01"),
550            "S01 should be invalid"
551        );
552        assert!(
553            INVALID_RELEASE_GROUP_REGEX.is_match("s02"),
554            "s02 should be invalid"
555        );
556        assert!(
557            INVALID_RELEASE_GROUP_REGEX.is_match("E15"),
558            "E15 should be invalid"
559        );
560        assert!(
561            INVALID_RELEASE_GROUP_REGEX.is_match("6B7FD717"),
562            "8-char hex should be invalid"
563        );
564        assert!(
565            INVALID_RELEASE_GROUP_REGEX.is_match("6b7fd717"),
566            "lowercase 8-char hex should be invalid"
567        );
568        assert!(
569            !INVALID_RELEASE_GROUP_REGEX.is_match("LOL"),
570            "LOL should be valid"
571        );
572    }
573
574    #[test]
575    fn blacklist_rejection() {
576        let title = "Series.S01E01.720p.WEB-DL.x264";
577        let candidates = find_trailing_group_candidates(title);
578        let dl_candidate = candidates.iter().find(|c| c.group == "DL");
579        assert!(dl_candidate.is_some(), "should find -DL candidate");
580        assert!(
581            context_is_blacklisted(title, dl_candidate.expect("dl candidate")),
582            "WEB-DL should be context-blacklisted"
583        );
584
585        let title2 = "Series.S01E01.DTS-HD.MA.5.1.x264";
586        let candidates2 = find_trailing_group_candidates(title2);
587        let hd_candidate = candidates2.iter().find(|c| c.group == "HD");
588        assert!(hd_candidate.is_some(), "should find -HD candidate");
589        assert!(
590            context_is_blacklisted(title2, hd_candidate.expect("hd candidate")),
591            "DTS-HD should be context-blacklisted"
592        );
593    }
594
595    #[test]
596    fn resolution_suffix_rejection() {
597        // "Series.720p.HDTV.x264-LOL.1080p" — position 25 is '.' after LOL
598        // From 25 onward: ".1080p" contains "1080p"
599        assert!(
600            resolution_after("Series.720p.HDTV.x264-LOL.1080p", 25),
601            "should detect 1080p after position 25"
602        );
603        // "Series.720p.HDTV.x264-LOL" — len=25, no content after
604        assert!(
605            !resolution_after("Series.720p.HDTV.x264-LOL", 25),
606            "no resolution after group at end"
607        );
608        assert!(is_resolution("1080p"), "1080p should be a resolution");
609        assert!(is_resolution("720p"), "720p should be a resolution");
610        assert!(!is_resolution("LOL"), "LOL should not be a resolution");
611    }
612}