From 80a63ea2e13d55a50d688d6dc85c2366902dcf05 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 22:03:04 +0100 Subject: [PATCH] feat(api): resolve trailer chips by TMDB id --- crates/arr-api/src/lib.rs | 4 + crates/arr-api/src/movies.rs | 4 + crates/arr-api/src/search.rs | 2 +- crates/arr-api/src/trailer.rs | 206 ++++++++++++++++++++++++++++++++++ 4 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 crates/arr-api/src/trailer.rs diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index a85c62b..9782f93 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -14,6 +14,7 @@ mod roots; mod search; mod series; mod state; +mod trailer; use axum::routing::get; use axum::{Json, Router}; @@ -39,6 +40,7 @@ pub use series::{ pub use state::{ AppState, EpisodeCommand, MovieCommand, SeasonCommand, Upstreams, DEFAULT_TMDB_URL, }; +pub use trailer::{Trailer, TrailerKind}; /// Where the generated document is served, and where `just gen-client` reads /// it back from when it is fetched rather than dumped from the binary. @@ -99,6 +101,7 @@ fn api_router() -> OpenApiRouter { .routes(routes!(owners::get, owners::update, owners::delete)) .routes(routes!(search::search)) .routes(routes!(search::releases)) + .routes(routes!(trailer::trailer)) .routes(routes!(roots::list, roots::create)) .routes(routes!(roots::get, roots::update, roots::delete)) .routes(routes!(policies::list, policies::create)) @@ -326,6 +329,7 @@ mod tests { "post", ), ("/api/queues/attention", "get"), + ("/api/trailer", "get"), ("/api/series", "get"), ("/api/policies", "get"), ("/api/policies", "post"), diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 676ee55..35fcb0e 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -149,6 +149,9 @@ pub enum ApiError { OwnerNotFound, PolicyNotFound, RootNotFound, + /// The §9.6 chip outcome: the title exists upstream but has no trailer. + /// Ordinary, so it must stay distinguishable from an upstream failure. + NoTrailer, Conflict(String), Invalid(String), Unavailable, @@ -169,6 +172,7 @@ impl IntoResponse for ApiError { Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string()), Self::PolicyNotFound => (StatusCode::NOT_FOUND, "policy not found".to_string()), Self::RootNotFound => (StatusCode::NOT_FOUND, "root not found".to_string()), + Self::NoTrailer => (StatusCode::NOT_FOUND, "no trailer".to_string()), Self::Conflict(error) => (StatusCode::CONFLICT, error), Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error), Self::Unavailable => ( diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 1fe6b46..0d82ed8 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -652,7 +652,7 @@ pub(crate) fn tmdb_client(state: &AppState) -> Result ApiError { +pub(crate) fn upstream_error(error: &arr_meta::Error) -> ApiError { match error { arr_meta::Error::NotFound { .. } => ApiError::NotFound, _ => ApiError::Unavailable, diff --git a/crates/arr-api/src/trailer.rs b/crates/arr-api/src/trailer.rs new file mode 100644 index 0000000..bc93b47 --- /dev/null +++ b/crates/arr-api/src/trailer.rs @@ -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, + Query(query): Query, +) -> Result, 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); + } +}