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
133pub 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
152pub 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
160pub 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
169pub 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
194pub fn localization_badge(title: &str) -> String {
196 localization_info(Some(title)).badge.to_string()
197}
198
199pub fn localization_rank(title: &str) -> i32 {
201 localization_rank_with_mode(title, false)
202}
203
204pub 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
236pub 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
278pub 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
302pub 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
315pub 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
367pub 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
423pub 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
602pub fn localization_info_lenient(title: Option<&str>) -> LocalizationInfo {
604 localization_info_with_mode(title, true)
605}
606
607pub 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}