mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(core,app,android): sort, fold and shelve the Android library
Bring the phase 2 library to Android. Search, sorting and franchise folding move from the desktop app into pstr_core::browse, so both clients find, fold and order the same titles the same way. The bridge gains AndroidEngine::arrangement, which returns the grid's tiles and up to three shelves as keys into the library's titles. Each TitleRecord now carries its display name, wide art, format, season, studios, directors, tags, airing state and franchise. The library bar has a sort menu with the franchise toggle, folded tiles show a +N badge, and shelves sit above the grid. The title page shows the same facts, tags and franchise row as the desktop. Settings -> Appearance gains Title names. The default title name is now English, which is what Android always showed. A folded franchise now sorts by its lead's displayed name rather than by its alphabetically first member.
This commit is contained in:
1 parent
aef182e7e8
commit
3638e0e5df
26 files changed
+1176
-250
No files matched your search
+339
-28
@@ -11,12 +11,13 @@ use std::sync::{Arc, OnceLock};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use pstr_core::appearance::{Accent, Appearance, Flavor, Palette};
|
||||
use pstr_core::browse::Sort;
|
||||
use pstr_core::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows};
|
||||
use pstr_core::chapters::{Chapter, ChapterRole};
|
||||
use pstr_core::config::AppDirs;
|
||||
use pstr_core::library::{Episode, Library, Title, TitleKind};
|
||||
use pstr_core::metadata::{
|
||||
EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata,
|
||||
EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata, TitleNames,
|
||||
};
|
||||
use pstr_core::prefs::PlaybackPrefs;
|
||||
use pstr_core::proton_drive_rs::{ProtonDrivePublicLinkClient, ThumbnailType};
|
||||
@@ -175,6 +176,55 @@ pub struct AppearanceRecord {
|
||||
pub flavor: FlavorChoice,
|
||||
pub accent: AccentChoice,
|
||||
pub gradients: bool,
|
||||
/// Which name a title goes by. Shared with the desktop.
|
||||
pub names: TitleNamesChoice,
|
||||
}
|
||||
|
||||
/// The shared `pstr_core::metadata::TitleNames`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
|
||||
pub enum TitleNamesChoice {
|
||||
/// The share's folder name.
|
||||
Library,
|
||||
English,
|
||||
Romaji,
|
||||
}
|
||||
|
||||
/// The shared `pstr_core::browse::Sort`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
|
||||
pub enum LibrarySort {
|
||||
Name,
|
||||
Recent,
|
||||
Added,
|
||||
Release,
|
||||
Rating,
|
||||
Popularity,
|
||||
}
|
||||
|
||||
/// One tile of the library grid.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct TileRecord {
|
||||
/// The title drawn.
|
||||
pub key: String,
|
||||
/// How many other titles of its franchise it stands for.
|
||||
pub folded: u32,
|
||||
}
|
||||
|
||||
/// One row of titles that share a director, studio or genre.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct ShelfRecord {
|
||||
pub label: String,
|
||||
pub keys: Vec<String>,
|
||||
}
|
||||
|
||||
/// The library grid, ordered and folded, and the shelves above it — what
|
||||
/// `pstr_core::browse` and `pstr_core::shelves` make of the library, as keys
|
||||
/// into [`AndroidEngine::library`]'s titles.
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct ArrangementRecord {
|
||||
pub tiles: Vec<TileRecord>,
|
||||
/// Empty while searching: a shelf of titles the search did not find reads
|
||||
/// as the search not working.
|
||||
pub shelves: Vec<ShelfRecord>,
|
||||
}
|
||||
|
||||
/// One flavour and accent resolved into colours, packed as `0xAARRGGBB`.
|
||||
@@ -327,6 +377,26 @@ pub struct TitleRecord {
|
||||
pub external_url: Option<String>,
|
||||
pub manual_match: bool,
|
||||
pub seasons: Vec<SeasonRecord>,
|
||||
/// The name to show, as the viewer's name setting has it.
|
||||
pub display_name: String,
|
||||
/// The widest art there is: fanart, else the provider's banner.
|
||||
pub wide_url: Option<String>,
|
||||
/// `TV`, `Film`, `OVA` — the provider's format, said for a person.
|
||||
pub format_label: Option<String>,
|
||||
/// `Spring 2013`.
|
||||
pub season_label: Option<String>,
|
||||
pub studios: Vec<String>,
|
||||
pub directors: Vec<String>,
|
||||
pub tags: Vec<String>,
|
||||
/// Still airing.
|
||||
pub airing: bool,
|
||||
/// The next episode to air and when, Unix seconds — only while that is
|
||||
/// still ahead.
|
||||
pub next_episode: Option<u32>,
|
||||
pub next_airing_at: Option<i64>,
|
||||
/// Every title of its franchise, in release order, its own key included;
|
||||
/// empty for a title that stands alone.
|
||||
pub franchise: Vec<String>,
|
||||
}
|
||||
|
||||
/// Playback preferences that outlive one file, one title and one launch.
|
||||
@@ -952,23 +1022,70 @@ impl AndroidEngine {
|
||||
/// table scans, a `Library::build` and a full record conversion per
|
||||
/// keystroke.
|
||||
pub fn library(&self, search: Option<String>) -> Result<Vec<TitleRecord>, BridgeError> {
|
||||
let titles = self.all_title_records()?;
|
||||
let needle = search.unwrap_or_default().trim().to_lowercase();
|
||||
if needle.is_empty() {
|
||||
return Ok(titles);
|
||||
}
|
||||
Ok(titles
|
||||
.into_iter()
|
||||
.filter(|title| {
|
||||
title.name.to_lowercase().contains(&needle)
|
||||
|| title.seasons.iter().any(|season| {
|
||||
season
|
||||
.episodes
|
||||
.iter()
|
||||
.any(|episode| episode.name.to_lowercase().contains(&needle))
|
||||
let query = search.unwrap_or_default();
|
||||
self.with_library(|cached| {
|
||||
if query.trim().is_empty() {
|
||||
return cached.titles.clone();
|
||||
}
|
||||
let found: std::collections::HashSet<&str> =
|
||||
pstr_core::browse::search(&cached.library, &cached.metadata, &query)
|
||||
.into_iter()
|
||||
.map(|title| title.key.as_str())
|
||||
.collect();
|
||||
cached
|
||||
.titles
|
||||
.iter()
|
||||
.filter(|title| found.contains(title.key.as_str()))
|
||||
.cloned()
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// The library grid for `search`, in `sort` order, each franchise folded
|
||||
/// into one tile when `grouped` — and the shelves above it. Keys into
|
||||
/// [`AndroidEngine::library`]'s titles, so the records cross the bridge
|
||||
/// once.
|
||||
pub fn arrangement(
|
||||
&self,
|
||||
search: Option<String>,
|
||||
sort: LibrarySort,
|
||||
grouped: bool,
|
||||
) -> Result<ArrangementRecord, BridgeError> {
|
||||
let query = search.unwrap_or_default();
|
||||
self.with_library(|cached| {
|
||||
let titles = pstr_core::browse::search(&cached.library, &cached.metadata, &query);
|
||||
let tiles = pstr_core::browse::arrange(
|
||||
&titles,
|
||||
pstr_core::browse::Order {
|
||||
sort: sort_choice(sort),
|
||||
grouped,
|
||||
names: cached.names,
|
||||
metadata: &cached.metadata,
|
||||
added: &cached.added,
|
||||
},
|
||||
);
|
||||
let shelves = if query.trim().is_empty() {
|
||||
pstr_core::shelves::pick(&cached.library.titles, &cached.metadata, SHELVES)
|
||||
.into_iter()
|
||||
.map(|shelf| ShelfRecord {
|
||||
label: shelf.label,
|
||||
keys: shelf.keys,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
ArrangementRecord {
|
||||
tiles: tiles
|
||||
.iter()
|
||||
.map(|tile| TileRecord {
|
||||
key: titles[tile.lead].key.clone(),
|
||||
folded: (tile.members.len() - 1) as u32,
|
||||
})
|
||||
.collect(),
|
||||
shelves,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn metadata_settings(&self) -> Result<MetadataSettingsRecord, BridgeError> {
|
||||
@@ -1184,12 +1301,11 @@ impl AndroidEngine {
|
||||
}
|
||||
|
||||
pub fn set_appearance(&self, appearance: AppearanceRecord) -> Result<(), BridgeError> {
|
||||
// The record carries the palette and nothing else; what the desktop's
|
||||
// library layout fields hold is kept as it was stored.
|
||||
// The record has no grid shape — Android's is always covers — so the
|
||||
// desktop's 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)
|
||||
@@ -1518,13 +1634,19 @@ impl AndroidEngine {
|
||||
}
|
||||
|
||||
/// Every title as a bridge record, rebuilt only when the catalog moved.
|
||||
fn all_title_records(&self) -> Result<Vec<TitleRecord>, BridgeError> {
|
||||
/// Run `read` over the library, its metadata and its records, rebuilt
|
||||
/// only when the catalog or the name setting has changed since.
|
||||
fn with_library<T>(&self, read: impl FnOnce(&CachedLibrary) -> T) -> Result<T, BridgeError> {
|
||||
let names = pstr_core::appearance::load(&self.dirs)
|
||||
.unwrap_or_default()
|
||||
.names;
|
||||
let catalog = self.catalog.lock();
|
||||
let writes = catalog.writes();
|
||||
if let Some(cached) = self.library_cache.lock().as_ref()
|
||||
&& cached.writes == writes
|
||||
&& cached.names == names
|
||||
{
|
||||
return Ok(cached.titles.clone());
|
||||
return Ok(read(cached));
|
||||
}
|
||||
|
||||
let files = catalog.all_files().map_err(BridgeError::from_display)?;
|
||||
@@ -1538,26 +1660,61 @@ impl AndroidEngine {
|
||||
let offline = catalog
|
||||
.all_offline_files()
|
||||
.map_err(BridgeError::from_display)?;
|
||||
let first_seen = catalog.first_seen().map_err(BridgeError::from_display)?;
|
||||
drop(catalog);
|
||||
|
||||
let library = Library::build(files, &watch);
|
||||
let all: Vec<&Title> = library.titles.iter().collect();
|
||||
let franchises: HashMap<String, Vec<String>> = 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 now = now();
|
||||
let titles: Vec<TitleRecord> = library
|
||||
.titles
|
||||
.iter()
|
||||
.map(|title| {
|
||||
title_record(
|
||||
let mut record = title_record(
|
||||
title,
|
||||
&offline,
|
||||
metadata.get(&title.key),
|
||||
guides.get(&title.key),
|
||||
)
|
||||
);
|
||||
record.display_name =
|
||||
pstr_core::browse::display_name(title, &metadata, names).to_owned();
|
||||
record.franchise = franchises.get(&title.key).cloned().unwrap_or_default();
|
||||
if let Some(details) = metadata
|
||||
.get(&title.key)
|
||||
.and_then(|record| record.metadata.as_ref())
|
||||
.and_then(|found| found.details.as_ref())
|
||||
&& let Some((episode, at)) = details.next_airing(now)
|
||||
{
|
||||
record.next_episode = Some(episode);
|
||||
record.next_airing_at = Some(at);
|
||||
}
|
||||
record
|
||||
})
|
||||
.collect();
|
||||
*self.library_cache.lock() = Some(CachedLibrary {
|
||||
let added = library.added_at(&first_seen);
|
||||
let cached = CachedLibrary {
|
||||
writes,
|
||||
titles: titles.clone(),
|
||||
});
|
||||
Ok(titles)
|
||||
names,
|
||||
library,
|
||||
metadata,
|
||||
added,
|
||||
titles,
|
||||
};
|
||||
let answer = read(&cached);
|
||||
*self.library_cache.lock() = Some(cached);
|
||||
Ok(answer)
|
||||
}
|
||||
|
||||
fn titles_and_metadata(
|
||||
@@ -2316,6 +2473,11 @@ fn normalized_language(language: Option<String>) -> Option<String> {
|
||||
/// The library conversion, valid while the catalog has not been written to.
|
||||
struct CachedLibrary {
|
||||
writes: u64,
|
||||
names: TitleNames,
|
||||
library: Library,
|
||||
metadata: HashMap<String, MetadataRecord>,
|
||||
/// When each title last gained a file.
|
||||
added: HashMap<String, i64>,
|
||||
titles: Vec<TitleRecord>,
|
||||
}
|
||||
|
||||
@@ -2370,9 +2532,29 @@ fn appearance_record(appearance: Appearance) -> AppearanceRecord {
|
||||
Accent::Red => AccentChoice::Red,
|
||||
},
|
||||
gradients: appearance.gradients,
|
||||
names: match appearance.names {
|
||||
TitleNames::Library => TitleNamesChoice::Library,
|
||||
TitleNames::English => TitleNamesChoice::English,
|
||||
TitleNames::Romaji => TitleNamesChoice::Romaji,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_choice(sort: LibrarySort) -> Sort {
|
||||
match sort {
|
||||
LibrarySort::Name => Sort::Name,
|
||||
LibrarySort::Recent => Sort::Recent,
|
||||
LibrarySort::Added => Sort::Added,
|
||||
LibrarySort::Release => Sort::Release,
|
||||
LibrarySort::Rating => Sort::Rating,
|
||||
LibrarySort::Popularity => Sort::Popularity,
|
||||
}
|
||||
}
|
||||
|
||||
/// How many shelves the library shows above the grid — fewer than the
|
||||
/// desktop's four, because on a phone each is a screen's height.
|
||||
const SHELVES: usize = 3;
|
||||
|
||||
fn appearance_choice(record: AppearanceRecord) -> Appearance {
|
||||
Appearance {
|
||||
flavor: match record.flavor {
|
||||
@@ -2395,6 +2577,11 @@ fn appearance_choice(record: AppearanceRecord) -> Appearance {
|
||||
AccentChoice::Red => Accent::Red,
|
||||
},
|
||||
gradients: record.gradients,
|
||||
names: match record.names {
|
||||
TitleNamesChoice::Library => TitleNames::Library,
|
||||
TitleNamesChoice::English => TitleNames::English,
|
||||
TitleNamesChoice::Romaji => TitleNames::Romaji,
|
||||
},
|
||||
..Appearance::default()
|
||||
}
|
||||
}
|
||||
@@ -2469,6 +2656,7 @@ fn title_record(
|
||||
guide: Option<&EpisodeGuide>,
|
||||
) -> TitleRecord {
|
||||
let metadata = record.and_then(|record| record.metadata.as_ref());
|
||||
let details = metadata.and_then(|found| found.details.as_ref());
|
||||
TitleRecord {
|
||||
key: title.key.clone(),
|
||||
name: title.name.clone(),
|
||||
@@ -2493,6 +2681,17 @@ fn title_record(
|
||||
provider_episode_count: metadata.and_then(|metadata| metadata.episodes),
|
||||
external_url: metadata.and_then(|metadata| metadata.url.clone()),
|
||||
manual_match: record.is_some_and(|record| record.manual),
|
||||
display_name: metadata.map_or_else(|| title.name.clone(), |found| found.name.clone()),
|
||||
wide_url: metadata.and_then(|found| found.wide_art().map(str::to_owned)),
|
||||
format_label: details.and_then(|details| details.format_label().map(str::to_owned)),
|
||||
season_label: details.and_then(|details| details.season_label()),
|
||||
studios: details.map_or_else(Vec::new, |details| details.studios.clone()),
|
||||
directors: details.map_or_else(Vec::new, |details| details.directors.clone()),
|
||||
tags: details.map_or_else(Vec::new, |details| details.tags.clone()),
|
||||
airing: details.is_some_and(|details| details.is_airing()),
|
||||
next_episode: None,
|
||||
next_airing_at: None,
|
||||
franchise: Vec::new(),
|
||||
seasons: title
|
||||
.seasons
|
||||
.iter()
|
||||
@@ -2641,6 +2840,7 @@ mod tests {
|
||||
flavor,
|
||||
accent: AccentChoice::Mauve,
|
||||
gradients: true,
|
||||
names: super::TitleNamesChoice::English,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2995,6 +3195,117 @@ mod tests {
|
||||
assert_eq!(pstr_android_stream_size(handle), -1);
|
||||
}
|
||||
|
||||
/// The arrangement folds a franchise into its series, and the name
|
||||
/// setting — stored in the file the desktop reads — renames the tile
|
||||
/// without anything in the catalog changing.
|
||||
#[test]
|
||||
fn the_library_folds_a_franchise_and_follows_the_name_setting() {
|
||||
use pstr_core::catalog::CatalogNode;
|
||||
use pstr_core::metadata::{MetadataRecord, TitleDetails};
|
||||
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"pstr-android-arrangement-{}-{}",
|
||||
std::process::id(),
|
||||
super::now()
|
||||
));
|
||||
let engine = AndroidEngine::new(
|
||||
AndroidPaths {
|
||||
config: root.join("config").to_string_lossy().into_owned(),
|
||||
data: root.join("data").to_string_lossy().into_owned(),
|
||||
cache: root.join("cache").to_string_lossy().into_owned(),
|
||||
},
|
||||
Box::<MemorySecrets>::default(),
|
||||
)
|
||||
.expect("engine");
|
||||
|
||||
let file = |link: &str, name: &str| CatalogNode {
|
||||
share_id: "s".into(),
|
||||
link_id: link.into(),
|
||||
volume_id: "v".into(),
|
||||
parent_link_id: None,
|
||||
name: name.into(),
|
||||
is_folder: false,
|
||||
media_type: None,
|
||||
size: None,
|
||||
active_revision_id: None,
|
||||
parsed: pstr_core::naming::parse(name),
|
||||
};
|
||||
let matched = |key: &str, id: &str, name: &str, kind, related: &str| MetadataRecord {
|
||||
title_key: key.into(),
|
||||
provider: ProviderId::AniList,
|
||||
metadata: Some(TitleMetadata {
|
||||
provider: ProviderId::AniList,
|
||||
remote_id: id.into(),
|
||||
name: name.into(),
|
||||
original_name: None,
|
||||
overview: None,
|
||||
year: None,
|
||||
kind,
|
||||
poster_url: None,
|
||||
backdrop_url: None,
|
||||
rating: None,
|
||||
genres: Vec::new(),
|
||||
episodes: None,
|
||||
url: None,
|
||||
details: Some(TitleDetails {
|
||||
related: vec![related.into()],
|
||||
..TitleDetails::default()
|
||||
}),
|
||||
}),
|
||||
fetched_at: 0,
|
||||
manual: false,
|
||||
};
|
||||
{
|
||||
let mut catalog = engine.catalog.lock();
|
||||
catalog
|
||||
.replace_share(
|
||||
"s",
|
||||
&[
|
||||
file("a", "Sousou no Frieren S01E01.mkv"),
|
||||
file("b", "Made in Abyss Dawn of the Deep Soul.mkv"),
|
||||
],
|
||||
)
|
||||
.expect("crawl");
|
||||
catalog
|
||||
.set_metadata(&matched(
|
||||
"sousou no frieren",
|
||||
"1",
|
||||
"Frieren",
|
||||
TitleKind::Series,
|
||||
"2",
|
||||
))
|
||||
.expect("match");
|
||||
catalog
|
||||
.set_metadata(&matched(
|
||||
"made in abyss dawn of the deep soul",
|
||||
"2",
|
||||
"Dawn of the Deep Soul",
|
||||
TitleKind::Film,
|
||||
"1",
|
||||
))
|
||||
.expect("match");
|
||||
}
|
||||
|
||||
let arranged = engine
|
||||
.arrangement(None, super::LibrarySort::Name, true)
|
||||
.expect("arrange");
|
||||
assert_eq!(arranged.tiles.len(), 1);
|
||||
assert_eq!(arranged.tiles[0].key, "sousou no frieren");
|
||||
assert_eq!(arranged.tiles[0].folded, 1);
|
||||
let titles = engine.library(Some("frieren".into())).expect("library");
|
||||
assert_eq!(titles.len(), 1);
|
||||
assert_eq!(titles[0].display_name, "Frieren");
|
||||
assert_eq!(titles[0].franchise.len(), 2);
|
||||
|
||||
let mut appearance = engine.appearance().expect("appearance");
|
||||
appearance.names = super::TitleNamesChoice::Library;
|
||||
engine.set_appearance(appearance).expect("store");
|
||||
let titles = engine.library(Some("frieren".into())).expect("library");
|
||||
assert_eq!(titles[0].display_name, "Sousou no Frieren");
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_mutations_cleanup_even_when_secret_deletion_fails() {
|
||||
let unique = format!(
|
||||
|
||||
+25
-157
@@ -382,45 +382,7 @@ impl Filter {
|
||||
}
|
||||
}
|
||||
|
||||
/// What order the grid is in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Sort {
|
||||
/// By the name shown, ignoring a leading article.
|
||||
#[default]
|
||||
Name,
|
||||
/// Most recently played first.
|
||||
Recent,
|
||||
/// 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use pstr_core::browse::Sort;
|
||||
|
||||
/// What the library view is built from, besides the library itself.
|
||||
pub struct ViewInputs<'a> {
|
||||
@@ -436,20 +398,13 @@ const SHELVES: usize = 4;
|
||||
|
||||
impl LibraryView {
|
||||
fn refresh(&mut self, library: &Library, inputs: ViewInputs<'_>) {
|
||||
use pstr_core::library::{Title, sort_name};
|
||||
use pstr_core::library::Title;
|
||||
|
||||
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 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)
|
||||
@@ -483,128 +438,41 @@ impl LibraryView {
|
||||
.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)
|
||||
let titles: Vec<&Title> = pstr_core::browse::search(library, metadata, inputs.query)
|
||||
.into_iter()
|
||||
.map(|title| title as *const Title)
|
||||
.filter(|title| self.filter.admits(title))
|
||||
.collect();
|
||||
let mut titles: Vec<(usize, &Title)> = library
|
||||
.titles
|
||||
let tiles = pstr_core::browse::arrange(
|
||||
&titles,
|
||||
pstr_core::browse::Order {
|
||||
sort: self.sort,
|
||||
grouped: self.grouped,
|
||||
names: inputs.names,
|
||||
metadata,
|
||||
added: inputs.added,
|
||||
},
|
||||
);
|
||||
let index_of = |title: &Title| {
|
||||
library
|
||||
.titles
|
||||
.iter()
|
||||
.position(|candidate| std::ptr::eq(candidate, title))
|
||||
};
|
||||
// Indices into what was arranged, made indices into the library.
|
||||
let entries: Vec<(usize, usize)> = tiles
|
||||
.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))
|
||||
.filter_map(|tile| Some((index_of(titles[tile.lead])?, tile.members.len())))
|
||||
.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 => {}
|
||||
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.folded = entries
|
||||
.iter()
|
||||
.filter(|(_, members)| members.len() > 1)
|
||||
.map(|(lead, members)| (*lead, members.len() - 1))
|
||||
.filter(|(_, members)| *members > 1)
|
||||
.map(|(lead, members)| (*lead, members - 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()
|
||||
|
||||
@@ -44,19 +44,7 @@ pub struct Art<'a> {
|
||||
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)
|
||||
}
|
||||
pub use pstr_core::browse::display_name as title_name;
|
||||
|
||||
impl Art<'_> {
|
||||
/// What the provider says about one file of a title, matched on the
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
//! Finding and ordering titles: what both library grids show, and in what
|
||||
//! order.
|
||||
//!
|
||||
//! Here rather than in a front end so the desktop and Android agree: the same
|
||||
//! search finds the same titles, a franchise folds into the same tile, and
|
||||
//! "Newest" means the same thing on both.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::library::{Library, Title, sort_name};
|
||||
use crate::metadata::{MetadataRecord, TitleNames};
|
||||
|
||||
/// What order a grid is in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Sort {
|
||||
/// By the name shown, ignoring a leading article.
|
||||
#[default]
|
||||
Name,
|
||||
/// Most recently played first.
|
||||
Recent,
|
||||
/// 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The name to show for `title`: the provider's, in the form the viewer
|
||||
/// picked, else the share's.
|
||||
pub fn display_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)
|
||||
}
|
||||
|
||||
/// The titles `query` finds, in library order.
|
||||
///
|
||||
/// By the share's name and every filename under it — a release group, an
|
||||
/// episode name — and by every name the provider gave: a folder called
|
||||
/// `Sousou no Frieren` is found by typing "Frieren: Beyond".
|
||||
pub fn search<'a>(
|
||||
library: &'a Library,
|
||||
metadata: &HashMap<String, MetadataRecord>,
|
||||
query: &str,
|
||||
) -> Vec<&'a Title> {
|
||||
let needle = query.trim().to_lowercase();
|
||||
let by_files: HashSet<*const Title> = library
|
||||
.search(query)
|
||||
.into_iter()
|
||||
.map(|title| title as *const Title)
|
||||
.collect();
|
||||
library
|
||||
.titles
|
||||
.iter()
|
||||
.filter(|title| {
|
||||
by_files.contains(&(*title as *const Title))
|
||||
|| metadata
|
||||
.get(&title.key)
|
||||
.and_then(|record| record.metadata.as_ref())
|
||||
.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))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How to order a grid.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Order<'a> {
|
||||
pub sort: Sort,
|
||||
/// One tile per franchise rather than one per title.
|
||||
pub grouped: bool,
|
||||
pub names: TitleNames,
|
||||
pub metadata: &'a HashMap<String, MetadataRecord>,
|
||||
/// When each title last gained a file, by key — see
|
||||
/// [`Library::added_at`].
|
||||
pub added: &'a HashMap<String, i64>,
|
||||
}
|
||||
|
||||
/// One tile: the title drawn, and every title it stands for — itself
|
||||
/// included, in release order — as indices into what was arranged.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Tile {
|
||||
pub lead: usize,
|
||||
pub members: Vec<usize>,
|
||||
}
|
||||
|
||||
/// `titles` as tiles, in `order`.
|
||||
///
|
||||
/// Every sort is stable over the name order, so ties — and the undated,
|
||||
/// unrated and unplayed — keep their alphabetical order.
|
||||
pub fn arrange<'t>(titles: &[&'t Title], order: Order<'_>) -> Vec<Tile> {
|
||||
let metadata = order.metadata;
|
||||
let found = |title: &Title| {
|
||||
metadata
|
||||
.get(&title.key)
|
||||
.and_then(|record| record.metadata.as_ref())
|
||||
};
|
||||
|
||||
let mut by_name: Vec<usize> = (0..titles.len()).collect();
|
||||
by_name
|
||||
.sort_by_cached_key(|&index| sort_name(display_name(titles[index], metadata, order.names)));
|
||||
let sorted: Vec<&Title> = by_name.iter().map(|&index| titles[index]).collect();
|
||||
|
||||
let mut tiles: Vec<Tile> = if order.grouped {
|
||||
crate::franchise::group(&sorted, metadata)
|
||||
.into_iter()
|
||||
.map(|franchise| Tile {
|
||||
lead: by_name[franchise.lead(&sorted)],
|
||||
members: franchise
|
||||
.members
|
||||
.iter()
|
||||
.map(|&member| by_name[member])
|
||||
.collect(),
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
by_name
|
||||
.iter()
|
||||
.map(|&index| Tile {
|
||||
lead: index,
|
||||
members: vec![index],
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// A folded franchise files under the name on its tile — the lead's — and
|
||||
// not under whichever member sorted first: "Made in Abyss" belongs at M
|
||||
// even when its film is called "Dawn of the Deep Soul".
|
||||
tiles.sort_by_cached_key(|tile| {
|
||||
sort_name(display_name(titles[tile.lead], metadata, order.names))
|
||||
});
|
||||
|
||||
let members = |tile: &Tile| -> Vec<&'t Title> {
|
||||
tile.members.iter().map(|&index| titles[index]).collect()
|
||||
};
|
||||
match order.sort {
|
||||
Sort::Name => {}
|
||||
Sort::Recent => tiles.sort_by_key(|tile| {
|
||||
std::cmp::Reverse(members(tile).iter().map(|title| title.last_played()).max())
|
||||
}),
|
||||
Sort::Added => tiles.sort_by_key(|tile| {
|
||||
std::cmp::Reverse(
|
||||
members(tile)
|
||||
.iter()
|
||||
.filter_map(|title| order.added.get(&title.key))
|
||||
.max()
|
||||
.copied(),
|
||||
)
|
||||
}),
|
||||
Sort::Release => tiles.sort_by_cached_key(|tile| {
|
||||
std::cmp::Reverse(
|
||||
members(tile)
|
||||
.iter()
|
||||
.map(|title| crate::franchise::release_key(title, metadata))
|
||||
// `~` is how the key says "undated"; it sorts last here
|
||||
// rather than first.
|
||||
.filter(|key| key != "~")
|
||||
.max(),
|
||||
)
|
||||
}),
|
||||
Sort::Rating => tiles.sort_by_key(|tile| {
|
||||
std::cmp::Reverse(
|
||||
found(titles[tile.lead])
|
||||
.and_then(|found| found.rating)
|
||||
.map(|rating| (rating * 100.0) as i64),
|
||||
)
|
||||
}),
|
||||
Sort::Popularity => tiles.sort_by_key(|tile| {
|
||||
std::cmp::Reverse(
|
||||
found(titles[tile.lead])
|
||||
.and_then(|found| found.details.as_ref())
|
||||
.and_then(|details| details.popularity),
|
||||
)
|
||||
}),
|
||||
}
|
||||
tiles
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::library::TitleKind;
|
||||
use crate::metadata::{ProviderId, TitleDetails, TitleMetadata};
|
||||
|
||||
fn title(key: &str, name: &str, kind: TitleKind) -> Title {
|
||||
Title {
|
||||
key: key.into(),
|
||||
name: name.into(),
|
||||
year: None,
|
||||
kind,
|
||||
seasons: Vec::new(),
|
||||
share_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn matched(
|
||||
key: &str,
|
||||
id: &str,
|
||||
english: &str,
|
||||
start: &str,
|
||||
rating: f32,
|
||||
related: &[&str],
|
||||
) -> (String, MetadataRecord) {
|
||||
(
|
||||
key.into(),
|
||||
MetadataRecord {
|
||||
title_key: key.into(),
|
||||
provider: ProviderId::AniList,
|
||||
metadata: Some(TitleMetadata {
|
||||
provider: ProviderId::AniList,
|
||||
remote_id: id.into(),
|
||||
name: english.into(),
|
||||
original_name: None,
|
||||
overview: None,
|
||||
year: None,
|
||||
kind: TitleKind::Series,
|
||||
poster_url: None,
|
||||
backdrop_url: None,
|
||||
rating: Some(rating),
|
||||
genres: Vec::new(),
|
||||
episodes: None,
|
||||
url: None,
|
||||
details: Some(TitleDetails {
|
||||
romaji: Some(key.into()),
|
||||
start_date: Some(start.into()),
|
||||
related: related.iter().map(|id| id.to_string()).collect(),
|
||||
..TitleDetails::default()
|
||||
}),
|
||||
}),
|
||||
fetched_at: 0,
|
||||
manual: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn fixture() -> (Vec<Title>, HashMap<String, MetadataRecord>) {
|
||||
let titles = vec![
|
||||
title("abyss film", "Made in Abyss Dawn", TitleKind::Film),
|
||||
title("made in abyss", "Made in Abyss", TitleKind::Series),
|
||||
title("sousou no frieren", "Sousou no Frieren", TitleKind::Series),
|
||||
];
|
||||
let metadata = HashMap::from([
|
||||
matched(
|
||||
"abyss film",
|
||||
"2",
|
||||
"Dawn of the Deep Soul",
|
||||
"2020-01-17",
|
||||
8.5,
|
||||
&["1"],
|
||||
),
|
||||
matched(
|
||||
"made in abyss",
|
||||
"1",
|
||||
"Made in Abyss",
|
||||
"2017-07-07",
|
||||
8.4,
|
||||
&["2"],
|
||||
),
|
||||
matched(
|
||||
"sousou no frieren",
|
||||
"3",
|
||||
"Frieren: Beyond Journey's End",
|
||||
"2023-09-29",
|
||||
9.1,
|
||||
&[],
|
||||
),
|
||||
]);
|
||||
(titles, metadata)
|
||||
}
|
||||
|
||||
fn keys(tiles: &[Tile], titles: &[&Title]) -> Vec<String> {
|
||||
tiles
|
||||
.iter()
|
||||
.map(|tile| titles[tile.lead].key.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_franchise_folds_into_its_series_and_sorts_by_the_name_shown() {
|
||||
let (owned, metadata) = fixture();
|
||||
let titles: Vec<&Title> = owned.iter().collect();
|
||||
let added = HashMap::new();
|
||||
let order = Order {
|
||||
sort: Sort::Name,
|
||||
grouped: true,
|
||||
names: TitleNames::English,
|
||||
metadata: &metadata,
|
||||
added: &added,
|
||||
};
|
||||
let tiles = arrange(&titles, order);
|
||||
// "Frieren" before "Made in Abyss" in English; the film is folded.
|
||||
assert_eq!(
|
||||
keys(&tiles, &titles),
|
||||
vec!["sousou no frieren", "made in abyss"]
|
||||
);
|
||||
assert_eq!(tiles[1].members, vec![1, 0]);
|
||||
|
||||
let tiles = arrange(
|
||||
&titles,
|
||||
Order {
|
||||
names: TitleNames::Library,
|
||||
grouped: false,
|
||||
..order
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
keys(&tiles, &titles),
|
||||
vec!["made in abyss", "abyss film", "sousou no frieren"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newest_and_best_rated_read_the_provider_and_put_the_unknown_last() {
|
||||
let (mut owned, metadata) = fixture();
|
||||
owned.push(title(
|
||||
"game of thrones",
|
||||
"Game of Thrones",
|
||||
TitleKind::Series,
|
||||
));
|
||||
let titles: Vec<&Title> = owned.iter().collect();
|
||||
let added = HashMap::new();
|
||||
let order = Order {
|
||||
sort: Sort::Release,
|
||||
grouped: true,
|
||||
names: TitleNames::Library,
|
||||
metadata: &metadata,
|
||||
added: &added,
|
||||
};
|
||||
// The Made in Abyss franchise dates from its newest part, 2020.
|
||||
assert_eq!(
|
||||
keys(&arrange(&titles, order), &titles),
|
||||
vec!["sousou no frieren", "made in abyss", "game of thrones"]
|
||||
);
|
||||
let order = Order {
|
||||
sort: Sort::Rating,
|
||||
..order
|
||||
};
|
||||
assert_eq!(
|
||||
keys(&arrange(&titles, order), &titles)
|
||||
.last()
|
||||
.map(String::as_str),
|
||||
Some("game of thrones")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_finds_a_title_by_any_name_the_provider_gave_it() {
|
||||
let (owned, metadata) = fixture();
|
||||
let library = Library { titles: owned };
|
||||
let found = search(&library, &metadata, "beyond journey");
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].key, "sousou no frieren");
|
||||
assert_eq!(search(&library, &metadata, "").len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod appearance;
|
||||
pub mod browse;
|
||||
pub mod catalog;
|
||||
pub mod chapters;
|
||||
pub mod config;
|
||||
|
||||
@@ -240,11 +240,13 @@ impl TitleMetadata {
|
||||
#[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]
|
||||
/// The folder's — what the share calls it.
|
||||
Library,
|
||||
/// The provider's English title, where it has one.
|
||||
/// The provider's English title, where it has one. The default, because
|
||||
/// it is what Android always showed, and a folder name is whatever an
|
||||
/// uploader typed — `Kaijuu 8-gou`, `Fate Stay Night - Heaven s Feel -
|
||||
/// THE MOVIE I presage flower`.
|
||||
#[default]
|
||||
English,
|
||||
/// The provider's romaji title.
|
||||
Romaji,
|
||||
|
||||
Reference in new issue
Block a user