From 238ecd822007723a95cca9c4ca40b16bf2e4a5c0 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 27 Oct 2026 19:50:26 +0100 Subject: [PATCH] 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. --- crates/pstr-core/src/catalog.rs | 211 +++++++++++++++++++++++++++++++- crates/pstr-core/src/naming.rs | 74 ++++++++++- crates/pstr-meta/src/anilist.rs | 51 ++++++++ docs/METADATA.md | 10 +- docs/USER_GUIDE.md | 4 +- 5 files changed, 341 insertions(+), 9 deletions(-) diff --git a/crates/pstr-core/src/catalog.rs b/crates/pstr-core/src/catalog.rs index 774d066..7e7fbcf 100644 --- a/crates/pstr-core/src/catalog.rs +++ b/crates/pstr-core/src/catalog.rs @@ -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 = 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, + 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> = 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, Option) { #[derive(Debug, Default)] struct Ancestry { title: Option, + /// The link id of the folder [`Ancestry::title`] came from. + title_folder: Option, + /// Whether a grouping folder sat between the file and that one. + through_container: bool, season: Option, year: Option, } @@ -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] diff --git a/crates/pstr-core/src/naming.rs b/crates/pstr-core/src/naming.rs index b770f93..ec6769d 100644 --- a/crates/pstr-core/src/naming.rs +++ b/crates/pstr-core/src/naming.rs @@ -539,6 +539,9 @@ fn find_dash_numbering(text: &str) -> Option { 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 { 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) -> 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() { diff --git a/crates/pstr-meta/src/anilist.rs b/crates/pstr-meta/src/anilist.rs index 5c3ef36..b69503a 100644 --- a/crates/pstr-meta/src/anilist.rs +++ b/crates/pstr-meta/src/anilist.rs @@ -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 { let mut terms = vec![name.to_string()]; @@ -386,9 +392,31 @@ fn search_terms(name: &str) -> Vec { 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::>() + .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() { diff --git a/docs/METADATA.md b/docs/METADATA.md index c2d9edf..80c45cb 100644 --- a/docs/METADATA.md +++ b/docs/METADATA.md @@ -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 *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. ## Phase 3 — reach @@ -168,7 +176,5 @@ Not done: TMDB fills no details. machine. - **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. -- **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 Jikan. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 5c21fa6..51ddfab 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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 | | `Spy x Family/Season 2 - 06.mkv` | Spy x Family, season 2, episode 6 | | `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`. - Folders such as `Specials`, `Extras`, `OVA`, `NCOP` or `Subs` are not taken as a show's name; the folder above them is. - 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 containers.