Files
proton-stream/crates/pstr-core/src/naming.rs
T
nvrl 238ecd8220 fix(core,meta): a folder of films is one title per film
The four Rebuild of Evangelion films in one folder were one series:
`- 1.11` read as episode 1, and every file took the folder's name, so
the whole folder matched a single film. A dash followed by a decimal is
now part of the film's name, and a folder whose files are all
unnumbered, outside any grouping folder and each named differently
gives each file its own title.

AniList needs every query word in a title, and a film's version number
is not in it, so a search that finds nothing is retried without it.
All four films now match their own entries.
2026-10-27 19:50:26 +01:00

1334 lines
47 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.
//! Turning release filenames into titles, seasons and episodes.
//!
//! A pure function over a string, deliberately: no I/O, no state, no network.
//! That is what lets the whole thing be pinned by table-driven tests instead of
//! by squinting at a real library, which is the only way a parser this
//! heuristic stays honest as cases are added.
//!
//! Written rather than bound to `anitomy` because that is C++ and would poison
//! the cross-compilation story the app exists to have.
/// What a filename claims to be.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedName {
/// The series or film title, cleaned of groups, tags and separators.
pub title: String,
/// Season number, when the name states one. Absent is *not* season 1 — a
/// film has no season, and an absolute-numbered anime episode states none
/// either, so the caller decides what to do about it.
pub season: Option<u32>,
/// Episode number, when the name states one.
pub episode: Option<u32>,
/// Release year, from a parenthesised or bracketed four-digit group.
pub year: Option<u32>,
/// The episode's own name, when the filename states one after the
/// numbering (`S01E01-Mother and Children`). Never the quality and codec
/// tags that follow numbering in scene releases — see [`episode_title`].
pub episode_title: Option<String>,
}
impl ParsedName {
/// Whether this looks like one episode of a series rather than a film.
pub fn is_episode(&self) -> bool {
self.episode.is_some()
}
}
/// Parse a filename (with or without its extension) into a title and numbering.
///
/// Never fails: an unrecognisable name yields its cleaned-up self as the title
/// with nothing else filled in. A catalog with a wrong title is recoverable; one
/// that drops files it could not parse is not.
pub fn parse(file_name: &str) -> ParsedName {
let stem = strip_extension(file_name);
// Release-group prefixes (`[SubsPlease] …`) and trailing checksums
// (`… [A1B2C3D4]`) are noise in every convention that uses them, but a
// bracketed *year* is signal, so the year is read before they are dropped.
let bracketed = bracketed_year(stem);
let without_groups = strip_bracketed(stem);
// A bare year is only looked for when there is no bracketed one, and only
// in the stripped text, so its offset lines up with the title span below.
let bare = bracketed
.is_none()
.then(|| bare_year(&without_groups))
.flatten();
let year = bracketed.or(bare.map(|(year, _)| year));
let found = find_numbering(&without_groups);
// The title ends at the numbering, or — for a film, which has none — at the
// bare year, because everything past it is source and codec tags.
let title_end = found
.as_ref()
.map(|m| m.start)
.or(bare.map(|(_, at)| at))
.unwrap_or(without_groups.len());
let title = clean_title(&without_groups[..title_end], year);
let episode_title = found
.as_ref()
.and_then(|m| episode_title(&without_groups[m.end..]));
ParsedName {
title,
season: found.as_ref().and_then(|m| m.numbering.season),
episode: found.as_ref().and_then(|m| m.numbering.episode),
year,
episode_title,
}
}
#[derive(Debug, Clone, Copy)]
struct Numbering {
season: Option<u32>,
episode: Option<u32>,
}
/// Where the numbering was found, and how far it ran.
#[derive(Debug, Clone, Copy)]
struct NumberingMatch {
numbering: Numbering,
/// The title ends here.
start: usize,
/// Just past the numbering token; the episode name, if any, starts here.
end: usize,
}
/// The episode's own name out of what followed the numbering.
///
/// Only taken when a dash introduces it. Scene releases separate their quality,
/// source and codec tags with the same character as their title words (`.`), so
/// without that requirement `Show.Name.S02E05.1080p.WEB-DL.x265` would yield an
/// "episode title" of `1080p WEB-DL x265`.
fn episode_title(tail: &str) -> Option<String> {
let rest = tail.trim_start();
let rest = rest
.strip_prefix('-')
.or_else(|| rest.strip_prefix('\u{2013}'))?;
let cleaned = clean_title(rest, None);
let cleaned = cleaned.trim().to_string();
if cleaned.is_empty() {
None
} else {
Some(cleaned)
}
}
/// Drop a trailing extension, but only one that looks like an extension.
///
/// A bare `rsplit('.')` would eat the tail of `Serial.Experiments.Lain`, so the
/// candidate has to be short and alphanumeric — which every container extension
/// is and no dot-separated title word reliably is.
fn strip_extension(name: &str) -> &str {
match name.rsplit_once('.') {
Some((stem, ext))
if !ext.is_empty()
&& ext.len() <= 4
&& ext.chars().all(|c| c.is_ascii_alphanumeric())
&& ext.chars().any(|c| c.is_ascii_alphabetic()) =>
{
stem
}
_ => name,
}
}
/// A four-digit year in brackets or parentheses, e.g. `Movie Title (2019)`.
///
/// Bounded to plausible release years so a resolution (`1080`) or a checksum
/// that happens to be four digits cannot be mistaken for one.
/// `Movie Title (2019)` / `Movie Title [2019]`.
fn bracketed_year(text: &str) -> Option<u32> {
let bytes = text.as_bytes();
for (index, window) in bytes.windows(6).enumerate() {
let opens = window[0] == b'(' || window[0] == b'[';
let closes = window[5] == b')' || window[5] == b']';
// Checked on the *bytes*, before any slicing: a four-byte run of ASCII
// digits is four characters, so `text[index + 1..index + 5]` is only a
// valid slice once this holds. `Death (True)²-007.mkv` is the case that
// proves it — indexing blind there cuts inside the `²`.
if !opens || !closes || !window[1..5].iter().all(u8::is_ascii_digit) {
continue;
}
if let Some(year) = plausible_year(&text[index + 1..index + 5]) {
return Some(year);
}
}
None
}
/// `Movie.Name.2019.1080p.BluRay.mkv` — the scene form for films, where the
/// year is a bare token rather than a bracketed one.
///
/// Only a token of *exactly* four digits counts, which is what keeps a
/// resolution out: `1080` and `720` are outside the plausible range, and `2160`
/// is inside it but never appears bare — it is always `2160p`, whose trailing
/// letter makes the token five characters and disqualifies it.
fn bare_year(text: &str) -> Option<(u32, usize)> {
// Walked by `char_indices`, not by byte: a token boundary is any character
// that is not ASCII-alphanumeric, and several of those are multi-byte.
let mut token_start = 0;
let mut token_len = 0_usize;
for (index, ch) in text
.char_indices()
.chain(std::iter::once((text.len(), ' ')))
{
if ch.is_ascii_alphanumeric() {
if token_len == 0 {
token_start = index;
}
token_len += 1;
continue;
}
if token_len == 4
&& let Some(year) = plausible_year(&text[token_start..index])
{
return Some((year, token_start));
}
token_len = 0;
}
None
}
/// Four digits that could be a release year.
fn plausible_year(digits: &str) -> Option<u32> {
if !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
digits
.parse()
.ok()
.filter(|year| (1900..=2999).contains(year))
}
/// Remove `[...]` and `(...)` spans.
///
/// Release groups, resolutions, codecs, checksums and source tags all live in
/// them, and none of it belongs in a title. Unbalanced brackets are left alone
/// rather than swallowing the rest of the name.
fn strip_bracketed(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut depth = 0_usize;
for ch in text.chars() {
match ch {
'[' | '(' => depth += 1,
']' | ')' => depth = depth.saturating_sub(1),
_ if depth == 0 => out.push(ch),
_ => {}
}
}
if depth != 0 {
// Unbalanced: the input was not the convention we assumed, so trust the
// original over a half-stripped guess.
return text.to_string();
}
out
}
/// Find the episode numbering and where the title ends.
///
/// Returns the byte offsets in `text` at which the numbering begins and ends, so
/// the caller keeps everything before it as the title and discards everything
/// after it — which is where quality, codec and source tags live in every
/// convention.
fn find_numbering(text: &str) -> Option<NumberingMatch> {
// `S02E05` / `s02e05`, the western convention. Checked first because it is
// unambiguous, where a bare number is not.
if let Some(matched) = find_season_episode(text) {
if matched.numbering.episode.is_some() {
return Some(matched);
}
// A season stated *alone* does not end the search: `Oshi no Ko S2 - 04`
// names its season one way and its episode another, and returning here
// would drop the episode entirely.
return Some(with_episode(matched, text));
}
// `Oshi no Ko 3rd Season - 07`: the season is spelled out. Same shape as the
// `S2` case above — a season with the episode stated some other way — and it
// has to be read, or the words end up in the title and, worse, in the
// episode's *name*.
if let Some(matched) = find_worded_season(text) {
return Some(with_episode(matched, text));
}
find_episode_only(text)
}
/// Fold whatever states the episode number into a season-only match.
///
/// The title ends at whichever of the two came first; the quality tags start
/// after whichever came last.
fn with_episode(season: NumberingMatch, text: &str) -> NumberingMatch {
let Some(episode) = find_episode_only(text) else {
return season;
};
NumberingMatch {
numbering: Numbering {
season: season.numbering.season,
episode: episode.numbering.episode,
},
start: season.start.min(episode.start),
end: season.end.max(episode.end),
}
}
/// `3rd Season`, `Season 3` — a season stated in words rather than as `S03`.
///
/// Both orders are in use and both are common in anime releases, where a sequel
/// is named rather than numbered. The keyword is required in either position: a
/// bare ordinal (`3rd`) says nothing on its own, and a bare number is the
/// episode.
fn find_worded_season(text: &str) -> Option<NumberingMatch> {
let lower = text.to_ascii_lowercase();
for (index, _) in lower.match_indices("season") {
if !is_token_start(&lower, index) {
continue;
}
let after_keyword = index + "season".len();
// `Season 3`: the number follows the word. A dash is deliberately *not*
// a separator here — `Show 2nd Season - 04` puts the episode there, and
// reading across it makes episode four season four.
let mut cursor = after_keyword;
while matches!(lower.as_bytes().get(cursor), Some(b' ' | b'_' | b'.')) {
cursor += 1;
}
if let Some((season, end)) = read_number(&lower, cursor)
&& end > cursor
&& season > 0
&& !is_token_char(lower.as_bytes().get(end).copied())
{
return Some(NumberingMatch {
numbering: Numbering {
season: Some(season),
episode: None,
},
start: index,
end,
});
}
// `3rd Season`: the ordinal precedes it. The keyword has to *end* a
// token here — `2nd Seasoning` is a word, not a season — where in the
// branch above the digits are allowed to run straight on (`Season3`).
if !is_token_char(lower.as_bytes().get(after_keyword).copied())
&& let Some((season, start)) = ordinal_before(&lower, index)
{
return Some(NumberingMatch {
numbering: Numbering {
season: Some(season),
episode: None,
},
start,
end: after_keyword,
});
}
}
None
}
/// The `3rd` of `3rd Season`, if that is what sits before `at`. Returns the
/// number and where it starts, so the title can be cut there.
fn ordinal_before(lower: &str, at: usize) -> Option<(u32, usize)> {
let head = lower[..at].trim_end_matches([' ', '_', '.', '-']);
// The suffix is required: `Oshi no Ko 3 Season` is not a thing anyone
// writes, and without it a title ending in a digit would lose it.
let head = ["st", "nd", "rd", "th"]
.into_iter()
.find_map(|suffix| head.strip_suffix(suffix))?;
let digits_start = trailing_digits_start(head).unwrap_or(0);
let digits = &head[digits_start..];
if digits.is_empty() || digits.len() > 2 {
return None;
}
let season: u32 = digits.parse().ok()?;
(season > 0).then_some((season, digits_start))
}
/// The conventions that state an episode without stating a season.
fn find_episode_only(text: &str) -> Option<NumberingMatch> {
// `1x05`, the older western convention.
find_cross_numbering(text)
// ` - 12` / ` - E1`, the fansub convention: absolute episode number.
.or_else(|| find_dash_numbering(text))
// `E11` standing alone, for libraries that name files with nothing else.
.or_else(|| find_episode_marker(text))
// `11.mkv` — the filename *is* the episode number.
.or_else(|| find_bare_number(text))
}
/// A name that is nothing but a number.
///
/// Capped at three digits so a film named for a year (`1917.mkv`) cannot become
/// episode 1917 — that case is caught by the bare-year rule instead. The title
/// comes from the folder, which for this convention is the only place it exists.
fn find_bare_number(text: &str) -> Option<NumberingMatch> {
let digits = text.trim();
if digits.is_empty() || digits.len() > 3 || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let start = text.len() - text.trim_start().len();
Some(NumberingMatch {
numbering: Numbering {
season: None,
episode: digits.parse().ok(),
},
start,
end: start + digits.len(),
})
}
/// A standalone `E01` / `EP01`, with no dash to introduce it.
///
/// Last in the chain because it is the weakest signal: it is only reached when
/// nothing else matched, which for a real library means the filename *is* the
/// episode number and the title comes from the folder.
fn find_episode_marker(text: &str) -> Option<NumberingMatch> {
let bytes = text.as_bytes();
for start in 0..bytes.len() {
if !matches!(bytes[start], b'e' | b'E') || !is_token_start(text, start) {
continue;
}
let digits = skip_episode_marker(text, start);
if digits == start {
continue;
}
let Some((episode, after)) = read_number(text, digits) else {
continue;
};
if after == digits {
continue;
}
// The number must end the token, so a checksum fragment cannot match.
if is_token_char(bytes.get(after).copied()) {
continue;
}
return Some(NumberingMatch {
numbering: Numbering {
season: None,
episode: Some(episode),
},
start,
end: after,
});
}
None
}
/// `S02E05`, or `S02` alone.
fn find_season_episode(text: &str) -> Option<NumberingMatch> {
let lower = text.to_ascii_lowercase();
let bytes = lower.as_bytes();
for start in 0..bytes.len() {
if bytes[start] != b's' || !is_token_start(&lower, start) {
continue;
}
let (season, after_season) = read_number(&lower, start + 1)?;
if after_season == start + 1 {
continue;
}
// `S02E05`
if bytes.get(after_season) == Some(&b'e')
&& let Some((episode, after_episode)) = read_number(&lower, after_season + 1)
&& after_episode > after_season + 1
{
return Some(NumberingMatch {
numbering: Numbering {
season: Some(season),
episode: Some(episode),
},
start,
end: after_episode,
});
}
// `S02` on its own — a season folder or a season pack.
if !is_token_char(bytes.get(after_season).copied()) {
return Some(NumberingMatch {
numbering: Numbering {
season: Some(season),
episode: None,
},
start,
end: after_season,
});
}
}
None
}
/// `1x05`.
fn find_cross_numbering(text: &str) -> Option<NumberingMatch> {
let lower = text.to_ascii_lowercase();
let bytes = lower.as_bytes();
for (index, &byte) in bytes.iter().enumerate() {
if byte != b'x' || index == 0 {
continue;
}
// Walk back over the season digits to the start of the token.
let mut start = index;
while start > 0 && bytes[start - 1].is_ascii_digit() {
start -= 1;
}
if start == index || !is_token_start(&lower, start) {
continue;
}
let season: u32 = lower[start..index].parse().ok()?;
let (episode, after) = read_number(&lower, index + 1)?;
if after == index + 1 {
continue;
}
return Some(NumberingMatch {
numbering: Numbering {
season: Some(season),
episode: Some(episode),
},
start,
end: after,
});
}
None
}
/// ` - 12`, the fansub convention: a dash-delimited bare number.
///
/// The *last* such group wins, because a title may contain a dash of its own
/// (`Fate/stay night - Unlimited Blade Works - 06`).
fn find_dash_numbering(text: &str) -> Option<NumberingMatch> {
let mut found: Option<NumberingMatch> = None;
let bytes = text.as_bytes();
for (index, &byte) in bytes.iter().enumerate() {
if byte != b'-' {
continue;
}
// `_-_01_` is as common as ` - 01 `: some groups separate every field
// with underscores, dashes included.
let mut cursor = index + 1;
while matches!(bytes.get(cursor), Some(b' ' | b'_' | b'.')) {
cursor += 1;
}
// An `E`/`EP` marker may introduce the number (`- E1`, `- Ep 03`).
// Handled here rather than as its own convention because it only ever
// appears in this position.
cursor = skip_episode_marker(text, cursor);
let Some((episode, after)) = read_number(text, cursor) else {
continue;
};
if after == cursor {
continue;
}
// A version suffix (`12v2`) still names episode 12.
let tail = bytes.get(after).copied();
if is_token_char(tail) && tail != Some(b'v') {
continue;
}
if is_decimal_tail(text, after) {
continue;
}
found = Some(NumberingMatch {
numbering: Numbering {
season: None,
episode: Some(episode),
},
start: index,
end: after,
});
}
found
}
/// Whether the number that ended at `at` goes on as a decimal: `.11` in
/// `Evangelion - 1.11 You Are (Not) Alone`, `.0` in `3.0+1.01`.
///
/// That is a version of a film, not episode 1 of anything, and reading it as
/// one turns a folder of four films into a series of episodes 1, 2, 3 and 3.
/// One or two digits only: `- 05.1080p` is episode 5 and a resolution.
fn is_decimal_tail(text: &str, at: usize) -> bool {
let bytes = text.as_bytes();
if bytes.get(at) != Some(&b'.') {
return false;
}
let digits = bytes[at + 1..]
.iter()
.take_while(|byte| byte.is_ascii_digit())
.count();
(1..=2).contains(&digits) && !is_token_char(bytes.get(at + 1 + digits).copied())
}
/// Step over an `E` / `EP` episode marker and any separator after it.
///
/// Returns `at` unchanged when there is none, so a bare number and a marked one
/// are handled identically by the caller.
fn skip_episode_marker(text: &str, at: usize) -> usize {
let bytes = text.as_bytes();
if !matches!(bytes.get(at), Some(b'e' | b'E')) {
return at;
}
let mut cursor = at + 1;
if matches!(bytes.get(cursor), Some(b'p' | b'P')) {
cursor += 1;
}
while matches!(bytes.get(cursor), Some(b'.' | b' ' | b'_')) {
cursor += 1;
}
// Only a marker if a number actually follows; otherwise the `e` was the
// first letter of a word.
if bytes.get(cursor).is_some_and(u8::is_ascii_digit) {
cursor
} else {
at
}
}
/// Read an unsigned number at `from`, returning it and the offset after it.
fn read_number(text: &str, from: usize) -> Option<(u32, usize)> {
let bytes = text.as_bytes();
let mut end = from;
while bytes.get(end).is_some_and(u8::is_ascii_digit) {
end += 1;
}
if end == from {
return Some((0, from));
}
// A run of digits far longer than any episode number is a checksum or a
// date, not numbering.
if end - from > 4 {
return None;
}
text[from..end].parse().ok().map(|n| (n, end))
}
/// Where the trailing run of ASCII digits starts, or `None` when the text is
/// nothing but digits (or empty).
///
/// Walked back by `char_indices`, not by `rfind(…) + 1`: one past the *start*
/// of the character before the digits is inside it whenever that character is
/// multi-byte. `Death (True)² - 007` is the case that proves it — the `+ 1`
/// form cuts the `²` in half and panics.
fn trailing_digits_start(text: &str) -> Option<usize> {
let mut start = None;
for (index, ch) in text.char_indices().rev() {
if ch.is_ascii_digit() {
start = Some(index);
} else {
return Some(start.unwrap_or(text.len()));
}
}
None
}
fn is_token_char(byte: Option<u8>) -> bool {
byte.is_some_and(|b| b.is_ascii_alphanumeric())
}
/// Whether `at` begins a token rather than sitting inside a word — so the `s`
/// of `Seasons` cannot start a season match.
fn is_token_start(text: &str, at: usize) -> bool {
if at == 0 {
return true;
}
!text.as_bytes()[at - 1].is_ascii_alphanumeric()
}
/// Normalize the title span: separators to spaces, then trim the debris that
/// sits between the title and whatever followed it.
fn clean_title(raw: &str, year: Option<u32>) -> String {
// A dot between two digits is a decimal point, not a separator: `1.11`
// is the film's name, `1 11` is two numbers.
let bytes = raw.as_bytes();
let mut text: String = raw
.char_indices()
.map(|(at, c)| match c {
'.' if at > 0
&& bytes[at - 1].is_ascii_digit()
&& bytes.get(at + 1).is_some_and(u8::is_ascii_digit) =>
{
'.'
}
'.' | '_' => ' ',
other => other,
})
.collect();
// The year was extracted separately; leave it out of the title.
if let Some(year) = year {
text = text.replace(&year.to_string(), " ");
}
let trimmed = text.trim().trim_end_matches(['-', '–', ' ']).trim();
// Collapse the runs of spaces the substitutions above create.
let mut out = String::with_capacity(trimmed.len());
let mut last_was_space = false;
for ch in trimmed.chars() {
let is_space = ch == ' ';
if is_space && last_was_space {
continue;
}
last_was_space = is_space;
out.push(ch);
}
out
}
/// What a folder contributes to the files beneath it.
///
/// A library is organised as `<Series>/Season 01/<file>.mkv` far more often than
/// it is named `<Series> - S01E01.mkv`, so for most real collections the folder
/// path — not the filename — is where the title lives. This is what
/// [`crate::catalog::build_rows`] walks.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FolderRole {
/// `Season 01`, `S02` — states a season and nothing else.
Season(u32),
/// `Movies`, `OVA`, `Specials` — groups files without naming them. Skipped
/// when looking for a title; the folder above it is the series.
Container,
/// Anything else: the folder names the series or film.
Title(Box<ParsedName>),
}
/// Folder names that group without naming.
const CONTAINER_FOLDERS: &[&str] = &[
"movies",
"movie",
"films",
"film",
"ova",
"ovas",
"oad",
"oads",
"specials",
"special",
"sp",
"extras",
"extra",
"bonus",
"ncop",
"nced",
"creditless",
"subs",
"subtitles",
];
/// What a folder name says about the files inside it.
pub fn classify_folder(name: &str) -> FolderRole {
let trimmed = name.trim();
let lower = trimmed.to_ascii_lowercase();
if let Some(season) = season_folder(&lower) {
return FolderRole::Season(season);
}
if CONTAINER_FOLDERS.contains(&lower.as_str()) {
return FolderRole::Container;
}
// `Game of Thrones - Season 1` names the series *and* its season. Read
// both, or the shelf grows one poster per season of the same show.
let mut parsed = parse(trimmed);
if parsed.season.is_none()
&& let Some((series, season)) = trailing_season(trimmed)
{
let mut from_series = parse(series);
if !from_series.title.is_empty() {
from_series.season = Some(season);
parsed = from_series;
}
}
FolderRole::Title(Box::new(parsed))
}
/// Split `<series> … Season 3` / `<series> … S03` into the series and the
/// season number.
///
/// The keyword is required. Without it, every title ending in a digit —
/// `Evangelion 3`, `Kaijuu 8-gou` — would lose it to a season it never claimed.
fn trailing_season(name: &str) -> Option<(&str, u32)> {
let trimmed = name.trim_end();
let digits_start = trailing_digits_start(trimmed)?;
let (head, digits) = trimmed.split_at(digits_start);
let season: u32 = digits.parse().ok()?;
// `… S03`: the marker is glued to the number and stands alone as a token,
// so a series whose own name ends in `s` keeps its last letter.
if let Some(before) = head.strip_suffix(['S', 's'])
&& before.ends_with([' ', '_', '.', '-'])
{
let series = before.trim_end_matches([' ', '_', '.', '-']);
return (!series.is_empty()).then_some((series, season));
}
// `… Season 3`.
let head = head.trim_end_matches([' ', '_', '.', '-']);
if !head.to_ascii_lowercase().ends_with("season") {
return None;
}
let series = head[..head.len() - "season".len()].trim_end_matches([' ', '_', '.', '-']);
// `Season 01` alone is a season folder, not a series called nothing.
(!series.is_empty()).then_some((series, season))
}
/// `season 01`, `season 1`, `s01`, `3rd season` — and nothing else in the name.
fn season_folder(lower: &str) -> Option<u32> {
// The ordinal form first: `3rd season` names a season and nothing else, and
// a folder called that must not become a title called that.
if let Some(matched) = find_worded_season(lower)
&& matched.start == 0
&& lower[matched.end..].trim().is_empty()
{
return matched.numbering.season;
}
let digits = lower
.strip_prefix("season")
.or_else(|| lower.strip_prefix('s'))?
.trim_start_matches([' ', '_', '.', '-']);
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
digits.parse().ok()
}
/// Whether a name states a season and nothing else — `3rd Season`, `Season 2`,
/// `S03`.
///
/// What [`crate::catalog::build_rows`] asks before keeping the remains of a
/// filename as the episode's own name: `[DB]Oshi no Ko 3rd Season - 07.mkv`
/// under a folder called `Oshi no Ko` leaves `3rd Season` behind, and that is a
/// season marker, not what episode seven is called.
pub fn is_season_marker(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.is_empty() {
return false;
}
if season_folder(&trimmed.to_ascii_lowercase()).is_some() {
return true;
}
let parsed = parse(trimmed);
parsed.title.is_empty() && parsed.season.is_some() && parsed.episode.is_none()
}
/// Containers this app can hand to mpv.
///
/// Deliberately a list rather than "anything that is not obviously metadata": a
/// share holds `.nfo`, `.txt` and `.jpg` alongside the video, and a catalog that
/// offers those as episodes is worse than one that omits an exotic container.
const VIDEO_EXTENSIONS: &[&str] = &[
"mkv", "mp4", "m4v", "avi", "mov", "webm", "ts", "m2ts", "mts", "mpg", "mpeg", "wmv", "flv",
"ogv", "ogm", "asf", "rmvb", "divx", "vob",
];
/// Whether a filename names something playable.
pub fn is_video_file(name: &str) -> bool {
name.rsplit_once('.')
.is_some_and(|(_, ext)| VIDEO_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
}
/// Subtitle formats mpv reads from a file of their own.
const SUBTITLE_EXTENSIONS: &[&str] = &["srt", "ass", "ssa", "vtt"];
/// Whether a filename names a subtitle file a video can be paired with.
pub fn is_subtitle_file(name: &str) -> bool {
name.rsplit_once('.')
.is_some_and(|(_, ext)| SUBTITLE_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
}
#[cfg(test)]
mod tests {
use super::*;
fn parsed(name: &str) -> (String, Option<u32>, Option<u32>, Option<u32>) {
let p = parse(name);
(p.title, p.season, p.episode, p.year)
}
/// The fansub convention: bracketed group, dash-delimited absolute episode
/// number, bracketed checksum. No season is stated and none is invented.
#[test]
fn a_fansub_release_yields_title_and_absolute_episode() {
assert_eq!(
parsed("[SubsPlease] Frieren - 12 (1080p) [A1B2C3D4].mkv"),
("Frieren".into(), None, Some(12), None)
);
}
/// A title containing its own dash must not be cut at the first one — the
/// numbering is the *last* dash-delimited number.
#[test]
fn a_dash_in_the_title_survives_absolute_numbering() {
assert_eq!(
parsed("[Group] Fate stay night - Unlimited Blade Works - 06 [720p].mkv"),
(
"Fate stay night - Unlimited Blade Works".into(),
None,
Some(6),
None
)
);
}
/// **What the four Rebuild of Evangelion films parsed as.** ` - 1.11` read
/// as episode 1, so the folder became a series of episodes 1, 2, 3 and 3.
/// A decimal is a version of the film, and the name keeps it.
#[test]
fn a_decimal_after_the_dash_is_part_of_a_films_name_not_an_episode() {
assert_eq!(
parsed("Evangelion - 1.11 You Are (Not) Alone.mkv"),
("Evangelion - 1.11 You Are Alone".into(), None, None, None)
);
assert_eq!(
parsed("Evangelion - 3.0+1.01 Thrice Upon a Time.mkv"),
(
"Evangelion - 3.0+1.01 Thrice Upon a Time".into(),
None,
None,
None
)
);
}
/// A recap numbered between two episodes is not a second copy of the one
/// before it.
#[test]
fn a_half_episode_is_not_the_episode_before_it() {
assert_eq!(
parsed("[Group] Show Name - 12.5 [1080p].mkv"),
("Show Name - 12.5".into(), None, None, None)
);
}
/// The decimal rule stops at two digits, so a resolution glued on with a
/// dot leaves the episode number alone.
#[test]
fn a_resolution_after_a_dot_does_not_hide_the_episode() {
assert_eq!(
parsed("Show Name - 05.1080p.mkv"),
("Show Name".into(), None, Some(5), None)
);
}
/// A version suffix still names the same episode.
#[test]
fn a_versioned_fansub_episode_keeps_its_number() {
assert_eq!(
parsed("[Group] Show Name - 07v2 [1080p].mkv"),
("Show Name".into(), None, Some(7), None)
);
}
/// The western convention: dot separators, `SxxEyy`, and a tail of quality
/// and codec tags that must not reach the title.
#[test]
fn a_scene_release_yields_title_season_and_episode() {
assert_eq!(
parsed("Show.Name.S02E05.1080p.WEB-DL.x265.mkv"),
("Show Name".into(), Some(2), Some(5), None)
);
}
/// Lowercase numbering is the same numbering.
#[test]
fn season_episode_matching_is_case_insensitive() {
assert_eq!(
parsed("show.name.s01e01.720p.mkv"),
("show name".into(), Some(1), Some(1), None)
);
}
/// The older cross convention.
#[test]
fn cross_numbering_is_read_as_season_and_episode() {
assert_eq!(
parsed("Some Show 1x05 [HDTV].avi"),
("Some Show".into(), Some(1), Some(5), None)
);
}
/// A film: a year, no numbering, and tags that are dropped.
#[test]
fn a_film_yields_a_title_and_year_and_no_numbering() {
assert_eq!(
parsed("Movie Title (2019) [1080p] [HEVC].mkv"),
("Movie Title".into(), None, None, Some(2019))
);
}
/// The scene form for films: a bare, dot-delimited year.
#[test]
fn a_bare_year_token_is_read_as_a_year() {
let p = parse("Movie.Name.2019.1080p.BluRay.x264.mkv");
assert_eq!(p.year, Some(2019));
assert_eq!(p.title, "Movie Name");
}
/// And the resolutions that sit right next to it are not years — `1080` and
/// `720` are out of range, and `2160` never appears without its `p`.
#[test]
fn a_bare_resolution_is_not_mistaken_for_a_year() {
assert_eq!(parse("Show.Name.S01E01.1080p.mkv").year, None);
assert_eq!(parse("Show.Name.S01E01.720p.mkv").year, None);
assert_eq!(parse("Show.Name.S01E01.2160p.HDR.mkv").year, None);
}
/// **A panic, not a mis-parse.** Superscripts and other multi-byte
/// characters appear in real release names (`Death (True)²-007.mkv`), and
/// byte-indexed slicing around them cuts inside a character.
#[test]
fn a_multi_byte_character_does_not_break_parsing() {
for name in [
"[Anime Time] Neon Genesis Evangelion - Death (True)\u{b2}-007.mkv",
"Show \u{2013} S01E01 \u{2013} caf\u{e9}.mkv",
"\u{5c0f}\u{8aac} Season 01 E03.mkv",
] {
let parsed = parse(name);
assert!(
!parsed.title.is_empty() || parsed.episode.is_some(),
"{name}"
);
}
}
/// **A panic, not a mis-parse.** A folder name whose trailing digits sit
/// directly behind a multi-byte character — or that simply *ends* in one —
/// used to be split one byte past that character's start, i.e. inside it.
#[test]
fn a_multi_byte_character_does_not_break_folder_classification() {
for name in [
"Neon Genesis Evangelion Death (True)\u{b2}",
"Show\u{b2}3",
"Show\u{b2}3rd Season",
"Caf\u{e9} Season 2",
"\u{b2}",
] {
// The classification itself is the assertion: any of these used to
// panic before it could return one.
let _ = classify_folder(name);
}
let FolderRole::Title(parsed) = classify_folder("Caf\u{e9} Season 2") else {
panic!("expected a title");
};
assert_eq!((parsed.title.as_str(), parsed.season), ("Café", Some(2)));
}
/// A resolution is four digits in brackets too, and must not read as a year.
#[test]
fn a_bracketed_resolution_is_not_mistaken_for_a_year() {
let p = parse("Show Name - 03 [1080p].mkv");
assert_eq!(p.year, None, "1080p is not a release year");
assert_eq!(p.episode, Some(3));
}
/// A season pack or season folder states a season and no episode.
#[test]
fn a_bare_season_is_read_without_an_episode() {
assert_eq!(
parsed("Show Name S03 COMPLETE"),
("Show Name".into(), Some(3), None, None)
);
}
/// `Seasons` starts with an `s` and a digit follows nearby; it is still not
/// numbering.
#[test]
fn a_word_beginning_with_s_does_not_start_a_season() {
let p = parse("Four Seasons 2019.mkv");
assert_eq!(p.season, None, "'Seasons' is not 'S...'");
}
/// Titles with dots in them are exactly why the extension check is
/// conservative.
#[test]
fn a_dotted_title_keeps_its_words() {
assert_eq!(
parsed("Serial.Experiments.Lain.S01E03.mkv"),
("Serial Experiments Lain".into(), Some(1), Some(3), None)
);
}
/// Nothing recognisable still yields a usable title — a catalog that drops
/// files it cannot parse is worse than one with an odd name in it.
#[test]
fn an_unrecognisable_name_still_yields_a_title() {
assert_eq!(
parsed("some_random_file.mkv"),
("some random file".into(), None, None, None)
);
}
/// Unbalanced brackets mean the name is not the convention assumed, so the
/// stripper leaves it alone rather than swallowing the rest.
#[test]
fn unbalanced_brackets_do_not_swallow_the_name() {
let p = parse("[Group Show Name - 05.mkv");
assert!(p.title.contains("Show Name"), "title survived: {}", p.title);
}
/// A name with no extension at all parses the same way.
#[test]
fn a_name_without_an_extension_parses_the_same() {
assert_eq!(
parsed("Show.Name.S02E05"),
("Show Name".into(), Some(2), Some(5), None)
);
}
/// The episode's own name, where the filename states one.
#[test]
fn an_episode_name_after_the_numbering_is_captured() {
let p = parse("S01E01-Mother and Children [B496EBF1].mkv");
assert_eq!(p.season, Some(1));
assert_eq!(p.episode, Some(1));
assert_eq!(p.episode_title.as_deref(), Some("Mother and Children"));
assert_eq!(p.title, "", "the filename states no series");
}
/// **The trap.** Scene releases separate their tags with the same character
/// they separate title words with, so without requiring a dash the quality
/// and codec tags would be read as the episode's name.
#[test]
fn quality_tags_after_the_numbering_are_not_an_episode_name() {
let p = parse("Show.Name.S02E05.1080p.WEB-DL.x265.mkv");
assert_eq!(p.episode_title, None, "1080p.WEB-DL.x265 is not a name");
assert_eq!(p.title, "Show Name");
}
/// A library that names its files with nothing but the episode marker.
#[test]
fn a_bare_episode_marker_is_read_as_an_episode() {
assert_eq!(parse("E11.mkv").episode, Some(11));
assert_eq!(parse("EP07.mkv").episode, Some(7));
assert_eq!(parse("Ep 3.mkv").episode, Some(3));
}
/// A dash-introduced marker, the form fansubs use for OVAs.
#[test]
fn a_dash_introduced_episode_marker_is_read_as_an_episode() {
let p = parse("[Reaktor] Fullmetal Alchemist Brotherhood OVA - E2 v2 [1080p].mkv");
assert_eq!(p.episode, Some(2));
assert_eq!(p.title, "Fullmetal Alchemist Brotherhood OVA");
}
/// A filename that is nothing but its episode number — the folder carries
/// the title in this convention.
#[test]
fn a_bare_number_filename_is_read_as_an_episode() {
for (name, episode) in [("11.mkv", 11), ("01.mkv", 1), ("7.mkv", 7)] {
let parsed = parse(name);
assert_eq!(parsed.episode, Some(episode), "{name}");
assert_eq!(parsed.title, "", "{name} states no title");
}
}
/// A film named for a year must not become episode 1917.
#[test]
fn a_year_shaped_filename_is_not_an_episode() {
let parsed = parse("1917.mkv");
assert_eq!(parsed.episode, None);
assert_eq!(parsed.year, Some(1917));
}
/// **A season and an episode stated in different conventions.** Matching the
/// season must not end the search, or the episode is dropped.
#[test]
fn a_season_marker_and_a_dash_episode_are_both_read() {
let p = parse("[EMBER] Oshi no Ko S2 - 04.mkv");
assert_eq!(p.title, "Oshi no Ko");
assert_eq!(p.season, Some(2));
assert_eq!(p.episode, Some(4));
}
/// Some groups separate every field with underscores, the dash included —
/// and spell the season out rather than numbering it `S03`.
///
/// **Both halves matter.** Reading `3rd Season` as part of the title files
/// the season under its own poster; leaving it in the span after the series
/// name makes it the *episode's* name, so every episode of season three is
/// called "3rd Season" in the list.
#[test]
fn underscore_separated_numbering_is_read() {
let p = parse("[DB]Oshi no Ko 3rd Season_-_07_(Dual Audio_10bit_1080p_x265).mkv");
assert_eq!(p.episode, Some(7));
assert_eq!(p.season, Some(3));
assert_eq!(p.title, "Oshi no Ko");
assert_eq!(p.episode_title, None);
}
/// Every way a release spells a season out, in both orders.
#[test]
fn a_worded_season_is_read_as_a_season() {
for (name, season, episode) in [
("[Group] Show 2nd Season - 04 [1080p].mkv", 2, 4),
("[Group] Show 3rd Season - 11.mkv", 3, 11),
("Show 1st Season - 01.mkv", 1, 1),
("Show 4th Season - 02.mkv", 4, 2),
("Show Season 2 - 06.mkv", 2, 6),
("Show.Season.3.E05.mkv", 3, 5),
] {
let parsed = parse(name);
assert_eq!(parsed.season, Some(season), "{name}");
assert_eq!(parsed.episode, Some(episode), "{name}");
assert_eq!(parsed.title, "Show", "{name}");
}
}
/// The ordinal has to belong to the word `Season`. A title that merely ends
/// in one keeps it, and a season with no keyword is not a season.
#[test]
fn an_ordinal_that_is_part_of_the_title_is_not_a_season() {
for name in [
"[Group] 3rd Rate Duelist - 04.mkv",
"[Group] My 1st Kiss - 02.mkv",
"[Group] Show 3 - 04.mkv",
] {
let parsed = parse(name);
assert_eq!(parsed.season, None, "{name}");
}
// `Seasons` is not `Season`: the keyword has to end the token.
assert_eq!(parse("Show 2nd Seasoning - 03.mkv").season, None);
}
/// A folder that spells its season out is a season folder, not a series
/// called `3rd Season`.
#[test]
fn a_worded_season_folder_is_a_season_folder() {
assert_eq!(classify_folder("3rd Season"), FolderRole::Season(3));
assert_eq!(classify_folder("2nd season"), FolderRole::Season(2));
assert_eq!(classify_folder("Season 3"), FolderRole::Season(3));
}
/// What `build_rows` asks before keeping a filename's leftovers as the
/// episode's name.
#[test]
fn a_season_marker_is_recognised_as_one() {
for name in ["3rd Season", "2nd season", "Season 2", "S03", "season 01"] {
assert!(is_season_marker(name), "{name} states only a season");
}
for name in ["Mother and Children", "The End of Evangelion", ""] {
assert!(!is_season_marker(name), "{name} is a name");
}
}
/// A title beginning with `E` is not an episode marker.
#[test]
fn a_word_beginning_with_e_does_not_start_an_episode() {
assert_eq!(parse("Evangelion.mkv").episode, None);
assert_eq!(parse("The End of Evangelion.mkv").episode, None);
}
/// Season folders state a season and nothing else.
#[test]
fn season_folders_are_classified_as_seasons() {
assert_eq!(classify_folder("Season 01"), FolderRole::Season(1));
assert_eq!(classify_folder("season 2"), FolderRole::Season(2));
assert_eq!(classify_folder("S03"), FolderRole::Season(3));
}
/// A folder that names the series *and* its season must give up both, or
/// the shelf shows one poster per season of the same show.
#[test]
fn a_folder_naming_series_and_season_yields_both() {
for (name, series, season) in [
("Game of Thrones - Season 1", "Game of Thrones", 1),
("Game of Thrones Season 10", "Game of Thrones", 10),
("The Expanse - S03", "The Expanse", 3),
("Breaking Bad.season.2", "Breaking Bad", 2),
] {
let FolderRole::Title(parsed) = classify_folder(name) else {
panic!("{name} was not classified as a title");
};
assert_eq!(parsed.title, series, "{name}");
assert_eq!(parsed.season, Some(season), "{name}");
}
}
/// The keyword is what makes it a season. A title that merely ends in a
/// number keeps it.
#[test]
fn a_title_ending_in_a_number_keeps_it() {
for name in ["Evangelion 3", "Kaijuu 8", "Alien 3"] {
let FolderRole::Title(parsed) = classify_folder(name) else {
panic!("{name} was not classified as a title");
};
assert_eq!(parsed.season, None, "{name}");
assert_eq!(parsed.title, name, "{name}");
}
// A four-digit tail is read as a year, as it is everywhere else — but
// still never as a season.
let FolderRole::Title(parsed) = classify_folder("Blade Runner 2049") else {
panic!("not a title");
};
assert_eq!(parsed.season, None);
assert_eq!(parsed.year, Some(2049));
}
/// Grouping folders name nothing, so a title has to come from above them.
#[test]
fn grouping_folders_are_classified_as_containers() {
for name in ["Movies", "OVA", "Specials", "extras", "NCED"] {
assert_eq!(
classify_folder(name),
FolderRole::Container,
"{name} groups without naming"
);
}
}
/// Everything else names the series or film — and is parsed, so a year in
/// the folder name is picked up too.
#[test]
fn other_folders_name_the_series() {
match classify_folder("Ghost in the Shell (1995)") {
FolderRole::Title(parsed) => {
assert_eq!(parsed.title, "Ghost in the Shell");
assert_eq!(parsed.year, Some(1995));
}
other => panic!("expected a title, got {other:?}"),
}
// A folder that merely contains the word is not a container.
assert!(matches!(
classify_folder("Satoshi Kon Movies"),
FolderRole::Title(_)
));
}
/// A share holds `.nfo` and `.jpg` next to the video; a catalog that offers
/// those as episodes is worse than one missing an exotic container.
#[test]
fn only_playable_containers_count_as_video() {
for name in ["a.mkv", "a.MP4", "a.webm", "a.m2ts", "a.avi"] {
assert!(is_video_file(name), "{name} is playable");
}
for name in ["torrent.info.nfo", "poster.jpg", "readme.txt", "noext"] {
assert!(!is_video_file(name), "{name} is not");
}
}
/// `is_episode` is what the catalog groups on.
#[test]
fn episodes_and_films_are_distinguishable() {
assert!(parse("Show.Name.S01E01.mkv").is_episode());
assert!(!parse("Movie Title (2019).mkv").is_episode());
}
}