//! 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, }, /// 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, /// Claimed release year. pub year: Option, /// Claimed resolution. pub resolution: Option, /// Claimed source tier. `Remux` wins when both it and `BluRay` appear. pub source: Option, /// Claimed video codec. pub codec: Option, /// Claimed HDR markers, in order of appearance, deduplicated. pub hdr: Vec, /// Claimed language markers, in order of appearance, deduplicated. pub languages: Vec, /// Claimed editions, in order of appearance, deduplicated. pub editions: Vec, /// Claimed release group. pub group: Option, /// Claimed season, episodes or air date. `None` for anything that does /// not carry a TV tag. pub episode: Option, } #[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 = 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 = 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 = 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, 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, editions: &mut Vec) { 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 { 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 { 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..` tag. fn strip_site_prefix(toks: &mut Vec) { 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, has_strong: bool) -> Option { 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) { 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 }