feat(api): resolve trailer chips by TMDB id

This commit is contained in:
Miguel Palhas
2026-08-23 22:03:04 +01:00
parent 9bd037d1b6
commit 80a63ea2e1
4 changed files with 215 additions and 1 deletions
+4
View File
@@ -14,6 +14,7 @@ mod roots;
mod search; mod search;
mod series; mod series;
mod state; mod state;
mod trailer;
use axum::routing::get; use axum::routing::get;
use axum::{Json, Router}; use axum::{Json, Router};
@@ -39,6 +40,7 @@ pub use series::{
pub use state::{ pub use state::{
AppState, EpisodeCommand, MovieCommand, SeasonCommand, Upstreams, DEFAULT_TMDB_URL, 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 /// 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. /// it back from when it is fetched rather than dumped from the binary.
@@ -99,6 +101,7 @@ fn api_router() -> OpenApiRouter<AppState> {
.routes(routes!(owners::get, owners::update, owners::delete)) .routes(routes!(owners::get, owners::update, owners::delete))
.routes(routes!(search::search)) .routes(routes!(search::search))
.routes(routes!(search::releases)) .routes(routes!(search::releases))
.routes(routes!(trailer::trailer))
.routes(routes!(roots::list, roots::create)) .routes(routes!(roots::list, roots::create))
.routes(routes!(roots::get, roots::update, roots::delete)) .routes(routes!(roots::get, roots::update, roots::delete))
.routes(routes!(policies::list, policies::create)) .routes(routes!(policies::list, policies::create))
@@ -326,6 +329,7 @@ mod tests {
"post", "post",
), ),
("/api/queues/attention", "get"), ("/api/queues/attention", "get"),
("/api/trailer", "get"),
("/api/series", "get"), ("/api/series", "get"),
("/api/policies", "get"), ("/api/policies", "get"),
("/api/policies", "post"), ("/api/policies", "post"),
+4
View File
@@ -149,6 +149,9 @@ pub enum ApiError {
OwnerNotFound, OwnerNotFound,
PolicyNotFound, PolicyNotFound,
RootNotFound, 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), Conflict(String),
Invalid(String), Invalid(String),
Unavailable, Unavailable,
@@ -169,6 +172,7 @@ impl IntoResponse for ApiError {
Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string()), Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string()),
Self::PolicyNotFound => (StatusCode::NOT_FOUND, "policy 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::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::Conflict(error) => (StatusCode::CONFLICT, error),
Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error), Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error),
Self::Unavailable => ( Self::Unavailable => (
+1 -1
View File
@@ -652,7 +652,7 @@ pub(crate) fn tmdb_client(state: &AppState) -> Result<arr_meta::TmdbClient, ApiE
.map_err(|_| ApiError::Unavailable) .map_err(|_| ApiError::Unavailable)
} }
fn upstream_error(error: &arr_meta::Error) -> ApiError { pub(crate) fn upstream_error(error: &arr_meta::Error) -> ApiError {
match error { match error {
arr_meta::Error::NotFound { .. } => ApiError::NotFound, arr_meta::Error::NotFound { .. } => ApiError::NotFound,
_ => ApiError::Unavailable, _ => ApiError::Unavailable,
+206
View File
@@ -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);
}
}