diff --git a/README.md b/README.md index a33f4a6..08d7de9 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ against the SDK's own range download. | Transcoding | None. mpv demuxes and decodes natively. | | UI | egui/eframe on glow; mpv renders into the same GL context. | | Themes | The shipped near-black palette, Catppuccin Latte, Frappé, Macchiato and Mocha, and a Persona 5 one — black, white and one loud red, which is what the Red accent is there for — each with a choice of nine accents, drawn as a gradient unless you turn that off. Picked on the Settings page, applied without a restart. | -| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses, fanart and per-episode titles and synopses (for AniList, from ani.zip, which is sent the matched ids); a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. See [docs/METADATA.md](docs/METADATA.md). | +| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses, fanart and per-episode titles and synopses (for AniList, from ani.zip, which is sent the matched ids); a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. The library sorts by name, watch, add or air date, rating and popularity, folds a franchise into one tile, and shelves titles by director, studio and genre. See [docs/METADATA.md](docs/METADATA.md). | | Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. A native Android 12+ client is available featuring a Material 3 UI and immersive fullscreen playback. | ## Building diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index 3168e03..d3f4fd2 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -264,6 +264,10 @@ pub struct MatchRecord { pub genres: Vec, pub episode_count: Option, pub external_url: Option, + /// `TitleDetails` as JSON, opaque to Kotlin: carried so that a match + /// picked by hand keeps the studios, relations and dates search found, + /// rather than losing them on the way back across the bridge. + pub details: Option, } #[derive(Debug, Clone, uniffi::Record)] @@ -1180,8 +1184,15 @@ impl AndroidEngine { } pub fn set_appearance(&self, appearance: AppearanceRecord) -> Result<(), BridgeError> { - pstr_core::appearance::save(&self.dirs, &appearance_choice(appearance)) - .map_err(BridgeError::from_display) + // The record carries the palette and nothing else; what the desktop's + // library layout fields hold is kept as it was stored. + let stored = pstr_core::appearance::load(&self.dirs).unwrap_or_default(); + let appearance = Appearance { + tiles: stored.tiles, + names: stored.names, + ..appearance_choice(appearance) + }; + pstr_core::appearance::save(&self.dirs, &appearance).map_err(BridgeError::from_display) } /// The colours the stored choice resolves to. @@ -1435,7 +1446,14 @@ impl AndroidEngine { .lock() .enrichment_ages() .map_err(BridgeError::from_display)?; - let pending = pstr_meta::service::plan(titles, &stored, &enriched, provider, force); + let pending = pstr_meta::service::plan( + titles, + &stored, + &enriched, + provider, + service.has_details(), + force, + ); let permits = Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY)); let mut lookups = tokio::task::JoinSet::new(); @@ -2377,6 +2395,7 @@ fn appearance_choice(record: AppearanceRecord) -> Appearance { AccentChoice::Red => Accent::Red, }, gradients: record.gradients, + ..Appearance::default() } } @@ -2533,6 +2552,10 @@ fn match_record(metadata: TitleMetadata) -> MatchRecord { genres: metadata.genres, episode_count: metadata.episodes, external_url: metadata.url, + details: metadata + .details + .as_ref() + .and_then(|details| serde_json::to_string(details).ok()), } } @@ -2551,6 +2574,10 @@ fn title_metadata(record: MatchRecord) -> TitleMetadata { genres: record.genres, episodes: record.episode_count, url: record.external_url, + details: record + .details + .as_deref() + .and_then(|details| serde_json::from_str(details).ok()), } } @@ -2926,6 +2953,11 @@ mod tests { genres: vec!["Action".to_owned(), "Sci-Fi".to_owned()], episodes: Some(26), url: Some("https://anilist.co/anime/1".to_owned()), + details: Some(pstr_core::metadata::TitleDetails { + studios: vec!["Sunrise".to_owned()], + related: vec!["5".to_owned()], + ..Default::default() + }), }; assert_eq!(title_metadata(match_record(metadata.clone())), metadata); diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index ac62f3d..8abf93f 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -12,7 +12,7 @@ use pstr_core::Share; use pstr_core::appearance::Appearance; use pstr_core::config::AppDirs; use pstr_core::library::Library; -use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord}; +use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, TitleNames}; use crate::engine::{ DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures, watch_state, @@ -213,6 +213,8 @@ pub enum Action { FocusSearch, /// Show a different slice of the library, or in a different order. SetShelf(Filter, Sort), + /// Fold each franchise into one tile, or show every title. + SetGrouped(bool), /// Open or close the list of keyboard shortcuts. ToggleShortcuts, /// One page back: a title to the library, the library to nowhere. @@ -302,25 +304,55 @@ impl Matcher { /// every title by when it was last played. Neither is slow once; both were /// being done on every frame, and while a film plays under the transport bar /// that is the film's frame rate. -#[derive(Default)] pub struct LibraryView { /// The query `matches` answers, as typed. query: String, /// Which titles the grid shows, and in what order. pub filter: Filter, pub sort: Sort, - /// The filter and order `matches` was built with. - built_with: (Filter, Sort), + /// One tile per franchise rather than one per title. See + /// `pstr_core::franchise`. + pub grouped: bool, + /// What `matches` was built with. + built_with: Option<(Filter, Sort, bool, TitleNames)>, /// Indices into [`Library::titles`]. pub matches: Vec, + /// In a grouped grid, how many other titles a tile stands for, by index. + pub folded: HashMap, + /// Every title in a franchise of more than one, by key: the keys of the + /// whole franchise in release order, its own included. + pub franchises: HashMap>, + /// The rows above the grid, as a heading and indices. + pub shelves: Vec<(String, Vec)>, /// Every played episode, newest first, for the history page. pub history: Vec, /// The "Continue watching" shelf, most recent first, as indices. pub resumable: Vec, - /// The library changed under the lists. + /// The library or what is known about it changed under the lists. stale: bool, } +impl Default for LibraryView { + fn default() -> Self { + Self { + query: String::new(), + filter: Filter::default(), + sort: Sort::default(), + // On: a share holding a series and its three films is four tiles + // of the same picture otherwise. + grouped: true, + built_with: None, + matches: Vec::new(), + folded: HashMap::new(), + franchises: HashMap::new(), + shelves: Vec::new(), + history: Vec::new(), + resumable: Vec::new(), + stale: false, + } + } +} + /// Which titles the grid shows. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Filter { @@ -353,49 +385,233 @@ impl Filter { /// What order the grid is in. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Sort { - /// The library's own order: by name, ignoring a leading article. + /// By the name shown, ignoring a leading article. #[default] Name, /// Most recently played first. Recent, - /// Newest first, undated last. - Year, + /// Most recently given a new file first. + Added, + /// Newest first by when it aired, undated last. + Release, + /// Best rated first, unrated last. + Rating, + /// Most followed at the provider first. + Popularity, } +impl Sort { + pub const ALL: [Self; 6] = [ + Self::Name, + Self::Recent, + Self::Added, + Self::Release, + Self::Rating, + Self::Popularity, + ]; + + pub fn label(self) -> &'static str { + match self { + Self::Name => "A – Z", + Self::Recent => "Recently watched", + Self::Added => "Recently added", + Self::Release => "Newest", + Self::Rating => "Highest rated", + Self::Popularity => "Most popular", + } + } +} + +/// What the library view is built from, besides the library itself. +pub struct ViewInputs<'a> { + pub query: &'a str, + pub metadata: &'a HashMap, + pub names: TitleNames, + /// When each title last gained a file, by key. + pub added: &'a HashMap, +} + +/// How many shelves the library shows above the grid. +const SHELVES: usize = 4; + impl LibraryView { - fn refresh(&mut self, library: &Library, query: &str) { - if !self.stale && self.query == query && self.built_with == (self.filter, self.sort) { + fn refresh(&mut self, library: &Library, inputs: ViewInputs<'_>) { + use pstr_core::library::{Title, sort_name}; + + let built_with = (self.filter, self.sort, self.grouped, inputs.names); + if !self.stale && self.query == inputs.query && self.built_with == Some(built_with) { return; } - let index_of = |title: &pstr_core::library::Title| { - library + let metadata = inputs.metadata; + let found = |title: &Title| { + metadata + .get(&title.key) + .and_then(|record| record.metadata.as_ref()) + }; + let name = |title: &Title| ui::title_name(title, metadata, inputs.names).to_owned(); + + if self.stale { + let all: Vec<&Title> = library.titles.iter().collect(); + self.franchises = pstr_core::franchise::group(&all, metadata) + .into_iter() + .filter(|franchise| franchise.members.len() > 1) + .flat_map(|franchise| { + let keys: Vec = franchise + .members + .iter() + .map(|&index| all[index].key.clone()) + .collect(); + keys.clone().into_iter().map(move |key| (key, keys.clone())) + }) + .collect(); + let index: HashMap<&str, usize> = library .titles .iter() - .position(|candidate| std::ptr::eq(candidate, title)) - }; - let mut matches: Vec<&pstr_core::library::Title> = library - .search(query) + .enumerate() + .map(|(at, title)| (title.key.as_str(), at)) + .collect(); + self.shelves = pstr_core::shelves::pick(&library.titles, metadata, SHELVES) + .into_iter() + .map(|shelf| { + let indices = shelf + .keys + .iter() + .filter_map(|key| index.get(key.as_str()).copied()) + .collect(); + (shelf.label, indices) + }) + .collect(); + } + + // A search finds a title by the provider's names for it as well as + // the share's: a folder called `Sousou no Frieren` is found by typing + // "Frieren: Beyond". + let needle = inputs.query.trim().to_lowercase(); + let by_files: std::collections::HashSet<*const Title> = library + .search(inputs.query) .into_iter() - .filter(|title| self.filter.admits(title)) + .map(|title| title as *const Title) .collect(); + let mut titles: Vec<(usize, &Title)> = library + .titles + .iter() + .enumerate() + .filter(|(_, title)| { + by_files.contains(&(*title as *const Title)) + || found(title).is_some_and(|found| { + std::iter::once(found.name.as_str()) + .chain(found.original_name.as_deref()) + .chain( + found + .details + .as_ref() + .and_then(|details| details.romaji.as_deref()), + ) + .any(|name| name.to_lowercase().contains(&needle)) + }) + }) + .filter(|(_, title)| self.filter.admits(title)) + .collect(); + titles.sort_by_cached_key(|(_, title)| sort_name(&name(title))); + + // Each entry is the tile and every title it stands for. + let entries: Vec<(usize, Vec)> = if self.grouped { + let refs: Vec<&Title> = titles.iter().map(|(_, title)| *title).collect(); + pstr_core::franchise::group(&refs, metadata) + .into_iter() + .map(|franchise| { + let lead = titles[franchise.lead(&refs)].0; + let members = franchise + .members + .iter() + .map(|&member| titles[member].0) + .collect(); + (lead, members) + }) + .collect() + } else { + titles + .iter() + .map(|&(index, _)| (index, vec![index])) + .collect() + }; + let mut entries = entries; + let titles_of = |members: &[usize]| -> Vec<&Title> { + members + .iter() + .map(|&index| &library.titles[index]) + .collect() + }; + // Every sort is stable over the name order above, so ties and the + // undated, unrated and unplayed keep their alphabetical order. match self.sort { Sort::Name => {} - // Stable, so titles never played keep their alphabetical order - // after the ones that have been. - Sort::Recent => matches.sort_by_key(|title| std::cmp::Reverse(title.last_played())), - Sort::Year => matches.sort_by_key(|title| std::cmp::Reverse(title.year.unwrap_or(0))), + Sort::Recent => entries.sort_by_key(|(_, members)| { + std::cmp::Reverse( + titles_of(members) + .iter() + .map(|title| title.last_played()) + .max(), + ) + }), + Sort::Added => entries.sort_by_key(|(_, members)| { + std::cmp::Reverse( + titles_of(members) + .iter() + .filter_map(|title| inputs.added.get(&title.key)) + .max() + .copied(), + ) + }), + Sort::Release => entries.sort_by_cached_key(|(_, members)| { + std::cmp::Reverse( + titles_of(members) + .iter() + .map(|title| pstr_core::franchise::release_key(title, metadata)) + // `~` is how the key says "undated"; it sorts last + // here rather than first. + .filter(|key| key != "~") + .max(), + ) + }), + Sort::Rating => entries.sort_by_key(|(lead, _)| { + std::cmp::Reverse( + found(&library.titles[*lead]) + .and_then(|found| found.rating) + .map(|rating| (rating * 100.0) as i64), + ) + }), + Sort::Popularity => entries.sort_by_key(|(lead, _)| { + std::cmp::Reverse( + found(&library.titles[*lead]) + .and_then(|found| found.details.as_ref()) + .and_then(|details| details.popularity), + ) + }), } - self.matches = matches.into_iter().filter_map(index_of).collect(); - self.built_with = (self.filter, self.sort); + + self.folded = entries + .iter() + .filter(|(_, members)| members.len() > 1) + .map(|(lead, members)| (*lead, members.len() - 1)) + .collect(); + self.matches = entries.into_iter().map(|(lead, _)| lead).collect(); + self.built_with = Some(built_with); if self.stale { self.history = ui::history::entries(library); + let index_of = |title: &Title| { + library + .titles + .iter() + .position(|candidate| std::ptr::eq(candidate, title)) + }; self.resumable = library .continue_watching() .into_iter() .filter_map(index_of) .collect(); } - self.query = query.to_owned(); + self.query = inputs.query.to_owned(); self.stale = false; } } @@ -428,6 +644,8 @@ pub struct App { pub posters: ImageCache, /// What the providers have said, keyed by title key. pub metadata: HashMap, + /// When each title last gained a file, by key — for "Recently added". + pub added: HashMap, /// What they said about the episodes under those titles. pub episodes: HashMap, pub settings: MetadataConfig, @@ -531,6 +749,7 @@ impl App { thumbs: ImageCache::default(), posters: ImageCache::default(), metadata: HashMap::new(), + added: HashMap::new(), episodes: HashMap::new(), settings, api_key: String::new(), @@ -612,8 +831,9 @@ impl App { self.connecting = false; self.note(ctx, format!("could not open the shares: {error}"), true); } - Event::LibraryLoaded(library) => { + Event::LibraryLoaded(library, added) => { self.library = library; + self.added = added; self.loaded = true; self.view.stale = true; } @@ -678,6 +898,8 @@ impl App { self.posters.forget(&key); } self.metadata = records; + // Names, franchises and shelves all come from it. + self.view.stale = true; } Event::EpisodeMetadata(episodes) => self.episodes = episodes, Event::MetadataConfig(config) => { @@ -975,6 +1197,7 @@ impl App { posters: &mut self.posters, metadata: &self.metadata, episodes: &self.episodes, + names: self.engine.appearance().names, } .still(&title.key, episode) }); @@ -1220,6 +1443,7 @@ impl App { self.view.filter = filter; self.view.sort = sort; } + Action::SetGrouped(grouped) => self.view.grouped = grouped, Action::Back => match self.page { Page::Title(_) => self.page = Page::Library, // Back out of a search before out of anything else: it is the @@ -1652,6 +1876,7 @@ impl eframe::App for App { posters, metadata, episodes, + names: engine.appearance().names, }, open: *episode_drawer, }); @@ -1750,7 +1975,15 @@ impl eframe::App for App { }); self.search = search; self.focus_search = false; - self.view.refresh(&self.library, &self.search); + self.view.refresh( + &self.library, + ViewInputs { + query: &self.search, + metadata: &self.metadata, + names: self.engine.appearance().names, + added: &self.added, + }, + ); let App { engine, @@ -1836,6 +2069,7 @@ impl eframe::App for App { posters: &mut *posters, metadata, episodes, + names: engine.appearance().names, }; match page { Page::Library => ui::library::show( @@ -1846,6 +2080,7 @@ impl eframe::App for App { view, loaded: *loaded, search, + tiles: engine.appearance().tiles, }, &mut actions, ), @@ -1854,6 +2089,7 @@ impl eframe::App for App { &mut art, library, key, + view.franchises.get(key.as_str()).map(Vec::as_slice), ui::title::OfflineView { downloads, files: offline_files, @@ -1883,6 +2119,7 @@ impl eframe::App for App { posters, metadata, episodes, + names: engine.appearance().names, }; ui::matcher::show(ctx, open, &mut art, settings.provider, &mut actions); } @@ -2036,7 +2273,8 @@ fn crawl_label(progress: &std::collections::BTreeMap) -> String { } } -/// Titles whose tile art is not what it was. +/// Texture keys whose picture is not what it was: a title's tile art, its cover +/// and its wide art each. /// /// Compared by URL: the same URL is the same picture, and anything else — /// a different one, a new one, or none any more — has to be fetched or @@ -2045,19 +2283,27 @@ fn changed_art( before: &HashMap, after: &HashMap, ) -> Vec { - let url = |records: &HashMap, key: &str| { - records - .get(key) - .and_then(|record| record.metadata.as_ref()) - .and_then(|metadata| metadata.tile_art()) - .map(|(url, _)| url.to_owned()) + // Every picture a title is drawn with, in the order of the keys below. + let urls = |records: &HashMap, key: &str| { + let found = records.get(key).and_then(|record| record.metadata.as_ref()); + [ + found + .and_then(|metadata| metadata.tile_art()) + .map(|(url, _)| url.to_owned()), + found.and_then(|metadata| metadata.poster_url.clone()), + found.and_then(|metadata| metadata.wide_art().map(str::to_owned)), + ] }; - let mut keys: Vec = before - .keys() - .chain(after.keys()) - .filter(|key| url(before, key) != url(after, key)) - .cloned() - .collect(); + let mut keys: Vec = Vec::new(); + for key in before.keys().chain(after.keys()) { + let (old, new) = (urls(before, key), urls(after, key)); + let texture_keys = [key.clone(), ui::poster_key(key), ui::wide_key(key)]; + for ((old, new), texture) in old.iter().zip(&new).zip(texture_keys) { + if old != new { + keys.push(texture); + } + } + } keys.sort_unstable(); keys.dedup(); keys @@ -2306,6 +2552,7 @@ mod tests { genres: Vec::new(), episodes: None, url: None, + details: None, }); ( key.into(), @@ -2395,9 +2642,20 @@ mod tests { record("added", Some("w")), ] .into(); + // The tile and the cover are both drawn from the poster here; the + // wide art has nothing to be. assert_eq!( changed_art(&before, &after), - vec!["added", "dropped", "found", "moved"] + vec![ + "added", + "dropped", + "found", + "moved", + "poster:added", + "poster:dropped", + "poster:found", + "poster:moved" + ] ); } } diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index 86bb651..dc8b874 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -160,6 +160,11 @@ struct PartialMarker { /// difference is VRAM held for every title in the library. const THUMBNAIL_MAX_EDGE: u32 = 640; +/// The longest edge of a picture drawn across the whole window. 1920 is what +/// a provider's fanart is, so nothing is scaled up and a 4K screen gets the +/// best there is. +pub const WIDE_MAX_EDGE: u32 = 1920; + /// What the background side tells the UI. pub enum Event { /// The share list changed on disk. @@ -175,8 +180,9 @@ pub enum Event { }, /// Shares could not be opened at all. ConnectFailed(String), - /// The catalog was re-read. - LibraryLoaded(Library), + /// The catalog was re-read: the library, and when each title last + /// gained a file. + LibraryLoaded(Library, HashMap), /// A crawl finished for one share. Crawled { share_id: String, @@ -739,15 +745,16 @@ impl Engine { self.runtime.spawn_blocking(move || { let result = { let catalog = engine.catalog.lock(); - catalog - .all_files() - .and_then(|files| Ok((files, catalog.all_watch_states()?))) + catalog.all_files().and_then(|files| { + Ok((files, catalog.all_watch_states()?, catalog.first_seen()?)) + }) }; match result { - Ok((files, watch)) => { + Ok((files, watch, first_seen)) => { let library = Library::build(files, &watch); let targets = download_targets(&library); - engine.emit(Event::LibraryLoaded(library)); + let added = library.added_at(&first_seen); + engine.emit(Event::LibraryLoaded(library, added)); engine.refresh_offline(Some(targets)); } Err(error) => engine.fail("read the catalog", error), @@ -1914,7 +1921,14 @@ impl Engine { } }; - let pending = pstr_meta::service::plan(titles, &stored, &enriched, provider, force); + let pending = pstr_meta::service::plan( + titles, + &stored, + &enriched, + provider, + service.has_details(), + force, + ); if pending.is_empty() { return self.emit(Event::Status("everything is already matched".into())); } @@ -2088,15 +2102,26 @@ impl Engine { /// paths invalidates by itself and the same picture shared by two titles is /// fetched once. pub fn request_poster(&self, title_key: String, url: String) { + self.request_art(title_key, url, THUMBNAIL_MAX_EDGE); + } + + /// [`Engine::request_poster`], for a picture drawn across the window — + /// the library's hero, the title page's band — kept at up to `max_edge` + /// pixels rather than a tile's 640. A 640 px still stretched to 1600 was + /// most of why those looked soft. + pub fn request_art(&self, title_key: String, url: String, max_edge: u32) { let engine = self.clone(); - let path = self - .dirs - .poster_cache() - .join(format!("{}.img", digest(&url))); + // A size of its own on disk: the tile-sized copy of the same URL is + // already there under the plain name, scaled down. + let name = match max_edge { + THUMBNAIL_MAX_EDGE => format!("{}.img", digest(&url)), + edge => format!("{}-{edge}.img", digest(&url)), + }; + let path = self.dirs.poster_cache().join(name); self.runtime.spawn(async move { if let Ok(bytes) = tokio::fs::read(&path).await { - match decode_thumbnail(&bytes) { + match decode_scaled(&bytes, max_edge) { Ok(decoded) => { if let Some(smaller) = &decoded.smaller { store_image(&path, smaller).await; @@ -2123,7 +2148,7 @@ impl Engine { } }; - match decode_thumbnail(&bytes) { + match decode_scaled(&bytes, max_edge) { Ok(decoded) => { store_image(&path, decoded.smaller.as_deref().unwrap_or(&bytes)).await; engine.emit(Event::Poster { @@ -2299,9 +2324,14 @@ struct Decoded { /// copy is handed back so the cache holds that instead, and the next launch /// decodes a picture already the size it is drawn at. fn decode_thumbnail(bytes: &[u8]) -> anyhow::Result { + decode_scaled(bytes, THUMBNAIL_MAX_EDGE) +} + +/// [`decode_thumbnail`], to `max_edge` rather than a tile's size. +fn decode_scaled(bytes: &[u8], max_edge: u32) -> anyhow::Result { let decoded = image::load_from_memory(bytes)?; - let (decoded, smaller) = if decoded.width().max(decoded.height()) > THUMBNAIL_MAX_EDGE { - let scaled = decoded.thumbnail(THUMBNAIL_MAX_EDGE, THUMBNAIL_MAX_EDGE); + let (decoded, smaller) = if decoded.width().max(decoded.height()) > max_edge { + let scaled = decoded.thumbnail(max_edge, max_edge); let encoded = encode_for_cache(&scaled); (scaled, encoded) } else { diff --git a/crates/pstr-app/src/theme.rs b/crates/pstr-app/src/theme.rs index b908d9e..ca4e779 100644 --- a/crates/pstr-app/src/theme.rs +++ b/crates/pstr-app/src/theme.rs @@ -35,6 +35,12 @@ pub const CARD_WIDTH: f32 = 232.0; /// Video stills are 16:9. A poster shape would letterbox every one of them. pub const CARD_ASPECT: f32 = 9.0 / 16.0; pub const CARD_GAP: f32 = 14.0; +/// The poster grid's minimum width: narrower than a still, because a cover is +/// read by its art rather than by detail, and six across a laptop screen is +/// what makes a wall of covers look like a shelf. +pub const POSTER_WIDTH: f32 = 168.0; +/// Covers are 2:3. +pub const POSTER_ASPECT: f32 = 3.0 / 2.0; /// The type ramp, named once. /// diff --git a/crates/pstr-app/src/ui/library.rs b/crates/pstr-app/src/ui/library.rs index dcce0de..30f07c9 100644 --- a/crates/pstr-app/src/ui/library.rs +++ b/crates/pstr-app/src/ui/library.rs @@ -1,5 +1,6 @@ //! The library page: what to watch, as a wall of stills. +use pstr_core::appearance::Tiles; use pstr_core::library::{Library, Title, TitleKind}; use crate::app::{Action, Filter, LibraryView, Page, Sort}; @@ -16,6 +17,8 @@ pub struct Shelves<'a> { /// Whether the catalog has been read yet. pub loaded: bool, pub search: &'a str, + /// The grid's shape. + pub tiles: Tiles, } pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: &mut Vec) { @@ -24,6 +27,7 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: view, loaded, search, + tiles, } = shelves; if !loaded { return placeholder(ui); @@ -55,6 +59,16 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: continue_row(ui, art, &resumable, actions); ui.add_space(theme::space::XL); } + // Only over the whole library: under a filter, a shelf of + // titles the grid below is hiding reads as the filter not + // working. + if view.filter == Filter::All { + for (label, indices) in &view.shelves { + ui::section(ui, label); + shelf_row(ui, art, label, &pick(indices), tiles, actions); + ui.add_space(theme::space::XL); + } + } } ui.horizontal(|ui| { @@ -86,7 +100,7 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: return; } - grid(ui, art, &matches, actions); + grid(ui, art, &matches, view, tiles, actions); }); } @@ -121,7 +135,7 @@ fn featured<'a>( metadata .get(&title.key) .and_then(|record| record.metadata.as_ref()) - .is_some_and(|found| found.backdrop_url.is_some()) + .is_some_and(|found| found.wide_art().is_some()) }) .collect(); if pictured.is_empty() { @@ -144,7 +158,7 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec, title: &Title, actions: &mut Vec, title: &Title, actions: &mut Vec) { // Right to left: the order first, so it ends up last. let sort = egui::ComboBox::from_id_salt("library-sort") - .selected_text(match view.sort { - Sort::Name => "A – Z", - Sort::Recent => "Recently watched", - Sort::Year => "Newest", - }) + .selected_text(view.sort.label()) .width(150.0) .show_ui(ui, |ui| { let mut picked = None; - for (sort, label) in [ - (Sort::Name, "A – Z"), - (Sort::Recent, "Recently watched"), - (Sort::Year, "Newest"), - ] { - if ui.selectable_label(view.sort == sort, label).clicked() { + for sort in Sort::ALL { + if ui + .selectable_label(view.sort == sort, sort.label()) + .clicked() + { picked = Some(sort); } } @@ -292,6 +302,19 @@ fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec, + label: &str, + titles: &[&Title], + tiles: Tiles, + actions: &mut Vec, +) { + let (width, aspect) = tile_shape(tiles); + egui::ScrollArea::horizontal() + .id_salt(("shelf", label)) + .auto_shrink([false, true]) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = theme::CARD_GAP; + for title in titles { + let picture = match tiles { + Tiles::Posters => art.poster(title), + Tiles::Stills => art.of(title), + }; + let response = ui::card( + ui, + Card { + art: picture, + name: art.name(title), + subtitle: subtitle(title), + progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32), + badge: None, + width, + aspect, + }, + ); + tile_menu(&response, title, false, actions); + if response.clicked() { + actions.push(Action::Goto(Page::Title(title.key.clone()))); + } + } + }); + }); +} + +/// The minimum width and the picture's shape of a tile. +fn tile_shape(tiles: Tiles) -> (f32, f32) { + match tiles { + Tiles::Posters => (theme::POSTER_WIDTH, theme::POSTER_ASPECT), + Tiles::Stills => (theme::CARD_WIDTH, theme::CARD_ASPECT), + } +} + /// The top shelf: one card per part-watched title, scrolling sideways. fn continue_row( ui: &mut egui::Ui, @@ -337,17 +411,20 @@ fn continue_row( }) .unwrap_or_else(|| episode.label()); + let picture = art.of(title); let response = ui::card( ui, Card { - art: art.of(title), - name: &title.name, + art: picture, + name: art.name(title), subtitle: remaining, progress: episode.progress().map(|value| value as f32), badge: episode.numbering(), // A shelf that scrolls sideways has no right edge - // to reach, so nothing to flex to. + // to reach, so nothing to flex to. Always a + // still: it is a frame from where they stopped. width: theme::CARD_WIDTH, + aspect: theme::CARD_ASPECT, }, ); tile_menu(&response, title, true, actions); @@ -361,10 +438,18 @@ fn continue_row( } /// Every title, wrapped to the window. -fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec) { - let grid = ui::columns(ui.available_width()); - let row_height = ui::card_height(grid.width); - for row in titles.chunks(grid.columns) { +fn grid( + ui: &mut egui::Ui, + art: &mut Art<'_>, + titles: &[&Title], + view: &LibraryView, + tiles: Tiles, + actions: &mut Vec, +) { + let (minimum, aspect) = tile_shape(tiles); + let grid = ui::columns_of(ui.available_width(), minimum); + let row_height = ui::card_height(grid.width, aspect); + for (row, chunk) in titles.chunks(grid.columns).enumerate() { // A row scrolled out of view is only its height. Laying it out anyway // costs a subtitle and an artwork lookup per card per frame, and while // a film plays under the transport bar a frame is every frame. @@ -385,18 +470,32 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V // the difference times the column count is a visible drift towards // the left edge. ui.spacing_mut().item_spacing.x = theme::CARD_GAP; - for title in row { + for (column, title) in chunk.iter().enumerate() { + let picture = match tiles { + Tiles::Posters => art.poster(title), + Tiles::Stills => art.of(title), + }; + let folded = view + .matches + .get(row * grid.columns + column) + .and_then(|index| view.folded.get(index)) + .copied(); let response = ui::card( ui, Card { - art: art.of(title), - name: &title.name, - subtitle: subtitle(title), + art: picture, + name: art.name(title), + subtitle: match folded { + Some(more) => format!("{} · +{more} more", subtitle(title)), + None => subtitle(title), + }, progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32), // What a film is says itself in the subtitle below the - // card; a second `Film` over the poster is noise. - badge: None, + // card; a second `Film` over the poster is noise. A + // folded franchise says how much is under it. + badge: folded.map(|more| format!("+{more}")), width: grid.width, + aspect, }, ); tile_menu(&response, title, false, actions); @@ -517,7 +616,7 @@ fn subtitle(title: &Title) -> String { /// fraction is a flash of something false. fn placeholder(ui: &mut egui::Ui) { let grid = ui::columns(ui.available_width()); - let height = ui::card_height(grid.width); + let height = ui::card_height(grid.width, theme::CARD_ASPECT); ui::section(ui, "Library"); for _ in 0..2 { ui.horizontal(|ui| { diff --git a/crates/pstr-app/src/ui/mod.rs b/crates/pstr-app/src/ui/mod.rs index b94f144..319017f 100644 --- a/crates/pstr-app/src/ui/mod.rs +++ b/crates/pstr-app/src/ui/mod.rs @@ -20,9 +20,9 @@ use std::collections::HashMap; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Vec2}; use pstr_core::library::{Episode, Title}; -use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecord}; +use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecord, TitleNames}; -use crate::engine::{Engine, ImageCache, thumbnail_key}; +use crate::engine::{Engine, ImageCache, WIDE_MAX_EDGE, thumbnail_key}; use crate::theme; /// Everything the pages need to draw one title's picture. @@ -40,6 +40,22 @@ pub struct Art<'a> { pub metadata: &'a HashMap, /// What they said about the episodes under those titles. pub episodes: &'a HashMap, + /// Which name a title goes by. + pub names: TitleNames, +} + +/// The name to show for `title`: the provider's, in the form the viewer +/// picked, else the share's. +pub fn title_name<'a>( + title: &'a Title, + metadata: &'a HashMap, + names: TitleNames, +) -> &'a str { + metadata + .get(&title.key) + .and_then(|record| record.metadata.as_ref()) + .and_then(|found| found.display_name(names)) + .unwrap_or(&title.name) } impl Art<'_> { @@ -74,6 +90,58 @@ impl Art<'_> { .texture(&thumbnail_key(node), || engine.request_thumbnail(node)) } + /// The name to show for `title`. See [`title_name`]. + pub fn name<'t>(&'t self, title: &'t Title) -> &'t str { + title_name(title, self.metadata, self.names) + } + + /// A 2:3 cover for `title`, for the poster grid; whatever [`Art::of`] + /// has when there is none. + pub fn poster(&mut self, title: &Title) -> Option<(egui::TextureHandle, ArtShape)> { + let url = self + .metadata + .get(&title.key) + .and_then(|record| record.metadata.as_ref()) + .and_then(|metadata| metadata.poster_url.clone()); + if let Some(url) = url { + let key = poster_key(&title.key); + let engine = self.engine; + if let Some(texture) = self + .posters + .texture(&key, || engine.request_poster(key.clone(), url)) + { + return Some((texture, ArtShape::Portrait)); + } + } + self.of(title) + } + + /// Art for a strip across the whole window, at full size: fanart, else + /// AniList's banner, else whatever [`Art::of`] has. + /// + /// Its own texture rather than the tile's: a 640 px tile picture + /// stretched to a 1600 px hero is soft, and a 1920 px one in every tile + /// would be most of the texture budget. + pub fn wide(&mut self, title: &Title) -> Option<(egui::TextureHandle, ArtShape)> { + let url = self + .metadata + .get(&title.key) + .and_then(|record| record.metadata.as_ref()) + .and_then(|metadata| metadata.wide_art()) + .map(str::to_owned); + if let Some(url) = url { + let key = wide_key(&title.key); + let engine = self.engine; + if let Some(texture) = self + .posters + .texture(&key, || engine.request_art(key.clone(), url, WIDE_MAX_EDGE)) + { + return Some((texture, ArtShape::Landscape)); + } + } + self.of(title) + } + /// The picture for a title, and how to fit it. /// /// Provider artwork first, then Proton's still, then nothing. That order is @@ -104,6 +172,17 @@ impl Art<'_> { } } +/// The texture key of a title's cover, beside [`Art::of`]'s, which is the +/// title key itself. +pub fn poster_key(title_key: &str) -> String { + format!("poster:{title_key}") +} + +/// The texture key of a title's full-size wide art. +pub fn wide_key(title_key: &str) -> String { + format!("wide:{title_key}") +} + /// `1:03:47`, or `4:12` for anything under an hour. pub fn format_time(seconds: f64) -> String { if !seconds.is_finite() || seconds < 0.0 { @@ -492,6 +571,9 @@ pub struct Card<'a> { /// sideways-scrolling shelf has no edge to reach and passes /// [`theme::CARD_WIDTH`]. pub width: f32, + /// The picture's height over its width: [`theme::CARD_ASPECT`] for a + /// still, [`theme::POSTER_ASPECT`] for a cover. + pub aspect: f32, } /// Two lines of name plus one of subtitle. Fixed, because the tile is allocated @@ -499,10 +581,10 @@ pub struct Card<'a> { /// different height by title length reads as broken. const CARD_TEXT_HEIGHT: f32 = 54.0; -/// How tall [`card`] draws a tile of this width, so a grid can step over rows -/// it does not draw. -pub fn card_height(width: f32) -> f32 { - (width * theme::CARD_ASPECT).round() + CARD_TEXT_HEIGHT +/// How tall [`card`] draws a tile of this width and shape, so a grid can step +/// over rows it does not draw. +pub fn card_height(width: f32, aspect: f32) -> f32 { + (width * aspect).round() + CARD_TEXT_HEIGHT } /// Draw one tile and report whether it was clicked. @@ -515,7 +597,10 @@ pub fn card_height(width: f32) -> f32 { /// reads as deliberate in a way a stretched poster does not. pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { let width = card.width; - let image_height = (width * theme::CARD_ASPECT).round(); + let image_height = (width * card.aspect).round(); + // A cover fitted into a still-shaped tile is drawn whole; into a tile its + // own shape, it fills it like anything else. + let fit = card.aspect < 1.0; let text_height = CARD_TEXT_HEIGHT; let radius = CornerRadius::same(theme::radius::MD); @@ -574,7 +659,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { .with_texture(texture.id(), cover_uv(size, image_rect.size())), ); } - Some((texture, ArtShape::Portrait)) => { + Some((texture, ArtShape::Portrait)) if fit => { painter.add( egui::epaint::RectShape::filled( contain_rect(texture.size_vec2(), image_rect), @@ -587,6 +672,14 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { ), ); } + Some((texture, ArtShape::Portrait)) => { + painter.add( + egui::epaint::RectShape::filled(image_rect, radius, ink).with_texture( + texture.id(), + cover_uv(texture.size_vec2(), image_rect.size()), + ), + ); + } None => {} } @@ -777,9 +870,13 @@ pub struct Grid { /// dead space at the right of every row, and on a wide window that reads as a /// layout that failed to line up rather than as a margin. pub fn columns(available: f32) -> Grid { - let columns = (((available + theme::CARD_GAP) / (theme::CARD_WIDTH + theme::CARD_GAP)).floor() - as usize) - .max(1); + columns_of(available, theme::CARD_WIDTH) +} + +/// [`columns`], for cards at least `minimum` wide. +pub fn columns_of(available: f32, minimum: f32) -> Grid { + let columns = + (((available + theme::CARD_GAP) / (minimum + theme::CARD_GAP)).floor() as usize).max(1); // The gaps come out of the width before it is split. Floored, because a // fraction of a point times four columns is enough to push the last card // onto a row of its own. A window too narrow for even one full card gets a diff --git a/crates/pstr-app/src/ui/settings.rs b/crates/pstr-app/src/ui/settings.rs index 3c880de..98be74b 100644 --- a/crates/pstr-app/src/ui/settings.rs +++ b/crates/pstr-app/src/ui/settings.rs @@ -5,8 +5,8 @@ //! that manages Proton Drive links, and a page that is half link management and //! half preferences is two pages. -use pstr_core::appearance::{Accent, Appearance, Flavor}; -use pstr_core::metadata::{MetadataConfig, ProviderId}; +use pstr_core::appearance::{Accent, Appearance, Flavor, Tiles}; +use pstr_core::metadata::{MetadataConfig, ProviderId, TitleNames}; use crate::app::Action; use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs}; @@ -102,6 +102,41 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec< } }); + ui::widgets::settings_row( + ui, + "Library tiles", + Some("Covers, or 16:9 stills. Covers are the art every matched title has."), + |ui| { + let choices: Vec<(Tiles, &str)> = Tiles::ALL + .iter() + .map(|tiles| (*tiles, tiles.label())) + .collect(); + if let Some(tiles) = ui::widgets::segmented(ui, appearance.tiles, &choices) { + actions.push(Action::SetAppearance(Appearance { + tiles, + ..appearance + })); + } + }, + ); + ui::widgets::settings_row( + ui, + "Title names", + Some("The name a matched title goes by. Its other names still find it in search."), + |ui| { + let choices: Vec<(TitleNames, &str)> = TitleNames::ALL + .iter() + .map(|names| (*names, names.label())) + .collect(); + if let Some(names) = ui::widgets::segmented(ui, appearance.names, &choices) { + actions.push(Action::SetAppearance(Appearance { + names, + ..appearance + })); + } + }, + ); + let mut gradients = appearance.gradients; ui::widgets::settings_row( ui, diff --git a/crates/pstr-app/src/ui/title.rs b/crates/pstr-app/src/ui/title.rs index dd81506..2653763 100644 --- a/crates/pstr-app/src/ui/title.rs +++ b/crates/pstr-app/src/ui/title.rs @@ -20,6 +20,7 @@ pub fn show( art: &mut Art<'_>, library: &Library, key: &str, + franchise: Option<&[String]>, offline: OfflineView<'_>, actions: &mut Vec, ) { @@ -41,6 +42,15 @@ pub fn show( header(ui, art, title, &offline, actions); ui.add_space(theme::space::XL); + if let Some(keys) = franchise { + let members: Vec<&Title> = keys.iter().filter_map(|key| library.get(key)).collect(); + if members.len() > 1 { + ui::section(ui, "In this franchise"); + franchise_row(ui, art, title, &members, actions); + ui.add_space(theme::space::XL); + } + } + // One season at a time, picked from a row of pills, rather than // every season stacked as a collapsing header: a four-season show // was a page of headers to scroll past to reach the one being @@ -100,6 +110,60 @@ pub fn show( }); } +/// Every title of the franchise, in release order, as covers — this one +/// marked rather than left out, so the row says where it sits in the story. +fn franchise_row( + ui: &mut egui::Ui, + art: &mut Art<'_>, + current: &Title, + members: &[&Title], + actions: &mut Vec, +) { + egui::ScrollArea::horizontal() + .id_salt(("franchise", ¤t.key)) + .auto_shrink([false, true]) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = theme::CARD_GAP; + for member in members { + let here = member.key == current.key; + let picture = art.poster(member); + let found = art + .metadata + .get(&member.key) + .and_then(|record| record.metadata.as_ref()); + let what = found + .and_then(|found| found.details.as_ref()) + .and_then(|details| details.format_label()) + .unwrap_or(match member.kind { + TitleKind::Series => "Series", + TitleKind::Film => "Film", + }); + let year = found + .and_then(|found| found.year) + .or(member.year) + .map(|year| format!(" · {year}")) + .unwrap_or_default(); + let response = ui::card( + ui, + Card { + art: picture, + name: art.name(member), + subtitle: format!("{what}{year}"), + progress: member.resume().and_then(|e| e.progress()).map(|v| v as f32), + badge: here.then(|| "Here".to_owned()), + width: 128.0, + aspect: theme::POSTER_ASPECT, + }, + ); + if !here && response.clicked() { + actions.push(Action::Goto(Page::Title(member.key.clone()))); + } + } + }); + }); +} + /// A title's poster, at the poster's own shape, with how far into it the /// viewer is along its foot. fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option) { @@ -190,16 +254,19 @@ fn header( let record = art.metadata.get(&title.key); let found = record.and_then(|record| record.metadata.clone()); let by_hand = record.is_some_and(|record| record.manual); - // Asked for once and used twice: the same picture is the poster beside the - // text and the banner behind all of it. - let picture = art.of(title); + // The cover beside the text, and the widest, largest picture there is + // behind all of it — two textures, because the band is drawn across the + // whole window and a tile-sized one stretched that far is soft. + let picture = art.poster(title); + let wide = art.wide(title); // Reserved before the header is laid out, because the band is as tall as // whatever the header comes to — a fixed height would clip a long overview // on one title and leave a gap under a short one on the next. - let backdrop = picture + let backdrop = wide .as_ref() .map(|(texture, _)| (texture.clone(), ui.painter().add(egui::Shape::Noop))); + let shown = art.name(title).to_owned(); ui.add_space(theme::space::S); ui.horizontal_top(|ui| { @@ -214,11 +281,12 @@ fn header( ui, Card { art: picture, - name: &title.name, + name: &shown, subtitle: String::new(), progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32), badge: None, width: theme::CARD_WIDTH, + aspect: theme::CARD_ASPECT, }, ); } @@ -229,16 +297,24 @@ fn header( // Text in a column a person can read, with the picture to the // right of it rather than under it. ui.set_max_width(ui.available_width().min(820.0)); - ui.label(theme::Role::Display.rich(&title.name).strong()); - // The provider's name for it, when it is not the one the files use. - // Worth showing rather than replacing the filename's: a viewer - // should be able to tell what the match actually matched. - if let Some(found) = &found - && found.name != title.name - { + ui.label(theme::Role::Display.rich(&shown).strong()); + // Whichever of the share's name and the provider's is not the one + // shown. Worth showing rather than only replacing: a viewer should + // be able to tell what the match actually matched, and find the + // folder it came from. + let other = found + .as_ref() + .map(|found| { + if shown == found.name { + title.name.clone() + } else { + found.name.clone() + } + }) + .filter(|other| *other != shown); + if let Some(other) = other { ui.label(ui::muted(format!( - "also known as {}{}", - found.name, + "also known as {other}{}", if by_hand { " (chosen by hand)" } else { "" } ))); } @@ -449,12 +525,22 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S /// What the provider had to say. Only ever drawn under a match. fn description(ui: &mut egui::Ui, found: &TitleMetadata) { - if !found.genres.is_empty() { + let tags = found + .details + .as_ref() + .map(|details| details.tags.as_slice()) + .unwrap_or_default(); + if !found.genres.is_empty() || !tags.is_empty() { ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 6.0; for genre in &found.genres { ui::widgets::chip(ui, genre); } + // After the genres and quieter than them: a tag is the + // provider's community describing it, not what it is filed as. + for tag in tags.iter().take(5) { + ui.label(ui::muted(tag)); + } }); ui.add_space(theme::space::M); } @@ -530,6 +616,30 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String { parts.push(format!("{watched} watched")); } + if let Some(details) = found.and_then(|found| found.details.as_ref()) { + if let Some(season) = details.season_label() { + parts.push(season); + } + parts.extend(details.studios.iter().take(2).cloned()); + if let Some(director) = details.directors.first() { + parts.push(format!("dir. {director}")); + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |since| since.as_secs() as i64); + if let Some((episode, at)) = details.next_airing(now) { + let when = chrono::DateTime::from_timestamp(at, 0) + .map(|at| { + at.with_timezone(&chrono::Local) + .format("%a %-d %b, %H:%M") + .to_string() + }) + .unwrap_or_default(); + parts.push(format!("episode {episode} airs {when}")); + } else if details.is_airing() { + parts.push("airing".to_owned()); + } + } if let Some(found) = found { if let Some(rating) = found.rating { parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR)); diff --git a/crates/pstr-cli/src/main.rs b/crates/pstr-cli/src/main.rs index 38cec7d..074ceb9 100644 --- a/crates/pstr-cli/src/main.rs +++ b/crates/pstr-cli/src/main.rs @@ -493,6 +493,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> { &stored, &enriched, settings.provider, + service.has_details(), force, ); if let Some(limit) = limit { @@ -514,7 +515,12 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> { // for is reading the output. let (mut matched, mut failed) = (0usize, 0usize); for work in pending { + use pstr_meta::service::Work; let name = &work.title().name; + // Whether this work asks for episodes at all — a refresh of an + // already-enriched title does not, and saying they were "not + // fetched" there reads as a failure. + let enriches = matches!(work, Work::Match(_) | Work::Refresh { enrich: true, .. }); let outcome = match service.work(&work).await { Ok(outcome) => outcome, Err(error) => { @@ -564,10 +570,11 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> { } // Matched, but the episodes could not be asked for; the // next run asks again. - None if outcome - .record - .as_ref() - .is_some_and(|r| r.metadata.is_some()) => + None if enriches + && outcome + .record + .as_ref() + .is_some_and(|r| r.metadata.is_some()) => { println!(" {:<40} ! episodes not fetched", ""); } diff --git a/crates/pstr-core/src/appearance.rs b/crates/pstr-core/src/appearance.rs index ebf2930..f3dc541 100644 --- a/crates/pstr-core/src/appearance.rs +++ b/crates/pstr-core/src/appearance.rs @@ -123,6 +123,30 @@ impl Accent { } } +/// What shape the library's grid is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Tiles { + /// 2:3 covers. The default, because the art a provider has for every + /// title is a cover; a 16:9 still exists for fewer of them, and a grid of + /// letterboxed covers was the desktop's weakest page. + #[default] + Posters, + /// 16:9 stills: the provider's fanart, else a frame from the file. + Stills, +} + +impl Tiles { + pub const ALL: [Self; 2] = [Self::Posters, Self::Stills]; + + pub fn label(self) -> &'static str { + match self { + Self::Posters => "Posters", + Self::Stills => "Stills", + } + } +} + /// The whole of the choice. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] // An older or hand-edited file is missing fields rather than invalid. @@ -138,6 +162,10 @@ pub struct Appearance { /// slow ramp across a wide bar bands visibly, and flat is better than /// striped. pub gradients: bool, + /// The library grid's shape. + pub tiles: Tiles, + /// Which name a title goes by. + pub names: crate::metadata::TitleNames, } impl Default for Appearance { @@ -146,6 +174,8 @@ impl Default for Appearance { flavor: Flavor::default(), accent: Accent::default(), gradients: true, + tiles: Tiles::default(), + names: crate::metadata::TitleNames::default(), } } } @@ -615,6 +645,7 @@ mod tests { flavor: Flavor::Mocha, accent: Accent::PinkSky, gradients: false, + ..Appearance::default() }; save(&dirs, &appearance).unwrap(); assert_eq!(load(&dirs).unwrap(), appearance); @@ -652,6 +683,7 @@ mod tests { flavor, accent, gradients: true, + ..Appearance::default() }); let ratio = worst_contrast(palette.accent, palette.accent_alt, palette.on_accent); assert!( @@ -681,6 +713,7 @@ mod tests { flavor: Flavor::Persona5, accent: Accent::Red, gradients: false, + ..Appearance::default() }); assert_eq!(palette.accent, PERSONA5.red); assert!(!palette.light); @@ -697,6 +730,7 @@ mod tests { flavor, accent: Accent::Red, gradients: true, + ..Appearance::default() }); // The accent is the flavour's red — deepened on the light flavour, // where it would otherwise be too bright to put a label on. diff --git a/crates/pstr-core/src/catalog.rs b/crates/pstr-core/src/catalog.rs index a25d9c1..0b8debe 100644 --- a/crates/pstr-core/src/catalog.rs +++ b/crates/pstr-core/src/catalog.rs @@ -18,12 +18,13 @@ use rusqlite::{Connection, OptionalExtension, params}; use crate::error::Result; use crate::library::TitleKind; use crate::metadata::{ - Enrichment, EpisodeGuide, EpisodeMetadata, MetadataRecord, ProviderId, TitleMetadata, + Enrichment, EpisodeGuide, EpisodeMetadata, MetadataRecord, ProviderId, TitleDetails, + TitleMetadata, }; use crate::naming::{self, ParsedName}; /// Bump when adding a migration. -pub const SCHEMA_VERSION: i64 = 9; +pub const SCHEMA_VERSION: i64 = 10; const MIGRATION_V1: &str = r#" CREATE TABLE nodes ( @@ -179,6 +180,33 @@ DELETE FROM episode_metadata WHERE provider = 'anilist'; UPDATE title_metadata SET backdrop_url = NULL WHERE provider = 'anilist'; "#; +/// What the library is sorted, grouped and shelved by, and when each file +/// first appeared. +/// +/// `details` is [`TitleDetails`] as JSON — see there for why one column. +/// `NULL` means it was never fetched, which a match run reads as "fetch it" +/// rather than as "there is none"; every existing match starts that way. +/// `chain` is the provider ids enrichment walked, newline-separated; every +/// title is marked un-enriched so the next run records it. +/// +/// `node_first_seen` is kept apart from `nodes` because a recrawl replaces +/// every node row and "recently added" has to survive that. Files already in +/// the catalog are recorded as seen at 0 — when they arrived is not known, and +/// pretending it was today would put the whole library at the top. +const MIGRATION_V10: &str = r#" +ALTER TABLE title_metadata ADD COLUMN details TEXT; +ALTER TABLE title_metadata ADD COLUMN chain TEXT; +CREATE TABLE node_first_seen ( + share_id TEXT NOT NULL, + link_id TEXT NOT NULL, + seen_at INTEGER NOT NULL, + PRIMARY KEY (share_id, link_id) +); +INSERT OR IGNORE INTO node_first_seen (share_id, link_id, seen_at) + SELECT share_id, link_id, 0 FROM nodes WHERE is_folder = 0; +UPDATE title_metadata SET enriched_at = NULL; +"#; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct OfflineFile { pub revision_id: String, @@ -364,6 +392,12 @@ impl Catalog { tx.pragma_update(None, "user_version", 9)?; tx.commit()?; } + if version < 10 { + let tx = self.conn.transaction()?; + tx.execute_batch(MIGRATION_V10)?; + tx.pragma_update(None, "user_version", 10)?; + tx.commit()?; + } Ok(()) } @@ -424,6 +458,13 @@ impl Catalog { } } + // Only files: a folder appearing is not something anyone watches. + tx.execute( + "INSERT OR IGNORE INTO node_first_seen (share_id, link_id, seen_at) + SELECT share_id, link_id, ?2 FROM nodes WHERE share_id = ?1 AND is_folder = 0", + params![share_id, unix_now()], + )?; + if !retain_offline { tx.execute("DELETE FROM offline_files WHERE share_id = ?1 AND NOT EXISTS (SELECT 1 FROM nodes WHERE nodes.share_id = offline_files.share_id AND nodes.link_id = offline_files.link_id AND nodes.active_revision_id = offline_files.revision_id)", params![share_id])?; } @@ -460,6 +501,18 @@ impl Catalog { Ok(rows.collect::, _>>()?) } + /// When each file first appeared in a crawl, keyed `(share_id, link_id)`. + /// `0` for a file that was already there before this was recorded. + pub fn first_seen(&self) -> Result> { + let mut statement = self + .conn + .prepare("SELECT share_id, link_id, seen_at FROM node_first_seen")?; + let rows = statement.query_map([], |row| { + Ok(((row.get(0)?, row.get(1)?), row.get::<_, i64>(2)?)) + })?; + Ok(rows.collect::>()?) + } + /// Drop everything a share contributed, watch positions included. /// /// Unlike a recrawl — which replaces rows and deliberately leaves watch @@ -477,6 +530,10 @@ impl Catalog { "DELETE FROM offline_files WHERE share_id = ?1", params![share_id], )?; + tx.execute( + "DELETE FROM node_first_seen WHERE share_id = ?1", + params![share_id], + )?; tx.commit()?; Ok(()) } @@ -697,7 +754,7 @@ impl Catalog { let mut statement = self.conn.prepare( "SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name, overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url, - manual + manual, details, chain FROM title_metadata", )?; let rows = statement.query_map([], row_to_metadata)?; @@ -718,7 +775,7 @@ impl Catalog { .query_row( "SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name, overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, - url, manual + url, manual, details, chain FROM title_metadata WHERE title_key = ?1", params![title_key], row_to_metadata, @@ -750,12 +807,22 @@ impl Catalog { data.map(|data| data.remote_id.as_str()), ], )?; + let details = data + .and_then(|data| data.details.as_ref()) + .map(serde_json::to_string) + .transpose() + .map_err(|error| crate::error::Error::Config(error.to_string()))?; + // `same` below is whether the row already describes this entry. When + // it does, what enrichment added — the fanart, the chain, the record + // that it was asked — still describes it and is kept: refreshing a + // match's details must not cost the library its episode lists. self.conn.execute( - "INSERT INTO title_metadata ( + &"INSERT INTO title_metadata ( title_key, provider, matched, fetched_at, remote_id, name, original_name, overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url, - manual - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17) + manual, details + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, + ?17, ?18) ON CONFLICT (title_key) DO UPDATE SET provider = excluded.provider, matched = excluded.matched, @@ -767,13 +834,23 @@ impl Catalog { year = excluded.year, kind = excluded.kind, poster_url = excluded.poster_url, - backdrop_url = excluded.backdrop_url, + backdrop_url = CASE WHEN same THEN COALESCE(excluded.backdrop_url, + title_metadata.backdrop_url) + ELSE excluded.backdrop_url END, rating = excluded.rating, genres = excluded.genres, episodes = excluded.episodes, url = excluded.url, manual = excluded.manual, - enriched_at = NULL", + details = excluded.details, + chain = CASE WHEN same THEN title_metadata.chain END, + enriched_at = CASE WHEN same THEN title_metadata.enriched_at END" + .replace( + "same", + "(title_metadata.provider = excluded.provider + AND title_metadata.remote_id IS excluded.remote_id + AND excluded.matched = 1)", + ), params![ record.title_key, record.provider.as_str(), @@ -792,6 +869,7 @@ impl Catalog { data.and_then(|data| data.episodes), data.and_then(|data| data.url.as_deref()), record.manual as i64, + details, ], )?; Ok(()) @@ -859,13 +937,14 @@ impl Catalog { replace_episodes(&tx, title_key, provider, fetched_at, &enrichment.episodes)?; tx.execute( "UPDATE title_metadata - SET enriched_at = ?3, backdrop_url = COALESCE(?4, backdrop_url) + SET enriched_at = ?3, backdrop_url = COALESCE(?4, backdrop_url), chain = ?5 WHERE title_key = ?1 AND provider = ?2", params![ title_key, provider.as_str(), fetched_at, enrichment.backdrop_url.as_deref(), + enrichment.chain.join("\n"), ], )?; tx.commit()?; @@ -982,6 +1061,28 @@ fn replace_episodes( Ok(()) } +/// The `details` and `chain` columns, read back. +/// +/// JSON that will not parse is treated as never fetched rather than failing +/// the read: the next match run fetches it again, where an error here would +/// take the whole grid down over one row. +fn details_column(details: Option, chain: Option) -> Option { + let mut details: TitleDetails = serde_json::from_str(&details?).ok()?; + details.chain = chain + .unwrap_or_default() + .lines() + .filter(|id| !id.is_empty()) + .map(str::to_string) + .collect(); + Some(details) +} + +fn unix_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |since| since.as_secs() as i64) +} + /// `None` for a row whose provider column this build does not recognise. fn row_to_metadata(row: &rusqlite::Row<'_>) -> rusqlite::Result> { let title_key: String = row.get(0)?; @@ -1014,6 +1115,7 @@ fn row_to_metadata(row: &rusqlite::Row<'_>) -> rusqlite::Result 0); + catalog + .conn + .execute("UPDATE node_first_seen SET seen_at = 5", []) + .expect("age it"); + catalog + .replace_share( + "s", + &[ + file("s", "a", "Show S01E01.mkv"), + file("s", "b", "Show S01E02.mkv"), + ], + ) + .expect("recrawl"); + let seen = catalog.first_seen().expect("read"); + assert_eq!(seen[&("s".to_string(), "a".to_string())], 5); + assert!(seen[&("s".to_string(), "b".to_string())] > 5); + } + /// Schema v9 repairs what AniList's first episode source wrote: its /// episode rows and its banners go, and the matches — hand-picked ones /// included — stay. diff --git a/crates/pstr-core/src/franchise.rs b/crates/pstr-core/src/franchise.rs new file mode 100644 index 0000000..d38979a --- /dev/null +++ b/crates/pstr-core/src/franchise.rs @@ -0,0 +1,290 @@ +//! Which titles in a library are one story. +//! +//! A share files a franchise however its uploader did: Made in Abyss as a +//! series folder and three film folders, Evangelion as a series, two films and +//! a folder of four more. The provider knows they belong together — AniList +//! records prequels, sequels, side stories, recaps and retellings as relations +//! — so the library can show one tile for the lot and the parts on its page. +//! +//! Grouping is by shared provider id, not by name. Two titles are one +//! franchise when one names the other as related, or when both name the same +//! entry: Mushishi's *Suzu no Shizuku* film relates to *Zoku Shou 2*, which the +//! library does not hold as a title of its own but which the series' episode +//! walk passed through (see [`crate::metadata::TitleDetails::chain`]). + +use std::collections::HashMap; + +use crate::library::{Title, TitleKind}; +use crate::metadata::MetadataRecord; + +/// One franchise: indices into the titles it was built from, in release order. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Franchise { + pub members: Vec, +} + +impl Franchise { + /// The member that stands for the rest: the first series to air, or the + /// first film when there is no series. What a viewer means by "Made in + /// Abyss" is the show, not the recap film that came out after it. + pub fn lead(&self, titles: &[&Title]) -> usize { + self.members + .iter() + .copied() + .find(|&index| titles[index].kind == TitleKind::Series) + .unwrap_or(self.members[0]) + } +} + +/// Group `titles` into franchises, every title in exactly one. +/// +/// Titles with no match are franchises of one. The groups come back in the +/// order of their first member in `titles`, so a sorted list stays sorted. +pub fn group(titles: &[&Title], metadata: &HashMap) -> Vec { + let mut sets = DisjointSets::new(titles.len()); + // The first title to name each provider id, for later titles naming it too. + let mut named: HashMap<(crate::metadata::ProviderId, &str), usize> = HashMap::new(); + for (index, title) in titles.iter().enumerate() { + let Some(found) = metadata + .get(&title.key) + .and_then(|record| record.metadata.as_ref()) + else { + continue; + }; + let details = found.details.as_ref(); + let ids = std::iter::once(found.remote_id.as_str()) + .chain(details.into_iter().flat_map(|details| { + details + .related + .iter() + .chain(&details.chain) + .map(String::as_str) + })) + .filter(|id| !id.is_empty()); + for id in ids { + match named.get(&(found.provider, id)) { + Some(&other) => sets.union(index, other), + None => { + named.insert((found.provider, id), index); + } + } + } + } + + let mut groups: Vec = Vec::new(); + let mut slot: HashMap = HashMap::new(); + for index in 0..titles.len() { + let root = sets.find(index); + match slot.get(&root) { + Some(&at) => groups[at].members.push(index), + None => { + slot.insert(root, groups.len()); + groups.push(Franchise { + members: vec![index], + }); + } + } + } + for franchise in &mut groups { + franchise + .members + .sort_by_cached_key(|&index| release_key(titles[index], metadata)); + } + groups +} + +/// What release order sorts on: the provider's start date, else the year the +/// files state, else last. +pub fn release_key(title: &Title, metadata: &HashMap) -> String { + let found = metadata + .get(&title.key) + .and_then(|record| record.metadata.as_ref()); + found + .and_then(|found| found.details.as_ref()) + .and_then(|details| details.start_date.clone()) + .or_else(|| { + found + .and_then(|found| found.year) + .map(|year| year.to_string()) + }) + .or_else(|| title.year.map(|year| year.to_string())) + // `~` sorts after every digit: undated goes last. + .unwrap_or_else(|| "~".to_string()) +} + +struct DisjointSets { + parent: Vec, +} + +impl DisjointSets { + fn new(size: usize) -> Self { + Self { + parent: (0..size).collect(), + } + } + + fn find(&mut self, mut index: usize) -> usize { + while self.parent[index] != index { + self.parent[index] = self.parent[self.parent[index]]; + index = self.parent[index]; + } + index + } + + fn union(&mut self, left: usize, right: usize) { + let (left, right) = (self.find(left), self.find(right)); + if left != right { + // The earlier title stays the root, which keeps `group` stable. + let (root, child) = (left.min(right), left.max(right)); + self.parent[child] = root; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::metadata::{ProviderId, TitleDetails, TitleMetadata}; + + fn title(key: &str, kind: TitleKind) -> Title { + Title { + key: key.into(), + name: key.into(), + year: None, + kind, + seasons: Vec::new(), + share_ids: Vec::new(), + } + } + + fn matched( + key: &str, + id: &str, + start: &str, + related: &[&str], + chain: &[&str], + ) -> MetadataRecord { + MetadataRecord { + title_key: key.into(), + provider: ProviderId::AniList, + metadata: Some(TitleMetadata { + provider: ProviderId::AniList, + remote_id: id.into(), + name: key.into(), + original_name: None, + overview: None, + year: None, + kind: TitleKind::Series, + poster_url: None, + backdrop_url: None, + rating: None, + genres: Vec::new(), + episodes: None, + url: None, + details: Some(TitleDetails { + start_date: Some(start.into()), + related: related.iter().map(|id| id.to_string()).collect(), + chain: chain.iter().map(|id| id.to_string()).collect(), + ..TitleDetails::default() + }), + }), + fetched_at: 0, + manual: false, + } + } + + fn keys(groups: &[Franchise], titles: &[&Title]) -> Vec> { + groups + .iter() + .map(|group| { + group + .members + .iter() + .map(|&index| titles[index].key.clone()) + .collect() + }) + .collect() + } + + /// Made in Abyss: the films name the series, the series names the films. + /// Paprika names nothing in the library and stays alone. + #[test] + fn titles_that_name_each_other_are_one_franchise_in_release_order() { + let owned = [ + title("dawn of the deep soul", TitleKind::Film), + title("made in abyss", TitleKind::Series), + title("paprika", TitleKind::Film), + ]; + let titles: Vec<&Title> = owned.iter().collect(); + let metadata = HashMap::from([ + ( + "dawn of the deep soul".to_string(), + matched( + "dawn of the deep soul", + "100643", + "2020-01-17", + &["97986"], + &[], + ), + ), + ( + "made in abyss".to_string(), + matched("made in abyss", "97986", "2017-07-07", &["100643"], &[]), + ), + ( + "paprika".to_string(), + matched("paprika", "1943", "2006-11-25", &[], &[]), + ), + ]); + + let groups = group(&titles, &metadata); + assert_eq!( + keys(&groups, &titles), + vec![ + vec!["made in abyss", "dawn of the deep soul"], + vec!["paprika"] + ] + ); + assert_eq!(titles[groups[0].lead(&titles)].key, "made in abyss"); + } + + /// Mushishi: the special names an entry the series only reached by + /// walking its sequels for episodes. That is still the same story. + #[test] + fn an_entry_the_series_walked_through_joins_what_names_it() { + let owned = [ + title("mushishi", TitleKind::Series), + title("suzu no shizuku", TitleKind::Film), + ]; + let titles: Vec<&Title> = owned.iter().collect(); + let metadata = HashMap::from([ + ( + "mushishi".to_string(), + matched( + "mushishi", + "457", + "2005-10-23", + &["20526"], + &["457", "20595", "20751"], + ), + ), + ( + "suzu no shizuku".to_string(), + matched("suzu no shizuku", "21008", "2015-05-16", &["20751"], &[]), + ), + ]); + assert_eq!(group(&titles, &metadata).len(), 1); + } + + /// Nothing matched, nothing to group by — and two unmatched titles are + /// not one franchise because they share an empty id. + #[test] + fn unmatched_titles_stand_alone() { + let owned = [ + title("game of thrones", TitleKind::Series), + title("something else", TitleKind::Series), + ]; + let titles: Vec<&Title> = owned.iter().collect(); + assert_eq!(group(&titles, &HashMap::new()).len(), 2); + } +} diff --git a/crates/pstr-core/src/lib.rs b/crates/pstr-core/src/lib.rs index bd714b5..634e920 100644 --- a/crates/pstr-core/src/lib.rs +++ b/crates/pstr-core/src/lib.rs @@ -10,11 +10,13 @@ pub mod catalog; pub mod chapters; pub mod config; pub mod error; +pub mod franchise; pub mod library; pub mod metadata; pub mod naming; pub mod prefs; pub mod shares; +pub mod shelves; pub use error::{Error, Result}; #[cfg(not(target_os = "android"))] diff --git a/crates/pstr-core/src/library.rs b/crates/pstr-core/src/library.rs index f9d4e9d..d1ba7b4 100644 --- a/crates/pstr-core/src/library.rs +++ b/crates/pstr-core/src/library.rs @@ -389,6 +389,27 @@ impl Library { 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 { + 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> { @@ -438,7 +459,7 @@ pub fn title_key(title: &str) -> String { /// Sort key for the shelf: case-insensitive, and a leading article ignored, so /// `The Expanse` files under E where a viewer looks for it. -fn sort_name(name: &str) -> String { +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) { diff --git a/crates/pstr-core/src/metadata.rs b/crates/pstr-core/src/metadata.rs index d7072ce..f052a13 100644 --- a/crates/pstr-core/src/metadata.rs +++ b/crates/pstr-core/src/metadata.rs @@ -31,7 +31,7 @@ use crate::library::TitleKind; /// Stored as a string in the catalog rather than an integer: a row whose /// provider this build does not recognise should read as "some other provider", /// which is a re-match, not a parse failure. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum ProviderId { /// AniList. Anime only, no API key, no account. @@ -112,6 +112,154 @@ pub struct TitleMetadata { pub episodes: Option, /// The title's page, for a viewer who wants the rest of it. pub url: Option, + /// Everything else the provider said: what the library is sorted, grouped + /// and shelved by. `None` on a record stored before there was any of it, + /// which is what tells a match run to fetch it — see + /// `pstr_meta::service::plan`. + pub details: Option, +} + +/// What a provider says about a title beyond what a tile draws. +/// +/// Stored whole as one JSON column rather than a column each: nothing here is +/// ever queried by SQLite, every field is optional at every provider, and a +/// field added later has to read as absent from a row written before it, not +/// fail the row. TMDB fills none of it yet. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct TitleDetails { + /// The title in romaji — `Sousou no Frieren` — for a viewer who would + /// rather see that than the English one. + pub romaji: Option, + /// AniList's ultra-wide banner, around 4.75:1. Never drawn into a 16:9 + /// tile — it is what a strip that shape falls back to when there is no + /// fanart. + pub banner_url: Option, + /// As the provider writes it: `TV`, `TV_SHORT`, `MOVIE`, `SPECIAL`, `OVA`, + /// `ONA`, `MUSIC`. [`TitleDetails::format_label`] is what is shown. + pub format: Option, + /// The broadcast season: `WINTER`, `SPRING`, `SUMMER` or `FALL`. + pub season: Option, + /// When it first aired, `YYYY-MM-DD` with whatever parts are known — + /// `2013-04`, `2013`. Sorts as text, which is why it is kept that way. + pub start_date: Option, + /// The main animation studios. + pub studios: Vec, + pub directors: Vec, + /// The provider's descriptive tags, most relevant first, spoilers left out. + pub tags: Vec, + /// How many people follow it at the provider. Only ever compared with + /// other titles from the same provider. + pub popularity: Option, + /// `FINISHED`, `RELEASING`, `NOT_YET_RELEASED`, `CANCELLED` or `HIATUS`. + pub status: Option, + /// The next episode to air, and when — Unix seconds. As of when the + /// answer was fetched, so a time in the past means nothing. + pub next_episode: Option, + pub next_airing_at: Option, + /// The provider ids of entries in the same story — prequels, sequels, + /// side stories, recaps, retellings. What the library groups a franchise + /// by; see `crate::franchise`. + pub related: Vec, + /// The entries enrichment walked through to find the title's episodes — + /// its own sequels, by provider id. Stored apart from the rest, because + /// it is enrichment's answer rather than the match's. + #[serde(skip)] + pub chain: Vec, +} + +impl TitleDetails { + /// `TV` → `TV`, `MOVIE` → `Film`, `TV_SHORT` → `TV short`. + pub fn format_label(&self) -> Option<&'static str> { + Some(match self.format.as_deref()? { + "TV" => "TV", + "TV_SHORT" => "TV short", + "MOVIE" => "Film", + "SPECIAL" => "Special", + "OVA" => "OVA", + "ONA" => "ONA", + "MUSIC" => "Music video", + _ => return None, + }) + } + + /// `Spring 2013`, or just the year. + pub fn season_label(&self) -> Option { + let year = self.start_date.as_deref()?.get(..4)?; + let season = match self.season.as_deref() { + Some("WINTER") => "Winter", + Some("SPRING") => "Spring", + Some("SUMMER") => "Summer", + Some("FALL") => "Fall", + _ => return Some(year.to_string()), + }; + Some(format!("{season} {year}")) + } + + /// Whether it is still airing. + pub fn is_airing(&self) -> bool { + self.status.as_deref() == Some("RELEASING") + } + + /// The next episode and when it airs, when that is still after `now`. + pub fn next_airing(&self, now: i64) -> Option<(u32, i64)> { + let at = self.next_airing_at.filter(|at| *at > now)?; + Some((self.next_episode?, at)) + } +} + +impl TitleMetadata { + /// Ultra-wide art: the backdrop where there is one, else the banner. + /// + /// For the library's hero and the title page's band, which are much wider + /// than 16:9 — a backdrop cropped to them loses some sky, a banner fits + /// them as drawn. + pub fn wide_art(&self) -> Option<&str> { + self.backdrop_url.as_deref().or_else(|| { + self.details + .as_ref() + .and_then(|details| details.banner_url.as_deref()) + }) + } + + /// The name to show, as the viewer asked for it. + pub fn display_name(&self, names: TitleNames) -> Option<&str> { + match names { + TitleNames::Library => None, + TitleNames::English => Some(&self.name), + TitleNames::Romaji => self + .details + .as_ref() + .and_then(|details| details.romaji.as_deref()) + .or(Some(&self.name)), + } + } +} + +/// Which name a title goes by in the library. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum TitleNames { + /// The folder's — what the share calls it. The default, because it is the + /// one name that is there whether or not anything matched. + #[default] + Library, + /// The provider's English title, where it has one. + English, + /// The provider's romaji title. + Romaji, +} + +impl TitleNames { + pub const ALL: [Self; 3] = [Self::Library, Self::English, Self::Romaji]; + + pub fn label(self) -> &'static str { + match self { + Self::Library => "As in the share", + Self::English => "English", + Self::Romaji => "Romaji", + } + } } impl TitleMetadata { @@ -181,6 +329,9 @@ pub struct Enrichment { /// A 16:9 picture of the whole title, when one was found. `None` leaves /// whatever backdrop the match already had. pub backdrop_url: Option, + /// Every provider entry the episodes were taken from, the match's own + /// included. See [`TitleDetails::chain`]. + pub chain: Vec, } /// Every episode a provider listed for one title, ready to look up. @@ -401,6 +552,7 @@ mod tests { genres: Vec::new(), episodes: Some(26), url: None, + details: None, } } diff --git a/crates/pstr-core/src/shelves.rs b/crates/pstr-core/src/shelves.rs new file mode 100644 index 0000000..98cc27c --- /dev/null +++ b/crates/pstr-core/src/shelves.rs @@ -0,0 +1,261 @@ +//! Rows of titles that share something, for the top of the library. +//! +//! A grid sorted one way answers one question. Shelves answer the ones a +//! viewer did not think to ask: what is still airing, what else that studio +//! made, the director whose four films happen to be in the share. They are +//! picked from what the provider said, and only where there is enough of +//! something to be worth a row — three titles from one studio is a shelf, one +//! is a fact on its page. + +use std::collections::{BTreeMap, HashMap}; + +use crate::library::Title; +use crate::metadata::MetadataRecord; + +/// How many franchises a person or studio needs before it earns a row. +/// +/// Franchises, not titles: Made in Abyss and its three films are four titles +/// with one director, and a "Directed by" row holding nothing but them says +/// what the franchise's own tile already does. +const MIN_TITLES: usize = 3; + +/// A genre on more than this share of the matched titles says nothing: an +/// "Action" row in an anime library is half the library again — which is what +/// it was, at 0.6, in the library this was tuned on. +const MAX_GENRE_SHARE: f32 = 0.4; + +/// One row: a heading, and the keys of the titles under it, in library order. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Shelf { + pub label: String, + pub keys: Vec, +} + +/// Up to `limit` shelves for `titles`, most specific first: what is airing, +/// then directors, studios and genres, each by how many titles it holds. +/// +/// Two shelves holding exactly the same titles are one shelf — the first, +/// which is the more specific. +pub fn pick( + titles: &[Title], + metadata: &HashMap, + limit: usize, +) -> Vec { + let mut airing = Vec::new(); + let mut directors: BTreeMap<&str, Vec> = BTreeMap::new(); + let mut studios: BTreeMap<&str, Vec> = BTreeMap::new(); + let mut genres: BTreeMap<&str, Vec> = BTreeMap::new(); + let mut matched = 0usize; + let refs: Vec<&Title> = titles.iter().collect(); + let franchise_of: HashMap<&str, usize> = crate::franchise::group(&refs, metadata) + .into_iter() + .enumerate() + .flat_map(|(at, franchise)| { + franchise + .members + .into_iter() + .map(move |member| (titles[member].key.as_str(), at)) + }) + .collect(); + let franchises = |keys: &[String]| { + keys.iter() + .filter_map(|key| franchise_of.get(key.as_str())) + .collect::>() + .len() + }; + + for title in titles { + let Some(found) = metadata + .get(&title.key) + .and_then(|record| record.metadata.as_ref()) + else { + continue; + }; + matched += 1; + for genre in &found.genres { + genres.entry(genre).or_default().push(title.key.clone()); + } + let Some(details) = &found.details else { + continue; + }; + if details.is_airing() { + airing.push(title.key.clone()); + } + for director in &details.directors { + directors + .entry(director) + .or_default() + .push(title.key.clone()); + } + for studio in &details.studios { + studios.entry(studio).or_default().push(title.key.clone()); + } + } + + let ranked = |groups: BTreeMap<&str, Vec>, label: &dyn Fn(&str) -> String| { + let mut rows: Vec = groups + .into_iter() + .filter(|(_, keys)| franchises(keys) >= MIN_TITLES) + .map(|(name, keys)| Shelf { + label: label(name), + keys, + }) + .collect(); + // Stable, so equal counts keep their alphabetical order. + rows.sort_by_key(|shelf| std::cmp::Reverse(shelf.keys.len())); + rows + }; + + let genre_ceiling = (matched as f32 * MAX_GENRE_SHARE).floor() as usize; + let genres: BTreeMap<&str, Vec> = genres + .into_iter() + .filter(|(_, keys)| keys.len() <= genre_ceiling) + .collect(); + + let mut shelves = Vec::new(); + if !airing.is_empty() { + shelves.push(Shelf { + label: "Airing now".into(), + keys: airing, + }); + } + shelves.extend(ranked(directors, &|name| format!("Directed by {name}"))); + shelves.extend(ranked(studios, &|name| format!("From {name}"))); + shelves.extend(ranked(genres, &|name| name.to_string())); + + let mut picked: Vec = Vec::new(); + for shelf in shelves { + if picked.len() == limit { + break; + } + if !picked.iter().any(|other| other.keys == shelf.keys) { + picked.push(shelf); + } + } + picked +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::library::TitleKind; + use crate::metadata::{ProviderId, TitleDetails, TitleMetadata}; + + fn title(key: &str) -> Title { + Title { + key: key.into(), + name: key.into(), + year: None, + kind: TitleKind::Film, + seasons: Vec::new(), + share_ids: Vec::new(), + } + } + + fn record(key: &str, genres: &[&str], details: TitleDetails) -> (String, MetadataRecord) { + ( + key.to_string(), + MetadataRecord { + title_key: key.into(), + provider: ProviderId::AniList, + metadata: Some(TitleMetadata { + provider: ProviderId::AniList, + remote_id: key.into(), + name: key.into(), + original_name: None, + overview: None, + year: None, + kind: TitleKind::Film, + poster_url: None, + backdrop_url: None, + rating: None, + genres: genres.iter().map(|genre| genre.to_string()).collect(), + episodes: None, + url: None, + details: Some(details), + }), + fetched_at: 0, + manual: false, + }, + ) + } + + fn kon(studio: &str) -> TitleDetails { + TitleDetails { + directors: vec!["Satoshi Kon".into()], + studios: vec![studio.into()], + ..TitleDetails::default() + } + } + + /// The case this exists for: four Satoshi Kon films in one share. + #[test] + fn a_director_with_enough_titles_gets_a_shelf_ahead_of_a_studio() { + let titles: Vec = [ + "perfect blue", + "millennium actress", + "tokyo godfathers", + "paprika", + ] + .map(title) + .to_vec(); + let metadata = HashMap::from([ + record("perfect blue", &["Drama"], kon("Madhouse")), + record("millennium actress", &["Drama"], kon("Madhouse")), + record("tokyo godfathers", &["Drama"], kon("Madhouse")), + record("paprika", &["Sci-Fi"], kon("Madhouse")), + ]); + let shelves = pick(&titles, &metadata, 5); + assert_eq!(shelves[0].label, "Directed by Satoshi Kon"); + assert_eq!(shelves[0].keys.len(), 4); + // Madhouse holds exactly the same four, so it is not a second row. + assert!(!shelves.iter().any(|shelf| shelf.label == "From Madhouse")); + // Drama is three of four: past the share a genre may cover. + assert!(!shelves.iter().any(|shelf| shelf.label == "Drama")); + } + + /// Made in Abyss: four titles, one director, one story. Not a shelf. + #[test] + fn one_franchise_is_not_a_shelf_however_many_titles_it_has() { + let titles: Vec<Title> = ["abyss", "film one", "film two", "film three"] + .map(title) + .to_vec(); + let kojima = |related: &[&str]| TitleDetails { + directors: vec!["Masayuki Kojima".into()], + related: related.iter().map(|id| id.to_string()).collect(), + ..TitleDetails::default() + }; + let metadata = HashMap::from([ + record( + "abyss", + &[], + kojima(&["film one", "film two", "film three"]), + ), + record("film one", &[], kojima(&["abyss"])), + record("film two", &[], kojima(&["abyss"])), + record("film three", &[], kojima(&["abyss"])), + ]); + assert!(pick(&titles, &metadata, 5).is_empty()); + } + + #[test] + fn what_is_airing_comes_first_and_too_few_titles_make_no_shelf() { + let titles: Vec<Title> = ["a", "b"].map(title).to_vec(); + let metadata = HashMap::from([ + record( + "a", + &[], + TitleDetails { + status: Some("RELEASING".into()), + studios: vec!["MAPPA".into()], + ..TitleDetails::default() + }, + ), + record("b", &[], TitleDetails::default()), + ]); + let shelves = pick(&titles, &metadata, 5); + assert_eq!(shelves.len(), 1); + assert_eq!(shelves[0].label, "Airing now"); + assert_eq!(shelves[0].keys, vec!["a"]); + } +} diff --git a/crates/pstr-meta/src/anilist.rs b/crates/pstr-meta/src/anilist.rs index 919391b..5c3ef36 100644 --- a/crates/pstr-meta/src/anilist.rs +++ b/crates/pstr-meta/src/anilist.rs @@ -17,7 +17,9 @@ use std::collections::HashSet; use pstr_core::library::{self, TitleKind}; -use pstr_core::metadata::{Enrichment, EpisodeGuide, EpisodeMetadata, ProviderId, TitleMetadata}; +use pstr_core::metadata::{ + Enrichment, EpisodeGuide, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata, +}; use serde::Deserialize; use crate::anizip::AniZip; @@ -54,25 +56,81 @@ const PAGE_SIZE: u32 = 8; /// AniList scores out of 100 and the rest of the app works out of 10. const SCORE_SCALE: f32 = 10.0; -const SEARCH: &str = r#" +/// Everything one entry is asked for, by search and by id alike. +/// +/// The relations are the franchise's — see `pstr_core::franchise` — and the +/// staff are only the most relevant few, which is where a director sits. +macro_rules! entry_fragment { + () => { + r#" +fragment entry on Media { + id + title { romaji english native } + synonyms + description(asHtml: false) + startDate { year month day } + season + coverImage { extraLarge large } + bannerImage + averageScore + popularity + genres + tags { name rank isMediaSpoiler } + episodes + format + status + siteUrl + studios(isMain: true) { nodes { name } } + staff(sort: RELEVANCE, perPage: 6) { edges { role node { name { full } } } } + nextAiringEpisode { episode airingAt } + relations { edges { relationType node { id type } } } +} +"# + }; +} + +const SEARCH: &str = concat!( + r#" query ($search: String, $perPage: Int) { Page(page: 1, perPage: $perPage) { - media(search: $search, type: ANIME, sort: SEARCH_MATCH) { - id - title { romaji english native } - synonyms - description(asHtml: false) - startDate { year } - coverImage { extraLarge large } - averageScore - genres - episodes - format - siteUrl - } + media(search: $search, type: ANIME, sort: SEARCH_MATCH) { ...entry } } } -"#; +"#, + entry_fragment!() +); + +/// One entry by the id a match already found — how a stored match gets the +/// fields it was stored without, without being searched for and decided +/// again. +const BY_ID: &str = concat!( + r#" +query ($id: Int) { + Media(id: $id, type: ANIME) { ...entry } +} +"#, + entry_fragment!() +); + +/// The relations that keep to one story. `SPIN_OFF`, `CHARACTER` and `OTHER` +/// are left out: they are how one franchise reaches into another, and +/// following them merged unrelated shows into one tile. +const STORY_RELATIONS: [&str; 8] = [ + "PREQUEL", + "SEQUEL", + "PARENT", + "SIDE_STORY", + "SUMMARY", + "ALTERNATIVE", + "COMPILATION", + "CONTAINS", +]; + +/// A tag ranked below this is AniList's community being unsure of it. +const MIN_TAG_RANK: u32 = 60; + +/// How many tags to keep: the rest are increasingly specific trivia. +const MAX_TAGS: usize = 8; /// The entries an entry leads on to. /// @@ -173,6 +231,8 @@ impl Provider for AniList { let mut enrichment = Enrichment::default(); let mut taken = HashSet::new(); let mut visited = HashSet::new(); + // The same ids as `visited`, in the order they were read. + let mut chain = Vec::new(); let mut series_entries = 0; let mut next = Some(Link { id: first, @@ -180,6 +240,7 @@ impl Provider for AniList { }); while let Some(link) = next.take() { visited.insert(link.id); + chain.push(link.id.to_string()); let entry_season = link.series.then(|| { series_entries += 1; series_entries @@ -209,11 +270,29 @@ impl Provider for AniList { enrichment.episodes.len(), visited.len() ); + enrichment.chain = chain; Ok(enrichment) } } impl AniList { + /// The entry the AniList id `id` names, as search would have found it. + /// `None` for an id AniList no longer has. + pub async fn entry(&self, id: &str) -> Result<Option<TitleMetadata>> { + let Ok(id) = id.parse::<i64>() else { + return Ok(None); + }; + let data: Option<EntryData> = self + .query(serde_json::json!({ + "query": BY_ID, + "variables": { "id": id }, + })) + .await?; + Ok(data + .and_then(|data| data.media) + .map(|media| media.into_candidate().metadata)) + } + /// The entry `id` continues into, if any. async fn sequel(&self, id: i64, visited: &HashSet<i64>) -> Result<Option<Link>> { let data: Option<RelationsData> = self @@ -443,14 +522,80 @@ struct Media { start_date: Option<FuzzyDate>, #[serde(rename = "coverImage")] cover_image: Option<CoverImage>, + #[serde(rename = "bannerImage")] + banner_image: Option<String>, #[serde(rename = "averageScore")] average_score: Option<f32>, + popularity: Option<u32>, #[serde(default)] genres: Vec<String>, + #[serde(default)] + tags: Vec<Tag>, episodes: Option<u32>, format: Option<String>, + season: Option<String>, + status: Option<String>, #[serde(rename = "siteUrl")] site_url: Option<String>, + studios: Option<Studios>, + staff: Option<Staff>, + #[serde(rename = "nextAiringEpisode")] + next_airing_episode: Option<NextAiring>, + relations: Option<Relations>, +} + +#[derive(Deserialize)] +struct EntryData { + #[serde(rename = "Media")] + media: Option<Media>, +} + +#[derive(Deserialize)] +struct Tag { + name: String, + rank: Option<u32>, + #[serde(rename = "isMediaSpoiler", default)] + spoiler: bool, +} + +#[derive(Deserialize)] +struct Studios { + #[serde(default)] + nodes: Vec<Named>, +} + +#[derive(Deserialize)] +struct Named { + name: String, +} + +#[derive(Deserialize)] +struct Staff { + #[serde(default)] + edges: Vec<StaffEdge>, +} + +#[derive(Deserialize)] +struct StaffEdge { + role: Option<String>, + node: Option<Person>, +} + +#[derive(Deserialize)] +struct Person { + name: PersonName, +} + +#[derive(Deserialize)] +struct PersonName { + full: Option<String>, +} + +#[derive(Deserialize)] +struct NextAiring { + episode: Option<u32>, + #[serde(rename = "airingAt")] + airing_at: Option<i64>, } #[derive(Deserialize)] @@ -463,6 +608,20 @@ struct Title { #[derive(Deserialize)] struct FuzzyDate { year: Option<u32>, + month: Option<u32>, + day: Option<u32>, +} + +impl FuzzyDate { + /// `2013-04-07`, `2013-04` or `2013` — as much as AniList knows. + fn text(&self) -> Option<String> { + let year = self.year?; + Some(match (self.month, self.day) { + (Some(month), Some(day)) => format!("{year:04}-{month:02}-{day:02}"), + (Some(month), None) => format!("{year:04}-{month:02}"), + _ => format!("{year:04}"), + }) + } } #[derive(Deserialize)] @@ -506,6 +665,72 @@ impl Media { .cover_image .and_then(|cover| cover.extra_large.or(cover.large)); + let details = TitleDetails { + romaji: self.title.romaji.clone(), + banner_url: self.banner_image, + format: self.format.clone(), + season: self.season, + start_date: self.start_date.as_ref().and_then(FuzzyDate::text), + studios: self + .studios + .map(|studios| { + studios + .nodes + .into_iter() + .map(|studio| studio.name) + .collect() + }) + .unwrap_or_default(), + directors: self + .staff + .map(|staff| { + staff + .edges + .into_iter() + // `Director`, and not `Assistant Director`, + // `Episode Director` or `Director (eps 3, 7)` — those + // are many people per show, and a shelf of one + // storyboard artist's episodes is not what a + // "directed by" row means. + .filter(|edge| edge.role.as_deref() == Some("Director")) + .filter_map(|edge| edge.node.and_then(|person| person.name.full)) + .collect() + }) + .unwrap_or_default(), + tags: self + .tags + .into_iter() + .filter(|tag| !tag.spoiler && tag.rank.unwrap_or(0) >= MIN_TAG_RANK) + .take(MAX_TAGS) + .map(|tag| tag.name) + .collect(), + popularity: self.popularity, + status: self.status, + next_episode: self + .next_airing_episode + .as_ref() + .and_then(|next| next.episode), + next_airing_at: self.next_airing_episode.and_then(|next| next.airing_at), + related: self + .relations + .map(|relations| { + relations + .edges + .into_iter() + .filter(|edge| { + edge.relation_type + .as_deref() + .is_some_and(|kind| STORY_RELATIONS.contains(&kind)) + }) + .filter_map(|edge| edge.node) + .filter(|node| node.kind.as_deref() == Some("ANIME")) + .map(|node| node.id.to_string()) + .collect() + }) + .unwrap_or_default(), + chain: Vec::new(), + }; + Candidate { metadata: TitleMetadata { provider: ProviderId::AniList, @@ -525,6 +750,7 @@ impl Media { genres: self.genres, episodes: self.episodes, url: self.site_url, + details: Some(details), }, aliases, // Null on anything AniList has not seen air yet, which is not the diff --git a/crates/pstr-meta/src/matching.rs b/crates/pstr-meta/src/matching.rs index 31771fb..6fac0eb 100644 --- a/crates/pstr-meta/src/matching.rs +++ b/crates/pstr-meta/src/matching.rs @@ -208,6 +208,7 @@ mod tests { genres: Vec::new(), episodes: None, url: None, + details: None, }, aliases: std::iter::once(name) .chain(aliases.iter().copied()) diff --git a/crates/pstr-meta/src/service.rs b/crates/pstr-meta/src/service.rs index 9d1f8e7..f9f5696 100644 --- a/crates/pstr-meta/src/service.rs +++ b/crates/pstr-meta/src/service.rs @@ -64,6 +64,20 @@ impl Source { } } + /// The entry a stored match names, fetched again by its id. `None` from a + /// provider that has no details to add. + async fn entry(&self, id: &str) -> Result<Option<TitleMetadata>> { + match self { + Self::AniList(provider) => provider.entry(id).await, + Self::Tmdb(_) => Ok(None), + } + } + + /// Whether [`Source::entry`] has anything to give. + fn has_details(&self) -> bool { + matches!(self, Self::AniList(_)) + } + async fn enrich(&self, title: &Title, found: &TitleMetadata) -> Result<Enrichment> { match self { Self::AniList(provider) => provider.enrich(title, found).await, @@ -247,12 +261,20 @@ pub enum Work { Match(Title), /// It is already matched; only its enrichment is missing or old. Enrich(Title, Box<TitleMetadata>), + /// It is matched, but stored before [`TitleMetadata::details`] existed: + /// fetch the same entry again by id — never searched for, so a match the + /// viewer picked by hand stays theirs — and enrich it too if `enrich`. + Refresh { + title: Title, + record: Box<MetadataRecord>, + enrich: bool, + }, } impl Work { pub fn title(&self) -> &Title { match self { - Self::Match(title) | Self::Enrich(title, _) => title, + Self::Match(title) | Self::Enrich(title, _) | Self::Refresh { title, .. } => title, } } } @@ -261,7 +283,8 @@ impl Work { #[derive(Debug)] pub struct Outcome { pub title_key: String, - /// The new answer, for [`Work::Match`]. Store it — misses included. + /// The new answer, for [`Work::Match`] and [`Work::Refresh`]. Store it — + /// misses included. pub record: Option<MetadataRecord>, /// What enriching the match found, when it was asked and answered. `None` /// after a failure, which must leave the title un-enriched so the next run @@ -271,20 +294,26 @@ pub struct Outcome { /// Decide what a match run over `titles` has to do. /// -/// Two kinds of work, and the difference is a request saved: a title that +/// Three kinds of work, and the difference is requests saved: a title that /// already has a good match but has not been enriched — or was enriched longer /// ago than a match is trusted — needs enriching only, and searching for it -/// again would ask the provider something it has already answered. +/// again would ask the provider something it has already answered. One whose +/// match predates its details is fetched again by id, which is one request +/// and decides nothing. /// /// A hand-picked entry is never searched for again, not even when `force`d — /// that means "the automatic answers are wrong", and re-deciding the one title /// the viewer already fixed by hand is the opposite of what they asked for. It -/// is still enriched. +/// is still enriched and refreshed. +/// +/// `details` is whether the provider has [`TitleMetadata::details`] to give +/// — see [`MetadataService::has_details`]. pub fn plan( titles: Vec<Title>, stored: &HashMap<String, MetadataRecord>, enriched: &HashMap<String, (ProviderId, i64)>, provider: ProviderId, + details: bool, force: bool, ) -> Vec<Work> { let now = now(); @@ -296,12 +325,24 @@ pub fn plan( work.push(Work::Match(title)); continue; } + let Some(record) = record.filter(|record| record.metadata.is_some()) else { + continue; + }; let fresh = enriched.get(&title.key).is_some_and(|(asked, at)| { *asked == provider && now.saturating_sub(*at) < MATCH_TTL_SECS }); - if let Some(found) = record.and_then(|record| record.metadata.clone()) - && !fresh - { + let stale_details = details + && record + .metadata + .as_ref() + .is_some_and(|found| found.details.is_none()); + if stale_details { + work.push(Work::Refresh { + title, + record: Box::new(record.clone()), + enrich: !fresh, + }); + } else if !fresh && let Some(found) = record.metadata.clone() { work.push(Work::Enrich(title, Box::new(found))); } } @@ -309,6 +350,12 @@ pub fn plan( } impl MetadataService { + /// Whether this provider fills in [`TitleMetadata::details`], and so + /// whether a match stored without them is worth fetching again. + pub fn has_details(&self) -> bool { + self.source.has_details() + } + /// Do one piece of [`Work`]. /// /// A failed search is an `Err` and nothing is stored. A failed enrichment @@ -321,11 +368,7 @@ impl MetadataService { Work::Match(title) => { let record = self.record(title).await?; let enrichment = match &record.metadata { - Some(found) => self - .enrich(title, found) - .await - .inspect_err(|error| tracing::warn!("enrich {}: {error}", title.name)) - .ok(), + Some(found) => self.enrich_or_log(title, found).await, None => None, }; Ok(Outcome { @@ -339,8 +382,51 @@ impl MetadataService { record: None, enrichment: Some(self.enrich(title, found).await?), }), + Work::Refresh { + title, + record, + enrich, + } => { + let Some(stored) = &record.metadata else { + return Ok(Outcome { + title_key: title.key.clone(), + record: None, + enrichment: None, + }); + }; + // An id the provider no longer has keeps what was stored, with + // empty details so it is not asked about on every run. + let found = self + .source + .entry(&stored.remote_id) + .await? + .unwrap_or_else(|| TitleMetadata { + details: Some(Default::default()), + ..stored.clone() + }); + let enrichment = match enrich { + true => self.enrich_or_log(title, &found).await, + false => None, + }; + Ok(Outcome { + title_key: title.key.clone(), + record: Some(MetadataRecord { + metadata: Some(found), + fetched_at: now(), + ..(**record).clone() + }), + enrichment, + }) + } } } + + async fn enrich_or_log(&self, title: &Title, found: &TitleMetadata) -> Option<Enrichment> { + self.enrich(title, found) + .await + .inspect_err(|error| tracing::warn!("enrich {}: {error}", title.name)) + .ok() + } } /// Whether a stored answer can be used as-is. @@ -408,6 +494,7 @@ mod tests { genres: Vec::new(), episodes: None, url: None, + details: None, }), fetched_at: now(), manual, @@ -419,6 +506,7 @@ mod tests { .map(|work| match work { Work::Match(title) => (title.key.as_str(), "match"), Work::Enrich(title, _) => (title.key.as_str(), "enrich"), + Work::Refresh { title, .. } => (title.key.as_str(), "refresh"), }) .collect() } @@ -441,7 +529,14 @@ mod tests { ]); let titles = ["enriched", "stale", "bare", "new"].map(title).to_vec(); - let work = plan(titles, &stored, &enriched, ProviderId::AniList, false); + let work = plan( + titles, + &stored, + &enriched, + ProviderId::AniList, + false, + false, + ); assert_eq!( kinds(&work), vec![("stale", "enrich"), ("bare", "enrich"), ("new", "match")] @@ -458,13 +553,58 @@ mod tests { ]); let titles = ["pinned", "automatic"].map(title).to_vec(); - let work = plan(titles, &stored, &HashMap::new(), ProviderId::AniList, true); + let work = plan( + titles, + &stored, + &HashMap::new(), + ProviderId::AniList, + false, + true, + ); assert_eq!( kinds(&work), vec![("pinned", "enrich"), ("automatic", "match")] ); } + /// A match stored before details existed is fetched again by id — a + /// pinned one included, and without being searched for — and only by a + /// provider that has details to give. + #[test] + fn a_match_without_details_is_refreshed_rather_than_searched_for() { + let stored = HashMap::from([ + ("pinned".to_string(), matched("pinned", true)), + ("automatic".to_string(), matched("automatic", false)), + ]); + let enriched = HashMap::from([("pinned".to_string(), (ProviderId::AniList, now()))]); + let titles = ["pinned", "automatic"].map(title).to_vec(); + + let work = plan( + titles.clone(), + &stored, + &enriched, + ProviderId::AniList, + true, + false, + ); + assert_eq!( + kinds(&work), + vec![("pinned", "refresh"), ("automatic", "refresh")] + ); + assert!(matches!(&work[0], Work::Refresh { enrich: false, .. })); + assert!(matches!(&work[1], Work::Refresh { enrich: true, .. })); + + let work = plan( + titles, + &stored, + &enriched, + ProviderId::AniList, + false, + false, + ); + assert_eq!(kinds(&work), vec![("automatic", "enrich")]); + } + /// A record from the other provider is never usable — switching provider has /// to re-ask, including about titles the previous one found nothing for. #[test] diff --git a/crates/pstr-meta/src/tmdb.rs b/crates/pstr-meta/src/tmdb.rs index 3836da1..dbbe197 100644 --- a/crates/pstr-meta/src/tmdb.rs +++ b/crates/pstr-meta/src/tmdb.rs @@ -102,6 +102,7 @@ impl Provider for Tmdb { Ok(Enrichment { episodes: self.episodes(found).await?, backdrop_url: None, + chain: Vec::new(), }) } } @@ -350,6 +351,9 @@ impl Result_ { }, self.id )), + // Nothing here yet: TMDB's would come from a detail request + // per title, and the search answer has none of it. + details: None, }, aliases, // Always: `search/multi` results are filtered on `media_type` diff --git a/docs/METADATA.md b/docs/METADATA.md index d5949e5..10d93f6 100644 --- a/docs/METADATA.md +++ b/docs/METADATA.md @@ -103,30 +103,62 @@ stored AniList episode row and every AniList banner-as-backdrop — all of it un-enriched, so the next match run (which the app starts on its own) refills them. Matches, hand-picked ones included, are kept. -## Phase 2 — art and order +## Phase 2 — art and order (done) -- **Art slots** instead of one backdrop: poster (2:3), fanart (16:9), banner - (ultra-wide), logo. The hero and the 16:9 tiles take fanart; the banner only - ever fills an ultra-wide strip. -- **A larger size class for the hero.** `THUMBNAIL_MAX_EDGE` is 640 for every - picture; the hero is drawn up to ~1600 px wide. -- **A poster grid on desktop**, as an option or the default — AniList's covers - are the best art this library has, and Android already proves the layout. -- **More of the AniList answer kept**: format, season and year, studios, - director, tags, popularity, status, next airing episode — all in the search - response already, so no extra request. -- **Sorts**: by the displayed name (English / romaji / folder, as a setting), - release season, rating, popularity, recently added (needs a `first_seen` - column), in-progress first. -- **Franchise grouping** from relations: the Evangelion TV series, its two films - and the Rebuild folder; Made in Abyss and its three films; Mushishi and its - specials; the Heaven's Feel trilogy. One tile each, with the parts in release - order on the title page. -- **Shelves**: genre, studio, director — this library has a Satoshi Kon shelf - waiting in it — format, decade, airing now. +**More of the answer kept.** The AniList query now also asks for the full +start date, broadcast season, banner, popularity, status, the next airing +episode, spoiler-free tags ranked 60 or more, main studios, the `Director` +among the most relevant staff, and the relations that keep to one story +(`PREQUEL`, `SEQUEL`, `PARENT`, `SIDE_STORY`, `SUMMARY`, `ALTERNATIVE`, +`COMPILATION`, `CONTAINS` — not `SPIN_OFF`, `CHARACTER` or `OTHER`, which reach +into other franchises). All of it lives in `TitleDetails`, stored as one JSON +column (**schema v10**). A match stored before that is fetched again *by id* +(`Work::Refresh`) — never searched for, so hand-picked matches stay — and a +refresh of the same entry keeps its episodes, fanart and enrichment record. +Enrichment also stores the chain of entries it walked. + +**Art.** Three textures per title where there was one: the tile's (`Art::of`), +the 2:3 cover (`Art::poster`), and wide art (`Art::wide` — fanart, else the +AniList banner) decoded at up to 1920 px for the library's hero and the title +page's band. The banner, wrong in a 16:9 tile, fits those strips as drawn. + +**Poster grid.** Settings → Appearance → *Library tiles*: covers (the default) +or 16:9 stills. Covers fill their tile; a cover in a still-shaped tile is still +fitted whole. + +**Names.** Settings → Appearance → *Title names*: the share's, English or +romaji. Search matches all of them whichever is shown. + +**Sorts.** A–Z by the shown name, recently watched, recently added (from +`node_first_seen`, which survives recrawls; files present before v10 count as +added at 0), newest by air date, highest rated, most popular. + +**Franchises.** `pstr_core::franchise::group` joins titles that share a +provider id — their own, a related one, or one their episode walk passed +through. The grid folds each franchise into its lead (the first series to air, +else the first film) with a `+N` badge; the stack button beside the sort turns +that off. The title page shows the whole franchise in release order. On the +library this was built against: Evangelion ×4, Made in Abyss ×4, Heaven's Feel +×3, Mushishi with its OVA, Jujutsu Kaisen with *0*, Chainsaw Man with *Reze*. + +**Shelves.** `pstr_core::shelves::pick`: airing now, then directors, studios +and genres, each only when it spans three *franchises* — Made in Abyss's four +titles are one director's, and that is not a shelf — and a genre only under 40% +of the library. Four at most, over the unfiltered library. Here: *Directed by +Satoshi Kon*, *From MADHOUSE*, *From MAPPA*, *From Production I.G*. + +**The title page** adds the season, studios, director and next airing episode +to its line of facts, and the tags beside the genres. + +Not done: Android shows none of this yet beyond what it already drew — the +bridge carries `TitleDetails` through a hand-picked match, and the grouping, +shelves and sorts are in `pstr-core` ready for it, but its screens are +unchanged. TMDB fills no details. ## Phase 3 — reach +- **Android**: the sorts, grouping, shelves and names above. + - **Local matching** against the offline database, so only ids leave the machine. - **Routing by title**: AniList first, then TMDB (with a key) or TVmaze for