//! Title detail metadata for the §9.6 pages (#146). One endpoint per kind, //! one upstream call each through `arr-meta`'s detail methods, cached 24h and //! never persisted. Library titles only: search rows already carry what a //! results list needs. use arr_meta::{MovieDetail, SeriesDetail}; use axum::extract::{Path, State}; use axum::Json; use serde::Serialize; use utoipa::ToSchema; use crate::movies::{pool, ApiError, ErrorBody}; use crate::search::{tmdb_client, upstream_error}; use crate::state::AppState; /// One of the top-billed cast members on a detail page. #[derive(Debug, Clone, Serialize, ToSchema)] pub struct MetadataCastMember { /// The person's TMDB id, for the link out to tmdb.org (§9.6). pub tmdb_id: u32, pub name: String, pub character: String, /// Path fragment exactly as TMDB sends it (§9.6): the browser composes /// the URL and picks the size. pub profile_path: Option, } /// The one trailer worth showing, resolved by the §9.6 rule inside `arr-meta`. #[derive(Debug, Clone, Serialize, ToSchema)] pub struct MetadataTrailer { pub youtube_key: String, pub name: String, } /// Rich detail for one library movie's §9.6 page. Image paths are fragments, /// never composed URLs; the Rotten Tomatoes link is deliberately absent — it /// is a browser-built search URL, not an identifier this service holds. #[derive(Debug, Clone, Serialize, ToSchema)] pub struct MovieMetadata { pub tmdb_id: u32, pub overview: Option, pub tagline: Option, pub genres: Vec, /// Minutes. pub runtime: Option, pub status: String, pub poster_path: Option, pub backdrop_path: Option, pub vote_average: f64, pub vote_count: u32, pub homepage: Option, /// §9.6 links out to `IMDb` for movies. pub imdb_id: Option, /// Top 10 billed, ordered by TMDB's own cast order. pub cast: Vec, pub trailer: Option, } /// Rich detail for one library series' §9.6 page. #[derive(Debug, Clone, Serialize, ToSchema)] pub struct SeriesMetadata { pub tmdb_id: u32, pub overview: Option, pub tagline: Option, pub genres: Vec, /// Episode length in minutes. pub runtime: Option, pub status: String, pub poster_path: Option, pub backdrop_path: Option, pub vote_average: f64, pub vote_count: u32, pub homepage: Option, /// §9.6 links out to TVDB for series. pub tvdb_id: Option, /// Top 10 billed, ordered by TMDB's own cast order. pub cast: Vec, pub trailer: Option, } impl From for MovieMetadata { fn from(detail: MovieDetail) -> Self { Self { tmdb_id: detail.tmdb_id, overview: detail.overview, tagline: detail.tagline, genres: detail.genres.into_iter().map(|genre| genre.name).collect(), runtime: detail.runtime, status: detail.status, poster_path: detail.poster_path, backdrop_path: detail.backdrop_path, vote_average: detail.vote_average, vote_count: detail.vote_count, homepage: detail.homepage, imdb_id: detail.imdb_id, cast: detail.cast.into_iter().map(Into::into).collect(), trailer: detail.trailer.map(Into::into), } } } impl From for SeriesMetadata { fn from(detail: SeriesDetail) -> Self { Self { tmdb_id: detail.tmdb_id, overview: detail.overview, tagline: detail.tagline, genres: detail.genres.into_iter().map(|genre| genre.name).collect(), runtime: detail.episode_runtime, status: detail.status, poster_path: detail.poster_path, backdrop_path: detail.backdrop_path, vote_average: detail.vote_average, vote_count: detail.vote_count, homepage: detail.homepage, tvdb_id: detail.tvdb_id, cast: detail.cast.into_iter().map(Into::into).collect(), trailer: detail.trailer.map(Into::into), } } } impl From for MetadataCastMember { fn from(member: arr_meta::CastMember) -> Self { Self { tmdb_id: member.tmdb_id, name: member.name, character: member.character, profile_path: member.profile_path, } } } impl From for MetadataTrailer { fn from(video: arr_meta::Video) -> Self { Self { youtube_key: video.key, name: video.name, } } } fn tmdb_id(raw: i64) -> Result { u32::try_from(raw).map_err(|_| ApiError::Invalid("stored tmdb_id is out of range".into())) } #[utoipa::path( get, path = "/api/movies/{movie_id}/metadata", tag = "movies", params(("movie_id" = i64, Path, description = "Movie row id")), responses( (status = 200, body = MovieMetadata), (status = 404, body = ErrorBody), (status = 422, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn movie_metadata( State(state): State, Path(id): Path, ) -> Result, ApiError> { let tmdb_row = sqlx::query_scalar!("SELECT tmdb_id FROM movies WHERE id = ?", id) .fetch_one(pool(&state)?) .await?; let tmdb = tmdb_client(&state)?; let detail = tmdb .movie_detail(tmdb_id(tmdb_row)?) .await .map_err(|error| upstream_error(&error))?; Ok(Json(detail.into())) } #[utoipa::path( get, path = "/api/series/{series_id}/metadata", tag = "series", params(("series_id" = i64, Path, description = "Series row id")), responses( (status = 200, body = SeriesMetadata), (status = 404, body = ErrorBody), (status = 422, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn series_metadata( State(state): State, Path(id): Path, ) -> Result, ApiError> { let tmdb_row = sqlx::query_scalar!("SELECT tmdb_id FROM series WHERE id = ?", id) .fetch_one(pool(&state)?) .await .map_err(|error| { if matches!(error, sqlx::Error::RowNotFound) { ApiError::SeriesNotFound } else { error.into() } })?; let tmdb = tmdb_client(&state)?; let detail = tmdb .series_detail(tmdb_id(tmdb_row)?) .await .map_err(|error| upstream_error(&error))?; Ok(Json(detail.into())) } #[cfg(test)] mod tests { use super::*; use crate::{router, Upstreams}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; /// App against a mocked TMDB, with a real migrated database so library /// rows exist to resolve local ids against. async fn application(tmdb: &MockServer) -> (tempfile::TempDir, String) { let dir = tempfile::tempdir().expect("tempdir"); let database = arr_db::Db::connect(dir.path().join("arr.db")) .await .expect("connect database"); database.migrate().await.expect("migrate database"); 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") .with_database(database); 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") }); (dir, format!("http://{address}")) } async fn add_movie(base: &str) -> serde_json::Value { let response = reqwest::Client::new() .post(format!("{base}/api/movies")) .json(&serde_json::json!({ "tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024, "original_language": "en", "root_id": 1 })) .send() .await .expect("create movie"); assert_eq!(response.status(), 201); response.json().await.expect("movie json") } async fn add_series(base: &str) -> serde_json::Value { let roots: Vec = reqwest::get(format!("{base}/api/roots")) .await .expect("roots") .json() .await .expect("roots json"); let tv_root = roots .iter() .find(|root| root["kind"] == "tv") .and_then(|root| root["id"].as_i64()) .expect("a seeded TV root"); let response = reqwest::Client::new() .post(format!("{base}/api/series")) .json(&serde_json::json!({ "tmdb_id": 82_728, "title": "The Last of Us", "original_language": "en", "root_id": tv_root })) .send() .await .expect("create series"); assert_eq!(response.status(), 201); response.json().await.expect("series json") } #[tokio::test] async fn a_movie_metadata_resolves_through_one_detail_call() { let tmdb = MockServer::start().await; Mock::given(method("GET")) .and(path("/movie/693134")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 693_134, "overview": "Paul Atreides unites with the Fremen.", "tagline": "Long live the fighters.", "genres": [{"id": 878, "name": "Science Fiction"}, {"id": 12, "name": "Adventure"}], "backdrop_path": "/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg", "poster_path": "/1pdfLlvXA5qYN3ikFm4QDe7EJ19.jpg", "vote_average": 8.417, "vote_count": 3_512, "homepage": "https://www.duneparttwo.com", "status": "Released", "runtime": 166, "imdb_id": "tt15239678", "credits": {"cast": [ {"id": 9_058_792, "name": "Timothée Chalamet", "character": "Paul Atreides", "profile_path": "/tzLq0VAm3myi2lzNpdfhVGjXAdB.jpg", "order": 0}, {"id": 1_320_062, "name": "Zendaya", "character": "Chani", "profile_path": "/ultRzqcGdWXWX3rjg1up9HcsoGi.jpg", "order": 1} ]}, "videos": {"results": [ {"key": "fan_edit", "site": "YouTube", "type": "Trailer", "name": "Fan Trailer", "official": false}, {"key": "Way9Dexny3w", "site": "YouTube", "type": "Trailer", "name": "Dune: Part Two | Official Trailer", "official": true} ]} }))) .expect(1) .mount(&tmdb) .await; let (_dir, base) = application(&tmdb).await; let movie = add_movie(&base).await; let movie_id = movie["id"].as_i64().expect("movie id"); let body: serde_json::Value = reqwest::get(format!("{base}/api/movies/{movie_id}/metadata")) .await .expect("request") .json() .await .expect("json"); assert_eq!(body["tmdb_id"], 693_134); assert_eq!(body["overview"], "Paul Atreides unites with the Fremen."); assert_eq!(body["tagline"], "Long live the fighters."); assert_eq!(body["genres"][0], "Science Fiction"); assert_eq!(body["runtime"], 166); assert_eq!(body["status"], "Released"); assert_eq!(body["imdb_id"], "tt15239678"); assert_eq!(body["vote_average"], serde_json::json!(8.417)); assert_eq!(body["vote_count"], 3_512); // §9.6: path fragments exactly as TMDB sends them, never composed URLs. assert_eq!(body["poster_path"], "/1pdfLlvXA5qYN3ikFm4QDe7EJ19.jpg"); assert_eq!(body["backdrop_path"], "/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg"); assert_eq!( body["cast"][0]["profile_path"], "/tzLq0VAm3myi2lzNpdfhVGjXAdB.jpg" ); assert_eq!(body["cast"][0]["character"], "Paul Atreides"); assert_eq!(body["trailer"]["youtube_key"], "Way9Dexny3w"); tmdb.verify().await; } #[tokio::test] async fn a_series_metadata_carries_the_tvdb_id_and_episode_runtime() { let tmdb = MockServer::start().await; Mock::given(method("GET")) .and(path("/tv/82728")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 82_728, "overview": "Twenty years after civilization fell.", "tagline": "When you're lost in the darkness, look for the light.", "genres": [{"id": 18, "name": "Drama"}], "backdrop_path": "/eMhDKZscBd07OLpAeeAyu3N3U8c.jpg", "poster_path": "/uKvVjHNqB5VmOrdxqAt2F7J78ED.jpg", "vote_average": 8.5, "vote_count": 2_911, "homepage": "", "status": "Returning Series", "episode_run_time": [55, 57], "external_ids": {"tvdb_id": 392_256}, "credits": {"cast": [ {"id": 1_320_062, "name": "Pedro Pascal", "character": "Joel Miller", "profile_path": "/zEmkctzuGBEbxFfOtpTAg9jtkTu.jpg", "order": 0} ]}, "videos": {"results": [ {"key": "uLtkt8Bonuu", "site": "YouTube", "type": "Trailer", "name": "Official Trailer", "official": true} ]} }))) .mount(&tmdb) .await; let (_dir, base) = application(&tmdb).await; let series = add_series(&base).await; let series_id = series["id"].as_i64().expect("series id"); let body: serde_json::Value = reqwest::get(format!("{base}/api/series/{series_id}/metadata")) .await .expect("request") .json() .await .expect("json"); assert_eq!(body["tmdb_id"], 82_728); assert_eq!(body["tvdb_id"], 392_256); // TMDB sends a list of episode lengths; the API reports one runtime. assert_eq!(body["runtime"], 55); assert_eq!(body["status"], "Returning Series"); // TMDB uses "" where null is meant; it stays null on the way out. assert_eq!(body["homepage"], serde_json::Value::Null); assert_eq!(body["poster_path"], "/uKvVjHNqB5VmOrdxqAt2F7J78ED.jpg"); assert_eq!(body["trailer"]["youtube_key"], "uLtkt8Bonuu"); } /// An id that is not in the library is a 404 before TMDB is asked — /// search rows are addressed through #144's trailer endpoint instead. #[tokio::test] async fn an_unknown_local_id_is_a_forty_forty_without_touching_tmdb() { let tmdb = MockServer::start().await; let (_dir, base) = application(&tmdb).await; let movie = reqwest::get(format!("{base}/api/movies/99/metadata")) .await .expect("request"); assert_eq!(movie.status(), 404); assert_eq!( movie.json::().await.expect("json")["error"], "movie not found" ); let series = reqwest::get(format!("{base}/api/series/99/metadata")) .await .expect("request"); assert_eq!(series.status(), 404); assert_eq!( series.json::().await.expect("json")["error"], "series not found" ); tmdb.verify().await; } #[tokio::test] async fn no_tmdb_key_is_a_fifty_three_like_the_rest_of_the_surface() { let dir = tempfile::tempdir().expect("tempdir"); let database = arr_db::Db::connect(dir.path().join("arr.db")) .await .expect("connect database"); database.migrate().await.expect("migrate database"); let state = AppState::new(Upstreams::new( "http://127.0.0.1:1".into(), "http://127.0.0.1:1".into(), )) .expect("state") .with_database(database); 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 movie = add_movie(&base).await; let movie_id = movie["id"].as_i64().expect("movie id"); let response = reqwest::get(format!("{base}/api/movies/{movie_id}/metadata")) .await .expect("request"); assert_eq!(response.status(), 503); } }