mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
87ccaf26e1
commit
7bfebc5fd5
15 files changed
+539
-19
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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| {
|
||||
|
||||
Reference in new issue
Block a user