mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(core,meta,app,android,cli): sort, group and shelve the library
The AniList query now keeps the start date, season, banner, popularity, status, next airing episode, tags, main studios, director and story relations, as TitleDetails in one JSON column (schema v10). Matches stored without them are fetched again by id, never searched, so hand-picked matches stay; refreshing the same entry keeps its episodes and fanart. Enrichment records the entries it walked. node_first_seen remembers when each file appeared, across recrawls. pstr_core::franchise groups titles that share a provider id. The grid folds each franchise into its lead with a +N badge, and the title page lists the whole franchise in release order. pstr_core::shelves picks rows by airing status, director, studio and genre. A row needs three franchises, so one franchise's titles never make a row on their own. Desktop: - Covers are the default grid (Settings > Appearance > Library tiles). - The hero and title band use fanart or banner decoded at 1920 px. - New sorts: recently added, newest, highest rated, most popular. - Titles can show the share's, English or romaji name; search matches all of them. Android carries TitleDetails through a hand-picked match; its screens are unchanged.
This commit is contained in:
1 parent
4c80076762
commit
6853725ca0
22 files changed
+2239
-187
No files matched your search
@@ -264,6 +264,10 @@ pub struct MatchRecord {
|
||||
pub genres: Vec<String>,
|
||||
pub episode_count: Option<u32>,
|
||||
pub external_url: Option<String>,
|
||||
/// `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<String>,
|
||||
}
|
||||
|
||||
#[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);
|
||||
|
||||
+298
-40
@@ -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<usize>,
|
||||
/// In a grouped grid, how many other titles a tile stands for, by index.
|
||||
pub folded: HashMap<usize, usize>,
|
||||
/// 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<String, Vec<String>>,
|
||||
/// The rows above the grid, as a heading and indices.
|
||||
pub shelves: Vec<(String, Vec<usize>)>,
|
||||
/// Every played episode, newest first, for the history page.
|
||||
pub history: Vec<ui::history::Entry>,
|
||||
/// The "Continue watching" shelf, most recent first, as indices.
|
||||
pub resumable: Vec<usize>,
|
||||
/// 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<String, MetadataRecord>,
|
||||
pub names: TitleNames,
|
||||
/// When each title last gained a file, by key.
|
||||
pub added: &'a HashMap<String, i64>,
|
||||
}
|
||||
|
||||
/// 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<String> = 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<usize>)> = 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<String, MetadataRecord>,
|
||||
/// When each title last gained a file, by key — for "Recently added".
|
||||
pub added: HashMap<String, i64>,
|
||||
/// What they said about the episodes under those titles.
|
||||
pub episodes: HashMap<String, EpisodeGuide>,
|
||||
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, usize>) -> 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<String, MetadataRecord>,
|
||||
after: &HashMap<String, MetadataRecord>,
|
||||
) -> Vec<String> {
|
||||
let url = |records: &HashMap<String, MetadataRecord>, 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<String, MetadataRecord>, 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<String> = before
|
||||
.keys()
|
||||
.chain(after.keys())
|
||||
.filter(|key| url(before, key) != url(after, key))
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut keys: Vec<String> = 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"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String, i64>),
|
||||
/// 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<Decoded> {
|
||||
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<Decoded> {
|
||||
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 {
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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<Action>) {
|
||||
@@ -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<A
|
||||
return;
|
||||
}
|
||||
let radius = egui::CornerRadius::same(theme::radius::LG);
|
||||
let picture = art.of(title);
|
||||
let picture = art.wide(title);
|
||||
let found = art
|
||||
.metadata
|
||||
.get(&title.key)
|
||||
@@ -252,7 +266,7 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
theme::Role::Display
|
||||
.rich(&title.name)
|
||||
.rich(art.name(title))
|
||||
.strong()
|
||||
.color(theme::text()),
|
||||
)
|
||||
@@ -266,24 +280,20 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
|
||||
}
|
||||
}
|
||||
|
||||
/// The filter pills and the order, at the right of the grid's heading.
|
||||
/// The filter pills, the order and the grouping, at the right of the grid's
|
||||
/// heading.
|
||||
fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Action>) {
|
||||
// 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<Actio
|
||||
if let Some(Some(sort)) = sort.inner {
|
||||
actions.push(Action::SetShelf(view.filter, sort));
|
||||
}
|
||||
ui.add_space(theme::space::S);
|
||||
let mut grouped = view.grouped;
|
||||
if ui
|
||||
.toggle_value(&mut grouped, egui_phosphor::regular::STACK)
|
||||
.on_hover_text(if view.grouped {
|
||||
"One tile per franchise. Click to show every title"
|
||||
} else {
|
||||
"Every title. Click to fold each franchise into one tile"
|
||||
})
|
||||
.changed()
|
||||
{
|
||||
actions.push(Action::SetGrouped(grouped));
|
||||
}
|
||||
ui.add_space(theme::space::L);
|
||||
if let Some(filter) = ui::widgets::segmented(
|
||||
ui,
|
||||
@@ -308,6 +331,57 @@ fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Actio
|
||||
}
|
||||
}
|
||||
|
||||
/// One shelf: titles that share something, scrolling sideways, in the grid's
|
||||
/// own shape.
|
||||
fn shelf_row(
|
||||
ui: &mut egui::Ui,
|
||||
art: &mut Art<'_>,
|
||||
label: &str,
|
||||
titles: &[&Title],
|
||||
tiles: Tiles,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
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<Action>) {
|
||||
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<Action>,
|
||||
) {
|
||||
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| {
|
||||
|
||||
+108
-11
@@ -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<String, MetadataRecord>,
|
||||
/// What they said about the episodes under those titles.
|
||||
pub episodes: &'a HashMap<String, EpisodeGuide>,
|
||||
/// 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<String, MetadataRecord>,
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+125
-15
@@ -20,6 +20,7 @@ pub fn show(
|
||||
art: &mut Art<'_>,
|
||||
library: &Library,
|
||||
key: &str,
|
||||
franchise: Option<&[String]>,
|
||||
offline: OfflineView<'_>,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
@@ -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<Action>,
|
||||
) {
|
||||
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<f64>) {
|
||||
@@ -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));
|
||||
|
||||
@@ -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", "");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+227
-12
@@ -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::<std::result::Result<Vec<_>, _>>()?)
|
||||
}
|
||||
|
||||
/// 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<HashMap<(String, String), i64>> {
|
||||
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::<std::result::Result<_, _>>()?)
|
||||
}
|
||||
|
||||
/// 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<String>, chain: Option<String>) -> Option<TitleDetails> {
|
||||
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<Option<MetadataRecord>> {
|
||||
let title_key: String = row.get(0)?;
|
||||
@@ -1014,6 +1115,7 @@ fn row_to_metadata(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<MetadataR
|
||||
.collect(),
|
||||
episodes: row.get(14)?,
|
||||
url: row.get(15)?,
|
||||
details: details_column(row.get(17)?, row.get(18)?),
|
||||
})
|
||||
};
|
||||
|
||||
@@ -1298,6 +1400,7 @@ mod tests {
|
||||
genres: vec!["Action".into(), "Drama".into()],
|
||||
episodes: Some(25),
|
||||
url: Some("https://anilist.co/anime/16498".into()),
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1651,7 +1754,7 @@ mod tests {
|
||||
/// Enrichment adds a backdrop when it found one and otherwise leaves the
|
||||
/// match's own alone; a new match makes the title un-enriched again.
|
||||
#[test]
|
||||
fn enrichment_keeps_the_backdrop_it_did_not_replace_and_a_new_match_resets_it() {
|
||||
fn enrichment_keeps_the_backdrop_it_did_not_replace_and_a_different_match_resets_it() {
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
let record = MetadataRecord {
|
||||
title_key: "attack on titan".into(),
|
||||
@@ -1691,10 +1794,122 @@ mod tests {
|
||||
.expect("enrich again");
|
||||
assert_eq!(backdrop(&catalog).as_deref(), Some("fanart.jpg"));
|
||||
|
||||
catalog.set_metadata(&record).expect("match again");
|
||||
catalog
|
||||
.set_metadata(&MetadataRecord {
|
||||
metadata: Some(TitleMetadata {
|
||||
remote_id: "99147".into(),
|
||||
..found()
|
||||
}),
|
||||
..record
|
||||
})
|
||||
.expect("match something else");
|
||||
assert!(catalog.enrichment_ages().expect("ages").is_empty());
|
||||
}
|
||||
|
||||
/// Details round-trip, and refreshing a match's details — the same entry
|
||||
/// stored again — keeps what enrichment found for it.
|
||||
#[test]
|
||||
fn refreshing_the_same_match_keeps_its_enrichment_and_a_new_one_does_not() {
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
let record = MetadataRecord {
|
||||
title_key: "mushishi".into(),
|
||||
provider: ProviderId::AniList,
|
||||
metadata: Some(found()),
|
||||
fetched_at: 1,
|
||||
manual: true,
|
||||
};
|
||||
catalog.set_metadata(&record).expect("store");
|
||||
let enrichment = Enrichment {
|
||||
episodes: vec![EpisodeMetadata {
|
||||
season: Some(1),
|
||||
number: 1,
|
||||
absolute: Some(1),
|
||||
entry_season: Some(1),
|
||||
entry_number: Some(1),
|
||||
name: Some("The Green Seat".into()),
|
||||
overview: None,
|
||||
still_url: None,
|
||||
air_date: None,
|
||||
}],
|
||||
backdrop_url: Some("fanart.jpg".into()),
|
||||
chain: vec!["457".into(), "20595".into()],
|
||||
};
|
||||
catalog
|
||||
.set_enrichment("mushishi", ProviderId::AniList, 2, &enrichment)
|
||||
.expect("enrich");
|
||||
|
||||
let refreshed = MetadataRecord {
|
||||
metadata: Some(TitleMetadata {
|
||||
details: Some(TitleDetails {
|
||||
studios: vec!["Artland".into()],
|
||||
..TitleDetails::default()
|
||||
}),
|
||||
..found()
|
||||
}),
|
||||
fetched_at: 3,
|
||||
..record.clone()
|
||||
};
|
||||
catalog.set_metadata(&refreshed).expect("refresh");
|
||||
let read = catalog.metadata("mushishi").expect("read").expect("a row");
|
||||
let data = read.metadata.expect("matched");
|
||||
assert_eq!(data.backdrop_url.as_deref(), Some("fanart.jpg"));
|
||||
let details = data.details.expect("details");
|
||||
assert_eq!(details.studios, vec!["Artland"]);
|
||||
assert_eq!(details.chain, vec!["457", "20595"]);
|
||||
assert!(
|
||||
catalog
|
||||
.enrichment_ages()
|
||||
.expect("ages")
|
||||
.contains_key("mushishi")
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.all_episode_metadata().expect("read")["mushishi"].len(),
|
||||
1
|
||||
);
|
||||
|
||||
// A different entry is a different answer: nothing carries over.
|
||||
catalog
|
||||
.set_metadata(&MetadataRecord {
|
||||
metadata: Some(TitleMetadata {
|
||||
remote_id: "20526".into(),
|
||||
..found()
|
||||
}),
|
||||
..record
|
||||
})
|
||||
.expect("repin");
|
||||
let read = catalog.metadata("mushishi").expect("read").expect("a row");
|
||||
assert_eq!(read.metadata.and_then(|data| data.backdrop_url), None);
|
||||
assert!(catalog.enrichment_ages().expect("ages").is_empty());
|
||||
}
|
||||
|
||||
/// A file keeps the moment it first appeared across recrawls, and one
|
||||
/// that was there before this was recorded reads as 0.
|
||||
#[test]
|
||||
fn a_file_remembers_when_it_first_appeared() {
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
catalog
|
||||
.replace_share("s", &[file("s", "a", "Show S01E01.mkv")])
|
||||
.expect("crawl");
|
||||
let first = catalog.first_seen().expect("read")[&("s".to_string(), "a".to_string())];
|
||||
assert!(first > 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.
|
||||
|
||||
@@ -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<usize>,
|
||||
}
|
||||
|
||||
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<String, MetadataRecord>) -> Vec<Franchise> {
|
||||
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<Franchise> = Vec::new();
|
||||
let mut slot: HashMap<usize, usize> = 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, MetadataRecord>) -> 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<usize>,
|
||||
}
|
||||
|
||||
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<Vec<String>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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<String, i64> {
|
||||
self.titles
|
||||
.iter()
|
||||
.map(|title| {
|
||||
let newest = title
|
||||
.episodes()
|
||||
.filter_map(|episode| {
|
||||
first_seen
|
||||
.get(&(episode.node.share_id.clone(), episode.node.link_id.clone()))
|
||||
})
|
||||
.copied()
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
(title.key.clone(), newest)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Substring match over the display name and every filename under it, so
|
||||
/// searching for a release group or an episode name finds its title.
|
||||
pub fn search(&self, query: &str) -> Vec<&Title> {
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<u32>,
|
||||
/// The title's page, for a viewer who wants the rest of it.
|
||||
pub url: Option<String>,
|
||||
/// 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<TitleDetails>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// As the provider writes it: `TV`, `TV_SHORT`, `MOVIE`, `SPECIAL`, `OVA`,
|
||||
/// `ONA`, `MUSIC`. [`TitleDetails::format_label`] is what is shown.
|
||||
pub format: Option<String>,
|
||||
/// The broadcast season: `WINTER`, `SPRING`, `SUMMER` or `FALL`.
|
||||
pub season: Option<String>,
|
||||
/// 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<String>,
|
||||
/// The main animation studios.
|
||||
pub studios: Vec<String>,
|
||||
pub directors: Vec<String>,
|
||||
/// The provider's descriptive tags, most relevant first, spoilers left out.
|
||||
pub tags: Vec<String>,
|
||||
/// How many people follow it at the provider. Only ever compared with
|
||||
/// other titles from the same provider.
|
||||
pub popularity: Option<u32>,
|
||||
/// `FINISHED`, `RELEASING`, `NOT_YET_RELEASED`, `CANCELLED` or `HIATUS`.
|
||||
pub status: Option<String>,
|
||||
/// 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<u32>,
|
||||
pub next_airing_at: Option<i64>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<String>,
|
||||
/// Every provider entry the episodes were taken from, the match's own
|
||||
/// included. See [`TitleDetails::chain`].
|
||||
pub chain: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<String, MetadataRecord>,
|
||||
limit: usize,
|
||||
) -> Vec<Shelf> {
|
||||
let mut airing = Vec::new();
|
||||
let mut directors: BTreeMap<&str, Vec<String>> = BTreeMap::new();
|
||||
let mut studios: BTreeMap<&str, Vec<String>> = BTreeMap::new();
|
||||
let mut genres: BTreeMap<&str, Vec<String>> = 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::<std::collections::HashSet<_>>()
|
||||
.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<String>>, label: &dyn Fn(&str) -> String| {
|
||||
let mut rows: Vec<Shelf> = 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<String>> = 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<Shelf> = 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<Title> = [
|
||||
"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"]);
|
||||
}
|
||||
}
|
||||
+242
-16
@@ -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
|
||||
|
||||
@@ -208,6 +208,7 @@ mod tests {
|
||||
genres: Vec::new(),
|
||||
episodes: None,
|
||||
url: None,
|
||||
details: None,
|
||||
},
|
||||
aliases: std::iter::once(name)
|
||||
.chain(aliases.iter().copied())
|
||||
|
||||
+155
-15
@@ -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]
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user