207 lines
7.2 KiB
Rust
207 lines
7.2 KiB
Rust
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);
|
|
}
|
|
}
|