feat(meta): rich detail calls for movies and series
movie_detail/series_detail fetch credits, videos and external ids in one upstream request via append_to_response; cast is truncated to the top 10 billed in the crate and a trailer is chosen by rule (official YouTube trailer, any YouTube trailer, YouTube teaser, none). movie_videos/series_videos serve #144's search-row chip from the videos endpoint alone. Everything rides the existing 24h cache.
This commit is contained in:
@@ -9,8 +9,10 @@ use serde::de::DeserializeOwned;
|
||||
use crate::cache::Cache;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::model::{
|
||||
ExternalIds, FindResults, Movie, MovieSearchResult, RawExternalIds, RawFindPage, RawMovie,
|
||||
RawSearchPage, RawSeason, RawSeries, RawSeriesSearchPage, Season, Series, SeriesSearchResult,
|
||||
select_trailer, ExternalIds, FindResults, Movie, MovieDetail, MovieSearchResult,
|
||||
RawExternalIds, RawFindPage, RawMovie, RawMovieDetail, RawSearchPage, RawSeason, RawSeries,
|
||||
RawSeriesDetail, RawSeriesSearchPage, RawVideoList, Season, Series, SeriesDetail,
|
||||
SeriesSearchResult, Video,
|
||||
};
|
||||
|
||||
/// TMDB's v3 API root.
|
||||
@@ -163,6 +165,65 @@ impl TmdbClient {
|
||||
Ok(raw.into())
|
||||
}
|
||||
|
||||
/// Rich detail for one movie's §9.6 page.
|
||||
///
|
||||
/// One HTTP call: credits, videos and external ids come back appended to
|
||||
/// the same response. Served through the same cache as [`Self::movie`];
|
||||
/// nothing here is persisted (§9.6).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
|
||||
pub async fn movie_detail(&self, tmdb_id: u32) -> Result<MovieDetail> {
|
||||
let path = format!("movie/{tmdb_id}");
|
||||
let params = [(
|
||||
"append_to_response",
|
||||
"credits,videos,external_ids".to_owned(),
|
||||
)];
|
||||
let raw: RawMovieDetail = self.get_json(&path, ¶ms).await?;
|
||||
Ok(raw.into())
|
||||
}
|
||||
|
||||
/// Rich detail for one series' §9.6 page.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
|
||||
pub async fn series_detail(&self, tmdb_id: u32) -> Result<SeriesDetail> {
|
||||
let path = format!("tv/{tmdb_id}");
|
||||
let params = [(
|
||||
"append_to_response",
|
||||
"credits,videos,external_ids".to_owned(),
|
||||
)];
|
||||
let raw: RawSeriesDetail = self.get_json(&path, ¶ms).await?;
|
||||
Ok(raw.into())
|
||||
}
|
||||
|
||||
/// The chosen trailer for a movie, fetching only the videos list.
|
||||
///
|
||||
/// #144's search-row chip resolves through this, where a full detail
|
||||
/// response would be waste (§9.6).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
|
||||
pub async fn movie_videos(&self, tmdb_id: u32) -> Result<Option<Video>> {
|
||||
let path = format!("movie/{tmdb_id}/videos");
|
||||
let raw: RawVideoList = self.get_json(&path, &[]).await?;
|
||||
Ok(select_trailer(&raw.into_videos()))
|
||||
}
|
||||
|
||||
/// The chosen trailer for a series, fetching only the videos list.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
|
||||
pub async fn series_videos(&self, tmdb_id: u32) -> Result<Option<Video>> {
|
||||
let path = format!("tv/{tmdb_id}/videos");
|
||||
let raw: RawVideoList = self.get_json(&path, &[]).await?;
|
||||
Ok(select_trailer(&raw.into_videos()))
|
||||
}
|
||||
|
||||
/// External ids for one TV series, of which the TVDB id is the one this
|
||||
/// project needs (§6.1).
|
||||
///
|
||||
|
||||
@@ -24,6 +24,6 @@ mod model;
|
||||
pub use client::{TmdbClient, TmdbClientBuilder, DEFAULT_BASE_URL, DEFAULT_CACHE_TTL};
|
||||
pub use error::{Error, Result};
|
||||
pub use model::{
|
||||
Episode, ExternalIds, FindResults, Movie, MovieSearchResult, Season, Series,
|
||||
SeriesSearchResult, UNTITLED_EPISODE,
|
||||
CastMember, Episode, ExternalIds, FindResults, Genre, Movie, MovieDetail, MovieSearchResult,
|
||||
Season, Series, SeriesDetail, SeriesSearchResult, Video, UNTITLED_EPISODE,
|
||||
};
|
||||
|
||||
@@ -157,6 +157,107 @@ pub struct Episode {
|
||||
pub air_date: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
/// Cast is truncated to the top 10 billed, in the crate, so no caller has to
|
||||
/// remember to (§9.6).
|
||||
const CAST_LIMIT: usize = 10;
|
||||
|
||||
/// A genre as a detail response carries it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Genre {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// One of the top-billed cast members on a detail page. `profile_path` is a
|
||||
/// path fragment — §9.6 hotlinks images and the browser composes the URL.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CastMember {
|
||||
/// The person's TMDB id, for the link out to tmdb.org (§9.6).
|
||||
pub tmdb_id: u32,
|
||||
pub name: String,
|
||||
pub character: String,
|
||||
pub profile_path: Option<String>,
|
||||
pub order: u32,
|
||||
}
|
||||
|
||||
/// One entry of a title's video list.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Video {
|
||||
pub key: String,
|
||||
/// `YouTube`, `Vimeo`, … Only `YouTube` ever becomes a trailer (§9.6).
|
||||
pub site: String,
|
||||
/// TMDB calls this field `type`: `Trailer`, `Teaser`, `Clip`, …
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub official: bool,
|
||||
}
|
||||
|
||||
/// Rich movie detail for the §9.6 page. One upstream request via
|
||||
/// `append_to_response`, served through the same cache as everything else;
|
||||
/// nothing here is persisted.
|
||||
///
|
||||
/// No float fields are involved in equality except `vote_average`, so this is
|
||||
/// `PartialEq` only.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MovieDetail {
|
||||
pub tmdb_id: u32,
|
||||
pub overview: Option<String>,
|
||||
pub tagline: Option<String>,
|
||||
pub genres: Vec<Genre>,
|
||||
pub backdrop_path: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
pub vote_average: f64,
|
||||
pub vote_count: u32,
|
||||
pub homepage: Option<String>,
|
||||
pub status: String,
|
||||
pub runtime: Option<u32>,
|
||||
/// §9.6 links out to `IMDb` for movies.
|
||||
pub imdb_id: Option<String>,
|
||||
/// Top [`CAST_LIMIT`] billed, ordered by TMDB's own cast order.
|
||||
pub cast: Vec<CastMember>,
|
||||
/// The one trailer worth showing, chosen by the §9.6 rule.
|
||||
pub trailer: Option<Video>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SeriesDetail {
|
||||
pub tmdb_id: u32,
|
||||
pub overview: Option<String>,
|
||||
pub tagline: Option<String>,
|
||||
pub genres: Vec<Genre>,
|
||||
pub backdrop_path: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
pub vote_average: f64,
|
||||
pub vote_count: u32,
|
||||
pub homepage: Option<String>,
|
||||
pub status: String,
|
||||
/// Episode length in minutes; TMDB sends a list per episode, this takes
|
||||
/// the first.
|
||||
pub episode_runtime: Option<u32>,
|
||||
/// §9.6 links out to TVDB for series.
|
||||
pub tvdb_id: Option<u32>,
|
||||
pub cast: Vec<CastMember>,
|
||||
pub trailer: Option<Video>,
|
||||
}
|
||||
|
||||
/// The trailer rule from §9.6, shared by the detail calls and the videos-only
|
||||
/// calls #144 reads. Preference order: an official `YouTube` trailer, then any
|
||||
/// `YouTube` trailer, then any `YouTube` teaser, then nothing. Within a tier
|
||||
/// the first match wins, which keeps the result deterministic for a given
|
||||
/// response.
|
||||
#[must_use]
|
||||
pub(crate) fn select_trailer(videos: &[Video]) -> Option<Video> {
|
||||
let pick = |want: &dyn Fn(&Video) -> bool| {
|
||||
videos
|
||||
.iter()
|
||||
.find(|video| video.site == "YouTube" && want(video))
|
||||
.cloned()
|
||||
};
|
||||
pick(&|video| video.kind == "Trailer" && video.official)
|
||||
.or_else(|| pick(&|video| video.kind == "Trailer"))
|
||||
.or_else(|| pick(&|video| video.kind == "Teaser"))
|
||||
}
|
||||
|
||||
// --- TMDB wire types -------------------------------------------------------
|
||||
//
|
||||
// Private on purpose. TMDB's field names stop here.
|
||||
@@ -468,3 +569,209 @@ fn parse_datetime(raw: &str) -> Option<NaiveDate> {
|
||||
fn non_empty(value: Option<String>) -> Option<String> {
|
||||
value.filter(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct RawMovieDetail {
|
||||
id: u32,
|
||||
#[serde(default)]
|
||||
tagline: Option<String>,
|
||||
#[serde(default)]
|
||||
overview: Option<String>,
|
||||
#[serde(default)]
|
||||
genres: Vec<RawGenre>,
|
||||
#[serde(default)]
|
||||
backdrop_path: Option<String>,
|
||||
#[serde(default)]
|
||||
poster_path: Option<String>,
|
||||
vote_average: f64,
|
||||
vote_count: u32,
|
||||
#[serde(default)]
|
||||
homepage: Option<String>,
|
||||
#[serde(default)]
|
||||
status: String,
|
||||
#[serde(default)]
|
||||
runtime: Option<u32>,
|
||||
#[serde(default)]
|
||||
imdb_id: Option<String>,
|
||||
#[serde(default)]
|
||||
credits: Option<RawCredits>,
|
||||
#[serde(default)]
|
||||
videos: Option<RawVideoList>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct RawSeriesDetail {
|
||||
id: u32,
|
||||
#[serde(default)]
|
||||
tagline: Option<String>,
|
||||
#[serde(default)]
|
||||
overview: Option<String>,
|
||||
#[serde(default)]
|
||||
genres: Vec<RawGenre>,
|
||||
#[serde(default)]
|
||||
backdrop_path: Option<String>,
|
||||
#[serde(default)]
|
||||
poster_path: Option<String>,
|
||||
vote_average: f64,
|
||||
vote_count: u32,
|
||||
#[serde(default)]
|
||||
homepage: Option<String>,
|
||||
#[serde(default)]
|
||||
status: String,
|
||||
#[serde(default)]
|
||||
episode_run_time: Vec<u32>,
|
||||
#[serde(default)]
|
||||
external_ids: Option<RawExternalIds>,
|
||||
#[serde(default)]
|
||||
credits: Option<RawCredits>,
|
||||
#[serde(default)]
|
||||
videos: Option<RawVideoList>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawGenre {
|
||||
id: u32,
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct RawCredits {
|
||||
#[serde(default)]
|
||||
cast: Vec<RawCastMember>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct RawCastMember {
|
||||
id: u32,
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
character: String,
|
||||
#[serde(default)]
|
||||
profile_path: Option<String>,
|
||||
order: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct RawVideoList {
|
||||
#[serde(default)]
|
||||
results: Vec<RawVideo>,
|
||||
}
|
||||
|
||||
impl RawVideoList {
|
||||
pub(crate) fn into_videos(self) -> Vec<Video> {
|
||||
self.results.into_iter().map(Into::into).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawVideo {
|
||||
#[serde(default)]
|
||||
key: String,
|
||||
#[serde(default)]
|
||||
site: String,
|
||||
#[serde(rename = "type", default)]
|
||||
kind: String,
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
official: bool,
|
||||
}
|
||||
|
||||
impl From<RawVideo> for Video {
|
||||
fn from(raw: RawVideo) -> Self {
|
||||
Self {
|
||||
key: raw.key,
|
||||
site: raw.site,
|
||||
kind: raw.kind,
|
||||
name: raw.name,
|
||||
official: raw.official,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cast_from(raw: Option<RawCredits>) -> Vec<CastMember> {
|
||||
let mut cast: Vec<CastMember> = raw
|
||||
.map(|credits| {
|
||||
credits
|
||||
.cast
|
||||
.into_iter()
|
||||
.map(|member| CastMember {
|
||||
tmdb_id: member.id,
|
||||
name: member.name,
|
||||
character: member.character,
|
||||
profile_path: non_empty(member.profile_path),
|
||||
order: member.order,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// TMDB's own ordering is by `order`; sorting makes the truncation hold
|
||||
// even if a fixture or future API revision sends them shuffled.
|
||||
cast.sort_by_key(|member| member.order);
|
||||
cast.truncate(CAST_LIMIT);
|
||||
cast
|
||||
}
|
||||
|
||||
fn trailer_from(raw: Option<RawVideoList>) -> Option<Video> {
|
||||
let videos: Vec<Video> = raw
|
||||
.map(|list| list.results.into_iter().map(Into::into).collect())
|
||||
.unwrap_or_default();
|
||||
select_trailer(&videos)
|
||||
}
|
||||
|
||||
impl From<RawMovieDetail> for MovieDetail {
|
||||
fn from(raw: RawMovieDetail) -> Self {
|
||||
Self {
|
||||
tmdb_id: raw.id,
|
||||
overview: non_empty(raw.overview),
|
||||
tagline: non_empty(raw.tagline),
|
||||
genres: raw
|
||||
.genres
|
||||
.into_iter()
|
||||
.map(|genre| Genre {
|
||||
id: genre.id,
|
||||
name: genre.name,
|
||||
})
|
||||
.collect(),
|
||||
backdrop_path: non_empty(raw.backdrop_path),
|
||||
poster_path: non_empty(raw.poster_path),
|
||||
vote_average: raw.vote_average,
|
||||
vote_count: raw.vote_count,
|
||||
homepage: non_empty(raw.homepage),
|
||||
status: raw.status,
|
||||
runtime: raw.runtime,
|
||||
imdb_id: non_empty(raw.imdb_id),
|
||||
cast: cast_from(raw.credits),
|
||||
trailer: trailer_from(raw.videos),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RawSeriesDetail> for SeriesDetail {
|
||||
fn from(raw: RawSeriesDetail) -> Self {
|
||||
Self {
|
||||
tmdb_id: raw.id,
|
||||
overview: non_empty(raw.overview),
|
||||
tagline: non_empty(raw.tagline),
|
||||
genres: raw
|
||||
.genres
|
||||
.into_iter()
|
||||
.map(|genre| Genre {
|
||||
id: genre.id,
|
||||
name: genre.name,
|
||||
})
|
||||
.collect(),
|
||||
backdrop_path: non_empty(raw.backdrop_path),
|
||||
poster_path: non_empty(raw.poster_path),
|
||||
vote_average: raw.vote_average,
|
||||
vote_count: raw.vote_count,
|
||||
homepage: non_empty(raw.homepage),
|
||||
status: raw.status,
|
||||
episode_runtime: raw.episode_run_time.into_iter().next(),
|
||||
tvdb_id: raw.external_ids.and_then(|ids| ids.tvdb_id),
|
||||
cast: cast_from(raw.credits),
|
||||
trailer: trailer_from(raw.videos),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user