feat(api): resolve trailer chips by TMDB id
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
use axum::extract::{Query, State};
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::movies::{ApiError, ErrorBody};
|
||||
use crate::search::{tmdb_client, upstream_error};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct TrailerQuery {
|
||||
kind: TrailerKind,
|
||||
tmdb_id: u32,
|
||||
}
|
||||
|
||||
/// Which namespace the TMDB id names. Movie and series ids are independent
|
||||
/// numbering spaces at TMDB.
|
||||
#[derive(Debug, Clone, Copy, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TrailerKind {
|
||||
Movie,
|
||||
Tv,
|
||||
}
|
||||
|
||||
/// The one trailer worth showing (§9.6), resolved on click rather than
|
||||
/// prefetched: TMDB's search responses carry no videos.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct Trailer {
|
||||
pub youtube_key: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/trailer", tag = "search", params(TrailerQuery),
|
||||
responses(
|
||||
(status = 200, body = Trailer),
|
||||
(status = 404, body = ErrorBody),
|
||||
(status = 422, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn trailer(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<TrailerQuery>,
|
||||
) -> Result<Json<Trailer>, ApiError> {
|
||||
let tmdb = tmdb_client(&state)?;
|
||||
let video = match query.kind {
|
||||
TrailerKind::Movie => tmdb.movie_videos(query.tmdb_id).await,
|
||||
TrailerKind::Tv => tmdb.series_videos(query.tmdb_id).await,
|
||||
}
|
||||
.map_err(|error| upstream_error(&error))?;
|
||||
match video {
|
||||
Some(video) => Ok(Json(Trailer {
|
||||
youtube_key: video.key,
|
||||
name: video.name,
|
||||
})),
|
||||
None => Err(ApiError::NoTrailer),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{router, Upstreams};
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// Serve the app against a mocked TMDB. No database: the handler never
|
||||
/// touches one, because the chip addresses titles that have no local row.
|
||||
async fn application(tmdb: &MockServer) -> String {
|
||||
let state = AppState::new(
|
||||
Upstreams::new("http://127.0.0.1:1".into(), "http://127.0.0.1:1".into())
|
||||
.with_tmdb_url(tmdb.uri())
|
||||
.with_tmdb_api_key(Some("tmdb-key".into())),
|
||||
)
|
||||
.expect("state");
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let address = listener.local_addr().expect("address");
|
||||
tokio::spawn(async move { axum::serve(listener, router(state)).await.expect("serve") });
|
||||
format!("http://{address}")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_movie_trailer_resolves_through_the_videos_call() {
|
||||
let tmdb = MockServer::start().await;
|
||||
// One request, videos only — §9.6 keeps the click cost at one call.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/movie/693134/videos"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"id": 693_134,
|
||||
"results": [
|
||||
{"key": "fan_edit", "site": "YouTube", "type": "Trailer",
|
||||
"name": "Fan Trailer", "official": false},
|
||||
{"key": "Way9Dexny3w", "site": "YouTube", "type": "Trailer",
|
||||
"name": "Official Trailer", "official": true}
|
||||
]
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
let base = application(&tmdb).await;
|
||||
|
||||
let response: serde_json::Value =
|
||||
reqwest::get(format!("{base}/api/trailer?kind=movie&tmdb_id=693134"))
|
||||
.await
|
||||
.expect("request")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
assert_eq!(response["youtube_key"], "Way9Dexny3w");
|
||||
assert_eq!(response["name"], "Official Trailer");
|
||||
tmdb.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_series_trailer_resolves_through_the_same_rule() {
|
||||
let tmdb = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/tv/82728/videos"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"id": 82_728,
|
||||
"results": [
|
||||
{"key": "abc123", "site": "YouTube", "type": "Teaser",
|
||||
"name": "Series Teaser", "official": true}
|
||||
]
|
||||
})))
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
let base = application(&tmdb).await;
|
||||
|
||||
let response: serde_json::Value =
|
||||
reqwest::get(format!("{base}/api/trailer?kind=tv&tmdb_id=82728"))
|
||||
.await
|
||||
.expect("request")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
assert_eq!(response["youtube_key"], "abc123");
|
||||
assert_eq!(response["name"], "Series Teaser");
|
||||
}
|
||||
|
||||
/// A title TMDB knows but has no `YouTube` trailer for is an ordinary
|
||||
/// outcome: the browser renders "no trailer", so 404, never a 5xx.
|
||||
#[tokio::test]
|
||||
async fn a_title_without_a_trailer_is_a_forty_forty() {
|
||||
let tmdb = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/movie/1/videos"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"id": 1,
|
||||
"results": [
|
||||
{"key": "clip", "site": "Vimeo", "type": "Clip",
|
||||
"name": "A Clip", "official": true}
|
||||
]
|
||||
})))
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
let base = application(&tmdb).await;
|
||||
|
||||
let response = reqwest::get(format!("{base}/api/trailer?kind=movie&tmdb_id=1"))
|
||||
.await
|
||||
.expect("request");
|
||||
assert_eq!(response.status(), 404);
|
||||
let body: serde_json::Value = response.json().await.expect("json");
|
||||
assert_eq!(body["error"], "no trailer");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_upstream_outage_is_not_mistaken_for_no_trailer() {
|
||||
let tmdb = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/movie/1/videos"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
let base = application(&tmdb).await;
|
||||
|
||||
let response = reqwest::get(format!("{base}/api/trailer?kind=movie&tmdb_id=1"))
|
||||
.await
|
||||
.expect("request");
|
||||
assert_eq!(response.status(), 503);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_tmdb_key_behaves_like_the_rest_of_the_search_surface() {
|
||||
let state = AppState::new(Upstreams::new(
|
||||
"http://127.0.0.1:1".into(),
|
||||
"http://127.0.0.1:1".into(),
|
||||
))
|
||||
.expect("state");
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let address = listener.local_addr().expect("address");
|
||||
tokio::spawn(async move { axum::serve(listener, router(state)).await.expect("serve") });
|
||||
let base = format!("http://{address}");
|
||||
|
||||
let response = reqwest::get(format!("{base}/api/trailer?kind=movie&tmdb_id=693134"))
|
||||
.await
|
||||
.expect("request");
|
||||
assert_eq!(response.status(), 503);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user