From fca7ab8f65e4f5100bb180afd9a19088c5fd9686 Mon Sep 17 00:00:00 2001 From: noctuum <25441068+noctuum@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:31:46 +0500 Subject: [PATCH] feat(core): name every show by its row id Migration 2 lets two shows share a title, as AniList entries do. --- crates/cli/src/render.rs | 4 +- crates/core/src/lib.rs | 7 + crates/core/src/recognise/altname.rs | 137 ++++--- crates/core/src/recognise/corpus.rs | 126 ++++--- crates/core/src/recognise/index.rs | 127 ++++--- crates/core/src/recognise/mod.rs | 133 +++++-- crates/core/src/recognise/score.rs | 107 ++++-- crates/core/src/session.rs | 144 +++++--- .../tests/recognition_against_the_fixtures.rs | 33 +- .../tests/the_session_against_a_recording.rs | 9 +- crates/daemon/src/detection.rs | 70 ++-- crates/daemon/src/protocol.rs | 37 +- crates/daemon/src/recognition.rs | 10 +- crates/daemon/tests/the_daemon_runs.rs | 26 +- crates/store/src/bin/record_then_sleep.rs | 7 +- crates/store/src/lib.rs | 86 ++++- crates/store/src/migrate.rs | 131 ++++++- crates/store/src/queue.rs | 66 ++-- crates/store/src/record.rs | 133 +++---- crates/store/src/watching.rs | 346 ++++++++---------- 20 files changed, 1132 insertions(+), 607 deletions(-) diff --git a/crates/cli/src/render.rs b/crates/cli/src/render.rs index 2a370a0..16bc01f 100644 --- a/crates/cli/src/render.rs +++ b/crates/cli/src/render.rs @@ -317,7 +317,7 @@ mod tests { use benshi_core::recognise::altname::Assignment; use benshi_core::recognise::parse::Episode; use benshi_core::{ - AppName, Capabilities, Known, MediaRef, PlayState, PlayerId, PlayerSnapshot, + AppName, Capabilities, Known, MediaRef, PlayState, PlayerId, PlayerSnapshot, ShowId, }; use benshi_daemon::bus::BusEvent; use benshi_daemon::protocol::{DecidedBy, Explanation, Outcome, SourceListing}; @@ -719,6 +719,7 @@ mod tests { spelled: "Kaguya-sama Love is War".to_owned(), season: Some(3), part: None, + show: ShowId(2), title: "Kaguya-sama: Love is War -Ultra Romantic-".to_owned(), }), ), @@ -744,6 +745,7 @@ mod tests { spelled: "SnK".to_owned(), season: None, part: None, + show: ShowId(1), title: "Show Title".to_owned(), }), "stage: altname", diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index d0a932a..7716cd3 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -76,6 +76,13 @@ pub struct Capabilities { #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub struct PlayerId(pub String); +/// A show, as the store files it: the `id` of its row in `shows`. +/// +/// What recognition, a session and a store name a show by. A title is what a +/// person is shown, and two entries of one list can share it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct ShowId(pub i64); + /// The application a source belongs to, as the platform names it. /// /// Distinct from [`PlayerId`], and the distinction is not academic. A live diff --git a/crates/core/src/recognise/altname.rs b/crates/core/src/recognise/altname.rs index e1f6e67..661aa23 100644 --- a/crates/core/src/recognise/altname.rs +++ b/crates/core/src/recognise/altname.rs @@ -10,12 +10,12 @@ //! **An assignment is checked against the corpus while it is being made**, //! which is the one moment the user can still do something about it. Three //! things are refused, each named by [`Unnameable`]: text that spells no -//! letters, a title no entry carries, and text that already means one entry of -//! its own. The last is the dangerous one. An assignment names a *spelling*, -//! not a file, so every file spelling that way follows it: text already -//! reaching `Mushoku Tensei: Jobless Reincarnation Cour 2` and pointed at the -//! special that extends the same name sends every file of the cour to the -//! special, until somebody takes the assignment back. +//! letters, a show the corpus does not hold, and text that already means one +//! entry of its own. The last is the dangerous one. An assignment names a +//! *spelling*, not a file, so every file spelling that way follows it: text +//! already reaching `Mushoku Tensei: Jobless Reincarnation Cour 2` and pointed +//! at the special that extends the same name sends every file of the cour to +//! the special, until somebody takes the assignment back. //! //! **Text reaching several entries is not refused**, because there the corpus //! answers nothing: `Nisekoi` and `Nisekoi:` are two entries a filename cannot @@ -27,6 +27,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use crate::ShowId; use crate::recognise::corpus::Corpus; use crate::recognise::normalise::Key; use crate::recognise::parse::Parsed; @@ -46,7 +47,9 @@ pub struct Assignment { pub season: Option, /// The part the assignment is bound to, where its key holds one. pub part: Option, - /// The entry it names, as the corpus spells it. + /// The entry it names, as the corpus filed it. + pub show: ShowId, + /// The title of that entry, as the corpus spells it. pub title: String, } @@ -72,11 +75,11 @@ pub enum Unnameable { /// The text that was given. spelled: String, }, - /// No entry in the corpus carries that title. - #[error("no entry is titled `{title}`")] + /// The corpus holds no such show. + #[error("no entry is filed as show {}", .show.0)] NotOnTheList { - /// The title that was named. - title: String, + /// The show that was named. + show: ShowId, }, /// The text already means one entry, and it is not the one being named. /// @@ -87,7 +90,7 @@ pub enum Unnameable { AlreadyMeans { /// The text that was given. spelled: String, - /// The entry that text reaches today. + /// The title of the entry that text reaches today. entry: String, }, } @@ -114,13 +117,13 @@ impl Altnames { pub fn name( &mut self, spelled: &str, - title: &str, + show: ShowId, corpus: &Corpus, ) -> Result { let key = Key::from_title(spelled).ok_or_else(|| Unnameable::Unspellable { spelled: spelled.to_owned(), })?; - self.record(key, spelled.to_owned(), title, corpus) + self.record(key, spelled.to_owned(), show, corpus) } /// Name the entry a file means, from what that file spelled. @@ -138,14 +141,14 @@ impl Altnames { pub fn name_as_parsed( &mut self, parsed: &Parsed, - title: &str, + show: ShowId, corpus: &Corpus, ) -> Result { let spelled = parsed.title.clone().unwrap_or_default(); let key = Key::from_parsed(parsed).ok_or_else(|| Unnameable::Unspellable { spelled: spelled.clone(), })?; - self.record(key, spelled, title, corpus) + self.record(key, spelled, show, corpus) } /// File one assignment, once the corpus has been asked about it. @@ -153,26 +156,18 @@ impl Altnames { &mut self, key: Key, spelled: String, - title: &str, + show: ShowId, corpus: &Corpus, ) -> Result { - // An entry answers to its own title, because the corpus files every - // entry under the key of the title as well as under its other - // spellings. A title its own key does not reach is a title no entry - // carries. - let carried = Key::from_title(title) - .is_some_and(|of_title| corpus.candidates(&of_title).contains(&title)); - if !carried { - return Err(Unnameable::NotOnTheList { - title: title.to_owned(), - }); - } + let title = corpus + .title_of(show) + .ok_or(Unnameable::NotOnTheList { show })?; if let [already] = corpus.candidates(&key).as_slice() - && *already != title + && *already != show { return Err(Unnameable::AlreadyMeans { spelled, - entry: (*already).to_owned(), + entry: corpus.title_of(*already).unwrap_or_default().to_owned(), }); } @@ -180,6 +175,7 @@ impl Altnames { spelled, season: key.season(), part: key.part(), + show, title: title.to_owned(), }; let replaced = self.named.insert(key, recorded.clone()); @@ -208,6 +204,7 @@ impl Altnames { #[cfg(test)] mod tests { use super::{Altnames, Assignment, Unnameable}; + use crate::ShowId; use crate::path::RawPath; use crate::recognise::corpus::Corpus; use crate::recognise::normalise::Key; @@ -226,7 +223,7 @@ mod tests { let mut altnames = Altnames::new(); let named = altnames - .name("SnK", "Shingeki no Kyojin", &corpus) + .name("SnK", ShowId(1), &corpus) .expect("a text nothing else reaches is free to name"); assert_eq!( @@ -235,6 +232,7 @@ mod tests { spelled: "SnK".to_owned(), season: None, part: None, + show: ShowId(1), title: "Shingeki no Kyojin".to_owned(), } ); @@ -259,7 +257,7 @@ mod tests { let mut altnames = Altnames::new(); let named = altnames - .name_as_parsed(&third, "Kaguya-sama: Love is War -Ultra Romantic-", &corpus) + .name_as_parsed(&third, ShowId(2), &corpus) .expect("the third season reaches no entry, so naming it takes nothing"); assert_eq!( @@ -268,6 +266,7 @@ mod tests { spelled: "Kaguya-sama Love is War".to_owned(), season: Some(3), part: None, + show: ShowId(2), title: "Kaguya-sama: Love is War -Ultra Romantic-".to_owned(), } ); @@ -282,7 +281,7 @@ mod tests { let mut altnames = Altnames::new(); let named = altnames - .name_as_parsed(&second, "Show Title Part 2", &corpus) + .name_as_parsed(&second, ShowId(2), &corpus) .expect("the file's key reaches the part it names, so naming it takes nothing"); assert_eq!(named.recorded.spelled, "Show Title"); @@ -308,7 +307,7 @@ mod tests { let refused = altnames.name( "Mushoku Tensei: Jobless Reincarnation Cour 2", - "Mushoku Tensei: Jobless Reincarnation Cour 2 - Eris the Goblin Slayer", + ShowId(3), &corpus, ); @@ -331,7 +330,7 @@ mod tests { let corpus: Corpus = ["Nisekoi", "Nisekoi:"].into_iter().collect(); let mut altnames = Altnames::new(); - assert!(altnames.name("Nisekoi", "Nisekoi:", &corpus).is_ok()); + assert!(altnames.name("Nisekoi", ShowId(2), &corpus).is_ok()); assert_eq!( altnames .assigned(&Key::from_title("Nisekoi").unwrap()) @@ -341,21 +340,58 @@ mod tests { } #[test] - fn a_title_no_entry_carries_cannot_be_named() { - // A typo, or an English title the user's list does not carry. Caught - // while they are still at the terminal, rather than recorded as a - // title that outranks every heuristic and resolves to nothing. - let corpus: Corpus = ["Nisekoi", "Nisekoi:"].into_iter().collect(); + fn an_assignment_names_one_show_of_a_title_two_share() { + let mut corpus = Corpus::new(); + corpus.file(ShowId(7), "Show Title", Vec::<&str>::new()); + corpus.file(ShowId(9), "Show Title", Vec::<&str>::new()); let mut altnames = Altnames::new(); + let named = altnames + .name("SnK", ShowId(9), &corpus) + .expect("a text nothing else reaches is free to name"); + assert_eq!( - altnames.name("Nisekoi", "False Love", &corpus), - Err(Unnameable::NotOnTheList { - title: "False Love".to_owned(), + named.recorded, + Assignment { + spelled: "SnK".to_owned(), + season: None, + part: None, + show: ShowId(9), + title: "Show Title".to_owned(), + } + ); + } + + #[test] + fn text_that_means_one_show_is_refused_for_another_of_the_same_title() { + // The title is the same and the show is not, so the assignment would + // take every file of the one and give it to the other. + let mut corpus = Corpus::new(); + corpus.file(ShowId(7), "Show Title", ["Shoo Taitoru"]); + corpus.file(ShowId(9), "Show Title", Vec::<&str>::new()); + + assert_eq!( + Altnames::new().name("Shoo Taitoru", ShowId(9), &corpus), + Err(Unnameable::AlreadyMeans { + spelled: "Shoo Taitoru".to_owned(), + entry: "Show Title".to_owned(), }) ); } + #[test] + fn a_show_the_corpus_does_not_hold_cannot_be_named() { + // Refused when the assignment is made, rather than recorded as an + // entry that outranks every heuristic and resolves to nothing. + let corpus: Corpus = ["Nisekoi", "Nisekoi:"].into_iter().collect(); + let mut altnames = Altnames::new(); + + assert_eq!( + altnames.name("Nisekoi", ShowId(3), &corpus), + Err(Unnameable::NotOnTheList { show: ShowId(3) }) + ); + } + #[test] fn text_with_no_letters_cannot_be_named() { // `Season 2` and `P4` are a season and a part and nothing else, so @@ -365,13 +401,13 @@ mod tests { let mut altnames = Altnames::new(); assert_eq!( - altnames.name("Season 2", "Show Title", &corpus), + altnames.name("Season 2", ShowId(1), &corpus), Err(Unnameable::Unspellable { spelled: "Season 2".to_owned(), }) ); assert!(matches!( - altnames.name("P4", "Show Title", &corpus), + altnames.name("P4", ShowId(1), &corpus), Err(Unnameable::Unspellable { .. }) )); } @@ -382,10 +418,10 @@ mod tests { let mut altnames = Altnames::new(); altnames - .name("SnK", "Show Title", &corpus) + .name("SnK", ShowId(1), &corpus) .expect("free to name"); let again = altnames - .name("SnK", "Another Show", &corpus) + .name("SnK", ShowId(2), &corpus) .expect("the user may change their mind"); assert_eq!( @@ -394,6 +430,7 @@ mod tests { spelled: "SnK".to_owned(), season: None, part: None, + show: ShowId(1), title: "Show Title".to_owned(), }) ); @@ -407,7 +444,7 @@ mod tests { let corpus: Corpus = ["Show Title"].into_iter().collect(); let mut altnames = Altnames::new(); altnames - .name("SnK", "Show Title", &corpus) + .name("SnK", ShowId(1), &corpus) .expect("free to name"); let forgotten = altnames.forget("SnK"); @@ -439,7 +476,7 @@ mod tests { let mut altnames = Altnames::new(); altnames - .name_as_parsed(&third, "Kaguya-sama: Love is War -Ultra Romantic-", &corpus) + .name_as_parsed(&third, ShowId(2), &corpus) .expect("the third season reaches no entry, so naming it takes nothing"); assert_eq!( @@ -465,7 +502,7 @@ mod tests { let mut altnames = Altnames::new(); altnames - .name("SHOW TITLE!", "Show Title", &corpus) + .name("SHOW TITLE!", ShowId(1), &corpus) .expect("free to name"); assert_eq!( diff --git a/crates/core/src/recognise/corpus.rs b/crates/core/src/recognise/corpus.rs index 1e84c9a..893ab1d 100644 --- a/crates/core/src/recognise/corpus.rs +++ b/crates/core/src/recognise/corpus.rs @@ -17,16 +17,19 @@ //! **An entry filed under several spellings is one candidate.** Two spellings //! of one entry reach one key when the title is repeated among them, or when //! they differ only in the marks a lookup widens over. One entry reached twice -//! is not two entries. +//! is not two entries. **Two entries that share a title are two candidates**, +//! because an entry is the show it is filed as, and its title is what a person +//! is shown. use std::collections::HashMap; +use crate::ShowId; use crate::recognise::normalise::Key; -/// The titles recognition matches against. +/// The shows recognition matches against. /// /// Every spelling of an entry is filed under its own key, and every key answers -/// with the entry's title rather than with the spelling that was reached. +/// with the entry's show rather than with the spelling that was reached. #[derive(Debug, Clone, Default)] pub struct Corpus { /// Filed under the key with its marks taken off, so that a name which lost @@ -34,6 +37,8 @@ pub struct Corpus { /// holds is then told apart by the marks alone: everything else about the /// key is what put it there. filed: HashMap>, + /// The title of every show filed, which is what an answer shows a person. + titles: HashMap, } /// One spelling of one entry. @@ -46,8 +51,8 @@ struct Filed { /// erases the breaks between words, and a stage that compares words needs /// them. spelled: String, - /// The entry's title, which is what an answer names. - title: String, + /// The show the entry is, which is what an answer names. + show: ShowId, } impl Corpus { @@ -57,19 +62,26 @@ impl Corpus { Self::default() } - /// File an entry under its title and under every other spelling it carries. + /// File an entry as a show, under its title and under every other spelling + /// it carries. /// /// A spelling with no letters in it is filed nowhere: nothing could look it /// up, because a name spelling no letters has no key either. - pub fn file(&mut self, title: &str, also_spelled: impl IntoIterator>) { - self.put(title, title); + pub fn file( + &mut self, + show: ShowId, + title: &str, + also_spelled: impl IntoIterator>, + ) { + self.titles.insert(show, title.to_owned()); + self.put(title, show); for spelling in also_spelled { - self.put(spelling.as_ref(), title); + self.put(spelling.as_ref(), show); } } /// File one spelling of one entry. - fn put(&mut self, spelling: &str, title: &str) { + fn put(&mut self, spelling: &str, show: ShowId) { let Some(key) = Key::from_title(spelling) else { return; }; @@ -79,20 +91,26 @@ impl Corpus { .push(Filed { marks: key.marks().to_owned(), spelled: spelling.to_owned(), - title: title.to_owned(), + show, }); } + /// The title a show was filed with, and nothing where it was not filed. + #[must_use] + pub fn title_of(&self, show: ShowId) -> Option<&str> { + self.titles.get(&show).map(String::as_str) + } + /// Every spelling one entry is filed under. /// /// For the stage that compares an entry against a name word by word: an /// entry filed under romaji, English and native spellings meets the name on /// the closest of those that stage compares, and the title it answers with /// is not necessarily the one that matched. - pub(super) fn spellings_of(&self, title: &str) -> Vec<&str> { + pub(super) fn spellings_of(&self, show: ShowId) -> Vec<&str> { let mut found: Vec<&str> = self .spellings() - .filter(|(_, filed)| *filed == title) + .filter(|&(_, filed)| filed == show) .map(|(spelled, _)| spelled) .collect(); // Sorted for the same reason the index sorts: these come out of a hash @@ -106,11 +124,11 @@ impl Corpus { /// /// For the stage that compares words rather than keys, so that one corpus /// answers both and the two cannot be filed differently. - pub(super) fn spellings(&self) -> impl Iterator { + pub(super) fn spellings(&self) -> impl Iterator { self.filed .values() .flatten() - .map(|filed| (filed.spelled.as_str(), filed.title.as_str())) + .map(|filed| (filed.spelled.as_str(), filed.show)) } /// The entries a key reaches, in the order they were filed. @@ -119,15 +137,15 @@ impl Corpus { /// key either hits or misses, and the stages below are what answer a name /// that misses. #[must_use] - pub fn candidates(&self, key: &Key) -> Vec<&str> { - let mut found: Vec<&str> = Vec::new(); + 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.title.as_str()) { - found.push(&filed.title); + if reached && !found.contains(&filed.show) { + found.push(filed.show); } } found @@ -135,11 +153,12 @@ impl Corpus { } impl> FromIterator for Corpus { - /// A corpus of entries spelled one way each. + /// A corpus of entries spelled one way each, filed as shows numbered from + /// one in the order they are given. fn from_iter>(titles: I) -> Self { let mut corpus = Self::new(); - for title in titles { - corpus.file(title.as_ref(), Vec::<&str>::new()); + for (show, title) in (1..).zip(titles) { + corpus.file(ShowId(show), title.as_ref(), Vec::<&str>::new()); } corpus } @@ -148,6 +167,7 @@ impl> FromIterator for Corpus { #[cfg(test)] mod tests { use super::Corpus; + use crate::ShowId; use crate::recognise::normalise::Key; /// The key a list entry spelled this way is filed under. @@ -155,11 +175,21 @@ mod tests { Key::from_title(title).expect("a title with letters in it has a key") } + /// The titles of the entries this spelling's key reaches, in the order + /// the corpus answers them. + fn reached<'c>(corpus: &'c Corpus, spelled: &str) -> Vec<&'c str> { + corpus + .candidates(&listed(spelled)) + .into_iter() + .map(|show| corpus.title_of(show).expect("a candidate is a show filed")) + .collect() + } + #[test] fn an_entry_answers_the_key_of_its_own_spelling() { let corpus: Corpus = ["Show Title", "Another Show"].into_iter().collect(); - assert_eq!(corpus.candidates(&listed("Show Title")), ["Show Title"]); + assert_eq!(reached(&corpus, "Show Title"), ["Show Title"]); } #[test] @@ -179,10 +209,30 @@ mod tests { // the title files that entry twice. Answered as they are filed, the two // would be an ambiguity between one entry and itself. let mut corpus = Corpus::new(); - corpus.file("Show Title", ["Show Title", "Shoo Taitoru"]); + corpus.file(ShowId(1), "Show Title", ["Show Title", "Shoo Taitoru"]); - assert_eq!(corpus.candidates(&listed("Show Title")), ["Show Title"]); - assert_eq!(corpus.candidates(&listed("Shoo Taitoru")), ["Show Title"]); + assert_eq!(corpus.candidates(&listed("Show Title")), [ShowId(1)]); + assert_eq!(corpus.candidates(&listed("Shoo Taitoru")), [ShowId(1)]); + } + + #[test] + fn two_entries_of_one_title_are_two_candidates() { + // A title does not name an entry: AniList holds entries that share a + // romaji title. Told apart by the show each is, the two stay two. + let mut corpus = Corpus::new(); + corpus.file(ShowId(7), "Show Title", ["Shoo Taitoru"]); + corpus.file(ShowId(9), "Show Title", Vec::<&str>::new()); + + assert_eq!( + corpus.candidates(&listed("Show Title")), + [ShowId(7), ShowId(9)] + ); + assert_eq!(corpus.title_of(ShowId(9)), Some("Show Title")); + assert_eq!( + corpus.spellings_of(ShowId(7)), + ["Shoo Taitoru", "Show Title"] + ); + assert_eq!(corpus.spellings_of(ShowId(9)), ["Show Title"]); } #[test] @@ -192,10 +242,7 @@ mod tests { // to the text. let corpus: Corpus = ["Nisekoi", "Nisekoi:"].into_iter().collect(); - assert_eq!( - corpus.candidates(&listed("Nisekoi")), - ["Nisekoi", "Nisekoi:"] - ); + assert_eq!(reached(&corpus, "Nisekoi"), ["Nisekoi", "Nisekoi:"]); } #[test] @@ -204,7 +251,7 @@ mod tests { // name that did keep its colon names one season exactly. let corpus: Corpus = ["Nisekoi", "Nisekoi:"].into_iter().collect(); - assert_eq!(corpus.candidates(&listed("Nisekoi:")), ["Nisekoi:"]); + assert_eq!(reached(&corpus, "Nisekoi:"), ["Nisekoi:"]); } #[test] @@ -233,15 +280,12 @@ mod tests { .collect(); assert_eq!( - corpus.candidates(&listed("Show Title Season 2")), + reached(&corpus, "Show Title Season 2"), ["Show Title Season 2"] ); + assert_eq!(reached(&corpus, "Show Title Part 2"), ["Show Title Part 2"]); assert_eq!( - corpus.candidates(&listed("Show Title Part 2")), - ["Show Title Part 2"] - ); - assert_eq!( - corpus.candidates(&listed("Show Title")), + reached(&corpus, "Show Title"), ["Show Title", "Show Title!"] ); } @@ -252,10 +296,10 @@ mod tests { // arrives in no particular order. A stage picking between spellings // that score alike would pick differently between two runs. let mut corpus = Corpus::new(); - corpus.file("Show Title", ["Zeta Spelling", "Alpha Spelling"]); + corpus.file(ShowId(1), "Show Title", ["Zeta Spelling", "Alpha Spelling"]); assert_eq!( - corpus.spellings_of("Show Title"), + corpus.spellings_of(ShowId(1)), ["Alpha Spelling", "Show Title", "Zeta Spelling"] ); } @@ -266,9 +310,9 @@ mod tests { // and nothing can look it up. The entry's other spellings are filed as // usual. let mut corpus = Corpus::new(); - corpus.file("Show Title", ["Season 2"]); + corpus.file(ShowId(1), "Show Title", ["Season 2"]); - assert_eq!(corpus.candidates(&listed("Show Title")), ["Show Title"]); + assert_eq!(reached(&corpus, "Show Title"), ["Show Title"]); assert!(Key::from_title("Season 2").is_none()); } } diff --git a/crates/core/src/recognise/index.rs b/crates/core/src/recognise/index.rs index 83822ad..9776864 100644 --- a/crates/core/src/recognise/index.rs +++ b/crates/core/src/recognise/index.rs @@ -31,8 +31,9 @@ //! an episode number or by dropping the text it could not place. Nothing this //! stage does can find a title in `;`. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use crate::ShowId; use crate::recognise::corpus::Corpus; use crate::recognise::normalise::words_in; @@ -61,11 +62,10 @@ pub const SHARED: usize = 2; /// The entries each word reaches. #[derive(Debug, Clone, Default)] pub struct Index { - /// Every entry, once, in whatever order the corpus hands its spellings - /// over. - entries: Vec, - /// Which entries a word reaches, by their place in `entries`. - reached: HashMap>, + /// How many entries the corpus files under a spelling with a key. + entries: usize, + /// Which entries a word reaches. + reached: HashMap>, } impl Index { @@ -76,23 +76,21 @@ impl Index { #[must_use] pub fn of(corpus: &Corpus) -> Self { let mut index = Self::default(); - let mut at = HashMap::new(); - for (spelled, title) in corpus.spellings() { - let entry = *at.entry(title).or_insert_with(|| { - index.entries.push(title.to_owned()); - index.entries.len() - 1 - }); + let mut shows = HashSet::new(); + for (spelled, show) in corpus.spellings() { + shows.insert(show); for word in words_in(spelled) { let reaches = index.reached.entry(word).or_default(); - if !reaches.contains(&entry) { - reaches.push(entry); + if !reaches.contains(&show) { + reaches.push(show); } } } + index.entries = shows.len(); index } - /// The entries a name reaches, by title, in one order. + /// The entries a name reaches, in the order of their shows. /// /// Sorted rather than left as they were found: a corpus files its entries /// into buckets by key, so the order they come back in is the order of a @@ -102,20 +100,20 @@ impl Index { /// refusal: this stage decides nothing, and a name it cannot narrow is a /// name the scorer is not asked about. #[must_use] - pub fn candidates(&self, spelled: &str) -> Vec<&str> { + pub fn candidates(&self, spelled: &str) -> Vec { let asked = self.narrowing(&words_in(spelled)); - let mut shared: HashMap = HashMap::new(); + let mut shared: HashMap = HashMap::new(); for reaches in &asked { - for &entry in *reaches { - *shared.entry(entry).or_default() += 1; + for &show in *reaches { + *shared.entry(show).or_default() += 1; } } let wanted = SHARED.min(asked.len()); - let mut found: Vec<&str> = shared + let mut found: Vec = shared .into_iter() .filter(|&(_, carried)| carried >= wanted) - .map(|(entry, _)| self.entries[entry].as_str()) + .map(|(show, _)| show) .collect(); found.sort_unstable(); found @@ -126,13 +124,13 @@ impl Index { /// Where every word the corpus knows is crowded, the least crowded of them /// are used anyway: they narrow badly, and dropping them would lose the /// name altogether. - fn narrowing(&self, words: &[String]) -> Vec<&[usize]> { - let known: Vec<&[usize]> = words + fn narrowing(&self, words: &[String]) -> Vec<&[ShowId]> { + let known: Vec<&[ShowId]> = words .iter() .filter_map(|word| self.reached.get(word).map(Vec::as_slice)) .collect(); - let ceiling = (self.entries.len() / CROWDED).max(1); - let uncrowded: Vec<&[usize]> = known + let ceiling = (self.entries / CROWDED).max(1); + let uncrowded: Vec<&[ShowId]> = known .iter() .copied() .filter(|reaches| reaches.len() <= ceiling) @@ -152,11 +150,20 @@ impl Index { #[cfg(test)] mod tests { use super::Index; + use crate::ShowId; use crate::path::RawPath; use crate::recognise::by_key; use crate::recognise::corpus::Corpus; use crate::recognise::parse::parse; + /// The titles of these shows, in the order they are given. + fn titled(corpus: &Corpus, shows: Vec) -> Vec<&str> { + shows + .into_iter() + .map(|show| corpus.title_of(show).expect("a candidate is a show filed")) + .collect() + } + /// A list big enough for a share of it to mean anything: four hundred /// entries, every one of them spelling `The` and `Show`. fn a_long_list() -> Corpus { @@ -171,7 +178,7 @@ mod tests { let corpus = a_long_list(); let index = Index::of(&corpus); - let found = index.candidates("The Show 137"); + let found = titled(&corpus, index.candidates("The Show 137")); assert!( found.contains(&"The Show 137"), @@ -188,7 +195,10 @@ mod tests { let corpus = a_long_list(); let index = Index::of(&corpus); - assert_eq!(index.candidates("The Show 137"), ["The Show 137"]); + assert_eq!( + titled(&corpus, index.candidates("The Show 137")), + ["The Show 137"] + ); } #[test] @@ -200,12 +210,30 @@ mod tests { let corpus = a_long_list(); let index = Index::of(&corpus); - let found = index.candidates("The Show"); + let found = titled(&corpus, index.candidates("The Show")); assert!(found.len() > 100, "nothing here narrows: {}", found.len()); assert!(found.contains(&"The Show 137")); } + #[test] + fn a_word_is_crowded_by_the_entries_that_carry_it_and_not_by_their_spellings() { + // Two hundred entries spelled two ways each, and three more. Two of + // the 203 entries carry `alpha`, one more than [`CROWDED`] allows, so + // it narrows nothing and `delta` answers alone. Counted by the 403 + // spellings, two would be allowed, and a name of both words would + // reach no entry, since none carries both. + let mut corpus = Corpus::new(); + for n in 0..200 { + corpus.file(ShowId(n), &format!("Entry {n}"), [format!("Eintrag {n}")]); + } + corpus.file(ShowId(200), "Alpha", Vec::<&str>::new()); + corpus.file(ShowId(201), "Alpha Beta", Vec::<&str>::new()); + corpus.file(ShowId(202), "Delta", Vec::<&str>::new()); + + assert_eq!(Index::of(&corpus).candidates("Alpha Delta"), [ShowId(202)]); + } + #[test] fn an_entry_sharing_one_word_of_several_is_not_a_candidate() { // Two shared words, or the name is not about that entry. `Fate` alone @@ -216,7 +244,10 @@ mod tests { .collect(); let index = Index::of(&corpus); - assert_eq!(index.candidates("Fate Zero"), ["Fate Zero"]); + assert_eq!( + titled(&corpus, index.candidates("Fate Zero")), + ["Fate Zero"] + ); } #[test] @@ -227,7 +258,7 @@ mod tests { let corpus: Corpus = ["Monster", "Another", "Bleach"].into_iter().collect(); let index = Index::of(&corpus); - assert_eq!(index.candidates("Monster"), ["Monster"]); + assert_eq!(titled(&corpus, index.candidates("Monster")), ["Monster"]); } #[test] @@ -249,9 +280,11 @@ mod tests { assert_eq!(by_key(&file, &corpus), None, "the exact key misses it"); assert!( - index - .candidates(file.title.as_deref().expect("the name spells a title")) - .contains(&"Shingeki no Kyojin") + titled( + &corpus, + index.candidates(file.title.as_deref().expect("the name spells a title")) + ) + .contains(&"Shingeki no Kyojin") ); } @@ -262,17 +295,14 @@ mod tests { // release carrying the romaji title and the English one does, and a // second entry would answer it with the title twice. let mut corpus = Corpus::new(); - corpus.file("Shingeki no Kyojin", ["Attack on Titan"]); + corpus.file(ShowId(1), "Shingeki no Kyojin", ["Attack on Titan"]); let index = Index::of(&corpus); - assert_eq!(index.candidates("Attack on Titan"), ["Shingeki no Kyojin"]); - assert_eq!( - index.candidates("Shingeki no Kyojin"), - ["Shingeki no Kyojin"] - ); + assert_eq!(index.candidates("Attack on Titan"), [ShowId(1)]); + assert_eq!(index.candidates("Shingeki no Kyojin"), [ShowId(1)]); assert_eq!( index.candidates("Shingeki no Kyojin (Attack on Titan)"), - ["Shingeki no Kyojin"] + [ShowId(1)] ); } @@ -283,7 +313,7 @@ mod tests { // is, and the entry looks to share more of the name's words than it // spells. let mut corpus: Corpus = ["Beta Gamma"].into_iter().collect(); - corpus.file("Alpha Show", ["Show Alpha"]); + corpus.file(ShowId(2), "Alpha Show", ["Show Alpha"]); let index = Index::of(&corpus); assert!(index.candidates("Show Beta").is_empty()); @@ -293,7 +323,8 @@ mod tests { fn candidates_come_back_in_one_order_however_they_were_filed() { // A corpus files into buckets by key, so what comes back from it is in // the order of a hash map. Left alone, one name would answer in a - // different order between two runs of the same program. + // different order between two runs of the same program. The order is + // that of the shows, numbered here in the order the titles are given. // // Six entries rather than three, because three of them come back in // sorted order by chance about one run in six, and a test that passes @@ -315,12 +346,12 @@ mod tests { assert_eq!( index.candidates("Alpha Beta Gamma"), [ - "Alpha Beta", - "Alpha Gamma", - "Beta Alpha", - "Beta Gamma", - "Gamma Alpha", - "Gamma Beta" + ShowId(1), + ShowId(2), + ShowId(3), + ShowId(4), + ShowId(5), + ShowId(6) ] ); } diff --git a/crates/core/src/recognise/mod.rs b/crates/core/src/recognise/mod.rs index 6b2eae7..6525e93 100644 --- a/crates/core/src/recognise/mod.rs +++ b/crates/core/src/recognise/mod.rs @@ -21,6 +21,7 @@ pub mod normalise; pub mod parse; pub mod score; +use crate::ShowId; use crate::recognise::altname::{Altnames, Assignment}; use crate::recognise::corpus::Corpus; use crate::recognise::index::Index; @@ -66,6 +67,7 @@ pub fn decide(parsed: &Parsed, altnames: &Altnames, corpus: &Corpus, index: &Ind pub fn by_altname(parsed: &Parsed, altnames: &Altnames) -> Option { let assignment = altnames.assigned(&Key::from_parsed(parsed)?)?; Some(Match { + show: assignment.show, title: assignment.title.clone(), episode: parsed.episode, stage: Stage::Altname(assignment.clone()), @@ -82,23 +84,32 @@ pub fn by_altname(parsed: &Parsed, altnames: &Altnames) -> Option { /// Several entries under one key is an answer, and the answer is that the name /// does not say. Two list entries share a key honestly - a remake spelled like /// its original, a season told from the one before it by a mark a filename -/// cannot carry - and picking whichever was filed first writes progress against -/// a title nobody named. +/// cannot carry, a series and a special that carry one romaji title - and +/// picking whichever was filed first writes progress against an entry nobody +/// named. #[must_use] pub fn by_key(parsed: &Parsed, corpus: &Corpus) -> Option { let spelled = parsed.title.as_deref()?; - let candidates = corpus.candidates(&Key::from_parsed(parsed)?); + let candidates: Vec = corpus + .candidates(&Key::from_parsed(parsed)?) + .into_iter() + .map(|show| Candidate { + show, + title: corpus.title_of(show).unwrap_or_default().to_owned(), + }) + .collect(); match candidates.as_slice() { [] => None, - [title] => Some(Recognition::Recognised(Match { - title: (*title).to_owned(), + [one] => Some(Recognition::Recognised(Match { + show: one.show, + title: one.title.clone(), episode: parsed.episode, stage: Stage::Key, })), - several => Some(Recognition::Ambiguous(Ambiguity { + _ => Some(Recognition::Ambiguous(Ambiguity { parsed: spelled.to_owned(), - candidates: several.iter().map(|title| (*title).to_owned()).collect(), + candidates, })), } } @@ -164,11 +175,13 @@ pub enum Stage { /// A filename that was recognised. /// -/// The title is the corpus entry that matched, rather than the text the filename +/// 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. #[derive(Debug, Clone, PartialEq)] pub struct Match { + /// The show the corpus filed the entry as. + pub show: ShowId, /// The title as the corpus spells it. pub title: String, /// The episode in the release's numbering, as it was read from the name. @@ -220,29 +233,37 @@ pub struct Refusal { /// /// Apart from a [`Refusal`] because the two send a reader to different places. /// A refusal says the corpus was searched and nothing in it was the title; an -/// ambiguity says the corpus holds the title more than once and the name does -/// not say which. What settles it is the entries' own data - a format, an +/// ambiguity says the name reaches more than one entry and does not say +/// which. What settles it is the entries' own data - a format, an /// episode count, a date - which is the list's to answer and not the text's. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Ambiguity { /// The title as the filename spelled it. pub parsed: String, - /// The entries it reaches, as the corpus spells them, in the order the - /// corpus filed them. + /// The entries it reaches, in the order the corpus filed them. /// /// 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 /// [`Refusal::best`] is - the field is public and nothing here refuses a /// shorter list. - pub candidates: Vec, + pub candidates: Vec, +} + +/// One entry of several that a name reaches. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Candidate { + /// The show the corpus filed the entry as. + pub show: ShowId, + /// The title as the corpus spells it. + pub title: String, } /// What recognition answers with. #[derive(Debug, Clone, PartialEq)] pub enum Recognition { - /// A title, and the stage that decided it. + /// A show, and the stage that decided it. Recognised(Match), - /// Several titles, and nothing in the name to choose between them. + /// Several entries, and nothing in the name to choose between them. /// /// A variant of its own rather than a refusal carrying a list, because a /// consumer that treats it as a refusal loses the one thing that makes it @@ -260,7 +281,8 @@ pub enum Recognition { #[cfg(test)] mod tests { - use super::{Ambiguity, Match, Recognition, Refusal, Score, Stage, by_key, decide}; + use super::{Ambiguity, Candidate, Match, Recognition, Refusal, Score, Stage, by_key, decide}; + use crate::ShowId; use crate::path::RawPath; use crate::recognise::altname::{Altnames, Assignment}; use crate::recognise::corpus::Corpus; @@ -283,15 +305,24 @@ mod tests { by_key(&named(name), corpus) } - /// A recognition of this title by this stage, for comparing against. - fn recognised(title: &str, episode: Episode) -> Recognition { + /// A recognition of this show by this stage, for comparing against. + fn recognised(show: i64, title: &str, episode: Episode) -> Recognition { Recognition::Recognised(Match { + show: ShowId(show), title: title.to_owned(), episode, stage: Stage::Key, }) } + /// One of several entries a name reaches. + fn candidate(show: i64, title: &str) -> Candidate { + Candidate { + show: ShowId(show), + title: title.to_owned(), + } + } + #[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 @@ -300,6 +331,7 @@ mod tests { // certainty, and a stage on its own does not say how close the call // was. The two travel together or neither is worth printing. let scored = Recognition::Recognised(Match { + show: ShowId(1), title: "Show".to_owned(), episode: Episode::Only(3), stage: Stage::Scored(a_score(0.91)), @@ -322,6 +354,7 @@ mod tests { spelled: "SnK".to_owned(), season: None, part: None, + show: ShowId(1), title: "Show".to_owned(), }); let exact = Stage::Key; @@ -342,6 +375,7 @@ mod tests { // own answer is carried rather than a number that may be missing: a // film can be marked watched and a file of twelve episodes cannot. let film = Match { + show: ShowId(1), title: "A Film".to_owned(), episode: Episode::Absent, stage: Stage::Key, @@ -416,7 +450,7 @@ mod tests { assert_eq!( found("[Group] Show Title - 03 [1080p].mkv", &corpus), - Some(recognised("Show Title", Episode::Only(3))) + Some(recognised(1, "Show Title", Episode::Only(3))) ); } @@ -435,13 +469,48 @@ mod tests { Some(Recognition::Ambiguous(Ambiguity { parsed: "Fruits Basket".to_owned(), candidates: vec![ - "Fruits Basket".to_owned(), - "Fruits Basket (2019)".to_owned() + candidate(1, "Fruits Basket"), + candidate(2, "Fruits Basket (2019)") ], })) ); } + #[test] + fn two_shows_of_one_title_are_reported_rather_than_merged() { + // Two entries that share a title are two shows, and a name that + // reaches both names neither. Merged by their title, they would be + // one answer, and every file of one would be written to the other. + let mut corpus = Corpus::new(); + corpus.file(ShowId(7), "Show Title", Vec::<&str>::new()); + corpus.file(ShowId(9), "Show Title", Vec::<&str>::new()); + + assert_eq!( + found("Show Title - 03.mkv", &corpus), + Some(Recognition::Ambiguous(Ambiguity { + parsed: "Show Title".to_owned(), + candidates: vec![candidate(7, "Show Title"), candidate(9, "Show Title")], + })) + ); + } + + #[test] + fn a_match_names_the_show_it_found() { + let mut corpus = Corpus::new(); + corpus.file(ShowId(7), "Show Title", Vec::<&str>::new()); + corpus.file(ShowId(9), "Another Show", Vec::<&str>::new()); + + assert_eq!( + found("Another Show - 03.mkv", &corpus), + Some(Recognition::Recognised(Match { + show: ShowId(9), + title: "Another Show".to_owned(), + episode: Episode::Only(3), + stage: Stage::Key, + })) + ); + } + #[test] fn a_name_no_entry_is_filed_under_leaves_the_answer_to_the_stages_below() { // Not a refusal. An exact key hits or misses, and a name the parser @@ -462,13 +531,13 @@ mod tests { assert_eq!( found("Nisekoi: - 03.mkv", &corpus), - Some(recognised("Nisekoi:", Episode::Only(3))) + Some(recognised(2, "Nisekoi:", Episode::Only(3))) ); assert_eq!( found("Nisekoi - 03.mkv", &corpus), Some(Recognition::Ambiguous(Ambiguity { parsed: "Nisekoi".to_owned(), - candidates: vec!["Nisekoi".to_owned(), "Nisekoi:".to_owned()], + candidates: vec![candidate(1, "Nisekoi"), candidate(2, "Nisekoi:")], })) ); } @@ -488,7 +557,7 @@ mod tests { assert_eq!( found("Gokushufudou S2 - 03.mkv", &corpus), - Some(recognised("Gokushufudou Season 2", Episode::Only(3))) + Some(recognised(3, "Gokushufudou Season 2", Episode::Only(3))) ); } @@ -502,7 +571,7 @@ mod tests { let corpus: Corpus = ["Nisekoi", "Nisekoi:"].into_iter().collect(); let mut altnames = Altnames::new(); altnames - .name("Nisekoi", "Nisekoi:", &corpus) + .name("Nisekoi", ShowId(2), &corpus) .expect("two entries under one key are the user's to settle"); assert_eq!( @@ -513,12 +582,14 @@ mod tests { &Index::of(&corpus) ), Recognition::Recognised(Match { + show: ShowId(2), title: "Nisekoi:".to_owned(), episode: Episode::Only(3), stage: Stage::Altname(Assignment { spelled: "Nisekoi".to_owned(), season: None, part: None, + show: ShowId(2), title: "Nisekoi:".to_owned(), }), }) @@ -527,7 +598,7 @@ mod tests { by_key(&named("Nisekoi - 03.mkv"), &corpus), Some(Recognition::Ambiguous(Ambiguity { parsed: "Nisekoi".to_owned(), - candidates: vec!["Nisekoi".to_owned(), "Nisekoi:".to_owned()], + candidates: vec![candidate(1, "Nisekoi"), candidate(2, "Nisekoi:")], })) ); } @@ -550,18 +621,20 @@ mod tests { let third = named("Kaguya-sama.Love.is.War.S03E03.1080p.WEB-DL.mkv"); let mut altnames = Altnames::new(); altnames - .name_as_parsed(&third, "Kaguya-sama: Love is War -Ultra Romantic-", &corpus) + .name_as_parsed(&third, ShowId(2), &corpus) .expect("the third season reaches no entry, so naming it takes nothing"); assert_eq!( decide(&third, &altnames, &corpus, &Index::of(&corpus)), Recognition::Recognised(Match { + show: ShowId(2), title: "Kaguya-sama: Love is War -Ultra Romantic-".to_owned(), episode: Episode::Only(3), stage: Stage::Altname(Assignment { spelled: "Kaguya-sama Love is War".to_owned(), season: Some(3), part: None, + show: ShowId(2), title: "Kaguya-sama: Love is War -Ultra Romantic-".to_owned(), }), }) @@ -573,7 +646,7 @@ mod tests { &corpus, &Index::of(&corpus) ), - recognised("Kaguya-sama: Love is War", Episode::Only(3)) + recognised(1, "Kaguya-sama: Love is War", Episode::Only(3)) ); } @@ -591,7 +664,7 @@ mod tests { &corpus, &Index::of(&corpus) ), - recognised("Show Title", Episode::Only(3)) + recognised(1, "Show Title", Episode::Only(3)) ); } @@ -668,7 +741,7 @@ mod tests { assert_eq!( found("[Group] Show Title 01-12 [BD].mkv", &corpus), - Some(recognised("Show Title", Episode::Several)) + Some(recognised(1, "Show Title", Episode::Several)) ); } } diff --git a/crates/core/src/recognise/score.rs b/crates/core/src/recognise/score.rs index fdab5bb..cbcb8a5 100644 --- a/crates/core/src/recognise/score.rs +++ b/crates/core/src/recognise/score.rs @@ -53,6 +53,7 @@ use std::collections::HashSet; +use crate::ShowId; use crate::recognise::corpus::Corpus; use crate::recognise::normalise::wording_of; use crate::recognise::parse::Parsed; @@ -117,7 +118,7 @@ pub fn alike(name: &[String], spelling: &[String]) -> Score { /// season no candidate spells is refused with nothing scored, and so is a /// name that spells two seasons or two parts. #[must_use] -pub fn by_score(parsed: &Parsed, candidates: &[&str], corpus: &Corpus) -> Recognition { +pub fn by_score(parsed: &Parsed, candidates: &[ShowId], corpus: &Corpus) -> Recognition { let name = wording_of(&parsed.text); let refused = |best| { Recognition::Unrecognised(Refusal { @@ -131,23 +132,23 @@ pub fn by_score(parsed: &Parsed, candidates: &[&str], corpus: &Corpus) -> Recogn ) else { return refused(None); }; - let mut judged: Vec<(Score, usize, &str)> = candidates + let mut judged: Vec<(Score, usize, ShowId)> = candidates .iter() - .filter_map(|title| { + .filter_map(|&show| { let (score, words) = corpus - .spellings_of(title) + .spellings_of(show) .into_iter() .map(wording_of) .filter(|spelling| spelling.season == season && spelling.part == part) .map(|spelling| (alike(&name.words, &spelling.words), spelling.words.len())) .max_by(|one, other| one.0.value().total_cmp(&other.0.value()))?; - Some((score, words, *title)) + Some((score, words, show)) }) .collect(); // Stable, so candidates that score alike keep the order they arrived in. judged.sort_by(|one, other| other.0.value().total_cmp(&one.0.value())); - let Some(&(best, words, title)) = judged.first() else { + let Some(&(best, words, show)) = judged.first() else { return refused(None); }; let runner_up = judged.get(1).map_or(0.0, |&(score, ..)| score.value()); @@ -160,7 +161,8 @@ pub fn by_score(parsed: &Parsed, candidates: &[&str], corpus: &Corpus) -> Recogn return refused(Some(best)); } Recognition::Recognised(Match { - title: title.to_owned(), + show, + title: corpus.title_of(show).unwrap_or_default().to_owned(), episode: parsed.episode, stage: Stage::Scored(best), }) @@ -185,10 +187,11 @@ struct Two; #[cfg(test)] mod tests { use super::{NEAR_ENOUGH, alike, by_score}; + use crate::ShowId; use crate::encoding::Confidence; use crate::path::RawPath; use crate::recognise::corpus::Corpus; - use crate::recognise::normalise::words_in; + use crate::recognise::normalise::{Key, words_in}; use crate::recognise::parse::{Episode, Parsed, parse}; use crate::recognise::{Match, Recognition, Refusal, Score, Stage}; @@ -202,6 +205,22 @@ mod tests { Score::new(value).expect("the value is between nought and one") } + /// The shows these titles are filed as, for a test that names its + /// candidates by their titles. + fn shows(corpus: &Corpus, titles: &[&str]) -> Vec { + titles + .iter() + .map(|&title| { + let key = Key::from_title(title).expect("a title with letters in it has a key"); + corpus + .candidates(&key) + .into_iter() + .find(|&show| corpus.title_of(show) == Some(title)) + .expect("the title is filed") + }) + .collect() + } + /// The match an answer carries, where the test says it has to be one. fn answered(answer: Recognition, must: &str) -> Match { match answer { @@ -224,12 +243,15 @@ mod tests { let by_the_short = by_score( &named("Alpha Beta Gamma - 03.mkv"), - &["Alpha Beta", "Alpha Beta Delta"], + &shows(&short, &["Alpha Beta", "Alpha Beta Delta"]), &short, ); let by_the_long = by_score( &named("Alpha Beta Gamma Delta - 03.mkv"), - &["Alpha Beta Gamma Delta", "Alpha Beta Gamma Delta Epsilon"], + &shows( + &long, + &["Alpha Beta Gamma Delta", "Alpha Beta Gamma Delta Epsilon"], + ), &long, ); @@ -243,6 +265,29 @@ mod tests { ); } + #[test] + fn a_scored_answer_names_the_show_it_scored() { + let mut corpus = Corpus::new(); + corpus.file(ShowId(7), "Show Title", Vec::<&str>::new()); + corpus.file( + ShowId(9), + "Show Title Cour 2 The Subtitle", + Vec::<&str>::new(), + ); + + let found = answered( + by_score( + &named("Show Title Cour 2 - The Subtitle - 03.mkv"), + &[ShowId(7), ShowId(9)], + &corpus, + ), + "the subtitle tells the two apart", + ); + + assert_eq!(found.show, ShowId(9)); + assert_eq!(found.title, "Show Title Cour 2 The Subtitle"); + } + #[test] fn a_candidate_with_nothing_to_beat_still_has_to_be_near_enough() { // A name reaching one entry has no runner-up, so a margin measured @@ -250,7 +295,11 @@ mod tests { // entry. `Some Other Show` shares `show` and nothing else. let corpus: Corpus = ["Show Title"].into_iter().collect(); - let alone = by_score(&named("Some Other Show - 03.mkv"), &["Show Title"], &corpus); + let alone = by_score( + &named("Some Other Show - 03.mkv"), + &shows(&corpus, &["Show Title"]), + &corpus, + ); assert!(matches!(alone, Recognition::Unrecognised(_))); assert!(alike(&words_in("Some Other Show"), &words_in("Show Title")).value() < NEAR_ENOUGH); @@ -267,7 +316,7 @@ mod tests { assert!(matches!( by_score( &named("Show Title - 03.mkv"), - &["Show Title Alpha", "Show Title Beta"], + &shows(&corpus, &["Show Title Alpha", "Show Title Beta"]), &corpus ), Recognition::Unrecognised(_) @@ -289,7 +338,7 @@ mod tests { assert_eq!( by_score( &named("Show Title - 03.mkv"), - &["Show Title Alpha", "Show Title Beta Gamma"], + &shows(&corpus, &["Show Title Alpha", "Show Title Beta Gamma"]), &corpus ), Recognition::Unrecognised(Refusal { @@ -323,7 +372,7 @@ mod tests { let found = answered( by_score( &named("[Group] Shingeki no Kyojin - 03 [1080p].mkv"), - &["Shingeki no Kyojin", "Kimi no Na wa"], + &shows(&corpus, &["Shingeki no Kyojin", "Kimi no Na wa"]), &corpus, ), "one candidate stands out", @@ -349,7 +398,7 @@ mod tests { let found = answered( by_score( &file, - &["Show Title", "Show Title Cour 2 The Subtitle"], + &shows(&corpus, &["Show Title", "Show Title Cour 2 The Subtitle"]), &corpus, ), "the subtitle is in the name even where the title lost it", @@ -369,7 +418,7 @@ mod tests { let found = answered( by_score( &named("[SubsPlease] Show Title - 03 (1080p) [A1B2C3D4].mkv"), - &["Show Title", "Another Show"], + &shows(&corpus, &["Show Title", "Another Show"]), &corpus, ), "the release's own words are not the name", @@ -384,12 +433,12 @@ mod tests { // English: the entry meets the name on whichever spelling is closest, // not on the one its title is written in. let mut corpus: Corpus = ["Kimi no Na wa"].into_iter().collect(); - corpus.file("Shingeki no Kyojin", ["Attack on Titan"]); + corpus.file(ShowId(2), "Shingeki no Kyojin", ["Attack on Titan"]); let found = answered( by_score( &named("Attack on Titan - 03.mkv"), - &["Shingeki no Kyojin", "Kimi no Na wa"], + &shows(&corpus, &["Shingeki no Kyojin", "Kimi no Na wa"]), &corpus, ), "the English spelling is what matched", @@ -403,7 +452,7 @@ mod tests { fn scored(name: &str, entries: &[&str]) -> Recognition { let corpus: Corpus = entries.iter().copied().collect(); - by_score(&named(name), entries, &corpus) + by_score(&named(name), &shows(&corpus, entries), &corpus) } /// Whether an answer is a refusal with nothing scored, which is what a @@ -473,7 +522,11 @@ mod tests { let corpus: Corpus = seasons.into_iter().collect(); let found = answered( - by_score(&reported("Show Title II", Some(1)), &seasons, &corpus), + by_score( + &reported("Show Title II", Some(1)), + &shows(&corpus, &seasons), + &corpus, + ), "the numeral is the season", ); @@ -487,7 +540,11 @@ mod tests { let seasons = ["Show Title", "Show Title II", "Show Title III"]; let corpus: Corpus = seasons.into_iter().collect(); - let answer = by_score(&reported("Show Title II", Some(3)), &seasons, &corpus); + let answer = by_score( + &reported("Show Title II", Some(3)), + &shows(&corpus, &seasons), + &corpus, + ); assert!(nothing_was_scored(&answer), "got {answer:?}"); } @@ -548,8 +605,12 @@ mod tests { // is scored against the spelling that holds what it spells, and the // other spelling is no way in. let mut corpus = Corpus::new(); - corpus.file("Alpha Beta 2nd Season", ["Delta Epsilon Zeta Part 2"]); - let entry = ["Alpha Beta 2nd Season"]; + corpus.file( + ShowId(1), + "Alpha Beta 2nd Season", + ["Delta Epsilon Zeta Part 2"], + ); + let entry = [ShowId(1)]; let by_its_part = by_score(&named("Delta Epsilon Part 2 - 03.mkv"), &entry, &corpus); let by_another_season = by_score(&named("Delta Epsilon Zeta II - 03.mkv"), &entry, &corpus); diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index e8ed362..d5c5607 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -67,12 +67,12 @@ use crate::path::RawPath; use crate::recognise::Recognition; use crate::recognise::parse::Episode; use crate::timeline::{Progress, Timeline, WatchedPolicy}; -use crate::{Known, MediaRef, PlayerId, PlayerSnapshot}; +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 title is the title as the corpus spells it. An episode is in the -/// release's numbering, and a film has none. +/// 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. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Effect { /// Ask what was kept of an episode, and bring the answer to @@ -80,16 +80,16 @@ pub enum Effect { Resume { /// What the answer is handed back with. question: Question, - /// The title of what was opened. - title: String, + /// The show that was opened. + show: ShowId, /// The episode that was opened. episode: Option, }, /// Keep the total watched of an episode, in place of the one kept /// before. Keep { - /// The title of what is open. - title: String, + /// The show that is open. + show: ShowId, /// The episode that is open. episode: Option, /// The file the reading that added to the total is of. @@ -104,8 +104,8 @@ pub enum Effect { /// same answer, so a store that marks the total it holds for the episode /// holds one by then. Record { - /// The title of what was seen. - title: String, + /// The show that was seen. + show: ShowId, /// The episode that was seen. episode: Option, /// The file the reading that reached the policy is of. @@ -113,8 +113,8 @@ pub enum Effect { }, /// Close the sitting of an episode. Close { - /// The title of what was open. - title: String, + /// The show that was open. + show: ShowId, /// The episode that was open. episode: Option, }, @@ -156,8 +156,8 @@ pub struct Resumed { struct Named<'reading> { /// The file that is open. media: &'reading RawPath, - /// The title as the corpus spells it. - title: &'reading str, + /// The show recognition named. + show: ShowId, /// The episode, and none for a film. episode: Option, } @@ -188,7 +188,7 @@ impl<'reading> Named<'reading> { Some(Self { media, - title: &found.title, + show: found.show, episode, }) } @@ -269,8 +269,8 @@ enum Count { /// reading its last player left at until the round is over. #[derive(Debug)] struct Sitting { - /// The title as the corpus spells it. - title: String, + /// The show recognition named. + show: ShowId, /// The episode, and none for a film. episode: Option, /// How far the total has got, and who counts into it. @@ -281,7 +281,7 @@ impl Sitting { /// A sitting that begins at this reading and asks the store. fn asking(question: Question, named: Named<'_>, reading: &PlayerSnapshot) -> Self { Self { - title: named.title.to_owned(), + show: named.show, episode: named.episode, count: Count::Asked { question, @@ -295,7 +295,7 @@ impl Sitting { /// Whether a reading that names this is a reading of this episode. fn is_of(&self, named: Named<'_>) -> bool { - self.title == named.title && self.episode == named.episode + self.show == named.show && self.episode == named.episode } /// Whether this player has the episode open. @@ -426,7 +426,7 @@ impl Sitting { *watched += added; let mut effects = vec![Effect::Keep { - title: self.title.clone(), + show: self.show, episode: self.episode, media: player.media.clone(), watched: *watched, @@ -439,7 +439,7 @@ impl Sitting { if !*registered && policy.counts_as_watched(&reached) { *registered = true; effects.push(Effect::Record { - title: self.title.clone(), + show: self.show, episode: self.episode, media: player.media.clone(), }); @@ -451,7 +451,7 @@ impl Sitting { /// What the sitting ends with. fn close(self) -> Effect { Effect::Close { - title: self.title, + show: self.show, episode: self.episode, } } @@ -587,7 +587,7 @@ impl Session { Some(Effect::Resume { question, - title: named.title.to_owned(), + show: named.show, episode: named.episode, }) } @@ -602,9 +602,9 @@ mod tests { use crate::recognise::corpus::Corpus; use crate::recognise::index::Index; use crate::recognise::parse::{Episode, parse}; - use crate::recognise::{Ambiguity, Match, Recognition, Refusal, Stage, decide}; + use crate::recognise::{Ambiguity, Candidate, Match, Recognition, Refusal, Stage, decide}; use crate::timeline::{WATCHED_FALLBACK, WatchedPolicy}; - use crate::{Known, MediaRef, PlayState, PlayerId, PlayerSnapshot}; + use crate::{Known, MediaRef, PlayState, PlayerId, PlayerSnapshot, ShowId}; use std::cell::Cell; use std::time::Duration; @@ -622,14 +622,14 @@ mod tests { /// second is watched at the reading of this index. const ASKED_AT: usize = 100; - /// The title the list holds for the series. - const SERIES: &str = "Show Title"; + /// The series of the list. + const SERIES: ShowId = ShowId(1); - /// The title the list holds for the film. - const FILM: &str = "A Film"; + /// A second series of the list. + const SECOND_SERIES: ShowId = ShowId(2); - /// The title the list holds for a second series. - const SECOND_SERIES: &str = "Second Series"; + /// The film of the list. + const FILM: ShowId = ShowId(3); const THIRD: &str = "/anime/[Group] Show Title - 03 [1080p].mkv"; const THIRD_BY_OTHERS: &str = "/anime/[Others] Show Title - 03 [720p].mkv"; @@ -773,7 +773,10 @@ mod tests { let MediaRef::LocalFile(path) = &reading.media else { return None; }; - let list: Corpus = [SERIES, SECOND_SERIES, FILM].into_iter().collect(); + 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()); Some(decide( @@ -784,10 +787,21 @@ mod tests { )) } + /// The title of a show of the list, and that of the series for any other + /// show. + fn title_of(show: ShowId) -> &'static str { + match show { + SECOND_SERIES => "Second Series", + FILM => "A Film", + _ => "Show Title", + } + } + /// A decision written out by hand, for a test that is about the decision. - fn recognised(title: &str, episode: Episode) -> Recognition { + fn recognised(show: ShowId, episode: Episode) -> Recognition { Recognition::Recognised(Match { - title: title.to_owned(), + show, + title: title_of(show).to_owned(), episode, stage: Stage::Key, }) @@ -862,40 +876,37 @@ mod tests { } /// The question of this number, a session's first being the first. - fn resume(number: u64, title: &str, episode: Option) -> Effect { + fn resume(number: u64, show: ShowId, episode: Option) -> Effect { Effect::Resume { question: Question(number), - title: title.to_owned(), + show, episode, } } - fn keep(title: &str, episode: Option, name: &str, seconds: u64) -> Effect { - keep_ms(title, episode, name, seconds * 1000) + fn keep(show: ShowId, episode: Option, name: &str, seconds: u64) -> Effect { + keep_ms(show, episode, name, seconds * 1000) } - fn keep_ms(title: &str, episode: Option, name: &str, millis: u64) -> Effect { + fn keep_ms(show: ShowId, episode: Option, name: &str, millis: u64) -> Effect { Effect::Keep { - title: title.to_owned(), + show, episode, media: a_path(name), watched: Duration::from_millis(millis), } } - fn record(title: &str, episode: Option, name: &str) -> Effect { + fn record(show: ShowId, episode: Option, name: &str) -> Effect { Effect::Record { - title: title.to_owned(), + show, episode, media: a_path(name), } } - fn close(title: &str, episode: Option) -> Effect { - Effect::Close { - title: title.to_owned(), - episode, - } + fn close(show: ShowId, episode: Option) -> Effect { + Effect::Close { show, episode } } #[test] @@ -1017,7 +1028,7 @@ mod tests { #[test] fn an_answer_to_a_question_asked_before_a_close_is_dropped() { - // The same episode, closed and opened again, so that title and + // The same episode, closed and opened again, so that show and // episode tell the two questions apart no longer. A store drops a // total at a close where the episode registered, so the answer to // the first question says what the store no longer holds: taken, it @@ -2081,7 +2092,7 @@ mod tests { #[test] fn a_file_of_several_episodes_is_not_recorded() { - // The title is recognised, which the first assertion holds, so what + // The show is recognised, which the first assertion holds, so what // is refused is the episode: a file of twelve is neither the first // nor the twelfth, and with no episode it would arrive as a film. let mut session = Session::new(half()); @@ -2130,8 +2141,17 @@ mod tests { let mut session = Session::new(half()); let player = Player::named("mpv"); let either = Recognition::Ambiguous(Ambiguity { - parsed: SERIES.to_owned(), - candidates: vec![SERIES.to_owned(), "Show Title (2019)".to_owned()], + 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 effects: Vec = (0..150) @@ -2183,7 +2203,7 @@ mod tests { fn a_file_decided_otherwise_closes_one_sitting_and_begins_another() { // The file stays open and what it is changes under it, a decision // being given with every reading. The episode alone changes first, - // then the title alone, then the file is nothing the list holds, + // then the show alone, then the file is nothing the list holds, // and then it is what it was at the start. let mut session = Session::new(half()); let player = Player::named("mpv"); @@ -2191,7 +2211,7 @@ mod tests { let the_fourth = recognised(SERIES, Episode::Only(4)); let of_the_second = recognised(SECOND_SERIES, Episode::Only(4)); let nothing = Recognition::Unrecognised(Refusal { - parsed: SERIES.to_owned(), + parsed: "Show Title".to_owned(), best: None, }); @@ -2259,4 +2279,24 @@ mod tests { ] ); } + + #[test] + fn two_shows_of_one_title_are_two_sittings() { + // A title does not name an entry. Told apart by their title, the two + // would be one sitting, and the time of one would count into the + // total of the other. + let mut session = Session::new(half()); + let stamps = Stamps::new(); + let of_one = recognised(ShowId(7), Episode::Only(3)); + let of_the_other = recognised(ShowId(9), Episode::Only(3)); + + let one = session.advance(&stamps.playing("mpv", THIRD, 1000), Some(&of_one)); + let the_other = session.advance( + &stamps.playing("vlc", THIRD_BY_OTHERS, 1200), + Some(&of_the_other), + ); + + assert_eq!(one, [resume(1, ShowId(7), Some(3))]); + assert_eq!(the_other, [resume(2, ShowId(9), Some(3))]); + } } diff --git a/crates/core/tests/recognition_against_the_fixtures.rs b/crates/core/tests/recognition_against_the_fixtures.rs index 78b7574..b3b0767 100644 --- a/crates/core/tests/recognition_against_the_fixtures.rs +++ b/crates/core/tests/recognition_against_the_fixtures.rs @@ -22,6 +22,7 @@ //! to the arithmetic while holding no rule of its own; whether the best //! candidate was scored at all is the fact a refusal is checked for. +use benshi_core::ShowId; use benshi_core::path::RawPath; use benshi_core::recognise::altname::Altnames; use benshi_core::recognise::corpus::Corpus; @@ -158,7 +159,13 @@ impl Shape { Stage::Scored(_) => StageName::Score, }, }, - Recognition::Ambiguous(ambiguity) => Self::Ambiguous(ambiguity.candidates.clone()), + Recognition::Ambiguous(ambiguity) => Self::Ambiguous( + ambiguity + .candidates + .iter() + .map(|candidate| candidate.title.clone()) + .collect(), + ), Recognition::Unrecognised(refusal) => Self::Unrecognised(if refusal.best.is_some() { Refused::Scored } else { @@ -188,25 +195,37 @@ fn parsed(name: &RawPath) -> Parsed { parse(&RawPath::from_bytes(name.file_name().to_vec())) } +/// The list as a corpus, each entry filed as a show numbered by its place. fn corpus_of(list: &[Entry]) -> Corpus { let mut corpus = Corpus::new(); - for entry in list { - corpus.file(&entry.title, &entry.spellings); + for (show, entry) in (1..).zip(list) { + corpus.file(ShowId(show), &entry.title, &entry.spellings); } corpus } +/// The show of the entry with this title, as [`corpus_of`] files it. +fn show_titled(list: &[Entry], title: &str, rule: &str) -> ShowId { + let Some((show, _)) = (1..).zip(list).find(|(_, entry)| entry.title == title) else { + panic!("{rule}: no entry is titled {title:?}"); + }; + + ShowId(show) +} + /// The assignments a fixture makes, each checked against the corpus as a /// user's would be, so a fixture cannot assign what the program refuses. -fn altnames_of(named: &[Named], corpus: &Corpus, rule: &str) -> Altnames { +fn altnames_of(named: &[Named], list: &[Entry], corpus: &Corpus, rule: &str) -> Altnames { let mut altnames = Altnames::new(); for assignment in named { let made = match assignment { - Named::Typed { spelled, title } => altnames.name(spelled, title, corpus), + Named::Typed { spelled, title } => { + altnames.name(spelled, show_titled(list, title, rule), corpus) + } Named::FromFile { file, title } => { let file = RawPath::from_bytes(file.as_bytes().to_vec()); - altnames.name_as_parsed(&parsed(&file), title, corpus) + altnames.name_as_parsed(&parsed(&file), show_titled(list, title, rule), corpus) } }; made.unwrap_or_else(|refused| panic!("{rule}: the assignment was refused: {refused}")); @@ -224,7 +243,7 @@ fn every_fixture_resolves_to_the_answer_it_expects() { for fixture in fixtures() { let corpus = corpus_of(&fixture.list); let index = Index::of(&corpus); - let altnames = altnames_of(&fixture.altnames, &corpus, &fixture.rule); + let altnames = altnames_of(&fixture.altnames, &fixture.list, &corpus, &fixture.rule); let answer = decide(&parsed(&fixture.name), &altnames, &corpus, &index); diff --git a/crates/core/tests/the_session_against_a_recording.rs b/crates/core/tests/the_session_against_a_recording.rs index d50a6a2..b640cda 100644 --- a/crates/core/tests/the_session_against_a_recording.rs +++ b/crates/core/tests/the_session_against_a_recording.rs @@ -20,7 +20,7 @@ use benshi_core::recognise::{Match, Recognition, Stage, decide}; use benshi_core::session::{Effect, Session}; use benshi_core::timeline::{WATCHED_MINIMUM, WATCHED_PERCENT_MIN, WatchedPolicy}; use benshi_core::trace::Trace; -use benshi_core::{MediaRef, PlayerSnapshot}; +use benshi_core::{MediaRef, PlayerSnapshot, ShowId}; const RECORDING: &str = include_str!("fixtures/mpv-one-episode.jsonl"); @@ -33,6 +33,9 @@ const PLAYING_INTERVALS: usize = 39; /// The title the list holds for what the recording plays. const TITLE: &str = "Test Show"; +/// The show the list files that title as. +const SHOW: ShowId = ShowId(1); + fn recording() -> Vec { Trace::from_jsonl(RECORDING) .expect("the recording parses") @@ -45,7 +48,8 @@ fn decided(reading: &PlayerSnapshot) -> Recognition { let MediaRef::LocalFile(path) = &reading.media else { panic!("the recording plays a file") }; - let list: Corpus = [TITLE].into_iter().collect(); + let mut list = Corpus::new(); + list.file(SHOW, TITLE, Vec::<&str>::new()); let name = RawPath::from_bytes(path.file_name().to_vec()); decide(&parse(&name), &Altnames::new(), &list, &Index::of(&list)) @@ -64,6 +68,7 @@ fn the_recording_is_kept_interval_by_interval_and_never_recorded() { assert_eq!( decided(&readings[0]), Recognition::Recognised(Match { + show: SHOW, title: TITLE.to_owned(), episode: Episode::Only(3), stage: Stage::Key, diff --git a/crates/daemon/src/detection.rs b/crates/daemon/src/detection.rs index 031b9c6..e1284b7 100644 --- a/crates/daemon/src/detection.rs +++ b/crates/daemon/src/detection.rs @@ -450,11 +450,11 @@ impl Detection { match effect { Effect::Resume { question, - title, + show, episode, } => { let kept = ask(Arc::clone(&self.store), move |store| { - store.resume(&title, episode) + store.resume(show, episode) }) .await?; self.session.resumed( @@ -466,13 +466,13 @@ impl Detection { ); } Effect::Keep { - title, + show, episode, media, watched, } => { let total = Total { - title, + show, episode, media, watched, @@ -481,21 +481,21 @@ impl Detection { ask(Arc::clone(&self.store), move |store| store.keep(&total)).await?; } Effect::Record { - title, + show, episode, media, } => { let viewing = Viewing { - title, + show, episode, media, at: (self.now)(), }; ask(Arc::clone(&self.store), move |store| store.record(&viewing)).await?; } - Effect::Close { title, episode } => { + Effect::Close { show, episode } => { ask(Arc::clone(&self.store), move |store| { - store.close(&title, episode) + store.close(show, episode) }) .await?; } @@ -543,9 +543,10 @@ mod tests { use benshi_core::policy::{Policy, PolicyTable}; use benshi_core::recognise::Recognition; use benshi_core::recognise::altname::Altnames; + use benshi_core::recognise::corpus::Corpus; use benshi_core::recognise::parse::Episode; use benshi_core::{ - AppName, Capabilities, Known, MediaRef, PlayState, PlayerId, PlayerSnapshot, + AppName, Capabilities, Known, MediaRef, PlayState, PlayerId, PlayerSnapshot, ShowId, }; use benshi_detect::{PlayerWatcher, PollOutcome, SourceInfo, WatchError}; use benshi_store::queue::{Kind, Operation}; @@ -587,6 +588,21 @@ mod tests { /// The title the list holds for the film. const FILM: &str = "A Film"; + /// Two series and a film, each spelled one way, as a store files them. + const LIST: [(ShowId, &str); 3] = [ + (ShowId(1), SERIES), + (ShowId(2), "Second Series"), + (ShowId(3), FILM), + ]; + + /// The show of the list with this title. + fn show_of(title: &str) -> ShowId { + LIST.iter() + .find(|(_, listed)| *listed == title) + .map(|&(show, _)| show) + .expect("the list holds the title") + } + const THIRD: &str = "/anime/[Group] Show Title - 03 [1080p].mkv"; const FOURTH: &str = "/anime/[Group] Show Title - 04 [1080p].mkv"; const NOT_LISTED: &str = "/anime/[Group] Some Other Show - 03 [1080p].mkv"; @@ -657,10 +673,14 @@ mod tests { } impl Database { + /// A database with the shows of the list filed. fn new() -> Self { let home = tempfile::tempdir().expect("a directory of our own"); let path = home.path().join("data").join("benshi.db"); let store = Store::open(&path).expect("the database opens"); + for (show, title) in LIST { + assert_eq!(store.new_show(title).expect("the show is filed"), show); + } Self { _home: home, @@ -669,12 +689,13 @@ mod tests { } } - /// What the store holds of an episode. + /// What the store holds of an episode of the show of the list with + /// this title. fn kept(&self, title: &str, episode: Option) -> Option { self.store .lock() .expect("the store") - .resume(title, episode) + .resume(show_of(title), episode) .expect("the total reads") } @@ -756,13 +777,14 @@ mod tests { } } - /// Two series and a film, each spelled one way, and nothing named by - /// hand. + /// The list, and nothing named by hand. fn a_list() -> Recogniser { - Recogniser::of( - [SERIES, "Second Series", FILM].into_iter().collect(), - Altnames::new(), - ) + let mut corpus = Corpus::new(); + for (show, title) in LIST { + corpus.file(show, title, Vec::<&str>::new()); + } + + Recogniser::of(corpus, Altnames::new()) } /// Wiring to this database and [`the_time`], with the table a user starts @@ -1646,6 +1668,7 @@ mod tests { database.queued(), [Operation { id: 1, + show: show_of(SERIES), title: SERIES.to_owned(), kind: Kind::Progress { episode: Some(3) }, }] @@ -1751,7 +1774,7 @@ mod tests { let mut store = database.store.lock().expect("the store"); store .keep(&Total { - title: SERIES.to_owned(), + show: show_of(SERIES), episode: Some(3), media: a_path(THIRD), watched: STEP * HALF_AT, @@ -1760,7 +1783,7 @@ mod tests { .expect("the total is kept"); store .record(&Viewing { - title: SERIES.to_owned(), + show: show_of(SERIES), episode: Some(3), media: a_path(THIRD), at: the_time(), @@ -2055,7 +2078,12 @@ mod tests { rounds(&mut detection, HALF_AT + 1).await; - for table in ["shows", "watching", "episodes_seen", "sync_queue"] { + assert_eq!( + database.count_of("shows"), + [3], + "the shows of the list alone" + ); + for table in ["watching", "episodes_seen", "sync_queue"] { assert_eq!(database.count_of(table), [0], "{table}"); } } @@ -2103,7 +2131,7 @@ mod tests { .lock() .expect("the store") .keep(&Total { - title: SERIES.to_owned(), + show: show_of(SERIES), episode: Some(3), media: a_path(THIRD), watched: STEP, diff --git a/crates/daemon/src/protocol.rs b/crates/daemon/src/protocol.rs index 6aa46fe..82b3e83 100644 --- a/crates/daemon/src/protocol.rs +++ b/crates/daemon/src/protocol.rs @@ -173,9 +173,10 @@ pub enum Outcome { /// Which stage decided, and what it decided by. stage: DecidedBy, }, - /// Several titles the name reaches, and nothing in it to choose between. + /// Several entries the name reaches, and nothing in it to choose between. Ambiguous { - /// The entries it reaches, as the corpus spells them. + /// The title of each entry it reaches, as the corpus spells it. Two + /// entries can share one. candidates: Vec, }, /// Nothing matched. @@ -194,7 +195,11 @@ impl Outcome { stage: DecidedBy::of(&found.stage), }, Recognition::Ambiguous(ambiguity) => Self::Ambiguous { - candidates: ambiguity.candidates.clone(), + candidates: ambiguity + .candidates + .iter() + .map(|candidate| candidate.title.clone()) + .collect(), }, Recognition::Unrecognised(refusal) => Self::Unrecognised { best: refusal.best.map(Score::value), @@ -234,9 +239,9 @@ mod tests { use benshi_core::policy::Policy; use benshi_core::recognise::altname::Assignment; use benshi_core::recognise::parse::Episode; - use benshi_core::recognise::{Ambiguity, Match, Recognition, Refusal, Score, Stage}; + use benshi_core::recognise::{Ambiguity, Candidate, Match, Recognition, Refusal, Score, Stage}; use benshi_core::{ - AppName, Capabilities, Known, MediaRef, PlayState, PlayerId, PlayerSnapshot, + AppName, Capabilities, Known, MediaRef, PlayState, PlayerId, PlayerSnapshot, ShowId, }; use benshi_detect::SourceInfo; use std::time::Duration; @@ -296,6 +301,7 @@ mod tests { spelled: "SnK".to_owned(), season: Some(3), part: None, + show: ShowId(1), title: "Shingeki no Kyojin".to_owned(), } } @@ -303,6 +309,7 @@ mod tests { /// A match of this title by this stage, for building decisions from. fn found_by(stage: Stage) -> Recognition { Recognition::Recognised(Match { + show: ShowId(1), title: "Shingeki no Kyojin".to_owned(), episode: Episode::Only(3), stage, @@ -319,8 +326,14 @@ mod tests { Recognition::Ambiguous(Ambiguity { parsed: "Fruits Basket".to_owned(), candidates: vec![ - "Fruits Basket".to_owned(), - "Fruits Basket (2019)".to_owned(), + Candidate { + show: ShowId(1), + title: "Fruits Basket".to_owned(), + }, + Candidate { + show: ShowId(2), + title: "Fruits Basket (2019)".to_owned(), + }, ], }), Recognition::Unrecognised(Refusal { @@ -518,8 +531,14 @@ mod tests { let explained = Explanation::of(&a_decision(Recognition::Ambiguous(Ambiguity { parsed: "Fruits Basket".to_owned(), candidates: vec![ - "Fruits Basket".to_owned(), - "Fruits Basket (2019)".to_owned(), + Candidate { + show: ShowId(1), + title: "Fruits Basket".to_owned(), + }, + Candidate { + show: ShowId(2), + title: "Fruits Basket (2019)".to_owned(), + }, ], }))); diff --git a/crates/daemon/src/recognition.rs b/crates/daemon/src/recognition.rs index 5580007..8f7b857 100644 --- a/crates/daemon/src/recognition.rs +++ b/crates/daemon/src/recognition.rs @@ -125,13 +125,14 @@ mod tests { use benshi_core::recognise::corpus::Corpus; use benshi_core::recognise::parse::Episode; use benshi_core::recognise::{Match, Recognition, Refusal, Stage}; - use benshi_core::{MediaRef, PlayerId}; + use benshi_core::{MediaRef, PlayerId, ShowId}; fn a_path(name: &str) -> RawPath { RawPath::from_bytes(name.as_bytes().to_vec()) } - /// A list of these titles, each spelled one way. + /// A list of these titles, each spelled one way, filed as shows numbered + /// from one in the order given. fn a_list_of(titles: &[&str]) -> Corpus { titles.iter().collect() } @@ -187,6 +188,7 @@ mod tests { assert_eq!( answer, Recognition::Recognised(Match { + show: ShowId(1), title: "Show Title".to_owned(), episode: Episode::Only(3), stage: Stage::Key, @@ -199,7 +201,7 @@ mod tests { let list = a_list_of(&["Show Title", "Another Show"]); let mut named = Altnames::new(); named - .name("ShoTi", "Show Title", &list) + .name("ShoTi", ShowId(1), &list) .expect("text that reaches no entry can be named"); let recogniser = Recogniser::of(list, named); @@ -209,6 +211,7 @@ mod tests { matches!( &answer, Recognition::Recognised(Match { + show: ShowId(1), title, episode: Episode::Only(3), stage: Stage::Altname(_), @@ -234,6 +237,7 @@ mod tests { matches!( &answer, Recognition::Recognised(Match { + show: ShowId(2), title, episode: Episode::Only(3), stage: Stage::Scored(_), diff --git a/crates/daemon/tests/the_daemon_runs.rs b/crates/daemon/tests/the_daemon_runs.rs index 9b1892a..21609ff 100644 --- a/crates/daemon/tests/the_daemon_runs.rs +++ b/crates/daemon/tests/the_daemon_runs.rs @@ -26,7 +26,10 @@ use benshi_core::clock::{Clock, TestClock, Timestamp}; use benshi_core::path::RawPath; use benshi_core::policy::Policy; use benshi_core::recognise::altname::Altnames; -use benshi_core::{AppName, Capabilities, Known, MediaRef, PlayState, PlayerId, PlayerSnapshot}; +use benshi_core::recognise::corpus::Corpus; +use benshi_core::{ + AppName, Capabilities, Known, MediaRef, PlayState, PlayerId, PlayerSnapshot, ShowId, +}; use benshi_daemon::bus::BusEvent; use benshi_daemon::ipc::bind; use benshi_daemon::protocol::{Request, Response, SourceListing}; @@ -235,11 +238,16 @@ fn a_socket() -> (TempDir, PathBuf) { (home, socket) } -/// A database in that directory, opened the way the binary opens one. +/// The show the list holds, as a fresh store files it first. +const THE_SHOW: ShowId = ShowId(1); + +/// A database in that directory, opened the way the binary opens one, with +/// the show of the list filed. fn a_store(home: &TempDir) -> Arc> { - Arc::new(Mutex::new( - Store::open(&the_database(home)).expect("the database opens"), - )) + let store = Store::open(&the_database(home)).expect("the database opens"); + assert_eq!(store.new_show("Show").expect("the show is filed"), THE_SHOW); + + Arc::new(Mutex::new(store)) } /// Where the database of [`a_store`] is. @@ -317,11 +325,13 @@ fn a_daemon_that_tells( told: &Told, ) -> tokio::task::JoinHandle> { let listener = Arc::new(bind(socket).expect("the socket binds")); + let mut list = Corpus::new(); + list.file(THE_SHOW, "Show", Vec::<&str>::new()); tokio::spawn(benshi_daemon::run( platform.factory(), listener, - Recogniser::of(["Show"].into_iter().collect(), Altnames::new()), + Recogniser::of(list, Altnames::new()), Arc::clone(store), the_time, PERIOD, @@ -334,7 +344,7 @@ fn watched(store: &Arc>) -> Option { store .lock() .expect("the store") - .resume("Show", Some(3)) + .resume(THE_SHOW, Some(3)) .expect("the total reads") .map(|kept| kept.watched) } @@ -442,6 +452,7 @@ async fn a_running_daemon_records_what_its_detection_watched() { queued, [Operation { id: 1, + show: THE_SHOW, title: "Show".to_owned(), kind: Kind::Progress { episode: Some(3) }, }] @@ -648,6 +659,7 @@ async fn a_daemon_whose_database_was_in_use_records_once_it_is_free() { queued, [Operation { id: 1, + show: THE_SHOW, title: "Show".to_owned(), kind: Kind::Progress { episode: Some(3) }, }] diff --git a/crates/store/src/bin/record_then_sleep.rs b/crates/store/src/bin/record_then_sleep.rs index 4bb0974..b02ac71 100644 --- a/crates/store/src/bin/record_then_sleep.rs +++ b/crates/store/src/bin/record_then_sleep.rs @@ -1,5 +1,5 @@ -//! Record one episode into the database at the path given, say so, and sleep -//! until killed. +//! File a show in the database at the path given, record one episode of it, +//! say so, and sleep until killed. //! //! Exists for one test, which kills this process with `SIGKILL` after the line //! is printed and then reads what the file holds. It is not a command anybody @@ -22,10 +22,11 @@ fn main() { .map(PathBuf::from) .expect("the database path is the one argument"); let mut store = Store::open(&path).expect("the database opens"); + let show = store.new_show("Show Title").expect("the show is filed"); store .record(&Viewing { - title: "Show Title".to_owned(), + show, episode: Some(3), media: RawPath::from_bytes(b"/anime/[Group] Show Title - 03.mkv".to_vec()), at: SystemTime::now(), diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 86dda03..6b7b938 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -26,6 +26,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; +use benshi_core::ShowId; use rusqlite::Connection; pub use migrate::SCHEMA_VERSION; @@ -230,16 +231,25 @@ impl Store { .pragma_query_value(None, "user_version", |row| row.get(0)) .expect("every SQLite file has user_version in its header") } -} - -/// File a show under its title where it is not filed, and answer its row. -pub(crate) fn filed(connection: &Connection, title: &str) -> Result { - connection.execute("INSERT OR IGNORE INTO shows (title) VALUES (?1)", [title])?; - let show = connection.query_row("SELECT id FROM shows WHERE title = ?1", [title], |row| { - row.get(0) - })?; - Ok(show) + /// File a show with this title, and answer the show it is filed as. + /// + /// A row of its own every time: two entries of one list can share a + /// title, and each is a show. + /// + /// # Errors + /// + /// [`Error::Busy`] when another connection has the database, and + /// [`Error::Write`] when the statement fails for any other reason. + pub fn new_show(&self, title: &str) -> Result { + let id = self.connection.query_row( + "INSERT INTO shows (title) VALUES (?1) RETURNING id", + [title], + |row| row.get(0), + )?; + + Ok(ShowId(id)) + } } /// Make sure the database's directory exists, private where this call is what @@ -286,6 +296,7 @@ mod tests { use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; + use benshi_core::ShowId; use benshi_core::path::RawPath; use tempfile::TempDir; @@ -386,6 +397,32 @@ mod tests { ); } + #[test] + fn every_show_filed_is_a_row_of_its_own_whatever_its_title() { + let (_home, database) = a_home(); + let store = Store::open(&database).expect("a fresh database opens"); + + let one = store.new_show("Show Title").expect("the show is filed"); + let other = store.new_show("Show Title").expect("the show is filed"); + + assert_ne!(one, other); + let titles: Vec<(i64, String)> = store + .connection + .prepare("SELECT id, title FROM shows ORDER BY id") + .expect("the table is there") + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .expect("the rows are read") + .collect::>() + .expect("every row is read"); + assert_eq!( + titles, + [ + (one.0, "Show Title".to_owned()), + (other.0, "Show Title".to_owned()) + ] + ); + } + #[test] fn the_database_is_in_wal_mode_and_syncs_only_at_checkpoints() { // The store is built for a write a second while anything plays. The @@ -433,13 +470,16 @@ mod tests { const MEDIA: &[u8] = b"/anime/[Group] Show Title - 03.mkv"; + /// The show a store in a hurry files first. + const SHOW: ShowId = ShowId(1); + fn an_instant() -> SystemTime { UNIX_EPOCH + Duration::from_secs(1_790_000_000) } fn a_total() -> Total { Total { - title: "Show Title".to_owned(), + show: SHOW, episode: Some(3), media: RawPath::from_bytes(MEDIA.to_vec()), watched: Duration::from_mins(9), @@ -449,7 +489,7 @@ mod tests { fn a_viewing() -> Viewing { Viewing { - title: "Show Title".to_owned(), + show: SHOW, episode: Some(3), media: RawPath::from_bytes(MEDIA.to_vec()), at: an_instant(), @@ -458,9 +498,14 @@ mod tests { /// A store that waits a twentieth of a second for a lock, where one a /// daemon opens waits a second. What it answers once it has waited is - /// the same, and a test of that need not take the second. + /// the same, and a test of that need not take the second. It has the + /// show filed. fn a_store_in_a_hurry(database: &Path) -> Store { let store = Store::open(database).expect("a fresh database opens"); + assert_eq!( + store.new_show("Show Title").expect("the show is filed"), + SHOW + ); store .connection .pragma_update(None, "busy_timeout", 50) @@ -491,6 +536,17 @@ mod tests { assert!(matches!(refused, Err(Error::Busy(_))), "got {refused:?}"); } + #[test] + fn a_show_filed_while_another_connection_writes_is_refused_as_busy() { + let (_home, database) = a_home(); + let store = a_store_in_a_hurry(&database); + let _writer = a_writer(&database); + + let refused = store.new_show("Another Show"); + + assert!(matches!(refused, Err(Error::Busy(_))), "got {refused:?}"); + } + #[test] fn an_episode_recorded_while_another_connection_writes_is_refused_as_busy() { let (_home, database) = a_home(); @@ -508,7 +564,7 @@ mod tests { let store = a_store_in_a_hurry(&database); let _writer = a_writer(&database); - let refused = store.close("Show Title", Some(3)); + let refused = store.close(SHOW, Some(3)); assert!(matches!(refused, Err(Error::Busy(_))), "got {refused:?}"); } @@ -522,7 +578,7 @@ mod tests { store.keep(&a_total()).expect("the total is kept"); let _writer = a_writer(&database); - let asked = store.resume("Show Title", Some(3)); + let asked = store.resume(SHOW, Some(3)); assert!(matches!(asked, Ok(Some(_))), "got {asked:?}"); } @@ -558,7 +614,7 @@ mod tests { let store = a_store_without_wal(&database); let _alone = a_connection_with_the_file_to_itself(&database); - let refused = store.resume("Show Title", Some(3)); + let refused = store.resume(SHOW, Some(3)); assert!(matches!(refused, Err(Error::Busy(_))), "got {refused:?}"); } diff --git a/crates/store/src/migrate.rs b/crates/store/src/migrate.rs index b2f927f..dd419e6 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 = 1; +pub const SCHEMA_VERSION: u32 = 2; /// Every migration, in order, each a script of statements. /// @@ -55,11 +55,29 @@ pub(crate) const MIGRATIONS: &[&str] = &[ registered INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL );", + // `shows` loses the uniqueness of its title, because two entries of one + // list can share a title and each is a show of its own. `ALTER TABLE` in + // SQLite cannot drop a constraint, so the table is built again: a new + // one, the rows copied with their ids, the old one dropped, and the new + // one renamed to `shows`, the name the other tables point at. + "CREATE TABLE new_shows ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL + ); + INSERT INTO new_shows (id, title) SELECT id, title FROM shows; + DROP TABLE shows; + ALTER TABLE new_shows RENAME TO shows;", ]; /// Apply every migration the database has not seen, in order. /// /// A database ahead of the list is refused rather than written into. +/// +/// The foreign keys are off while the migrations run, and set back as they +/// were after them. A migration may drop a table that other tables point at, +/// and with the foreign keys on SQLite deletes its rows first, which the rows +/// pointing at them refuse. The switch does nothing inside a transaction, so +/// it is made around them. pub(crate) fn apply(connection: &mut Connection, migrations: &[&str]) -> Result<(), Error> { let found: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; let known = u32::try_from(migrations.len()).unwrap_or(u32::MAX); @@ -67,8 +85,19 @@ pub(crate) fn apply(connection: &mut Connection, migrations: &[&str]) -> Result< return Err(Error::FromTheFuture { found, known }); } + let enforced: bool = connection.pragma_query_value(None, "foreign_keys", |row| row.get(0))?; + connection.pragma_update(None, "foreign_keys", false)?; + let applied = apply_after(connection, migrations, found); + connection.pragma_update(None, "foreign_keys", enforced)?; + + applied +} + +/// Apply the migrations after the first `found`, each in a transaction of +/// its own. +fn apply_after(connection: &mut Connection, migrations: &[&str], found: u32) -> Result<(), Error> { let applied = usize::try_from(found).unwrap_or(usize::MAX); - for (number, script) in (1..=known).zip(migrations).skip(applied) { + for (number, script) in (1..).zip(migrations).skip(applied) { let failed = |cause| Error::Migrate { number, cause }; let transaction = connection.transaction().map_err(failed)?; // The version goes in before the script, so that the transaction is @@ -86,6 +115,7 @@ pub(crate) fn apply(connection: &mut Connection, migrations: &[&str]) -> Result< #[cfg(test)] mod tests { use rusqlite::Connection; + use rusqlite::types::Value; use super::{MIGRATIONS, SCHEMA_VERSION, apply}; use crate::Error; @@ -191,20 +221,97 @@ mod tests { } #[test] - fn a_title_is_filed_once() { - // Two rows for one title would be two shows for one thing, and every - // episode after the first would have to choose between them. + fn two_shows_may_share_a_title() { + // A title does not name an entry: AniList holds entries that share + // one, and each is a show of its own. let mut connection = Connection::open_in_memory().expect("memory opens"); + apply(&mut connection, MIGRATIONS).expect("every migration applies"); + + for _ in 0..2 { + connection + .execute("INSERT INTO shows (title) VALUES ('Show Title')", []) + .expect("a show of that title goes in"); + } + + let shows: u32 = connection + .query_row("SELECT count(*) FROM shows", [], |row| row.get(0)) + .expect("the table is there"); + assert_eq!(shows, 2); + } + + /// Every row of every table, in the order of their ids. + fn every_row(connection: &Connection) -> Vec> { + let mut rows = Vec::new(); + for table in ["shows", "episodes_seen", "sync_queue", "watching"] { + let mut statement = connection + .prepare(&format!("SELECT * FROM {table} ORDER BY id")) + .expect("the table is there"); + let width = statement.column_count(); + let read = statement + .query_map([], |row| { + (0..width).map(|at| row.get::<_, Value>(at)).collect() + }) + .expect("the rows are read") + .collect::>, _>>() + .expect("every value is read"); + rows.extend(read); + } + rows + } + + #[test] + fn a_database_of_the_first_schema_keeps_every_row_through_the_second() { + // `shows` is rebuilt, and every other table points at it. With the + // foreign keys a store's connection enforces, dropping it is refused. + let mut connection = Connection::open_in_memory().expect("memory opens"); + connection + .pragma_update(None, "foreign_keys", true) + .expect("the foreign keys are enforced"); apply(&mut connection, &MIGRATIONS[..1]).expect("the first migration applies"); connection - .execute("INSERT INTO shows (title) VALUES ('Show Title')", []) - .expect("the first row goes in"); + .execute_batch( + "INSERT INTO shows (id, title) VALUES (4, 'Show Title'), (9, 'A Film'); + INSERT INTO episodes_seen (show, episode, seen_at, media) + VALUES (4, 3, '2026-09-27T00:00:00Z', 'Show Title - 03.mkv'), + (9, NULL, '2026-09-28T00:00:00Z', 'A Film.mkv'); + INSERT INTO sync_queue (show, kind, payload, created_at) + VALUES (4, 'progress', '{\"episode\":3}', '2026-09-27T00:00:00Z'); + INSERT INTO watching (show, episode, media, watched_ms, registered, updated_at) + VALUES (9, NULL, 'A Film.mkv', 600000, 1, '2026-09-28T00:00:00Z');", + ) + .expect("a row goes into every table"); + let before = every_row(&connection); - let again = connection.execute("INSERT INTO shows (title) VALUES ('Show Title')", []); + apply(&mut connection, MIGRATIONS).expect("the second migration applies"); - assert!( - again.is_err(), - "the second row for the same title was refused" - ); + assert_eq!(version(&connection), 2); + assert_eq!(every_row(&connection), before); + let enforced: bool = connection + .pragma_query_value(None, "foreign_keys", |row| row.get(0)) + .expect("the pragma is readable"); + assert!(enforced, "the foreign keys are enforced again"); + let broken: u32 = connection + .query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + }) + .expect("the check runs"); + assert_eq!(broken, 0, "every row points at a show that is there"); + } + + #[test] + fn foreign_keys_that_were_off_are_left_off() { + // The SQLite this crate builds turns them on in every connection it + // opens. + let mut connection = Connection::open_in_memory().expect("memory opens"); + connection + .pragma_update(None, "foreign_keys", false) + .expect("the foreign keys are turned off"); + + apply(&mut connection, MIGRATIONS).expect("every migration applies"); + + let enforced: bool = connection + .pragma_query_value(None, "foreign_keys", |row| row.get(0)) + .expect("the pragma is readable"); + assert!(!enforced); } } diff --git a/crates/store/src/queue.rs b/crates/store/src/queue.rs index 761d3ae..49e5d79 100644 --- a/crates/store/src/queue.rs +++ b/crates/store/src/queue.rs @@ -15,6 +15,7 @@ //! it is written, which is what allows a delivery to be repeated when nobody //! can tell whether the first one arrived. +use benshi_core::ShowId; use rusqlite::{Connection, params}; use serde::de::Error as _; use serde::{Deserialize, Deserializer, Serialize}; @@ -133,7 +134,9 @@ impl<'de> Deserialize<'de> for Rating { pub struct Operation { /// The row's `id` in `sync_queue`. pub id: i64, - /// The show it is about, by the title the corpus spells. + /// The show it is about. + pub show: ShowId, + /// That show's title, as the store files it. Two shows can share one. pub title: String, /// What a list is to be told. pub kind: Kind, @@ -154,25 +157,32 @@ impl Store { /// than answering without it. pub fn queued(&self) -> Result, Error> { let mut statement = self.connection.prepare( - "SELECT sync_queue.id, shows.title, sync_queue.kind, sync_queue.payload \ + "SELECT sync_queue.id, sync_queue.show, shows.title, sync_queue.kind, \ + sync_queue.payload \ FROM sync_queue LEFT JOIN shows ON shows.id = sync_queue.show \ ORDER BY sync_queue.id", )?; let rows = statement.query_map([], |row| { Ok(( row.get::<_, i64>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, String>(2)?, + row.get::<_, i64>(1)?, + row.get::<_, Option>(2)?, row.get::<_, String>(3)?, + row.get::<_, String>(4)?, )) })?; rows.map(|row| { - let (id, title, kind, payload) = row?; + let (id, show, title, kind, payload) = row?; let title = title.ok_or(Error::Orphan { id })?; let kind = Kind::from_columns(&kind, &payload) .map_err(|cause| Error::Operation { id, cause })?; - Ok(Operation { id, title, kind }) + Ok(Operation { + id, + show: ShowId(show), + title, + kind, + }) }) .collect() } @@ -243,22 +253,32 @@ mod tests { use std::collections::BTreeMap; use std::time::{Duration, UNIX_EPOCH}; + use benshi_core::ShowId; use benshi_core::path::RawPath; + use rusqlite::OptionalExtension; use serde_json::json; use tempfile::TempDir; use super::{Kind, Operation, Rating, Status, enqueue}; use crate::{Error, Store, Viewing}; + /// The show a fresh store here files first, titled `Show Title`. + const SHOW: ShowId = ShowId(1); + + /// A store with the show filed. fn a_store() -> (TempDir, Store) { let home = tempfile::tempdir().expect("a directory of our own"); let store = Store::open(&home.path().join("benshi.db")).expect("a fresh database opens"); + assert_eq!( + store.new_show("Show Title").expect("the show is filed"), + SHOW + ); (home, store) } fn a_viewing() -> Viewing { Viewing { - title: "Show Title".to_owned(), + show: SHOW, episode: Some(3), media: RawPath::from_bytes(b"Show Title - 03.mkv".to_vec()), at: UNIX_EPOCH + Duration::from_secs(1_790_000_000), @@ -292,18 +312,20 @@ mod tests { every } - /// File the show if it is new and queue one operation for it. + /// Queue one operation for the show of this title, filed where no show + /// has it. fn queue(store: &Store, title: &str, kind: &Kind) { - store - .connection - .execute("INSERT OR IGNORE INTO shows (title) VALUES (?1)", [title]) - .expect("the show is filed"); - let show: i64 = store + let filed: Option = store .connection .query_row("SELECT id FROM shows WHERE title = ?1", [title], |row| { row.get(0) }) - .expect("the show is there"); + .optional() + .expect("the table is there"); + let show = match filed { + Some(show) => show, + None => store.new_show(title).expect("the show is filed").0, + }; enqueue(&store.connection, show, kind, "2026-09-27T00:00:00Z") .expect("the operation is queued"); } @@ -311,13 +333,6 @@ mod tests { /// A row put into the queue by hand, which is how a row this build did /// not write gets there. fn a_row(store: &Store, kind: &str, payload: &str) { - store - .connection - .execute( - "INSERT OR IGNORE INTO shows (title) VALUES ('Show Title')", - [], - ) - .expect("the show is filed"); store .connection .execute( @@ -345,11 +360,11 @@ mod tests { /// work that out. That is the meaning the documentation of each kind /// gives it, written as code. #[derive(Debug, Default)] - struct Ledger(BTreeMap); + struct Ledger(BTreeMap); impl Ledger { fn apply(&mut self, operation: &Operation) { - let listed = self.0.entry(operation.title.clone()).or_default(); + let listed = self.0.entry(operation.show).or_default(); match operation.kind { Kind::Progress { episode: Some(episode), @@ -381,6 +396,7 @@ mod tests { store.queued().expect("the queue is readable"), [Operation { id: 1, + show: SHOW, title: "Show Title".to_owned(), kind: Kind::Progress { episode: Some(3) }, }] @@ -765,14 +781,14 @@ mod tests { ledger.apply(operation); assert_eq!( - ledger.0.get("Show Title"), + ledger.0.get(&operation.show), Some(&left), "{kind:?} applied once" ); ledger.apply(operation); assert_eq!( - ledger.0.get("Show Title"), + ledger.0.get(&operation.show), Some(&left), "{kind:?} applied twice" ); diff --git a/crates/store/src/record.rs b/crates/store/src/record.rs index 50ee13d..4807740 100644 --- a/crates/store/src/record.rs +++ b/crates/store/src/record.rs @@ -2,11 +2,10 @@ //! //! **One transaction across two tables.** The row in `episodes_seen` and the //! operation in `sync_queue` that will carry it to a list are written -//! together, and so is the row in `shows` that both point at; a failure -//! anywhere between them leaves none of the three. A row without its -//! operation would be an episode no list ever hears of, and an operation -//! without its row would be a list told of an episode that was never -//! recorded. The transaction rules out both. +//! together, and a failure anywhere between them leaves neither. A row +//! without its operation would be an episode no list ever hears of, and an +//! operation without its row would be a list told of an episode that was +//! never recorded. The transaction rules out both. //! //! The mark on the episode's total in `watching` is made in the same //! transaction. Made after it, a process killed between the two would leave @@ -16,13 +15,14 @@ use std::fmt::Write as _; use std::time::{SystemTime, UNIX_EPOCH}; +use benshi_core::ShowId; use benshi_core::path::RawPath; use humantime::format_rfc3339_seconds; use rusqlite::params; use crate::queue::{Kind, enqueue}; use crate::watching::registered; -use crate::{Error, Store, filed}; +use crate::{Error, Store}; /// One viewing, as it reaches [`Store::record`]. /// @@ -31,8 +31,8 @@ use crate::{Error, Store, filed}; /// and the two meet in one module there. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Viewing { - /// The title as the corpus spells it. - pub title: String, + /// The show that was seen, one the store has filed. + pub show: ShowId, /// The episode in the release's numbering, and none for a film. /// /// A film spells no episode and is a work this program marks watched, so @@ -50,27 +50,27 @@ impl Store { /// Record that an episode was seen, and queue the operation that will /// tell a list. /// - /// The show is filed under its title the first time an episode of it is - /// recorded. Instants are written as RFC 3339 text with whole seconds, - /// and the media as the path's escaped form, so that `sqlite3` shows - /// rows a person can read. Where a total is kept for the episode, it is - /// marked registered. + /// Instants are written as RFC 3339 text with whole seconds, and the + /// media as the path's escaped form, so that `sqlite3` shows rows a + /// person can read. Where a total is kept for the episode, it is marked + /// registered. /// /// # Errors /// /// [`Error::Instant`] for a clock reading before 1970 or past the year /// 9999, before anything is written. [`Error::Busy`] when another /// connection has the database, and [`Error::Write`] when any statement - /// fails for any other reason. Nothing is left behind: the transaction - /// is rolled back with the error. + /// fails for any other reason, a show the store has not filed among + /// them. Nothing is left behind: the transaction is rolled back with the + /// error. pub fn record(&mut self, viewing: &Viewing) -> Result<(), Error> { let at = rfc3339(viewing.at).ok_or(Error::Instant { at: viewing.at })?; let seen = Kind::Progress { episode: viewing.episode, }; + let show = viewing.show.0; let transaction = self.connection.transaction()?; - let show = filed(&transaction, &viewing.title)?; transaction.execute( "INSERT INTO episodes_seen (show, episode, seen_at, media) \ VALUES (?1, ?2, ?3, ?4)", @@ -102,6 +102,7 @@ pub(crate) fn rfc3339(at: SystemTime) -> Option { mod tests { use std::time::{Duration, SystemTime, UNIX_EPOCH}; + use benshi_core::ShowId; use benshi_core::path::RawPath; use tempfile::TempDir; @@ -113,15 +114,17 @@ mod tests { UNIX_EPOCH + Duration::from_secs(1_790_000_000) } - fn a_store() -> (TempDir, Store) { + /// A store with one show filed, and that show. + fn a_store() -> (TempDir, Store, ShowId) { let home = tempfile::tempdir().expect("a directory of our own"); let store = Store::open(&home.path().join("benshi.db")).expect("a fresh database opens"); - (home, store) + let show = store.new_show("Show Title").expect("the show is filed"); + (home, store, show) } - fn viewing(title: &str, episode: Option, media: &[u8]) -> Viewing { + fn viewing(show: ShowId, episode: Option, media: &[u8]) -> Viewing { Viewing { - title: title.to_owned(), + show, episode, media: RawPath::from_bytes(media.to_vec()), at: an_instant(), @@ -138,24 +141,19 @@ mod tests { } #[test] - fn recording_an_episode_leaves_a_show_an_episode_and_an_operation() { - let (_home, mut store) = a_store(); + fn recording_an_episode_leaves_an_episode_and_an_operation_of_its_show() { + let (_home, mut store, filed) = a_store(); store .record(&viewing( - "Show Title", + filed, Some(3), b"/anime/[Group] Show Title - 03.mkv", )) .expect("recording cannot fail"); - let (show, title): (i64, String) = store - .connection - .query_row("SELECT id, title FROM shows", [], |row| { - Ok((row.get(0)?, row.get(1)?)) - }) - .expect("one show"); - assert_eq!(title, "Show Title"); + assert_eq!(count(&store, "shows"), 1, "no show is filed by a recording"); + let show = filed.0; let (episode_show, episode, seen_at, media): (i64, Option, String, String) = store .connection @@ -190,39 +188,50 @@ mod tests { ); } + /// The show each row of a table points at, in the order of the rows. + fn shows_of(store: &Store, table: &str) -> Vec { + store + .connection + .prepare(&format!("SELECT show FROM {table} ORDER BY id")) + .expect("the table is there") + .query_map([], |row| row.get(0)) + .expect("the rows are read") + .collect::>() + .expect("every row is read") + } + #[test] - fn a_second_episode_of_the_same_show_adds_no_second_show() { - let (_home, mut store) = a_store(); + fn two_shows_of_one_title_are_recorded_apart() { + let (_home, mut store, one) = a_store(); + let other = store.new_show("Show Title").expect("the show is filed"); + + for show in [one, other] { + store + .record(&viewing(show, Some(3), b"Show Title - 03.mkv")) + .expect("recording cannot fail"); + } - store - .record(&viewing("Show Title", Some(3), b"Show Title - 03.mkv")) - .expect("recording cannot fail"); - store - .record(&viewing("Show Title", Some(4), b"Show Title - 04.mkv")) - .expect("recording cannot fail"); + assert_eq!(shows_of(&store, "episodes_seen"), [one.0, other.0]); + assert_eq!(shows_of(&store, "sync_queue"), [one.0, other.0]); + } - assert_eq!(count(&store, "shows"), 1); - assert_eq!(count(&store, "episodes_seen"), 2); - assert_eq!(count(&store, "sync_queue"), 2); - let shows: u32 = store - .connection - .query_row( - "SELECT count(DISTINCT show) FROM episodes_seen", - [], - |row| row.get(0), - ) - .expect("the column is there"); - assert_eq!(shows, 1, "both episodes point at the one show"); + #[test] + fn an_episode_of_a_show_that_is_not_filed_is_refused() { + let (_home, mut store, _show) = a_store(); + + let refused = store.record(&viewing(ShowId(42), Some(3), b"Show Title - 03.mkv")); + + assert!(matches!(refused, Err(Error::Write(_))), "got {refused:?}"); } #[test] fn a_film_is_recorded_with_no_episode() { // A film spells no episode and is a work this program marks watched. // NULL, and never a number standing in for one. - let (_home, mut store) = a_store(); + let (_home, mut store, show) = a_store(); store - .record(&viewing("A Film", None, b"A Film (2019).mkv")) + .record(&viewing(show, None, b"A Film (2019).mkv")) .expect("recording cannot fail"); let episode: Option = store @@ -242,11 +251,10 @@ mod tests { // The one transaction across two tables, from the inside: a TEMP // trigger, which lives on this connection alone, refuses the // operation. RAISE(ABORT) backs out the statement that fired it and - // leaves the transaction open, so what takes the show and the episode - // with it is the rollback as the transaction is dropped. A row - // without its operation is the thing this store exists to make - // impossible. - let (_home, mut store) = a_store(); + // leaves the transaction open, so what takes the episode with it is + // the rollback as the transaction is dropped. A row without its + // operation is the thing this store exists to make impossible. + let (_home, mut store, show) = a_store(); store .connection .execute_batch( @@ -255,11 +263,10 @@ mod tests { ) .expect("a trigger on this connection"); - let refused = store.record(&viewing("Show Title", Some(3), b"Show Title - 03.mkv")); + let refused = store.record(&viewing(show, Some(3), b"Show Title - 03.mkv")); assert!(matches!(refused, Err(Error::Write(_))), "got {refused:?}"); assert_eq!(count(&store, "episodes_seen"), 0); - assert_eq!(count(&store, "shows"), 0); assert_eq!(count(&store, "sync_queue"), 0); } @@ -268,10 +275,10 @@ mod tests { // The same escaping a trace uses: valid UTF-8 verbatim, every other // byte as %XX, so `sqlite3` shows a name a person can read and the // bytes can still be recovered. - let (_home, mut store) = a_store(); + let (_home, mut store, show) = a_store(); store - .record(&viewing("Show Title", Some(3), b"/anime/\xFF\xFE - 03.mkv")) + .record(&viewing(show, Some(3), b"/anime/\xFF\xFE - 03.mkv")) .expect("recording cannot fail"); let media: String = store @@ -287,7 +294,7 @@ mod tests { // 9999 at all. A clock set to either is a broken clock, and a broken // clock is a reason to say so, not a reason to bring the daemon down // inside a write. - let (_home, mut store) = a_store(); + let (_home, mut store, show) = a_store(); let before_the_epoch = UNIX_EPOCH - Duration::from_secs(1); // The first second of the year 10000, in hours since the epoch. let the_year_ten_thousand = UNIX_EPOCH + Duration::from_hours(70_389_528); @@ -304,7 +311,7 @@ mod tests { ] { let refused = store.record(&Viewing { at, - ..viewing("Show Title", Some(3), b"Show Title - 03.mkv") + ..viewing(show, Some(3), b"Show Title - 03.mkv") }); assert!( diff --git a/crates/store/src/watching.rs b/crates/store/src/watching.rs index 7a729a5..d2d5f38 100644 --- a/crates/store/src/watching.rs +++ b/crates/store/src/watching.rs @@ -16,17 +16,18 @@ use std::time::{Duration, SystemTime}; +use benshi_core::ShowId; use benshi_core::path::RawPath; use rusqlite::{Connection, OptionalExtension, params}; use crate::record::rfc3339; -use crate::{Error, Store, filed}; +use crate::{Error, Store}; /// What has been watched of one episode, as it reaches [`Store::keep`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Total { - /// The title as the corpus spells it. - pub title: String, + /// The show that is open, one the store has filed. + pub show: ShowId, /// The episode in the release's numbering, and none for a film. pub episode: Option, /// The file that is open. @@ -52,9 +53,7 @@ impl Store { /// Keep the total watched of an episode. /// /// The total takes the place of the one kept before, with its media and - /// its instant. Whether the episode registered stays as it was. The show - /// is filed if it is not, because a total is kept before any episode of - /// the show registers. + /// its instant. Whether the episode registered stays as it was. /// /// The column holds whole milliseconds. What is under one is dropped, /// and a total longer than the column holds is kept as the longest it @@ -65,15 +64,15 @@ impl Store { /// [`Error::Instant`] for a clock reading before 1970 or past the year /// 9999, before anything is written. [`Error::Busy`] when another /// connection has the database, and [`Error::Write`] when a statement - /// fails for any other reason. The transaction is rolled back with - /// either. + /// fails for any other reason, a show the store has not filed among + /// them. The transaction is rolled back with either. pub fn keep(&mut self, total: &Total) -> Result<(), Error> { let at = rfc3339(total.at).ok_or(Error::Instant { at: total.at })?; let media = total.media.escaped(); let watched = i64::try_from(total.watched.as_millis()).unwrap_or(i64::MAX); + let show = total.show.0; let transaction = self.connection.transaction()?; - let show = filed(&transaction, &total.title)?; let kept = transaction.execute( "UPDATE watching SET media = ?3, watched_ms = ?4, updated_at = ?5 \ WHERE show = ?1 AND episode IS ?2", @@ -100,14 +99,13 @@ impl Store { /// not cause. [`Error::Write`] when the row cannot be read for any other /// reason, a total below nought included. [`Error::Media`] for a row /// whose media is not a path as this store writes one. - pub fn resume(&self, title: &str, episode: Option) -> Result, Error> { + pub fn resume(&self, show: ShowId, episode: Option) -> Result, Error> { let row = self .connection .query_row( - "SELECT watching.id, media, watched_ms, registered \ - FROM watching JOIN shows ON shows.id = watching.show \ - WHERE shows.title = ?1 AND episode IS ?2", - params![title, episode], + "SELECT id, media, watched_ms, registered FROM watching \ + WHERE show = ?1 AND episode IS ?2", + params![show.0, episode], |row| { Ok(( row.get::<_, i64>(0)?, @@ -143,12 +141,10 @@ impl Store { /// /// [`Error::Busy`] when another connection has the database, and /// [`Error::Write`] when the statement fails for any other reason. - pub fn close(&self, title: &str, episode: Option) -> Result<(), Error> { + pub fn close(&self, show: ShowId, episode: Option) -> Result<(), Error> { self.connection.execute( - "DELETE FROM watching \ - WHERE registered = 1 AND episode IS ?2 \ - AND show = (SELECT id FROM shows WHERE title = ?1)", - params![title, episode], + "DELETE FROM watching WHERE registered = 1 AND show = ?1 AND episode IS ?2", + params![show.0, episode], )?; Ok(()) } @@ -175,6 +171,7 @@ pub(crate) fn registered( mod tests { use std::time::{Duration, SystemTime, UNIX_EPOCH}; + use benshi_core::ShowId; use benshi_core::path::RawPath; use tempfile::TempDir; @@ -192,15 +189,30 @@ mod tests { UNIX_EPOCH + Duration::from_secs(1_790_000_000) } + /// The series a fresh store files first. + const SERIES: ShowId = ShowId(1); + /// Another show, filed second. + const ANOTHER: ShowId = ShowId(2); + /// A film, filed third. + const FILM: ShowId = ShowId(3); + + /// A store with the series, another show and a film filed. fn a_store() -> (TempDir, Store) { let home = tempfile::tempdir().expect("a directory of our own"); let store = Store::open(&home.path().join("benshi.db")).expect("a fresh database opens"); + for (show, title) in [ + (SERIES, "Show Title"), + (ANOTHER, "Another Show"), + (FILM, "A Film"), + ] { + assert_eq!(store.new_show(title).expect("the show is filed"), show); + } (home, store) } - fn total(title: &str, episode: Option, watched: Duration) -> Total { + fn total(show: ShowId, episode: Option, watched: Duration) -> Total { Total { - title: title.to_owned(), + show, episode, media: RawPath::from_bytes(MEDIA.to_vec()), watched, @@ -208,9 +220,9 @@ mod tests { } } - fn viewing(title: &str, episode: Option) -> Viewing { + fn viewing(show: ShowId, episode: Option) -> Viewing { Viewing { - title: title.to_owned(), + show, episode, media: RawPath::from_bytes(MEDIA.to_vec()), at: an_instant(), @@ -236,12 +248,12 @@ mod tests { fn an_episode_never_opened_has_nothing_to_resume() { let (_home, mut store) = a_store(); store - .keep(&total("Show Title", Some(3), A_SITTING)) + .keep(&total(SERIES, Some(3), A_SITTING)) .expect("the total is kept"); - let another_episode = store.resume("Show Title", Some(4)); - let another_show = store.resume("Another Show", Some(3)); - let the_film = store.resume("Show Title", None); + let another_episode = store.resume(SERIES, Some(4)); + let another_show = store.resume(ANOTHER, Some(3)); + let the_film = store.resume(SERIES, None); assert_eq!(rows(&store), 1, "the one that was opened is there"); assert_eq!(another_episode.expect("the store answers"), None); @@ -254,13 +266,11 @@ mod tests { let (_home, mut store) = a_store(); store - .keep(&total("Show Title", Some(3), A_SITTING)) + .keep(&total(SERIES, Some(3), A_SITTING)) .expect("the total is kept"); assert_eq!( - store - .resume("Show Title", Some(3)) - .expect("the store answers"), + store.resume(SERIES, Some(3)).expect("the store answers"), Some(kept(A_SITTING, false)) ); } @@ -270,7 +280,7 @@ mod tests { let (_home, mut store) = a_store(); store - .keep(&total("Show Title", Some(3), A_SITTING)) + .keep(&total(SERIES, Some(3), A_SITTING)) .expect("the total is kept"); let (title, episode, media, watched_ms, registered, updated_at): ( @@ -315,22 +325,20 @@ mod tests { // arrives here is the sum, and it is kept as it arrives. let (_home, mut store) = a_store(); store - .keep(&total("Show Title", Some(3), A_SITTING)) + .keep(&total(SERIES, Some(3), A_SITTING)) .expect("the total is kept"); store .keep(&Total { media: RawPath::from_bytes(b"/anime/[Other] Show Title - 03.mkv".to_vec()), at: an_instant() + Duration::from_secs(3600), - ..total("Show Title", Some(3), TWO_SITTINGS) + ..total(SERIES, Some(3), TWO_SITTINGS) }) .expect("the total is kept"); assert_eq!(rows(&store), 1); assert_eq!( - store - .resume("Show Title", Some(3)) - .expect("the store answers"), + store.resume(SERIES, Some(3)).expect("the store answers"), Some(Kept { media: RawPath::from_bytes(b"/anime/[Other] Show Title - 03.mkv".to_vec()), watched: TWO_SITTINGS, @@ -355,15 +363,15 @@ mod tests { let (_home, mut store) = a_store(); store - .keep(&total("A Film", None, A_SITTING)) + .keep(&total(FILM, None, A_SITTING)) .expect("the total is kept"); store - .keep(&total("A Film", None, TWO_SITTINGS)) + .keep(&total(FILM, None, TWO_SITTINGS)) .expect("the total is kept"); assert_eq!(rows(&store), 1); assert_eq!( - store.resume("A Film", None).expect("the store answers"), + store.resume(FILM, None).expect("the store answers"), Some(kept(TWO_SITTINGS, false)) ); } @@ -373,41 +381,26 @@ mod tests { let (_home, mut store) = a_store(); store - .keep(&total("Show Title", Some(3), A_SITTING)) + .keep(&total(SERIES, Some(3), A_SITTING)) .expect("the total is kept"); store - .keep(&total("Show Title", Some(4), TWO_SITTINGS)) + .keep(&total(SERIES, Some(4), TWO_SITTINGS)) .expect("the total is kept"); store - .keep(&total( - "Another Show", - Some(3), - Duration::from_millis(5_000), - )) + .keep(&total(ANOTHER, Some(3), Duration::from_millis(5_000))) .expect("the total is kept"); assert_eq!(rows(&store), 3); - let shows: u32 = store - .connection - .query_row("SELECT count(*) FROM shows", [], |row| row.get(0)) - .expect("the table is there"); - assert_eq!(shows, 2, "two episodes of one show are one show"); assert_eq!( - store - .resume("Show Title", Some(3)) - .expect("the store answers"), + store.resume(SERIES, Some(3)).expect("the store answers"), Some(kept(A_SITTING, false)) ); assert_eq!( - store - .resume("Show Title", Some(4)) - .expect("the store answers"), + store.resume(SERIES, Some(4)).expect("the store answers"), Some(kept(TWO_SITTINGS, false)) ); assert_eq!( - store - .resume("Another Show", Some(3)) - .expect("the store answers"), + store.resume(ANOTHER, Some(3)).expect("the store answers"), Some(kept(Duration::from_millis(5_000), false)) ); } @@ -416,36 +409,30 @@ mod tests { fn recording_an_episode_marks_its_total_registered() { let (_home, mut store) = a_store(); store - .keep(&total("Show Title", Some(3), TWO_SITTINGS)) + .keep(&total(SERIES, Some(3), TWO_SITTINGS)) .expect("the total is kept"); store - .keep(&total("Show Title", Some(4), A_SITTING)) + .keep(&total(SERIES, Some(4), A_SITTING)) .expect("the total is kept"); store - .keep(&total("Another Show", Some(3), A_SITTING)) + .keep(&total(ANOTHER, Some(3), A_SITTING)) .expect("the total is kept"); store - .record(&viewing("Show Title", Some(3))) + .record(&viewing(SERIES, Some(3))) .expect("recording cannot fail"); assert_eq!( - store - .resume("Show Title", Some(3)) - .expect("the store answers"), + store.resume(SERIES, Some(3)).expect("the store answers"), Some(kept(TWO_SITTINGS, true)) ); assert_eq!( - store - .resume("Show Title", Some(4)) - .expect("the store answers"), + store.resume(SERIES, Some(4)).expect("the store answers"), Some(kept(A_SITTING, false)), "another episode of the show did not register" ); assert_eq!( - store - .resume("Another Show", Some(3)) - .expect("the store answers"), + store.resume(ANOTHER, Some(3)).expect("the store answers"), Some(kept(A_SITTING, false)), "the same episode of another show did not register" ); @@ -455,15 +442,15 @@ mod tests { fn recording_a_film_marks_its_total_registered() { let (_home, mut store) = a_store(); store - .keep(&total("A Film", None, TWO_SITTINGS)) + .keep(&total(FILM, None, TWO_SITTINGS)) .expect("the total is kept"); store - .record(&viewing("A Film", None)) + .record(&viewing(FILM, None)) .expect("recording cannot fail"); assert_eq!( - store.resume("A Film", None).expect("the store answers"), + store.resume(FILM, None).expect("the store answers"), Some(kept(TWO_SITTINGS, true)) ); } @@ -475,14 +462,12 @@ mod tests { let (_home, mut store) = a_store(); store - .record(&viewing("Show Title", Some(3))) + .record(&viewing(SERIES, Some(3))) .expect("recording cannot fail"); assert_eq!(rows(&store), 0); assert_eq!( - store - .resume("Show Title", Some(3)) - .expect("the store answers"), + store.resume(SERIES, Some(3)).expect("the store answers"), None ); } @@ -493,7 +478,7 @@ mod tests { // did not happen must not leave an episode that looks registered. let (_home, mut store) = a_store(); store - .keep(&total("Show Title", Some(3), TWO_SITTINGS)) + .keep(&total(SERIES, Some(3), TWO_SITTINGS)) .expect("the total is kept"); store .connection @@ -503,13 +488,11 @@ mod tests { ) .expect("a trigger on this connection"); - let refused = store.record(&viewing("Show Title", Some(3))); + let refused = store.record(&viewing(SERIES, Some(3))); assert!(matches!(refused, Err(Error::Write(_))), "got {refused:?}"); assert_eq!( - store - .resume("Show Title", Some(3)) - .expect("the store answers"), + store.resume(SERIES, Some(3)).expect("the store answers"), Some(kept(TWO_SITTINGS, false)) ); } @@ -521,7 +504,7 @@ mod tests { // not outlive the mark. let (_home, mut store) = a_store(); store - .keep(&total("Show Title", Some(3), TWO_SITTINGS)) + .keep(&total(SERIES, Some(3), TWO_SITTINGS)) .expect("the total is kept"); store .connection @@ -531,7 +514,7 @@ mod tests { ) .expect("a trigger on this connection"); - let refused = store.record(&viewing("Show Title", Some(3))); + let refused = store.record(&viewing(SERIES, Some(3))); assert!(matches!(refused, Err(Error::Write(_))), "got {refused:?}"); let (episodes, operations): (u32, u32) = store @@ -553,20 +536,18 @@ mod tests { // total goes on growing. let (_home, mut store) = a_store(); store - .keep(&total("Show Title", Some(3), A_SITTING)) + .keep(&total(SERIES, Some(3), A_SITTING)) .expect("the total is kept"); store - .record(&viewing("Show Title", Some(3))) + .record(&viewing(SERIES, Some(3))) .expect("recording cannot fail"); store - .keep(&total("Show Title", Some(3), TWO_SITTINGS)) + .keep(&total(SERIES, Some(3), TWO_SITTINGS)) .expect("the total is kept"); assert_eq!( - store - .resume("Show Title", Some(3)) - .expect("the store answers"), + store.resume(SERIES, Some(3)).expect("the store answers"), Some(kept(TWO_SITTINGS, true)) ); } @@ -575,21 +556,17 @@ mod tests { fn closing_an_episode_that_registered_drops_its_total() { let (_home, mut store) = a_store(); store - .keep(&total("Show Title", Some(3), TWO_SITTINGS)) + .keep(&total(SERIES, Some(3), TWO_SITTINGS)) .expect("the total is kept"); store - .record(&viewing("Show Title", Some(3))) + .record(&viewing(SERIES, Some(3))) .expect("recording cannot fail"); assert_eq!(rows(&store), 1, "there is a total to drop"); - store - .close("Show Title", Some(3)) - .expect("the store closes"); + store.close(SERIES, Some(3)).expect("the store closes"); assert_eq!( - store - .resume("Show Title", Some(3)) - .expect("the store answers"), + store.resume(SERIES, Some(3)).expect("the store answers"), None ); assert_eq!(rows(&store), 0); @@ -601,17 +578,13 @@ mod tests { // they are only if the first two survive the file being closed. let (_home, mut store) = a_store(); store - .keep(&total("Show Title", Some(3), A_SITTING)) + .keep(&total(SERIES, Some(3), A_SITTING)) .expect("the total is kept"); - store - .close("Show Title", Some(3)) - .expect("the store closes"); + store.close(SERIES, Some(3)).expect("the store closes"); assert_eq!( - store - .resume("Show Title", Some(3)) - .expect("the store answers"), + store.resume(SERIES, Some(3)).expect("the store answers"), Some(kept(A_SITTING, false)) ); } @@ -619,39 +592,31 @@ mod tests { #[test] fn closing_an_episode_closes_that_episode_alone() { let (_home, mut store) = a_store(); - for (title, episode) in [ - ("Show Title", Some(3)), - ("Show Title", Some(4)), - ("Show Title", None), - ("Another Show", Some(3)), + for (show, episode) in [ + (SERIES, Some(3)), + (SERIES, Some(4)), + (SERIES, None), + (ANOTHER, Some(3)), ] { store - .keep(&total(title, episode, TWO_SITTINGS)) + .keep(&total(show, episode, TWO_SITTINGS)) .expect("the total is kept"); store - .record(&viewing(title, episode)) + .record(&viewing(show, episode)) .expect("recording cannot fail"); } - store - .close("Show Title", Some(3)) - .expect("the store closes"); + store.close(SERIES, Some(3)).expect("the store closes"); assert_eq!( - store - .resume("Show Title", Some(3)) - .expect("the store answers"), + store.resume(SERIES, Some(3)).expect("the store answers"), None ); - for (title, episode) in [ - ("Show Title", Some(4)), - ("Show Title", None), - ("Another Show", Some(3)), - ] { + for (show, episode) in [(SERIES, Some(4)), (SERIES, None), (ANOTHER, Some(3))] { assert_eq!( - store.resume(title, episode).expect("the store answers"), + store.resume(show, episode).expect("the store answers"), Some(kept(TWO_SITTINGS, true)), - "{title} {episode:?} was closed with it" + "{show:?} {episode:?} was closed with it" ); } } @@ -660,53 +625,46 @@ mod tests { fn closing_a_film_that_registered_drops_its_total() { let (_home, mut store) = a_store(); store - .keep(&total("A Film", None, TWO_SITTINGS)) + .keep(&total(FILM, None, TWO_SITTINGS)) .expect("the total is kept"); store - .record(&viewing("A Film", None)) + .record(&viewing(FILM, None)) .expect("recording cannot fail"); assert_eq!(rows(&store), 1, "there is a total to drop"); - store.close("A Film", None).expect("the store closes"); + store.close(FILM, None).expect("the store closes"); - assert_eq!( - store.resume("A Film", None).expect("the store answers"), - None - ); + assert_eq!(store.resume(FILM, None).expect("the store answers"), None); } #[test] fn an_episode_opened_again_after_it_closed_begins_from_nothing() { let (_home, mut store) = a_store(); store - .keep(&total("Show Title", Some(3), TWO_SITTINGS)) + .keep(&total(SERIES, Some(3), TWO_SITTINGS)) .expect("the total is kept"); store - .record(&viewing("Show Title", Some(3))) + .record(&viewing(SERIES, Some(3))) .expect("recording cannot fail"); - store - .close("Show Title", Some(3)) - .expect("the store closes"); + store.close(SERIES, Some(3)).expect("the store closes"); store - .keep(&total("Show Title", Some(3), Duration::from_millis(5_000))) + .keep(&total(SERIES, Some(3), Duration::from_millis(5_000))) .expect("the total is kept"); assert_eq!( - store - .resume("Show Title", Some(3)) - .expect("the store answers"), + store.resume(SERIES, Some(3)).expect("the store answers"), Some(kept(Duration::from_millis(5_000), false)) ); } #[test] fn closing_an_episode_never_opened_is_nothing() { + // Whether or not the store has filed the show. let (_home, store) = a_store(); - store - .close("Show Title", Some(3)) - .expect("the store closes"); + store.close(SERIES, Some(3)).expect("the store closes"); + store.close(ShowId(42), Some(3)).expect("the store closes"); assert_eq!(rows(&store), 0); } @@ -716,17 +674,11 @@ mod tests { let (_home, mut store) = a_store(); store - .keep(&total( - "Show Title", - Some(3), - Duration::from_nanos(1_999_999_999), - )) + .keep(&total(SERIES, Some(3), Duration::from_nanos(1_999_999_999))) .expect("the total is kept"); assert_eq!( - store - .resume("Show Title", Some(3)) - .expect("the store answers"), + store.resume(SERIES, Some(3)).expect("the store answers"), Some(kept(Duration::from_millis(1_999), false)) ); } @@ -736,7 +688,7 @@ mod tests { let (_home, mut store) = a_store(); store - .keep(&total("Show Title", Some(3), Duration::MAX)) + .keep(&total(SERIES, Some(3), Duration::MAX)) .expect("the total is kept"); let watched_ms: i64 = store @@ -754,12 +706,12 @@ mod tests { store .keep(&Total { media: RawPath::from_bytes(shift_jis.to_vec()), - ..total("Show Title", Some(3), A_SITTING) + ..total(SERIES, Some(3), A_SITTING) }) .expect("the total is kept"); let resumed = store - .resume("Show Title", Some(3)) + .resume(SERIES, Some(3)) .expect("the store answers") .expect("the total is there"); assert_eq!(resumed.media.as_bytes(), shift_jis); @@ -769,14 +721,14 @@ mod tests { fn media_that_is_not_a_path_as_the_store_writes_one_is_refused_and_the_row_is_named() { let (_home, mut store) = a_store(); store - .keep(&total("Show Title", Some(3), A_SITTING)) + .keep(&total(SERIES, Some(3), A_SITTING)) .expect("the total is kept"); store .connection .execute("UPDATE watching SET media = '/anime/100% - 03.mkv'", []) .expect("the row is edited by hand"); - let refused = store.resume("Show Title", Some(3)); + let refused = store.resume(SERIES, Some(3)); assert!( matches!( @@ -793,14 +745,14 @@ mod tests { // total anybody watched. let (_home, mut store) = a_store(); store - .keep(&total("Show Title", Some(3), A_SITTING)) + .keep(&total(SERIES, Some(3), A_SITTING)) .expect("the total is kept"); store .connection .execute("UPDATE watching SET watched_ms = -5", []) .expect("the row is edited by hand"); - let refused = store.resume("Show Title", Some(3)); + let refused = store.resume(SERIES, Some(3)); assert!( matches!( @@ -820,7 +772,7 @@ mod tests { let refused = store.keep(&Total { at: UNIX_EPOCH - Duration::from_secs(1), - ..total("Show Title", Some(3), A_SITTING) + ..total(SERIES, Some(3), A_SITTING) }); assert!( @@ -828,24 +780,19 @@ mod tests { "got {refused:?}" ); assert_eq!(rows(&store), 0); - let shows: u32 = store - .connection - .query_row("SELECT count(*) FROM shows", [], |row| row.get(0)) - .expect("the table is there"); - assert_eq!(shows, 0); } #[test] fn a_total_is_still_there_after_the_store_is_opened_again() { let (home, mut store) = a_store(); store - .keep(&total("Show Title", Some(3), A_SITTING)) + .keep(&total(SERIES, Some(3), A_SITTING)) .expect("the total is kept"); store - .keep(&total("Show Title", Some(4), TWO_SITTINGS)) + .keep(&total(SERIES, Some(4), TWO_SITTINGS)) .expect("the total is kept"); store - .record(&viewing("Show Title", Some(4))) + .record(&viewing(SERIES, Some(4))) .expect("recording cannot fail"); drop(store); @@ -853,42 +800,51 @@ mod tests { Store::open(&home.path().join("benshi.db")).expect("the database opens again"); assert_eq!( - reopened - .resume("Show Title", Some(3)) - .expect("the store answers"), + reopened.resume(SERIES, Some(3)).expect("the store answers"), Some(kept(A_SITTING, false)) ); assert_eq!( - reopened - .resume("Show Title", Some(4)) - .expect("the store answers"), + reopened.resume(SERIES, Some(4)).expect("the store answers"), Some(kept(TWO_SITTINGS, true)), "and so is the mark on the one that registered" ); } #[test] - fn a_total_that_cannot_be_kept_leaves_no_show_behind() { - // The show is filed before the total is written, so the two are one - // transaction: a show with nothing pointing at it is a title the - // store never heard anything about. + fn a_total_of_a_show_that_is_not_filed_is_refused() { let (_home, mut store) = a_store(); - store - .connection - .execute_batch( - "CREATE TEMP TRIGGER refuse_totals BEFORE INSERT ON watching \ - BEGIN SELECT RAISE(ABORT, 'refused by the test'); END", - ) - .expect("a trigger on this connection"); - let refused = store.keep(&total("Show Title", Some(3), A_SITTING)); + let refused = store.keep(&total(ShowId(42), Some(3), A_SITTING)); assert!(matches!(refused, Err(Error::Write(_))), "got {refused:?}"); - let shows: u32 = store - .connection - .query_row("SELECT count(*) FROM shows", [], |row| row.get(0)) - .expect("the table is there"); - assert_eq!(shows, 0); assert_eq!(rows(&store), 0); } + + #[test] + fn two_shows_of_one_title_keep_their_totals_apart() { + // A title does not name an entry, so the store reaches a total by + // the show and never by its title. + let (_home, mut store) = a_store(); + let namesake = store.new_show("Show Title").expect("the show is filed"); + store + .keep(&total(SERIES, Some(3), TWO_SITTINGS)) + .expect("the total is kept"); + store + .keep(&total(namesake, Some(3), A_SITTING)) + .expect("the total is kept"); + store + .record(&viewing(SERIES, Some(3))) + .expect("recording cannot fail"); + + store.close(SERIES, Some(3)).expect("the store closes"); + + assert_eq!( + store.resume(SERIES, Some(3)).expect("the store answers"), + None + ); + assert_eq!( + store.resume(namesake, Some(3)).expect("the store answers"), + Some(kept(A_SITTING, false)) + ); + } }