585 lines
18 KiB
Rust
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
|
|
}
|