Skip to main content

stremio_addon_core/
filename.rs

1use crate::ranking::clean_title;
2use regex::Regex;
3use std::sync::LazyLock;
4
5const UNKNOWN_QUALITY: &str = "unknown";
6
7#[derive(Clone, Debug, Default, PartialEq, Eq)]
8pub struct ParsedFilename {
9    pub title: Option<String>,
10    pub year: Option<i32>,
11    pub season: Option<u32>,
12    pub episode: Option<u32>,
13    pub quality: Option<String>,
14    pub quality_rank: Option<i32>,
15    pub language: Option<String>,
16}
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub struct LocalizationInfo {
20    pub badge: &'static str,
21    pub description: &'static str,
22}
23
24struct FilenameView {
25    normalized: String,
26    spaced: String,
27}
28
29impl FilenameView {
30    fn new(value: &str) -> Self {
31        let normalized = value.to_lowercase();
32        let spaced = normalized
33            .chars()
34            .map(|char| if char.is_alphanumeric() { char } else { ' ' })
35            .collect::<String>();
36        Self { normalized, spaced }
37    }
38
39    fn has_token(&self, predicate: impl FnMut(&str) -> bool) -> bool {
40        self.spaced.split_whitespace().any(predicate)
41    }
42
43    fn contains_any(&self, needles: &[&str]) -> bool {
44        needles
45            .iter()
46            .any(|needle| self.normalized.contains(needle))
47    }
48}
49
50static YEAR_RE: LazyLock<Regex> = LazyLock::new(|| {
51    Regex::new(r"(?:^|[^\p{L}\p{N}])((?:19|20)\d{2})(?:[^\p{L}\p{N}]|$)").expect("valid regex")
52});
53
54static SEASON_EPISODE_RE: LazyLock<Regex> = LazyLock::new(|| {
55    Regex::new(r"(?iu)(?:^|[^\p{L}\p{N}])(?:(?:s|season\s*)(\d{1,2})[^\p{L}\p{N}]*(?:e|ep|episode\s*)(\d{1,3})|(\d{1,2})[^\p{L}\p{N}]*(?:x|×)[^\p{L}\p{N}]*(\d{1,3}))(?:[^\p{L}\p{N}]|$)")
56        .expect("valid regex")
57});
58static EPISODE_ONLY_RE: LazyLock<Regex> = LazyLock::new(|| {
59    Regex::new(r"(?iu)(?:^|[^\p{L}\p{N}])(?:e|ep|episode|#|part|pt)[^\p{L}\p{N}]*(\d{1,3})(?:[^\p{L}\p{N}]|$)").expect("valid regex")
60});
61
62static QUALITY_SIZE_RE: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
63    [
64        ("2160", "2160p"),
65        ("1080", "1080p"),
66        ("720", "720p"),
67        ("576", "576p"),
68        ("480", "480p"),
69    ]
70    .into_iter()
71    .map(|(size, label)| {
72        (
73            Regex::new(&format!(r"\b{size}p?\b")).expect("valid regex"),
74            label,
75        )
76    })
77    .collect()
78});
79
80static QUALITY_LABELS: &[QualityLabel] = &[
81    QualityLabel {
82        label: "2160p",
83        needles: &["2160", "4k", "uhd"],
84    },
85    QualityLabel {
86        label: "1080p",
87        needles: &["1080", "full hd", "fhd"],
88    },
89    QualityLabel {
90        label: "WEB-DL",
91        needles: &["web-dl", "webdl"],
92    },
93    QualityLabel {
94        label: "WEBRip",
95        needles: &["webrip"],
96    },
97    QualityLabel {
98        label: "BluRay",
99        needles: &["bluray", "blu-ray", "brrip", "bdrip"],
100    },
101    QualityLabel {
102        label: "HDRip",
103        needles: &["hdrip"],
104    },
105    QualityLabel {
106        label: "DVDRip",
107        needles: &["dvdrip"],
108    },
109    QualityLabel {
110        label: "KinoRip",
111        needles: &["kinorip", "kino rip"],
112    },
113    QualityLabel {
114        label: "HDCAM",
115        needles: &["hdcam", "hd cam"],
116    },
117    QualityLabel {
118        label: "CAMRip",
119        needles: &["camrip"],
120    },
121    QualityLabel {
122        label: "Telesync",
123        needles: &["telesync", "hdts", "hd-ts"],
124    },
125];
126
127#[derive(Clone, Copy)]
128struct QualityLabel {
129    label: &'static str,
130    needles: &'static [&'static str],
131}
132
133/// Parse filename metadata commonly used by adapters in one pass.
134pub fn parse_filename(filename: &str) -> ParsedFilename {
135    let view = FilenameView::new(filename);
136    let year = parse_year(filename);
137    let (season, episode) = parse_season_episode_parts(filename);
138    let quality = infer_quality_label(&view);
139    let quality_rank = quality.as_deref().and_then(release_quality_rank);
140
141    ParsedFilename {
142        title: parsed_title(filename, year, season),
143        year,
144        season,
145        episode,
146        quality,
147        quality_rank,
148        language: Some(localization_badge(filename)),
149    }
150}
151
152/// Parse a 4-digit year from a filename.
153pub fn parse_year(filename: &str) -> Option<i32> {
154    YEAR_RE
155        .captures(filename)
156        .and_then(|captures| captures.get(1))
157        .and_then(|value| value.as_str().parse().ok())
158}
159
160/// Parse season+episode, where both parts are present, or return None.
161pub fn parse_season_episode(filename: &str) -> Option<(u32, u32)> {
162    let (season, episode) = parse_season_episode_parts(filename);
163    match (season, episode) {
164        (Some(season), Some(episode)) => Some((season, episode)),
165        _ => None,
166    }
167}
168
169/// Parse season+episode as separate options, preserving Webshare compatibility.
170pub fn parse_season_episode_parts(filename: &str) -> (Option<u32>, Option<u32>) {
171    if let Some(caps) = SEASON_EPISODE_RE.captures(filename) {
172        if let (Some(season), Some(episode)) = (caps.get(1), caps.get(2)) {
173            return (season.as_str().parse().ok(), episode.as_str().parse().ok());
174        }
175        if let (Some(season), Some(episode)) = (caps.get(3), caps.get(4)) {
176            let episode = episode.as_str().parse().ok();
177            if matches!(episode, Some(264..=266)) {
178                return (None, None);
179            }
180            return (season.as_str().parse().ok(), episode);
181        }
182    }
183
184    if let Some(caps) = EPISODE_ONLY_RE.captures(filename) {
185        return (
186            Some(1),
187            caps.get(1).and_then(|value| value.as_str().parse().ok()),
188        );
189    }
190
191    (None, None)
192}
193
194/// Parse a human-friendly localization badge from title tokens.
195pub fn localization_badge(title: &str) -> String {
196    localization_info(Some(title)).badge.to_string()
197}
198
199/// Compute localization preference score.
200pub fn localization_rank(title: &str) -> i32 {
201    localization_rank_with_mode(title, false)
202}
203
204/// Preserve Webshare-style release quality ranking labels.
205pub fn release_quality_rank(label: &str) -> Option<i32> {
206    match label.to_ascii_lowercase().as_str() {
207        "2160p" => Some(400),
208        "1080p" => Some(300),
209        "720p" => Some(200),
210        "576p" => Some(150),
211        "480p" => Some(100),
212        "web-dl" => Some(80),
213        "webrip" => Some(80),
214        "bluray" => Some(80),
215        "brrip" => Some(80),
216        "bdrip" => Some(80),
217        "hdrip" => Some(70),
218        "dvdrip" => Some(60),
219        "kinorip" => Some(40),
220        "hdcam" => Some(30),
221        "camerip" => Some(30),
222        "camrip" => Some(30),
223        "telesync" => Some(20),
224        "hdts" => Some(20),
225        "original" => Some(1),
226        _ => {
227            if is_unknown_quality_value(label) || label == UNKNOWN_QUALITY {
228                Some(0)
229            } else {
230                None
231            }
232        }
233    }
234}
235
236/// Parse quality into rank by encoded resolution height.
237pub fn height_quality_rank(label: &str) -> i32 {
238    let normalized = label.trim().to_ascii_lowercase();
239    if normalized.is_empty() {
240        return 0;
241    }
242    if let Ok(height) = normalized.parse::<i32>() {
243        return height;
244    }
245    if normalized.ends_with('p') {
246        let raw = normalized.trim_end_matches('p');
247        if let Ok(height) = raw.parse::<i32>() {
248            return height;
249        }
250    }
251    if normalized.contains("2160") || normalized.contains("4k") || normalized.contains("uhd") {
252        return 2160;
253    }
254    if normalized.contains("1440") {
255        return 1440;
256    }
257    if normalized.contains("1080") || normalized.contains("full hd") || normalized.contains("fhd") {
258        return 1080;
259    }
260    if normalized.contains("720") {
261        return 720;
262    }
263    if normalized.contains("576") {
264        return 576;
265    }
266    if normalized.contains("480") {
267        return 480;
268    }
269    if normalized.contains("360") {
270        return 360;
271    }
272    if normalized.contains("hd") {
273        return 540;
274    }
275    1
276}
277
278/// Convert conversion quality values from an API into an internal quality label.
279pub fn quality_label_from_conversion(conversion_quality: &str, title: &str) -> String {
280    let trimmed = conversion_quality.trim();
281    let conversion_view = FilenameView::new(trimmed);
282    let title_view = FilenameView::new(title);
283    if trimmed.eq_ignore_ascii_case("original") {
284        return "original".to_string();
285    }
286
287    if is_unknown_value(trimmed) {
288        return infer_quality_label(&title_view).unwrap_or_else(unknown_quality);
289    }
290
291    if let Ok(height) = trimmed.parse::<u32>() {
292        if height > 0 {
293            return format!("{height}p");
294        }
295    }
296
297    infer_quality_label(&conversion_view)
298        .or_else(|| infer_quality_label(&title_view))
299        .unwrap_or_else(unknown_quality)
300}
301
302/// Reverse mapping for Webshare release quality ranks.
303pub fn quality_from_release_rank(rank: i32) -> Option<String> {
304    match rank {
305        400 => Some("2160p".to_string()),
306        300 => Some("1080p".to_string()),
307        200 => Some("720p".to_string()),
308        150 => Some("576p".to_string()),
309        100 => Some("480p".to_string()),
310        1 => Some("original".to_string()),
311        _ => None,
312    }
313}
314
315/// Rank mixed filename or stream quality text by resolution and release tier.
316pub fn media_quality_rank(value: &str) -> i32 {
317    let normalized = value.to_lowercase();
318    if contains_any(
319        &normalized,
320        &[
321            "kinorip", "kino rip", "camrip", "hdcam", "hd cam", "hd-ts", "hdts", "telesync", " ts ",
322        ],
323    ) {
324        return 240;
325    }
326    if normalized.contains(" cam ")
327        || normalized.starts_with("cam ")
328        || normalized.ends_with(" cam")
329    {
330        return 200;
331    }
332    if contains_any(&normalized, &["2160", "4k", "uhd"]) {
333        return 2160;
334    }
335    if normalized.contains("1440") {
336        return 1440;
337    }
338    if contains_any(&normalized, &["1080", "full hd", "fhd"]) {
339        return 1080;
340    }
341    if normalized.contains("720") {
342        return 720;
343    }
344    if normalized.contains("576") {
345        return 576;
346    }
347    if normalized.contains("480") {
348        return 480;
349    }
350    if contains_any(
351        &normalized,
352        &[
353            "web-dl", "webdl", "webrip", "bluray", "blu-ray", "brrip", "bdrip",
354        ],
355    ) {
356        return 700;
357    }
358    if contains_any(&normalized, &["hdrip", "dvdrip"]) {
359        return 600;
360    }
361    if normalized.contains("original") {
362        return 1;
363    }
364    0
365}
366
367/// Detect whether parsed titles look too generic for ranking.
368pub fn is_generic_parsed_title(title: &str) -> bool {
369    let tokens = title
370        .split_whitespace()
371        .map(|token| token.trim_matches(|token_char: char| !token_char.is_alphanumeric()))
372        .filter(|token| !token.is_empty())
373        .collect::<Vec<_>>();
374
375    !tokens.is_empty()
376        && tokens.iter().all(|token| {
377            matches!(
378                token.to_ascii_lowercase().as_str(),
379                "file"
380                    | "film"
381                    | "movie"
382                    | "video"
383                    | "stream"
384                    | "download"
385                    | "webshare"
386                    | "unknown"
387                    | "sample"
388                    | "episode"
389                    | "part"
390                    | "pt"
391                    | "sd"
392                    | "hd"
393                    | "fhd"
394                    | "uhd"
395                    | "480p"
396                    | "576p"
397                    | "720p"
398                    | "1080p"
399                    | "2160p"
400                    | "cz"
401                    | "cs"
402                    | "cze"
403                    | "sk"
404                    | "svk"
405                    | "en"
406                    | "eng"
407                    | "dab"
408                    | "dub"
409                    | "dabing"
410                    | "titulky"
411                    | "tit"
412                    | "sub"
413                    | "subs"
414                    | "avi"
415                    | "mkv"
416                    | "mov"
417                    | "mp4"
418                    | "webm"
419            )
420        })
421}
422
423/// Keep parsed title only when it is non-generic, else replace with matched query.
424pub fn parsed_title_for_ranking(
425    parsed_title: Option<String>,
426    matched_query: Option<&str>,
427) -> Option<String> {
428    match parsed_title {
429        Some(title) if is_generic_parsed_title(&title) => matched_query
430            .filter(|query| !query.is_empty())
431            .map(str::to_string)
432            .or(Some(title)),
433        other => other,
434    }
435}
436
437fn parsed_title(filename: &str, year: Option<i32>, season: Option<u32>) -> Option<String> {
438    let mut boundary = filename.len();
439    let filename_lower = filename.to_lowercase();
440    for needle in [
441        year.map(|year| year.to_string()),
442        season.map(|season| format!("s{season:02}")),
443        season.map(|season| format!("{season:02}x")),
444    ]
445    .into_iter()
446    .flatten()
447    {
448        if let Some(index) = filename_lower.find(&needle.to_lowercase()) {
449            boundary = boundary.min(index);
450        }
451    }
452
453    let candidate = clean_title(&filename[..boundary]);
454    (!candidate.is_empty()).then_some(candidate)
455}
456
457fn infer_quality_label(view: &FilenameView) -> Option<String> {
458    if view.has_token(|token| token == "original") {
459        return Some("original".to_string());
460    }
461
462    if let Some(label) = QUALITY_SIZE_RE
463        .iter()
464        .find(|(regex, _)| regex.is_match(&view.spaced))
465        .map(|(_, label)| label.to_string())
466    {
467        return Some(label);
468    }
469
470    if let Some(entry) = QUALITY_LABELS
471        .iter()
472        .find(|entry| view.contains_any(entry.needles))
473    {
474        return Some(entry.label.to_string());
475    }
476
477    if view.has_token(is_unknown_quality_value) || has_dash_unknown_marker(&view.normalized) {
478        return Some(UNKNOWN_QUALITY.to_string());
479    }
480
481    None
482}
483
484fn is_unknown_quality_value(value: &str) -> bool {
485    let normalized = value.trim().to_ascii_lowercase();
486    normalized.is_empty()
487        || matches!(
488            normalized.as_str(),
489            "0" | "unknown" | "unk" | "n/a" | "na" | "null" | "none" | "-"
490        )
491}
492
493fn is_unknown_value(value: &str) -> bool {
494    is_unknown_quality_value(value)
495}
496
497fn has_dash_unknown_marker(value: &str) -> bool {
498    value.contains(".-.") || value.contains(" - ") || value.contains("_-_")
499}
500
501fn unknown_quality() -> String {
502    UNKNOWN_QUALITY.to_string()
503}
504
505fn has_czech_dub_signal(view: &FilenameView) -> bool {
506    view.has_token(|token| {
507        matches!(
508            token,
509            "czdub" | "czdab" | "czdabing" | "czechdub" | "ceskydabing"
510        )
511    }) || view.contains_any(&["czdab", "cz-dab", "cz dab", "cz.dab"])
512        || (has_czech_language_marker(view)
513            && view.has_token(|token| matches!(token, "dabing" | "dab" | "dub")))
514        || view.has_token(|token| {
515            (token.starts_with("česk") || token.starts_with("cesk"))
516                && (view.spaced.contains("dabing") || view.spaced.contains("dab"))
517        })
518}
519
520fn has_czech_subtitle_signal(view: &FilenameView) -> bool {
521    has_czech_subtitle_signal_with_mode(view, false)
522}
523
524fn has_czech_subtitle_signal_lenient(view: &FilenameView) -> bool {
525    has_czech_subtitle_signal_with_mode(view, true)
526}
527
528fn has_czech_subtitle_signal_with_mode(view: &FilenameView, allow_generic: bool) -> bool {
529    view.has_token(|token| {
530        matches!(token, "cztit" | "cztitulky" | "czsub" | "czsubs")
531            || (allow_generic && matches!(token, "titulky" | "tit"))
532    }) || view.contains_any(&[
533        "cz tit",
534        "cz.tit",
535        "cz-tit",
536        "cz_tit",
537        "cz titulky",
538        "ceske titulky",
539        "české titulky",
540        "české titul",
541        "ceske.titul",
542    ])
543}
544
545fn has_slovak_dub_signal(view: &FilenameView) -> bool {
546    view.has_token(|token| {
547        matches!(
548            token,
549            "skdab" | "skdabing" | "slovakdub" | "slovenskydabing"
550        ) || token.starts_with("slovensk")
551            && (view.spaced.contains("dabing") || view.spaced.contains("dab"))
552    }) || view.contains_any(&["skdab", "sk-dab", "sk dab", "sk.dab"])
553}
554
555fn has_slovak_subtitle_signal(view: &FilenameView) -> bool {
556    view.has_token(|token| matches!(token, "sktit" | "sktitulky" | "sksub" | "sksubs"))
557        || view.contains_any(&[
558            "sk tit",
559            "sk.tit",
560            "sk-tit",
561            "sk_tit",
562            "sk titulky",
563            "slovenske titulky",
564            "slovenské titulky",
565            "slovenské titul",
566            "slovenske.titul",
567        ])
568}
569
570fn has_slovak_language_marker(view: &FilenameView) -> bool {
571    view.has_token(|token| {
572        matches!(
573            token,
574            "sk" | "svk" | "slovak" | "slovensky" | "slovenske" | "slovenska"
575        ) || token.starts_with("slovensk")
576    }) || view.contains_any(&["+sk", "sk+"])
577}
578
579fn has_czech_language_marker(view: &FilenameView) -> bool {
580    view.has_token(|token| {
581        matches!(
582            token,
583            "cz" | "cs" | "cze" | "czech" | "cesky" | "ceske" | "ceska"
584        ) || token.starts_with("česk")
585            || token.starts_with("cesk")
586    }) || view.contains_any(&["+cz", "cz+"])
587}
588
589fn has_english_signal(view: &FilenameView) -> bool {
590    view.has_token(|token| {
591        matches!(
592            token,
593            "en" | "eng" | "english" | "anglicky" | "angl" | "entit" | "ensub" | "ensubs"
594        )
595    }) || view.contains_any(&["+en", "en+"])
596}
597
598pub fn localization_info(title: Option<&str>) -> LocalizationInfo {
599    localization_info_with_mode(title, false)
600}
601
602/// Parse localization with Hellspy-compatible generic subtitle tokens.
603pub fn localization_info_lenient(title: Option<&str>) -> LocalizationInfo {
604    localization_info_with_mode(title, true)
605}
606
607/// Compute localization score with Hellspy-compatible generic subtitle tokens.
608pub fn localization_rank_lenient(title: &str) -> i32 {
609    localization_rank_with_mode(title, true)
610}
611
612fn localization_info_with_mode(
613    title: Option<&str>,
614    allow_generic_subtitles: bool,
615) -> LocalizationInfo {
616    let Some(title) = title else {
617        return unknown_localization();
618    };
619    let view = FilenameView::new(title);
620    let has_cz_dub = has_czech_dub_signal(&view);
621    let has_cz_subs = if allow_generic_subtitles {
622        has_czech_subtitle_signal_lenient(&view)
623    } else {
624        has_czech_subtitle_signal(&view)
625    };
626    let has_cz = has_cz_dub || has_cz_subs || has_czech_language_marker(&view);
627    let has_sk_dub = has_slovak_dub_signal(&view);
628    let has_sk_subs = has_slovak_subtitle_signal(&view);
629    let has_sk = has_sk_dub || has_sk_subs || has_slovak_language_marker(&view);
630    let has_en = has_english_signal(&view);
631
632    if has_cz_dub && has_en {
633        LocalizationInfo {
634            badge: "CZ/EN",
635            description: "CZ dabing / EN",
636        }
637    } else if has_cz_dub {
638        LocalizationInfo {
639            badge: "CZ",
640            description: "CZ dabing",
641        }
642    } else if has_cz_subs && has_en {
643        LocalizationInfo {
644            badge: "CZ TIT/EN",
645            description: "CZ titulky / EN",
646        }
647    } else if has_cz_subs {
648        LocalizationInfo {
649            badge: "CZ TIT",
650            description: "CZ titulky",
651        }
652    } else if has_cz && has_en {
653        LocalizationInfo {
654            badge: "CZ/EN",
655            description: "CZ/EN",
656        }
657    } else if has_cz {
658        LocalizationInfo {
659            badge: "CZ",
660            description: "CZ",
661        }
662    } else if has_sk_dub && has_en {
663        LocalizationInfo {
664            badge: "SK/EN",
665            description: "SK dabing / EN",
666        }
667    } else if has_sk_dub {
668        LocalizationInfo {
669            badge: "SK",
670            description: "SK dabing",
671        }
672    } else if has_sk_subs && has_en {
673        LocalizationInfo {
674            badge: "SK TIT/EN",
675            description: "SK titulky / EN",
676        }
677    } else if has_sk_subs {
678        LocalizationInfo {
679            badge: "SK TIT",
680            description: "SK titulky",
681        }
682    } else if has_sk && has_en {
683        LocalizationInfo {
684            badge: "SK/EN",
685            description: "SK/EN",
686        }
687    } else if has_sk {
688        LocalizationInfo {
689            badge: "SK",
690            description: "SK",
691        }
692    } else if has_en {
693        LocalizationInfo {
694            badge: "EN",
695            description: "EN",
696        }
697    } else {
698        unknown_localization()
699    }
700}
701
702fn localization_rank_with_mode(title: &str, allow_generic_subtitles: bool) -> i32 {
703    let view = FilenameView::new(title);
704    let has_cz_dub = has_czech_dub_signal(&view);
705    let has_cz_subs = if allow_generic_subtitles {
706        has_czech_subtitle_signal_lenient(&view)
707    } else {
708        has_czech_subtitle_signal(&view)
709    };
710    let has_cz = has_cz_dub || has_cz_subs || has_czech_language_marker(&view);
711    let has_sk_dub = has_slovak_dub_signal(&view);
712    let has_sk_subs = has_slovak_subtitle_signal(&view);
713    let has_sk = has_sk_dub || has_sk_subs || has_slovak_language_marker(&view);
714    let has_en = has_english_signal(&view);
715
716    if has_cz_dub && has_en {
717        5
718    } else if has_cz_dub || has_sk_dub {
719        4
720    } else if (has_cz_subs || has_sk_subs || has_cz || has_sk) && has_en {
721        3
722    } else if has_cz_subs || has_sk_subs || has_cz || has_sk {
723        2
724    } else if has_en {
725        1
726    } else {
727        0
728    }
729}
730
731fn contains_any(value: &str, needles: &[&str]) -> bool {
732    needles.iter().any(|needle| value.contains(needle))
733}
734
735fn unknown_localization() -> LocalizationInfo {
736    LocalizationInfo {
737        badge: "UNK",
738        description: "Unknown localization",
739    }
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745
746    #[test]
747    fn parses_movie_filename() {
748        let parsed = parse_filename("The.Matrix.1999.1080p.BluRay.CZ.mkv");
749        assert_eq!(parsed.title.as_deref(), Some("the matrix"));
750        assert_eq!(parsed.year, Some(1999));
751        assert_eq!(parsed.quality.as_deref(), Some("1080p"));
752        assert_eq!(parsed.quality_rank, Some(300));
753        assert_eq!(parsed.language.as_deref(), Some("CZ"));
754    }
755
756    #[test]
757    fn parses_series_and_ignores_x264_false_positive() {
758        let parsed = parse_filename("Show.Name.S01E02.1080p.x264.mkv");
759        assert_eq!(parsed.season, Some(1));
760        assert_eq!(parsed.episode, Some(2));
761
762        let movie = parse_filename("Movie.Name.2020.1080p.x264.mkv");
763        assert_eq!(movie.season, None);
764        assert_eq!(movie.episode, None);
765    }
766
767    #[test]
768    fn localization_info_returns_expected_rank() {
769        let local = localization_info(Some("Film [CZ DABING] WEB-DL.mkv"));
770        assert_eq!(local.badge, "CZ");
771        assert_eq!(local.description, "CZ dabing");
772
773        let local = localization_info(Some("Movie EN TITULKY.mkv"));
774        assert_eq!(local.badge, "EN");
775
776        let local = localization_info_lenient(Some("Movie EN TITULKY.mkv"));
777        assert_eq!(local.badge, "CZ TIT/EN");
778    }
779
780    #[test]
781    fn localization_parses_slovak_signals() {
782        assert_eq!(
783            parse_filename("Film.SK.DABING.WEB-DL.mkv")
784                .language
785                .as_deref(),
786            Some("SK")
787        );
788        assert_eq!(
789            parse_filename("Film.EN.SK.TITULKY.WEB-DL.mkv")
790                .language
791                .as_deref(),
792            Some("SK TIT/EN")
793        );
794        assert!(localization_rank("Film.SK.DABING.mkv") > localization_rank("Film.EN.mkv"));
795    }
796
797    #[test]
798    fn release_quality_ranking_remains_webshare() {
799        assert_eq!(release_quality_rank("1080p"), Some(300));
800        assert_eq!(release_quality_rank("kine"), None);
801    }
802
803    #[test]
804    fn quality_label_from_conversion_and_ranks() {
805        assert_eq!(
806            quality_label_from_conversion("1080", "Film (2026) CZtit KinoRip.mkv"),
807            "1080p"
808        );
809        assert_eq!(
810            quality_label_from_conversion("0", "Film.2026. WEB-DL.mkv"),
811            "WEB-DL"
812        );
813        assert_eq!(height_quality_rank("1080p"), 1080);
814        assert_eq!(height_quality_rank("h264"), 1);
815        assert_eq!(media_quality_rank("Film 1080p WEB-DL"), 1080);
816        assert_eq!(media_quality_rank("Film WEB-DL"), 700);
817        assert_eq!(media_quality_rank("Film KinoRip"), 240);
818        assert_eq!(quality_from_release_rank(80), None);
819    }
820
821    #[test]
822    fn parsed_title_with_generic_token() {
823        let parsed = parsed_title_for_ranking(Some("Movie".to_string()), Some("Alien"));
824        assert_eq!(parsed.as_deref(), Some("Alien"));
825        let parsed = parsed_title_for_ranking(Some("Great Film".to_string()), Some("Alien"));
826        assert_eq!(parsed.as_deref(), Some("Great Film"));
827    }
828
829    #[test]
830    fn parse_season_episode_with_simple_markers() {
831        assert_eq!(parse_season_episode("S01E02"), Some((1, 2)));
832        assert_eq!(parse_season_episode("01x10"), Some((1, 10)));
833        assert_eq!(parse_season_episode("E 03"), Some((1, 3)));
834    }
835}