From a534a80eaa496182235f71ac953a0a6f5cfef323 Mon Sep 17 00:00:00 2001 From: noctuum <25441068+noctuum@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:21:21 +0500 Subject: [PATCH] feat(core): settle a name by its entry's facts A name the list does not hold is kept as a show of its own. --- crates/cli/src/commands.rs | 4 +- crates/cli/src/render.rs | 266 +++- crates/core/src/lib.rs | 1 + crates/core/src/path.rs | 42 + crates/core/src/recognize/corpus.rs | 204 ++- crates/core/src/recognize/mod.rs | 927 ++++++++++- crates/core/src/recognize/normalize.rs | 151 +- crates/core/src/recognize/parse.rs | 417 ++++- crates/core/src/recognize/score.rs | 4 + crates/core/src/session.rs | 291 +++- crates/core/src/settle.rs | 1380 +++++++++++++++++ crates/core/src/timeline.rs | 2 +- crates/core/tests/fixtures/filenames.jsonl | 4 +- .../tests/recognition_against_the_fixtures.rs | 13 +- .../tests/the_session_against_a_recording.rs | 50 +- crates/daemon/src/detection.rs | 297 +++- crates/daemon/src/ipc.rs | 12 +- crates/daemon/src/listing.rs | 35 +- crates/daemon/src/protocol.rs | 284 +++- crates/daemon/src/recognition.rs | 125 +- crates/daemon/tests/the_daemon_runs.rs | 90 +- crates/store/src/lib.rs | 11 + crates/store/src/migrate.rs | 48 +- crates/store/src/names.rs | 240 +++ 24 files changed, 4647 insertions(+), 251 deletions(-) create mode 100644 crates/core/src/settle.rs create mode 100644 crates/store/src/names.rs diff --git a/crates/cli/src/commands.rs b/crates/cli/src/commands.rs index 5f41998..46c4b77 100644 --- a/crates/cli/src/commands.rs +++ b/crates/cli/src/commands.rs @@ -349,7 +349,7 @@ async fn why(daemon: &mut Daemon, out: &mut impl Write) -> Result<()> { daemon.ask(&Request::Why).await?; match daemon.expect_answer().await? { - Response::Why(explained) => write!(out, "{}", render::why(explained.as_ref())) + Response::Why(explained) => write!(out, "{}", render::why(explained.as_deref())) .context("explanation could not be printed"), Response::Error { message } => bail!("{message}"), unexpected => bail!("daemon answered why with {unexpected:?}"), @@ -519,6 +519,7 @@ mod tests { use benshi_core::clock::{Clock, TestClock}; use benshi_core::path::RawPath; use benshi_core::policy::{Policy, PolicyTable}; + use benshi_core::settle::Settled; use benshi_core::token::Token; use benshi_core::trace::Trace; use benshi_core::{ @@ -841,6 +842,7 @@ mod tests { media: MediaRef::LocalFile(path), parsed, answer, + settled: Settled::Unnamed, }); } diff --git a/crates/cli/src/render.rs b/crates/cli/src/render.rs index ded4a75..195d8bf 100644 --- a/crates/cli/src/render.rs +++ b/crates/cli/src/render.rs @@ -14,7 +14,8 @@ use std::time::{Duration, UNIX_EPOCH}; use benshi_core::encoding::{Confidence, decode}; use benshi_core::policy::Policy; -use benshi_core::recognize::parse::{Episode, Parsed}; +use benshi_core::recognize::parse::{Episode, Kind, Parsed}; +use benshi_core::settle::Fact; use benshi_core::{Capabilities, Known, MediaRef, PlayState}; use benshi_daemon::bus::BusEvent; use benshi_daemon::protocol::{DecidedBy, Explanation, Outcome, SourceListing}; @@ -187,27 +188,51 @@ pub fn explanation(of: &Explanation) -> String { ) } -/// What recognition answered, as the lines below the parse. +/// What recognition answered, or the show kept in its place, as the lines +/// below the parse. /// -/// Each line ends in a newline, and a match takes two: what it was recognized -/// as, and the stage that decided. +/// Each line ends in a newline. A match takes two, what it was recognized as +/// and the stage that decided, and a third where a fact of its entry refused +/// the file. An ambiguity takes one, and a line for each entry a fact +/// refused and one for the entry the facts chose. fn answer(of: &Outcome) -> String { match of { Outcome::Recognized { title, episode, stage, - } => format!( - "recognized: \"{title}\", {}\nstage: {}\n", - episode_words(*episode), - decided_by(stage) - ), - Outcome::Ambiguous { candidates } => { + refused, + } => { + let mut shown = format!( + "recognized: \"{title}\", {}\nstage: {}\n", + episode_words(*episode), + decided_by(stage) + ); + if let Some(fact) = refused { + writeln!(shown, "refused: {}", refusal(fact)) + .expect("a String accepts every write"); + } + shown + } + Outcome::Ambiguous { + candidates, + refused, + chosen, + } => { let quoted: Vec = candidates .iter() .map(|candidate| format!("\"{candidate}\"")) .collect(); - format!("ambiguous: {}\n", quoted.join(", ")) + let mut shown = format!("ambiguous: {}\n", quoted.join(", ")); + for (title, fact) in refused { + writeln!(shown, "refused: \"{title}\", {}", refusal(fact)) + .expect("a String accepts every write"); + } + if let Some((title, episode)) = chosen { + writeln!(shown, "chosen: \"{title}\", {}", episode_words(*episode)) + .expect("a String accepts every write"); + } + shown } Outcome::Unrecognized { best: Some(score) } => { format!("unrecognized: closest candidate scored {score:.2}\n") @@ -227,7 +252,13 @@ fn answer(of: &Outcome) -> String { fn spelled(of: &Parsed) -> String { let mut parts = vec![of.title.as_ref().map_or_else( || "no title".to_owned(), - |title| format!("title \"{title}\""), + |title| { + if of.from_directory { + format!("title \"{title}\" from directory") + } else { + format!("title \"{title}\"") + } + }, )]; if let Some(season) = of.season { parts.push(format!("season {season}")); @@ -263,6 +294,35 @@ fn episode_words(of: Episode) -> String { } } +/// The fact of an entry that refused a file, with what the name or the file +/// says beside what the entry holds where the fact compares the two. +/// +/// A length is said as `watch` says one, in minutes and seconds. +fn refusal(of: &Fact) -> String { + match of { + Fact::NotReleased => "entry not released yet".to_owned(), + Fact::Year { spelled, began } => { + format!("name spells year {spelled}, entry began in {began}") + } + Fact::Kind(Kind::Extra) => "name spells OVA or special, entry is TV series".to_owned(), + Fact::Kind(Kind::Film) => "name spells film, entry is TV series".to_owned(), + Fact::Episodes(count) => format!("name spells no episode, entry has {count} episodes"), + Fact::PastCount { episode, episodes } => { + let unit = if *episodes == 1 { + "episode" + } else { + "episodes" + }; + format!("name spells episode {episode}, entry has {episodes} {unit}") + } + Fact::Length { file, episode } => format!( + "file is {} long, under half of entry's {} episode", + moment(Known::Value(*file)), + moment(Known::Value(*episode)) + ), + } +} + /// The stage that decided, by its name, and what it decided by. fn decided_by(of: &DecidedBy) -> String { match of { @@ -278,6 +338,7 @@ fn decided_by(of: &DecidedBy) -> String { } DecidedBy::Key => "key".to_owned(), DecidedBy::Scored(score) => format!("score {score:.2}"), + DecidedBy::Unlisted => "not on list, kept as show of its own".to_owned(), } } @@ -346,7 +407,8 @@ mod tests { use benshi_core::path::RawPath; use benshi_core::policy::Policy; use benshi_core::recognize::altname::Assignment; - use benshi_core::recognize::parse::Episode; + use benshi_core::recognize::parse::{Episode, Kind}; + use benshi_core::settle::Fact; use benshi_core::{ AppName, Capabilities, Known, MediaRef, PlayState, PlayerId, PlayerSnapshot, ShowId, }; @@ -654,6 +716,7 @@ mod tests { title: title.to_owned(), episode: Episode::Only(3), stage, + refused: None, } } @@ -706,6 +769,30 @@ mod tests { assert!(spelled.contains("\"Group\""), "{spelled}"); } + #[test] + fn a_title_read_from_a_directory_says_so() { + let shown = explanation(&explained( + b"/video/Show.Title.Season.1.1999.DVDRip-Group/01 Quiet Harbor.mkv", + Outcome::Unrecognized { best: None }, + )); + + let spelled = line_of(&shown, "spelled:"); + assert!( + spelled.starts_with("spelled: title \"Show Title\" from directory, "), + "{spelled}" + ); + + let shown = explanation(&explained( + b"/anime/[Group] Show Title - 03.mkv", + Outcome::Unrecognized { best: None }, + )); + + assert!( + !line_of(&shown, "spelled:").contains("directory"), + "{shown}" + ); + } + #[test] fn a_name_that_spelled_no_title_says_so() { let shown = explanation(&explained( @@ -798,6 +885,19 @@ mod tests { } } + #[test] + fn a_name_the_list_does_not_hold_says_so_in_place_of_a_stage() { + let shown = explanation(&explained( + b"[Group] Some Other Show - 03.mkv", + found("Some Other Show", DecidedBy::Unlisted), + )); + + assert_eq!( + line_of(&shown, "stage:"), + "stage: not on list, kept as show of its own" + ); + } + #[test] fn a_match_by_score_shows_the_score_it_won_with() { let shown = explanation(&explained( @@ -808,6 +908,84 @@ mod tests { assert!(line_of(&shown, "stage:").contains("0.83"), "{shown}"); } + #[test] + fn a_file_a_fact_refused_names_the_fact_after_the_stage() { + // The line after the stage, one for each fact, with what the name or + // the file says beside what the entry holds where the fact compares + // the two. + let cases = [ + (Fact::NotReleased, "refused: entry not released yet"), + ( + Fact::Year { + spelled: 2019, + began: 2021, + }, + "refused: name spells year 2019, entry began in 2021", + ), + ( + Fact::Episodes(2), + "refused: name spells no episode, entry has 2 episodes", + ), + ( + Fact::Length { + file: Duration::from_secs(114), + episode: Duration::from_mins(40), + }, + "refused: file is 01:54 long, under half of entry's 40:00 episode", + ), + ( + Fact::PastCount { + episode: 5, + episodes: 1, + }, + "refused: name spells episode 5, entry has 1 episode", + ), + ( + Fact::Kind(Kind::Extra), + "refused: name spells OVA or special, entry is TV series", + ), + ( + Fact::Kind(Kind::Film), + "refused: name spells film, entry is TV series", + ), + ( + Fact::PastCount { + episode: 13, + episodes: 12, + }, + "refused: name spells episode 13, entry has 12 episodes", + ), + ]; + + for (fact, line) in cases { + let shown = explanation(&explained( + b"[Group] Show Title [1080p].mkv", + Outcome::Recognized { + title: "Show Title".to_owned(), + episode: Episode::Absent, + stage: DecidedBy::Key, + refused: Some(fact), + }, + )); + + let lines: Vec<&str> = shown.lines().collect(); + assert_eq!(lines.len(), 5, "{shown:?}"); + assert_eq!(lines[3], "stage: key", "{shown:?}"); + assert_eq!(lines[4], line, "{shown:?}"); + } + } + + #[test] + fn a_file_the_facts_let_stand_has_no_line_of_them() { + let shown = explanation(&explained( + b"[Group] Show Title - 03.mkv", + found("Show Title", DecidedBy::Key), + )); + + assert_eq!(shown.lines().count(), 4, "{shown:?}"); + assert!(!shown.contains("refused"), "{shown:?}"); + } + #[test] fn an_ambiguity_names_every_candidate() { let shown = explanation(&explained( @@ -817,6 +995,8 @@ mod tests { "Fruits Basket".to_owned(), "Fruits Basket (2019)".to_owned(), ], + refused: Vec::new(), + chosen: None, }, )); @@ -828,6 +1008,65 @@ mod tests { ); } + #[test] + fn an_ambiguity_the_facts_settled_names_each_refusal_and_what_they_chose() { + let shown = explanation(&explained( + b"Show Title (1988).mkv", + Outcome::Ambiguous { + candidates: vec!["Show Title".to_owned(), "Show Title".to_owned()], + refused: vec![("Show Title".to_owned(), Fact::NotReleased)], + chosen: Some(("Show Title".to_owned(), Episode::Absent)), + }, + )); + + let lines: Vec<&str> = shown.lines().skip(2).collect(); + assert_eq!( + lines, + [ + "ambiguous: \"Show Title\", \"Show Title\"", + "refused: \"Show Title\", entry not released yet", + "chosen: \"Show Title\", no episode", + ], + "{shown:?}" + ); + } + + #[test] + fn an_ambiguity_the_facts_left_undecided_names_each_refusal_and_nothing_chosen() { + let shown = explanation(&explained( + b"[Group] Show Title - 05.mkv", + Outcome::Ambiguous { + candidates: vec![ + "Show Title".to_owned(), + "Show Title (2019)".to_owned(), + "Show Title (2027)".to_owned(), + ], + refused: vec![ + ( + "Show Title".to_owned(), + Fact::Year { + spelled: 2019, + began: 2001, + }, + ), + ("Show Title (2027)".to_owned(), Fact::Episodes(2)), + ], + chosen: None, + }, + )); + + let lines: Vec<&str> = shown.lines().skip(2).collect(); + assert_eq!( + lines, + [ + "ambiguous: \"Show Title\", \"Show Title (2019)\", \"Show Title (2027)\"", + "refused: \"Show Title\", name spells year 2019, entry began in 2001", + "refused: \"Show Title (2027)\", name spells no episode, entry has 2 episodes", + ], + "{shown:?}" + ); + } + #[test] fn a_refusal_says_how_close_the_best_came_or_that_nothing_was_scored() { // Absent and low are different answers and lead different places: a @@ -871,6 +1110,7 @@ mod tests { title: "Show Title".to_owned(), episode, stage: DecidedBy::Key, + refused: None, }, )); diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index c8ba7eb..f91cf20 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -12,6 +12,7 @@ pub mod path; pub mod policy; pub mod recognize; pub mod session; +pub mod settle; pub mod timeline; pub mod token; pub mod trace; diff --git a/crates/core/src/path.rs b/crates/core/src/path.rs index c1a96d1..f8bdbd9 100644 --- a/crates/core/src/path.rs +++ b/crates/core/src/path.rs @@ -69,6 +69,17 @@ impl RawPath { None => &self.0, } } + + /// Everything before the last `/`, which is the directory the path is in, + /// or nothing where the path has no `/` or its last `/` is the first byte. + /// + /// A path with no `/` is in no directory it names, and a path whose only + /// `/` is the first byte is in the root, which has no name to read. + #[must_use] + pub fn parent(&self) -> Option { + let last = self.0.iter().rposition(|byte| *byte == b'/')?; + (last > 0).then(|| Self(self.0[..last].to_vec())) + } } /// Shows the escaped form, which is readable for the cases that are readable @@ -181,6 +192,37 @@ mod tests { /// The windows-1251 encoding of a Cyrillic filename. Not valid UTF-8. const CP1251: &[u8] = b"/anime/\xcf\xf0\xe8\xe2\xe5\xf2.mkv"; + #[test] + fn a_path_is_in_the_directory_before_its_last_slash() { + let path = RawPath::from_bytes(b"/anime/Show Title/01.mkv".to_vec()); + + let parent = path.parent().expect("a directory"); + assert_eq!(parent.as_bytes(), b"/anime/Show Title"); + assert_eq!(parent.file_name(), b"Show Title"); + let above = parent.parent().expect("a directory above"); + assert_eq!(above.as_bytes(), b"/anime"); + assert_eq!(above.parent(), None, "the root has no name"); + assert_eq!( + RawPath::from_bytes(b"Show Title/01.mkv".to_vec()) + .parent() + .map(|parent| parent.as_bytes().to_vec()), + Some(b"Show Title".to_vec()) + ); + assert_eq!(RawPath::from_bytes(b"01.mkv".to_vec()).parent(), None); + assert_eq!( + RawPath::from_bytes(b"/anime//01.mkv".to_vec()) + .parent() + .map(|parent| parent.as_bytes().to_vec()), + Some(b"/anime/".to_vec()) + ); + assert_eq!( + RawPath::from_bytes(b"//01.mkv".to_vec()) + .parent() + .map(|parent| parent.as_bytes().to_vec()), + Some(b"/".to_vec()) + ); + } + #[test] fn a_utf8_path_round_trips() { let path = RawPath::from_bytes(b"/anime/[Group] Show - 03.mkv".to_vec()); diff --git a/crates/core/src/recognize/corpus.rs b/crates/core/src/recognize/corpus.rs index 799cf8e..649a0a6 100644 --- a/crates/core/src/recognize/corpus.rs +++ b/crates/core/src/recognize/corpus.rs @@ -24,7 +24,9 @@ use std::collections::HashMap; use crate::ShowId; -use crate::recognize::normalize::Key; +use crate::recognize::normalize::{Key, without_type_words}; +use crate::recognize::parse::Kind; +use crate::settle::Facts; /// The shows recognition matches against. /// @@ -39,6 +41,17 @@ pub struct Corpus { filed: HashMap>, /// The title of every show filed, which is what an answer shows a person. titles: HashMap, + /// What the list holds of each show filed with its facts, which settles + /// whether a file is of it. + facts: HashMap, + /// Each spelling that `without_type_words` takes type words out of, filed + /// again without them under each kind they name, for a name that spells + /// that kind: where the parser leaves the word out of the title it + /// reports, the key would miss. + typed: HashMap>>, + /// The numbers each show's spellings carry, a spelling of no letters + /// among them, which a year a name spells is read against. + numbers: HashMap>, } /// One spelling of one entry. @@ -75,24 +88,57 @@ impl Corpus { ) { self.titles.insert(show, title.to_owned()); self.put(title, show); + self.keep_numbers(title, show); for spelling in also_spelled { self.put(spelling.as_ref(), show); + self.keep_numbers(spelling.as_ref(), show); } } - /// File one spelling of one entry. + /// Keep the numbers one spelling of one entry carries. + fn keep_numbers(&mut self, spelling: &str, show: ShowId) { + let carried = spelling + .split(|c: char| !c.is_ascii_digit()) + .filter_map(|digits| digits.parse::().ok()); + self.numbers.entry(show).or_default().extend(carried); + } + + /// File one spelling of one entry, and again without the type words + /// `without_type_words` takes out of it. fn put(&mut self, spelling: &str, show: ShowId) { - let Some(key) = Key::from_title(spelling) else { - return; - }; - self.filed - .entry(key.without_marks()) - .or_default() - .push(Filed { - marks: key.marks().to_owned(), - spelled: spelling.to_owned(), - show, - }); + file_into(&mut self.filed, spelling, spelling, show); + if let Some((untyped, kinds)) = without_type_words(spelling) { + for kind in kinds { + file_into( + self.typed.entry(kind).or_default(), + &untyped, + spelling, + show, + ); + } + } + } + + /// File what the list holds of a show, in place of what was filed of it + /// before. + pub fn file_facts(&mut self, show: ShowId, facts: Facts) { + self.facts.insert(show, facts); + } + + /// What the list holds of a show, and nothing where it was filed with no + /// facts. + #[must_use] + pub(crate) fn facts_of(&self, show: ShowId) -> Option<&Facts> { + self.facts.get(&show) + } + + /// Whether a run of digits in a spelling of a show reads as this number, + /// as `Show Title 2032` carries 2032 and not 203. + #[must_use] + pub(crate) fn spells_number(&self, show: ShowId, number: u32) -> bool { + self.numbers + .get(&show) + .is_some_and(|numbers| numbers.contains(&number)) } /// The title a show was filed with, and nothing where it was not filed. @@ -131,6 +177,20 @@ impl Corpus { .map(|filed| (filed.spelled.as_str(), filed.show)) } + /// The entries a key reaches through a spelling that held a type word of + /// this kind, filed with the word taken out, in the order they were + /// filed. + /// + /// For a name that spells that kind, and only for one. Each kind is filed + /// apart: a name of an OVA reaches a spelling here only through a type + /// word of an extra. + #[must_use] + pub(crate) fn typed_candidates(&self, kind: Kind, key: &Key) -> Vec { + self.typed + .get(&kind) + .map_or_else(Vec::new, |typed| reached(typed, key)) + } + /// The entries a key reaches, in the order they were filed. /// /// Empty where nothing is filed under it, which is not a refusal: an exact @@ -138,18 +198,37 @@ impl Corpus { /// that misses. #[must_use] pub fn candidates(&self, key: &Key) -> Vec { - let mut found: Vec = Vec::new(); - let Some(bucket) = self.filed.get(&key.without_marks()) else { - return found; - }; - for filed in bucket { - let reached = key.marks().is_empty() || filed.marks == key.marks(); - if reached && !found.contains(&filed.show) { - found.push(filed.show); - } + reached(&self.filed, key) + } +} + +/// File `filed_as` under its key, as one spelling of one entry spelled +/// `spelling`. +fn file_into(map: &mut HashMap>, filed_as: &str, spelling: &str, show: ShowId) { + let Some(key) = Key::from_title(filed_as) else { + return; + }; + map.entry(key.without_marks()).or_default().push(Filed { + marks: key.marks().to_owned(), + spelled: spelling.to_owned(), + show, + }); +} + +/// The entries a key reaches in one map of spellings, in the order they +/// were filed. +fn reached(map: &HashMap>, key: &Key) -> Vec { + let mut found: Vec = Vec::new(); + let Some(bucket) = map.get(&key.without_marks()) else { + return found; + }; + for filed in bucket { + let reached = key.marks().is_empty() || filed.marks == key.marks(); + if reached && !found.contains(&filed.show) { + found.push(filed.show); } - found } + found } impl> FromIterator for Corpus { @@ -166,9 +245,13 @@ impl> FromIterator for Corpus { #[cfg(test)] mod tests { + use std::time::Duration; + use super::Corpus; use crate::ShowId; use crate::recognize::normalize::Key; + use crate::recognize::parse::Kind; + use crate::settle::Facts; /// The key a list entry spelled this way is filed under. fn listed(title: &str) -> Key { @@ -185,6 +268,57 @@ mod tests { .collect() } + #[test] + fn a_spelling_with_a_type_word_is_reached_without_it_by_a_name_of_a_type() { + let mut corpus = Corpus::new(); + corpus.file(ShowId(1), "Show Title Season 3", Vec::<&str>::new()); + corpus.file( + ShowId(2), + "Show Title Season 3 OVA", + ["Show Title Season 3 Specials"], + ); + corpus.file(ShowId(3), "Show Title The Movie", Vec::<&str>::new()); + + let season = listed("Show Title Season 3"); + assert_eq!(corpus.candidates(&season), [ShowId(1)]); + assert_eq!(corpus.typed_candidates(Kind::Extra, &season), [ShowId(2)]); + assert_eq!( + corpus.typed_candidates(Kind::Film, &listed("Show Title The")), + [ShowId(3)] + ); + assert_eq!( + corpus.typed_candidates(Kind::Extra, &listed("Show Title The")), + Vec::::new(), + "a film is reached by a name of a film alone" + ); + assert_eq!( + corpus.typed_candidates(Kind::Extra, &listed("Show Title Season 3 OVA")), + Vec::::new(), + "under its kind, a spelling is filed only with its type word taken out" + ); + } + + #[test] + fn a_number_a_spelling_carries_is_known_of_its_show_alone() { + let mut corpus = Corpus::new(); + corpus.file(ShowId(1), "Show Title 2032", ["Other Title (1987 Release)"]); + corpus.file(ShowId(2), "Second Show", Vec::<&str>::new()); + corpus.file(ShowId(3), "Third Show", ["1999"]); + + assert!(corpus.spells_number(ShowId(1), 2032)); + assert!(corpus.spells_number(ShowId(1), 1987)); + assert!( + corpus.spells_number(ShowId(3), 1999), + "a spelling of no letters" + ); + assert!(!corpus.spells_number(ShowId(1), 203), "part of a number"); + assert!(!corpus.spells_number(ShowId(1), 2033)); + assert!( + !corpus.spells_number(ShowId(2), 2032), + "another show's number" + ); + } + #[test] fn an_entry_answers_the_key_of_its_own_spelling() { let corpus: Corpus = ["Show Title", "Another Show"].into_iter().collect(); @@ -304,6 +438,30 @@ mod tests { ); } + #[test] + fn a_show_answers_the_facts_it_was_filed_with() { + let mut corpus = Corpus::new(); + corpus.file(ShowId(1), "Show Title", Vec::<&str>::new()); + corpus.file(ShowId(2), "Another Show", Vec::<&str>::new()); + let first = Facts { + episodes: Some(12), + episode_length: Some(Duration::from_mins(24)), + year: Some(2019), + released: true, + format: None, + }; + let again = Facts { + episodes: Some(13), + ..first + }; + + corpus.file_facts(ShowId(1), first); + assert_eq!(corpus.facts_of(ShowId(1)), Some(&first)); + corpus.file_facts(ShowId(1), again); + assert_eq!(corpus.facts_of(ShowId(1)), Some(&again)); + assert_eq!(corpus.facts_of(ShowId(2)), None); + } + #[test] fn a_spelling_with_no_letters_is_filed_nowhere() { // `Season 2` alone names no entry, so it has no key to be filed under diff --git a/crates/core/src/recognize/mod.rs b/crates/core/src/recognize/mod.rs index 6e0b0ee..17d3dfd 100644 --- a/crates/core/src/recognize/mod.rs +++ b/crates/core/src/recognize/mod.rs @@ -3,8 +3,10 @@ //! The pipeline has four stages: a mapping the user made by hand, an exact match //! on a normalized key, a token index that narrows the corpus to a handful, and //! a score over what is left. **Only three of them can answer.** The index -//! decides nothing - it hands candidates to the scorer - so [`Stage`] has three -//! variants and not four. Why they run in that order is written on [`Stage`]. +//! decides nothing: it hands candidates to the scorer. So three variants of +//! [`Stage`] are stages of the pipeline, and the fourth, [`Stage::Unlisted`], +//! is what the daemon answers for a name the pipeline refused. Why they run in +//! that order is written on [`Stage`]. //! //! The answer is defined for every stage, so that each one is written against a //! shape that already has to explain itself. [`by_key`] is the only stage here @@ -22,11 +24,12 @@ pub mod parse; pub mod score; use crate::ShowId; +use crate::path::RawPath; use crate::recognize::altname::{Altnames, Assignment}; use crate::recognize::corpus::Corpus; use crate::recognize::index::Index; -use crate::recognize::normalize::Key; -use crate::recognize::parse::{Episode, Parsed}; +use crate::recognize::normalize::{Key, without_type_words}; +use crate::recognize::parse::{Episode, Kind, Parsed, kind_of, parse}; use crate::recognize::score::by_score; /// What the stages answer, in the order they are asked. @@ -54,6 +57,189 @@ pub fn decide(parsed: &Parsed, altnames: &Altnames, corpus: &Corpus, index: &Ind }) } +/// Decide a name, and read it another way where neither an assignment nor +/// the key answers the title as parsed. +/// +/// `Show Title 13.mkv` reads as the title `Show Title` and episode 13. Where +/// neither an assignment nor the key answers that title, and an assignment +/// or the list holds `Show Title 13`, the name is that work with no episode. +/// The words after the number then belong to the title too, so +/// `Show Title 13 - The Subtitle` reaches only the entry that spells all of +/// it. Only spaces and underscores may part the title from the number, so +/// `Show Title - 13` stays the thirteenth episode whatever the list holds. +/// In the same way `01 Show Title.mkv` is episode one of `Show Title` where +/// the list answers that and not `01 Show Title`. +/// +/// Where no other reading is answered either, the name is decided as the +/// parser read it. The other readings are asked of the assignments and the +/// key, and before the scorer is asked of the title as parsed: against a +/// list holding `Show Title 13` alone, the scorer answers `Show Title` as +/// its thirteenth episode. +/// +/// Answers with the parse the answer was decided by. +#[must_use] +pub fn read( + parsed: Parsed, + altnames: &Altnames, + corpus: &Corpus, + index: &Index, +) -> (Parsed, Recognition) { + if by_altname(&parsed, altnames).is_none() && by_key(&parsed, corpus).is_none() { + for other in other_readings(&parsed) { + let answer = by_altname(&other, altnames) + .map(Recognition::Recognized) + .or_else(|| by_key(&other, corpus)); + if let Some(answer) = answer { + return (other, answer); + } + } + } + let answer = decide(&parsed, altnames, corpus, index); + (parsed, answer) +} + +/// The readings a parse keeps beside its own: its title read on through its +/// number, and the episode it begins with as an episode of the show the +/// words after it name. +fn other_readings(parsed: &Parsed) -> impl Iterator { + let reading = |title: String, episode: Episode| Parsed { + title: Some(title), + episode, + title_with_number: None, + episode_first: None, + ..parsed.clone() + }; + let read_on = parsed + .title_with_number + .clone() + .map(|title| reading(title, Episode::Absent)); + let first = parsed + .episode_first + .clone() + .map(|(episode, title)| reading(title, Episode::Only(episode))); + + read_on.into_iter().chain(first) +} + +/// Decide a file by its name, and by the directories above it where the name +/// reaches nothing and may not spell its show: it spells an episode but no +/// title, or begins with its episode as [`Parsed::episode_first`] reads one. +/// +/// [`read`] decides the name alone. A directory is not part of what a +/// release spells, and one named after another show would carry that show's +/// words into the name, so a name that spells a title and an episode, as +/// `Show Title - 03.mkv`, is never read against its directory. A name that +/// begins with its episode may not spell its show at all: +/// `01 Quiet Harbor.mkv` and `01 - Quiet Harbor.mkv` are episode one, and +/// the show is in the directory, as `Show Title/01 Quiet Harbor.mkv`. +/// +/// The directories are read from the file up. A directory is a part of the +/// show above it where it spells no title, as `Season 2`, a type word alone, +/// as `Specials`, or a number the parser reads as an episode, as `Disc 1`. +/// Such a directory gives the reading the season, the part and the type it +/// spells, where the name and the directories below it spell none. The first +/// directory that names a show is decided with the file's episode, and the +/// walk ends there. A directory the parser reads as a title and a number, as +/// `Show Title 13`, is a show where the list answers it and a part of one +/// where it does not. A path through `..` ends the walk there, because the +/// name of the directory it leads to is not in the path. +/// +/// Where that show reaches nothing either, the directory's reading is +/// answered, with its refusal, only where the directory names a release: it +/// spells a season, a year, a release group or a type. A directory of a +/// title alone, as `video`, can hold many shows, so the name is then +/// answered as it spells itself. +#[must_use] +pub fn read_file( + path: &RawPath, + altnames: &Altnames, + corpus: &Corpus, + index: &Index, +) -> (Parsed, Recognition) { + let name = parse(&RawPath::from_bytes(path.file_name().to_vec())); + let episode = match &name.episode_first { + Some((episode, _)) => Episode::Only(*episode), + None if name.title.is_none() && name.episode != Episode::Absent => name.episode, + None => return read(name, altnames, corpus, index), + }; + + let (parsed, answer) = read(name, altnames, corpus, index); + if !matches!(answer, Recognition::Unrecognized(_)) { + return (parsed, answer); + } + by_directory(path, &parsed, episode, altnames, corpus, index).unwrap_or((parsed, answer)) +} + +/// The show a directory above the file names, decided with the file's +/// episode, as [`read_file`] says. +fn by_directory( + path: &RawPath, + name: &Parsed, + episode: Episode, + altnames: &Altnames, + corpus: &Corpus, + index: &Index, +) -> Option<(Parsed, Recognition)> { + let mut season = name.season; + let mut part = name.part; + let mut kind = name.kind; + let mut directory = path.parent(); + + while let Some(here) = directory { + directory = here.parent(); + match here.file_name() { + b".." => return None, + b"." => continue, + _ => {} + } + let spelled = parse(&RawPath::from_bytes(here.file_name().to_vec())); + let type_alone = spelled + .title + .as_deref() + .is_some_and(|title| title.split_whitespace().all(|word| kind_of(word).is_some())); + + if !type_alone && let Some(title) = spelled.title.clone() { + let as_show = |title: String| Parsed { + title: Some(title), + episode, + season: season.or(spelled.season), + part: part.or(spelled.part), + year: name.year.or(spelled.year), + kind: kind.or(spelled.kind), + title_with_number: None, + episode_first: None, + from_directory: true, + release_group: name.release_group.clone(), + confidence: spelled.confidence, + text: spelled.text.clone(), + }; + if spelled.episode == Episode::Absent { + let show = as_show(title); + let answer = decide(&show, altnames, corpus, index); + let names_a_release = spelled.season.is_some() + || spelled.year.is_some() + || spelled.release_group.is_some() + || spelled.kind.is_some(); + return (names_a_release || !matches!(answer, Recognition::Unrecognized(_))) + .then_some((show, answer)); + } + if let Some(numbered) = spelled.title_with_number.clone() { + let show = as_show(numbered); + let answer = decide(&show, altnames, corpus, index); + if !matches!(answer, Recognition::Unrecognized(_)) { + return Some((show, answer)); + } + } + } + + season = season.or(spelled.season); + part = part.or(spelled.part); + kind = kind.or(spelled.kind); + } + + None +} + /// The entry the user named this file's spelling, where they named one. /// /// A [`Match`] rather than a [`Recognition`]: this stage cannot be ambiguous, @@ -90,8 +276,21 @@ pub fn by_altname(parsed: &Parsed, altnames: &Altnames) -> Option { #[must_use] pub fn by_key(parsed: &Parsed, corpus: &Corpus) -> Option { let spelled = parsed.title.as_deref()?; - let candidates: Vec = corpus - .candidates(&Key::from_parsed(parsed)?) + let key = Key::from_parsed(parsed)?; + let mut shows = reached_by(&key, parsed.kind, corpus); + if shows.is_empty() + && parsed.kind.is_some() + && let Some((title, _)) = without_type_words(spelled) + && let Some(untyped) = Key::from_parsed(&Parsed { + title: Some(title), + ..parsed.clone() + }) + { + // The parser leaves a type word in a title that no season ends, as + // in `Show Title OVA - 01`, where a list spells `Show Title (OVA)`. + shows = reached_by(&untyped, parsed.kind, corpus); + } + let candidates: Vec = shows .into_iter() .map(|show| Candidate { show, @@ -114,6 +313,21 @@ pub fn by_key(parsed: &Parsed, corpus: &Corpus) -> Option { } } +/// The entries a key reaches, in the order they were filed, and after them +/// those it reaches through a spelling that held a type word of the kind the +/// name spells, in the order those were filed. +fn reached_by(key: &Key, kind: Option, corpus: &Corpus) -> Vec { + let mut shows = corpus.candidates(key); + if let Some(kind) = kind { + for show in corpus.typed_candidates(kind, key) { + if !shows.contains(&show) { + shows.push(show); + } + } + } + shows +} + /// How well a candidate title matched, from nothing to exactly. /// /// A proportion and nothing else can be one, so it is checked on the way in and @@ -171,18 +385,30 @@ pub enum Stage { /// would read as the worst possible match and one as a perfect comparison /// that never happened; both are false of an exact match. Scored(Score), + /// A name the list does not hold, kept by its exact key as a show of its + /// own. The show is filed the first time the key is seen and found by + /// the key every time after. + /// + /// Asked after every stage of the list has refused the name, by the key + /// alone: the scorer never compares a name with one of these, so a name + /// that is close to one is a show of its own. + Unlisted, } /// A filename that was recognized. /// /// The show is the corpus entry that matched, rather than the text the filename /// spelled. A [`Refusal`] carries that second thing, and confusing the two is how -/// a near miss gets written down as a title. +/// a near miss gets written down as a title. Under [`Stage::Unlisted`] the show +/// is one kept for a name the list does not hold, and the title is the name's +/// own spelling. #[derive(Debug, Clone, PartialEq)] pub struct Match { - /// The show the corpus filed the entry as. + /// The show the corpus filed the entry as, or the show kept for a name the + /// list does not hold. pub show: ShowId, - /// The title as the corpus spells it. + /// The title as the corpus spells it, or as the name spelled it under + /// [`Stage::Unlisted`]. pub title: String, /// The episode in the release's numbering, as it was read from the name. /// @@ -240,7 +466,8 @@ pub struct Refusal { pub struct Ambiguity { /// The title as the filename spelled it. pub parsed: String, - /// The entries it reaches, in the order the corpus filed them. + /// The entries it reaches: those its key reaches in the order the corpus + /// filed them, then those it reaches through a type word. /// /// More than one wherever [`by_key`] built it: one candidate is a [`Match`] /// and none is no ambiguity at all. Reserved rather than enforced, as @@ -281,13 +508,17 @@ pub enum Recognition { #[cfg(test)] mod tests { - use super::{Ambiguity, Candidate, Match, Recognition, Refusal, Score, Stage, by_key, decide}; + use super::{ + Ambiguity, Candidate, Match, Recognition, Refusal, Score, Stage, by_key, decide, read, + read_file, + }; use crate::ShowId; + use crate::encoding::Confidence; use crate::path::RawPath; use crate::recognize::altname::{Altnames, Assignment}; use crate::recognize::corpus::Corpus; use crate::recognize::index::Index; - use crate::recognize::parse::{Episode, Parsed, parse}; + use crate::recognize::parse::{Episode, Kind, Parsed, parse}; /// A score the tests use where the exact value is not the point. fn a_score(value: f64) -> Score { @@ -323,6 +554,678 @@ mod tests { } } + /// What a file of this name is read as against this corpus, with no + /// assignments. + fn read_as(name: &str, corpus: &Corpus) -> (Parsed, Recognition) { + read(named(name), &Altnames::new(), corpus, &Index::of(corpus)) + } + + #[test] + fn a_title_that_ends_in_a_number_is_read_on_where_its_key_reaches_nothing() { + let mut corpus = Corpus::new(); + corpus.file(ShowId(1), "Show Title 13", Vec::<&str>::new()); + + let (parsed, answer) = read_as("Show Title 13.mkv", &corpus); + + assert_eq!(parsed.title.as_deref(), Some("Show Title 13")); + assert_eq!(parsed.episode, Episode::Absent); + assert_eq!(parsed.title_with_number, None); + assert_eq!(answer, recognized(1, "Show Title 13", Episode::Absent)); + } + + #[test] + fn a_title_read_on_keeps_the_words_after_its_number() { + let mut corpus = Corpus::new(); + corpus.file(ShowId(1), "Show Title 13", Vec::<&str>::new()); + corpus.file( + ShowId(2), + "Show Title 13 - The Subtitle", + Vec::<&str>::new(), + ); + + let (parsed, answer) = read_as("Show Title 13 - The Subtitle.mkv", &corpus); + + assert_eq!( + parsed.title.as_deref(), + Some("Show Title 13 - The Subtitle") + ); + assert_eq!( + answer, + recognized(2, "Show Title 13 - The Subtitle", Episode::Absent) + ); + } + + /// What a file at this path is read as against this corpus, with no + /// assignments. + fn read_at(path: &str, corpus: &Corpus) -> (Parsed, Recognition) { + read_file( + &RawPath::from_bytes(path.as_bytes().to_vec()), + &Altnames::new(), + corpus, + &Index::of(corpus), + ) + } + + /// A corpus of these titles, each spelled one way, filed as shows + /// numbered from one in the order given. + fn a_list_of(titles: &[&str]) -> Corpus { + let mut corpus = Corpus::new(); + for (show, title) in (1..).zip(titles) { + corpus.file(ShowId(show), title, Vec::<&str>::new()); + } + corpus + } + + #[test] + fn a_name_that_begins_with_its_episode_reaches_the_show_named_after_it() { + let (parsed, answer) = read_as("01 Show Title.mkv", &a_list_of(&["Show Title"])); + + assert_eq!(parsed.title.as_deref(), Some("Show Title")); + assert_eq!(parsed.episode_first, None); + assert_eq!(answer, recognized(1, "Show Title", Episode::Only(1))); + } + + #[test] + fn a_title_that_begins_with_a_number_reaches_its_entry_whole() { + let (parsed, answer) = read_as( + "12 Quiet Lanterns.mkv", + &a_list_of(&["12 Quiet Lanterns", "Quiet Lanterns"]), + ); + + assert_eq!(parsed.episode, Episode::Absent); + assert_eq!(answer, recognized(1, "12 Quiet Lanterns", Episode::Absent)); + } + + #[test] + fn a_name_that_begins_with_its_episode_is_of_the_show_its_directory_names() { + let (parsed, answer) = read_at( + "/anime/Show Title/01 Quiet Harbor.mkv", + &a_list_of(&["Show Title"]), + ); + + assert_eq!(parsed.title.as_deref(), Some("Show Title")); + assert!(parsed.from_directory); + assert_eq!(answer, recognized(1, "Show Title", Episode::Only(1))); + } + + #[test] + fn a_name_with_no_title_is_of_the_show_its_directory_names() { + let (parsed, answer) = read_at( + "/anime/Show Title/01 - Quiet Harbor.mkv", + &a_list_of(&["Show Title"]), + ); + + assert!(parsed.from_directory); + assert_eq!(answer, recognized(1, "Show Title", Episode::Only(1))); + + let (_, answer) = read_at("/anime/Show Title/01.mkv", &a_list_of(&["Show Title"])); + + assert_eq!(answer, recognized(1, "Show Title", Episode::Only(1))); + } + + #[test] + fn a_name_with_a_title_of_its_own_never_asks_its_directory() { + let (parsed, answer) = read_at( + "/anime/Other Show/[Group] Show Title - 03.mkv", + &a_list_of(&["Other Show"]), + ); + + assert_eq!(parsed.title.as_deref(), Some("Show Title")); + assert!(!parsed.from_directory); + assert!( + matches!(answer, Recognition::Unrecognized(_)), + "got {answer:?}" + ); + } + + #[test] + fn a_season_or_a_disc_directory_is_part_of_the_show_above_it() { + let list = a_list_of(&["Show Title", "Show Title Season 2"]); + + let (parsed, answer) = read_at("/anime/Show Title/Season 2/01 - Quiet Harbor.mkv", &list); + + assert_eq!(parsed.season, Some(2)); + assert_eq!( + answer, + recognized(2, "Show Title Season 2", Episode::Only(1)) + ); + + let (_, answer) = read_at("/anime/Show Title/Disc 1/01 Quiet Harbor.mkv", &list); + + assert_eq!(answer, recognized(1, "Show Title", Episode::Only(1))); + } + + #[test] + fn a_directory_gives_what_the_name_does_not_spell_and_the_name_keeps_its_own() { + let list = a_list_of(&["Show Title Season 3 Part 2"]); + + let (_, answer) = read_at( + "/anime/Show Title Season 3 Part 2/01 - Quiet Harbor.mkv", + &list, + ); + assert_eq!( + answer, + recognized(1, "Show Title Season 3 Part 2", Episode::Only(1)) + ); + + let (parsed, _) = read_at( + "/anime/Show Title Season 2/S03E01 - Quiet Harbor.mkv", + &list, + ); + assert_eq!(parsed.season, Some(3)); + + let (parsed, _) = read_at("/anime/Show Title (1999)/01 - Quiet Harbor.mkv", &list); + assert_eq!(parsed.year, Some(1999)); + let (parsed, _) = read_at( + "/anime/Show Title (1999)/01 - Quiet Harbor (2001).mkv", + &list, + ); + assert_eq!(parsed.year, Some(2001)); + } + + #[test] + fn a_directory_the_key_misses_is_scored_on_its_own_words() { + let (_, answer) = read_at( + "/anime/Show Title Cour 2 - The Subtitle/01 - Quiet Harbor.mkv", + &a_list_of(&["Show Title", "Show Title Cour 2 The Subtitle"]), + ); + + assert!( + matches!( + &answer, + Recognition::Recognized(Match { + show: ShowId(2), + episode: Episode::Only(1), + stage: Stage::Scored(_), + .. + }) + ), + "got {answer:?}" + ); + } + + #[test] + fn a_name_with_neither_a_title_nor_an_episode_never_asks_its_directory() { + let (parsed, answer) = + read_at("/anime/Show Title/[1080p].mkv", &a_list_of(&["Show Title"])); + + assert!(!parsed.from_directory); + assert!( + matches!(answer, Recognition::Unrecognized(_)), + "got {answer:?}" + ); + } + + #[test] + fn a_title_read_from_a_directory_carries_how_the_directory_was_decoded() { + let path = RawPath::from_bytes( + b"/anime/\x83\x5c\x83\x8c\x83\x62\x83\x5e [Group]/01 - Quiet Harbor.mkv".to_vec(), + ); + + let (parsed, _) = read_file( + &path, + &Altnames::new(), + &Corpus::new(), + &Index::of(&Corpus::new()), + ); + + assert_eq!(parsed.title.as_deref(), Some("ソレッタ")); + assert_eq!(parsed.confidence, Confidence::Detected); + } + + #[test] + fn a_directory_of_specials_reads_its_files_as_extras_of_the_show_above_it() { + let (parsed, _) = read_at( + "/anime/Show Title/Specials/01 - Quiet Harbor.mkv", + &a_list_of(&["Show Title"]), + ); + + assert_eq!(parsed.title.as_deref(), Some("Show Title")); + assert_eq!(parsed.kind, Some(Kind::Extra)); + assert_eq!(parsed.episode, Episode::Only(1)); + } + + #[test] + fn a_directory_whose_number_is_its_own_reaches_its_entry() { + let (_, answer) = read_at( + "/anime/Show Title 13/01 - Quiet Harbor.mkv", + &a_list_of(&["Show Title 13"]), + ); + + assert_eq!(answer, recognized(1, "Show Title 13", Episode::Only(1))); + } + + #[test] + fn a_directory_that_names_a_release_is_kept_as_the_show() { + let (parsed, answer) = read_at( + "/video/Show.Title.Season.1.1999.DVDRip-Group/01 Quiet Harbor.mkv", + &Corpus::new(), + ); + + assert_eq!(parsed.title.as_deref(), Some("Show Title")); + assert_eq!(parsed.episode, Episode::Only(1)); + assert!(parsed.from_directory); + assert!( + matches!(answer, Recognition::Unrecognized(_)), + "got {answer:?}" + ); + + for directory in [ + "Show Title Season 2", + "Show Title (1999)", + "[Group] Show Title", + "Show Title OVA", + ] { + let (parsed, _) = read_at( + &format!("/video/{directory}/01 Quiet Harbor.mkv"), + &Corpus::new(), + ); + + assert!(parsed.from_directory, "{directory}"); + assert_eq!( + parsed.kind.is_some(), + directory.ends_with("OVA"), + "{directory}" + ); + } + } + + #[test] + fn a_plain_directory_that_reaches_nothing_leaves_the_name_as_it_spells_itself() { + for (path, title) in [ + ( + "/video/7 Lanterns per Night.mkv", + Some("7 Lanterns per Night"), + ), + ("/video/01 - Quiet Harbor.mkv", None), + ] { + let (parsed, _) = read_at(path, &Corpus::new()); + + assert_eq!(parsed.title.as_deref(), title, "{path}"); + assert!(!parsed.from_directory, "{path}"); + } + } + + #[test] + fn a_walk_ends_at_the_first_directory_that_names_a_show() { + // A show kept inside another's directory is not that other show. + let (_, answer) = read_at( + "/anime/Show Title/Other Title/01 Quiet Harbor.mkv", + &a_list_of(&["Show Title"]), + ); + + assert!( + matches!(answer, Recognition::Unrecognized(_)), + "got {answer:?}" + ); + } + + #[test] + fn a_name_the_list_answers_never_asks_its_directory() { + let (parsed, answer) = read_at( + "/anime/Other Show/01 Show Title.mkv", + &a_list_of(&["Show Title", "Other Show"]), + ); + + assert!(!parsed.from_directory); + assert_eq!(answer, recognized(1, "Show Title", Episode::Only(1))); + + let (parsed, answer) = read_at( + "/anime/Show Title/01 Quiet Harbor.mkv", + &a_list_of(&["Show Title", "Quiet Harbor"]), + ); + + assert_eq!(parsed.title.as_deref(), Some("Quiet Harbor")); + assert_eq!(answer, recognized(2, "Quiet Harbor", Episode::Only(1))); + } + + #[test] + fn a_title_beginning_with_one_digit_is_no_episode_of_its_directory() { + let (parsed, answer) = read_at( + "/anime/Other Show/7 Lanterns per Night.mkv", + &a_list_of(&["Other Show"]), + ); + + assert!(!parsed.from_directory); + assert!( + matches!(answer, Recognition::Unrecognized(_)), + "got {answer:?}" + ); + } + + #[test] + fn a_part_of_the_show_gives_its_season_its_part_and_its_type() { + let list = Corpus::new(); + for (path, season, part, kind) in [ + ( + "/anime/[Group] Show Title/Season 2 OVA/01 - Quiet Harbor.mkv", + Some(2), + None, + Some(Kind::Extra), + ), + ( + "/anime/[Group] Show Title/[OVA]/01.mkv", + None, + None, + Some(Kind::Extra), + ), + ( + "/anime/[Group] Show Title/Disc 1 - Season 2/01.mkv", + Some(2), + None, + None, + ), + ( + "/anime/[Group] Show Title/Cour 2/01.mkv", + None, + Some(2), + None, + ), + ] { + let (parsed, _) = read_at(path, &list); + + assert!(parsed.from_directory, "{path}"); + assert_eq!( + (parsed.season, parsed.part, parsed.kind), + (season, part, kind), + "{path}" + ); + } + } + + #[test] + fn the_season_of_the_name_and_then_of_the_nearest_directory_holds() { + let list = Corpus::new(); + for (path, season) in [ + ( + "/anime/[Group] Show Title/Season 2/S03E01 - Quiet Harbor.mkv", + Some(3), + ), + ("/anime/Show Title Season 3/Season 2/01.mkv", Some(2)), + ] { + let (parsed, _) = read_at(path, &list); + + assert!(parsed.from_directory, "{path}"); + assert_eq!(parsed.season, season, "{path}"); + } + } + + #[test] + fn a_directory_never_gives_the_file_its_release_group() { + let (parsed, _) = read_at( + "/video/[Group] Show Title/01 Quiet Harbor.mkv", + &Corpus::new(), + ); + + assert!(parsed.from_directory); + assert_eq!(parsed.release_group, None); + } + + #[test] + fn a_name_of_a_season_and_an_episode_alone_reads_its_directory() { + let (_, answer) = read_at( + "/anime/Show Title/Season 2 - 03.mkv", + &a_list_of(&["Show Title", "Show Title Season 2"]), + ); + + assert_eq!( + answer, + recognized(2, "Show Title Season 2", Episode::Only(3)) + ); + } + + #[test] + fn a_walk_through_a_parent_reference_ends_there() { + let list = a_list_of(&["Show Title"]); + + let (_, answer) = read_at("/anime/Show Title/../01 Quiet Harbor.mkv", &list); + assert!( + matches!(answer, Recognition::Unrecognized(_)), + "got {answer:?}" + ); + + let (_, answer) = read_at("/anime/Show Title/./01 Quiet Harbor.mkv", &list); + assert_eq!(answer, recognized(1, "Show Title", Episode::Only(1))); + } + + #[test] + fn an_assignment_answers_a_name_read_episode_first_and_a_directory() { + let list = a_list_of(&["Show Title", "Show Title (2019)"]); + let mut altnames = Altnames::new(); + altnames + .name("Show Title", ShowId(2), &list) + .expect("the name is assigned"); + let index = Index::of(&list); + + for path in ["01 Show Title.mkv", "/anime/Show Title/01 Quiet Harbor.mkv"] { + let (_, answer) = read_file( + &RawPath::from_bytes(path.as_bytes().to_vec()), + &altnames, + &list, + &index, + ); + + assert!( + matches!( + answer, + Recognition::Recognized(Match { + show: ShowId(2), + stage: Stage::Altname(_), + .. + }) + ), + "{path}: {answer:?}" + ); + } + } + + #[test] + fn a_title_read_on_that_reaches_nothing_is_decided_as_the_name_spells_it() { + let mut corpus = Corpus::new(); + corpus.file(ShowId(1), "Show Title 13", Vec::<&str>::new()); + + let (parsed, answer) = read_as("Show Title 13 - The Subtitle.mkv", &corpus); + + assert_eq!(parsed.title.as_deref(), Some("Show Title")); + assert_eq!(parsed.episode, Episode::Only(13)); + assert_eq!( + answer, + decide( + &named("Show Title 13 - The Subtitle.mkv"), + &Altnames::new(), + &corpus, + &Index::of(&corpus) + ) + ); + } + + #[test] + fn a_title_read_on_is_asked_before_the_scorer_would_answer_the_title_as_parsed() { + let mut corpus = Corpus::new(); + corpus.file(ShowId(1), "Show Title 13", Vec::<&str>::new()); + + let scored = decide( + &named("Show Title 13.mkv"), + &Altnames::new(), + &corpus, + &Index::of(&corpus), + ); + + assert!( + matches!( + scored, + Recognition::Recognized(Match { + show: ShowId(1), + episode: Episode::Only(13), + stage: Stage::Scored(_), + .. + }) + ), + "got {scored:?}" + ); + assert_eq!( + read_as("Show Title 13.mkv", &corpus).1, + recognized(1, "Show Title 13", Episode::Absent) + ); + } + + #[test] + fn an_assignment_of_the_title_read_on_answers_before_the_key() { + let mut corpus = Corpus::new(); + corpus.file(ShowId(1), "Show Title 13", Vec::<&str>::new()); + corpus.file(ShowId(2), "Show Title 13 (2019)", Vec::<&str>::new()); + let mut altnames = Altnames::new(); + altnames + .name("Show Title 13", ShowId(2), &corpus) + .expect("the name is assigned"); + + let (_, answer) = read( + named("Show Title 13.mkv"), + &altnames, + &corpus, + &Index::of(&corpus), + ); + + assert!( + matches!( + answer, + Recognition::Recognized(Match { + show: ShowId(2), + stage: Stage::Altname(_), + .. + }) + ), + "got {answer:?}" + ); + } + + #[test] + fn a_title_that_reaches_an_entry_keeps_its_number_as_the_episode() { + let mut corpus = Corpus::new(); + corpus.file(ShowId(1), "Show Title", Vec::<&str>::new()); + corpus.file(ShowId(2), "Show Title 13", Vec::<&str>::new()); + + let (parsed, answer) = read_as("Show Title 13.mkv", &corpus); + + assert_eq!(parsed.episode, Episode::Only(13)); + assert_eq!(answer, recognized(1, "Show Title", Episode::Only(13))); + } + + #[test] + fn a_number_after_a_dash_is_never_read_into_the_title() { + let mut corpus = Corpus::new(); + corpus.file(ShowId(1), "Show Title 13", Vec::<&str>::new()); + + let (parsed, answer) = read_as("Show Title - 13.mkv", &corpus); + + assert_eq!(parsed.episode, Episode::Only(13)); + assert_ne!(answer, recognized(1, "Show Title 13", Episode::Absent)); + } + + #[test] + fn a_name_the_user_assigned_is_not_read_on() { + let mut corpus = Corpus::new(); + corpus.file(ShowId(1), "Assigned Show", Vec::<&str>::new()); + corpus.file(ShowId(2), "Show Title 13", Vec::<&str>::new()); + let mut altnames = Altnames::new(); + altnames + .name("Show Title", ShowId(1), &corpus) + .expect("the name is assigned"); + + let (parsed, _) = read( + named("Show Title 13.mkv"), + &altnames, + &corpus, + &Index::of(&corpus), + ); + + assert_eq!(parsed.episode, Episode::Only(13)); + } + + #[test] + fn a_name_that_spells_a_type_also_reaches_the_entry_whose_title_spells_one() { + let mut corpus = Corpus::new(); + corpus.file(ShowId(1), "Show Title Season 3", Vec::<&str>::new()); + corpus.file(ShowId(2), "Show Title Season 3 OVA", Vec::<&str>::new()); + + assert_eq!( + found("[Group] Show Title Season 3 OVA - 01 [1080p].mkv", &corpus), + Some(Recognition::Ambiguous(Ambiguity { + parsed: "Show Title".to_owned(), + candidates: vec![ + candidate(1, "Show Title Season 3"), + candidate(2, "Show Title Season 3 OVA"), + ], + })) + ); + assert_eq!( + found("[Group] Show Title Season 3 - 01 [1080p].mkv", &corpus), + Some(recognized(1, "Show Title Season 3", Episode::Only(1))), + "a name of no type word never reaches the entry through its type" + ); + } + + #[test] + fn a_name_of_an_ova_reaches_the_entry_its_title_spells_and_no_film() { + // The title as read reaches an entry, so it is not asked again + // without its type word, and a film's spelling is no OVA's. + let mut corpus = Corpus::new(); + corpus.file(ShowId(1), "Show Title", Vec::<&str>::new()); + corpus.file(ShowId(2), "Show Title OVA", Vec::<&str>::new()); + corpus.file(ShowId(3), "Show Title Movie", Vec::<&str>::new()); + + assert_eq!( + found("[Group] Show Title OVA - 01 [1080p].mkv", &corpus), + Some(recognized(2, "Show Title OVA", Episode::Only(1))) + ); + assert_eq!( + found("[Group] Show Title Movie [1080p].mkv", &corpus), + Some(recognized(3, "Show Title Movie", Episode::Absent)) + ); + } + + #[test] + fn a_name_reaches_the_spellings_filed_under_its_own_kind_alone() { + let mut corpus = Corpus::new(); + corpus.file(ShowId(1), "Show Title Season 3 Movie", Vec::<&str>::new()); + + assert_eq!( + found("[Group] Show Title Season 3 Movie [1080p].mkv", &corpus), + Some(recognized(1, "Show Title Season 3 Movie", Episode::Absent)) + ); + assert_eq!( + found("[Group] Show Title Season 3 OVA - 01 [1080p].mkv", &corpus), + None + ); + } + + #[test] + fn a_name_reaches_the_entries_of_its_key_before_those_of_a_type_word() { + let mut corpus = Corpus::new(); + corpus.file(ShowId(1), "Show Title Season 3 OVA", Vec::<&str>::new()); + corpus.file(ShowId(2), "Show Title Season 3", Vec::<&str>::new()); + + let Some(Recognition::Ambiguous(several)) = + found("[Group] Show Title Season 3 OVA - 01 [1080p].mkv", &corpus) + else { + panic!("the name reaches both entries"); + }; + let shows: Vec = several.candidates.iter().map(|c| c.show).collect(); + assert_eq!(shows, [ShowId(2), ShowId(1)]); + } + + #[test] + fn a_file_of_a_special_named_in_japanese_title_quotes_reaches_the_special() { + let mut corpus = Corpus::new(); + corpus.file(ShowId(1), "Series Title", ["作品名"]); + corpus.file(ShowId(2), "Series Title: The Subtitle", ["作品名『副題』"]); + + assert_eq!( + found("作品名『副題』.mkv", &corpus), + Some(recognized(2, "Series Title: The Subtitle", Episode::Absent)) + ); + } + #[test] fn an_answer_names_the_stage_that_decided_and_how_well_it_scored() { // The whole reason a recognition is inspectable. A score on its own diff --git a/crates/core/src/recognize/normalize.rs b/crates/core/src/recognize/normalize.rs index 0dd09c9..9830294 100644 --- a/crates/core/src/recognize/normalize.rs +++ b/crates/core/src/recognize/normalize.rs @@ -57,12 +57,13 @@ use std::ops::Range; use unicode_normalization::UnicodeNormalization; use unicode_normalization::char::is_combining_mark; -use crate::recognize::parse::Parsed; +use crate::recognize::parse::{Kind, Parsed, kind_of}; /// The form a title is compared in. /// /// Built from a list entry's text by [`Key::from_title`] and from a parsed -/// filename by [`Key::from_parsed`], so that the two can be compared. +/// filename by [`Key::from_parsed`], so that the two can be compared, and +/// from the parts a store kept of one by [`Key::from_parts`]. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Key { letters: String, @@ -102,19 +103,58 @@ impl Key { ) } + /// A key from the parts [`letters`](Key::letters), + /// [`marks`](Key::marks), [`season`](Key::season) and + /// [`part`](Key::part) answered, as a store keeps one. + /// + /// Nothing where the letters are empty, hold a letter in upper case, or + /// hold a character that is not a letter, a digit or a combining mark. + /// Nothing either where the marks hold a letter, a digit, a combining + /// mark, a break between words, a bracket or a dash. The parts are not + /// brought to NFKC, so letters in full width pass and build a key that + /// no title builds. A first season and a first part are none, as in any + /// key. + #[must_use] + pub fn from_parts( + letters: &str, + marks: &str, + season: Option, + part: Option, + ) -> Option { + let held = letters.chars().all(is_letter) + && letters.to_lowercase() == letters + && !marks + .chars() + .any(|c| is_letter(c) || is_word_break(c) || is_bracket(c) || is_dash(c)); + if !held { + return None; + } + Self::new(letters.to_owned(), marks.to_owned(), season, part) + } + + /// The letters and digits of the title, in lower case, with nothing + /// between them. + #[must_use] + pub fn letters(&self) -> &str { + &self.letters + } + /// The marks the title ended in, which are the one thing a lookup widens /// over. - pub(super) fn marks(&self) -> &str { + #[must_use] + pub fn marks(&self) -> &str { &self.marks } /// The season the key holds, where the text spelled one past the first. - pub(super) fn season(&self) -> Option { + #[must_use] + pub fn season(&self) -> Option { self.season } /// The part the key holds, where the text spelled one past the first. - pub(super) fn part(&self) -> Option { + #[must_use] + pub fn part(&self) -> Option { self.part } @@ -392,6 +432,31 @@ fn is_year(word: &str) -> bool { && (word.starts_with("19") || word.starts_with("20")) } +/// The spelling with every type word but `TV` taken out where nothing but +/// whitespace stands beside it, and the kinds the words taken out name. None +/// where the spelling holds no such word or holds nothing else. +/// +/// A type in brackets is out of the key already. One standing in the title, +/// as in `Show Title Season 3 OVA`, stays in the key, while the parser leaves +/// it out of the title of `Show Title Season 3 OVA - 01`, so a name of that +/// release would miss. +pub(crate) fn without_type_words(spelling: &str) -> Option<(String, Vec)> { + let mut kinds = Vec::new(); + let mut kept = Vec::new(); + for word in spelling.split_whitespace() { + match kind_of(word) { + Some(kind) => { + if !kinds.contains(&kind) { + kinds.push(kind); + } + } + None => kept.push(word), + } + } + + (!kinds.is_empty() && !kept.is_empty()).then(|| (kept.join(" "), kinds)) +} + /// A word the parser reads as the type of a release rather than as a name. fn is_type(word: &str) -> bool { const TYPES: [&str; 10] = [ @@ -695,6 +760,74 @@ mod tests { .expect("the name spells a title") } + #[test] + fn a_key_built_from_its_parts_is_the_key_they_came_from() { + for spelled in [ + "Show Title", + "Nisekoi:", + "K-ON!!", + "Show Title Season 3", + "Show Title Part 2", + "Haikyuu!! 2nd Season Cour 2", + "ソレッタ", + ] { + let key = listed(spelled); + + assert_eq!( + Key::from_parts(key.letters(), key.marks(), key.season(), key.part()), + Some(key), + "{spelled}" + ); + } + } + + #[test] + fn the_parts_of_a_key_are_what_it_was_built_of() { + let key = listed("Show Title!? Season 3 Part 2"); + + assert_eq!(key.letters(), "showtitle"); + assert_eq!(key.marks(), "!?"); + assert_eq!(key.season(), Some(3)); + assert_eq!(key.part(), Some(2)); + } + + #[test] + fn parts_no_key_can_hold_build_none() { + for (letters, marks) in [ + ("", ""), + ("Showtitle", ""), + ("show title", ""), + ("show-title", ""), + ("showtitle", "a"), + ("showtitle", "! "), + ("showtitle", "]"), + ("showtitle", "-"), + ("showtitle", "2"), + ("showtitle", "\u{301}"), + ] { + assert_eq!( + Key::from_parts(letters, marks, None, None), + None, + "{letters:?} {marks:?}" + ); + } + } + + #[test] + fn parts_are_taken_as_they_are_and_not_brought_to_nfkc() { + let built = Key::from_parts("show", "", None, None).expect("a letter is a letter"); + + assert_ne!(Some(built), Key::from_title("show")); + } + + #[test] + fn a_first_season_or_part_built_from_parts_is_none() { + assert_eq!( + Key::from_parts("showtitle", "", Some(1), Some(1)), + Some(listed("Show Title")) + ); + } + #[test] fn season_2_and_2nd_season_reach_the_same_key() { assert_eq!( @@ -1128,6 +1261,10 @@ mod tests { season: Some(2), part: None, year: None, + kind: None, + title_with_number: None, + episode_first: None, + from_directory: false, release_group: Some("Group".to_owned()), confidence: Confidence::Certain, text: String::new(), @@ -1200,6 +1337,10 @@ mod tests { season: Some(3), part: None, year: None, + kind: None, + title_with_number: None, + episode_first: None, + from_directory: false, release_group: None, confidence: Confidence::Certain, text: String::new(), diff --git a/crates/core/src/recognize/parse.rs b/crates/core/src/recognize/parse.rs index 366126f..36abd24 100644 --- a/crates/core/src/recognize/parse.rs +++ b/crates/core/src/recognize/parse.rs @@ -16,6 +16,13 @@ //! title ends at the season marker, so in `Show Title Season 3 Part 2 - 03` //! the part belongs to no element at all. [`parse`] reads it back from that //! one place and no other. +//! +//! **Japanese title quotes are read as spaces.** `「」` and `『』` set off a +//! title or a subtitle in Japanese, and the parser counts them among its +//! brackets: `作品名『副題』` would arrive as the title `作品名` with +//! the subtitle reported as a group, and a file of a special would reach the +//! series it belongs to. Read as spaces, the subtitle stays in the title, and +//! the title meets the entry that spells it. use std::ops::Range; @@ -60,14 +67,51 @@ pub enum Episode { Absent, } +/// What a name says the release is, where it spells a type word. +/// +/// The word stays out of the title where the title ends before it, so +/// `Show Title Season 3 OVA - 01` reports the title and the season of the +/// series it extends. Without the word kept here, an episode of an OVA reads +/// as an episode of the series. `TV` says nothing a name without it does not, +/// and is not kept. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Kind { + /// A film: `Movie`, `Gekijouban`. + Film, + /// A release apart from the broadcast: `OVA`, `OAD`, `OAV`, `ONA`, `SP`, + /// `Special`, `Specials`. One kind, because a service lists such a + /// release as an OVA where another lists it as a special. + Extra, +} + +/// The kind a type word names, and none for `TV` and for a word of no type. +pub(crate) fn kind_of(word: &str) -> Option { + const FILMS: [&str; 2] = ["Movie", "Gekijouban"]; + const EXTRAS: [&str; 7] = ["OVA", "OAD", "OAV", "ONA", "SP", "Special", "Specials"]; + + let named = |words: &[&str]| words.iter().any(|w| w.eq_ignore_ascii_case(word)); + if named(&FILMS) { + Some(Kind::Film) + } else if named(&EXTRAS) { + Some(Kind::Extra) + } else { + None + } +} + /// What a filename spells. /// /// What [`parse`] reads from a name for the stages below, and nothing is -/// matched yet. Every field is read from the **name**, never from what a -/// corpus holds. +/// matched yet. Every field [`parse`] fills is read from the **name**, never +/// from what a corpus holds. [`read`](super::read) answers with another +/// reading where an assignment or the list holds it, and +/// [`read_file`](super::read_file) with one read from a directory above the +/// file, where [`from_directory`](Parsed::from_directory) says so. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Parsed { - /// The title as the name spelled it, where the parser found one. + /// The title as the name spelled it, where the parser found one, or as a + /// directory above the file spelled it where + /// [`from_directory`](Parsed::from_directory) says so. pub title: Option, /// The episode, or the reason there is not exactly one. pub episode: Episode, @@ -89,6 +133,51 @@ pub struct Parsed { pub part: Option, /// The year, which is what tells a remake from its original. pub year: Option, + /// What the name says the release is, where it spells a type word other + /// than `TV`. + pub kind: Option, + /// The title read on through the episode number and the words after it. + /// + /// The parser reads `Show Title 13.mkv` as the title `Show Title` and + /// episode 13, and the words after such a number as the episode's title. + /// Where the number belongs to the title, this is the title the name + /// spells: `Show Title 13`, or `Show Title 13 - The Subtitle` of `Show + /// Title 13 - The Subtitle [1080p].mkv`. The list decides which reading + /// holds, so this one is kept beside the first. + /// + /// Nothing where anything but spaces and underscores parts the title from + /// the number, or where the name spells anything but one episode in digits + /// alone. Nothing either where the parser left a letter or a digit after + /// the number unread, as the `v` of `Show Title 13v2` or the subtitle of + /// `Show Title 13 (1983) - The Subtitle`. A version mark and a subtitle + /// are refused alike, and a title read on without the subtitle would drop + /// what tells one work from another. + pub title_with_number: Option, + /// The episode a name begins with and the words after it, as + /// `(1, "Quiet Harbor")` of `01 Quiet Harbor.mkv` and of + /// `01 - Quiet Harbor.mkv`. + /// + /// The words are the show's title in `01 Show Title.mkv` and an episode's + /// in `01 Quiet Harbor.mkv`, and a number beginning a title is the title's + /// own in `12 Quiet Lanterns.mkv`. The parser cannot tell these apart, so + /// this reading is kept beside its own, and the list decides. A year the + /// parser reads as one, as in `2001 Quiet Harbor.mkv`, is no episode. + /// + /// Where the parser took the number into the title, a number of one digit + /// is not read as an episode. A title begins with one, as in + /// `7 Lanterns per Night.mkv`, and a name read episode first can be read + /// against its directory, where such a film would be taken for an episode + /// of the show it is kept beside. Nothing either where the parser left a + /// letter or a digit after the title or the episode unread, as + /// [`title_with_number`](Parsed::title_with_number) says. + pub episode_first: Option<(u32, String)>, + /// The title was read from a directory above the file. + /// + /// The episode and the release group are what the file's name spells, + /// and so are the season, the part, the year and the type wherever it + /// spells one. The confidence and the text are the directory's. Set only + /// by [`read_file`](super::read_file); [`parse`] reads the name alone. + pub from_directory: bool, /// The release group, which the parser reports apart from the title. /// /// Taken off here and nowhere later. Brackets in a list entry are part of @@ -116,10 +205,15 @@ pub struct Parsed { /// release's own vocabulary. /// /// A space is left wherever something was struck out, so that the words on - /// either side of it stay two words. + /// either side of it stay two words. The directory's text where + /// [`from_directory`](Parsed::from_directory) says so. pub text: String, } +/// The quotes Japanese sets a title or a subtitle off with, which are read as +/// spaces. +const TITLE_QUOTES: [char; 4] = ['「', '」', '『', '』']; + /// Read a filename for what it says about the media. /// /// Begins with a decode, because the parser takes text and this program stores @@ -134,15 +228,23 @@ pub fn parse(name: &RawPath) -> Parsed { season: None, part: None, year: None, + kind: None, + title_with_number: None, + episode_first: None, + from_directory: false, release_group: None, confidence: decoded.confidence, text: String::new(), }; let mut episodes = Vec::new(); - let elements = anitomy_ng::parse(&decoded.text, Options::default()); - let lost_part = part_lost_after_season(&decoded.text, &elements); - let cut_marks = marks_cut_off_the_title(&decoded.text, &elements); - parsed.text = without_the_release(&decoded.text, &elements); + let text = decoded.text.replace(TITLE_QUOTES, " "); + let elements = anitomy_ng::parse(&text, Options::default()); + let lost_part = part_lost_after_season(&text, &elements); + let cut_marks = marks_cut_off_the_title(&text, &elements); + parsed.text = without_the_release(&text, &elements); + parsed.title_with_number = title_read_on(&text, &elements); + parsed.episode_first = episode_first(&text, &elements); + parsed.kind = release_kind(&text, &elements); for element in elements { // The first of a kind wins. The parser sorts what it reports by @@ -167,6 +269,9 @@ pub fn parse(name: &RawPath) -> Parsed { | ElementKind::Year | ElementKind::ReleaseGroup + // Read by `release_kind` above, where it stands. + | ElementKind::Type + // Dropped, and listed rather than caught by a wildcard: a kind // added upstream then stops the build and has to be answered for. | ElementKind::AudioTerm @@ -180,7 +285,6 @@ pub fn parse(name: &RawPath) -> Parsed { | ElementKind::ReleaseVersion | ElementKind::Source | ElementKind::Subtitles - | ElementKind::Type | ElementKind::VideoResolution | ElementKind::VideoTerm | ElementKind::Volume => {} @@ -207,6 +311,154 @@ pub fn parse(name: &RawPath) -> Parsed { parsed } +/// The title read on through the episode number and the words after it, as +/// [`Parsed::title_with_number`] says. +fn title_read_on(text: &str, elements: &[Element]) -> Option { + let title = elements + .iter() + .find(|element| element.kind == ElementKind::Title)?; + let mut episodes = elements + .iter() + .filter(|element| element.kind == ElementKind::Episode); + let episode = episodes.next()?; + if episodes.next().is_some() || !episode.value.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + + let chars: Vec = text.chars().collect(); + if !chars + .get(span(title).end..episode.position)? + .iter() + .all(|&c| c == ' ' || c == '_') + { + return None; + } + if unread_after(&chars, elements, span(episode).end)? { + return None; + } + + // The parser reports in order of position, so the first element after the + // number is the one that follows it. + let upto = elements + .iter() + .find(|element| element.position > episode.position) + .filter(|element| element.kind == ElementKind::EpisodeTitle) + .map_or(span(episode).end, |subtitle| span(subtitle).end); + Some( + chars + .get(title.position..upto)? + .iter() + .map(|&c| if c == '_' { ' ' } else { c }) + .collect(), + ) +} + +/// The episode a name begins with and the words after it, as +/// [`Parsed::episode_first`] says. +fn episode_first(text: &str, elements: &[Element]) -> Option<(u32, String)> { + let mut titles = elements + .iter() + .filter(|element| element.kind == ElementKind::Title); + let mut episodes = elements + .iter() + .filter(|element| element.kind == ElementKind::Episode); + + let chars: Vec = text.chars().collect(); + + match (titles.next(), episodes.next(), episodes.next()) { + // `01 Quiet Harbor`: the parser took the number into the title. + (Some(title), None, None) => { + let (number, words) = title.value.split_once(' ')?; + if number.len() < 2 || unread_after(&chars, elements, span(title).end)? { + return None; + } + Some((number.parse().ok()?, words.trim().to_owned())) + } + // `01 - Quiet Harbor`: the parser read the number, and the words + // after it as the episode's title. + (None, Some(episode), None) => { + let number = episode.value.parse().ok()?; + let words = elements + .iter() + .find(|element| element.kind == ElementKind::EpisodeTitle)?; + if unread_after(&chars, elements, span(episode).end)? { + return None; + } + Some((number, words.value.clone())) + } + _ => None, + } +} + +/// Where in the text an element stands, in characters. +fn span(element: &Element) -> Range { + element.position..element.position + element.value.chars().count() +} + +/// Whether a letter or a digit after this place in the text is one no +/// element reports, or nothing where an element does not fit the text. +/// +/// The parser drops what it cannot place: in `Show Title 13 (1983) - The +/// Subtitle` it reports the year and nothing after it. +fn unread_after(chars: &[char], elements: &[Element], from: usize) -> Option { + let mut reported = vec![false; chars.len()]; + for element in elements { + reported.get_mut(span(element))?.fill(true); + } + + Some( + chars + .iter() + .zip(&reported) + .skip(from) + .any(|(c, &reported)| c.is_alphanumeric() && !reported), + ) +} + +/// The kind of the release, from the first type word the parser reports +/// where it names the release rather than standing as a word of a title. +/// +/// A type word names the release in brackets, as in `Show Title (OVA) - 01` +/// and `Show Title - 01 [OVA]`, and before the episode where it ends the +/// title or stands after it, as in `Show Title OVA - 01` and `Show Title +/// Season 3 OVA - 01`. `Gekijouban` names a film from in front of its title, +/// as in `Gekijouban Show Title`. Anywhere else the word is a title's: the +/// show's own, as in `Special Title - 03`, or an episode's, as in `Show +/// Title - 07 - Special Training`. The parser reports a type there all the +/// same. +fn release_kind(text: &str, elements: &[Element]) -> Option { + let chars: Vec = text.chars().collect(); + let title = elements + .iter() + .find(|element| element.kind == ElementKind::Title) + .map(span); + let episode = elements + .iter() + .find(|element| element.kind == ElementKind::Episode) + .map(|element| element.position); + + elements + .iter() + .filter(|element| element.kind == ElementKind::Type) + .filter(|element| { + let word = span(element); + let bracketed = word + .start + .checked_sub(1) + .and_then(|before| chars.get(before)) + .is_some_and(|c| "([{【".contains(*c)); + let before_episode = episode.is_none_or(|episode| word.start < episode); + let placed = title.as_ref().is_none_or(|title| { + word.start >= title.end + || word.end == title.end + || (word.start == title.start + && element.value.eq_ignore_ascii_case("Gekijouban")) + }); + bracketed || (before_episode && placed) + }) + .find_map(|element| kind_of(&element.value)) +} + /// The marks the parser cut off the end of the title, read back from the name. /// /// The parser counts `:`, `,`, `+`, `|`, `&`, `.` and the dashes as @@ -422,7 +674,7 @@ fn is_delimiter(c: char) -> bool { #[cfg(test)] mod tests { - use super::{Episode, Parsed, parse}; + use super::{Episode, Kind, Parsed, parse}; use crate::encoding::Confidence; use crate::path::RawPath; @@ -670,6 +922,151 @@ mod tests { assert_eq!(parsed.release_group.as_deref(), Some("SubsPlease")); } + #[test] + fn a_subtitle_in_japanese_title_quotes_stays_in_the_title() { + // 「」 and 『』 mark a title in Japanese. Read as the brackets of a + // release group, they would leave the title of the series alone, and a + // file of a special would reach the series. + for (name, episode) in [ + ("作品名『副題』.mkv", Episode::Absent), + ("作品名「副題」 - 03.mkv", Episode::Only(3)), + ] { + let parsed = spelled(name); + let title = parsed.title.clone().unwrap_or_default(); + + assert!( + title.starts_with("作品名") && title.contains("副題"), + "{name}: {title:?}" + ); + assert_eq!(parsed.release_group, None, "{name}"); + assert_eq!(parsed.episode, episode, "{name}"); + assert_eq!(words_left(name), ["作品名", "副題"], "{name}"); + } + } + + #[test] + fn a_type_word_the_name_spells_is_kept_as_its_kind() { + for (name, kind) in [ + ( + "[Group] Show Title Season 3 OVA - 01 [1080p].mkv", + Some(Kind::Extra), + ), + ("[Group] Show Title OAD - 02 [1080p].mkv", Some(Kind::Extra)), + ("Show Title Specials - 02.mkv", Some(Kind::Extra)), + ("[Group] Show Title Movie [1080p].mkv", Some(Kind::Film)), + ("[Group] Show Title - 03 [1080p].mkv", None), + ("[Group] Show Title (TV) - 03 [1080p].mkv", None), + ( + "[Group] Show Title (OVA) - 01 [1080p].mkv", + Some(Kind::Extra), + ), + ("[Group] Show Title - 01 [OVA].mkv", Some(Kind::Extra)), + ("Gekijouban Show Title (2019).mkv", Some(Kind::Film)), + ] { + assert_eq!(spelled(name).kind, kind, "{name}"); + } + } + + #[test] + fn a_type_word_of_a_title_is_no_kind_of_the_release() { + for name in [ + "[Group] Show Title - 05 - Movie Night [1080p].mkv", + "Show.Title.S01E05.The.Movie.Night.1080p.WEB.mkv", + "[Group] Show Title - 07 - Special Training [1080p].mkv", + "[Group] Show Title - 08 - The OVA Club [1080p].mkv", + "[Group] Special Title - 03 [1080p].mkv", + "[Group] Show Title SP Professional - 03 [1080p].mkv", + ] { + assert_eq!(spelled(name).kind, None, "{name}"); + } + } + + #[test] + fn a_type_word_after_the_season_is_out_of_the_title_and_the_season_stays() { + let parsed = spelled("[Group] Show Title Season 3 OVA - 01 [1080p].mkv"); + + assert_eq!(parsed.title.as_deref(), Some("Show Title")); + assert_eq!(parsed.season, Some(3)); + assert_eq!(parsed.episode, Episode::Only(1)); + } + + #[test] + fn a_name_that_begins_with_a_number_is_also_read_as_that_episode() { + for (name, first) in [ + ("01 Quiet Harbor.mkv", Some((1, "Quiet Harbor"))), + ( + "[Group] 12 Show Title [1080p].mkv", + Some((12, "Show Title")), + ), + ("01 - Show Title.mkv", Some((1, "Show Title"))), + ("01. Quiet Harbor.mkv", Some((1, "Quiet Harbor"))), + ("S01E01 Quiet Harbor.mkv", Some((1, "Quiet Harbor"))), + ("12 Quiet Lanterns.mkv", Some((12, "Quiet Lanterns"))), + ("7 Lanterns per Night.mkv", None), + ("01 Quiet Harbor (1983) - The Subtitle.mkv", None), + ("Show Title - 01.mkv", None), + ("01.mkv", None), + ("1100 Quiet Harbor.mkv", Some((1100, "Quiet Harbor"))), + ("2001 Quiet Harbor.mkv", None), + ("01 - The Episode Name.mkv", None), + ("01-02 - Quiet Harbor.mkv", None), + ("12 Show Title - 05.mkv", None), + ] { + assert_eq!( + spelled(name) + .episode_first + .as_ref() + .map(|(episode, title)| (*episode, title.as_str())), + first, + "{name}" + ); + } + } + + #[test] + fn a_number_after_a_space_is_read_on_into_the_title() { + for (name, read_on) in [ + ("Show Title 13.mkv", Some("Show Title 13")), + ("[Group] Show Title 13 [1080p].mkv", Some("Show Title 13")), + ("Show Title 13 (1983).mkv", Some("Show Title 13")), + ( + "[Group] Show Title 13 - The Subtitle [1080p].mkv", + Some("Show Title 13 - The Subtitle"), + ), + ( + "Show_Title_13_-_The_Subtitle.mkv", + Some("Show Title 13 - The Subtitle"), + ), + ("Show Title - 13.mkv", None), + ("Show Title-13.mkv", None), + ("Show Title S01E13.mkv", None), + ("Show Title.mkv", None), + ("Show Title 01-12.mkv", None), + ("Show.Title.13.mkv", None), + ("Show Title 13 [1080p] 2.mkv", None), + ("Show Title 05.5.mkv", None), + ("Show Title 13v2.mkv", None), + ("Show Title 13 - The Subtitle v2.mkv", None), + ("Show Title 13 (1983) - The Subtitle.mkv", None), + ("Show Title 13 - The Episode Name.mkv", None), + ] { + assert_eq!( + spelled(name).title_with_number.as_deref(), + read_on, + "{name}" + ); + } + } + + #[test] + fn a_release_group_in_lenticular_brackets_is_still_a_group() { + let parsed = spelled("【Group】作品名 - 03.mkv"); + + assert_eq!(parsed.release_group.as_deref(), Some("Group")); + assert_eq!(parsed.title.as_deref(), Some("作品名")); + assert_eq!(parsed.episode, Episode::Only(3)); + } + #[test] fn a_name_spelling_two_episodes_spells_no_single_one() { // `01-12` comes back as two `Episode` elements, "01" and "12", and not diff --git a/crates/core/src/recognize/score.rs b/crates/core/src/recognize/score.rs index ef5c1d5..a983048 100644 --- a/crates/core/src/recognize/score.rs +++ b/crates/core/src/recognize/score.rs @@ -506,6 +506,10 @@ mod tests { season, part: None, year: None, + kind: None, + title_with_number: None, + episode_first: None, + from_directory: false, release_group: None, confidence: Confidence::Certain, text: title.to_owned(), diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index 5a21bab..e66c28a 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -1,9 +1,9 @@ //! What is open in the players, and what a store is to do about it. //! -//! A [`Session`] takes a reading together with what recognition decided -//! about it, and answers with [`Effect`]s: a question for a store, a total -//! to keep, an episode to record, a sitting to close. It carries out none of -//! them, so this crate stays free of I/O and a test reads the whole answer. +//! A [`Session`] takes a reading together with what was settled about it, +//! and answers with [`Effect`]s: a question for a store, a total to keep, an +//! episode to record, a sitting to close. It carries out none of them, so +//! this crate stays free of I/O and a test reads the whole answer. //! The caller carries each one out, in the order given, and brings the //! answer to a question back through [`Session::resumed`]. //! @@ -54,25 +54,27 @@ //! store that the episode registered already records nothing at all. //! //! **What names no episode begins no sitting.** That is a reading that names -//! no file, a reading recognition was not asked about, a name it refused or -//! found under several entries, a file of several episodes, and a half -//! episode. Nothing is kept or recorded for any of them, and such a reading -//! answers with no effect: its player leaves the sitting it was in. +//! no file, a reading nothing was settled about, a name recognition refused, +//! or found under several entries the facts chose none of, a file a fact of +//! its entry refused, a file of several episodes, and a half episode. Nothing +//! is kept or recorded for any of them, and such a reading answers with no +//! effect: its player leaves the sitting it was in. use std::mem; use std::time::Duration; use crate::clock::Timestamp; use crate::path::RawPath; -use crate::recognize::Recognition; use crate::recognize::parse::Episode; +use crate::settle::Settled; use crate::timeline::{Progress, Timeline, WatchedPolicy}; use crate::{Known, MediaRef, PlayerId, PlayerSnapshot, ShowId}; /// What a store is to do, in answer to a reading or to the end of a round. /// -/// A show is the one recognition named, as the store files it. An episode is -/// in the release's numbering, and a film has none. +/// A show is the one recognition named, or the one the facts chose of +/// several, as the store files it. An episode is in the release's numbering, +/// and a film has none. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Effect { /// Ask what was kept of an episode, and bring the answer to @@ -148,7 +150,7 @@ pub struct Resumed { pub registered: bool, } -/// What a reading names, borrowed from the reading and the decision. +/// What a reading names, with its file borrowed from the reading. /// /// Borrowed because every reading is asked what it names and a sitting /// begins at few of them. @@ -156,7 +158,7 @@ pub struct Resumed { struct Named<'reading> { /// The file that is open. media: &'reading RawPath, - /// The show recognition named. + /// The show recognition named, or the one the facts chose of several. show: ShowId, /// The episode, and none for a film. episode: Option, @@ -165,20 +167,19 @@ struct Named<'reading> { impl<'reading> Named<'reading> { /// What this reading names, and nothing where it names no episode. /// - /// Both matches are written out, so that an answer recognition gains and - /// a case an episode gains each stop the build here. - fn by( - reading: &'reading PlayerSnapshot, - decision: Option<&'reading Recognition>, - ) -> Option { + /// Both matches are written out, so that an answer settling gains and a + /// case an episode gains each stop the build here. + fn by(reading: &'reading PlayerSnapshot, decision: Option<&Settled>) -> Option { let MediaRef::LocalFile(media) = &reading.media else { return None; }; - let found = match decision? { - Recognition::Recognized(found) => found, - Recognition::Ambiguous(_) | Recognition::Unrecognized(_) => return None, + let (show, episode) = match decision? { + Settled::Stands { show, episode } | Settled::Chosen { show, episode, .. } => { + (*show, *episode) + } + Settled::Refused { .. } | Settled::Undecided { .. } | Settled::Unnamed => return None, }; - let episode = match found.episode { + let episode = match episode { Episode::Only(number) => Some(number), Episode::Absent => None, // Neither is an episode to record, and with no episode either @@ -188,7 +189,7 @@ impl<'reading> Named<'reading> { Some(Self { media, - show: found.show, + show, episode, }) } @@ -269,7 +270,7 @@ enum Count { /// reading its last player left at until the round is over. #[derive(Debug)] struct Sitting { - /// The show recognition named. + /// The show recognition named, or the one the facts chose of several. show: ShowId, /// The episode, and none for a film. episode: Option, @@ -480,24 +481,20 @@ impl Session { } } - /// Take one reading with what was decided about it, and answer with what + /// Take one reading with what was settled about it, and answer with what /// a store is to do. /// - /// `decision` is what recognition answered for the file the reading - /// names, and nothing where recognition was not asked. The effects are - /// in the order they are to be carried out: a total is kept before its - /// episode is recorded. No reading closes a sitting: the player of a - /// reading that names another episode or none leaves the sitting it was - /// in, and [`Session::end_round`] closes a sitting no player is in. + /// `decision` is what was settled of the file the reading names, and + /// nothing where nothing was asked. The effects are in the order they are + /// to be carried out: a total is kept before its episode is recorded. No + /// reading closes a sitting: the player of a reading that names another + /// episode or none leaves the sitting it was in, and + /// [`Session::end_round`] closes a sitting no player is in. /// /// Every reading is stamped from the clock every other reading of this /// session is stamped from. #[must_use = "an effect that is dropped is one nobody carries out"] - pub fn advance( - &mut self, - reading: &PlayerSnapshot, - decision: Option<&Recognition>, - ) -> Vec { + pub fn advance(&mut self, reading: &PlayerSnapshot, decision: Option<&Settled>) -> Vec { let named = Named::by(reading, decision); if let Some(named) = named @@ -602,7 +599,8 @@ mod tests { use crate::recognize::corpus::Corpus; use crate::recognize::index::Index; use crate::recognize::parse::{Episode, parse}; - use crate::recognize::{Ambiguity, Candidate, Match, Recognition, Refusal, Stage, decide}; + use crate::recognize::{Ambiguity, Candidate, Recognition, Refusal, decide}; + use crate::settle::{Fact, Facts, Settled, settle}; use crate::timeline::{WATCHED_FALLBACK, WatchedPolicy}; use crate::{Known, MediaRef, PlayState, PlayerId, PlayerSnapshot, ShowId}; use std::cell::Cell; @@ -765,26 +763,52 @@ mod tests { } } - /// What recognition answers for the file a reading names, against a list - /// of two series and one film, and nothing where it names no file. - /// - /// The name and never the directory, which is how the daemon asks. - fn decided(reading: &PlayerSnapshot) -> Option { - let MediaRef::LocalFile(path) = &reading.media else { - return None; - }; + /// A list of two series and one film, filed with no facts. + fn the_list() -> Corpus { let mut list = Corpus::new(); for show in [SERIES, SECOND_SERIES, FILM] { list.file(show, title_of(show), Vec::<&str>::new()); } - let name = RawPath::from_bytes(path.file_name().to_vec()); + list + } + + /// What recognition answers for the file a reading names, against + /// `list`, and what the list's facts settle of it, and nothing where the + /// reading names no file. + /// + /// The name alone, by [`decide`]. The daemon asks + /// [`read_file`](crate::recognize::read_file), which answers these names + /// the same: none has another reading, and none begins with its episode. + fn decided_against(list: &Corpus, reading: &PlayerSnapshot) -> Option<(Recognition, Settled)> { + let MediaRef::LocalFile(path) = &reading.media else { + return None; + }; + let spelled = parse(&RawPath::from_bytes(path.file_name().to_vec())); + let answer = decide(&spelled, &Altnames::new(), list, &Index::of(list)); + let settled = settle(&answer, &spelled, reading, list); + + Some((answer, settled)) + } + + /// What is settled of the file a reading names, against [`the_list`]. + fn decided(reading: &PlayerSnapshot) -> Option { + decided_against(&the_list(), reading).map(|(_, settled)| settled) + } + + /// What recognition answers for the file a reading names, against + /// [`the_list`]. + fn recognized_as(reading: &PlayerSnapshot) -> Option { + decided_against(&the_list(), reading).map(|(answer, _)| answer) + } - Some(decide( - &parse(&name), - &Altnames::new(), - &list, - &Index::of(&list), - )) + /// What the facts of [`the_list`] settle of an answer written out by + /// hand, for the file a reading names. + fn settled(answer: &Recognition, reading: &PlayerSnapshot) -> Settled { + let MediaRef::LocalFile(path) = &reading.media else { + panic!("the reading names a file") + }; + let spelled = parse(&RawPath::from_bytes(path.file_name().to_vec())); + settle(answer, &spelled, reading, &the_list()) } /// The title of a show of the list, and that of the series for any other @@ -797,14 +821,20 @@ mod tests { } } + /// The facts of a work the list holds as not yet released. + fn unreleased() -> Facts { + Facts { + episodes: None, + episode_length: None, + year: None, + released: false, + format: None, + } + } + /// A decision written out by hand, for a test that is about the decision. - fn recognized(show: ShowId, episode: Episode) -> Recognition { - Recognition::Recognized(Match { - show, - title: title_of(show).to_owned(), - episode, - stage: Stage::Key, - }) + fn recognized(show: ShowId, episode: Episode) -> Settled { + Settled::Stands { show, episode } } /// What the session answers to one reading, with no answer given back. @@ -2126,9 +2156,10 @@ mod tests { let mut session = Session::new(half()); let player = Player::named("mpv"); assert!(matches!( - decided(&player.playing(NOT_LISTED)), + recognized_as(&player.playing(NOT_LISTED)), Some(Recognition::Unrecognized(_)) )); + assert_eq!(decided(&player.playing(NOT_LISTED)), Some(Settled::Unnamed)); let answers = watch(&mut session, &player, NOT_LISTED, 150, None); @@ -2136,23 +2167,26 @@ mod tests { } #[test] - fn a_name_that_reaches_several_entries_is_not_recorded() { + fn a_name_several_entries_share_is_not_recorded_where_the_facts_choose_none() { // Neither candidate is taken, the first included. let mut session = Session::new(half()); let player = Player::named("mpv"); - let either = Recognition::Ambiguous(Ambiguity { - parsed: "Show Title".to_owned(), - candidates: vec![ - Candidate { - show: SERIES, - title: "Show Title".to_owned(), - }, - Candidate { - show: ShowId(4), - title: "Show Title (2019)".to_owned(), - }, - ], - }); + let either = settled( + &Recognition::Ambiguous(Ambiguity { + parsed: "Show Title".to_owned(), + candidates: vec![ + Candidate { + show: SERIES, + title: "Show Title".to_owned(), + }, + Candidate { + show: ShowId(4), + title: "Show Title (2019)".to_owned(), + }, + ], + }), + &player.playing(THIRD), + ); let effects: Vec = (0..150) .flat_map(|_| session.advance(&player.playing(THIRD), Some(&either))) @@ -2161,6 +2195,100 @@ mod tests { assert_eq!(effects, NOTHING); } + #[test] + fn a_file_a_fact_of_its_entry_refused_is_not_recorded() { + // The series runs an hour an episode, and a file of two hundred + // seconds is no episode of it, however long it plays. + let mut list = the_list(); + list.file_facts( + SERIES, + Facts { + episodes: Some(12), + episode_length: Some(Duration::from_hours(1)), + year: None, + released: true, + format: None, + }, + ); + let mut session = Session::new(half()); + let player = Player::named("mpv"); + assert_eq!( + decided_against(&list, &player.playing(THIRD)).map(|(_, settled)| settled), + Some(Settled::Refused { + show: SERIES, + fact: Fact::Length { + file: LENGTH, + episode: Duration::from_hours(1), + }, + }) + ); + + let effects: Vec = (0..150) + .flat_map(|_| { + let reading = player.playing(THIRD); + let refused = decided_against(&list, &reading).map(|(_, settled)| settled); + session.advance(&reading, refused.as_ref()) + }) + .collect(); + + assert_eq!(effects, NOTHING); + } + + #[test] + fn a_show_the_facts_chose_of_several_is_recorded_as_any_other() { + // Two entries share the title, and the list holds the second as not + // yet released, so the file is of the first. + let mut list = the_list(); + list.file(ShowId(4), "Show Title", Vec::<&str>::new()); + list.file_facts(ShowId(4), unreleased()); + let mut session = Session::new(half()); + let player = Player::named("mpv"); + let reading = player.playing(THIRD); + let (answer, chosen) = decided_against(&list, &reading).expect("the reading names a file"); + assert!(matches!(answer, Recognition::Ambiguous(_)), "{answer:?}"); + assert_eq!( + chosen, + Settled::Chosen { + show: SERIES, + episode: Episode::Only(3), + refused: vec![(ShowId(4), Fact::NotReleased)], + } + ); + + assert_eq!( + session.advance(&reading, Some(&chosen)), + [resume(1, SERIES, Some(3))] + ); + } + + #[test] + fn a_name_the_facts_leave_several_entries_of_is_not_recorded() { + // Three entries share the title and the list holds one as not yet + // released: two are left, and neither is taken. + let mut list = the_list(); + list.file(ShowId(4), "Show Title", Vec::<&str>::new()); + list.file(ShowId(5), "Show Title", Vec::<&str>::new()); + list.file_facts(ShowId(5), unreleased()); + let mut session = Session::new(half()); + let player = Player::named("mpv"); + assert_eq!( + decided_against(&list, &player.playing(THIRD)).map(|(_, settled)| settled), + Some(Settled::Undecided { + refused: vec![(ShowId(5), Fact::NotReleased)], + }) + ); + + let effects: Vec = (0..150) + .flat_map(|_| { + let reading = player.playing(THIRD); + let left = decided_against(&list, &reading).map(|(_, settled)| settled); + session.advance(&reading, left.as_ref()) + }) + .collect(); + + assert_eq!(effects, NOTHING); + } + #[test] fn a_reading_nothing_was_decided_about_is_not_recorded() { let mut session = Session::new(half()); @@ -2210,10 +2338,13 @@ mod tests { let the_third = recognized(SERIES, Episode::Only(3)); let the_fourth = recognized(SERIES, Episode::Only(4)); let of_the_second = recognized(SECOND_SERIES, Episode::Only(4)); - let nothing = Recognition::Unrecognized(Refusal { - parsed: "Show Title".to_owned(), - best: None, - }); + let nothing = settled( + &Recognition::Unrecognized(Refusal { + parsed: "Show Title".to_owned(), + best: None, + }), + &player.playing(THIRD), + ); // A round each: what the reading answered, and what the end of the // round did. @@ -2260,7 +2391,7 @@ mod tests { let stamps = Stamps::new(); let the_third = recognized(SERIES, Episode::Only(3)); let the_fourth = recognized(SERIES, Episode::Only(4)); - let mut round = |at: u64, decision: &Recognition| { + let mut round = |at: u64, decision: &Settled| { let first = session.advance(&stamps.playing("mpv", THIRD, at), Some(decision)); let second = session.advance(&stamps.playing("vlc", THIRD, at + 200), Some(decision)); [first, second, session.end_round(&[])] diff --git a/crates/core/src/settle.rs b/crates/core/src/settle.rs new file mode 100644 index 0000000..f052e4d --- /dev/null +++ b/crates/core/src/settle.rs @@ -0,0 +1,1380 @@ +//! Whether a file is of the show recognition named, or of which of several, +//! by what the list holds of each show's entry. +//! +//! Recognition reads what a path spells and nothing else, so it answers a +//! trailer of a work as the work, and a name that spells no episode as the +//! whole of a series. The list knows more of each entry than any name says: whether the +//! work has been released, the year it began, how many episodes it has and +//! how long one is. [`settle`] stands between recognition and the session and +//! asks those facts of the answer. +//! +//! **Five facts refuse a file, asked in this order, and the first that +//! refuses is the one an explanation names:** +//! +//! - a work that has not been released takes no file; +//! - a year the name spells and the work did not begin in refuses it, unless a +//! title of the work carries the same number, which is then the title's; +//! - a name that spells a film or an extra, `Movie` or `OVA`, is no episode of +//! a series broadcast on television; +//! - a name that spells no episode is the one episode of a work of one, and +//! names nothing of a work of several; +//! - a file shorter than half of one episode of the work is not an episode. +//! +//! **Of several shows, the facts choose one where they refuse every other.** +//! A name that reaches several entries is asked the same five facts of each, +//! with the episode the name spells, and one more: an episode past the count +//! of a show takes that show out, so the fifth episode of a title a film and +//! a series share is the series'. Where one is left, it is the answer; +//! where several or none are, the name stays unsettled, and the shows the +//! facts refused are named with the fact that refused each. +//! +//! **A fact that is not known refuses nothing.** A work filed with no facts at +//! all, a year one side does not say, a count the service does not hold and a +//! length either side does not report each leave the answer as recognition +//! gave it. A fact is asked only where both sides of it are known. + +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::list::{Entry, Format}; +use crate::recognize::corpus::Corpus; +use crate::recognize::parse::{Episode, Kind, Parsed}; +use crate::recognize::{Ambiguity, Recognition}; +use crate::timeline::duration_of; +use crate::{Known, PlayerSnapshot, ShowId}; + +/// What a list holds of a work that settles whether a file is of it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Facts { + /// How many episodes the work has, and none where the service holds no + /// count. + pub episodes: Option, + /// How long one episode is, and none where the service does not say. + pub episode_length: Option, + /// The year the work began, and none where the service does not say. + pub year: Option, + /// Whether any of the work has been released. + pub released: bool, + /// The format the service lists the work in, and none where it does not + /// say. + pub format: Option, +} + +impl From<&Entry> for Facts { + fn from(entry: &Entry) -> Self { + Self { + episodes: entry.episodes, + episode_length: entry.episode_length, + year: entry.year, + released: entry.released, + format: entry.format, + } + } +} + +/// The fact of an entry that refused a file. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Fact { + /// The work has not been released. + NotReleased, + /// The name spells a year the work did not begin in. + Year { + /// The year the name spells. + spelled: u32, + /// The year the work began. + began: u16, + }, + /// The name spells a film or an extra, and the work is a series broadcast + /// on television. + Kind(Kind), + /// The name spells no episode, and the work has more than one. + Episodes(u32), + /// The name spells an episode past the count of the work. + /// + /// Asked only among several shows. A show recognition named alone keeps + /// an episode past its count, because a release can number a later + /// season on from the first. + PastCount { + /// The episode the name spells. + episode: u32, + /// How many episodes the work has. + episodes: u32, + }, + /// The file is shorter than half of one episode of the work. + Length { + /// How long the file is. + file: Duration, + /// How long one episode of the work is. + episode: Duration, + }, +} + +/// What the facts of the entries made of what recognition answered. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Settled { + /// No fact of the entry refused the show recognition named. + Stands { + /// The show recognition named: the one the corpus filed the entry as, + /// or the one kept for a name the list does not hold. + show: ShowId, + /// The episode in the release's numbering, as it was read from the + /// name. + episode: Episode, + }, + /// A fact of the entry refused the show recognition named. + Refused { + /// The show recognition named. + show: ShowId, + /// The fact that refused it, the first of the five that did. + fact: Fact, + }, + /// Of several shows recognition named, the facts refused all but this + /// one, and the file is of it. + Chosen { + /// The show the facts let stand. + show: ShowId, + /// The episode in the release's numbering, as it was read from the + /// name. + episode: Episode, + /// Every other show recognition named, with the fact that refused + /// it, in the order recognition named them. + refused: Vec<(ShowId, Fact)>, + }, + /// Of several shows recognition named, the facts refused these, and left + /// none or more than one. + Undecided { + /// The shows the facts refused, with the fact that refused each, in + /// the order recognition named them. + refused: Vec<(ShowId, Fact)>, + }, + /// Recognition named no show, or several that the facts refused none + /// of, so there was nothing to settle. + Unnamed, +} + +/// Ask the facts of the entry recognition named whether the file is of it, +/// or the facts of each of several which one it is of. +/// +/// `spelled` is the parse the answer was decided by, and `reading` the +/// reading of the file, whose length is the file's where its own position +/// does not contradict it. The facts are those `corpus` holds of each show. +#[must_use] +pub fn settle( + answer: &Recognition, + spelled: &Parsed, + reading: &PlayerSnapshot, + corpus: &Corpus, +) -> Settled { + let found = match answer { + Recognition::Recognized(found) => found, + Recognition::Ambiguous(several) => return choose(several, spelled, reading, corpus), + Recognition::Unrecognized(_) => return Settled::Unnamed, + }; + let year = year_against(corpus, found.show, spelled.year); + let refused = corpus.facts_of(found.show).and_then(|facts| { + refusal( + facts, + year, + spelled.kind, + found.episode, + duration_of(reading), + ) + }); + + match refused { + Some(fact) => Settled::Refused { + show: found.show, + fact, + }, + None => Settled::Stands { + show: found.show, + episode: found.episode, + }, + } +} + +/// Of several shows recognition named, the one the facts let stand where +/// they refuse every other. +fn choose( + several: &Ambiguity, + spelled: &Parsed, + reading: &PlayerSnapshot, + corpus: &Corpus, +) -> Settled { + let length = duration_of(reading); + let mut left = Vec::new(); + let mut refused = Vec::new(); + for candidate in &several.candidates { + let year = year_against(corpus, candidate.show, spelled.year); + let fact = corpus.facts_of(candidate.show).and_then(|facts| { + refusal(facts, year, spelled.kind, spelled.episode, length) + .or_else(|| past_count(facts, spelled.episode)) + }); + match fact { + Some(fact) => refused.push((candidate.show, fact)), + None => left.push(candidate.show), + } + } + + if refused.is_empty() { + return Settled::Unnamed; + } + + match left.as_slice() { + [show] => Settled::Chosen { + show: *show, + episode: spelled.episode, + refused, + }, + _ => Settled::Undecided { refused }, + } +} + +/// An episode the name spells past the count of a show, which takes one of +/// several shows out, and none where the show holds it or its count is not +/// known. +fn past_count(facts: &Facts, episode: Episode) -> Option { + if let Episode::Only(episode) = episode + && let Some(episodes) = facts.episodes + && episode > episodes + { + return Some(Fact::PastCount { episode, episodes }); + } + + None +} + +/// The year a name spells, as a year to ask of a show, and none where a +/// title of the show carries the same number. +fn year_against(corpus: &Corpus, show: ShowId, year: Option) -> Option { + year.filter(|&year| !corpus.spells_number(show, year)) +} + +/// The first of the five facts that refuses a file, and none where none +/// does. +/// +/// `year` is the one the name spells where no title of the show carries it, +/// `kind` the one the name spells, `episode` the name's episode, and +/// `length` the file's. +fn refusal( + facts: &Facts, + year: Option, + kind: Option, + episode: Episode, + length: Known, +) -> Option { + if !facts.released { + return Some(Fact::NotReleased); + } + if let (Some(spelled), Some(began)) = (year, facts.year) + && spelled != u32::from(began) + { + return Some(Fact::Year { spelled, began }); + } + if let Some(kind) = kind + && matches!(facts.format, Some(Format::Tv | Format::TvShort)) + { + return Some(Fact::Kind(kind)); + } + if episode == Episode::Absent + && let Some(episodes) = facts.episodes + && episodes > 1 + { + return Some(Fact::Episodes(episodes)); + } + // A length so long that twice it overflows is no short file. + if let (Known::Value(file), Some(one)) = (length, facts.episode_length) + && file.checked_mul(2).is_some_and(|twice| twice < one) + { + return Some(Fact::Length { file, episode: one }); + } + + None +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::{Fact, Facts, Settled, settle}; + use crate::clock::Timestamp; + use crate::list::{Entry, EntryId, Format, Status}; + use crate::path::RawPath; + use crate::recognize::altname::Altnames; + use crate::recognize::corpus::Corpus; + use crate::recognize::index::Index; + use crate::recognize::parse::{Episode, Kind, Parsed, parse}; + use crate::recognize::{Ambiguity, Candidate, Recognition, Refusal, decide}; + use crate::{Known, MediaRef, PlayState, PlayerId, PlayerSnapshot, ShowId}; + + /// The show every test files its one entry as. + const SHOW: ShowId = ShowId(1); + + /// One episode of the works these tests file, where a test needs one. + const EPISODE: Duration = Duration::from_mins(24); + + /// The facts of a series of twelve episodes of 24 minutes, released, + /// begun in 2019, which every rule lets a file of an episode of through. + fn a_series() -> Facts { + Facts { + episodes: Some(12), + episode_length: Some(EPISODE), + year: Some(2019), + released: true, + format: None, + } + } + + /// What a file of this name spells. + fn named(name: &str) -> Parsed { + parse(&RawPath::from_bytes(name.as_bytes().to_vec())) + } + + /// A reading of a file that is this long, at no position. + fn a_file(length: Known) -> PlayerSnapshot { + PlayerSnapshot { + player: PlayerId("mpv".to_owned()), + media: MediaRef::LocalFile(RawPath::from_bytes(b"/anime/a file.mkv".to_vec())), + state: PlayState::Playing, + position: Known::NotReported, + duration: length, + observed_at: Timestamp::epoch(), + } + } + + /// A corpus of one entry, `Show Title`, filed as [`SHOW`] with these + /// facts. + fn listed(facts: Facts) -> Corpus { + let mut corpus = Corpus::new(); + corpus.file(SHOW, "Show Title", Vec::<&str>::new()); + corpus.file_facts(SHOW, facts); + corpus + } + + /// What the facts of `corpus` make of a file of this name and length, + /// after recognition decided it against the same corpus. + /// + /// Recognition is asked here rather than written out, so a name that does + /// not reach the entry settles as [`Settled::Unnamed`] and never passes as + /// refused. + fn settled(name: &str, length: Known, corpus: &Corpus) -> Settled { + let spelled = named(name); + let answer = decide(&spelled, &Altnames::new(), corpus, &Index::of(corpus)); + settle(&answer, &spelled, &a_file(length), corpus) + } + + /// A length of this many seconds, as a reading reports one. + fn seconds(of: u64) -> Known { + Known::Value(Duration::from_secs(of)) + } + + #[test] + fn a_file_of_an_episode_stands_as_recognition_named_it() { + assert_eq!( + settled( + "[Group] Show Title - 03 [1080p].mkv", + Known::Value(EPISODE), + &listed(a_series()) + ), + Settled::Stands { + show: SHOW, + episode: Episode::Only(3), + } + ); + } + + #[test] + fn a_work_not_yet_released_takes_no_file() { + // A name can reach an entry the service lists as not yet released, + // a sequel announced under the title of the series. No file is of + // it, whatever the name spells. + let announced = Facts { + released: false, + ..a_series() + }; + + assert_eq!( + settled( + "[Group] Show Title - 03 [1080p].mkv", + Known::Value(EPISODE), + &listed(announced) + ), + Settled::Refused { + show: SHOW, + fact: Fact::NotReleased, + } + ); + } + + #[test] + fn a_year_the_work_did_not_begin_in_refuses_the_file() { + // The key drops a year, so a remake reaches the entry of its + // original. The year the name spells is the one fact that tells them + // apart, and the entry began in another. + let spelled = named("[Group] Show Title (2001) - 03 [1080p].mkv"); + assert_eq!(spelled.year, Some(2001)); + + assert_eq!( + settled( + "[Group] Show Title (2001) - 03 [1080p].mkv", + Known::Value(EPISODE), + &listed(a_series()) + ), + Settled::Refused { + show: SHOW, + fact: Fact::Year { + spelled: 2001, + began: 2019, + }, + } + ); + } + + #[test] + fn the_year_the_work_began_lets_the_file_through() { + assert_eq!( + settled( + "[Group] Show Title (2019) - 03 [1080p].mkv", + Known::Value(EPISODE), + &listed(a_series()) + ), + Settled::Stands { + show: SHOW, + episode: Episode::Only(3), + } + ); + } + + #[test] + fn a_year_a_title_of_the_work_spells_refuses_nothing() { + // The number is the title's: a work can be named for a year it did + // not begin in. + let mut corpus = Corpus::new(); + corpus.file(SHOW, "Show Title 2032", Vec::<&str>::new()); + corpus.file_facts( + SHOW, + Facts { + year: Some(1987), + ..a_series() + }, + ); + let name = "[Group] Show Title 2032 - 03 [1080p].mkv"; + assert_eq!(named(name).year, Some(2032)); + + assert_eq!( + settled(name, Known::Value(EPISODE), &corpus), + Settled::Stands { + show: SHOW, + episode: Episode::Only(3), + } + ); + } + + #[test] + fn a_year_another_spelling_of_the_work_carries_refuses_nothing() { + let mut corpus = Corpus::new(); + corpus.file(SHOW, "Show Title", ["Other Title (1987 Release Title)"]); + corpus.file_facts( + SHOW, + Facts { + episodes: Some(1), + year: Some(1984), + ..a_series() + }, + ); + let name = "Other Title (1987 Release Title).mkv"; + assert_eq!(named(name).year, Some(1987)); + + assert_eq!( + settled(name, Known::Value(EPISODE), &corpus), + Settled::Stands { + show: SHOW, + episode: Episode::Absent, + } + ); + } + + #[test] + fn a_name_that_spells_an_extra_or_a_film_refuses_a_series() { + let series = Facts { + format: Some(Format::Tv), + ..a_series() + }; + for (name, kind) in [ + ("[Group] Show Title OVA - 01 [1080p].mkv", Kind::Extra), + ("[Group] Show Title Movie [1080p].mkv", Kind::Film), + ] { + assert_eq!( + settled(name, Known::NotReported, &listed(series)), + Settled::Refused { + show: SHOW, + fact: Fact::Kind(kind), + }, + "{name}" + ); + } + } + + #[test] + fn a_type_word_of_an_episode_title_refuses_no_series() { + let series = Facts { + format: Some(Format::Tv), + ..a_series() + }; + + assert_eq!( + settled( + "[Group] Show Title - 07 - Special Training [1080p].mkv", + Known::NotReported, + &listed(series) + ), + Settled::Stands { + show: SHOW, + episode: Episode::Only(7), + } + ); + } + + #[test] + fn the_kind_is_asked_after_the_release_and_the_year() { + let series = Facts { + format: Some(Format::Tv), + ..a_series() + }; + for (facts, fact) in [ + ( + Facts { + released: false, + ..series + }, + Fact::NotReleased, + ), + ( + series, + Fact::Year { + spelled: 2001, + began: 2019, + }, + ), + ] { + assert_eq!( + settled( + "[Group] Show Title OVA (2001) [1080p].mkv", + Known::NotReported, + &listed(facts) + ), + Settled::Refused { show: SHOW, fact }, + "{fact:?}" + ); + } + } + + #[test] + fn a_name_that_spells_an_extra_stands_for_an_ova_a_special_an_ona_and_an_unknown_format() { + for format in [ + Some(Format::Ova), + Some(Format::Special), + Some(Format::Ona), + None, + ] { + assert_eq!( + settled( + "[Group] Show Title OVA - 01 [1080p].mkv", + Known::NotReported, + &listed(Facts { + format, + ..a_series() + }) + ), + Settled::Stands { + show: SHOW, + episode: Episode::Only(1), + }, + "{format:?}" + ); + } + } + + #[test] + fn a_short_series_is_a_series_to_a_name_that_spells_an_extra() { + assert_eq!( + settled( + "[Group] Show Title OVA - 01 [1080p].mkv", + Known::NotReported, + &listed(Facts { + format: Some(Format::TvShort), + ..a_series() + }) + ), + Settled::Refused { + show: SHOW, + fact: Fact::Kind(Kind::Extra), + } + ); + } + + #[test] + fn a_year_one_side_does_not_say_refuses_nothing() { + let undated = Facts { + year: None, + ..a_series() + }; + + assert_eq!( + settled( + "[Group] Show Title (2001) - 03 [1080p].mkv", + Known::Value(EPISODE), + &listed(undated) + ), + Settled::Stands { + show: SHOW, + episode: Episode::Only(3), + } + ); + assert_eq!(named("[Group] Show Title - 03 [1080p].mkv").year, None); + assert_eq!( + settled( + "[Group] Show Title - 03 [1080p].mkv", + Known::Value(EPISODE), + &listed(a_series()) + ), + Settled::Stands { + show: SHOW, + episode: Episode::Only(3), + } + ); + } + + #[test] + fn a_name_with_no_episode_names_nothing_of_an_ova_of_two() { + // A name that spells no episode does not say which of the two + // episodes it is. + let ova = Facts { + episodes: Some(2), + episode_length: Some(Duration::from_mins(40)), + year: None, + released: true, + format: None, + }; + assert_eq!( + named("[Group] Show Title OVA [1080p].mkv").episode, + Episode::Absent + ); + + assert_eq!( + settled( + "[Group] Show Title OVA [1080p].mkv", + Known::Value(Duration::from_mins(40)), + &listed(ova) + ), + Settled::Refused { + show: SHOW, + fact: Fact::Episodes(2), + } + ); + } + + #[test] + fn a_name_with_no_episode_is_the_one_episode_of_a_work_of_one() { + // A film spells no episode, and the release's numbering keeps it + // that way: it stands with none rather than as the first. + let film = Facts { + episodes: Some(1), + episode_length: Some(Duration::from_mins(95)), + year: Some(2019), + released: true, + format: None, + }; + + assert_eq!( + settled( + "[Group] Show Title (2019) [1080p].mkv", + seconds(95 * 60), + &listed(film) + ), + Settled::Stands { + show: SHOW, + episode: Episode::Absent, + } + ); + } + + #[test] + fn a_count_the_service_does_not_hold_refuses_no_name_with_no_episode() { + let uncounted = Facts { + episodes: None, + ..a_series() + }; + + assert_eq!( + settled( + "[Group] Show Title [1080p].mkv", + Known::Value(EPISODE), + &listed(uncounted) + ), + Settled::Stands { + show: SHOW, + episode: Episode::Absent, + } + ); + } + + #[test] + fn a_name_that_spells_an_episode_is_not_asked_the_count() { + // The count refuses a name with no episode alone, so an episode past + // the count stands. + let short = Facts { + episodes: Some(2), + ..a_series() + }; + + assert_eq!( + settled( + "[Group] Show Title - 25 [1080p].mkv", + Known::Value(EPISODE), + &listed(short) + ), + Settled::Stands { + show: SHOW, + episode: Episode::Only(25), + } + ); + } + + #[test] + fn a_trailer_is_not_the_episode_of_its_work() { + // The case the length is asked for: a trailer of 1.9 minutes beside + // an OVA of one episode of 40, which its name reaches and which a + // name with no episode names whole. + let ova = Facts { + episodes: Some(1), + episode_length: Some(Duration::from_mins(40)), + year: None, + released: true, + format: None, + }; + + assert_eq!( + settled( + "[Group] Show Title OVA [1080p].mkv", + seconds(114), + &listed(ova) + ), + Settled::Refused { + show: SHOW, + fact: Fact::Length { + file: Duration::from_secs(114), + episode: Duration::from_mins(40), + }, + } + ); + } + + #[test] + fn a_length_too_long_to_double_is_no_short_file() { + // A length read from a recording has no bound, and twice the longest + // does not fit. + assert_eq!( + settled( + "[Group] Show Title - 03 [1080p].mkv", + Known::Value(Duration::MAX), + &listed(a_series()) + ), + Settled::Stands { + show: SHOW, + episode: Episode::Only(3), + } + ); + } + + #[test] + fn files_near_the_length_of_an_episode_stay_episodes() { + // A file of an episode runs a little under or over the length the + // list holds for its entry, and a file of exactly half is not shorter + // than half. + for fraction in [0.94, 1.0, 1.04, 0.5] { + let length = EPISODE.mul_f64(fraction); + + assert_eq!( + settled( + "[Group] Show Title - 03 [1080p].mkv", + Known::Value(length), + &listed(a_series()) + ), + Settled::Stands { + show: SHOW, + episode: Episode::Only(3), + }, + "{fraction}" + ); + } + } + + #[test] + fn a_file_a_second_short_of_half_an_episode_is_refused() { + let length = Duration::from_secs(EPISODE.as_secs() / 2 - 1); + + assert_eq!( + settled( + "[Group] Show Title - 03 [1080p].mkv", + Known::Value(length), + &listed(a_series()) + ), + Settled::Refused { + show: SHOW, + fact: Fact::Length { + file: length, + episode: EPISODE, + }, + } + ); + } + + #[test] + fn a_length_either_side_does_not_report_refuses_nothing() { + let unmeasured = Facts { + episode_length: None, + ..a_series() + }; + let stands = Settled::Stands { + show: SHOW, + episode: Episode::Only(3), + }; + let name = "[Group] Show Title - 03 [1080p].mkv"; + + assert_eq!(settled(name, seconds(60), &listed(unmeasured)), stands); + for length in [Known::NotReported, Known::Unsupported] { + assert_eq!( + settled(name, length, &listed(a_series())), + stands, + "{length:?}" + ); + } + } + + #[test] + fn a_length_its_own_position_contradicts_is_not_the_files() { + // A length below the position it arrives with describes media + // playback has run past, so it says nothing of how long the file is. + let spelled = named("[Group] Show Title - 03 [1080p].mkv"); + let corpus = listed(a_series()); + let answer = decide(&spelled, &Altnames::new(), &corpus, &Index::of(&corpus)); + let reading = PlayerSnapshot { + position: Known::Value(Duration::from_mins(20)), + ..a_file(seconds(60)) + }; + + assert_eq!( + settle(&answer, &spelled, &reading, &corpus), + Settled::Stands { + show: SHOW, + episode: Episode::Only(3), + } + ); + } + + #[test] + fn the_first_fact_that_refuses_is_the_one_named() { + // Not released, another year, no episode of several and a file far + // too short, all at once: the facts are asked in that order. + let everything_wrong = Facts { + episodes: Some(12), + episode_length: Some(EPISODE), + year: Some(2019), + released: false, + format: None, + }; + let name = "[Group] Show Title (2001) [1080p].mkv"; + + assert_eq!( + settled(name, seconds(60), &listed(everything_wrong)), + Settled::Refused { + show: SHOW, + fact: Fact::NotReleased, + } + ); + assert_eq!( + settled(name, seconds(60), &listed(a_series())), + Settled::Refused { + show: SHOW, + fact: Fact::Year { + spelled: 2001, + began: 2019, + }, + } + ); + assert_eq!( + settled( + "[Group] Show Title [1080p].mkv", + seconds(60), + &listed(a_series()) + ), + Settled::Refused { + show: SHOW, + fact: Fact::Episodes(12), + } + ); + } + + #[test] + fn a_show_filed_with_no_facts_stands_whatever_the_file() { + let corpus: Corpus = ["Show Title"].into_iter().collect(); + + assert_eq!( + settled("[Group] Show Title (2001) [1080p].mkv", seconds(1), &corpus), + Settled::Stands { + show: SHOW, + episode: Episode::Absent, + } + ); + } + + #[test] + fn a_name_recognition_refused_or_found_twice_settles_nothing() { + // The facts settle the show recognition named, and these named none. + let corpus = listed(a_series()); + let spelled = named("[Group] Show Title - 03 [1080p].mkv"); + let refused = Recognition::Unrecognized(Refusal { + parsed: "Show Title".to_owned(), + best: None, + }); + let several = Recognition::Ambiguous(Ambiguity { + parsed: "Show Title".to_owned(), + candidates: vec![ + Candidate { + show: SHOW, + title: "Show Title".to_owned(), + }, + Candidate { + show: ShowId(2), + title: "Show Title".to_owned(), + }, + ], + }); + + for answer in [refused, several] { + assert_eq!( + settle(&answer, &spelled, &a_file(Known::Value(EPISODE)), &corpus), + Settled::Unnamed, + "{answer:?}" + ); + } + } + + /// A corpus of these entries, filed as shows numbered from one in the + /// order given, each with its facts where it has any. + fn listed_as(entries: &[(&str, Option)]) -> Corpus { + let mut corpus = Corpus::new(); + for ((title, facts), show) in entries.iter().zip(1..) { + corpus.file(ShowId(show), title, Vec::<&str>::new()); + if let Some(facts) = facts { + corpus.file_facts(ShowId(show), *facts); + } + } + corpus + } + + /// A film begun in this year, released or not. + fn a_film(year: u16, released: bool) -> Facts { + Facts { + episodes: Some(1), + episode_length: Some(Duration::from_mins(124)), + year: Some(year), + released, + format: None, + } + } + + #[test] + fn of_two_entries_of_one_title_the_one_released_is_the_answer() { + // Two films of one title, and the service lists the second as not + // yet released. The name reaches both, and only the first can be + // what is playing. + let corpus = listed_as(&[ + ("Show Title", Some(a_film(1988, true))), + ("Show Title", Some(a_film(2027, false))), + ]); + let name = "Show Title (1988).mkv"; + assert!(matches!( + decide(&named(name), &Altnames::new(), &corpus, &Index::of(&corpus)), + Recognition::Ambiguous(_) + )); + + assert_eq!( + settled(name, seconds(124 * 60), &corpus), + Settled::Chosen { + show: ShowId(1), + episode: Episode::Absent, + refused: vec![(ShowId(2), Fact::NotReleased)], + } + ); + } + + #[test] + fn a_year_chooses_between_a_remake_and_its_original() { + // The key drops the year, so the name reaches both, and the year it + // spells is the one fact that tells them apart. + let corpus = listed_as(&[ + ( + "Show Title", + Some(Facts { + year: Some(2001), + ..a_series() + }), + ), + ("Show Title (2019)", Some(a_series())), + ]); + + assert_eq!( + settled( + "[Group] Show Title (2019) - 05 [1080p].mkv", + Known::Value(EPISODE), + &corpus + ), + Settled::Chosen { + show: ShowId(2), + episode: Episode::Only(5), + refused: vec![( + ShowId(1), + Fact::Year { + spelled: 2019, + began: 2001, + } + )], + } + ); + } + + #[test] + fn a_length_chooses_between_two_works_of_one_title() { + // An OVA of one episode of 40 minutes and a film of 124, both named + // with no episode: a file of 40 minutes is under half the film. + let corpus = listed_as(&[ + ("Show Title", Some(a_film(2019, true))), + ( + "Show Title", + Some(Facts { + episode_length: Some(Duration::from_mins(40)), + ..a_film(2019, true) + }), + ), + ]); + + assert_eq!( + settled("Show Title.mkv", seconds(40 * 60), &corpus), + Settled::Chosen { + show: ShowId(2), + episode: Episode::Absent, + refused: vec![( + ShowId(1), + Fact::Length { + file: Duration::from_mins(40), + episode: Duration::from_mins(124), + } + )], + } + ); + } + + #[test] + fn a_show_with_no_facts_can_be_the_one_left() { + // A fact that is not known refuses nothing, so a show filed with none + // stands beside one the facts refused. + let corpus = listed_as(&[ + ("Show Title", Some(a_film(2027, false))), + ("Show Title", None), + ]); + + assert_eq!( + settled("Show Title.mkv", seconds(124 * 60), &corpus), + Settled::Chosen { + show: ShowId(2), + episode: Episode::Absent, + refused: vec![(ShowId(1), Fact::NotReleased)], + } + ); + } + + #[test] + fn shows_the_facts_refuse_every_one_of_leave_nothing() { + let corpus = listed_as(&[ + ("Show Title", Some(a_film(2027, false))), + ( + "Show Title", + Some(Facts { + episodes: Some(2), + ..a_film(1988, true) + }), + ), + ]); + + assert_eq!( + settled("Show Title.mkv", seconds(124 * 60), &corpus), + Settled::Undecided { + refused: vec![ + (ShowId(1), Fact::NotReleased), + (ShowId(2), Fact::Episodes(2)), + ], + } + ); + } + + #[test] + fn shows_the_facts_refuse_some_of_leave_the_rest_undecided() { + let corpus = listed_as(&[ + ("Show Title", Some(a_film(1988, true))), + ("Show Title", Some(a_film(2027, false))), + ("Show Title", Some(a_film(1988, true))), + ]); + + assert_eq!( + settled("Show Title.mkv", seconds(124 * 60), &corpus), + Settled::Undecided { + refused: vec![(ShowId(2), Fact::NotReleased)], + } + ); + } + + #[test] + fn an_episode_past_a_candidate_s_count_takes_it_out() { + // A film and a series of one title: the fifth episode is the + // series'. No length is known, so the film's length refuses nothing. + let corpus = listed_as(&[ + ("Show Title", Some(a_film(1983, true))), + ( + "Show Title", + Some(Facts { + episodes: Some(50), + ..a_series() + }), + ), + ]); + + assert_eq!( + settled("Show Title - 05.mkv", Known::NotReported, &corpus), + Settled::Chosen { + show: ShowId(2), + episode: Episode::Only(5), + refused: vec![( + ShowId(1), + Fact::PastCount { + episode: 5, + episodes: 1, + } + )], + } + ); + } + + #[test] + fn of_several_shows_a_fact_of_the_five_is_named_before_the_count() { + // The film is refused by its length and by its count, and the length + // is asked first. + let corpus = listed_as(&[ + ("Show Title", Some(a_film(1983, true))), + ( + "Show Title", + Some(Facts { + episodes: Some(50), + ..a_series() + }), + ), + ]); + + assert_eq!( + settled("Show Title - 05.mkv", Known::Value(EPISODE), &corpus), + Settled::Chosen { + show: ShowId(2), + episode: Episode::Only(5), + refused: vec![( + ShowId(1), + Fact::Length { + file: EPISODE, + episode: Duration::from_mins(124), + } + )], + } + ); + } + + #[test] + fn the_season_that_holds_the_episode_is_chosen_over_one_too_short() { + let corpus = listed_as(&[ + ("Show Title", Some(a_series())), + ( + "Show Title", + Some(Facts { + episodes: Some(13), + ..a_series() + }), + ), + ]); + + assert_eq!( + settled( + "[Group] Show Title - 13 [1080p].mkv", + Known::Value(EPISODE), + &corpus + ), + Settled::Chosen { + show: ShowId(2), + episode: Episode::Only(13), + refused: vec![( + ShowId(1), + Fact::PastCount { + episode: 13, + episodes: 12, + } + )], + } + ); + } + + #[test] + fn a_candidate_with_no_count_holds_any_episode() { + let corpus = listed_as(&[ + ( + "Show Title", + Some(Facts { + episodes: None, + ..a_series() + }), + ), + ("Show Title", Some(a_series())), + ]); + + assert_eq!( + settled( + "[Group] Show Title - 13 [1080p].mkv", + Known::Value(EPISODE), + &corpus + ), + Settled::Chosen { + show: ShowId(1), + episode: Episode::Only(13), + refused: vec![( + ShowId(2), + Fact::PastCount { + episode: 13, + episodes: 12, + } + )], + } + ); + } + + #[test] + fn an_episode_past_the_count_of_the_one_entry_named_is_not_asked() { + // A release can number a later season on from the first. + assert_eq!( + settled( + "[Group] Show Title - 13 [1080p].mkv", + Known::Value(EPISODE), + &listed(a_series()) + ), + Settled::Stands { + show: SHOW, + episode: Episode::Only(13), + } + ); + } + + #[test] + fn of_a_series_and_its_ova_a_name_that_spells_ova_is_the_ova() { + let corpus = listed_as(&[ + ( + "Show Title", + Some(Facts { + format: Some(Format::Tv), + ..a_series() + }), + ), + ( + "Show Title (OVA)", + Some(Facts { + episodes: Some(2), + format: Some(Format::Ova), + ..a_series() + }), + ), + ]); + + assert_eq!( + settled( + "[Group] Show Title OVA - 01 [1080p].mkv", + Known::NotReported, + &corpus + ), + Settled::Chosen { + show: ShowId(2), + episode: Episode::Only(1), + refused: vec![(ShowId(1), Fact::Kind(Kind::Extra))], + } + ); + } + + #[test] + fn a_year_a_candidate_s_title_carries_is_not_asked_of_it() { + // Two entries of one title, one not yet released. The number in the + // name is the title's, so it refuses neither, and the facts choose. + let corpus = listed_as(&[ + ( + "Show Title 2032", + Some(Facts { + year: Some(1987), + ..a_series() + }), + ), + ( + "Show Title 2032", + Some(Facts { + released: false, + ..a_series() + }), + ), + ]); + + assert_eq!( + settled( + "[Group] Show Title 2032 - 03 [1080p].mkv", + Known::Value(EPISODE), + &corpus + ), + Settled::Chosen { + show: ShowId(1), + episode: Episode::Only(3), + refused: vec![(ShowId(2), Fact::NotReleased)], + } + ); + } + + #[test] + fn the_facts_of_an_entry_are_the_list_s() { + let entry = Entry { + id: EntryId(900_001), + title: "Show Title".to_owned(), + spellings: Vec::new(), + format: Some(Format::Ova), + episodes: Some(2), + episode_length: Some(Duration::from_mins(40)), + year: Some(2011), + released: false, + status: Status::Planned, + progress: 0, + rewatches: 0, + score: None, + }; + + assert_eq!( + Facts::from(&entry), + Facts { + episodes: Some(2), + episode_length: Some(Duration::from_mins(40)), + year: Some(2011), + released: false, + format: Some(Format::Ova), + } + ); + } +} diff --git a/crates/core/src/timeline.rs b/crates/core/src/timeline.rs index fe96f83..78cdcbd 100644 --- a/crates/core/src/timeline.rs +++ b/crates/core/src/timeline.rs @@ -381,7 +381,7 @@ impl WatchedPolicy { /// same two values before a reading is ever built. It is repeated here because a /// recording is a text file anyone can edit, and because another adapter is not /// bound by what this one refuses. -fn duration_of(reading: &PlayerSnapshot) -> Known { +pub(crate) fn duration_of(reading: &PlayerSnapshot) -> Known { let Known::Value(duration) = reading.duration else { return reading.duration; }; diff --git a/crates/core/tests/fixtures/filenames.jsonl b/crates/core/tests/fixtures/filenames.jsonl index 527c483..5575fed 100644 --- a/crates/core/tests/fixtures/filenames.jsonl +++ b/crates/core/tests/fixtures/filenames.jsonl @@ -42,7 +42,9 @@ {"name": "Alpha Beta - 03 [Uncensored].mkv", "rule": "a word the parser reports as other, such as Uncensored, counts against no entry", "list": [{"title": "Alpha Beta Gamma"}], "expect": {"recognized": {"title": "Alpha Beta Gamma", "episode": 3, "stage": "score"}}} {"name": "Completely Different - 03.mkv", "rule": "a name no entry shares a word with is refused with nothing scored", "list": [{"title": "Show Title"}], "expect": {"unrecognized": "nothing_scored"}} {"name": "ep 03.mkv", "rule": "a name that spells no title reaches the end of the sequence and is refused", "list": [{"title": "Show Title"}], "expect": {"unrecognized": "nothing_scored"}} -{"name": "[Group] 文豪ストレイドッグス 『独り歩む』; - 03.mkv", "rule": "a stage cannot find a title in a stray mark", "list": [{"title": "Bungou Stray Dogs: Hitori Ayumu", "spellings": ["文豪ストレイドッグス 『独り歩む』;"]}], "expect": {"unrecognized": "nothing_scored"}, "owed": "the parser: it takes the bracketed part of the name for a release group and drops the rest, leaving a title of one mark that no stage can place"} +{"name": "[Group] 文豪ストレイドッグス 『独り歩む』; - 03.mkv", "rule": "a subtitle in Japanese title quotes stays in the title and reaches the entry that spells it", "list": [{"title": "Bungou Stray Dogs: Hitori Ayumu", "spellings": ["文豪ストレイドッグス 『独り歩む』;"]}], "expect": {"recognized": {"title": "Bungou Stray Dogs: Hitori Ayumu", "episode": 3, "stage": "key"}}} {"name": "[Group] E7 - 03 [1080p].mkv", "rule": "a title that looks like an episode marker is read as one", "list": [{"title": "E7"}], "expect": {"unrecognized": "nothing_scored"}, "owed": "the parser: it reads the title as the seventh episode and the episode as the title"} {"name": "Alpha Beta Remastered - 03.mkv", "rule": "an entry that spells a keyword the parser reports as other is filed without it", "list": [{"title": "Alpha Beta Remastered"}], "expect": {"recognized": {"title": "Alpha Beta Remastered", "episode": 3, "stage": "key"}}} {"name": "Alpha Beta Uncut - 03.mkv", "rule": "a name with a keyword the parser reports as other reaches the entry that spells it and the entry that does not, and says so", "list": [{"title": "Alpha Beta"}, {"title": "Alpha Beta Uncut"}], "expect": {"ambiguous": ["Alpha Beta", "Alpha Beta Uncut"]}} +{"name": "Show Title 13.mkv", "rule": "a number ending the title is read into it where the key of the title alone reaches nothing", "list": [{"title": "Show Title 13"}, {"title": "Another Show"}], "expect": {"recognized": {"title": "Show Title 13", "episode": "none", "stage": "key"}}} +{"name": "Show Title/01 Quiet Harbor.mkv", "rule": "a name that begins with its episode is of the show its directory names", "list": [{"title": "Show Title"}, {"title": "Another Show"}], "expect": {"recognized": {"title": "Show Title", "episode": 1, "stage": "key"}}} diff --git a/crates/core/tests/recognition_against_the_fixtures.rs b/crates/core/tests/recognition_against_the_fixtures.rs index 5063545..a63c893 100644 --- a/crates/core/tests/recognition_against_the_fixtures.rs +++ b/crates/core/tests/recognition_against_the_fixtures.rs @@ -13,8 +13,8 @@ //! says what somebody watches. A line names the rule it holds, the list it is //! decided against, and the answer. **Where the answer is a known wrong or //! weak one, the line says what is owed and to what**: to the list, for an -//! entry's format and episode count; to the year, which the list's side does -//! not carry; to the parser, for text it dropped or placed wrongly. Those lines +//! entry's format and episode count; to the year, which the lists of the +//! fixtures do not carry; to the parser, for text it dropped or placed wrongly. Those lines //! pin where the answer lands today, so that a change to it is noticed rather //! than assumed. //! @@ -28,7 +28,7 @@ use benshi_core::recognize::altname::Altnames; use benshi_core::recognize::corpus::Corpus; use benshi_core::recognize::index::Index; use benshi_core::recognize::parse::{Episode, Parsed, parse}; -use benshi_core::recognize::{Recognition, Stage, decide}; +use benshi_core::recognize::{Recognition, Stage, read_file}; use serde::Deserialize; const FIXTURES: &str = include_str!("fixtures/filenames.jsonl"); @@ -157,6 +157,9 @@ impl Shape { Stage::Altname(_) => StageName::Altname, Stage::Key => StageName::Key, Stage::Scored(_) => StageName::Score, + // Kept by the daemon, which files a show for a name the + // list refused; the sequence alone never answers it. + Stage::Unlisted => panic!("recognition answered a name kept as unlisted"), }, }, Recognition::Ambiguous(ambiguity) => Self::Ambiguous( @@ -190,7 +193,7 @@ fn fixtures() -> Vec { .collect() } -/// A name parsed the way the daemon parses it: the file's name and not its path. +/// A file's name parsed alone, without the directories above it. fn parsed(name: &RawPath) -> Parsed { parse(&RawPath::from_bytes(name.file_name().to_vec())) } @@ -245,7 +248,7 @@ fn every_fixture_resolves_to_the_answer_it_expects() { let index = Index::of(&corpus); let altnames = altnames_of(&fixture.altnames, &fixture.list, &corpus, &fixture.rule); - let answer = decide(&parsed(&fixture.name), &altnames, &corpus, &index); + let (_, answer) = read_file(&fixture.name, &altnames, &corpus, &index); let got = Shape::of(&answer); let wanted = Shape::expected(&fixture.expect, &fixture.rule); diff --git a/crates/core/tests/the_session_against_a_recording.rs b/crates/core/tests/the_session_against_a_recording.rs index 899ba27..0f0ddf1 100644 --- a/crates/core/tests/the_session_against_a_recording.rs +++ b/crates/core/tests/the_session_against_a_recording.rs @@ -1,7 +1,8 @@ //! The recording, from the file to what a store is asked to do. //! //! Through the public API alone: parse a trace, decide each reading's file -//! against a list, hand both to a session, and read what it answers. +//! against a list, settle it by the list's facts, hand both to a session, and +//! read what it answers. //! //! The recording is sixty readings of one mpv playing one episode, with a //! pause of twenty seconds in it. It holds thirty-nine seconds of playback @@ -11,13 +12,13 @@ use std::time::Duration; -use benshi_core::path::RawPath; use benshi_core::recognize::altname::Altnames; use benshi_core::recognize::corpus::Corpus; use benshi_core::recognize::index::Index; -use benshi_core::recognize::parse::{Episode, parse}; -use benshi_core::recognize::{Match, Recognition, Stage, decide}; +use benshi_core::recognize::parse::Episode; +use benshi_core::recognize::{Match, Recognition, Stage, read_file}; use benshi_core::session::{Effect, Session}; +use benshi_core::settle::{Facts, Settled, settle}; use benshi_core::timeline::{WATCHED_MINIMUM, WATCHED_PERCENT_MIN, WatchedPolicy}; use benshi_core::trace::Trace; use benshi_core::{MediaRef, PlayerSnapshot, ShowId}; @@ -43,16 +44,28 @@ fn recording() -> Vec { } /// What recognition answers for the file a reading names, against a list -/// holding the title. -fn decided(reading: &PlayerSnapshot) -> Recognition { +/// holding the title as a series of episodes as long as the file, and what +/// the list's facts settle of it. +fn decided(reading: &PlayerSnapshot) -> (Recognition, Settled) { let MediaRef::LocalFile(path) = &reading.media else { panic!("the recording plays a file") }; let mut list = Corpus::new(); list.file(SHOW, TITLE, Vec::<&str>::new()); - let name = RawPath::from_bytes(path.file_name().to_vec()); + list.file_facts( + SHOW, + Facts { + episodes: Some(12), + episode_length: Some(Duration::from_secs(600)), + year: None, + released: true, + format: None, + }, + ); + let (spelled, answer) = read_file(path, &Altnames::new(), &list, &Index::of(&list)); + let settled = settle(&answer, &spelled, reading, &list); - decide(&parse(&name), &Altnames::new(), &list, &Index::of(&list)) + (answer, settled) } #[test] @@ -67,12 +80,18 @@ fn the_recording_is_kept_interval_by_interval_and_never_recorded() { let readings = recording(); assert_eq!( decided(&readings[0]), - Recognition::Recognized(Match { - show: SHOW, - title: TITLE.to_owned(), - episode: Episode::Only(3), - stage: Stage::Key, - }) + ( + Recognition::Recognized(Match { + show: SHOW, + title: TITLE.to_owned(), + episode: Episode::Only(3), + stage: Stage::Key, + }), + Settled::Stands { + show: SHOW, + episode: Episode::Only(3), + } + ) ); let loosest = WatchedPolicy::new(WATCHED_PERCENT_MIN, WATCHED_MINIMUM) .expect("the smallest percentage and the smallest fallback are in range"); @@ -81,7 +100,8 @@ fn the_recording_is_kept_interval_by_interval_and_never_recorded() { let mut kept = Vec::new(); let mut recorded = 0; for reading in &readings { - for effect in session.advance(reading, Some(&decided(reading))) { + let (_, settled) = decided(reading); + for effect in session.advance(reading, Some(&settled)) { match effect { Effect::Resume { question, .. } => session.resumed(question, None), Effect::Keep { watched, .. } => kept.push(watched), diff --git a/crates/daemon/src/detection.rs b/crates/daemon/src/detection.rs index ddca78d..cb0e358 100644 --- a/crates/daemon/src/detection.rs +++ b/crates/daemon/src/detection.rs @@ -17,16 +17,25 @@ //! shows what the daemon is acting on, and a second look at the platform would //! be free to disagree with it. //! -//! And a round decides what each admitted reading has open, leaving the last -//! decision in [`Decided`] for a client asking why. Recognition itself is pure -//! logic in `benshi-core`; what is here is the moment it is asked. +//! And a round decides what each admitted reading has open and settles it by +//! the facts the list holds, leaving the last decision in [`Decided`] for a +//! client asking why. Recognition and settling are pure logic in +//! `benshi-core`; what is here is the moment they are asked. //! -//! **What was decided is what gets recorded.** Every admitted reading goes to -//! a [`Session`] with the decision about it, and what the session answers is +//! **What was settled is what gets recorded.** Every admitted reading goes to +//! a [`Session`] with what was settled of it, and what the session answers is //! carried out in the store: what was watched of an episode is kept, an //! episode watched far enough is recorded, and an episode is closed at the //! end of a round that leaves it open in no player. //! +//! **A name the list does not hold is kept.** Where recognition refuses a +//! name that spells a title, the store files a show of its own for the name +//! the first time its key is seen, and the same show every time after, so the +//! name is settled and recorded as any other. The show is linked to no entry +//! of a list. A list not read yet holds no name, so a name played before the +//! first reading of the list is kept this way too. A name that spells no +//! title stays refused. +//! //! **A player has left what it had open when a round hears nothing of it //! that says otherwise.** A reading that policy admitted says otherwise. So //! does a failure to answer, and so does a reading that came without a @@ -56,8 +65,11 @@ use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, SystemTime}; use benshi_core::policy::PolicyTable; -use benshi_core::recognize::Recognition; +use benshi_core::recognize::normalize::Key; +use benshi_core::recognize::parse::Parsed; +use benshi_core::recognize::{Match, Recognition, Stage}; use benshi_core::session::{Effect, Resumed, Session}; +use benshi_core::settle::Settled; use benshi_core::timeline::WatchedPolicy; use benshi_core::{MediaRef, PlayerId, PlayerSnapshot}; use benshi_detect::{PlayerWatcher, PollOutcome, SourceInfo, WatchError}; @@ -173,6 +185,7 @@ fn refused(error: benshi_store::Error) -> TaskError { | Error::Orphan { .. } | Error::Entry { .. } | Error::Showless { .. } + | Error::Name { .. } | Error::Media { .. } | Error::Instant { .. } => TaskError::Permanent(error.into()), } @@ -249,6 +262,33 @@ where } } +/// A name recognition refused, as the show it is kept as where it spells a +/// title, and as it was answered where it spells none. +/// +/// The store files the show the first time the name's key is seen, and finds +/// it by that key alone after. +async fn unlisted( + store: Arc>, + answer: Recognition, + parsed: &Parsed, +) -> Result { + let Recognition::Unrecognized(_) = &answer else { + return Ok(answer); + }; + let (Some(title), Some(key)) = (parsed.title.clone(), Key::from_parsed(parsed)) else { + return Ok(answer); + }; + let spelled = title.clone(); + let show = ask(store, move |store| store.unlisted(&key, &spelled)).await?; + + Ok(Recognition::Recognized(Match { + show, + title, + episode: parsed.episode, + stage: Stage::Unlisted, + })) +} + /// Readings from a platform, filtered by policy, published to the bus and /// recorded in the store. #[derive(Debug)] @@ -382,7 +422,7 @@ impl Detection { if !self.heard.contains(&snapshot.player) { self.heard.insert(snapshot.player.clone()); } - let decision = self.decide(&recognizer, &snapshot); + let decision = self.decide(&recognizer, &snapshot).await?; let effects = self.session.advance(&snapshot, decision.as_ref()); self.carry_out(effects).await?; self.bus.publish(BusEvent::Snapshot(snapshot)); @@ -424,8 +464,9 @@ impl Detection { } } - /// Decide what an admitted reading has open, leave the decision where a - /// client can read it, and answer with it. + /// Decide what an admitted reading has open, settle it by the facts the + /// list holds, leave both where a client can read them, and answer with + /// what was settled. /// /// A file and nothing else. An address names no file on this machine, and /// a title with nothing underneath it is what a browser publishes; @@ -433,10 +474,15 @@ impl Detection { /// for. Every admitted reading of a file is decided, every round, so that /// what a client reads is the decision about the reading the daemon last /// acted on. A name that spells a title the list does not hold wakes the - /// list's task, which may read the list again. - fn decide(&self, recognizer: &Recognizer, snapshot: &PlayerSnapshot) -> Option { + /// list's task, which may read the list again, and is kept as a show of + /// its own. + async fn decide( + &mut self, + recognizer: &Recognizer, + snapshot: &PlayerSnapshot, + ) -> Result, TaskError> { let MediaRef::LocalFile(path) = &snapshot.media else { - return None; + return Ok(None); }; let (parsed, answer) = recognizer.decide(path); if let Recognition::Unrecognized(refusal) = &answer @@ -444,15 +490,18 @@ impl Detection { { self.wakes.missed(); } + let answer = unlisted(Arc::clone(&self.store), answer, &parsed).await?; + let settled = recognizer.settle(&answer, &parsed, snapshot); self.decided.record(Decision { player: snapshot.player.clone(), media: snapshot.media.clone(), parsed, - answer: answer.clone(), + answer, + settled: settled.clone(), }); - Some(answer) + Ok(Some(settled)) } /// Carry out what the session answered, in the order it answered, and @@ -557,10 +606,11 @@ mod tests { use benshi_core::clock::{Clock, TestClock, Timestamp}; use benshi_core::path::RawPath; use benshi_core::policy::{Policy, PolicyTable}; - use benshi_core::recognize::Recognition; use benshi_core::recognize::altname::Altnames; use benshi_core::recognize::corpus::Corpus; use benshi_core::recognize::parse::Episode; + use benshi_core::recognize::{Recognition, Stage}; + use benshi_core::settle::{Fact, Facts, Settled}; use benshi_core::{ AppName, Capabilities, Known, MediaRef, PlayState, PlayerId, PlayerSnapshot, ShowId, }; @@ -822,9 +872,9 @@ mod tests { /// Detection over these rounds, against this policy table, leaving what it /// saw in `seen`. /// - /// Nothing to match a name against, so nothing reaches the database it is - /// handed back with, and no decision read back: a test that reads one - /// takes [`deciding`] instead. + /// Nothing to match a name against, so a name that spells a title is kept + /// as a show of its own in the database it is handed back with, and no + /// decision read back: a test that reads one takes [`deciding`] instead. fn detecting( watcher: ScriptedWatcher, bus: Arc, @@ -1414,8 +1464,12 @@ mod tests { assert_eq!(decision.parsed.title.as_deref(), Some("Show Title")); assert_eq!(decision.parsed.episode, Episode::Only(3)); assert!( - matches!(decision.answer, Recognition::Unrecognized(_)), - "nothing to match against, got {:?}", + matches!( + &decision.answer, + Recognition::Recognized(found) + if found.stage == Stage::Unlisted && found.title == "Show Title" + ), + "nothing to match against, so a show of its own, got {:?}", decision.answer ); } @@ -1692,6 +1746,105 @@ mod tests { ); } + #[tokio::test] + async fn a_file_a_fact_of_its_entry_refuses_is_neither_kept_nor_recorded() { + // The list holds the series as running an hour an episode, and a + // file of four minutes is no episode of it however long it plays. + // The decision a client reads says which fact refused it. + let database = Database::new(); + let decided = Decided::new(); + let mpv = a_source("mpv", "mpv"); + let watcher = ScriptedWatcher::of(watching(&mpv, THIRD, 0..=HALF_AT + 1)); + let mut list = Corpus::new(); + for (show, title) in LIST { + list.file(show, title, Vec::<&str>::new()); + } + let an_hour = Duration::from_hours(1); + list.file_facts( + show_of(SERIES), + Facts { + episodes: Some(12), + episode_length: Some(an_hour), + year: None, + released: true, + format: None, + }, + ); + let wiring = Wiring { + recognizer: Current::new(Recognizer::of(list, Altnames::new())), + decided: decided.clone(), + ..wired_to(&database) + }; + let mut detection = Detection::new(watcher, wiring); + + rounds(&mut detection, HALF_AT + 2).await; + + assert_eq!(database.seen(), NOTHING_SEEN); + assert_eq!(database.count_of("watching"), [0]); + assert!(database.queued().is_empty(), "{:?}", database.queued()); + assert_eq!( + decided.latest().expect("the reading was decided").settled, + Settled::Refused { + show: show_of(SERIES), + fact: Fact::Length { + file: LENGTH, + episode: an_hour, + }, + } + ); + } + + #[tokio::test] + async fn a_name_two_entries_share_is_recorded_under_the_one_the_facts_chose() { + // A second entry of the series' title, which the list holds as not + // yet released: the name reaches both, and the file is of the first. + let database = Database::new(); + let mpv = a_source("mpv", "mpv"); + let watcher = ScriptedWatcher::of(watching(&mpv, THIRD, 0..=HALF_AT)); + let mut list = Corpus::new(); + for (show, title) in LIST { + list.file(show, title, Vec::<&str>::new()); + } + list.file(ShowId(4), SERIES, Vec::<&str>::new()); + list.file_facts( + ShowId(4), + Facts { + episodes: None, + episode_length: None, + year: None, + released: false, + format: None, + }, + ); + let wiring = Wiring { + recognizer: Current::new(Recognizer::of(list, Altnames::new())), + ..wired_to(&database) + }; + let mut detection = Detection::new(watcher, wiring); + + rounds(&mut detection, HALF_AT + 1).await; + + assert_eq!( + database.seen(), + [Viewed { + title: SERIES.to_owned(), + episode: Some(3), + media: THIRD.to_owned(), + at: THE_TIME.to_owned(), + }] + ); + // The show of the list, where a show kept for the name would carry + // its title too. + assert_eq!( + database + .queued() + .iter() + .map(|operation| operation.show) + .collect::>(), + [show_of(SERIES)] + ); + } + #[tokio::test] async fn what_was_watched_is_kept_with_its_file_and_the_time() { let database = Database::new(); @@ -1987,7 +2140,7 @@ mod tests { async fn a_player_that_opens_a_file_nobody_recognized_closes_its_episode() { let database = Database::new(); let mpv = a_source("mpv", "mpv"); - let moved_on = a_round_of(&[&mpv], vec![playing(&mpv, NOT_LISTED, HALF_AT + 1)]); + let moved_on = a_round_of(&[&mpv], vec![playing(&mpv, "/anime/03.mkv", HALF_AT + 1)]); let mut detection = registered(&database, &mpv, moved_on).await; rounds(&mut detection, 1).await; @@ -2087,10 +2240,10 @@ mod tests { } #[tokio::test] - async fn a_file_nobody_recognized_reaches_no_table() { + async fn a_name_that_spells_no_title_reaches_no_table() { let database = Database::new(); let mpv = a_source("mpv", "mpv"); - let watcher = ScriptedWatcher::of(watching(&mpv, NOT_LISTED, 0..=HALF_AT)); + let watcher = ScriptedWatcher::of(watching(&mpv, "/anime/03.mkv", 0..=HALF_AT)); let mut detection = recording(watcher, &database); rounds(&mut detection, HALF_AT + 1).await; @@ -2100,11 +2253,94 @@ mod tests { [3], "the shows of the list alone" ); - for table in ["watching", "episodes_seen", "sync_queue"] { + for table in ["names", "watching", "episodes_seen", "sync_queue"] { assert_eq!(database.count_of(table), [0], "{table}"); } } + #[tokio::test] + async fn a_name_the_list_does_not_hold_is_recorded_as_a_show_of_its_own() { + let database = Database::new(); + let mpv = a_source("mpv", "mpv"); + let watcher = ScriptedWatcher::of(watching(&mpv, NOT_LISTED, 0..=HALF_AT)); + let mut detection = recording(watcher, &database); + + rounds(&mut detection, HALF_AT + 1).await; + + assert_eq!( + database.seen(), + [Viewed { + title: "Some Other Show".to_owned(), + episode: Some(3), + media: NOT_LISTED.to_owned(), + at: THE_TIME.to_owned(), + }] + ); + assert_eq!( + database.queued(), + [Operation { + id: 1, + show: ShowId(4), + title: "Some Other Show".to_owned(), + kind: Kind::Progress { episode: Some(3) }, + }] + ); + assert_eq!(database.count_of("names"), [1]); + } + + #[tokio::test] + async fn a_name_kept_once_is_the_same_show_after_a_restart() { + let database = Database::new(); + let mpv = a_source("mpv", "mpv"); + let fourth = "/anime/[Group] Some Other Show - 04 [1080p].mkv"; + let first = ScriptedWatcher::of(watching(&mpv, NOT_LISTED, 0..=HALF_AT)); + rounds(&mut recording(first, &database), HALF_AT + 1).await; + + let again = ScriptedWatcher::of(watching(&mpv, fourth, 0..=HALF_AT)); + rounds(&mut recording(again, &database), HALF_AT + 1).await; + + let shows = database.rows( + "SELECT DISTINCT show FROM episodes_seen ORDER BY show", + |row| row.get::<_, i64>(0), + ); + assert_eq!(shows, [4], "{:?}", database.seen()); + assert_eq!(database.seen().len(), 2, "{:?}", database.seen()); + assert_eq!(database.count_of("shows"), [4]); + } + + #[tokio::test] + async fn a_name_near_one_kept_is_a_show_of_its_own() { + // The scorer takes the near name for the kept one where the list + // holds that title, and a kept name is reached by its key alone. + let near = "/anime/[Group] Some Other Show Again - 03 [1080p].mkv"; + let mut holding = Corpus::new(); + holding.file(ShowId(1), "Some Other Show", Vec::<&str>::new()); + let (_, against_a_list) = Recognizer::of(holding, Altnames::new()).decide(&a_path(near)); + assert!( + matches!( + &against_a_list, + Recognition::Recognized(found) if matches!(found.stage, Stage::Scored(_)) + ), + "{against_a_list:?}" + ); + let database = Database::new(); + let mpv = a_source("mpv", "mpv"); + let mut rounds_of = watching(&mpv, NOT_LISTED, 0..=HALF_AT); + rounds_of.extend(watching(&mpv, near, HALF_AT + 1..=2 * HALF_AT + 1)); + let mut detection = recording(ScriptedWatcher::of(rounds_of), &database); + + rounds(&mut detection, 2 * HALF_AT + 2).await; + + let titles = database.rows("SELECT title FROM shows ORDER BY id", |row| { + row.get::<_, String>(0) + }); + assert_eq!( + titles[LIST.len()..], + ["Some Other Show", "Some Other Show Again"] + ); + assert_eq!(database.count_of("names"), [2]); + } + #[tokio::test] async fn a_total_the_store_refuses_stops_detection_for_good() { // Detecting on while nothing is recorded looks the same as working. @@ -2368,9 +2604,14 @@ mod tests { detection.round().await.expect("a round"); + // The empty list before keeps the name as a show of its own, where the + // one put in place would have answered the series by its key. let decision = decided.latest().expect("the reading was decided"); assert!( - matches!(decision.answer, Recognition::Unrecognized(_)), + matches!( + &decision.answer, + Recognition::Recognized(found) if found.stage == Stage::Unlisted + ), "got {:?}", decision.answer ); @@ -2391,9 +2632,13 @@ mod tests { current.replace(a_list()); detection.round().await.expect("a round"); + // By its key: a name kept as a show of its own carries the title too. let decision = decided.latest().expect("the reading was decided"); assert!( - matches!(&decision.answer, Recognition::Recognized(found) if found.title == SERIES), + matches!( + &decision.answer, + Recognition::Recognized(found) if found.title == SERIES && found.stage == Stage::Key + ), "got {:?}", decision.answer ); diff --git a/crates/daemon/src/ipc.rs b/crates/daemon/src/ipc.rs index 1b85a15..0df9f5f 100644 --- a/crates/daemon/src/ipc.rs +++ b/crates/daemon/src/ipc.rs @@ -391,9 +391,13 @@ impl Server { self.policy.write().expect(POISONED).set(&app, policy); Response::Ok } - Ok(Request::Why) => { - Response::Why(self.decided.latest().as_ref().map(Explanation::of)) - } + Ok(Request::Why) => Response::Why( + self.decided + .latest() + .as_ref() + .map(Explanation::of) + .map(Box::new), + ), // Answered at once and again once done, since what is done // may wait on a person. Ok(Request::Login { token }) => { @@ -515,6 +519,7 @@ mod tests { use benshi_core::path::RawPath; use benshi_core::policy::{Policy, PolicyTable}; use benshi_core::recognize::parse::Episode; + use benshi_core::settle::Settled; use benshi_core::token::Token; use benshi_core::{ AppName, Capabilities, Known, MediaRef, PlayState, PlayerId, PlayerSnapshot, @@ -723,6 +728,7 @@ mod tests { media: snapshot.media.clone(), parsed, answer, + settled: Settled::Unnamed, }); } } diff --git a/crates/daemon/src/listing.rs b/crates/daemon/src/listing.rs index 0c4dd67..f9b1cfa 100644 --- a/crates/daemon/src/listing.rs +++ b/crates/daemon/src/listing.rs @@ -23,6 +23,7 @@ use benshi_core::ShowId; use benshi_core::list::Entry; use benshi_core::recognize::altname::Altnames; use benshi_core::recognize::corpus::Corpus; +use benshi_core::settle::Facts; use benshi_core::token::Token; use benshi_store::Store; #[cfg(target_os = "linux")] @@ -148,12 +149,14 @@ pub struct Listing { pub say: Say, } -/// A recognizer of these shows of a list and their entries. +/// A recognizer of these shows of a list and their entries, each filed with +/// the facts that settle what is recognized as it. #[must_use] pub fn recognizer_of(listed: &[(ShowId, Entry)]) -> Recognizer { let mut corpus = Corpus::new(); for (show, entry) in listed { corpus.file(*show, &entry.title, &entry.spellings); + corpus.file_facts(*show, Facts::from(entry)); } Recognizer::of(corpus, Altnames::new()) @@ -386,7 +389,9 @@ mod tests { use benshi_core::list::{Entry, EntryId, Status}; use benshi_core::path::RawPath; use benshi_core::recognize::Recognition; + use benshi_core::settle::{Fact, Settled}; use benshi_core::token::Token; + use benshi_core::{Known, MediaRef, PlayState, PlayerId, PlayerSnapshot, ShowId}; use benshi_store::Store; use tempfile::TempDir; use tokio::task::JoinHandle; @@ -640,6 +645,34 @@ mod tests { ); } + #[test] + fn a_recognizer_of_a_list_settles_by_the_facts_of_each_entry() { + // The service lists the entry as not yet released, and no file is of + // a work that has not been released. + let mut announced = an_entry(900_001, "Show Title"); + announced.released = false; + let recognizer = recognizer_of(&[(ShowId(1), announced)]); + let path = RawPath::from_bytes(b"[Group] Show Title - 03.mkv".to_vec()); + let reading = PlayerSnapshot { + player: PlayerId("mpv".to_owned()), + media: MediaRef::LocalFile(path.clone()), + state: PlayState::Playing, + position: Known::NotReported, + duration: Known::NotReported, + observed_at: benshi_core::clock::Timestamp::epoch(), + }; + + let (parsed, answer) = recognizer.decide(&path); + + assert_eq!( + recognizer.settle(&answer, &parsed, &reading), + Settled::Refused { + show: ShowId(1), + fact: Fact::NotReleased, + } + ); + } + #[test] fn a_recognizer_of_the_store_holds_the_list_filed_there() { let home = tempfile::tempdir().expect("a directory of our own"); diff --git a/crates/daemon/src/protocol.rs b/crates/daemon/src/protocol.rs index eaa54ba..a1f51dc 100644 --- a/crates/daemon/src/protocol.rs +++ b/crates/daemon/src/protocol.rs @@ -14,8 +14,9 @@ use benshi_core::policy::Policy; use benshi_core::recognize::altname::Assignment; use benshi_core::recognize::parse::{Episode, Parsed}; use benshi_core::recognize::{Recognition, Score, Stage}; +use benshi_core::settle::{Fact, Settled}; use benshi_core::token::Token; -use benshi_core::{AppName, Capabilities, MediaRef, PlayState, PlayerId}; +use benshi_core::{AppName, Capabilities, MediaRef, PlayState, PlayerId, ShowId}; use benshi_detect::SourceInfo; use serde::{Deserialize, Serialize}; @@ -143,7 +144,7 @@ pub enum Response { /// The most recent decision, in answer to [`Request::Why`], and nothing /// where no reading has been decided yet. - Why(Option), + Why(Option>), /// Whose the token is, in answer to [`Request::Login`], once it is kept. LoggedIn { @@ -160,19 +161,22 @@ pub enum Response { /// The most recent decision, as a client is told it. /// -/// Whose file, which file, what its name spelled and what recognition -/// answered. The parse travels whole rather than as a title, because the -/// season and the part are what the parser cut out of the title, and they are -/// what an explanation of an assignment has to show. +/// Whose file, which file, what its name spelled, what recognition answered +/// or the show kept in its place, and what the facts made of it: the fact +/// that refused the file, or the entries refused and the one chosen of +/// several. The parse travels whole rather than as a title, because the +/// season and the part are what the parser cut out of the title, and they +/// are what an explanation of an assignment has to show. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Explanation { /// The source whose reading was decided. pub player: PlayerId, /// What it had open. pub media: MediaRef, - /// What the name spelled, before anything was matched. + /// What the name spelled, in the reading the answer was decided by. pub spelled: Parsed, - /// What recognition answered. + /// What recognition answered, or the show kept for a name it refused that + /// spells a title. pub outcome: Outcome, } @@ -184,12 +188,12 @@ impl Explanation { player: decision.player.clone(), media: decision.media.clone(), spelled: decision.parsed.clone(), - outcome: Outcome::of(&decision.answer), + outcome: Outcome::of(&decision.answer, &decision.settled), } } } -/// What recognition answered, on the wire. +/// What recognition answered, or the show kept in its place, on the wire. /// /// A form of its own rather than the domain's, because a score is a checked /// proportion that deliberately does not deserialize: a message carrying a @@ -197,20 +201,32 @@ impl Explanation { /// a consumer that needs the checked kind converts through the check. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum Outcome { - /// A title, an episode and the stage that decided. + /// A title, an episode, the stage that decided, and the fact of the + /// entry that refused the file, where one did. Recognized { - /// The title as the corpus spells it. + /// The title as the corpus spells it, or as the name spelled it where + /// the stage is [`DecidedBy::Unlisted`]. title: String, /// The episode in the release's numbering, as it was read from the name. episode: Episode, /// Which stage decided, and what it decided by. stage: DecidedBy, + /// The fact of the entry that refused the file, and none where the + /// facts let it stand. + refused: Option, }, - /// Several entries the name reaches, and nothing in it to choose between. + /// Several entries the name reaches, the ones the facts of their + /// entries refused, and the one they chose. Ambiguous { /// The title of each entry it reaches, as the corpus spells it. Two /// entries can share one. candidates: Vec, + /// The title of each entry a fact refused, with the fact, in the + /// order of the candidates. + refused: Vec<(String, Fact)>, + /// The title of the entry the facts left alone, with the episode the + /// name spells, and none where they left none or several. + chosen: Option<(String, Episode)>, }, /// Nothing matched. Unrecognized { @@ -220,20 +236,54 @@ pub enum Outcome { } impl Outcome { - fn of(answer: &Recognition) -> Self { + fn of(answer: &Recognition, settled: &Settled) -> Self { match answer { Recognition::Recognized(found) => Self::Recognized { title: found.title.clone(), episode: found.episode, stage: DecidedBy::of(&found.stage), + refused: match settled { + Settled::Refused { fact, .. } => Some(*fact), + Settled::Stands { .. } + | Settled::Chosen { .. } + | Settled::Undecided { .. } + | Settled::Unnamed => None, + }, }, - Recognition::Ambiguous(ambiguity) => Self::Ambiguous { - candidates: ambiguity - .candidates - .iter() - .map(|candidate| candidate.title.clone()) - .collect(), - }, + Recognition::Ambiguous(ambiguity) => { + let title_of = |show: ShowId| { + ambiguity + .candidates + .iter() + .find(|candidate| candidate.show == show) + .map(|candidate| candidate.title.clone()) + .unwrap_or_default() + }; + let (refused, chosen) = match settled { + Settled::Chosen { + show, + episode, + refused, + } => (refused.as_slice(), Some((title_of(*show), *episode))), + Settled::Undecided { refused } => (refused.as_slice(), None), + Settled::Stands { .. } | Settled::Refused { .. } | Settled::Unnamed => { + (&[][..], None) + } + }; + + Self::Ambiguous { + candidates: ambiguity + .candidates + .iter() + .map(|candidate| candidate.title.clone()) + .collect(), + refused: refused + .iter() + .map(|&(show, fact)| (title_of(show), fact)) + .collect(), + chosen, + } + } Recognition::Unrecognized(refusal) => Self::Unrecognized { best: refusal.best.map(Score::value), }, @@ -250,6 +300,8 @@ pub enum DecidedBy { Key, /// The best candidate the scorer found, and how well it scored. Scored(f64), + /// A name the list does not hold, kept as a show of its own. + Unlisted, } impl DecidedBy { @@ -258,6 +310,7 @@ impl DecidedBy { Stage::Altname(assignment) => Self::Altname(assignment.clone()), Stage::Key => Self::Key, Stage::Scored(score) => Self::Scored(score.value()), + Stage::Unlisted => Self::Unlisted, } } } @@ -271,8 +324,9 @@ mod tests { use benshi_core::path::RawPath; use benshi_core::policy::Policy; use benshi_core::recognize::altname::Assignment; - use benshi_core::recognize::parse::Episode; + use benshi_core::recognize::parse::{Episode, Kind}; use benshi_core::recognize::{Ambiguity, Candidate, Match, Recognition, Refusal, Score, Stage}; + use benshi_core::settle::{Fact, Settled}; use benshi_core::token::Token; use benshi_core::{ AppName, Capabilities, Known, MediaRef, PlayState, PlayerId, PlayerSnapshot, ShowId, @@ -311,11 +365,25 @@ mod tests { } } - /// A decision about the file [`a_snapshot`] has open, answered this way. + /// A decision about the file [`a_snapshot`] has open, answered this way + /// and refused by no fact. /// /// Parsed the way a round parses it, so that what the tests explain is /// what the daemon would. fn a_decision(answer: Recognition) -> Decision { + let settled = match &answer { + Recognition::Recognized(found) => Settled::Stands { + show: found.show, + episode: found.episode, + }, + Recognition::Ambiguous(_) | Recognition::Unrecognized(_) => Settled::Unnamed, + }; + decided_as(answer, settled) + } + + /// A decision about the file [`a_snapshot`] has open, answered this way + /// and settled this way. + fn decided_as(answer: Recognition, settled: Settled) -> Decision { let snapshot = a_snapshot(); let MediaRef::LocalFile(path) = &snapshot.media else { panic!("the snapshot has a file open"); @@ -327,9 +395,31 @@ mod tests { media: snapshot.media.clone(), parsed, answer, + settled, } } + /// One of each fact that refuses a file. + fn every_fact() -> [Fact; 6] { + [ + Fact::NotReleased, + Fact::Year { + spelled: 2001, + began: 2019, + }, + Fact::Episodes(2), + Fact::Length { + file: Duration::from_secs(114), + episode: Duration::from_mins(40), + }, + Fact::PastCount { + episode: 5, + episodes: 1, + }, + Fact::Kind(Kind::Extra), + ] + } + fn an_assignment() -> Assignment { Assignment { spelled: "SnK".to_owned(), @@ -357,6 +447,7 @@ mod tests { found_by(Stage::Altname(an_assignment())), found_by(Stage::Key), found_by(Stage::Scored(Score::new(0.83).expect("a proportion"))), + found_by(Stage::Unlisted), Recognition::Ambiguous(Ambiguity { parsed: "Fruits Basket".to_owned(), candidates: vec![ @@ -409,8 +500,39 @@ mod tests { responses.extend( every_recognition() .into_iter() - .map(|answer| Response::Why(Some(Explanation::of(&a_decision(answer))))), + .map(|answer| Response::Why(Some(Box::new(Explanation::of(&a_decision(answer)))))), + ); + responses.extend(every_fact().into_iter().map(|fact| { + let refused = decided_as( + found_by(Stage::Key), + Settled::Refused { + show: ShowId(1), + fact, + }, + ); + Response::Why(Some(Box::new(Explanation::of(&refused)))) + })); + let chosen = decided_as( + Recognition::Ambiguous(Ambiguity { + parsed: "Show Title".to_owned(), + candidates: vec![ + Candidate { + show: ShowId(1), + title: "Show Title".to_owned(), + }, + Candidate { + show: ShowId(2), + title: "Show Title (2019)".to_owned(), + }, + ], + }), + Settled::Chosen { + show: ShowId(2), + episode: Episode::Only(3), + refused: vec![(ShowId(1), Fact::NotReleased)], + }, ); + responses.push(Response::Why(Some(Box::new(Explanation::of(&chosen))))); responses } @@ -553,6 +675,7 @@ mod tests { title: "Shingeki no Kyojin".to_owned(), episode: Episode::Only(3), stage: DecidedBy::Altname(an_assignment()), + refused: None, } ); } @@ -569,6 +692,47 @@ mod tests { title: "Shingeki no Kyojin".to_owned(), episode: Episode::Only(3), stage: DecidedBy::Scored(0.83), + refused: None, + } + ); + } + + #[test] + fn an_explanation_of_a_file_a_fact_refused_carries_the_fact() { + // What `benshi why` names a refusal by: the match recognition made, + // and the fact of its entry that refused the file. + for fact in every_fact() { + let explained = Explanation::of(&decided_as( + found_by(Stage::Key), + Settled::Refused { + show: ShowId(1), + fact, + }, + )); + + assert_eq!( + explained.outcome, + Outcome::Recognized { + title: "Shingeki no Kyojin".to_owned(), + episode: Episode::Only(3), + stage: DecidedBy::Key, + refused: Some(fact), + } + ); + } + } + + #[test] + fn an_explanation_of_a_name_the_list_does_not_hold_says_so() { + let explained = Explanation::of(&a_decision(found_by(Stage::Unlisted))); + + assert_eq!( + explained.outcome, + Outcome::Recognized { + title: "Shingeki no Kyojin".to_owned(), + episode: Episode::Only(3), + stage: DecidedBy::Unlisted, + refused: None, } ); } @@ -596,6 +760,78 @@ mod tests { "Fruits Basket".to_owned(), "Fruits Basket (2019)".to_owned(), ], + refused: Vec::new(), + chosen: None, + } + ); + } + + #[test] + fn an_explanation_of_an_ambiguity_the_facts_settled_names_what_they_refused_and_chose() { + let several = || { + Recognition::Ambiguous(Ambiguity { + parsed: "Show Title".to_owned(), + candidates: vec![ + Candidate { + show: ShowId(1), + title: "Show Title".to_owned(), + }, + Candidate { + show: ShowId(2), + title: "Show Title (2019)".to_owned(), + }, + Candidate { + show: ShowId(3), + title: "Show Title".to_owned(), + }, + ], + }) + }; + let year = Fact::Year { + spelled: 2019, + began: 2001, + }; + + let chosen = Explanation::of(&decided_as( + several(), + Settled::Chosen { + show: ShowId(2), + episode: Episode::Only(5), + refused: vec![(ShowId(1), year), (ShowId(3), Fact::NotReleased)], + }, + )); + let undecided = Explanation::of(&decided_as( + several(), + Settled::Undecided { + refused: vec![(ShowId(3), Fact::NotReleased)], + }, + )); + + assert_eq!( + chosen.outcome, + Outcome::Ambiguous { + candidates: vec![ + "Show Title".to_owned(), + "Show Title (2019)".to_owned(), + "Show Title".to_owned(), + ], + refused: vec![ + ("Show Title".to_owned(), year), + ("Show Title".to_owned(), Fact::NotReleased), + ], + chosen: Some(("Show Title (2019)".to_owned(), Episode::Only(5))), + } + ); + assert_eq!( + undecided.outcome, + Outcome::Ambiguous { + candidates: vec![ + "Show Title".to_owned(), + "Show Title (2019)".to_owned(), + "Show Title".to_owned(), + ], + refused: vec![("Show Title".to_owned(), Fact::NotReleased)], + chosen: None, } ); } diff --git a/crates/daemon/src/recognition.rs b/crates/daemon/src/recognition.rs index f2775b7..60651c2 100644 --- a/crates/daemon/src/recognition.rs +++ b/crates/daemon/src/recognition.rs @@ -3,8 +3,8 @@ //! Recognition is pure logic in `benshi-core` and decides against a corpus the //! caller supplies. A [`Recognizer`] holds one, with its index and the names //! the user gave. Against [`Recognizer::empty`] every name reaches the end of -//! the sequence and is refused. A round decides, the decision is left here, -//! and a client reads it. +//! the sequence and is refused. A round decides, the facts the list holds +//! settle what was decided, the decision is left here, and a client reads it. //! //! One decision is kept, the most recent, the way //! [`Seen`](crate::detection::Seen) keeps the most recent listing and for the @@ -17,9 +17,10 @@ use benshi_core::path::RawPath; use benshi_core::recognize::altname::Altnames; use benshi_core::recognize::corpus::Corpus; use benshi_core::recognize::index::Index; -use benshi_core::recognize::parse::{Parsed, parse}; -use benshi_core::recognize::{Recognition, decide}; -use benshi_core::{MediaRef, PlayerId}; +use benshi_core::recognize::parse::Parsed; +use benshi_core::recognize::{Recognition, read_file}; +use benshi_core::settle::{Settled, settle}; +use benshi_core::{MediaRef, PlayerId, PlayerSnapshot}; /// What is said when the record of the last decision cannot be trusted. const POISONED: &str = "last decision was poisoned by a panic in another task"; @@ -54,17 +55,30 @@ impl Recognizer { } } - /// What a file's name spells, and what recognition made of it. + /// What a file's name spells, or the directory above it that names its + /// show, and what recognition made of it. /// - /// The name alone. A directory is not part of what a release spells, and a - /// folder named after another show would otherwise carry that show's words - /// into the name the stages read. + /// The spelling is the one the answer was decided by: a number + /// [`read`](benshi_core::recognize::read) took into the title is in the + /// title, and a show [`read_file`] read from a directory is the show. A + /// directory is read only for a name that spells an episode but no title, + /// or begins with its episode, since a folder named after another show + /// would otherwise carry that show's words into the name the stages read. #[must_use] pub fn decide(&self, path: &RawPath) -> (Parsed, Recognition) { - let parsed = parse(&RawPath::from_bytes(path.file_name().to_vec())); - let answer = decide(&parsed, &self.altnames, &self.corpus, &self.index); + read_file(path, &self.altnames, &self.corpus, &self.index) + } - (parsed, answer) + /// What the facts the list holds settle of an answer [`Recognizer::decide`] + /// gave, for the reading of that file. + #[must_use] + pub fn settle( + &self, + answer: &Recognition, + parsed: &Parsed, + reading: &PlayerSnapshot, + ) -> Settled { + settle(answer, parsed, reading, &self.corpus) } } @@ -100,17 +114,21 @@ impl Current { } } -/// One decision: whose file, which file, what its name spelled, and the answer. +/// One decision: whose file, which file, what its name spelled, the answer, +/// and what the list's facts settled of it. #[derive(Debug, Clone, PartialEq)] pub struct Decision { /// The source whose reading was decided. pub player: PlayerId, /// What that source had open. pub media: MediaRef, - /// What the name spelled, before anything was matched. + /// What the name spelled, in the reading the answer was decided by. pub parsed: Parsed, - /// What recognition answered. + /// What recognition answered, or the show kept for a name it refused that + /// spells a title. pub answer: Recognition, + /// What the facts the list holds settled of the answer. + pub settled: Settled, } /// The most recent decision, for a client asking why. @@ -157,7 +175,8 @@ mod tests { use benshi_core::recognize::corpus::Corpus; use benshi_core::recognize::parse::Episode; use benshi_core::recognize::{Match, Recognition, Refusal, Stage}; - use benshi_core::{MediaRef, PlayerId, ShowId}; + use benshi_core::settle::{Fact, Facts, Settled}; + use benshi_core::{Known, MediaRef, PlayState, PlayerId, PlayerSnapshot, ShowId}; fn a_path(name: &str) -> RawPath { RawPath::from_bytes(name.as_bytes().to_vec()) @@ -190,6 +209,7 @@ mod tests { media: MediaRef::LocalFile(path.clone()), parsed, answer, + settled: Settled::Unnamed, }); } @@ -228,6 +248,42 @@ mod tests { ); } + #[test] + fn a_number_that_is_the_titles_own_is_read_into_it() { + let recognizer = Recognizer::of(a_list_of(&["A Film 13", "Show Title"]), Altnames::new()); + + let (parsed, answer) = recognizer.decide(&a_path("/anime/A Film 13.mkv")); + + assert_eq!(parsed.title.as_deref(), Some("A Film 13")); + assert_eq!( + answer, + Recognition::Recognized(Match { + show: ShowId(1), + title: "A Film 13".to_owned(), + episode: Episode::Absent, + stage: Stage::Key, + }) + ); + } + + #[test] + fn a_name_that_begins_with_its_episode_is_of_the_show_its_directory_names() { + let recognizer = + Recognizer::of(a_list_of(&["Show Title", "Another Show"]), Altnames::new()); + + let (_parsed, answer) = recognizer.decide(&a_path("/anime/Show Title/01 Quiet Harbor.mkv")); + + assert_eq!( + answer, + Recognition::Recognized(Match { + show: ShowId(1), + title: "Show Title".to_owned(), + episode: Episode::Only(1), + stage: Stage::Key, + }) + ); + } + #[test] fn a_name_the_user_gave_is_read_before_the_list() { let list = a_list_of(&["Show Title", "Another Show"]); @@ -279,6 +335,43 @@ mod tests { ); } + #[test] + fn what_was_recognized_is_settled_by_the_facts_the_list_holds() { + // The list files the series as not yet released, and no file is of a + // work that has not been released. + let mut list = a_list_of(&["Show Title"]); + list.file_facts( + ShowId(1), + Facts { + episodes: None, + episode_length: None, + year: None, + released: false, + format: None, + }, + ); + let recognizer = Recognizer::of(list, Altnames::new()); + let path = a_path("/anime/[Group] Show Title - 03.mkv"); + let reading = PlayerSnapshot { + player: PlayerId("mpv".to_owned()), + media: MediaRef::LocalFile(path.clone()), + state: PlayState::Playing, + position: Known::NotReported, + duration: Known::NotReported, + observed_at: benshi_core::clock::Timestamp::epoch(), + }; + + let (parsed, answer) = recognizer.decide(&path); + + assert_eq!( + recognizer.settle(&answer, &parsed, &reading), + Settled::Refused { + show: ShowId(1), + fact: Fact::NotReleased, + } + ); + } + #[test] fn with_nothing_to_match_against_every_name_is_refused() { // With nothing to match against, a refusal with nothing scored is the diff --git a/crates/daemon/tests/the_daemon_runs.rs b/crates/daemon/tests/the_daemon_runs.rs index d92238f..cbfeee9 100644 --- a/crates/daemon/tests/the_daemon_runs.rs +++ b/crates/daemon/tests/the_daemon_runs.rs @@ -19,7 +19,7 @@ use std::future::{self, Future}; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -38,7 +38,7 @@ use benshi_daemon::bus::BusEvent; use benshi_daemon::config::{Period, Reading}; use benshi_daemon::ipc::bind; use benshi_daemon::listing::{Listing, Source}; -use benshi_daemon::protocol::{Request, Response, SourceListing}; +use benshi_daemon::protocol::{DecidedBy, Outcome, Request, Response, SourceListing}; use benshi_daemon::recognition::Recognizer; use benshi_daemon::secrets; use benshi_daemon::supervisor::{BACKOFF_BASE, Notice, RESTART_LIMIT, Stopped, TaskRecord}; @@ -87,11 +87,26 @@ struct Platform { built: Arc, fell_over: Arc, clock: Arc, + /// While set, the file plays and the clock stands, so no time is watched. + held: Arc, fails: bool, panics: bool, } impl Platform { + /// A platform whose clock stands until [`release`](Platform::release). + fn held() -> Self { + Self { + held: Arc::new(AtomicBool::new(true)), + ..Self::default() + } + } + + /// Let the clock of a [`held`](Platform::held) platform move again. + fn release(&self) { + self.held.store(false, Ordering::SeqCst); + } + fn source() -> SourceInfo { SourceInfo { player: PlayerId(PLAYER.to_owned()), @@ -166,6 +181,7 @@ impl Platform { let built = Arc::clone(&self.built); let fell_over = Arc::clone(&self.fell_over); let clock = Arc::clone(&self.clock); + let held = Arc::clone(&self.held); let fails = self.fails; let panics = self.panics; @@ -173,10 +189,12 @@ impl Platform { built.fetch_add(1, Ordering::SeqCst); let fell_over = Arc::clone(&fell_over); let clock = Arc::clone(&clock); + let held = Arc::clone(&held); std::future::ready(Ok(Watcher { fell_over, clock, + held, fails, panics, })) @@ -191,6 +209,7 @@ impl Platform { struct Watcher { fell_over: Arc, clock: Arc, + held: Arc, fails: bool, panics: bool, } @@ -227,7 +246,9 @@ impl PlayerWatcher for Watcher { return Err(Self::refuse()); } - self.clock.advance(STEP); + if !self.held.load(Ordering::SeqCst) { + self.clock.advance(STEP); + } Ok(PollOutcome { sources: vec![Platform::source()], @@ -589,16 +610,54 @@ fn an_empty_store(home: &TempDir) -> Arc> { } /// The one operation a daemon queues once it has recorded the platform's -/// file as an episode of [`the_entry`]. -fn the_episode_queued() -> [Operation; 1] { +/// file as an episode of the show the store filed [`the_entry`] as. +fn the_episode_queued(store: &Arc>) -> [Operation; 1] { + let listed = store + .lock() + .expect("the store") + .listed("anilist") + .expect("the list reads"); + let show = listed + .iter() + .find(|(_, entry)| entry.id == the_entry().id) + .map(|&(show, _)| show) + .expect("the store filed the entry"); + [Operation { id: 1, - show: THE_SHOW, + show, title: "Show".to_owned(), kind: Kind::Progress { episode: Some(3) }, }] } +/// Wait until detection decides the platform's file by the list, then let +/// the platform's clock move. +/// +/// The file plays from the first round, before the list is read, and a name +/// no list holds is kept as a show of its own. A held clock lets no time be +/// watched under that show, so the episode is recorded under the list's show +/// whichever task ran first. +async fn once_decided_by_its_list(platform: &Platform, socket: &Path, what: &str) { + timeout(PATIENCE, async { + loop { + if let Response::Why(Some(explained)) = ask(socket, &Request::Why).await + && let Outcome::Recognized { + stage: DecidedBy::Key, + .. + } = explained.outcome + { + return; + } + tokio::time::sleep(PERIOD).await; + } + }) + .await + .expect(what); + + platform.release(); +} + #[tokio::test] async fn a_running_daemon_records_what_the_list_it_read_holds() { // A daemon that starts with nothing to match a name against, and an @@ -606,14 +665,15 @@ async fn a_running_daemon_records_what_the_list_it_read_holds() { // task read, filed and put in place for detection. let (home, socket) = a_socket(); let store = an_empty_store(&home); - let platform = Platform::default(); + let platform = Platform::held(); let running = a_daemon_reading(&platform, &store, &socket, Reading::default()); + once_decided_by_its_list(&platform, &socket, "the list read at the start").await; let queued = once_queued(&store, "the daemon recorded an episode of its list").await; running.abort(); - assert_eq!(queued, the_episode_queued()); + assert_eq!(queued, the_episode_queued(&store)); } #[tokio::test] @@ -623,7 +683,7 @@ async fn a_running_daemon_reads_its_list_where_a_name_misses_it() { // recognizes the file. let (home, socket) = a_socket(); let store = an_empty_store(&home); - let platform = Platform::default(); + let platform = Platform::held(); let reading = Reading { refresh: Period::Off, at_start: false, @@ -632,17 +692,18 @@ async fn a_running_daemon_reads_its_list_where_a_name_misses_it() { let running = a_daemon_reading(&platform, &store, &socket, reading); - let queued = once_queued(&store, "a miss brought a reading of the list").await; + once_decided_by_its_list(&platform, &socket, "a miss brought a reading of the list").await; + let queued = once_queued(&store, "the daemon recorded an episode of its list").await; running.abort(); - assert_eq!(queued, the_episode_queued()); + assert_eq!(queued, the_episode_queued(&store)); } #[tokio::test] async fn a_running_daemon_reads_its_list_once_a_login_goes_through() { let (home, socket) = a_socket(); let store = an_empty_store(&home); - let platform = Platform::default(); + let platform = Platform::held(); let reading = Reading { refresh: Period::Off, at_start: false, @@ -658,10 +719,11 @@ async fn a_running_daemon_reads_its_list_once_a_login_goes_through() { ) .await; assert_eq!(taken, Response::Ok); - let queued = once_queued(&store, "a login brought a reading of the list").await; + once_decided_by_its_list(&platform, &socket, "a login brought a reading of the list").await; + let queued = once_queued(&store, "the daemon recorded an episode of its list").await; running.abort(); - assert_eq!(queued, the_episode_queued()); + assert_eq!(queued, the_episode_queued(&store)); } #[tokio::test] diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 994c145..7fae498 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -19,6 +19,7 @@ mod list; mod migrate; +mod names; pub mod queue; mod record; pub mod watching; @@ -139,6 +140,16 @@ pub enum Error { /// The entry the row is of. entry: EntryId, }, + /// A row of `names` whose letters or marks + /// [`Key::from_parts`](benshi_core::recognize::normalize::Key::from_parts) + /// refuses: no letters, or a character it does not take in one of them. + /// + /// The crate writes a key's own parts, so one is left by `sqlite3` alone. + #[error("name kept for show {} holds no key", .show.0)] + Name { + /// The show the row is kept for. + show: ShowId, + }, /// A row of `watching` whose media is not a path as this crate writes /// one: a per-cent sign with no two hexadecimal digits after it. #[error("media of row {id} in `watching` is not an escaped path: {text:?}")] diff --git a/crates/store/src/migrate.rs b/crates/store/src/migrate.rs index bda421f..2fbbf07 100644 --- a/crates/store/src/migrate.rs +++ b/crates/store/src/migrate.rs @@ -14,7 +14,7 @@ use crate::Error; /// /// Written by hand rather than computed, so that adding a migration is a /// change in two places that a test holds together. -pub const SCHEMA_VERSION: u32 = 3; +pub const SCHEMA_VERSION: u32 = 4; /// Every migration, in order, each a script of statements. /// @@ -100,6 +100,19 @@ pub(crate) const MIGRATIONS: &[&str] = &[ PRIMARY KEY (backend, entry), FOREIGN KEY (backend, entry) REFERENCES backend_ids(backend, entry) );", + // `names` holds a name the list does not hold, by the key of the name in + // its parts, and the show it is kept as. A key is one name: its season + // and its part are NULL where it holds none, and the index reads NULL as + // -1, which no key holds, so a second row of a key with none is refused. + // A plain UNIQUE counts two NULLs as distinct and would take it. + "CREATE TABLE names ( + show INTEGER NOT NULL REFERENCES shows(id), + letters TEXT NOT NULL, + marks TEXT NOT NULL, + season INTEGER, + part INTEGER + ); + CREATE UNIQUE INDEX names_by_key ON names (letters, marks, IFNULL(season, -1), IFNULL(part, -1));", ]; /// Apply every migration the database has not seen, in order. @@ -331,6 +344,39 @@ mod tests { assert_eq!(broken, 0, "every row points at a show that is there"); } + #[test] + fn migration_four_creates_the_names_the_list_does_not_hold() { + let mut connection = Connection::open_in_memory().expect("memory opens"); + + apply(&mut connection, &MIGRATIONS[..4]).expect("the fourth migration applies"); + + assert_eq!( + columns(&connection, "names"), + ["show", "letters", "marks", "season", "part"] + ); + } + + #[test] + fn migration_four_holds_one_row_for_a_key_with_no_season_and_no_part() { + let mut connection = Connection::open_in_memory().expect("memory opens"); + apply(&mut connection, &MIGRATIONS[..4]).expect("the fourth migration applies"); + let a_row = "INSERT INTO names (show, letters, marks, season, part) \ + VALUES (1, 'someothershow', '', NULL, NULL)"; + connection + .execute_batch("INSERT INTO shows (title) VALUES ('Some Other Show');") + .expect("the show is filed"); + connection + .execute(a_row, []) + .expect("the first row of the key goes in"); + + let again = connection.execute(a_row, []); + + assert!( + refused_by(&again, rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE), + "{again:?}" + ); + } + #[test] fn migration_three_creates_the_two_tables_of_a_list() { let mut connection = Connection::open_in_memory().expect("memory opens"); diff --git a/crates/store/src/names.rs b/crates/store/src/names.rs new file mode 100644 index 0000000..1cd06d7 --- /dev/null +++ b/crates/store/src/names.rs @@ -0,0 +1,240 @@ +//! The names a list does not hold, each kept as a show of its own. +//! +//! A name that spells a title and reaches no entry of the list is a show with +//! no link, and the name is found again by its key. The key is kept in its +//! parts, as [`Key`] answers them, so the key a name was kept under is read +//! back as it was and never built again from text. + +use benshi_core::ShowId; +use benshi_core::recognize::normalize::Key; +use rusqlite::{OptionalExtension, params}; + +use crate::{Error, Store}; + +impl Store { + /// The show a name the list does not hold is kept as. + /// + /// Filed under `title` the first time the key is seen, and the same show + /// every time after. Only the same key finds it: a name that differs in + /// its marks, its season or its part is a show of its own. + /// + /// # Errors + /// + /// [`Error::Busy`] when another connection has the database, and + /// [`Error::Write`] when a statement fails for any other reason. Nothing + /// is filed with either. + pub fn unlisted(&mut self, key: &Key, title: &str) -> Result { + let transaction = self.connection.transaction()?; + let kept: Option = transaction + .query_row( + "SELECT show FROM names \ + WHERE letters = ?1 AND marks = ?2 AND season IS ?3 AND part IS ?4", + params![key.letters(), key.marks(), key.season(), key.part()], + |row| row.get(0), + ) + .optional()?; + let show = if let Some(show) = kept { + show + } else { + let show: i64 = transaction.query_row( + "INSERT INTO shows (title) VALUES (?1) RETURNING id", + [title], + |row| row.get(0), + )?; + transaction.execute( + "INSERT INTO names (show, letters, marks, season, part) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + params![show, key.letters(), key.marks(), key.season(), key.part()], + )?; + show + }; + transaction.commit()?; + + Ok(ShowId(show)) + } + + /// Every name kept as a show of its own, with its show, in the order they + /// were first seen. + /// + /// # Errors + /// + /// [`Error::Name`] for a row whose letters or marks [`Key::from_parts`] + /// refuses, naming its show. [`Error::Busy`] when the database cannot be + /// read because another connection has it, and [`Error::Write`] when it + /// cannot be read for any other reason, a season or a part out of the + /// range of its field included. + pub fn unlisted_names(&self) -> Result, Error> { + let mut statement = self + .connection + .prepare("SELECT show, letters, marks, season, part FROM names ORDER BY rowid")?; + + statement + .query_and_then([], |row| { + let show = ShowId(row.get(0)?); + let letters: String = row.get(1)?; + let marks: String = row.get(2)?; + let key = Key::from_parts(&letters, &marks, row.get(3)?, row.get(4)?) + .ok_or(Error::Name { show })?; + Ok((key, show)) + })? + .collect() + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use benshi_core::ShowId; + use benshi_core::recognize::normalize::Key; + use rusqlite::Connection; + + use crate::{Error, Store}; + + /// A store of its own, in a directory that goes with it. + fn a_store() -> (tempfile::TempDir, Store) { + let home = tempfile::tempdir().expect("a directory of our own"); + let store = Store::open(&home.path().join("benshi.db")).expect("the database opens"); + (home, store) + } + + /// The key a list entry spelled this way is filed under. + fn key(spelled: &str) -> Key { + Key::from_title(spelled).expect("a title with letters in it has a key") + } + + #[test] + fn a_name_is_kept_as_a_show_of_its_own_and_found_again_by_its_key() { + let (home, mut store) = a_store(); + + let first = store + .unlisted(&key("Some Other Show"), "Some Other Show") + .expect("the name is kept"); + let again = store + .unlisted(&key("some other show"), "some other show") + .expect("the name is found"); + drop(store); + let mut opened = + Store::open(&home.path().join("benshi.db")).expect("the database opens again"); + let after = opened + .unlisted(&key("Some Other Show"), "Some Other Show") + .expect("the name is found again"); + + assert_eq!(again, first); + assert_eq!(after, first); + let connection = Connection::open(home.path().join("benshi.db")).expect("the file opens"); + let titles: Vec = connection + .prepare("SELECT title FROM shows ORDER BY id") + .expect("a query") + .query_map([], |row| row.get(0)) + .expect("the query runs") + .collect::>() + .expect("every row reads"); + assert_eq!(titles, ["Some Other Show"]); + let links: u32 = connection + .query_row("SELECT count(*) FROM backend_ids", [], |row| row.get(0)) + .expect("the count reads"); + assert_eq!(links, 0, "a show kept for a name is linked to no entry"); + } + + #[test] + fn names_that_differ_in_marks_season_or_part_are_shows_of_their_own() { + let (_home, mut store) = a_store(); + let mut shows = Vec::new(); + for spelled in [ + "Show Title", + "Show Title:", + "Show Title Season 2", + "Show Title Part 2", + "Show Title Season 2 Part 2", + ] { + shows.push( + store + .unlisted(&key(spelled), spelled) + .expect("the name is kept"), + ); + } + + let distinct: BTreeSet = shows.iter().copied().collect(); + assert_eq!( + distinct.len(), + shows.len(), + "two names shared a show: {shows:?}" + ); + } + + #[test] + fn every_name_kept_reads_back_with_the_key_it_was_kept_under() { + let (_home, mut store) = a_store(); + let one = key("Some Other Show"); + let two = key("K-ON!! Season 3 Part 2"); + let first = store + .unlisted(&one, "Some Other Show") + .expect("the name is kept"); + let second = store + .unlisted(&two, "K-ON!! Season 3 Part 2") + .expect("the name is kept"); + + assert_eq!( + store.unlisted_names().expect("the names read"), + [(one, first), (two, second)] + ); + } + + #[test] + fn a_name_whose_parts_are_no_key_is_refused_with_its_show() { + let (home, mut store) = a_store(); + let show = store + .unlisted(&key("Some Other Show"), "Some Other Show") + .expect("the name is kept"); + Connection::open(home.path().join("benshi.db")) + .expect("the file opens") + .execute("UPDATE names SET letters = ''", []) + .expect("the row is changed by hand"); + + assert!( + matches!(store.unlisted_names(), Err(Error::Name { show: refused }) if refused == show), + "{:?}", + store.unlisted_names() + ); + assert_ne!(show, ShowId(0)); + } + + #[test] + fn a_season_no_key_can_hold_is_a_failure_to_read() { + let (home, mut store) = a_store(); + store + .unlisted(&key("Some Other Show"), "Some Other Show") + .expect("the name is kept"); + Connection::open(home.path().join("benshi.db")) + .expect("the file opens") + .execute("UPDATE names SET season = -1", []) + .expect("the row is changed by hand"); + + assert!( + matches!(store.unlisted_names(), Err(Error::Write(_))), + "{:?}", + store.unlisted_names() + ); + } + + #[test] + fn a_name_that_cannot_be_filed_files_no_show() { + let (home, mut store) = a_store(); + let by_hand = Connection::open(home.path().join("benshi.db")).expect("the file opens"); + by_hand + .execute_batch( + "CREATE TRIGGER no_names BEFORE INSERT ON names \ + BEGIN SELECT RAISE(ABORT, 'refused'); END;", + ) + .expect("the trigger is made"); + + let kept = store.unlisted(&key("Some Other Show"), "Some Other Show"); + + assert!(matches!(kept, Err(Error::Write(_))), "{kept:?}"); + let shows: i64 = by_hand + .query_row("SELECT count(*) FROM shows", [], |row| row.get(0)) + .expect("the shows are counted"); + assert_eq!(shows, 0); + } +}