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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user