+20
@@ -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"
|
||||||
|
}
|
||||||
+20
@@ -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"
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
//! to compile, and the gate in DESIGN.md §12 fails with it.
|
//! to compile, and the gate in DESIGN.md §12 fails with it.
|
||||||
|
|
||||||
mod health;
|
mod health;
|
||||||
|
mod metadata;
|
||||||
mod movies;
|
mod movies;
|
||||||
mod owners;
|
mod owners;
|
||||||
mod policies;
|
mod policies;
|
||||||
@@ -24,6 +25,7 @@ use utoipa_axum::routes;
|
|||||||
use utoipa_scalar::{Scalar, Servable};
|
use utoipa_scalar::{Scalar, Servable};
|
||||||
|
|
||||||
pub use health::{Check, Health, HealthReport, Status};
|
pub use health::{Check, Health, HealthReport, Status};
|
||||||
|
pub use metadata::{MetadataCastMember, MetadataTrailer, MovieMetadata, SeriesMetadata};
|
||||||
pub use movies::{
|
pub use movies::{
|
||||||
Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, MovieFile, Release, UpdateMovie,
|
Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, MovieFile, Release, UpdateMovie,
|
||||||
};
|
};
|
||||||
@@ -83,6 +85,7 @@ fn api_router() -> OpenApiRouter<AppState> {
|
|||||||
.routes(routes!(movies::attention))
|
.routes(routes!(movies::attention))
|
||||||
.routes(routes!(movies::list_owners))
|
.routes(routes!(movies::list_owners))
|
||||||
.routes(routes!(movies::tag_owner, movies::untag_owner))
|
.routes(routes!(movies::tag_owner, movies::untag_owner))
|
||||||
|
.routes(routes!(metadata::movie_metadata))
|
||||||
.routes(routes!(series::list, series::create))
|
.routes(routes!(series::list, series::create))
|
||||||
.routes(routes!(series::get, series::update, series::delete))
|
.routes(routes!(series::get, series::update, series::delete))
|
||||||
.routes(routes!(series::seasons, series::create_season))
|
.routes(routes!(series::seasons, series::create_season))
|
||||||
@@ -95,6 +98,7 @@ fn api_router() -> OpenApiRouter<AppState> {
|
|||||||
.routes(routes!(series::season_releases))
|
.routes(routes!(series::season_releases))
|
||||||
.routes(routes!(series::grab_season_release))
|
.routes(routes!(series::grab_season_release))
|
||||||
.routes(routes!(series::files))
|
.routes(routes!(series::files))
|
||||||
|
.routes(routes!(metadata::series_metadata))
|
||||||
.routes(routes!(series::list_owners))
|
.routes(routes!(series::list_owners))
|
||||||
.routes(routes!(series::tag_owner, series::untag_owner))
|
.routes(routes!(series::tag_owner, series::untag_owner))
|
||||||
.routes(routes!(owners::list, owners::create))
|
.routes(routes!(owners::list, owners::create))
|
||||||
@@ -316,6 +320,8 @@ mod tests {
|
|||||||
("/api/movies/{movie_id}/search", "post"),
|
("/api/movies/{movie_id}/search", "post"),
|
||||||
("/api/movies/{movie_id}/releases", "get"),
|
("/api/movies/{movie_id}/releases", "get"),
|
||||||
("/api/movies/{movie_id}/releases/{release_id}/grab", "post"),
|
("/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",
|
"/api/series/{series_id}/seasons/{season_number}/search",
|
||||||
"post",
|
"post",
|
||||||
|
|||||||
@@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user