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 /// - grouping folders (`Movies`, `OVA`, `Specials`) are stepped over, so their
/// contents group under the series above them; /// contents group under the series above them;
/// - whatever the filename claimed as a title, when it differs, is kept as the /// - 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` /// 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 /// 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)) .map(|node| (node.uid.link_id.as_str(), node))
.collect(); .collect();
nodes let mut origins = Vec::new();
let mut rows: Vec<CatalogNode> = nodes
.iter() .iter()
.filter(|node| !node.is_folder() && naming::is_video_file(&node.name)) .filter(|node| !node.is_folder() && naming::is_video_file(&node.name))
.map(|node| { .map(|node| {
let mut row = from_node(share_id, node); let mut row = from_node(share_id, node);
let context = ancestry(node, &by_link); 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 { if let Some(title) = context.title {
// The filename's own title, if it had one and it differs, is the // 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 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. /// 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)] #[derive(Debug, Default)]
struct Ancestry { struct Ancestry {
title: Option<String>, 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>, season: Option<u32>,
year: Option<u32>, year: Option<u32>,
} }
@@ -1822,10 +1902,13 @@ fn ancestry(
match crate::naming::classify_folder(&ancestor.name) { match crate::naming::classify_folder(&ancestor.name) {
FolderRole::Season(season) => found.season = found.season.or(Some(season)), FolderRole::Season(season) => found.season = found.season.or(Some(season)),
FolderRole::Container => {} FolderRole::Container => {
found.through_container |= found.title.is_none();
}
FolderRole::Title(parsed) => { FolderRole::Title(parsed) => {
if found.title.is_none() && !parsed.title.is_empty() { if found.title.is_none() && !parsed.title.is_empty() {
found.title = Some(parsed.title.clone()); found.title = Some(parsed.title.clone());
found.title_folder = Some(link_id.clone());
found.year = found.year.or(parsed.year); found.year = found.year.or(parsed.year);
// `Game of Thrones - Season 1` names both; the season it // `Game of Thrones - Season 1` names both; the season it
// states belongs to the files under it. // states belongs to the files under it.
@@ -3081,6 +3164,126 @@ mod tests {
assert_eq!(row(&film, "film").parsed.title, "Millennium Actress"); 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 /// The share root names the *share*, not a series — using it would label
/// every top-level file identically. /// every top-level file identically.
#[test] #[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') { if is_token_char(tail) && tail != Some(b'v') {
continue; continue;
} }
if is_decimal_tail(text, after) {
continue;
}
found = Some(NumberingMatch { found = Some(NumberingMatch {
numbering: Numbering { numbering: Numbering {
season: None, season: None,
@@ -551,6 +554,24 @@ fn find_dash_numbering(text: &str) -> Option<NumberingMatch> {
found 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. /// 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 /// 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 /// Normalize the title span: separators to spaces, then trim the debris that
/// sits between the title and whatever followed it. /// sits between the title and whatever followed it.
fn clean_title(raw: &str, year: Option<u32>) -> String { 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 let mut text: String = raw
.chars() .char_indices()
.map(|c| match c { .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, 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. /// A version suffix still names the same episode.
#[test] #[test]
fn a_versioned_fansub_episode_keeps_its_number() { 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 /// 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 /// after the name as written found nothing, which is what keeps it from
/// broadening a search that was already working. /// 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> { fn search_terms(name: &str) -> Vec<String> {
let mut terms = vec![name.to_string()]; let mut terms = vec![name.to_string()];
@@ -386,9 +392,31 @@ fn search_terms(name: &str) -> Vec<String> {
if depossessed != name { if depossessed != name {
terms.push(depossessed); terms.push(depossessed);
} }
let unversioned = without_version_numbers(name);
if !unversioned.is_empty() && !terms.contains(&unversioned) {
terms.push(unversioned);
}
terms 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`. /// `Heavens Feel` → `Heaven Feel`.
/// ///
/// Only words long enough to still mean something without it, and only where /// 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. /// Byte slicing, on names that are not ASCII.
#[test] #[test]
fn a_multi_byte_name_is_not_cut_inside_a_character() { fn a_multi_byte_name_is_not_cut_inside_a_character() {
+8 -2
View File
@@ -160,6 +160,14 @@ same facts, tags and franchise row as the desktop's; *Title names* is in
Settings → Appearance. Android's grid is always covers, so it has no Settings → Appearance. Android's grid is always covers, so it has no
*Library tiles* choice. *Library tiles* choice.
**Folders of films.** `pstr_core::catalog::build_rows` gives each file in a
folder of films its own title, so the four Rebuild films in one folder are
four entries, matched one by one, and the franchise folds them back into one
tile. Only when no file is numbered, none sits under `Movies`/`OVA`, and each
names itself differently from the others and from the folder; anything less
is a series with its specials. A film's version number (`1.11`) is not part of its name on
AniList, so a search that finds nothing is asked again without it.
Not done: TMDB fills no details. Not done: TMDB fills no details.
## Phase 3 — reach ## Phase 3 — reach
@@ -168,7 +176,5 @@ Not done: TMDB fills no details.
machine. machine.
- **Routing by title**: AniList first, then TMDB (with a key) or TVmaze for - **Routing by title**: AniList first, then TMDB (with a key) or TVmaze for
whatever is not anime — Game of Thrones in a mostly-anime share. whatever is not anime — Game of Thrones in a mostly-anime share.
- **Folders of films** matched per file: four Rebuild films in one folder are
four entries, not one.
- **Skip intro** from AniSkip for files without chapters, and filler flags from - **Skip intro** from AniSkip for files without chapters, and filler flags from
Jikan. Jikan.
+3 -1
View File
@@ -46,12 +46,14 @@ The folder decides which show a file belongs to. The file name gives the season
| `Bocchi the Rock/[Group] Bocchi the Rock - 07 [1080p].mkv` | Bocchi the Rock, episode 7 | | `Bocchi the Rock/[Group] Bocchi the Rock - 07 [1080p].mkv` | Bocchi the Rock, episode 7 |
| `Spy x Family/Season 2 - 06.mkv` | Spy x Family, season 2, episode 6 | | `Spy x Family/Season 2 - 06.mkv` | Spy x Family, season 2, episode 6 |
| `Akira (1988).mkv` | A film, from 1988 | | `Akira (1988).mkv` | A film, from 1988 |
| `Rebuild of Evangelion/Evangelion - 1.11 You Are (Not) Alone.mkv` and three more | Four films, one title each |
- Season folders can be called `Season 01`, `Season 1`, `S01` or `3rd Season`. - Season folders can be called `Season 01`, `Season 1`, `S01` or `3rd Season`.
- Folders such as `Specials`, `Extras`, `OVA`, `NCOP` or `Subs` are not taken as a show's name; - Folders such as `Specials`, `Extras`, `OVA`, `NCOP` or `Subs` are not taken as a show's name;
the folder above them is. the folder above them is.
- An episode title is read only after a dash, as in `S01E01 - Name`. - An episode title is read only after a dash, as in `S01E01 - Name`.
- A file with no episode number is a film. - A file with no episode number is a film. A folder of such files, each with a different name, is
one title per film.
The app plays `mkv`, `mp4`, `m4v`, `avi`, `mov`, `webm`, `ts`, `m2ts` and most other common video The app plays `mkv`, `mp4`, `m4v`, `avi`, `mov`, `webm`, `ts`, `m2ts` and most other common video
containers. containers.