diff --git a/crates/arr-meta/src/client.rs b/crates/arr-meta/src/client.rs index 54b7d19..f0edbe7 100644 --- a/crates/arr-meta/src/client.rs +++ b/crates/arr-meta/src/client.rs @@ -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 { + 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 { + 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> { + 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> { + 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). /// diff --git a/crates/arr-meta/src/lib.rs b/crates/arr-meta/src/lib.rs index dfca90a..ca6d0f6 100644 --- a/crates/arr-meta/src/lib.rs +++ b/crates/arr-meta/src/lib.rs @@ -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, }; diff --git a/crates/arr-meta/src/model.rs b/crates/arr-meta/src/model.rs index c98608d..51f39ea 100644 --- a/crates/arr-meta/src/model.rs +++ b/crates/arr-meta/src/model.rs @@ -157,6 +157,107 @@ pub struct Episode { pub air_date: Option, } +/// 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, + 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, + pub tagline: Option, + pub genres: Vec, + pub backdrop_path: Option, + pub poster_path: Option, + pub vote_average: f64, + pub vote_count: u32, + pub homepage: Option, + pub status: String, + pub runtime: Option, + /// §9.6 links out to `IMDb` for movies. + pub imdb_id: Option, + /// Top [`CAST_LIMIT`] billed, ordered by TMDB's own cast order. + pub cast: Vec, + /// The one trailer worth showing, chosen by the §9.6 rule. + pub trailer: Option