breadarr/breadarrd/src/parser/mod.rs
Breadway 0f609aa4cc Fix bugs found by an Opus 5 audit: races, parsing, and CDATA gaps
- Atomic claim on review-queue approval, closing a double-approve race
  that could grab the same release twice (scheduler.rs)
- Constant-time comparison for the daemon API token, closing a timing
  side channel
- RSS items wrapped in CDATA (common for titles with '&') were
  silently dropped - only Event::Text was ever handled
- Reject malformed apibay info_hash values before building a magnet
  link that extract_btih can't parse back out
- Parse sizes with no space before the unit ("38.1GiB")
- Fix "Season N - NN" episode parsing and stop misreading a
  YYYY-MM-DD date as a bare episode range
- Query embeddings are no longer cached, fixing unbounded cache growth
  over the daemon's lifetime (only library-side candidates need caching)
2026-08-03 08:43:29 +08:00

373 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

mod tokens;
use regex::Regex;
use std::sync::LazyLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Source {
Hdtv,
WebRip,
WebDl,
BluRay,
Remux,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Codec {
H264,
Hevc,
Av1,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ParsedRelease {
pub title_raw: String,
pub title_normalized: String,
pub group: Option<String>,
pub season: Option<u32>,
pub episode: Option<u32>,
pub absolute_episode: Option<u32>,
pub year: Option<u32>,
pub resolution: Option<u32>,
pub source: Option<Source>,
pub codec: Option<Codec>,
pub bit_depth: Option<u8>,
pub container: Option<String>,
pub is_repack: bool,
}
pub fn parse(raw_title: &str) -> ParsedRelease {
let mut work = raw_title.to_string();
let group = tokens::extract_group(&work);
if group.is_some() {
work = tokens::GROUP_PREFIX_RE
.replace(&work, "")
.trim()
.to_string();
}
let container = tokens::extract_container(&work);
let resolution = tokens::extract_resolution(&work);
let source = tokens::extract_source(&work);
let codec = tokens::extract_codec(&work);
let bit_depth = tokens::extract_bit_depth(&work);
let is_repack = tokens::REPACK_RE.is_match(&work);
let year = tokens::extract_year(&work);
let (season, episode, absolute_episode, title_span_end) = tokens::extract_episode_info(&work);
let title_normalized = tokens::derive_title(&work, title_span_end);
ParsedRelease {
title_raw: raw_title.to_string(),
title_normalized,
group,
season,
episode,
absolute_episode,
year,
resolution,
source,
codec,
bit_depth,
container,
is_repack,
}
}
pub(crate) static WS_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").unwrap());
#[cfg(test)]
mod tests {
use super::*;
const CORPUS: &[&str] = &[
"Aime ton prochain (01-06) (Chida) (2019) [Digital-1920] [Manga FR] (PapriKa+)",
"[ANi] 小書痴的下剋上 為了成為圖書管理員不擇手段!領主的養女 - 13 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]",
"Ascendance of a Bookworm S04E11 The Gathering of Gutenberg 1080p CR WEB-DL DUAL AAC2.0 H 264-VARYG (Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen, Dual-Audio, Multi-Subs)",
"Ascendance of a Bookworm S04E12 The Winter Social Season and Debut 1080p CR WEB-DL DUAL AAC2.0 H 264-VARYG (Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen, Dual-Audio, Multi-Subs)",
"Ascendance of a Bookworm S04E13 VOSTFR 1080p WEB x264 AAC -Tsundere-Raws (CR) (Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen - Ryoushu no Youjo,Ascendance of a Bookworm: Adopted Daughter of an Archduke)",
"Ascendance of a Bookworm S04E13 VOSTFR 720p WEB x264 AAC -Tsundere-Raws (CR) (Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen - Ryoushu no Youjo,Ascendance of a Bookworm: Adopted Daughter of an Archduke)",
"Ascendance of a Bookworm S04E13 Winter Material Gathering 1080p CR WEB-DL AAC2.0 H 264-VARYG (Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen, Multi-Subs)",
"Assassin's Creed - Blade of Shao Jun (01-04) (Kurata) (2020) [Digital-1920] [Manga FR] (PapriKa+)",
"[Erai-raws] Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen - Ryushu no Youjo - 13 [1080p CR WEB-DL AVC AAC][MultiSub][18261A02]",
"[Erai-raws] Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen - Ryushu no Youjo - 13 [480p CR WEB-DL AVC AAC][MultiSub][A22FE68E]",
"[Erai-raws] Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen - Ryushu no Youjo - 13 [720p CR WEB-DL AVC AAC][MultiSub][4D409859]",
"[GM-Team][国漫][仙逆][Renegade Immortal][2023][148][AVC][GB][1080P]",
"[GM-Team][国漫][光阴之外][Beyond Time's Gaze][2025][29][GB][4K HEVC 10Bit]",
"[GM-Team][国漫][吞噬星空][Swallowed Star][2021][231][AVC][GB][1080P]",
"[GM-Team][国漫][斗破苍穹 第5季][Fights Break Sphere ][2022][206][AVC][GB][1080P]",
"[GM-Team][国漫][斗破苍穹 第5季][Fights Break Sphere ][2022][206][HEVC][GB][4K]",
"[GM-Team][国漫][牧神记][Tales of Qin Mu][2024][90][AVC][GB][1080P]",
"KILL BLUE S01E12 Life Paths 1080p AMZN WEB-DL MULTi DDP2.0 H 264-VARYG (Kill Ao, Multi-Audio, Multi-Subs)",
"[LoliHouse] 与奔驰于透明之夜的你,谈一场看不见的恋爱。 / 透明な夜に駆ける君と、目に見えない恋をした。 / KakeKoi - 01 [WebRip 1080p HEVC-10bit AAC][简繁内封字幕]",
"RILAKKUMA S01E13 1080p CR WEB-DL MULTi AAC2.0 H 264-VARYG (Multi-Audio, Multi-Subs)",
"[RUBaDUB] Kaiju No. 8 (S1 Complete) (1080p) (Dual Audio)",
"[SubsPlease] Honzuki no Gekokujou S4 - 13 (1080p) [A4FE0990].mkv",
"[SubsPlease] Honzuki no Gekokujou S4 - 13 (480p) [17152F60].mkv",
"[SubsPlease] Honzuki no Gekokujou S4 - 13 (720p) [97744C2D].mkv",
"That Time I Got Reincarnated as a Slime S04E12 Tempest Evolves 1080p CR WEB-DL DUAL AAC2.0 H 264-VARYG (Tensei shitara Slime Datta Ken, Dual-Audio, Multi-Subs)",
"The Drops of God S01E12 Take the Distant Path 1080p CR WEB-DL DUAL AAC2.0 H 264-VARYG (Kami no Shizuku, Dual-Audio, Multi-Subs)",
"[ToonsHub] Ascendance of a Bookworm S04E11 1080p CR WEB-DL DUAL AAC2.0 H.264 (Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen, Dual-Audio, Multi-Subs)",
"[ToonsHub] Ascendance of a Bookworm S04E12 1080p CR WEB-DL DUAL AAC2.0 H.264 (Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen, Dual-Audio, Multi-Subs)",
"[ToonsHub] Ascendance of a Bookworm S04E13 1080p CR WEB-DL AAC2.0 H.264 (Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen, Multi-Subs)",
"[ToonsHub] Honzuki no Gekokujou S04E13 1080p AMZN WEB-DL DDP2.0 H.264 (Ascendance of a Bookworm Side Story)",
"[Yameii] Ascendance of a Bookworm - S04E11 [English Dub] [CR WEB-DL 1080p H264 AAC] [8ACE7B72] (Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan o Erande Iraremasen - Ryoushu no Youjo | Adopted Daughter of an Archduke)",
"[Yameii] Ascendance of a Bookworm - S04E12 [English Dub] [CR WEB-DL 1080p H264 AAC] [ACE19921] (Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan o Erande Iraremasen - Ryoushu no Youjo | Adopted Daughter of an Archduke)",
"[桜都字幕组] 入间同学入魔了 第四季 / Mairimashita! Iruma-kun (2026) [13][1080P][简体内嵌]",
"[桜都字幕组] 入间同学入魔了 第四季 / Mairimashita! Iruma-kun (2026) [13][1080P][简繁内封]",
"[桜都字幕组] 入间同学入魔了 第四季 / Mairimashita! Iruma-kun (2026) [13][1080P][繁体内嵌]",
"[桜都字幕组] 入间同学入魔了 第四季 / Mairimashita! Iruma-kun (2026) [14][1080P][简繁内封]",
];
#[test]
fn parses_standard_sxxexx_with_group_and_quality_chain() {
let p = parse("Ascendance of a Bookworm S04E11 The Gathering of Gutenberg 1080p CR WEB-DL DUAL AAC2.0 H 264-VARYG (Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen, Dual-Audio, Multi-Subs)");
assert_eq!(p.season, Some(4));
assert_eq!(p.episode, Some(11));
assert_eq!(p.resolution, Some(1080));
assert_eq!(p.source, Some(Source::WebDl));
assert_eq!(p.codec, Some(Codec::H264));
assert_eq!(p.title_normalized, "Ascendance of a Bookworm");
}
#[test]
fn parses_sxxexx_with_a_trailing_fansub_revision_tag() {
// "v2" glued directly onto the episode number ("a fixed re-release
// of this episode") previously broke the word-boundary check
// entirely, leaving season/episode both None.
let p = parse("[Judas] Chainsaw Man - S01E01v2 1080p WEB-DL");
assert_eq!(p.season, Some(1));
assert_eq!(p.episode, Some(1));
}
#[test]
fn parses_subsplease_dash_episode_with_group_and_hash() {
let p = parse("[SubsPlease] Honzuki no Gekokujou S4 - 13 (1080p) [A4FE0990].mkv");
assert_eq!(p.group.as_deref(), Some("SubsPlease"));
assert_eq!(p.season, Some(4));
assert_eq!(p.episode, Some(13));
assert_eq!(p.resolution, Some(1080));
assert_eq!(p.container.as_deref(), Some("mkv"));
assert_eq!(p.title_normalized, "Honzuki no Gekokujou");
}
#[test]
fn parses_erai_raws_bracket_quality_block() {
let p = parse("[Erai-raws] Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen - Ryushu no Youjo - 13 [1080p CR WEB-DL AVC AAC][MultiSub][18261A02]");
assert_eq!(p.group.as_deref(), Some("Erai-raws"));
assert_eq!(p.season, None);
assert_eq!(p.episode, Some(13));
assert_eq!(p.resolution, Some(1080));
assert_eq!(p.source, Some(Source::WebDl));
}
#[test]
fn parses_lolihouse_webrip_hevc_10bit() {
let p = parse("[LoliHouse] Some Title - 01 [WebRip 1080p HEVC-10bit AAC][Subs]");
assert_eq!(p.group.as_deref(), Some("LoliHouse"));
assert_eq!(p.episode, Some(1));
assert_eq!(p.source, Some(Source::WebRip));
assert_eq!(p.codec, Some(Codec::Hevc));
assert_eq!(p.bit_depth, Some(10));
}
#[test]
fn parses_season_pack_no_episode() {
let p = parse("[RUBaDUB] Kaiju No. 8 (S1 Complete) (1080p) (Dual Audio)");
assert_eq!(p.group.as_deref(), Some("RUBaDUB"));
assert_eq!(p.season, Some(1));
assert_eq!(p.episode, None);
assert_eq!(p.resolution, Some(1080));
}
#[test]
fn parses_season_pack_shapes_without_literal_parens() {
// Real review-queue entries that all failed to resolve at all
// before this fix — season came back None, not just unmatched
// episode — because SEASON_PACK_RE required a literal
// "(S?N Complete)" shape.
assert_eq!(
parse("Modern.Family.S10.COMPLETE.720p.AMZN.WEBRip.x264-GalaxyTV").season,
Some(10)
);
assert_eq!(
parse("Modern Family 2009 Season 8 Complete 720p AMZN WEBRip x264 [i_c]").season,
Some(8)
);
assert_eq!(
parse("Red.Dwarf.S04.1080p.BluRay.x264-LATENCY [Season 4 Four Complete]").season,
Some(4)
);
assert_eq!(
parse("Red.Dwarf.S11.1080p.BluRay.x264-SHORTBREHD [Season 11 Eleven]").season,
Some(11)
);
assert_eq!(
parse("Game of Thrones - Season 8 S08 - 2019 1080p Bluray AAC5.1 x264-R").season,
Some(8)
);
}
// Regression test for a real gap found in review: "Season 2 - 25" was
// first swallowed whole by `looks_like_episode_range` (its own
// `BARE_EPISODE_RANGE_RE` skips over the un-matchable "Season" word and
// finds its first real match at "2 - 25", mistaking the season marker's
// own number for a range start), and even with that fixed,
// `extract_episode_info`'s `SEASON_PACK_RE` branch used to return
// season-only and never look for a trailing episode number at all.
// Either bug alone drops episode 25 silently.
#[test]
fn parses_a_season_marker_followed_by_a_dash_episode() {
let p = parse("[Erai-raws] Some Show Season 2 - 25 [1080p]");
assert_eq!(p.season, Some(2));
assert_eq!(p.episode, Some(25));
}
// Companion case: a genuine season-only pack (no trailing dash-episode
// anywhere) must still resolve to season-only, not spuriously pick up
// an unrelated number as an episode.
#[test]
fn a_genuine_season_only_pack_with_no_dash_episode_still_has_no_episode() {
let p = parse("Some Show Season 2 Complete [1080p]");
assert_eq!(p.season, Some(2));
assert_eq!(p.episode, None);
}
// Regression test for a real gap found in review: a date-named release
// ("2024-01-15") got misread by `BARE_EPISODE_RANGE_RE` as an episode
// range — the 4-digit year is too many digits for `\d{1,3}` to match
// whole, so its first real match starts at the month/day pair
// ("01-15") instead, and `looks_like_episode_range` treats that as a
// real range. Asserted directly against the tokenizer rather than
// `parse()`, since a false-positive range and a genuine "no episode
// marker at all" both surface identically as `None`/`None` on
// `ParsedRelease` — `looks_like_episode_range` returning `false` is the
// actual fix being tested here.
#[test]
fn does_not_mistake_a_yyyy_mm_dd_date_for_an_episode_range() {
assert!(!tokens::looks_like_episode_range("Some Daily Show 2024-01-15 1080p WEB-DL"));
let p = parse("Some Daily Show 2024-01-15 1080p WEB-DL");
assert_eq!(p.season, None);
assert_eq!(p.episode, None);
}
#[test]
fn parses_yameii_dash_sxxexx_with_english_dub_tag() {
let p = parse("[Yameii] Ascendance of a Bookworm - S04E11 [English Dub] [CR WEB-DL 1080p H264 AAC] [8ACE7B72] (Honzuki no Gekokujou)");
assert_eq!(p.group.as_deref(), Some("Yameii"));
assert_eq!(p.season, Some(4));
assert_eq!(p.episode, Some(11));
assert_eq!(p.codec, Some(Codec::H264));
}
#[test]
fn parses_year_and_bare_episode_number() {
let p = parse("[GM-Team][Renegade Immortal][2023][148][AVC][GB][1080P]");
assert_eq!(p.year, Some(2023));
assert_eq!(p.resolution, Some(1080));
assert_eq!(p.codec, Some(Codec::H264).or(Some(Codec::H264))); // AVC == H264 family
}
/// A batch of real, unmodified nyaa.si release titles pulled live
/// across several categories — not hand-picked for parseability. Not a
/// correctness check (no ground truth for the messier CJK/bracket-only
/// shapes), just a robustness net: parse() must never panic, and on
/// the common WEB-DL/anime shapes it should extract *something*.
#[test]
fn does_not_panic_on_real_corpus() {
let mut resolution_hits = 0;
for title in CORPUS {
let p = parse(title);
if p.resolution.is_some() {
resolution_hits += 1;
}
assert!(!p.title_normalized.is_empty(), "empty title for {title:?}");
}
// Most of this corpus carries an explicit resolution tag; a low hit
// rate would mean the resolution regex regressed, not just that a
// few CJK/manga edge cases were missed.
assert!(
resolution_hits * 2 > CORPUS.len(),
"only {resolution_hits}/{} titles yielded a resolution",
CORPUS.len()
);
}
#[test]
fn refuses_to_resolve_a_bracketed_batch_range_to_one_episode() {
// Previously resolved to episode 12, silently attributing the
// whole 12-episode batch torrent to one guessed episode.
let p = parse("[Judas] Some Show (01-12) [BD 1080p]");
assert_eq!(p.episode, None);
assert_eq!(p.absolute_episode, None);
}
#[test]
fn refuses_to_resolve_a_dash_prefixed_batch_range_to_one_episode() {
// Previously resolved to episode 1 (the first number after the
// leading dash), same underlying problem as above.
let p = parse("Show - 01-12 [1080p]");
assert_eq!(p.episode, None);
assert_eq!(p.absolute_episode, None);
}
#[test]
fn refuses_to_resolve_an_sxxexx_range_to_one_episode() {
// Previously resolved to S01E01 via SXXEXX_RE matching only the
// first half of the range.
let p = parse("Show S01E01-E12 1080p WEB-DL");
assert_eq!(p.season, Some(1));
assert_eq!(p.episode, None);
assert_eq!(p.absolute_episode, None);
}
#[test]
fn single_episode_dash_titles_are_unaffected_by_range_detection() {
// A genuine single episode using dash notation must still resolve
// normally — only real ranges should be refused.
let p = parse("[SubsPlease] Some Show - 05 (1080p) [ABCD1234].mkv");
assert_eq!(p.episode, Some(5));
assert_eq!(p.absolute_episode, Some(5));
}
#[test]
fn extracts_a_bare_year_from_a_scene_style_movie_name() {
// Previously parsed as year=None entirely, since these releases
// never bracket the year — silently disabling movie_year_mismatch,
// the only defense against a same-named-but-wrong film, on exactly
// the naming convention TPB/1337x results actually use.
let p = parse("Dune.1984.1080p.BluRay.x264-GROUP");
assert_eq!(p.year, Some(1984));
let p = parse("Dune 1984 1080p BluRay");
assert_eq!(p.year, Some(1984));
}
#[test]
fn does_not_mistake_a_year_titled_movie_for_a_bare_year() {
// "1917" and "2012" are real movie titles — a bare year at the very
// start of the string must not be treated as a release year, or
// the title would be mistaken for empty.
let p = parse("1917 1080p BluRay x264-GROUP");
assert_eq!(p.year, None);
}
#[test]
fn brackets_still_take_priority_over_a_coincidental_bare_year() {
let p = parse("Some.Show.2024.S01E01.(2023).1080p.WEB-DL");
assert_eq!(p.year, Some(2023));
}
#[test]
fn does_not_panic_on_unparsable_manga_release() {
// Not a video release at all — should degrade gracefully, not crash.
let p =
parse("Aime ton prochain (01-06) (Chida) (2019) [Digital-1920] [Manga FR] (PapriKa+)");
assert_eq!(p.year, Some(2019));
// season/episode extraction is allowed to miss here; the important
// thing is it returns *something* rather than panicking.
let _ = p.season;
}
}