From 60fcc9f3d501efe12635606f3534bf621cb305e5 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 22:17:51 +0100 Subject: [PATCH] feat(api): metadata endpoints for library titles --- ...21bbc42c5d412e31d2303b45813efa52201b9.json | 20 + ...a9030f528bee609fd0590cc715cf0343a51ba.json | 20 + crates/arr-api/src/lib.rs | 6 + crates/arr-api/src/metadata.rs | 449 ++++++++++++++++++ 4 files changed, 495 insertions(+) create mode 100644 .sqlx/query-1612f76ea880bbd06a396b3043c21bbc42c5d412e31d2303b45813efa52201b9.json create mode 100644 .sqlx/query-cf7554342c87190b40e41db90a4a9030f528bee609fd0590cc715cf0343a51ba.json create mode 100644 crates/arr-api/src/metadata.rs diff --git a/.sqlx/query-1612f76ea880bbd06a396b3043c21bbc42c5d412e31d2303b45813efa52201b9.json b/.sqlx/query-1612f76ea880bbd06a396b3043c21bbc42c5d412e31d2303b45813efa52201b9.json new file mode 100644 index 0000000..059d71c --- /dev/null +++ b/.sqlx/query-1612f76ea880bbd06a396b3043c21bbc42c5d412e31d2303b45813efa52201b9.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT tmdb_id FROM movies WHERE id = ?", + "describe": { + "columns": [ + { + "name": "tmdb_id", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "1612f76ea880bbd06a396b3043c21bbc42c5d412e31d2303b45813efa52201b9" +} diff --git a/.sqlx/query-cf7554342c87190b40e41db90a4a9030f528bee609fd0590cc715cf0343a51ba.json b/.sqlx/query-cf7554342c87190b40e41db90a4a9030f528bee609fd0590cc715cf0343a51ba.json new file mode 100644 index 0000000..f4c209d --- /dev/null +++ b/.sqlx/query-cf7554342c87190b40e41db90a4a9030f528bee609fd0590cc715cf0343a51ba.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT tmdb_id FROM series WHERE id = ?", + "describe": { + "columns": [ + { + "name": "tmdb_id", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "cf7554342c87190b40e41db90a4a9030f528bee609fd0590cc715cf0343a51ba" +} diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index 9782f93..c085011 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -7,6 +7,7 @@ //! to compile, and the gate in DESIGN.md §12 fails with it. mod health; +mod metadata; mod movies; mod owners; mod policies; @@ -24,6 +25,7 @@ use utoipa_axum::routes; use utoipa_scalar::{Scalar, Servable}; pub use health::{Check, Health, HealthReport, Status}; +pub use metadata::{MetadataCastMember, MetadataTrailer, MovieMetadata, SeriesMetadata}; pub use movies::{ Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, MovieFile, Release, UpdateMovie, }; @@ -83,6 +85,7 @@ fn api_router() -> OpenApiRouter { .routes(routes!(movies::attention)) .routes(routes!(movies::list_owners)) .routes(routes!(movies::tag_owner, movies::untag_owner)) + .routes(routes!(metadata::movie_metadata)) .routes(routes!(series::list, series::create)) .routes(routes!(series::get, series::update, series::delete)) .routes(routes!(series::seasons, series::create_season)) @@ -95,6 +98,7 @@ fn api_router() -> OpenApiRouter { .routes(routes!(series::season_releases)) .routes(routes!(series::grab_season_release)) .routes(routes!(series::files)) + .routes(routes!(metadata::series_metadata)) .routes(routes!(series::list_owners)) .routes(routes!(series::tag_owner, series::untag_owner)) .routes(routes!(owners::list, owners::create)) @@ -316,6 +320,8 @@ mod tests { ("/api/movies/{movie_id}/search", "post"), ("/api/movies/{movie_id}/releases", "get"), ("/api/movies/{movie_id}/releases/{release_id}/grab", "post"), + ("/api/movies/{movie_id}/metadata", "get"), + ("/api/series/{series_id}/metadata", "get"), ( "/api/series/{series_id}/seasons/{season_number}/search", "post", diff --git a/crates/arr-api/src/metadata.rs b/crates/arr-api/src/metadata.rs new file mode 100644 index 0000000..6ec30e9 --- /dev/null +++ b/crates/arr-api/src/metadata.rs @@ -0,0 +1,449 @@ +//! 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); + } +}