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.
This commit is contained in:
nvrl committed 2026-10-27 19:50:26 +01:00
1 parent 65a669e231
commit 238ecd8220
5 files changed
+341 -9

No files matched your search

+207 -4
View File
@@ -1549,7 +1549,9 @@ pub fn from_node(share_id: &str, node: &proton_drive_rs::Node) -> CatalogNode {
/// - grouping folders (`Movies`, `OVA`, `Specials`) are stepped over, so their
/// contents group under the series above them;
/// - whatever the filename claimed as a title, when it differs, is kept as the
/// episode's own name — with the redundant series prefix stripped.
/// episode's own name — with the redundant series prefix stripped;
/// - **a folder of films is one title per film**, not one title: see
/// [`split_films`].
///
/// Folders and non-video files are dropped: a share holds `.nfo` and `.jpg`
/// alongside the video, and a catalog that offers those as episodes is worse
@@ -1562,12 +1564,18 @@ pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<Catalo
.map(|node| (node.uid.link_id.as_str(), node))
.collect();
nodes
let mut origins = Vec::new();
let mut rows: Vec<CatalogNode> = nodes
.iter()
.filter(|node| !node.is_folder() && naming::is_video_file(&node.name))
.map(|node| {
let mut row = from_node(share_id, node);
let context = ancestry(node, &by_link);
origins.push(Origin {
own: row.parsed.clone(),
folder: context.title_folder.clone(),
through_container: context.through_container,
});
if let Some(title) = context.title {
// The filename's own title, if it had one and it differs, is the
@@ -1601,7 +1609,75 @@ pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<Catalo
}
row
})
.collect()
.collect();
split_films(&mut rows, &origins);
rows
}
/// Where a row's title came from, kept for [`split_films`].
struct Origin {
/// What the filename alone parsed as, before the folder overrode it.
own: ParsedName,
/// The link id of the folder that named the row.
folder: Option<String>,
through_container: bool,
}
/// Give each film in a folder of films its own title.
///
/// `Rebuild of Evangelion/` holding `Evangelion - 1.11 You Are (Not)
/// Alone.mkv` and three more is four films, each its own entry at every
/// provider. Titled by the folder they are one series of four unnumbered
/// files, matched as whichever single film the folder's name reads most like —
/// and no single film is right.
///
/// Only when every file in the folder says so, because the same shape with one
/// file out of place is a series:
///
/// - none is numbered, by its name or by a season folder;
/// - none is under a grouping folder, because `Neon Genesis Evangelion/Movies/`
/// is the series' films, and they belong on its page;
/// - each names itself, differently from the others, and not by the folder's
/// name — `Mushishi - The Green Seat.mkv` under `Mushishi/` is an episode
/// of that show whose number is missing, not a film.
fn split_films(rows: &mut [CatalogNode], origins: &[Origin]) {
use crate::library::title_key;
use std::collections::HashSet;
let mut by_folder: HashMap<&str, Vec<usize>> = HashMap::new();
for (index, origin) in origins.iter().enumerate() {
if let Some(folder) = &origin.folder {
by_folder.entry(folder.as_str()).or_default().push(index);
}
}
for members in by_folder.into_values() {
if members.len() < 2 {
continue;
}
let mut keys = HashSet::new();
let films = members.iter().all(|&index| {
let (row, origin) = (&rows[index], &origins[index]);
let own = &origin.own.title;
!origin.through_container
&& !origin.own.is_episode()
&& origin.own.season.is_none()
&& row.parsed.season.is_none()
&& !own.is_empty()
&& strip_prefix_title(own, &row.parsed.title) == *own
&& !own.eq_ignore_ascii_case(&row.parsed.title)
&& keys.insert(title_key(own))
});
if !films {
continue;
}
for index in members {
let row = &mut rows[index];
let own = &origins[index].own;
row.parsed.title = own.title.clone();
row.parsed.episode_title = own.episode_title.clone();
}
}
}
/// Pair each subtitle file in a crawl with the video it is for.
@@ -1784,6 +1860,10 @@ fn describe_subtitle(rest: &str) -> (Option<String>, Option<String>) {
#[derive(Debug, Default)]
struct Ancestry {
title: Option<String>,
/// The link id of the folder [`Ancestry::title`] came from.
title_folder: Option<String>,
/// Whether a grouping folder sat between the file and that one.
through_container: bool,
season: Option<u32>,
year: Option<u32>,
}
@@ -1822,10 +1902,13 @@ fn ancestry(
match crate::naming::classify_folder(&ancestor.name) {
FolderRole::Season(season) => found.season = found.season.or(Some(season)),
FolderRole::Container => {}
FolderRole::Container => {
found.through_container |= found.title.is_none();
}
FolderRole::Title(parsed) => {
if found.title.is_none() && !parsed.title.is_empty() {
found.title = Some(parsed.title.clone());
found.title_folder = Some(link_id.clone());
found.year = found.year.or(parsed.year);
// `Game of Thrones - Season 1` names both; the season it
// states belongs to the files under it.
@@ -3081,6 +3164,126 @@ mod tests {
assert_eq!(row(&film, "film").parsed.title, "Millennium Actress");
}
/// **What the Rebuild of Evangelion films were matched as.** A folder of
/// four films titled by the folder is one series, matched as a single film
/// that none of the four is. Each names itself, so each is its own title.
#[test]
fn a_folder_of_unnumbered_films_is_one_title_per_film() {
let nodes = vec![
root(),
folder("rebuild", Some("root"), "Rebuild of Evangelion"),
video(
"one",
"rebuild",
"Evangelion - 1.11 You Are (Not) Alone.mkv",
),
video(
"two",
"rebuild",
"Evangelion - 2.22 You Can (Not) Advance..mkv",
),
video(
"three",
"rebuild",
"Evangelion - 3.33 You Can (Not) Redo..mkv",
),
video(
"four",
"rebuild",
"Evangelion - 3.0+1.01 Thrice Upon a Time.mkv",
),
];
let rows = build_rows("share", &nodes);
assert_eq!(
row(&rows, "one").parsed.title,
"Evangelion - 1.11 You Are Alone"
);
assert_eq!(
row(&rows, "four").parsed.title,
"Evangelion - 3.0+1.01 Thrice Upon a Time"
);
for film in &rows {
assert_eq!(film.parsed.episode, None);
assert_eq!(film.parsed.episode_title, None, "a film is not an episode");
}
let library = crate::library::Library::build(rows, &HashMap::new());
assert_eq!(library.titles.len(), 4);
assert!(
library
.titles
.iter()
.all(|title| title.kind == TitleKind::Film)
);
}
/// One numbered file says the folder is a show, and the unnumbered ones are
/// its specials.
#[test]
fn a_folder_with_a_numbered_episode_in_it_is_not_split() {
let nodes = vec![
root(),
folder("show", Some("root"), "Promised Neverland"),
video("e1", "show", "[Group] Yakusoku no Neverland - 01.mkv"),
video("film", "show", "Some Recap Film.mkv"),
video("other", "show", "Another Special.mkv"),
];
let rows = build_rows("share", &nodes);
for file in &rows {
assert_eq!(file.parsed.title, "Promised Neverland");
}
}
/// Films under `Movies/` are the series' films, and stay on its page.
#[test]
fn films_under_a_grouping_folder_stay_with_the_series() {
let nodes = vec![
root(),
folder("eva", Some("root"), "Neon Genesis Evangelion"),
folder("movies", Some("eva"), "Movies"),
video("a", "movies", "Death and Rebirth.mkv"),
video("b", "movies", "The End of Evangelion.mkv"),
];
let rows = build_rows("share", &nodes);
for file in &rows {
assert_eq!(file.parsed.title, "Neon Genesis Evangelion");
}
}
/// Files that repeat the folder's name are that show's episodes with their
/// numbers missing, not films of their own.
#[test]
fn unnumbered_files_named_after_their_folder_stay_one_title() {
let nodes = vec![
root(),
folder("mushi", Some("root"), "Mushishi"),
video("a", "mushi", "Mushishi - The Green Seat.mkv"),
video("b", "mushi", "Mushishi - The Light of the Eyelid.mkv"),
];
let rows = build_rows("share", &nodes);
assert_eq!(row(&rows, "a").parsed.title, "Mushishi");
assert_eq!(
row(&rows, "a").parsed.episode_title.as_deref(),
Some("The Green Seat")
);
}
/// Two files that name themselves the same thing are not two films.
#[test]
fn files_whose_own_names_collide_are_not_split() {
let nodes = vec![
root(),
folder("film", Some("root"), "Akira"),
video("a", "film", "[GroupA] Neo Tokyo.mkv"),
video("b", "film", "Neo.Tokyo.mkv"),
];
let rows = build_rows("share", &nodes);
for file in &rows {
assert_eq!(file.parsed.title, "Akira");
}
}
/// The share root names the *share*, not a series — using it would label
/// every top-level file identically.
#[test]
+72 -2
View File
@@ -539,6 +539,9 @@ fn find_dash_numbering(text: &str) -> Option<NumberingMatch> {
if is_token_char(tail) && tail != Some(b'v') {
continue;
}
if is_decimal_tail(text, after) {
continue;
}
found = Some(NumberingMatch {
numbering: Numbering {
season: None,
@@ -551,6 +554,24 @@ fn find_dash_numbering(text: &str) -> Option<NumberingMatch> {
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
@@ -630,9 +651,18 @@ fn is_token_start(text: &str, at: usize) -> bool {
/// 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
.chars()
.map(|c| match c {
.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,
})
@@ -856,6 +886,46 @@ mod tests {
);
}
/// **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() {
+51
View File
@@ -379,6 +379,12 @@ impl AniList {
/// tokenisation: by dropping the `s` it was holding on to. It is asked for only
/// after the name as written found nothing, which is what keeps it from
/// broadening a search that was already working.
///
/// A film's version number is the same kind of miss. `Evangelion - 1.11 You
/// Are (Not) Alone` is the file of a film AniList calls `Evangelion: 1.0 You
/// Are (Not) Alone`, and `1.11` is not a word of that name — so the last
/// fallback asks without it, and leaves telling the films apart to the scoring,
/// which still sees the whole name.
fn search_terms(name: &str) -> Vec<String> {
let mut terms = vec![name.to_string()];
@@ -386,9 +392,31 @@ fn search_terms(name: &str) -> Vec<String> {
if depossessed != name {
terms.push(depossessed);
}
let unversioned = without_version_numbers(name);
if !unversioned.is_empty() && !terms.contains(&unversioned) {
terms.push(unversioned);
}
terms
}
/// `Evangelion - 1.11 You Are Alone` → `Evangelion - You Are Alone`.
///
/// A version is digits with a dot in them (`1.11`, `3.0+1.01`); a bare number
/// is left alone, because in `Kaijuu 8-gou` or `86` it is the name.
fn without_version_numbers(name: &str) -> String {
name.split(' ')
.filter(|word| {
let versioned = word.contains('.')
&& word.chars().any(|c| c.is_ascii_digit())
&& word
.chars()
.all(|c| c.is_ascii_digit() || matches!(c, '.' | '+'));
!versioned
})
.collect::<Vec<_>>()
.join(" ")
}
/// `Heavens Feel` → `Heaven Feel`.
///
/// Only words long enough to still mean something without it, and only where
@@ -836,6 +864,29 @@ mod tests {
);
}
/// **What three of the four Rebuild films matched as: nothing.** AniList
/// requires every word, and a film's version number is not one of its
/// title's.
#[test]
fn a_films_version_number_is_searched_for_again_without_it() {
assert_eq!(
search_terms("Evangelion - 1.11 You Are Alone"),
vec![
"Evangelion - 1.11 You Are Alone".to_string(),
"Evangelion - You Are Alone".to_string(),
]
);
assert_eq!(
search_terms("Evangelion - 3.0+1.01 Thrice Upon a Time")[1],
"Evangelion - Thrice Upon a Time"
);
assert_eq!(
search_terms("Kaijuu 8-gou"),
vec!["Kaijuu 8-gou"],
"a number that is part of the name stays"
);
}
/// Byte slicing, on names that are not ASCII.
#[test]
fn a_multi_byte_name_is_not_cut_inside_a_character() {