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:
nvrl committed 2026-10-30 23:07:51 +01:00
1 parent fafe0c3087
commit 87ccaf26e1
16 files changed
+869 -20

No files matched your search

+151
View File
@@ -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(&[]);
+1
View File
@@ -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;
+81
View File
@@ -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));
}
}