mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 00:47:00 +01:00
feat(app,android,ios): search across episodes and people
A search now also finds, with posters and descriptions on, the directors and studios and the genres whose names hold it, as chips that open their page, and the episodes whose provider names hold it, which play from the result (pstr_core::discover::find). The titles it found follow under their own heading. The last eight searches are kept in recent-searches.json on this device only (pstr_core::recent), written only when a search led somewhere, and offered under the empty search box with a way to clear them. The bridge exposes search_extras, recent_searches, remember_search and forget_searches. On Android and iOS the library's titles are now the whole library whatever is searched for; the grid narrows through the arrangement, as it already did. A title opened from an episode result can then always be shown, and History and Downloads no longer shrink to the search.
This commit is contained in:
1 parent
fafe0c3087
commit
87ccaf26e1
16 files changed
+869
-20
No files matched your search
@@ -365,6 +365,44 @@ pub enum BrowseFacet {
|
||||
Person { name: String },
|
||||
}
|
||||
|
||||
/// One episode a search found by its provider name.
|
||||
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
|
||||
pub struct EpisodeMatchRecord {
|
||||
pub title_key: String,
|
||||
pub link_id: String,
|
||||
/// `S01E05`.
|
||||
pub label: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// A genre, studio or person a search named, with how many titles carry it.
|
||||
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
|
||||
pub struct FacetCountRecord {
|
||||
pub facet: BrowseFacet,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
/// What a search found besides titles; the shared
|
||||
/// `pstr_core::discover::Found`.
|
||||
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
|
||||
pub struct SearchExtrasRecord {
|
||||
pub episodes: Vec<EpisodeMatchRecord>,
|
||||
/// Directors and studios.
|
||||
pub people: Vec<FacetCountRecord>,
|
||||
pub genres: Vec<FacetCountRecord>,
|
||||
}
|
||||
|
||||
impl From<pstr_core::discover::Facet> for BrowseFacet {
|
||||
fn from(facet: pstr_core::discover::Facet) -> Self {
|
||||
use pstr_core::discover::Facet;
|
||||
match facet {
|
||||
Facet::Genre(name) => Self::Genre { name },
|
||||
Facet::Studio(name) => Self::Studio { name },
|
||||
Facet::Person(name) => Self::Person { name },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowseFacet {
|
||||
fn core(&self) -> pstr_core::discover::Facet {
|
||||
use pstr_core::discover::Facet;
|
||||
@@ -1764,6 +1802,65 @@ impl AndroidEngine {
|
||||
})
|
||||
}
|
||||
|
||||
/// What `search` finds besides titles: episodes by name, and the people,
|
||||
/// studios and genres it names.
|
||||
pub fn search_extras(&self, search: String) -> Result<SearchExtrasRecord, BridgeError> {
|
||||
self.with_library(|cached| {
|
||||
let found = pstr_core::discover::find(
|
||||
&cached.library,
|
||||
&cached.metadata,
|
||||
&cached.guides,
|
||||
&search,
|
||||
);
|
||||
let facets = |facets: Vec<(pstr_core::discover::Facet, usize)>| {
|
||||
facets
|
||||
.into_iter()
|
||||
.map(|(facet, count)| FacetCountRecord {
|
||||
facet: BrowseFacet::from(facet),
|
||||
count: count as u32,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
SearchExtrasRecord {
|
||||
episodes: found
|
||||
.episodes
|
||||
.into_iter()
|
||||
.map(|hit| EpisodeMatchRecord {
|
||||
title_key: hit.title_key,
|
||||
link_id: hit.link_id,
|
||||
label: hit.label,
|
||||
name: hit.name,
|
||||
})
|
||||
.collect(),
|
||||
people: facets(found.people),
|
||||
genres: facets(found.genres),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The last few searches on this device, newest first.
|
||||
pub fn recent_searches(&self) -> Result<Vec<String>, BridgeError> {
|
||||
pstr_core::recent::load(&self.dirs)
|
||||
.map(|recent| recent.queries)
|
||||
.map_err(BridgeError::from_display)
|
||||
}
|
||||
|
||||
/// Put `query` at the front of the recent searches. Refused when the
|
||||
/// file would not parse, rather than writing a new list over it.
|
||||
pub fn remember_search(&self, query: String) -> Result<(), BridgeError> {
|
||||
let mut recent = pstr_core::recent::load(&self.dirs).map_err(BridgeError::from_display)?;
|
||||
if recent.remember(&query) {
|
||||
pstr_core::recent::save(&self.dirs, &recent).map_err(BridgeError::from_display)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget every recent search.
|
||||
pub fn forget_searches(&self) -> Result<(), BridgeError> {
|
||||
pstr_core::recent::load(&self.dirs).map_err(BridgeError::from_display)?;
|
||||
pstr_core::recent::save(&self.dirs, &Default::default()).map_err(BridgeError::from_display)
|
||||
}
|
||||
|
||||
/// The keys of every title `facet` stands for, in library order.
|
||||
pub fn browse(&self, facet: BrowseFacet) -> Result<Vec<String>, BridgeError> {
|
||||
let facet = facet.core();
|
||||
@@ -2645,6 +2742,7 @@ impl AndroidEngine {
|
||||
titles,
|
||||
offline: offline.into_keys().collect(),
|
||||
first_seen,
|
||||
guides,
|
||||
};
|
||||
let answer = read(&cached);
|
||||
*self.library_cache.lock() = Some(cached);
|
||||
@@ -3527,6 +3625,9 @@ struct CachedLibrary {
|
||||
offline: HashSet<(String, String)>,
|
||||
/// When each file first appeared in a crawl; see [`Catalog::first_seen`].
|
||||
first_seen: HashMap<(String, String), i64>,
|
||||
/// What the providers said about each title's episodes, for searching
|
||||
/// them by name.
|
||||
guides: HashMap<String, EpisodeGuide>,
|
||||
}
|
||||
|
||||
/// The titles whose files first appeared at or after `since`, with how many
|
||||
@@ -4487,6 +4588,23 @@ mod tests {
|
||||
assert!(engine.browse(genre.clone()).expect("browse").is_empty());
|
||||
assert_eq!(super::facet_kind(genre), "Genre");
|
||||
|
||||
// Recent searches: newest first, and forgotten on asking.
|
||||
engine.remember_search("frieren".into()).expect("remember");
|
||||
engine.remember_search("abyss".into()).expect("remember");
|
||||
assert_eq!(
|
||||
engine.recent_searches().expect("recent"),
|
||||
["abyss", "frieren"]
|
||||
);
|
||||
engine.forget_searches().expect("forget");
|
||||
assert!(engine.recent_searches().expect("recent").is_empty());
|
||||
assert!(
|
||||
engine
|
||||
.search_extras("frieren".into())
|
||||
.expect("extras")
|
||||
.episodes
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
|
||||
@@ -427,6 +427,8 @@ pub struct LibraryView {
|
||||
pub badges: HashMap<String, String>,
|
||||
/// The keys on My List.
|
||||
pub listed: std::collections::HashSet<String>,
|
||||
/// What the search found besides titles.
|
||||
pub found: pstr_core::discover::Found,
|
||||
/// "More like this" for the title page open, worked out once per title:
|
||||
/// its key, and the keys of the titles like it.
|
||||
pub similar: Option<(String, Vec<String>)>,
|
||||
@@ -468,6 +470,7 @@ impl Default for LibraryView {
|
||||
badges: HashMap::new(),
|
||||
listed: std::collections::HashSet::new(),
|
||||
similar: None,
|
||||
found: pstr_core::discover::Found::default(),
|
||||
stale: false,
|
||||
progressed: false,
|
||||
}
|
||||
@@ -488,6 +491,9 @@ pub struct ViewInputs<'a> {
|
||||
pub list: &'a MyList,
|
||||
/// When each file first appeared in a crawl, for what is new.
|
||||
pub first_seen: &'a HashMap<(String, String), i64>,
|
||||
/// What the providers said about each title's episodes, for searching
|
||||
/// them by name.
|
||||
pub guides: &'a HashMap<String, EpisodeGuide>,
|
||||
pub now: i64,
|
||||
}
|
||||
|
||||
@@ -615,6 +621,9 @@ impl LibraryView {
|
||||
self.resumable = Self::resumable(library);
|
||||
self.badges = Self::badges(library, &inputs);
|
||||
}
|
||||
if self.query != inputs.query || self.stale {
|
||||
self.found = pstr_core::discover::find(library, metadata, inputs.guides, inputs.query);
|
||||
}
|
||||
self.query = inputs.query.to_owned();
|
||||
self.stale = false;
|
||||
self.progressed = false;
|
||||
@@ -1599,6 +1608,15 @@ impl App {
|
||||
}
|
||||
|
||||
fn apply(&mut self, ctx: &egui::Context, action: Action) {
|
||||
// A search that led somewhere is one worth offering again.
|
||||
if self.page == Page::Library
|
||||
&& matches!(
|
||||
action,
|
||||
Action::Goto(Page::Title(_) | Page::Browse { .. }) | Action::Play(_)
|
||||
)
|
||||
{
|
||||
self.engine.remember_search(Some(&self.search));
|
||||
}
|
||||
match action {
|
||||
Action::Goto(page) => self.page = page,
|
||||
Action::Play(mut target) => {
|
||||
@@ -2221,7 +2239,9 @@ impl App {
|
||||
|
||||
if on_library && !self.library.is_empty() {
|
||||
ui.add_space(theme::space::M);
|
||||
ui::search_field(ui, search, focus_search);
|
||||
if ui::search_field(ui, search, focus_search, &self.engine.recent_searches()) {
|
||||
self.engine.remember_search(None);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2474,6 +2494,7 @@ impl eframe::App for App {
|
||||
offline: &self.offline_files,
|
||||
list: &self.my_list,
|
||||
first_seen: &self.first_seen,
|
||||
guides: &self.episodes,
|
||||
now: crate::engine::unix_now(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -425,6 +425,9 @@ pub struct Engine {
|
||||
/// My List, read once at launch. `None` when the file would not parse: it
|
||||
/// is then never written over, and changing the list is refused.
|
||||
my_list: Arc<Mutex<Option<MyList>>>,
|
||||
/// The last few searches, newest first. `None`, and never written, when
|
||||
/// the file would not parse.
|
||||
recent: Arc<Mutex<Option<pstr_core::recent::RecentSearches>>>,
|
||||
/// Preloaded so clicking Play never runs SQLite on egui's thread.
|
||||
track_prefs: Arc<Mutex<HashMap<String, TitleTrackPrefs>>>,
|
||||
downloads: Arc<Mutex<Downloads>>,
|
||||
@@ -535,6 +538,9 @@ impl Engine {
|
||||
let my_list = pstr_core::mylist::load(&dirs)
|
||||
.inspect_err(|error| tracing::warn!("read My List: {error}"))
|
||||
.ok();
|
||||
let recent = pstr_core::recent::load(&dirs)
|
||||
.inspect_err(|error| tracing::warn!("read recent searches: {error}"))
|
||||
.ok();
|
||||
|
||||
// The TMDB key, which every provider looks for, comes out of the OS
|
||||
// credential store, and that can be a D-Bus round trip to a keyring
|
||||
@@ -575,6 +581,7 @@ impl Engine {
|
||||
prefs: Arc::new(Mutex::new(prefs)),
|
||||
appearance: Arc::new(Mutex::new(appearance)),
|
||||
my_list: Arc::new(Mutex::new(my_list)),
|
||||
recent: Arc::new(Mutex::new(recent)),
|
||||
track_prefs: Arc::new(Mutex::new(track_prefs)),
|
||||
downloads: Arc::new(Mutex::new(Downloads::default())),
|
||||
download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)),
|
||||
@@ -676,6 +683,38 @@ impl Engine {
|
||||
saved
|
||||
}
|
||||
|
||||
/// The last few searches, newest first.
|
||||
pub fn recent_searches(&self) -> Vec<String> {
|
||||
self.recent
|
||||
.lock()
|
||||
.as_ref()
|
||||
.map(|recent| recent.queries.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Put `query` at the front of the recent searches, or with `None`
|
||||
/// forget them all, and write the list off the UI thread.
|
||||
pub fn remember_search(&self, query: Option<&str>) {
|
||||
let changed = match (self.recent.lock().as_mut(), query) {
|
||||
(Some(recent), Some(query)) => recent.remember(query),
|
||||
(Some(recent), None) => !std::mem::take(&mut recent.queries).is_empty(),
|
||||
(None, _) => false,
|
||||
};
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
let engine = self.clone();
|
||||
self.runtime.spawn_blocking(move || {
|
||||
let guard = engine.recent.lock();
|
||||
if let Some(recent) = guard.as_ref()
|
||||
&& let Err(error) = pstr_core::recent::save(&engine.dirs, recent)
|
||||
{
|
||||
drop(guard);
|
||||
engine.fail("save recent searches", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Change the theme and write it.
|
||||
///
|
||||
/// Always committed, unlike the volume: a theme is picked with a click and
|
||||
|
||||
@@ -94,6 +94,15 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
|
||||
}
|
||||
}
|
||||
|
||||
if searching && !view.found.is_empty() {
|
||||
found(ui, library, &view.found, art, actions);
|
||||
// Found as an episode or a person but not by name: no empty
|
||||
// heading over no titles.
|
||||
if matches.is_empty() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui::section(
|
||||
ui,
|
||||
@@ -127,6 +136,76 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
|
||||
});
|
||||
}
|
||||
|
||||
/// What a search found besides titles, above the titles it found: the
|
||||
/// people, studios and genres it names as chips, then episodes by name.
|
||||
fn found(
|
||||
ui: &mut egui::Ui,
|
||||
library: &Library,
|
||||
found: &pstr_core::discover::Found,
|
||||
art: &Art<'_>,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
let chips = |ui: &mut egui::Ui,
|
||||
heading: &str,
|
||||
facets: &[(pstr_core::discover::Facet, usize)],
|
||||
actions: &mut Vec<Action>| {
|
||||
if facets.is_empty() {
|
||||
return;
|
||||
}
|
||||
ui::section(ui, heading);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(6.0, 6.0);
|
||||
for (facet, count) in facets {
|
||||
if ui::widgets::link_chip(ui, &format!("{} · {count}", facet.name()))
|
||||
.on_hover_text(format!("{}: {}", facet.kind(), plural(*count, "title")))
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::Goto(Page::Browse {
|
||||
facet: facet.clone(),
|
||||
from: None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(theme::space::XL);
|
||||
};
|
||||
chips(ui, "People and studios", &found.people, actions);
|
||||
chips(ui, "Genres", &found.genres, actions);
|
||||
|
||||
if found.episodes.is_empty() {
|
||||
return;
|
||||
}
|
||||
ui::section(ui, "Episodes");
|
||||
for hit in &found.episodes {
|
||||
let Some(title) = library.get(&hit.title_key) else {
|
||||
continue;
|
||||
};
|
||||
let Some(episode) = title.episodes().find(|episode| {
|
||||
episode.node.link_id == hit.link_id && episode.node.share_id == hit.share_id
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let text = format!(
|
||||
"{} {} · {} · {}",
|
||||
egui_phosphor::regular::PLAY,
|
||||
art.name(title),
|
||||
hit.label,
|
||||
hit.name
|
||||
);
|
||||
let response = ui
|
||||
.add(egui::Button::selectable(false, text).frame_when_inactive(false))
|
||||
.on_hover_text("Play this episode");
|
||||
if response.clicked() {
|
||||
actions.push(Action::Play(PlaybackTarget::from_node(
|
||||
title,
|
||||
&episode.node,
|
||||
None,
|
||||
)));
|
||||
}
|
||||
}
|
||||
ui.add_space(theme::space::XL);
|
||||
}
|
||||
|
||||
/// What a chip on a title page opened.
|
||||
pub struct Browsing<'a> {
|
||||
pub facet: &'a pstr_core::discover::Facet,
|
||||
|
||||
@@ -407,7 +407,15 @@ pub fn glass_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
|
||||
///
|
||||
/// Escape inside it clears it. `focus` puts the caret in it this frame — which
|
||||
/// is what `Ctrl+F` and `/` ask for from anywhere in the library.
|
||||
pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
|
||||
///
|
||||
/// Empty and with the caret in it, it offers the `recent` searches under it.
|
||||
/// Picking one fills the box; the return says the viewer asked to forget them.
|
||||
pub fn search_field(
|
||||
ui: &mut egui::Ui,
|
||||
search: &mut String,
|
||||
focus: bool,
|
||||
recent: &[String],
|
||||
) -> bool {
|
||||
let id = egui::Id::new("library-search");
|
||||
let width = 240.0;
|
||||
let height = ui.spacing().interact_size.y;
|
||||
@@ -446,7 +454,7 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
|
||||
.id(id)
|
||||
.frame(egui::Frame::NONE)
|
||||
.hint_text(if has_focus {
|
||||
"Titles and filenames"
|
||||
"Titles, episodes, people"
|
||||
} else {
|
||||
"Search Ctrl+F"
|
||||
})
|
||||
@@ -490,6 +498,56 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) {
|
||||
search.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Kept up while the pointer is over it as well as while the box has the
|
||||
// caret: pressing on it takes the caret away before the click lands.
|
||||
let popup = id.with("recent");
|
||||
let over = ui
|
||||
.data(|data| data.get_temp::<bool>(popup))
|
||||
.unwrap_or(false);
|
||||
let mut forget = false;
|
||||
if search.is_empty() && !recent.is_empty() && (response.has_focus() || over) {
|
||||
let mut picked = None;
|
||||
let shown = egui::Area::new(popup)
|
||||
.order(egui::Order::Foreground)
|
||||
.fixed_pos(outer.left_bottom() + Vec2::new(0.0, 6.0))
|
||||
.show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_width(width - 12.0);
|
||||
ui.label(
|
||||
theme::Role::Caption
|
||||
.rich("Recent searches")
|
||||
.color(theme::muted()),
|
||||
);
|
||||
for query in recent {
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::selectable(false, query.as_str())
|
||||
.frame_when_inactive(false),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
picked = Some(query.clone());
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
forget = ui
|
||||
.add(
|
||||
egui::Button::selectable(false, muted("Clear recent searches"))
|
||||
.frame_when_inactive(false),
|
||||
)
|
||||
.clicked();
|
||||
});
|
||||
});
|
||||
let over = shown.response.contains_pointer();
|
||||
ui.data_mut(|data| data.insert_temp(popup, over && !forget && picked.is_none()));
|
||||
if let Some(query) = picked {
|
||||
*search = query;
|
||||
}
|
||||
} else {
|
||||
ui.data_mut(|data| data.remove::<bool>(popup));
|
||||
}
|
||||
forget
|
||||
}
|
||||
|
||||
/// A thin bar in the accent, for how far something has got.
|
||||
|
||||
@@ -70,6 +70,125 @@ impl Facet {
|
||||
}
|
||||
}
|
||||
|
||||
/// One episode a search found by the name the provider gave it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EpisodeMatch {
|
||||
pub title_key: String,
|
||||
pub share_id: String,
|
||||
pub link_id: String,
|
||||
/// `S01E05`, as the row everywhere else says it.
|
||||
pub label: String,
|
||||
/// The provider's name for it.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// What a search found besides titles: episodes by name, and the people,
|
||||
/// studios and genres whose names hold the query, each with how many titles
|
||||
/// in the library carry it.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Found {
|
||||
pub episodes: Vec<EpisodeMatch>,
|
||||
/// Directors and studios.
|
||||
pub people: Vec<(Facet, usize)>,
|
||||
pub genres: Vec<(Facet, usize)>,
|
||||
}
|
||||
|
||||
impl Found {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.episodes.is_empty() && self.people.is_empty() && self.genres.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// How many episodes a search lists: past this the query is too loose for a
|
||||
/// list of episodes to help.
|
||||
const EPISODES: usize = 12;
|
||||
|
||||
/// Everything but titles that `query` finds. Nothing for a query under two
|
||||
/// letters, which would hold half of every name.
|
||||
pub fn find(
|
||||
library: &crate::library::Library,
|
||||
metadata: &HashMap<String, MetadataRecord>,
|
||||
guides: &HashMap<String, crate::metadata::EpisodeGuide>,
|
||||
query: &str,
|
||||
) -> Found {
|
||||
let needle = query.trim().to_lowercase();
|
||||
if needle.chars().count() < 2 {
|
||||
return Found::default();
|
||||
}
|
||||
let holds = |name: &str| name.to_lowercase().contains(&needle);
|
||||
|
||||
let mut episodes = Vec::new();
|
||||
'titles: for title in &library.titles {
|
||||
let Some(guide) = guides.get(&title.key) else {
|
||||
continue;
|
||||
};
|
||||
for episode in title.episodes() {
|
||||
let node = &episode.node;
|
||||
let Some(name) = node
|
||||
.parsed
|
||||
.episode
|
||||
.and_then(|number| guide.get(node.parsed.season, number))
|
||||
.and_then(|found| found.name.as_deref())
|
||||
.filter(|name| holds(name))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
episodes.push(EpisodeMatch {
|
||||
title_key: title.key.clone(),
|
||||
share_id: node.share_id.clone(),
|
||||
link_id: node.link_id.clone(),
|
||||
label: episode.numbering().unwrap_or_else(|| episode.label()),
|
||||
name: name.to_owned(),
|
||||
});
|
||||
if episodes.len() == EPISODES {
|
||||
break 'titles;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut people: HashMap<Facet, usize> = HashMap::new();
|
||||
let mut genres: HashMap<Facet, usize> = HashMap::new();
|
||||
for title in &library.titles {
|
||||
let Some(found) = metadata
|
||||
.get(&title.key)
|
||||
.and_then(|record| record.metadata.as_ref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for genre in found.genres.iter().filter(|genre| holds(genre)) {
|
||||
*genres.entry(Facet::Genre(genre.clone())).or_default() += 1;
|
||||
}
|
||||
let Some(details) = &found.details else {
|
||||
continue;
|
||||
};
|
||||
let made = details
|
||||
.directors
|
||||
.iter()
|
||||
.map(|name| Facet::Person(name.clone()))
|
||||
.chain(
|
||||
details
|
||||
.studios
|
||||
.iter()
|
||||
.map(|name| Facet::Studio(name.clone())),
|
||||
);
|
||||
for facet in made.filter(|facet| holds(facet.name())) {
|
||||
*people.entry(facet).or_default() += 1;
|
||||
}
|
||||
}
|
||||
// Most titles first, then by name, so the order does not change from one
|
||||
// keystroke to the next.
|
||||
let ranked = |found: HashMap<Facet, usize>| {
|
||||
let mut found: Vec<(Facet, usize)> = found.into_iter().collect();
|
||||
found.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.name().cmp(b.0.name())));
|
||||
found
|
||||
};
|
||||
Found {
|
||||
episodes,
|
||||
people: ranked(people),
|
||||
genres: ranked(genres),
|
||||
}
|
||||
}
|
||||
|
||||
/// How much one shared trait says two titles are alike. A director is close
|
||||
/// to a signature; a genre is one of three or four on every entry.
|
||||
const DIRECTOR: u32 = 4;
|
||||
@@ -410,6 +529,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_search_finds_episodes_by_name_and_the_people_and_genres_it_names() {
|
||||
let (library, metadata) = library(&[]);
|
||||
let guide = crate::metadata::EpisodeGuide::new(vec![crate::metadata::EpisodeMetadata {
|
||||
season: None,
|
||||
number: 1,
|
||||
absolute: None,
|
||||
entry_season: None,
|
||||
entry_number: None,
|
||||
name: Some("The Journey's End".into()),
|
||||
overview: None,
|
||||
still_url: None,
|
||||
air_date: None,
|
||||
runtime: None,
|
||||
}]);
|
||||
let guides = HashMap::from([("frieren".to_owned(), guide)]);
|
||||
|
||||
let found = find(&library, &metadata, &guides, "journey");
|
||||
assert_eq!(found.episodes.len(), 1);
|
||||
assert_eq!(found.episodes[0].title_key, "frieren");
|
||||
assert_eq!(found.episodes[0].name, "The Journey's End");
|
||||
|
||||
let found = find(&library, &metadata, &guides, "saitou");
|
||||
assert_eq!(
|
||||
found.people,
|
||||
[(Facet::Person("Keiichirou Saitou".into()), 3)]
|
||||
);
|
||||
let found = find(&library, &metadata, &guides, "com");
|
||||
assert_eq!(found.genres, [(Facet::Genre("Comedy".into()), 2)]);
|
||||
assert!(find(&library, &metadata, &guides, "c").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_facet_page_lists_the_titles_that_carry_it() {
|
||||
let (library, metadata) = library(&[]);
|
||||
|
||||
@@ -22,6 +22,7 @@ pub mod naming;
|
||||
pub mod news;
|
||||
pub mod prefs;
|
||||
pub mod profile;
|
||||
pub mod recent;
|
||||
pub mod shares;
|
||||
pub mod shelves;
|
||||
pub mod subtitles;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! The last few searches, offered again under an empty search box.
|
||||
//!
|
||||
//! Kept on this device only, in `recent-searches.json` beside the other
|
||||
//! settings, and never synced: what someone typed is not a setting, and
|
||||
//! another device's list is not what they were just looking for.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::{AppDirs, read_json, write_json};
|
||||
use crate::error::Result;
|
||||
|
||||
/// How many searches are kept.
|
||||
pub const KEEP: usize = 8;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RecentSearches {
|
||||
/// Newest first.
|
||||
#[serde(default)]
|
||||
pub queries: Vec<String>,
|
||||
}
|
||||
|
||||
impl RecentSearches {
|
||||
/// Put `query` first, saying whether the list changed. The same search
|
||||
/// in other letter case moves rather than repeats, and one under two
|
||||
/// letters is not kept.
|
||||
pub fn remember(&mut self, query: &str) -> bool {
|
||||
let query = query.trim();
|
||||
if query.chars().count() < 2 {
|
||||
return false;
|
||||
}
|
||||
if self.queries.first().map(String::as_str) == Some(query) {
|
||||
return false;
|
||||
}
|
||||
let lower = query.to_lowercase();
|
||||
self.queries.retain(|kept| kept.to_lowercase() != lower);
|
||||
self.queries.insert(0, query.to_owned());
|
||||
self.queries.truncate(KEEP);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn file(dirs: &AppDirs) -> PathBuf {
|
||||
dirs.config.join("recent-searches.json")
|
||||
}
|
||||
|
||||
/// The list, empty when there is no file yet.
|
||||
pub fn load(dirs: &AppDirs) -> Result<RecentSearches> {
|
||||
Ok(read_json::<RecentSearches>(&file(dirs))?.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn save(dirs: &AppDirs, recent: &RecentSearches) -> Result<()> {
|
||||
write_json(&file(dirs), recent)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_search_repeated_moves_to_the_front_instead_of_appearing_twice() {
|
||||
let mut recent = RecentSearches::default();
|
||||
assert!(recent.remember("frieren"));
|
||||
assert!(recent.remember("kon"));
|
||||
assert!(recent.remember("Frieren "));
|
||||
assert_eq!(recent.queries, ["Frieren", "kon"]);
|
||||
assert!(!recent.remember("Frieren"));
|
||||
assert!(!recent.remember("x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_newest_searches_are_kept() {
|
||||
let mut recent = RecentSearches::default();
|
||||
for at in 0..KEEP + 3 {
|
||||
recent.remember(&format!("search {at}"));
|
||||
}
|
||||
assert_eq!(recent.queries.len(), KEEP);
|
||||
assert_eq!(recent.queries[0], format!("search {}", KEEP + 2));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user