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
+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);
}