Files
arr/crates/arr-api/src/metadata.rs
T
2026-08-23 22:17:51 +01:00

450 lines
17 KiB
Rust

//! 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<String>,
}
/// 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<String>,
pub tagline: Option<String>,
pub genres: Vec<String>,
/// Minutes.
pub runtime: Option<u32>,
pub status: String,
pub poster_path: Option<String>,
pub backdrop_path: Option<String>,
pub vote_average: f64,
pub vote_count: u32,
pub homepage: Option<String>,
/// §9.6 links out to `IMDb` for movies.
pub imdb_id: Option<String>,
/// Top 10 billed, ordered by TMDB's own cast order.
pub cast: Vec<MetadataCastMember>,
pub trailer: Option<MetadataTrailer>,
}
/// Rich detail for one library series' §9.6 page.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SeriesMetadata {
pub tmdb_id: u32,
pub overview: Option<String>,
pub tagline: Option<String>,
pub genres: Vec<String>,
/// Episode length in minutes.
pub runtime: Option<u32>,
pub status: String,
pub poster_path: Option<String>,
pub backdrop_path: Option<String>,
pub vote_average: f64,
pub vote_count: u32,
pub homepage: Option<String>,
/// §9.6 links out to TVDB for series.
pub tvdb_id: Option<u32>,
/// Top 10 billed, ordered by TMDB's own cast order.
pub cast: Vec<MetadataCastMember>,
pub trailer: Option<MetadataTrailer>,
}
impl From<MovieDetail> 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<SeriesDetail> 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<arr_meta::CastMember> 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<arr_meta::Video> for MetadataTrailer {
fn from(video: arr_meta::Video) -> Self {
Self {
youtube_key: video.key,
name: video.name,
}
}
}
fn tmdb_id(raw: i64) -> Result<u32, ApiError> {
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<AppState>,
Path(id): Path<i64>,
) -> Result<Json<MovieMetadata>, 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<AppState>,
Path(id): Path<i64>,
) -> Result<Json<SeriesMetadata>, 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<serde_json::Value> = 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::<serde_json::Value>().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::<serde_json::Value>().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);
}
}