mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
65a669e231
commit
238ecd8220
5 files changed
+341
-9
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user