//! Catalog rows, grouped into what a poster wall shows. //! //! The catalog is flat — one row per file, with whatever [`crate::naming`] made //! of its path. A UI wants the other shape: titles, each with seasons, each with //! episodes, and the one episode the viewer should land on when they click. That //! transform is here rather than in the app because it is pure, table-testable, //! and the CLI wants the same view of the same data. //! //! Two decisions worth stating: //! //! * **Titles merge across shares.** Two shares holding the same series are one //! poster, not two. The grouping key is the normalised title, so a share added //! later slots into the title that is already there. //! * **Watch state is joined in, not looked up per draw.** A grid render must //! not become one SQLite round-trip per tile — see the negative-caching trap //! in `docs/DEVELOPMENT.md`, which is the same lesson one layer up. use std::collections::HashMap; use crate::catalog::{CatalogNode, WatchState}; /// A file has been watched once playback passed this fraction of it. Short of /// 1.0 because nobody sits through the credits, and a title that never reaches /// "finished" keeps offering an episode the viewer is done with. pub const WATCHED_FRACTION: f64 = 0.9; /// Playback below this many seconds is not a resume point — it is a file the /// viewer opened and immediately left. pub const RESUME_FLOOR_SECS: f64 = 30.0; /// Whether a title is a series or a one-off. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] pub enum TitleKind { Series, Film, } /// One playable file, with where the viewer left off in it. #[derive(Debug, Clone, PartialEq)] pub struct Episode { pub node: CatalogNode, pub watch: Option, } impl Episode { /// `S01E02`, `#014`, or nothing when the name numbers nothing. pub fn numbering(&self) -> Option { match (self.node.parsed.season, self.node.parsed.episode) { (Some(season), Some(episode)) => Some(format!("S{season:02}E{episode:02}")), (None, Some(episode)) => Some(format!("#{episode:03}")), _ => None, } } /// What to put on the row: the episode's own name when the filename stated /// one, else the numbering, else the filename. pub fn label(&self) -> String { if let Some(title) = &self.node.parsed.episode_title { return title.clone(); } self.numbering().unwrap_or_else(|| self.node.name.clone()) } /// What to put on a row that already shows the numbering beside it: the /// episode's own name when the filename stated one, else the filename. /// /// Deliberately not [`Self::label`] — that falls back to the numbering, /// which on an episode row prints it twice. pub fn detail(&self) -> &str { self.node .parsed .episode_title .as_deref() .unwrap_or(&self.node.name) } /// Fraction of the file played, when both a position and a duration are /// known. `None` for a file that was never opened. pub fn progress(&self) -> Option { let watch = self.watch?; let duration = watch.duration_secs?; if duration <= 0.0 { return None; } Some((watch.position_secs / duration).clamp(0.0, 1.0)) } /// Whether this counts as seen. Either the flag was set explicitly, or /// playback got far enough that setting it is what the viewer meant. pub fn is_watched(&self) -> bool { self.watch.is_some_and(|watch| watch.watched) || self.progress().is_some_and(|p| p >= WATCHED_FRACTION) } /// Where to resume, or `None` to start from the beginning. A file barely /// started, or effectively finished, starts over. pub fn resume_at(&self) -> Option { let watch = self.watch?; if self.is_watched() || watch.position_secs < RESUME_FLOOR_SECS { return None; } Some(watch.position_secs) } /// When playback last touched this file. `0` for one never opened, so it /// sorts below everything that was. pub fn last_played(&self) -> i64 { self.watch.map_or(0, |watch| watch.updated_at) } } /// The episodes of one season, or the unnumbered pile when a library uses /// absolute numbering. #[derive(Debug, Clone, PartialEq)] pub struct Season { /// `None` when the names state no season. That is not season 1: an /// absolute-numbered anime and a film both land here. pub number: Option, pub episodes: Vec, } impl Season { pub fn label(&self) -> String { match self.number { Some(number) => format!("Season {number}"), None => "Episodes".to_string(), } } } /// A series or a film: everything under one title, across every share. #[derive(Debug, Clone, PartialEq)] pub struct Title { /// Stable identity for the UI to route on and for caches to key on. pub key: String, /// What to print. pub name: String, pub year: Option, pub kind: TitleKind, pub seasons: Vec, /// Every share contributing files, in first-seen order. pub share_ids: Vec, } impl Title { /// Every episode, in display order. pub fn episodes(&self) -> impl Iterator { self.seasons .iter() .flat_map(|season| season.episodes.iter()) } pub fn episode_count(&self) -> usize { self.seasons .iter() .map(|season| season.episodes.len()) .sum() } pub fn watched_count(&self) -> usize { self.episodes().filter(|e| e.is_watched()).count() } /// Whether the files themselves say this is episodic, rather than /// [`Title::kind`] having been inferred from how many of them there are. /// /// The difference matters to whoever is matching this against a provider: a /// folder of three unnumbered films is a `Series` here purely because it /// holds three files, and it must not be scored down against the film /// entries it is actually made of. Numbering is a statement; a file count /// is a guess. pub fn states_its_numbering(&self) -> bool { self.episodes() .any(|episode| episode.node.parsed.is_episode() || episode.node.parsed.season.is_some()) } /// The file to pull a poster thumbnail from: the first episode there is. /// Proton renders a thumbnail per file, so any of them is a frame of the /// right show; the first is the one most likely already cached. pub fn poster_node(&self) -> Option<&CatalogNode> { self.episodes().next().map(|episode| &episode.node) } /// The episode that is part-watched, most recently played first. pub fn resume(&self) -> Option<&Episode> { self.episodes() .filter(|episode| episode.resume_at().is_some()) .max_by_key(|episode| episode.last_played()) } /// What Continue watching offers for this title: the part-watched episode, /// or the one after the episode finished last. /// /// Read off whichever episode was touched last. Finishing an episode is /// the most ordinary moment to want the next, and a shelf that dropped the /// show right then was missing its point. An episode reset to the start — /// which is what leaving Continue watching does — says the viewer is done /// with it, so the title is left out. One opened for a few seconds says /// nothing either way and is passed over. pub fn continue_with(&self) -> Option<&Episode> { let latest = self .episodes() .filter(|episode| { episode.watch.is_some_and(|watch| { watch.position_secs == 0.0 || watch.position_secs >= RESUME_FLOOR_SECS || episode.is_watched() }) }) .max_by_key(|episode| episode.last_played())?; if latest.resume_at().is_some() { return Some(latest); } if !latest.is_watched() { return None; } let at = self.position_of(&latest.node.share_id, &latest.node.link_id)?; self.episodes() .skip(at + 1) .find(|episode| !episode.is_watched()) } /// What a click on the poster should play: what Continue watching offers, /// else whatever is part-watched, else the first unwatched episode, else /// the first episode. pub fn next_up(&self) -> Option<&Episode> { self.continue_with() .or_else(|| self.resume()) .or_else(|| self.episodes().find(|episode| !episode.is_watched())) .or_else(|| self.episodes().next()) } /// Where a file sits in display order, if it is under this title at all. /// /// Identity is `(share_id, link_id)`, not the name: a title merged across /// two shares can hold two files called `E01.mkv`, and "play the next one" /// must not be able to jump between shares by accident. pub fn position_of(&self, share_id: &str, link_id: &str) -> Option { self.episodes().position(|episode| { episode.node.share_id == share_id && episode.node.link_id == link_id }) } /// What comes after a file, in display order. `None` at the end of the /// title, and for a file that is not part of it. pub fn following(&self, share_id: &str, link_id: &str) -> Option<&Episode> { let position = self.position_of(share_id, link_id)?; self.episodes().nth(position + 1) } /// What comes before it. pub fn preceding(&self, share_id: &str, link_id: &str) -> Option<&Episode> { let position = self.position_of(share_id, link_id)?; self.episodes().nth(position.checked_sub(1)?) } /// When this title was last played at all. pub fn last_played(&self) -> i64 { self.episodes().map(Episode::last_played).max().unwrap_or(0) } } /// Every title the catalog holds. #[derive(Debug, Clone, Default, PartialEq)] pub struct Library { pub titles: Vec, } impl Library { /// Group catalog rows into titles, joining in watch state keyed by /// `(share_id, link_id)`. /// /// Folder rows are ignored: a poster wall shows playable files, and the /// folder structure has already been consumed by the parser that produced /// these titles. pub fn build(nodes: Vec<CatalogNode>, watch: &HashMap<(String, String), WatchState>) -> Self { // Insertion-ordered grouping: key -> index into `titles`, so the output // is deterministic before it is sorted. let mut index: HashMap<String, usize> = HashMap::new(); let mut titles: Vec<Title> = Vec::new(); for node in nodes { if node.is_folder { continue; } let key = title_key(&node.parsed.title); let position = match index.get(&key) { Some(position) => *position, None => { titles.push(Title { key: key.clone(), name: node.parsed.title.clone(), year: node.parsed.year, // Provisional: settled once every file is in. kind: TitleKind::Film, seasons: Vec::new(), share_ids: Vec::new(), }); index.insert(key, titles.len() - 1); titles.len() - 1 } }; let title = &mut titles[position]; if title.year.is_none() { title.year = node.parsed.year; } if !title.share_ids.contains(&node.share_id) { title.share_ids.push(node.share_id.clone()); } let watch_state = watch .get(&(node.share_id.clone(), node.link_id.clone())) .copied(); let season_number = node.parsed.season; let episode = Episode { node, watch: watch_state, }; match title .seasons .iter_mut() .find(|season| season.number == season_number) { Some(season) => season.episodes.push(episode), None => title.seasons.push(Season { number: season_number, episodes: vec![episode], }), } } for title in &mut titles { // A season number, an episode number or more than one file all mean // series. A lone unnumbered file is a film. let numbered = title.states_its_numbering(); title.kind = if numbered || title.episode_count() > 1 { TitleKind::Series } else { TitleKind::Film }; // Unnumbered seasons last: they are the specials pile, not season 0. title .seasons .sort_by_key(|season| (season.number.is_none(), season.number)); for season in &mut title.seasons { season.episodes.sort_by(|left, right| { left.node .parsed .episode .cmp(&right.node.parsed.episode) .then_with(|| left.node.name.cmp(&right.node.name)) }); } } titles.sort_by_key(|title| sort_name(&title.name)); Self { titles } } pub fn is_empty(&self) -> bool { self.titles.is_empty() } /// Record a new watch state for one file in place. Returns whether the /// file is in the library. /// /// What a save during playback or a "mark watched" needs: rebuilding the /// whole library from the catalog for one changed row re-reads and re-sorts /// every file, and on a large share that is a visible pause. pub fn set_watch(&mut self, share_id: &str, link_id: &str, state: WatchState) -> bool { let episode = self .titles .iter_mut() .flat_map(|title| title.seasons.iter_mut()) .flat_map(|season| season.episodes.iter_mut()) .find(|episode| episode.node.share_id == share_id && episode.node.link_id == link_id); match episode { Some(episode) => { episode.watch = Some(state); true } None => false, } } pub fn get(&self, key: &str) -> Option<&Title> { self.titles.iter().find(|title| title.key == key) } /// Titles with something to go on with — see [`Title::continue_with`] — /// most recent first. The top row of the library. pub fn continue_watching(&self) -> Vec<&Title> { let mut titles: Vec<&Title> = self .titles .iter() .filter(|title| title.continue_with().is_some()) .collect(); titles.sort_by_key(|title| std::cmp::Reverse(title.last_played())); titles } /// Every episode that has been started or finished, most recently /// touched first. The history page. /// /// An episode marked unwatched keeps a row with position zero, which is /// "never played" for every purpose here, so it is left out. pub fn history(&self) -> Vec<(&Title, &Episode)> { let mut played: Vec<(&Title, &Episode)> = self .titles .iter() .flat_map(|title| title.episodes().map(move |episode| (title, episode))) .filter(|(_, episode)| { episode .watch .is_some_and(|watch| watch.watched || watch.position_secs > 0.0) }) .collect(); played.sort_by_key(|(_, episode)| std::cmp::Reverse(episode.last_played())); played } /// When each title last gained a file, Unix seconds — the newest /// [`crate::catalog::Catalog::first_seen`] among its episodes. `0` for a /// title whose files were all there before that was recorded. pub fn added_at(&self, first_seen: &HashMap<(String, String), i64>) -> HashMap<String, i64> { self.titles .iter() .map(|title| { let newest = title .episodes() .filter_map(|episode| { first_seen .get(&(episode.node.share_id.clone(), episode.node.link_id.clone())) }) .copied() .max() .unwrap_or(0); (title.key.clone(), newest) }) .collect() } /// Substring match over the display name and every filename under it, so /// searching for a release group or an episode name finds its title. pub fn search(&self, query: &str) -> Vec<&Title> { let needle = query.trim().to_lowercase(); if needle.is_empty() { return self.titles.iter().collect(); } self.titles .iter() .filter(|title| { title.name.to_lowercase().contains(&needle) || title .episodes() .any(|episode| episode.node.name.to_lowercase().contains(&needle)) }) .collect() } } /// The grouping key: case- and punctuation-insensitive, so `Fullmetal /// Alchemist: Brotherhood` and `Fullmetal Alchemist Brotherhood` are one title. /// /// An apostrophe is the one punctuation mark that closes up rather than /// separating: `Heaven's Feel` keys as `heavens feel`, which is what a folder /// that had to give its apostrophe up to the filesystem is called. Splitting it /// into `heaven s feel` instead files the two apart and, worse, costs the /// matcher a real match on the difference. pub fn title_key(title: &str) -> String { let mut key = String::with_capacity(title.len()); let mut pending_space = false; for character in title.chars() { if matches!(character, '\'' | '\u{2019}' | '\u{02bc}') { continue; } if character.is_alphanumeric() { if pending_space && !key.is_empty() { key.push(' '); } pending_space = false; key.extend(character.to_lowercase()); } else { pending_space = true; } } key } /// Sort key for the shelf: case-insensitive, and a leading article ignored, so /// `The Expanse` files under E where a viewer looks for it. pub fn sort_name(name: &str) -> String { let lower = name.to_lowercase(); for article in ["the ", "a ", "an "] { if let Some(rest) = lower.strip_prefix(article) { return rest.to_string(); } } lower } #[cfg(test)] mod tests { use super::*; use crate::naming::ParsedName; fn node( share: &str, link: &str, name: &str, season: Option<u32>, episode: Option<u32>, ) -> CatalogNode { CatalogNode { share_id: share.into(), link_id: link.into(), volume_id: "vol".into(), parent_link_id: None, name: name.into(), is_folder: false, media_type: Some("video/x-matroska".into()), size: Some(1024), active_revision_id: Some("rev".into()), parsed: ParsedName { title: name.split(" - ").next().unwrap_or(name).to_string(), season, episode, year: None, episode_title: None, }, } } #[test] fn the_next_episode_is_the_next_one_in_display_order_across_seasons() { let library = Library::build( vec![ node("s1", "a", "Show - S01E01.mkv", Some(1), Some(1)), node("s1", "b", "Show - S01E02.mkv", Some(1), Some(2)), node("s1", "c", "Show - S02E01.mkv", Some(2), Some(1)), ], &HashMap::new(), ); let title = &library.titles[0]; assert_eq!( title.following("s1", "a").map(|e| e.node.link_id.clone()), Some("b".to_string()) ); // Autoplay has to walk off the end of a season into the next one, or it // stops two thirds of the way through a series for no reason a viewer // can see. assert_eq!( title.following("s1", "b").map(|e| e.node.link_id.clone()), Some("c".to_string()) ); assert_eq!(title.following("s1", "c"), None); assert_eq!( title.preceding("s1", "c").map(|e| e.node.link_id.clone()), Some("b".to_string()) ); assert_eq!(title.preceding("s1", "a"), None); } #[test] fn a_file_from_another_share_is_not_this_titles_neighbour() { let library = Library::build( vec![ node("s1", "a", "Show - S01E01.mkv", Some(1), Some(1)), node("s1", "b", "Show - S01E02.mkv", Some(1), Some(2)), ], &HashMap::new(), ); let title = &library.titles[0]; // Same link id, different share: two shares of the same series really // do collide on names, and "next" must not cross over on a false match. assert_eq!(title.position_of("other", "a"), None); assert_eq!(title.following("other", "a"), None); } fn watch(position: f64, duration: f64, watched: bool, at: i64) -> WatchState { WatchState { position_secs: position, duration_secs: Some(duration), watched, updated_at: at, } } #[test] fn history_is_every_played_episode_newest_first_across_titles() { let watch_state = HashMap::from([ ( ("s".to_string(), "a".to_string()), watch(600.0, 1400.0, false, 10), ), ( ("s".to_string(), "c".to_string()), watch(1400.0, 1400.0, true, 30), ), // Marked unwatched again: a row at zero, which is not a play. ( ("s".to_string(), "b".to_string()), watch(0.0, 1400.0, false, 40), ), ]); let library = Library::build( vec![ node("s", "a", "Show - S01E01", Some(1), Some(1)), node("s", "b", "Show - S01E02", Some(1), Some(2)), node("s", "c", "Film", None, None), node("s", "d", "Other - S01E01", Some(1), Some(1)), ], &watch_state, ); let order: Vec<&str> = library .history() .iter() .map(|(_, episode)| episode.node.link_id.as_str()) .collect(); assert_eq!(order, ["c", "a"]); } #[test] fn groups_episodes_into_seasons() { let nodes = vec![ node("s", "1", "Frieren - S01E02", Some(1), Some(2)), node("s", "2", "Frieren - S01E01", Some(1), Some(1)), node("s", "3", "Frieren - S02E01", Some(2), Some(1)), ]; let library = Library::build(nodes, &HashMap::new()); assert_eq!(library.titles.len(), 1); let title = &library.titles[0]; assert_eq!(title.kind, TitleKind::Series); assert_eq!(title.seasons.len(), 2); assert_eq!(title.seasons[0].number, Some(1)); // Sorted by episode number, not by insertion order. assert_eq!(title.seasons[0].episodes[0].node.link_id, "2"); assert_eq!(title.episode_count(), 3); } #[test] fn a_row_detail_never_repeats_the_numbering() { let mut file = node("s", "1", "Show - S01E01", Some(1), Some(1)); file.name = "[Group] Show - 01 (1080p).mkv".into(); let library = Library::build(vec![file], &HashMap::new()); let episode = &library.titles[0].seasons[0].episodes[0]; // No episode name in the filename: the row shows the filename, while // the card — which has no numbering column — shows the numbering. assert_eq!(episode.detail(), "[Group] Show - 01 (1080p).mkv"); assert_eq!(episode.label(), "S01E01"); } #[test] fn a_stated_episode_name_wins_on_both() { let mut file = node("s", "1", "Show - S01E01", Some(1), Some(1)); file.parsed.episode_title = Some("Mother and Children".into()); let library = Library::build(vec![file], &HashMap::new()); let episode = &library.titles[0].seasons[0].episodes[0]; assert_eq!(episode.detail(), "Mother and Children"); assert_eq!(episode.label(), "Mother and Children"); } #[test] fn unnumbered_seasons_sort_last() { let nodes = vec![ node("s", "1", "Show - OVA", None, None), node("s", "2", "Show - S01E01", Some(1), Some(1)), ]; let library = Library::build(nodes, &HashMap::new()); let seasons = &library.titles[0].seasons; assert_eq!(seasons[0].number, Some(1)); assert_eq!(seasons[1].number, None); assert_eq!(seasons[1].label(), "Episodes"); } #[test] fn a_lone_unnumbered_file_is_a_film() { let nodes = vec![node("s", "1", "Spirited Away", None, None)]; let library = Library::build(nodes, &HashMap::new()); assert_eq!(library.titles[0].kind, TitleKind::Film); } #[test] fn titles_merge_across_shares() { let nodes = vec![ node("a", "1", "Frieren - S01E01", Some(1), Some(1)), node("b", "2", "frieren! - S01E02", Some(1), Some(2)), ]; let library = Library::build(nodes, &HashMap::new()); assert_eq!(library.titles.len(), 1); assert_eq!(library.titles[0].share_ids, vec!["a", "b"]); } #[test] fn next_up_prefers_a_resume_over_an_unwatched_episode() { let nodes = vec![ node("s", "1", "Show - S01E01", Some(1), Some(1)), node("s", "2", "Show - S01E02", Some(1), Some(2)), node("s", "3", "Show - S01E03", Some(1), Some(3)), ]; let mut states = HashMap::new(); // Episode 1 finished, episode 2 half-watched. states.insert(("s".into(), "1".into()), watch(1400.0, 1440.0, false, 10)); states.insert(("s".into(), "2".into()), watch(700.0, 1440.0, false, 20)); let library = Library::build(nodes, &states); let title = &library.titles[0]; assert_eq!(title.watched_count(), 1); assert_eq!(title.next_up().unwrap().node.link_id, "2"); assert_eq!(title.resume().unwrap().resume_at(), Some(700.0)); assert_eq!(library.continue_watching().len(), 1); } fn three_episodes() -> Vec<CatalogNode> { vec![ node("s", "1", "Show - S01E01", Some(1), Some(1)), node("s", "2", "Show - S01E02", Some(1), Some(2)), node("s", "3", "Show - S01E03", Some(1), Some(3)), ] } #[test] fn a_finished_episode_puts_the_next_one_in_continue_watching() { let mut states = HashMap::new(); states.insert(("s".into(), "1".into()), watch(1440.0, 1440.0, true, 10)); let library = Library::build(three_episodes(), &states); let next = library.titles[0].continue_with().unwrap(); assert_eq!(next.node.link_id, "2"); assert_eq!(next.resume_at(), None); assert_eq!(library.continue_watching().len(), 1); } #[test] fn a_finished_last_episode_leaves_continue_watching() { let mut states = HashMap::new(); for link in ["1", "2", "3"] { states.insert(("s".into(), link.into()), watch(1440.0, 1440.0, true, 10)); } let library = Library::build(three_episodes(), &states); assert!(library.continue_watching().is_empty()); } /// Leaving Continue watching resets the episode to the start. Offering the /// one after the last finished episode instead would bring the title /// straight back. #[test] fn an_episode_reset_to_the_start_keeps_its_title_out_of_continue_watching() { let mut states = HashMap::new(); states.insert(("s".into(), "1".into()), watch(1440.0, 1440.0, true, 10)); states.insert(("s".into(), "2".into()), watch(0.0, 1440.0, false, 20)); let library = Library::build(three_episodes(), &states); assert!(library.continue_watching().is_empty()); } #[test] fn a_few_seconds_of_the_next_episode_do_not_hide_the_one_part_watched() { let mut states = HashMap::new(); states.insert(("s".into(), "1".into()), watch(700.0, 1440.0, false, 10)); states.insert(("s".into(), "2".into()), watch(4.0, 1440.0, false, 20)); let library = Library::build(three_episodes(), &states); assert_eq!(library.titles[0].continue_with().unwrap().node.link_id, "1"); } #[test] fn the_episode_after_the_finished_one_is_offered_past_an_older_unwatched_one() { let mut states = HashMap::new(); states.insert(("s".into(), "2".into()), watch(1440.0, 1440.0, true, 10)); let library = Library::build(three_episodes(), &states); assert_eq!(library.titles[0].continue_with().unwrap().node.link_id, "3"); } #[test] fn a_watch_state_set_in_place_matches_a_rebuild() { let nodes = vec![ node("s", "1", "Show - S01E01", Some(1), Some(1)), node("s", "2", "Show - S01E02", Some(1), Some(2)), ]; let mut states = HashMap::new(); let mut library = Library::build(nodes.clone(), &states); let state = watch(700.0, 1440.0, false, 20); assert!(library.set_watch("s", "2", state)); states.insert(("s".into(), "2".into()), state); assert_eq!(library, Library::build(nodes, &states)); assert!(!library.set_watch("s", "9", state)); } #[test] fn next_up_falls_through_to_the_first_unwatched() { let nodes = vec![ node("s", "1", "Show - S01E01", Some(1), Some(1)), node("s", "2", "Show - S01E02", Some(1), Some(2)), ]; let mut states = HashMap::new(); states.insert(("s".into(), "1".into()), watch(1440.0, 1440.0, true, 10)); let library = Library::build(nodes, &states); assert_eq!(library.titles[0].next_up().unwrap().node.link_id, "2"); } #[test] fn a_barely_started_file_starts_over() { let nodes = vec![node("s", "1", "Show - S01E01", Some(1), Some(1))]; let mut states = HashMap::new(); states.insert(("s".into(), "1".into()), watch(4.0, 1440.0, false, 10)); let library = Library::build(nodes, &states); let episode = &library.titles[0].seasons[0].episodes[0]; assert_eq!(episode.resume_at(), None); assert!(library.continue_watching().is_empty()); } #[test] fn a_nearly_finished_file_counts_as_watched() { let nodes = vec![node("s", "1", "Show - S01E01", Some(1), Some(1))]; let mut states = HashMap::new(); states.insert(("s".into(), "1".into()), watch(1400.0, 1440.0, false, 10)); let library = Library::build(nodes, &states); assert!(library.titles[0].seasons[0].episodes[0].is_watched()); } #[test] fn search_matches_filenames_as_well_as_titles() { let mut first = node("s", "1", "Frieren - S01E01", Some(1), Some(1)); first.name = "[SubsPlease] Frieren - 01 (1080p).mkv".into(); let library = Library::build(vec![first], &HashMap::new()); assert_eq!(library.search("subsplease").len(), 1); assert_eq!(library.search("frieren").len(), 1); assert_eq!(library.search("bebop").len(), 0); assert_eq!(library.search(" ").len(), 1); } #[test] fn sorting_ignores_a_leading_article() { let nodes = vec![ node("s", "1", "Zoo", None, Some(1)), node("s", "2", "The Expanse", None, Some(1)), node("s", "3", "Akira", None, Some(1)), ]; let library = Library::build(nodes, &HashMap::new()); let names: Vec<&str> = library.titles.iter().map(|t| t.name.as_str()).collect(); assert_eq!(names, vec!["Akira", "The Expanse", "Zoo"]); } #[test] fn title_key_ignores_punctuation_and_case() { assert_eq!( title_key("Fullmetal Alchemist: Brotherhood"), title_key("fullmetal alchemist brotherhood") ); assert_eq!(title_key("K-On!!"), "k on"); } /// An apostrophe closes up rather than separating, and a name written to /// disk has usually lost it — the two have to key alike, or a folder called /// `Heavens Feel` is a different title to the one the provider has. #[test] fn an_apostrophe_keys_the_same_as_its_absence() { assert_eq!(title_key("Heaven's Feel"), "heavens feel"); assert_eq!(title_key("Heaven\u{2019}s Feel"), title_key("Heavens Feel")); assert_eq!(title_key("Devils' Line"), title_key("Devils Line")); } #[test] fn folders_are_not_episodes() { let mut folder = node("s", "1", "Show", None, None); folder.is_folder = true; let library = Library::build(vec![folder], &HashMap::new()); assert!(library.is_empty()); } }