Files
arr/crates/arr-parse/src/lib.rs
T
naps62-yolo ffb8485939
ci / rust (push) Failing after 1m43s
ci / web (push) Successful in 58s
e2e / e2e (push) Successful in 1m25s
Import pipeline: probe, hardlink, rename, layout (#82)
2026-08-22 23:21:30 +01:00

585 lines
18 KiB
Rust

//! Release name parsing.
//!
//! Everything extracted here is a *claim*, not a fact (`DESIGN.md` §5.6):
//! release names lie or omit. Claims drive pre-grab filtering and scoring
//! only. Post-download truth comes from `ffprobe` and lives in different
//! types downstream — never mix the two in one struct.
//!
//! Parsing never fails: a malformed name yields a partial [`NameClaims`],
//! worst case an empty one.
mod episodes;
mod markers;
use serde::{Deserialize, Serialize};
use crate::markers::{Marker, Strength};
/// Claimed video resolution. Ordered worst to best.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum Resolution {
/// 480p or 480i.
#[serde(rename = "480p")]
P480,
/// 576p or 576i.
#[serde(rename = "576p")]
P576,
/// 720p.
#[serde(rename = "720p")]
P720,
/// 1080p or 1080i.
#[serde(rename = "1080p")]
P1080,
/// 2160p, also claimed by `4K` and `UHD` markers.
#[serde(rename = "2160p")]
P2160,
}
/// Claimed source tier. Ordered worst to best per `DESIGN.md` §5.5; the
/// bottom four are hard filters there, not low scores.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum Source {
/// Camcorder in a cinema.
Cam,
/// Telesync.
Telesync,
/// Telecine.
Telecine,
/// Screener copy.
Screener,
/// DVD or a DVD rip.
Dvd,
/// Over-the-air capture.
Hdtv,
/// Re-encoded streaming capture.
WebRip,
/// Untouched streaming download.
WebDl,
/// `BluRay` encode.
BluRay,
/// Untouched `BluRay` streams.
Remux,
}
/// Claimed video codec.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Codec {
/// H.264 / AVC.
X264,
/// H.265 / HEVC.
X265,
/// AV1.
Av1,
/// `XviD` / `DivX`.
Xvid,
}
/// Claimed HDR format markers. A name may carry several (`DV HDR10`), and a
/// `DV` claim says nothing about the profile — that is `ffprobe`'s job
/// (`DESIGN.md` §5.3).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum HdrMarker {
/// `DV`, `DoVi`, `Dolby Vision`.
DolbyVision,
/// HDR10+.
Hdr10Plus,
/// HDR10.
Hdr10,
/// Generic `HDR` with no format named.
Hdr,
/// Hybrid log-gamma.
Hlg,
/// Explicitly SDR.
Sdr,
}
/// Claimed language markers. `PtBr` and `Dual` are the first of the three
/// pt-BR detection signals in `DESIGN.md` §5.2; interpretation belongs to
/// the policy engine, this crate only reports what the name says.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum LanguageMarker {
/// `PT-BR`, `Dublado`, `Nacional`.
PtBr,
/// `PT-PT`.
PtPt,
/// `Portuguese`/`POR`/`PT` — flavour unknown.
Portuguese,
/// English.
English,
/// French.
French,
/// German.
German,
/// Spanish, including `Latino` and `Castellano`.
Spanish,
/// Italian.
Italian,
/// `MULTi` — several audio languages advertised.
Multi,
/// `DUAL` / `Dual Áudio` — two audio tracks, a common pt-BR signal.
Dual,
}
/// Claimed edition markers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Edition {
/// Extended cut or edition.
Extended,
/// Director's cut.
DirectorsCut,
/// Theatrical cut.
Theatrical,
/// Unrated.
Unrated,
/// Uncut.
Uncut,
/// IMAX.
Imax,
/// Remastered.
Remastered,
/// Criterion release.
Criterion,
/// Special edition.
SpecialEdition,
/// `LIMITED` theatrical run.
Limited,
}
/// What a release name claims about which episodes it holds. A season pack
/// and one episode of that season carry the same series title and differ
/// only here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum EpisodeClaim {
/// One or more episodes of a single season: `S01E02`, `S01E02E03`,
/// `S01E02-E04`, `1x02`.
Episodes {
season: u32,
/// Claimed episode numbers, in order of appearance. Ranges are
/// expanded.
episodes: Vec<u32>,
},
/// A whole season, no episode number: `S01`, `Season 1`.
Season { season: u32 },
/// Several whole seasons: `S01-S03`, `Seasons 1-3`.
Seasons { first: u32, last: u32 },
/// A daily show identified by its air date: `2024.05.13`.
Daily { year: u16, month: u8, day: u8 },
}
impl EpisodeClaim {
/// Whether the release claims a whole season or more, rather than
/// named episodes.
#[must_use]
pub fn is_season_pack(&self) -> bool {
matches!(self, Self::Season { .. } | Self::Seasons { .. })
}
/// Whether the release claims several episodes in one file.
#[must_use]
pub fn is_multi_episode(&self) -> bool {
matches!(self, Self::Episodes { episodes, .. } if episodes.len() > 1)
}
/// Whether the claim covers one numbered episode.
#[must_use]
pub fn covers(&self, season: u32, episode: u32) -> bool {
match self {
Self::Episodes {
season: claimed,
episodes,
} => *claimed == season && episodes.contains(&episode),
Self::Season { .. } | Self::Seasons { .. } => self.covers_season(season),
Self::Daily { .. } => false,
}
}
/// Whether the claim covers any part of one season.
#[must_use]
pub fn covers_season(&self, season: u32) -> bool {
match self {
Self::Episodes {
season: claimed, ..
}
| Self::Season { season: claimed } => *claimed == season,
Self::Seasons { first, last } => (*first..=*last).contains(&season),
Self::Daily { .. } => false,
}
}
/// Whether the claim is exactly this air date.
#[must_use]
pub fn covers_date(&self, year: u16, month: u8, day: u8) -> bool {
matches!(
self,
Self::Daily {
year: claimed_year,
month: claimed_month,
day: claimed_day,
} if *claimed_year == year && *claimed_month == month && *claimed_day == day
)
}
}
/// What a release name claims about itself. Every field is unverified
/// (`DESIGN.md` §5.6); absence of a marker means nothing.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct NameClaims {
/// Title words before the year / first quality marker, if any survived.
pub title: Option<String>,
/// Claimed release year.
pub year: Option<u16>,
/// Claimed resolution.
pub resolution: Option<Resolution>,
/// Claimed source tier. `Remux` wins when both it and `BluRay` appear.
pub source: Option<Source>,
/// Claimed video codec.
pub codec: Option<Codec>,
/// Claimed HDR markers, in order of appearance, deduplicated.
pub hdr: Vec<HdrMarker>,
/// Claimed language markers, in order of appearance, deduplicated.
pub languages: Vec<LanguageMarker>,
/// Claimed editions, in order of appearance, deduplicated.
pub editions: Vec<Edition>,
/// Claimed release group.
pub group: Option<String>,
/// Claimed season, episodes or air date. `None` for anything that does
/// not carry a TV tag.
pub episode: Option<EpisodeClaim>,
}
#[derive(Debug, Clone)]
struct Token {
orig: String,
low: String,
}
/// Parse a release name into [`NameClaims`]. Never panics; unrecognised
/// input degrades to partial or empty claims.
#[must_use]
pub fn parse(name: &str) -> NameClaims {
let trimmed = name.trim();
let stripped = strip_extension(trimmed);
let (cleaned, bracket_group) = take_trailing_bracket_group(stripped);
let mut toks = tokenize(&cleaned);
strip_site_prefix(&mut toks);
let has_strong = first_strong_index(&toks).is_some();
let dash_group = take_dash_group(&mut toks, has_strong);
let first_strong = first_strong_index(&toks);
let limit = first_strong.unwrap_or(toks.len());
let year_positions: Vec<usize> = toks
.iter()
.enumerate()
.skip(1)
.filter(|(_, t)| markers::year_of(&t.low).is_some())
.map(|(i, _)| i)
.collect();
let year_idx = year_positions
.iter()
.rev()
.find(|&&i| i < limit)
.or_else(|| year_positions.first())
.copied();
let boundary = year_idx.map_or(limit, |y| y.min(limit));
let lows: Vec<&str> = toks.iter().map(|t| t.low.as_str()).collect();
let mut claims = NameClaims {
year: year_idx
.and_then(|i| toks.get(i))
.and_then(|t| markers::year_of(&t.low)),
group: dash_group.or(bracket_group),
episode: episodes::claim(&lows),
..NameClaims::default()
};
let mut title_toks: Vec<Token> = toks.get(..boundary).unwrap_or_default().to_vec();
strip_title_editions(&mut title_toks, &mut claims.editions);
if !title_toks.is_empty() {
let words: Vec<&str> = title_toks.iter().map(|t| t.orig.as_str()).collect();
claims.title = Some(words.join(" "));
}
let mut sources: Vec<Source> = Vec::new();
let mut i = boundary;
while i < toks.len() {
if Some(i) == year_idx || markers::is_season_episode(&toks[i].low) {
i += 1;
continue;
}
if let Some(next) = toks.get(i + 1) {
if Some(i + 1) != year_idx {
if let Some((m, _)) = markers::classify_pair(&toks[i].low, &next.low) {
apply_marker(&mut claims, &mut sources, m);
i += 2;
continue;
}
}
}
if let Some((m, _)) = markers::classify(&toks[i].low) {
apply_marker(&mut claims, &mut sources, m);
}
i += 1;
}
claims.source = if sources.contains(&Source::Remux) {
Some(Source::Remux)
} else {
sources.first().copied()
};
claims
}
/// Collapse a release name to the blacklist key (`DESIGN.md` §6.3): lowercase,
/// every run of non-alphanumerics as one dot. The same release reappearing
/// with different separators or a different infohash still matches.
#[must_use]
pub fn normalise(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut pending_gap = false;
for character in name.chars() {
if character.is_alphanumeric() {
if pending_gap && !out.is_empty() {
out.push('.');
}
pending_gap = false;
out.extend(character.to_lowercase());
} else {
pending_gap = true;
}
}
out
}
fn apply_marker(claims: &mut NameClaims, sources: &mut Vec<Source>, m: Marker) {
match m {
Marker::Resolution(r) => {
if claims.resolution.is_none() {
claims.resolution = Some(r);
}
}
Marker::Source(s) => {
if !sources.contains(&s) {
sources.push(s);
}
}
Marker::Codec(c) => {
if claims.codec.is_none() {
claims.codec = Some(c);
}
}
Marker::Hdr(h) => {
if !claims.hdr.contains(&h) {
claims.hdr.push(h);
}
}
Marker::Language(l) => {
if !claims.languages.contains(&l) {
claims.languages.push(l);
}
}
Marker::Edition(e) => {
if !claims.editions.contains(&e) {
claims.editions.push(e);
}
}
Marker::Audio | Marker::Junk => {}
}
}
/// Pop edition markers off the tail of the title region ("Movie Extended
/// 2024" carries an edition, not a longer title). Always leaves at least
/// one title token.
fn strip_title_editions(title_toks: &mut Vec<Token>, editions: &mut Vec<Edition>) {
loop {
let n = title_toks.len();
if n >= 3 {
if let (Some(a), Some(b)) = (title_toks.get(n - 2), title_toks.get(n - 1)) {
if let Some((Marker::Edition(e), _)) = markers::classify_pair(&a.low, &b.low) {
if !editions.contains(&e) {
editions.insert(0, e);
}
title_toks.truncate(n - 2);
continue;
}
}
}
if n >= 2 {
if let Some(last) = title_toks.get(n - 1) {
if let Some((Marker::Edition(e), _)) = markers::classify(&last.low) {
if !editions.contains(&e) {
editions.insert(0, e);
}
title_toks.truncate(n - 1);
continue;
}
}
}
break;
}
}
/// Index of the first token that unambiguously marks the end of the title:
/// a strong marker, a strong pair, or a season/episode tag.
fn first_strong_index(toks: &[Token]) -> Option<usize> {
for (i, tok) in toks.iter().enumerate() {
if markers::is_season_episode(&tok.low) {
return Some(i);
}
if let Some(next) = toks.get(i + 1) {
if let Some((_, Strength::Strong)) = markers::classify_pair(&tok.low, &next.low) {
return Some(i);
}
}
if let Some((_, Strength::Strong)) = markers::classify(&tok.low) {
return Some(i);
}
}
None
}
fn tokenize(s: &str) -> Vec<Token> {
s.split(|c: char| {
matches!(
c,
'.' | '_' | '[' | ']' | '(' | ')' | '{' | '}' | ',' | ';' | '!' | '?'
) || c.is_whitespace()
})
.filter(|t| !t.is_empty())
.map(|t| Token {
orig: t.to_string(),
low: t.to_lowercase(),
})
.collect()
}
/// Drop a leading `www.<site>.<tld>` tag.
fn strip_site_prefix(toks: &mut Vec<Token>) {
if toks.first().is_some_and(|t| t.low == "www") {
let tld = toks.iter().take(4).position(|t| {
matches!(
t.low.as_str(),
"com" | "net" | "org" | "to" | "io" | "me" | "cc"
)
});
if let Some(i) = tld {
toks.drain(..=i);
}
}
while toks
.first()
.is_some_and(|t| !t.orig.is_empty() && t.orig.chars().all(|c| c == '-'))
{
toks.remove(0);
}
}
/// Group from a trailing dash: `x265-GROUP`, `TrueHD.7.1-FGT`, or the
/// spaced form `... - GROUP`. Requires either a known marker left of the
/// dash or a strong marker elsewhere, so `Spider-Man` alone keeps its dash.
fn take_dash_group(toks: &mut Vec<Token>, has_strong: bool) -> Option<String> {
if toks.len() >= 2 && has_strong {
let sep = &toks[toks.len() - 2].orig;
let last = &toks[toks.len() - 1];
if !sep.is_empty() && sep.chars().all(|c| c == '-') && group_ok(&last.low) {
let g = last.orig.clone();
toks.truncate(toks.len() - 2);
return Some(g);
}
}
let last = toks.last()?;
if !last.orig.contains('-')
|| markers::classify(&last.low).is_some()
|| markers::is_season_episode(&last.low)
{
return None;
}
let (left, right) = last.orig.rsplit_once('-')?;
let left_low = left.to_lowercase();
let left_known = markers::classify(&left_low).is_some();
if !left.is_empty() && group_ok(&right.to_lowercase()) && (left_known || has_strong) {
let group = right.to_string();
let n = toks.len();
toks[n - 1] = Token {
orig: left.to_string(),
low: left_low,
};
return Some(group);
}
None
}
fn group_ok(low: &str) -> bool {
!low.is_empty()
&& low.len() <= 24
&& low.chars().any(|c| c.is_ascii_alphabetic())
&& markers::classify(low).is_none()
&& markers::year_of(low).is_none()
&& !markers::is_season_episode(low)
}
/// Group from a trailing bracket tag, YTS style: `Movie (2024) [1080p]
/// [YTS.MX]`. Bracketed chunks holding known markers are left in place for
/// the token scan.
fn take_trailing_bracket_group(s: &str) -> (String, Option<String>) {
const DENY: [&str; 4] = ["rartv", "eztv", "ettv", "tgx"];
let mut end = s.len();
loop {
let head = s[..end].trim_end_matches(|c: char| c.is_whitespace() || c == '.' || c == '-');
if !head.ends_with(']') {
break;
}
let Some(open) = head.rfind('[') else { break };
let content = &head[open + 1..head.len() - 1];
let low = content.to_lowercase();
let tokens_clean = low
.split(['.', '_', ' ', '-'])
.filter(|t| !t.is_empty())
.all(|t| {
markers::classify(t).is_none()
&& markers::year_of(t).is_none()
&& !markers::is_season_episode(t)
});
if !content.is_empty()
&& !content.contains(' ')
&& content.chars().any(|c| c.is_ascii_alphabetic())
&& !DENY.contains(&low.as_str())
&& tokens_clean
{
let mut out = String::with_capacity(s.len());
out.push_str(&s[..open]);
out.push_str(&s[head.len()..]);
return (out, Some(content.to_string()));
}
end = open;
}
(s.to_string(), None)
}
fn strip_extension(s: &str) -> &str {
if let Some((stem, ext)) = s.rsplit_once('.') {
let known = matches!(
ext.to_lowercase().as_str(),
"mkv"
| "mp4"
| "avi"
| "m4v"
| "mov"
| "wmv"
| "webm"
| "m2ts"
| "flv"
| "mpg"
| "mpeg"
| "iso"
| "divx"
);
if known && !stem.is_empty() {
return stem;
}
}
s
}