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:
Miguel Palhas
2026-08-23 21:33:17 +01:00
parent 689487bf82
commit cbb21418a4
8 changed files with 718 additions and 4 deletions
+63 -2
View File
@@ -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, &params).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, &params).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).
///
+2 -2
View File
@@ -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,
};
+307
View File
@@ -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),
}
}
}
+46
View File
@@ -0,0 +1,46 @@
{
"id": 693134,
"imdb_id": "tt15239678",
"title": "Dune: Part Two",
"original_title": "Dune: Part Two",
"tagline": "Long live the fighters.",
"overview": "Paul Atreides unites with Chani and the Fremen while seeking revenge against the conspirators who destroyed his family.",
"status": "Released",
"runtime": 167,
"homepage": "https://www.dunemovie.com",
"vote_average": 8.152,
"vote_count": 6249,
"poster_path": "/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg",
"backdrop_path": "/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg",
"genres": [
{ "id": 878, "name": "Science Fiction" },
{ "id": 12, "name": "Adventure" }
],
"credits": {
"cast": [
{ "id": 5530, "name": "Timothée Chalamet", "character": "Paul Atreides", "profile_path": "/x3UkVAsKyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 0 },
{ "id": 3559977, "name": "Zendaya", "character": "Chani", "profile_path": "/xaWu0DjKyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 1 },
{ "id": 37614, "name": "Rebecca Ferguson", "character": "Lady Jessica", "profile_path": "/fPM5vM7KyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 2 },
{ "id": 1110844, "name": "Austin Butler", "character": "Feyd-Rautha Harkonnen", "profile_path": null, "order": 3 },
{ "id": 22451, "name": "Josh Brolin", "character": "Gurney Halleck", "profile_path": "/qR11vJKKyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 4 },
{ "id": 593015, "name": "Florence Pugh", "character": "Princess Irulan", "profile_path": "/mP55vB7KyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 5 },
{ "id": 1244949, "name": "Dave Bautista", "character": "Glossu Rabban Harkonnen", "profile_path": null, "order": 6 },
{ "id": 11220, "name": "Christopher Walken", "character": "Emperor Shaddam IV", "profile_path": "/wP55vC7KyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 7 },
{ "id": 38673, "name": "Léa Seydoux", "character": "Lady Margot Fenring", "profile_path": null, "order": 8 },
{ "id": 2880644, "name": "Souheila Yacoub", "character": "Shishakli", "profile_path": "/nP55vD7KyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 9 },
{ "id": 10952, "name": "Stellan Skarsgård", "character": "Baron Vladimir Harkonnen", "profile_path": null, "order": 10 },
{ "id": 33940, "name": "Charlotte Rampling", "character": "Reverend Mother Gaius Helen Mohiam", "profile_path": null, "order": 11 }
]
},
"videos": {
"results": [
{ "key": "n9xhJrPXop4", "site": "YouTube", "type": "Teaser", "name": "Official Teaser", "official": true },
{ "key": "fanmade_trailer", "site": "YouTube", "type": "Trailer", "name": "Dune Part Two Fan Trailer", "official": false },
{ "key": "clip_vimeo_id", "site": "Vimeo", "type": "Trailer", "name": "Trailer (Vimeo)", "official": true },
{ "key": "Way9Dexny3w", "site": "YouTube", "type": "Trailer", "name": "Official Trailer", "official": true }
]
},
"external_ids": {
"imdb_id": "tt15239678"
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": 693134,
"results": [
{ "key": "fanmade_trailer", "site": "YouTube", "type": "Trailer", "name": "Fan Trailer", "official": false },
{ "key": "Way9Dexny3w", "site": "YouTube", "type": "Trailer", "name": "Official Trailer", "official": true }
]
}
+36
View File
@@ -0,0 +1,36 @@
{
"id": 82728,
"name": "Bluey",
"original_name": "Bluey",
"tagline": "",
"overview": "The slice-of-life adventures of an Australian cattle dog called Bluey and her family.",
"status": "Returning Series",
"homepage": "https://www.blueytv.com",
"vote_average": 8.417,
"vote_count": 118,
"poster_path": "/58Pm1HTKHefFBCPVAVXOI0cDdIg.jpg",
"backdrop_path": "/9K4mLtNKhEfFBCPVAVXOI0cDdIg.jpg",
"first_air_date": "2018-10-01",
"episode_run_time": [7],
"genres": [
{ "id": 16, "name": "Animation" },
{ "id": 10751, "name": "Family" },
{ "id": 10759, "name": "Action & Adventure" }
],
"credits": {
"cast": [
{ "id": 2134777, "name": "Melanie Zanetti", "character": "Chilli Heeler (voice)", "profile_path": "/zAnetti.jpg", "order": 1 },
{ "id": 1760828, "name": "David McCormack", "character": "Bandit Heeler (voice)", "profile_path": "/dMcCormack.jpg", "order": 0 }
]
},
"videos": {
"results": [
{ "key": "bluey_clip", "site": "YouTube", "type": "Clip", "name": "Clip: Keepy Uppy", "official": true },
{ "key": "bluey_teaser", "site": "YouTube", "type": "Teaser", "name": "Series Teaser", "official": false }
]
},
"external_ids": {
"imdb_id": "tt7614372",
"tvdb_id": 361391
}
}
@@ -0,0 +1,7 @@
{
"id": 82728,
"results": [
{ "key": "bluey_clip", "site": "YouTube", "type": "Clip", "name": "Clip: Keepy Uppy", "official": true },
{ "key": "bluey_teaser", "site": "YouTube", "type": "Teaser", "name": "Series Teaser", "official": false }
]
}
+250
View File
@@ -20,6 +20,10 @@ const MOVIE_THEATRICAL_ONLY: &str = include_str!("fixtures/movie_theatrical_only
const MOVIE_FUTURE_DIGITAL: &str = include_str!("fixtures/movie_future_digital.json");
const SERIES_EXTERNAL_IDS: &str = include_str!("fixtures/series_external_ids.json");
const FIND_IMDB_SERIES: &str = include_str!("fixtures/find_imdb_series.json");
const MOVIE_DETAIL_DUNE: &str = include_str!("fixtures/movie_detail_dune.json");
const MOVIE_VIDEOS_DUNE: &str = include_str!("fixtures/movie_videos_dune.json");
const SERIES_DETAIL_BLUEY: &str = include_str!("fixtures/series_detail_bluey.json");
const SERIES_VIDEOS_BLUEY: &str = include_str!("fixtures/series_videos_bluey.json");
fn client(server: &MockServer) -> TmdbClient {
TmdbClient::builder("test-key")
@@ -556,3 +560,249 @@ async fn series_without_a_tvdb_id_maps_to_none() {
assert_eq!(ids.tvdb_id, None);
}
// --- detail (#143, §9.6) ----------------------------------------------------
/// A whole detail page costs one upstream request: credits, videos and
/// external ids ride along in the same response.
#[tokio::test]
async fn movie_detail_asks_for_credits_videos_and_external_ids_in_one_call() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/693134"))
.and(query_param(
"append_to_response",
"credits,videos,external_ids",
))
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
.expect(1)
.mount(&server)
.await;
client(&server)
.movie_detail(693_134)
.await
.expect("lookup succeeds");
}
#[tokio::test]
async fn movie_detail_parses_the_rich_fields() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/693134"))
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
.mount(&server)
.await;
let movie = client(&server)
.movie_detail(693_134)
.await
.expect("lookup succeeds");
assert_eq!(movie.tmdb_id, 693_134);
assert_eq!(
movie.overview.as_deref(),
Some("Paul Atreides unites with Chani and the Fremen while seeking revenge against the conspirators who destroyed his family.")
);
assert_eq!(movie.tagline.as_deref(), Some("Long live the fighters."));
assert_eq!(movie.genres.len(), 2);
assert_eq!(movie.genres[0].id, 878);
assert_eq!(movie.genres[0].name, "Science Fiction");
assert!(movie.poster_path.is_some());
assert!(movie.backdrop_path.is_some());
assert!((movie.vote_average - 8.152).abs() < f64::EPSILON);
assert_eq!(movie.vote_count, 6_249);
assert_eq!(movie.homepage.as_deref(), Some("https://www.dunemovie.com"));
assert_eq!(movie.status, "Released");
assert_eq!(movie.runtime, Some(167));
assert_eq!(movie.imdb_id.as_deref(), Some("tt15239678"));
}
/// §9.6: cast is the top 10 billed, truncated in the crate so no caller has to
/// remember to. The fixture carries 12 entries; only the first 10 by `order`
/// survive.
#[tokio::test]
async fn movie_detail_cast_is_truncated_to_ten_by_order() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/693134"))
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
.mount(&server)
.await;
let movie = client(&server)
.movie_detail(693_134)
.await
.expect("lookup succeeds");
assert_eq!(movie.cast.len(), 10);
assert_eq!(movie.cast[0].tmdb_id, 5_530);
assert_eq!(movie.cast[0].name, "Timothée Chalamet");
assert_eq!(movie.cast[0].character, "Paul Atreides");
assert_eq!(movie.cast[0].order, 0);
// Truncation keeps the lowest `order` values, not the first rows sent.
assert_eq!(movie.cast[9].name, "Souheila Yacoub");
assert!(movie.cast.iter().all(|member| member.tmdb_id != 10_952));
}
/// §9.6: an official `YouTube` trailer, then any `YouTube` trailer, then a
/// `YouTube` teaser. The fixture puts a fan trailer and a Vimeo entry before
/// the official one; neither wins.
#[tokio::test]
async fn movie_detail_picks_the_official_youtube_trailer() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/693134"))
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
.mount(&server)
.await;
let movie = client(&server)
.movie_detail(693_134)
.await
.expect("lookup succeeds");
let trailer = movie.trailer.expect("a trailer is chosen");
assert_eq!(trailer.key, "Way9Dexny3w");
assert_eq!(trailer.site, "YouTube");
assert_eq!(trailer.kind, "Trailer");
assert!(trailer.official);
}
#[tokio::test]
async fn series_detail_parses_the_rich_fields() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/tv/82728"))
.and(query_param(
"append_to_response",
"credits,videos,external_ids",
))
.respond_with(ResponseTemplate::new(200).set_body_string(SERIES_DETAIL_BLUEY))
.expect(1)
.mount(&server)
.await;
let series = client(&server)
.series_detail(82_728)
.await
.expect("lookup succeeds");
assert_eq!(series.tmdb_id, 82_728);
assert_eq!(series.status, "Returning Series");
assert_eq!(series.episode_runtime, Some(7));
assert_eq!(series.tvdb_id, Some(361_391));
assert!(series.tagline.is_none());
assert_eq!(series.genres[0].name, "Animation");
// Cast comes out in TMDB's `order`, not in the order the rows arrived.
assert_eq!(series.cast[0].name, "David McCormack");
// No trailer exists for this series, so the teaser tier is what fires.
let trailer = series.trailer.expect("the teaser is chosen");
assert_eq!(trailer.key, "bluey_teaser");
}
/// Detail goes through the same cache as everything else — two calls, one
/// request.
#[tokio::test]
async fn movie_and_series_detail_are_served_from_the_cache() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/693134"))
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/3/tv/82728"))
.respond_with(ResponseTemplate::new(200).set_body_string(SERIES_DETAIL_BLUEY))
.expect(1)
.mount(&server)
.await;
let tmdb = client(&server);
let _ = tmdb.movie_detail(693_134).await.expect("succeeds");
let _ = tmdb.movie_detail(693_134).await.expect("cached");
let _ = tmdb.series_detail(82_728).await.expect("succeeds");
let _ = tmdb.series_detail(82_728).await.expect("cached");
}
/// #144's search-row chip resolves through the videos-only call: one request,
/// no full detail response.
#[tokio::test]
async fn movie_videos_fetches_only_the_videos_endpoint() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/693134/videos"))
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_VIDEOS_DUNE))
.expect(1)
.mount(&server)
.await;
let trailer = client(&server)
.movie_videos(693_134)
.await
.expect("lookup succeeds")
.expect("an official trailer is chosen");
assert_eq!(trailer.key, "Way9Dexny3w");
}
#[tokio::test]
async fn series_videos_applies_the_same_selection_rule() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/tv/82728/videos"))
.respond_with(ResponseTemplate::new(200).set_body_string(SERIES_VIDEOS_BLUEY))
.expect(1)
.mount(&server)
.await;
let trailer = client(&server)
.series_videos(82_728)
.await
.expect("lookup succeeds")
.expect("the teaser is chosen");
assert_eq!(trailer.key, "bluey_teaser");
}
/// Only `YouTube` can be a trailer. A title whose videos are all Vimeo has none.
#[tokio::test]
async fn vimeo_only_video_lists_yield_no_trailer() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/1/videos"))
.respond_with(
ResponseTemplate::new(200).set_body_string(
r#"{"id": 1, "results": [
{"key": "vimeo_one", "site": "Vimeo", "type": "Trailer", "name": "Trailer", "official": true}
]}"#,
),
)
.mount(&server)
.await;
let trailer = client(&server)
.movie_videos(1)
.await
.expect("lookup succeeds");
assert_eq!(trailer, None);
}
#[tokio::test]
async fn empty_video_list_yields_no_trailer() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/1/videos"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"id": 1, "results": []}"#))
.mount(&server)
.await;
let trailer = client(&server)
.movie_videos(1)
.await
.expect("lookup succeeds");
assert_eq!(trailer, None);
}