feat(meta,core,app,android,ios): cast and trailer

AniList's fragment now asks for the first ten characters, main roles
first, with their Japanese voice actor, and for the trailer's site and
id. Both are stored in the existing details JSON as TitleDetails.cast and
.trailer with serde defaults, so no migration is needed and details
stored before them still read. The details REVISION goes to 2, so a
stored match is refetched by its id on the next metadata run and the
cast fills in without anyone matching the title again.

The title page shows a Cast row of round portraits with the character's
name over the actor's. Clicking a tile opens the Person browse page for
that actor, which now also admits voice actors, and search counts them
under People and studios. A Trailer button opens the YouTube or
Dailymotion page in the browser; the app itself makes no request to
either, and an unknown site or an id with odd characters gives no link.
This commit is contained in:
nvrl committed 2026-10-31 01:56:41 +01:00
1 parent 87ccaf26e1
commit 7bfebc5fd5
15 files changed
+539 -19

No files matched your search

+33 -1
View File
@@ -545,7 +545,7 @@ pub fn airing_countdown(episode: u32, at: i64, now: i64) -> String {
}
/// What kind of thing a [`BrowseFacet`] names — `Genre`, `Studio`,
/// `Director` — for over the heading of its page.
/// `Person` — for over the heading of its page.
#[uniffi::export]
pub fn facet_kind(facet: BrowseFacet) -> String {
facet.core().kind().to_owned()
@@ -706,6 +706,23 @@ pub struct TitleRecord {
/// `New`, or `3 new` on a show being watched; see `pstr_core::news`.
#[uniffi(default = None)]
pub badge: Option<String>,
/// The characters, main ones first, with who voices them.
#[uniffi(default = [])]
pub cast: Vec<CastRecord>,
/// The trailer's page, to open in the browser; see
/// `pstr_core::metadata::Trailer::url`.
#[uniffi(default = None)]
pub trailer_url: Option<String>,
}
/// One character and who voices them: the shared
/// `pstr_core::metadata::CastMember`.
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct CastRecord {
pub character: String,
pub character_image: Option<String>,
pub actor: Option<String>,
pub actor_image: Option<String>,
}
/// Playback preferences that outlive one file, one title and one launch.
@@ -3893,6 +3910,21 @@ fn title_record(
next_airing_at: None,
on_list: false,
badge: None,
cast: details.map_or_else(Vec::new, |details| {
details
.cast
.iter()
.map(|member| CastRecord {
character: member.character.clone(),
character_image: member.character_image.clone(),
actor: member.actor.clone(),
actor_image: member.actor_image.clone(),
})
.collect()
}),
trailer_url: details
.and_then(|details| details.trailer.as_ref())
.and_then(|trailer| trailer.url()),
franchise: Vec::new(),
logo_url: metadata.and_then(|found| found.logo_url.clone()),
runtime: details.and_then(|details| details.runtime),
+11
View File
@@ -81,6 +81,17 @@ impl Art<'_> {
.texture(&thumbnail_key(node), || engine.request_thumbnail(node))
}
/// A provider's picture that belongs to no one title's tile — a cast
/// member's portrait — fetched like a poster the first time it is asked
/// for.
pub fn picture(&mut self, url: &str) -> Option<egui::TextureHandle> {
let key = format!("picture:{url}");
let engine = self.engine;
let url = url.to_owned();
self.posters
.texture(&key, || engine.request_poster(key.clone(), url))
}
/// The name to show for `title`. See [`title_name`].
pub fn name<'t>(&'t self, title: &'t Title) -> &'t str {
title_name(title, self.metadata, self.names)
+124 -5
View File
@@ -151,6 +151,20 @@ pub fn show(
}
ui.add_space(theme::space::L);
let cast = art
.metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|found| found.details.as_ref())
.map(|details| details.cast.clone())
.unwrap_or_default();
if !cast.is_empty() {
ui.add_space(theme::space::L);
ui::section(ui, "Cast");
cast_row(ui, art, &title.key, &cast, 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();
@@ -163,6 +177,95 @@ pub fn show(
});
}
/// The characters, each a round portrait over their name and who voices
/// them. A voice actor opens every title in the library they are in.
fn cast_row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
key: &str,
cast: &[pstr_core::metadata::CastMember],
actions: &mut Vec<Action>,
) {
const SIZE: f32 = 88.0;
ui::shelf(ui, ("cast", key), |ui| {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = theme::space::L;
for (i, member) in cast.iter().enumerate() {
let tile = ui.allocate_ui(egui::vec2(SIZE + 16.0, SIZE + 52.0), |ui| {
ui.vertical_centered(|ui| {
let (rect, _) =
ui.allocate_exact_size(egui::vec2(SIZE, SIZE), egui::Sense::hover());
let picture = member
.character_image
.as_deref()
.or(member.actor_image.as_deref())
.and_then(|url| art.picture(url));
let painter = ui.painter();
match picture {
Some(texture) => {
let shape = egui::epaint::RectShape::filled(
rect,
egui::CornerRadius::same((SIZE / 2.0) as u8),
egui::Color32::WHITE,
)
.with_texture(
texture.id(),
super::cover_uv(texture.size_vec2(), rect.size()),
);
painter.add(shape);
}
None => {
painter.circle_filled(rect.center(), SIZE / 2.0, theme::card());
}
}
ui.add(
egui::Label::new(
theme::Role::Caption
.rich(&member.character)
.color(theme::text()),
)
.truncate()
.selectable(false),
);
if let Some(actor) = &member.actor {
ui.add(
egui::Label::new(ui::muted(actor))
.truncate()
.selectable(false),
);
}
rect
})
.inner
});
// The whole tile opens the actor's page, names included, not only the portrait.
let Some(actor) = &member.actor else { continue };
let response = ui
.interact(
tile.response.rect,
ui.id().with(("cast", i)),
egui::Sense::click(),
)
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(format!("Everything in the library with {actor}"));
if response.hovered() {
ui.painter().circle_stroke(
tile.inner.center(),
SIZE / 2.0,
egui::Stroke::new(2.0, theme::accent()),
);
}
if response.clicked() {
actions.push(Action::Goto(Page::Browse {
facet: pstr_core::discover::Facet::Person(actor.clone()),
from: Some(key.to_owned()),
}));
}
}
});
});
}
/// 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.
@@ -739,12 +842,28 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata, key: &str, actions: &mu
}
}
}
if let Some(url) = &found.url {
let trailer = details
.and_then(|details| details.trailer.as_ref())
.and_then(|trailer| trailer.url());
if found.url.is_some() || trailer.is_some() {
ui.add_space(theme::space::M);
ui.hyperlink_to(
ui::muted(format!("More on {}", found.provider.label())),
url,
);
ui.horizontal(|ui| {
// In the browser: the app itself never requests the trailer.
if let Some(trailer) = &trailer {
ui.hyperlink_to(
ui::muted(format!("{} Trailer", egui_phosphor::regular::FILM_STRIP)),
trailer,
)
.on_hover_text("Opens in your browser");
ui.add_space(theme::space::L);
}
if let Some(url) = &found.url {
ui.hyperlink_to(
ui::muted(format!("More on {}", found.provider.label())),
url,
);
}
});
}
}
+23 -9
View File
@@ -16,7 +16,7 @@ use crate::shelves::Shelf;
pub enum Facet {
Genre(String),
Studio(String),
/// A director.
/// A director, or a voice actor in the cast.
Person(String),
}
@@ -33,7 +33,7 @@ impl Facet {
match self {
Self::Genre(_) => "Genre",
Self::Studio(_) => "Studio",
Self::Person(_) => "Director",
Self::Person(_) => "Person",
}
}
@@ -45,9 +45,13 @@ impl Facet {
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))
}
Self::Person(name) => details.is_some_and(|details| {
details.directors.iter().any(|person| person == name)
|| details
.cast
.iter()
.any(|member| member.actor.as_deref() == Some(name.as_str()))
}),
}
}
@@ -161,17 +165,27 @@ pub fn find(
let Some(details) = &found.details else {
continue;
};
let made = details
// One count per title, however many characters a voice actor has
// in it.
let made: HashSet<Facet> = details
.directors
.iter()
.chain(
details
.cast
.iter()
.filter_map(|member| member.actor.as_ref()),
)
.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())) {
)
.filter(|facet| holds(facet.name()))
.collect();
for facet in made {
*people.entry(facet).or_default() += 1;
}
}
@@ -570,7 +584,7 @@ mod tests {
3,
"both Frieren titles and Bocchi"
);
assert_eq!(person.kind(), "Director");
assert_eq!(person.kind(), "Person");
let genre = Facet::Genre("Comedy".into());
let mut comedy = genre.titles(&library.titles, &metadata);
comedy.sort();
+84 -2
View File
@@ -180,6 +180,11 @@ pub struct TitleDetails {
/// Minutes: an episode's length for a series, the whole running time for
/// a film.
pub runtime: Option<u32>,
/// The main characters and then the supporting ones, each with who voices
/// them in the original language.
pub cast: Vec<CastMember>,
/// Where the provider says its trailer is.
pub trailer: Option<Trailer>,
/// Which [`TitleDetails::REVISION`] wrote this. A stored record from an
/// older one lacks a field a newer build asks for, and is fetched again
/// by id — see `pstr_meta::service::plan`. Not a reason to blank it in a
@@ -193,12 +198,56 @@ pub struct TitleDetails {
pub chain: Vec<String>,
}
/// One character, and who voices them.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CastMember {
pub character: String,
/// The character's portrait.
pub character_image: Option<String>,
/// `MAIN`, `SUPPORTING` or `BACKGROUND`.
pub role: Option<String>,
/// The voice actor in the original language.
pub actor: Option<String>,
pub actor_image: Option<String>,
}
/// A trailer, as the provider names it: a site and that site's id for it.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Trailer {
/// `youtube` or `dailymotion`.
pub site: String,
pub id: String,
}
impl Trailer {
/// The trailer's page, to open in the browser. The app itself never
/// requests it. `None` for a site this build does not know.
pub fn url(&self) -> Option<String> {
let id: String = self
.id
.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
.collect();
if id.is_empty() {
return None;
}
match self.site.to_ascii_lowercase().as_str() {
"youtube" => Some(format!("https://www.youtube.com/watch?v={id}")),
"dailymotion" => Some(format!("https://www.dailymotion.com/video/{id}")),
_ => None,
}
}
}
impl TitleDetails {
/// What a provider that fills these in today writes into
/// [`TitleDetails::revision`]. Raise it with a field that existing
/// records should be fetched again for. `1` added
/// [`TitleDetails::runtime`].
pub const REVISION: u32 = 1;
/// [`TitleDetails::runtime`], `2` [`TitleDetails::cast`] and
/// [`TitleDetails::trailer`].
pub const REVISION: u32 = 2;
/// Whether these were written by this build's [`TitleDetails::REVISION`]
/// or a later one.
@@ -603,6 +652,39 @@ impl Default for MetadataConfig {
mod tests {
use super::*;
#[test]
fn a_trailer_opens_on_its_own_site_and_an_unknown_site_or_odd_id_opens_nothing() {
let trailer = |site: &str, id: &str| Trailer {
site: site.into(),
id: id.into(),
};
assert_eq!(
trailer("youtube", "tR8YH0G67Rk").url().as_deref(),
Some("https://www.youtube.com/watch?v=tR8YH0G67Rk")
);
assert_eq!(
trailer("dailymotion", "x7tgad0").url().as_deref(),
Some("https://www.dailymotion.com/video/x7tgad0")
);
assert_eq!(trailer("vimeo", "1").url(), None);
// Whatever the provider sent, only an id's own characters reach the
// address.
assert_eq!(
trailer("youtube", "a&b=c").url().as_deref(),
Some("https://www.youtube.com/watch?v=abc")
);
assert_eq!(trailer("youtube", "&?").url(), None);
}
#[test]
fn details_stored_before_the_cast_existed_still_read() {
let old = r#"{"studios":["MAPPA"],"revision":1}"#;
let details: TitleDetails = serde_json::from_str(old).unwrap();
assert!(details.cast.is_empty());
assert_eq!(details.trailer, None);
assert!(!details.is_current(), "fetched again for the cast");
}
#[test]
fn a_status_is_said_for_a_person_and_an_unknown_one_is_not_said_at_all() {
let status = |value: &str| {
+115 -2
View File
@@ -18,7 +18,8 @@ use std::collections::HashSet;
use pstr_core::library::{self, TitleKind};
use pstr_core::metadata::{
Enrichment, EpisodeGuide, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata,
CastMember, Enrichment, EpisodeGuide, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata,
Trailer,
};
use serde::Deserialize;
@@ -60,7 +61,9 @@ const SCORE_SCALE: f32 = 10.0;
/// Everything one entry is asked for, by search and by id alike.
///
/// The relations are the franchise's — see `pstr_core::franchise` — and the
/// staff are only the most relevant few, which is where a director sits.
/// staff are only the most relevant few, which is where a director sits. The
/// characters are the first ten by role, main ones first, each with their
/// Japanese voice actor.
macro_rules! entry_fragment {
() => {
r#"
@@ -84,6 +87,14 @@ fragment entry on Media {
siteUrl
studios(isMain: true) { nodes { name } }
staff(sort: RELEVANCE, perPage: 6) { edges { role node { name { full } } } }
characters(sort: [ROLE, RELEVANCE, ID], perPage: 10) {
edges {
role
node { name { full } image { medium } }
voiceActors(language: JAPANESE, sort: [RELEVANCE, ID]) { name { full } image { medium } }
}
}
trailer { id site }
nextAiringEpisode { episode airingAt }
relations { edges { relationType node { id type } } }
}
@@ -591,6 +602,8 @@ struct Media {
site_url: Option<String>,
studios: Option<Studios>,
staff: Option<Staff>,
characters: Option<Characters>,
trailer: Option<AniTrailer>,
#[serde(rename = "nextAiringEpisode")]
next_airing_episode: Option<NextAiring>,
relations: Option<Relations>,
@@ -636,6 +649,32 @@ struct StaffEdge {
#[derive(Deserialize)]
struct Person {
name: PersonName,
image: Option<Image>,
}
#[derive(Deserialize)]
struct Image {
medium: Option<String>,
}
#[derive(Deserialize)]
struct Characters {
#[serde(default)]
edges: Vec<CharacterEdge>,
}
#[derive(Deserialize)]
struct CharacterEdge {
role: Option<String>,
node: Option<Person>,
#[serde(rename = "voiceActors", default)]
voice_actors: Vec<Person>,
}
#[derive(Deserialize)]
struct AniTrailer {
id: Option<String>,
site: Option<String>,
}
#[derive(Deserialize)]
@@ -759,6 +798,35 @@ impl Media {
popularity: self.popularity,
status: self.status,
runtime: self.duration.filter(|minutes| *minutes > 0),
cast: self
.characters
.map(|characters| {
characters
.edges
.into_iter()
.filter_map(|edge| {
let character = edge.node?;
let actor = edge.voice_actors.into_iter().next();
Some(CastMember {
character: character.name.full?,
character_image: character.image.and_then(|image| image.medium),
role: edge.role,
actor_image: actor
.as_ref()
.and_then(|actor| actor.image.as_ref())
.and_then(|image| image.medium.clone()),
actor: actor.and_then(|actor| actor.name.full),
})
})
.collect()
})
.unwrap_or_default(),
trailer: self.trailer.and_then(|trailer| {
Some(Trailer {
site: trailer.site?,
id: trailer.id?,
})
}),
revision: TitleDetails::REVISION,
next_episode: self
.next_airing_episode
@@ -973,6 +1041,51 @@ mod tests {
assert_eq!(candidate.metadata.poster_url.as_deref(), Some("big.jpg"));
}
#[test]
fn the_cast_comes_with_its_voice_actors_and_the_trailer_with_its_site() {
let media: Media = serde_json::from_str(
r#"{
"id": 154587,
"title": {"romaji": "Sousou no Frieren", "english": null, "native": null},
"characters": {"edges": [
{"role": "MAIN",
"node": {"name": {"full": "Frieren"}, "image": {"medium": "frieren.png"}},
"voiceActors": [{"name": {"full": "Atsumi Tanezaki"},
"image": {"medium": "tanezaki.png"}}]},
{"role": "SUPPORTING",
"node": {"name": {"full": "Himmel"}, "image": null},
"voiceActors": []},
{"role": "BACKGROUND", "node": {"name": {"full": null}}, "voiceActors": []}
]},
"trailer": {"id": "tR8YH0G67Rk", "site": "youtube"}
}"#,
)
.expect("parse");
let details = media.into_candidate().metadata.details.expect("details");
assert_eq!(
details.cast,
[
CastMember {
character: "Frieren".into(),
character_image: Some("frieren.png".into()),
role: Some("MAIN".into()),
actor: Some("Atsumi Tanezaki".into()),
actor_image: Some("tanezaki.png".into()),
},
// No voice actor yet is still a character; no name is not.
CastMember {
character: "Himmel".into(),
role: Some("SUPPORTING".into()),
..CastMember::default()
},
]
);
assert_eq!(
details.trailer.and_then(|trailer| trailer.url()).as_deref(),
Some("https://www.youtube.com/watch?v=tR8YH0G67Rk")
);
}
fn edge(relation: &str, id: i64, kind: &str, format: &str) -> RelationEdge {
RelationEdge {
relation_type: Some(relation.into()),