feat(core,app,android,ios): more like this and browse by genre, studio, person

pstr_core::discover scores every title against another by shared
director, studio, genres, leading tags and format, leaves out its own
franchise and keeps one title per other franchise. It drives a "More
like this" row under a title's episodes and, seeded by the title most
recently watched to the end, a "Because you watched" row on the library
of titles not yet started.

Genre chips on the title page now open a page of every title with that
genre, and the studios and director move from the line of facts onto
chips of their own that do the same. The desktop has a Browse page whose
back link returns to the title; Android and iOS show it over the library
pane. The bridge exposes more_like, browse and facet_kind.

Also moves a doc comment the My List commit left on the wrong function.
This commit is contained in:
nvrl committed 2026-10-30 22:46:08 +01:00
1 parent f66faf6b75
commit fafe0c3087
23 files changed
+1139 -92

No files matched your search

+62 -3
View File
@@ -356,6 +356,26 @@ pub enum TitleNamesChoice {
Romaji,
}
/// What a genre, studio or director chip opens: the shared
/// `pstr_core::discover::Facet`.
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
pub enum BrowseFacet {
Genre { name: String },
Studio { name: String },
Person { name: String },
}
impl BrowseFacet {
fn core(&self) -> pstr_core::discover::Facet {
use pstr_core::discover::Facet;
match self {
Self::Genre { name } => Facet::Genre(name.clone()),
Self::Studio { name } => Facet::Studio(name.clone()),
Self::Person { name } => Facet::Person(name.clone()),
}
}
}
/// The shared `pstr_core::browse::Filter`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum LibraryFilter {
@@ -479,9 +499,6 @@ pub fn picture_mood(pixels: Vec<u32>) -> Option<u32> {
.map(pstr_core::appearance::Rgb::argb)
}
/// The page colour `background` tinted by a picture's [`picture_mood`], for
/// the veils over that picture. All `0xAARRGGBB`; see
/// `pstr_core::appearance::mood_veil`.
/// What an airing show's tile says about its next episode, `Ep 8 · in 2 d`;
/// see `pstr_core::news::countdown`.
#[uniffi::export]
@@ -489,6 +506,16 @@ pub fn airing_countdown(episode: u32, at: i64, now: i64) -> String {
pstr_core::news::countdown(episode, at, now)
}
/// What kind of thing a [`BrowseFacet`] names — `Genre`, `Studio`,
/// `Director` — for over the heading of its page.
#[uniffi::export]
pub fn facet_kind(facet: BrowseFacet) -> String {
facet.core().kind().to_owned()
}
/// The page colour `background` tinted by a picture's [`picture_mood`], for
/// the veils over that picture. All `0xAARRGGBB`; see
/// `pstr_core::appearance::mood_veil`.
#[uniffi::export]
pub fn mood_veil(background: u32, mood: u32, light: bool) -> u32 {
let rgb = |argb: u32| {
@@ -1700,6 +1727,7 @@ impl AndroidEngine {
pstr_core::shelves::home(
&cached.library.titles,
&cached.metadata,
cached.names,
&cached.list,
&cached.first_seen,
now(),
@@ -1727,6 +1755,21 @@ impl AndroidEngine {
})
}
/// The keys of the titles most like `key`, best first: a title page's
/// "More like this" row. Worked out on asking rather than for every
/// record, since it compares the title with the whole library.
pub fn more_like(&self, key: String) -> Result<Vec<String>, BridgeError> {
self.with_library(|cached| {
pstr_core::discover::more_like(&key, &cached.library.titles, &cached.metadata, SIMILAR)
})
}
/// 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();
self.with_library(|cached| facet.titles(&cached.library.titles, &cached.metadata))
}
/// Put a title on My List or take it off. Refused when the list on disk
/// would not parse, rather than writing an empty one over it.
pub fn set_on_list(&self, key: String, on: bool) -> Result<(), BridgeError> {
@@ -3604,6 +3647,9 @@ fn sort_choice(sort: LibrarySort) -> Sort {
/// desktop's four, because on a phone each is a screen's height.
const SHELVES: usize = 3;
/// How many titles a title page's "More like this" row offers.
const SIMILAR: usize = 12;
fn appearance_choice(record: AppearanceRecord) -> Appearance {
Appearance {
flavor: match record.flavor {
@@ -4428,6 +4474,19 @@ mod tests {
.expect("list");
assert!(!engine.library(None).expect("library")[0].on_list);
// The film is Frieren's own franchise, so nothing else is like it.
assert!(
engine
.more_like("sousou no frieren".into())
.expect("similar")
.is_empty()
);
let genre = super::BrowseFacet::Genre {
name: "Drama".into(),
};
assert!(engine.browse(genre.clone()).expect("browse").is_empty());
assert_eq!(super::facet_kind(genre), "Genre");
let _ = std::fs::remove_dir_all(root);
}
+61 -4
View File
@@ -50,6 +50,7 @@ impl FrameTimer {
Page::Library => "library",
Page::History => "history",
Page::Title(_) => "title",
Page::Browse { .. } => "browse",
Page::Shares => "shares",
Page::Downloads => "downloads",
Page::Settings => "settings",
@@ -97,6 +98,12 @@ pub enum Page {
History,
/// One title, by [`pstr_core::library::Title::key`].
Title(String),
/// The titles a genre, studio or person chip stands for, with the title
/// it was opened from, for the way back.
Browse {
facet: pstr_core::discover::Facet,
from: Option<String>,
},
Shares,
Downloads,
/// How the app looks, how it plays, and what it looks up.
@@ -420,6 +427,9 @@ pub struct LibraryView {
pub badges: HashMap<String, String>,
/// The keys on My List.
pub listed: std::collections::HashSet<String>,
/// "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>)>,
/// The library or what is known about it changed under the lists.
stale: bool,
/// Only watch state changed: the shelves and franchises still stand,
@@ -457,6 +467,7 @@ impl Default for LibraryView {
resumable: Vec::new(),
badges: HashMap::new(),
listed: std::collections::HashSet::new(),
similar: None,
stale: false,
progressed: false,
}
@@ -483,6 +494,9 @@ pub struct ViewInputs<'a> {
/// How many shelves the library shows above the grid.
const SHELVES: usize = 4;
/// How many titles a title page's "More like this" row offers.
const SIMILAR: usize = 12;
impl LibraryView {
fn refresh(&mut self, library: &Library, inputs: ViewInputs<'_>) {
use pstr_core::library::Title;
@@ -506,6 +520,7 @@ impl LibraryView {
}
let metadata = inputs.metadata;
if self.stale {
self.similar = None;
let all: Vec<&Title> = library.titles.iter().collect();
self.franchises = pstr_core::franchise::group(&all, metadata)
.into_iter()
@@ -533,6 +548,7 @@ impl LibraryView {
self.shelves = pstr_core::shelves::home(
&library.titles,
metadata,
inputs.names,
inputs.list,
inputs.first_seen,
inputs.now,
@@ -1722,7 +1738,10 @@ impl App {
}
Action::WatchToEnd => self.up_next.dismissed = true,
Action::FocusSearch => {
if !matches!(self.page, Page::Library | Page::Title(_)) {
if !matches!(
self.page,
Page::Library | Page::Title(_) | Page::Browse { .. }
) {
self.page = Page::Library;
}
self.focus_search = true;
@@ -1741,8 +1760,11 @@ impl App {
self.my_list = self.engine.set_on_list(&key, on);
self.view.stale = true;
}
Action::Back => match self.page {
Action::Back => match &self.page {
Page::Title(_) => self.page = Page::Library,
Page::Browse { from, .. } => {
self.page = from.clone().map_or(Page::Library, Page::Title)
}
// Back out of a search before out of anything else: it is the
// last thing the viewer did.
Page::Library if !self.search.is_empty() => self.search.clear(),
@@ -2131,7 +2153,10 @@ impl App {
);
ui.add_space(theme::space::L);
let on_library = matches!(self.page, Page::Library | Page::Title(_));
let on_library = matches!(
self.page,
Page::Library | Page::Title(_) | Page::Browse { .. }
);
let active = self
.downloads
.iter()
@@ -2452,6 +2477,18 @@ impl eframe::App for App {
now: crate::engine::unix_now(),
},
);
// After the refresh, which forgets it when the library changed.
if let Page::Title(key) = &self.page
&& self.view.similar.as_ref().is_none_or(|(of, _)| of != key)
{
let keys = pstr_core::discover::more_like(
key,
&self.library.titles,
&self.metadata,
SIMILAR,
);
self.view.similar = Some((key.clone(), keys));
}
let crawl = self.crawling.then(|| crawl_label(&self.crawl_progress));
let App {
@@ -2580,6 +2617,12 @@ impl eframe::App for App {
key,
ui::title::Context {
franchise: view.franchises.get(key.as_str()).map(Vec::as_slice),
similar: view
.similar
.as_ref()
.filter(|(of, _)| of == key)
.map(|(_, keys)| keys.as_slice())
.unwrap_or_default(),
on_list: view.listed.contains(key.as_str()),
},
ui::title::OfflineView {
@@ -2588,6 +2631,18 @@ impl eframe::App for App {
},
&mut actions,
),
Page::Browse { facet, from } => ui::library::browse(
ui,
&mut art,
library,
ui::library::Browsing {
facet,
from: from.as_deref(),
},
view,
engine.appearance().tiles,
&mut actions,
),
Page::Shares => ui::shares::show(
ui,
shares,
@@ -2939,7 +2994,9 @@ fn global_shortcuts(ctx: &egui::Context, page: &Page, actions: &mut Vec<Action>)
}
if input.consume_key(Modifiers::ALT, Key::ArrowLeft)
|| input.pointer.button_pressed(egui::PointerButton::Extra1)
|| (!typing && matches!(page, Page::Title(_)) && input.key_pressed(Key::Escape))
|| (!typing
&& matches!(page, Page::Title(_) | Page::Browse { .. })
&& input.key_pressed(Key::Escape))
|| (!typing && *page == Page::Library && input.key_pressed(Key::Backspace))
{
actions.push(Action::Back);
+49 -1
View File
@@ -123,7 +123,51 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
return;
}
grid(ui, art, &matches, view, tiles, actions);
grid(ui, art, &matches, view, tiles, true, actions);
});
}
/// What a chip on a title page opened.
pub struct Browsing<'a> {
pub facet: &'a pstr_core::discover::Facet,
/// The title it was opened from, which is where back goes.
pub from: Option<&'a str>,
}
/// Every title sharing a genre, a studio or a director, as the library grid.
pub fn browse(
ui: &mut egui::Ui,
art: &mut Art<'_>,
library: &Library,
browsing: Browsing<'_>,
view: &LibraryView,
tiles: Tiles,
actions: &mut Vec<Action>,
) {
let Browsing { facet, from } = browsing;
let back = from
.and_then(|key| library.get(key))
.map_or("Library", |title| art.name(title))
.to_owned();
if ui::title::back_link(ui, &back).clicked() {
actions.push(Action::Back);
}
ui.add_space(theme::space::S);
let keys = facet.titles(&library.titles, art.metadata);
let titles: Vec<&Title> = keys.iter().filter_map(|key| library.get(key)).collect();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.label(ui::muted(facet.kind()));
ui.label(theme::Role::Display.rich(facet.name()).color(theme::text()));
ui.label(ui::muted(plural(titles.len(), "title")));
ui.add_space(theme::space::L);
if titles.is_empty() {
// A recrawl or a new match took the last one away.
ui.label(ui::muted("Nothing in the library has this any more."));
return;
}
grid(ui, art, &titles, view, tiles, false, actions);
});
}
@@ -740,12 +784,15 @@ fn continue_row(
}
/// Every title, wrapped to the window.
/// `titles` as a wall of tiles. `folding` says they are the library grid's
/// own, whose folded franchises `view` counts.
fn grid(
ui: &mut egui::Ui,
art: &mut Art<'_>,
titles: &[&Title],
view: &LibraryView,
tiles: Tiles,
folding: bool,
actions: &mut Vec<Action>,
) {
let (minimum, aspect) = tile_shape(tiles);
@@ -780,6 +827,7 @@ fn grid(
let folded = view
.matches
.get(row * grid.columns + column)
.filter(|_| folding)
.and_then(|index| view.folded.get(index))
.copied();
let response = ui::card(
+95 -26
View File
@@ -20,6 +20,8 @@ pub struct OfflineView<'a> {
pub struct Context<'a> {
/// The keys of its whole franchise in release order, if it has one.
pub franchise: Option<&'a [String]>,
/// The keys of the titles most like it, best first.
pub similar: &'a [String],
pub on_list: bool,
}
@@ -32,7 +34,11 @@ pub fn show(
offline: OfflineView<'_>,
actions: &mut Vec<Action>,
) {
let Context { franchise, on_list } = context;
let Context {
franchise,
similar,
on_list,
} = context;
let Some(title) = library.get(key) else {
// The catalog was replaced under the page — a recrawl that dropped this
// title. Nothing to show, so go back rather than draw an empty shell.
@@ -40,7 +46,7 @@ pub fn show(
return;
};
if back_link(ui).clicked() {
if back_link(ui, "Library").clicked() {
actions.push(Action::Goto(Page::Library));
}
ui.add_space(theme::space::S);
@@ -55,7 +61,14 @@ pub fn show(
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);
cover_row(
ui,
art,
("franchise", &title.key),
Some(title),
&members,
actions,
);
ui.add_space(theme::space::XL);
}
}
@@ -137,23 +150,35 @@ pub fn show(
episode_row(ui, art, title, season, episode, &offline, actions);
}
ui.add_space(theme::space::L);
// Under the episodes rather than over them: it is where to go
// once this one is done, not what the page is for.
let similar: Vec<&Title> = similar.iter().filter_map(|key| library.get(key)).collect();
if !similar.is_empty() {
ui.add_space(theme::space::L);
ui::section(ui, "More like this");
cover_row(ui, art, ("similar", &title.key), None, &similar, actions);
ui.add_space(theme::space::L);
}
});
}
/// 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(
/// A row of covers: every title of the franchise in release order, with
/// `current` marked rather than left out, so the row says where it sits in
/// the story — or, with no `current`, the titles like this one.
fn cover_row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
current: &Title,
id: impl std::hash::Hash + std::fmt::Debug,
current: Option<&Title>,
members: &[&Title],
actions: &mut Vec<Action>,
) {
ui::shelf(ui, ("franchise", &current.key), |ui| {
ui::shelf(ui, id, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
for member in members {
let here = member.key == current.key;
let here = current.is_some_and(|current| member.key == current.key);
let picture = art.poster(member);
let found = art
.metadata
@@ -217,9 +242,10 @@ fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64
}
/// "← Library", as a link rather than a button: it is navigation, and a
/// filled button beside the page's real actions competed with them.
fn back_link(ui: &mut egui::Ui) -> egui::Response {
let text = theme::Role::Label.rich(format!("{} Library", egui_phosphor::regular::ARROW_LEFT));
/// filled button beside the page's real actions competed with them. `to`
/// names where it goes.
pub(crate) fn back_link(ui: &mut egui::Ui, to: &str) -> egui::Response {
let text = theme::Role::Label.rich(format!("{} {to}", egui_phosphor::regular::ARROW_LEFT));
let response = ui.add(egui::Label::new(text.color(theme::muted())).sense(egui::Sense::click()));
if response.hovered() {
ui.painter().hline(
@@ -495,7 +521,7 @@ fn header(
if let Some(found) = &found {
ui.add_space(theme::space::L);
description(ui, found);
description(ui, found, &title.key, actions);
}
});
});
@@ -610,17 +636,31 @@ fn band(
}
/// What the provider had to say. Only ever drawn under a match.
fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
let tags = found
.details
.as_ref()
///
/// Its genres, studios and director are chips that open every title in the
/// library sharing them.
fn description(ui: &mut egui::Ui, found: &TitleMetadata, key: &str, actions: &mut Vec<Action>) {
use pstr_core::discover::Facet;
let browse = |facet: Facet| {
Action::Goto(Page::Browse {
facet,
from: Some(key.to_owned()),
})
};
let details = found.details.as_ref();
let tags = details
.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);
if ui::widgets::link_chip(ui, genre)
.on_hover_text(format!("Every {genre} title"))
.clicked()
{
actions.push(browse(Facet::Genre(genre.clone())));
}
}
// After the genres and quieter than them: a tag is the
// provider's community describing it, not what it is filed as.
@@ -630,6 +670,38 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
});
ui.add_space(theme::space::M);
}
// Who made it, out of the grey line and onto chips, so the names lead
// somewhere.
let studios = details
.map(|details| &details.studios[..details.studios.len().min(2)])
.unwrap_or_default();
let directors = details
.map(|details| &details.directors[..details.directors.len().min(1)])
.unwrap_or_default();
if !studios.is_empty() || !directors.is_empty() {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
for (label, names, facet) in [
("Studio", studios, Facet::Studio as fn(String) -> Facet),
("Director", directors, Facet::Person),
] {
if names.is_empty() {
continue;
}
ui.label(ui::muted(label));
for name in names {
if ui::widgets::link_chip(ui, name)
.on_hover_text(format!("Everything in the library by {name}"))
.clicked()
{
actions.push(browse(facet(name.clone())));
}
}
ui.add_space(theme::space::S);
}
});
ui.add_space(theme::space::M);
}
if let Some(overview) = &found.overview {
// Four lines, then "More". A provider's synopsis runs to three
// paragraphs on a long show, and all of them pushed the episodes —
@@ -711,14 +783,11 @@ 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}"));
}
if let Some(season) = found
.and_then(|found| found.details.as_ref())
.and_then(|details| details.season_label())
{
parts.push(season);
}
if let Some(found) = found {
if let Some(rating) = found.rating {
+26
View File
@@ -447,3 +447,29 @@ pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
})
.response
}
/// A [`chip`] that opens something: lighter under the pointer, with a ring
/// when keyboard focus is on it.
pub fn link_chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
let mut prepared = egui::Frame::new()
.corner_radius(CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::symmetric(9, 3))
.begin(ui);
prepared
.content_ui
.label(theme::Role::Caption.rich(text).color(theme::text()));
let response = prepared
.allocate_space(ui)
.interact(egui::Sense::click())
.on_hover_cursor(egui::CursorIcon::PointingHand);
prepared.frame.fill = if response.hovered() {
theme::card_hover()
} else {
theme::card_hover().gamma_multiply(0.8)
};
if response.has_focus() {
prepared.frame.stroke = egui::Stroke::new(1.5, theme::accent());
}
prepared.paint(ui);
response
}
+433
View File
@@ -0,0 +1,433 @@
//! Getting from one title to the next: "More like this", the "Because you
//! watched" row, and the pages a genre, studio or person chip opens.
//!
//! All of it is read off what the provider already said about each title —
//! genres, tags, studios, directors, format — so none of it costs a request,
//! and it only ever offers what the library holds.
use std::collections::{HashMap, HashSet};
use crate::library::Title;
use crate::metadata::{MetadataRecord, TitleMetadata, TitleNames};
use crate::shelves::Shelf;
/// What a chip on a title page stands for, and the page it opens.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Facet {
Genre(String),
Studio(String),
/// A director.
Person(String),
}
impl Facet {
/// The name on the chip, and the heading of its page.
pub fn name(&self) -> &str {
match self {
Self::Genre(name) | Self::Studio(name) | Self::Person(name) => name,
}
}
/// What kind of thing the name is, said over the heading.
pub fn kind(&self) -> &'static str {
match self {
Self::Genre(_) => "Genre",
Self::Studio(_) => "Studio",
Self::Person(_) => "Director",
}
}
/// Whether a title the provider described as `found` belongs on the page.
pub fn admits(&self, found: &TitleMetadata) -> bool {
let details = found.details.as_ref();
match self {
Self::Genre(name) => found.genres.iter().any(|genre| genre == name),
Self::Studio(name) => {
details.is_some_and(|details| details.studios.iter().any(|studio| studio == name))
}
Self::Person(name) => {
details.is_some_and(|details| details.directors.iter().any(|person| person == name))
}
}
}
/// The keys of the titles in `titles` the page lists, in library order.
pub fn titles(
&self,
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
) -> Vec<String> {
titles
.iter()
.filter(|title| {
metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.is_some_and(|found| self.admits(found))
})
.map(|title| title.key.clone())
.collect()
}
}
/// 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;
const STUDIO: u32 = 3;
const GENRE: u32 = 2;
const TAG: u32 = 1;
const FORMAT: u32 = 1;
/// How many of a title's tags count. A provider ranks them, and the tail of
/// the list ("Male Protagonist", "Primarily Teen Cast") is on half of
/// everything.
const TAGS: usize = 8;
/// The least score a title needs to be offered: one director, or a studio and
/// a tag, or two genres. One genre in common is not a recommendation.
const MIN_SCORE: u32 = 4;
/// What a title is, for comparing: only the parts that score.
struct Traits<'a> {
genres: HashSet<&'a str>,
tags: HashSet<&'a str>,
studios: HashSet<&'a str>,
directors: HashSet<&'a str>,
format: Option<&'a str>,
popularity: u32,
}
impl<'a> Traits<'a> {
fn of(found: &'a TitleMetadata) -> Self {
let details = found.details.as_ref();
let strings = |list: Option<&'a Vec<String>>, take: usize| -> HashSet<&'a str> {
list.into_iter()
.flatten()
.take(take)
.map(String::as_str)
.collect()
};
Self {
genres: found.genres.iter().map(String::as_str).collect(),
tags: strings(details.map(|details| &details.tags), TAGS),
studios: strings(details.map(|details| &details.studios), usize::MAX),
directors: strings(details.map(|details| &details.directors), usize::MAX),
format: details.and_then(|details| details.format.as_deref()),
popularity: details.and_then(|details| details.popularity).unwrap_or(0),
}
}
fn score(&self, other: &Self) -> u32 {
let shared = |a: &HashSet<&str>, b: &HashSet<&str>| a.intersection(b).count() as u32;
let traits = DIRECTOR * shared(&self.directors, &other.directors)
+ STUDIO * shared(&self.studios, &other.studios)
+ GENRE * shared(&self.genres, &other.genres)
+ TAG * shared(&self.tags, &other.tags);
// Only on top of something else: a TV series is like every other one.
if traits > 0 && self.format.is_some() && self.format == other.format {
traits + FORMAT
} else {
traits
}
}
}
/// Up to `limit` keys of the titles most like `key`, best first.
///
/// Never its own franchise — that is the row above it — and one title per
/// other franchise, its best scoring: three films of one story are one
/// suggestion, not three.
pub fn more_like(
key: &str,
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
limit: usize,
) -> Vec<String> {
ranked(key, titles, metadata, |_| true, limit)
}
/// The row seeded by the title most recently watched to the end: titles like
/// it the viewer has not started. `None` when nothing is finished, or nothing
/// is like it.
pub fn because_you_watched(
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
names: TitleNames,
limit: usize,
) -> Option<Shelf> {
let seed = titles
.iter()
.filter(|title| {
title.episode_count() > 0
&& title.watched_count() == title.episode_count()
&& metadata
.get(&title.key)
.is_some_and(|record| record.metadata.is_some())
})
.max_by_key(|title| title.last_played())?;
let keys = ranked(
&seed.key,
titles,
metadata,
|title| title.last_played() == 0,
limit,
);
(!keys.is_empty()).then(|| Shelf {
label: format!(
"Because you watched {}",
crate::browse::display_name(seed, metadata, names)
),
keys,
})
}
fn ranked(
key: &str,
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
admit: impl Fn(&Title) -> bool,
limit: usize,
) -> Vec<String> {
let found = |title: &Title| {
metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
};
let Some(seed) = titles
.iter()
.find(|title| title.key == key)
.and_then(found)
.map(Traits::of)
else {
return Vec::new();
};
let refs: Vec<&Title> = titles.iter().collect();
let franchise_of: Vec<usize> = {
let mut of = vec![0; titles.len()];
for (at, franchise) in crate::franchise::group(&refs, metadata)
.into_iter()
.enumerate()
{
for member in franchise.members {
of[member] = at;
}
}
of
};
let own = titles
.iter()
.position(|title| title.key == key)
.map(|at| franchise_of[at]);
// The best of each franchise: (score, popularity, index).
let mut best: HashMap<usize, (u32, u32, usize)> = HashMap::new();
for (index, title) in titles.iter().enumerate() {
if Some(franchise_of[index]) == own || !admit(title) {
continue;
}
let Some(traits) = found(title).map(Traits::of) else {
continue;
};
let score = seed.score(&traits);
if score < MIN_SCORE {
continue;
}
let candidate = (score, traits.popularity, index);
best.entry(franchise_of[index])
.and_modify(|held| {
if (candidate.0, candidate.1) > (held.0, held.1) {
*held = candidate;
}
})
.or_insert(candidate);
}
let mut picked: Vec<(u32, u32, usize)> = best.into_values().collect();
// Best score, then most followed, then library order for a stable row.
picked.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)).then(a.2.cmp(&b.2)));
picked
.into_iter()
.take(limit)
.map(|(_, _, index)| titles[index].key.clone())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::{CatalogNode, WatchState};
use crate::library::{Library, TitleKind};
use crate::metadata::{ProviderId, TitleDetails};
struct Entry {
key: &'static str,
genres: &'static [&'static str],
studio: &'static str,
director: &'static str,
related: &'static [&'static str],
}
fn record(entry: &Entry) -> (String, MetadataRecord) {
(
entry.key.into(),
MetadataRecord {
title_key: entry.key.into(),
provider: ProviderId::AniList,
metadata: Some(TitleMetadata {
provider: ProviderId::AniList,
remote_id: entry.key.into(),
name: entry.key.into(),
original_name: None,
overview: None,
year: None,
kind: TitleKind::Series,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: None,
genres: entry.genres.iter().map(|genre| genre.to_string()).collect(),
episodes: None,
url: None,
details: Some(TitleDetails {
studios: vec![entry.studio.into()],
directors: vec![entry.director.into()],
related: entry.related.iter().map(|id| id.to_string()).collect(),
format: Some("TV".into()),
..TitleDetails::default()
}),
}),
fetched_at: 0,
manual: false,
},
)
}
fn file(key: &str) -> CatalogNode {
let name = format!("{key} - 01.mkv");
CatalogNode {
share_id: "s".into(),
link_id: key.into(),
volume_id: "v".into(),
parent_link_id: None,
name: name.clone(),
is_folder: false,
media_type: Some("video/x-matroska".into()),
size: Some(1),
active_revision_id: Some("r".into()),
parsed: crate::naming::parse(&name),
}
}
const ENTRIES: [Entry; 5] = [
Entry {
key: "frieren",
genres: &["Adventure", "Drama", "Fantasy"],
studio: "Madhouse",
director: "Keiichirou Saitou",
related: &[],
},
Entry {
key: "frieren film",
genres: &["Adventure", "Drama", "Fantasy"],
studio: "Madhouse",
director: "Keiichirou Saitou",
related: &["frieren"],
},
Entry {
key: "bocchi",
genres: &["Comedy", "Music"],
studio: "CloverWorks",
director: "Keiichirou Saitou",
related: &[],
},
Entry {
key: "mushishi",
genres: &["Adventure", "Drama", "Fantasy", "Mystery"],
studio: "Artland",
director: "Hiroshi Nagahama",
related: &[],
},
Entry {
key: "lucky star",
genres: &["Comedy"],
studio: "Kyoto Animation",
director: "Yasuhiro Takemoto",
related: &[],
},
];
fn library(watched: &[&str]) -> (Library, HashMap<String, MetadataRecord>) {
let watch: HashMap<(String, String), WatchState> = watched
.iter()
.enumerate()
.map(|(at, key)| {
(
("s".to_owned(), key.to_string()),
WatchState {
position_secs: 0.0,
duration_secs: Some(1_440.0),
watched: true,
updated_at: 100 + at as i64,
},
)
})
.collect();
let library = Library::build(
ENTRIES.iter().map(|entry| file(entry.key)).collect(),
&watch,
);
// The library's keys are the folder names, normalised; the fixture's
// already are.
(library, ENTRIES.iter().map(record).collect())
}
#[test]
fn more_like_this_leaves_out_the_franchise_and_ranks_a_shared_director_and_genres() {
let (library, metadata) = library(&[]);
let similar = more_like("frieren", &library.titles, &metadata, 10);
// Mushishi shares three genres, Bocchi only the director; both the
// format.
// Lucky Star shares nothing and its film is Frieren's own franchise.
assert_eq!(similar, ["mushishi", "bocchi"]);
}
#[test]
fn one_genre_in_common_is_not_a_recommendation() {
let (library, metadata) = library(&[]);
assert!(more_like("lucky star", &library.titles, &metadata, 10).is_empty());
}
#[test]
fn because_you_watched_follows_the_last_finished_title_and_skips_what_was_started() {
let (library, metadata) = library(&["mushishi", "frieren"]);
let row =
because_you_watched(&library.titles, &metadata, TitleNames::default(), 10).unwrap();
assert_eq!(row.label, "Because you watched frieren");
// Mushishi is like it, but already watched.
assert_eq!(row.keys, ["bocchi"]);
assert!(
because_you_watched(&library.titles, &HashMap::new(), TitleNames::default(), 10)
.is_none()
);
}
#[test]
fn a_facet_page_lists_the_titles_that_carry_it() {
let (library, metadata) = library(&[]);
let person = Facet::Person("Keiichirou Saitou".into());
assert_eq!(
person.titles(&library.titles, &metadata).len(),
3,
"both Frieren titles and Bocchi"
);
assert_eq!(person.kind(), "Director");
let genre = Facet::Genre("Comedy".into());
let mut comedy = genre.titles(&library.titles, &metadata);
comedy.sort();
assert_eq!(comedy, ["bocchi", "lucky star"]);
assert!(
Facet::Studio("MAPPA".into())
.titles(&library.titles, &metadata)
.is_empty()
);
}
}
+1
View File
@@ -11,6 +11,7 @@ pub mod browse;
pub mod catalog;
pub mod chapters;
pub mod config;
pub mod discover;
pub mod error;
pub mod franchise;
pub mod language;
+18 -2
View File
@@ -135,12 +135,17 @@ pub fn pick(
picked
}
/// How many titles the "Because you watched" row offers.
const BECAUSE: usize = 12;
/// Every row above the library grid, in the order all three apps show them:
/// new episodes of what is being watched, My List, what was recently added,
/// then up to `limit` [`pick`]ed shelves.
/// what is like the last thing finished, then up to `limit` [`pick`]ed
/// shelves.
pub fn home(
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
names: crate::metadata::TitleNames,
list: &crate::mylist::MyList,
first_seen: &HashMap<(String, String), i64>,
now: i64,
@@ -163,6 +168,9 @@ pub fn home(
.into_iter()
.chain(mine)
.chain(news.added)
.chain(crate::discover::because_you_watched(
titles, metadata, names, BECAUSE,
))
.chain(pick(titles, metadata, limit))
.collect()
}
@@ -299,7 +307,15 @@ mod tests {
list.add("a", 1);
list.add("b", 3);
list.add("gone", 5);
let rows = home(&titles, &HashMap::new(), &list, &HashMap::new(), 10, 4);
let rows = home(
&titles,
&HashMap::new(),
Default::default(),
&list,
&HashMap::new(),
10,
4,
);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].label, "My List");
assert_eq!(rows[0].keys, ["b", "a"]);