Files
arr/crates/arr-api/src/trailer.rs
T
2026-08-23 22:03:04 +01:00

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