d8797e1996
The metadata lane runs daily, so a series added a moment ago showed no seasons for up to 24 hours and a movie had no digital release date — the field §6.2 gates targeted search on. AppState now carries a MetadataCommand channel alongside the movie, episode and season ones. Both create handlers send on it after the row is committed, and a new daemon lane drains it. Its own task rather than an arm of manual::run: a refresh against TMDB can take a while and must not sit in front of an operator's manual search. The add never waits on TMDB and never fails because of it. A refresh that fails leaves metadata_refreshed_at NULL, which is what the daily sweep already treats as due, so the title is retried rather than lost. A command naming a title deleted in between finds no row and does nothing. METADATA_INTERVAL is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
398 lines
14 KiB
Rust
398 lines
14 KiB
Rust
//! arr-api — the HTTP surface. See DESIGN.md §9.1.
|
|
//!
|
|
//! The API is the product; the web UI is one client of it. So the `OpenAPI`
|
|
//! document is not written by hand and not kept in step by review: routes are
|
|
//! registered through [`utoipa_axum::routes`], which only accepts a handler
|
|
//! carrying a `#[utoipa::path]` annotation. A handler added without one fails
|
|
//! to compile, and the gate in DESIGN.md §12 fails with it.
|
|
|
|
mod health;
|
|
mod metadata;
|
|
mod movies;
|
|
mod owners;
|
|
mod policies;
|
|
mod roots;
|
|
mod search;
|
|
mod series;
|
|
mod state;
|
|
mod trailer;
|
|
|
|
use axum::routing::get;
|
|
use axum::{Json, Router};
|
|
use utoipa::OpenApi;
|
|
use utoipa_axum::router::OpenApiRouter;
|
|
use utoipa_axum::routes;
|
|
use utoipa_scalar::{Scalar, Servable};
|
|
|
|
pub use health::{Check, Health, HealthReport, Status};
|
|
pub use metadata::{MetadataTrailer, MovieMetadata, SeriesMetadata};
|
|
pub use movies::{
|
|
Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, MovieFile, Release, UpdateMovie,
|
|
};
|
|
pub use owners::{CreateOwner, Owner, UpdateOwner};
|
|
pub use policies::{
|
|
HdrRulesSpec, Policy, PolicyInput, RequiredAudioSpec, ScoreWeightsSpec, SizeBandSpec,
|
|
};
|
|
pub use roots::{Root, RootInput};
|
|
pub use search::{ClassifiedRelease, SearchResponse};
|
|
pub use series::{
|
|
CreateEpisode, CreateSeason, CreateSeries, Episode, Season, Series, UpdateEpisode,
|
|
UpdateSeason, UpdateSeries,
|
|
};
|
|
pub use state::{
|
|
AppState, EpisodeCommand, MetadataCommand, MovieCommand, SeasonCommand, Upstreams,
|
|
DEFAULT_TMDB_URL,
|
|
};
|
|
pub use trailer::{Trailer, TrailerKind};
|
|
|
|
/// 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.
|
|
pub const OPENAPI_PATH: &str = "/api/openapi.json";
|
|
|
|
/// Where the browsable UI lives.
|
|
pub const DOCS_PATH: &str = "/api/docs";
|
|
|
|
/// Document-level metadata. Paths and schemas are collected from the router,
|
|
/// never listed here — a list is a thing to forget to update.
|
|
#[derive(OpenApi)]
|
|
#[openapi(
|
|
info(
|
|
title = "arr",
|
|
description = "One service in place of Radarr and Sonarr. No authentication: \
|
|
the perimeter is the VPN (DESIGN.md §2).",
|
|
),
|
|
tags(
|
|
(name = "system", description = "Service health and metadata"),
|
|
(name = "movies", description = "Movie library and actions"),
|
|
(name = "series", description = "Series, seasons and episodes (DESIGN.md §4.1, §4.2)"),
|
|
(name = "owners", description = "Owner tags and filtered views (DESIGN.md §4.3)"),
|
|
(name = "policies", description = "Quality policies (DESIGN.md §5)"),
|
|
(name = "search", description = "Unified title and release search"),
|
|
(name = "roots", description = "Root folders and their policies")
|
|
),
|
|
)]
|
|
struct ApiDoc;
|
|
|
|
/// Every annotated route, still needing state.
|
|
fn api_router() -> OpenApiRouter<AppState> {
|
|
OpenApiRouter::with_openapi(ApiDoc::openapi())
|
|
.routes(routes!(health::health))
|
|
.routes(routes!(movies::list, movies::create))
|
|
.routes(routes!(movies::get, movies::update, movies::delete))
|
|
.routes(routes!(movies::search))
|
|
.routes(routes!(movies::releases))
|
|
.routes(routes!(movies::files))
|
|
.routes(routes!(movies::grab))
|
|
.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))
|
|
.routes(routes!(series::update_season))
|
|
.routes(routes!(series::delete_season_files))
|
|
.routes(routes!(series::get_episode, series::update_episode))
|
|
.routes(routes!(series::delete_episode_files))
|
|
.routes(routes!(series::search_episode))
|
|
.routes(routes!(series::episode_releases))
|
|
.routes(routes!(series::grab_episode))
|
|
.routes(routes!(series::search_season))
|
|
.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))
|
|
.routes(routes!(owners::get, owners::update, owners::delete))
|
|
.routes(routes!(search::search))
|
|
.routes(routes!(search::releases))
|
|
.routes(routes!(trailer::trailer))
|
|
.routes(routes!(roots::list, roots::create))
|
|
.routes(routes!(roots::get, roots::update, roots::delete))
|
|
.routes(routes!(policies::list, policies::create))
|
|
.routes(routes!(policies::get, policies::update, policies::delete))
|
|
}
|
|
|
|
/// The generated `OpenAPI` document.
|
|
#[must_use]
|
|
pub fn openapi() -> utoipa::openapi::OpenApi {
|
|
api_router().split_for_parts().1
|
|
}
|
|
|
|
/// The whole application: the API, the served document, and the browsable UI.
|
|
pub fn router(state: AppState) -> Router {
|
|
let (router, api) = api_router().split_for_parts();
|
|
let document = api.clone();
|
|
|
|
router
|
|
.route(
|
|
OPENAPI_PATH,
|
|
get(move || {
|
|
let document = document.clone();
|
|
async move { Json(document) }
|
|
}),
|
|
)
|
|
.merge(Scalar::with_url(DOCS_PATH, api))
|
|
.with_state(state)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use wiremock::matchers::{method, path};
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
/// A Prowlarr that answers `/ping`, and a Transmission that answers an
|
|
/// RPC call the way a real one does when it has no session id yet.
|
|
async fn upstreams_up() -> (MockServer, MockServer) {
|
|
let prowlarr = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/ping"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "OK" })),
|
|
)
|
|
.mount(&prowlarr)
|
|
.await;
|
|
|
|
let transmission = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/transmission/rpc"))
|
|
.respond_with(
|
|
ResponseTemplate::new(409).insert_header("X-Transmission-Session-Id", "abc"),
|
|
)
|
|
.mount(&transmission)
|
|
.await;
|
|
|
|
(prowlarr, transmission)
|
|
}
|
|
|
|
/// Serve the app on an ephemeral port and return its base URL. The server
|
|
/// task dies with the runtime at the end of the test.
|
|
async fn serve(state: AppState) -> String {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind ephemeral port");
|
|
let addr = listener.local_addr().expect("local addr");
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, router(state)).await.expect("serve");
|
|
});
|
|
format!("http://{addr}")
|
|
}
|
|
|
|
async fn report(state: AppState) -> serde_json::Value {
|
|
let base = serve(state).await;
|
|
let response = reqwest::get(format!("{base}/api/health"))
|
|
.await
|
|
.expect("request health");
|
|
assert_eq!(response.status(), 200, "health always answers 200");
|
|
response.json().await.expect("health body is json")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn all_upstreams_up_is_ok() {
|
|
let (prowlarr, transmission) = upstreams_up().await;
|
|
let tmdb = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/configuration"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
|
|
.mount(&tmdb)
|
|
.await;
|
|
|
|
let state = AppState::new(
|
|
Upstreams::new(
|
|
prowlarr.uri(),
|
|
format!("{}/transmission/rpc", transmission.uri()),
|
|
)
|
|
.with_tmdb_url(tmdb.uri())
|
|
.with_tmdb_api_key(Some("key".into())),
|
|
)
|
|
.expect("state");
|
|
|
|
let body = report(state).await;
|
|
assert_eq!(body["status"], "ok");
|
|
assert_eq!(body["prowlarr"]["status"], "ok");
|
|
assert_eq!(body["transmission"]["status"], "ok");
|
|
assert_eq!(body["tmdb"]["status"], "ok");
|
|
assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_missing_tmdb_key_is_unconfigured_not_an_outage() {
|
|
let (prowlarr, transmission) = upstreams_up().await;
|
|
let state = AppState::new(Upstreams::new(
|
|
prowlarr.uri(),
|
|
format!("{}/transmission/rpc", transmission.uri()),
|
|
))
|
|
.expect("state");
|
|
|
|
let body = report(state).await;
|
|
assert_eq!(body["tmdb"]["status"], "unconfigured");
|
|
assert_eq!(body["status"], "degraded");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_unreachable_upstream_degrades_the_service() {
|
|
let (_prowlarr, transmission) = upstreams_up().await;
|
|
|
|
// Port 1 is privileged and nothing binds it, so the probe gets a
|
|
// refused connection immediately instead of waiting out the timeout.
|
|
let state = AppState::new(Upstreams::new(
|
|
"http://127.0.0.1:1".into(),
|
|
format!("{}/transmission/rpc", transmission.uri()),
|
|
))
|
|
.expect("state");
|
|
|
|
let body = report(state).await;
|
|
assert_eq!(body["status"], "degraded");
|
|
assert_eq!(body["prowlarr"]["status"], "unreachable");
|
|
assert_eq!(body["transmission"]["status"], "ok");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_upstream_answering_wrongly_is_unreachable() {
|
|
let prowlarr = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/ping"))
|
|
.respond_with(ResponseTemplate::new(500))
|
|
.mount(&prowlarr)
|
|
.await;
|
|
let transmission = MockServer::start().await;
|
|
|
|
let state = AppState::new(Upstreams::new(
|
|
prowlarr.uri(),
|
|
format!("{}/transmission/rpc", transmission.uri()),
|
|
))
|
|
.expect("state");
|
|
|
|
let body = report(state).await;
|
|
assert_eq!(body["prowlarr"]["status"], "unreachable");
|
|
assert_eq!(body["prowlarr"]["detail"], "http 500");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_failed_tmdb_probe_never_echoes_the_api_key() {
|
|
let (prowlarr, transmission) = upstreams_up().await;
|
|
let tmdb = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/configuration"))
|
|
.respond_with(ResponseTemplate::new(401))
|
|
.mount(&tmdb)
|
|
.await;
|
|
|
|
let state = AppState::new(
|
|
Upstreams::new(
|
|
prowlarr.uri(),
|
|
format!("{}/transmission/rpc", transmission.uri()),
|
|
)
|
|
.with_tmdb_url(tmdb.uri())
|
|
.with_tmdb_api_key(Some("super-secret".into())),
|
|
)
|
|
.expect("state");
|
|
|
|
let body = report(state).await;
|
|
assert_eq!(body["tmdb"]["status"], "unreachable");
|
|
assert!(
|
|
!body.to_string().contains("super-secret"),
|
|
"the key must not reach the response body: {body}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_document_is_generated_from_the_handler() {
|
|
let document = openapi();
|
|
let json = serde_json::to_value(&document).expect("serialise document");
|
|
|
|
assert!(
|
|
json["paths"]["/api/health"]["get"].is_object(),
|
|
"the health route registered itself: {json}"
|
|
);
|
|
assert_eq!(json["paths"]["/api/health"]["get"]["tags"][0], "system");
|
|
assert!(
|
|
json["components"]["schemas"]["HealthReport"].is_object(),
|
|
"the response body schema came along with it: {json}"
|
|
);
|
|
|
|
for (path, method) in [
|
|
("/api/movies", "get"),
|
|
("/api/movies", "post"),
|
|
("/api/movies/{movie_id}", "get"),
|
|
("/api/movies/{movie_id}", "patch"),
|
|
("/api/movies/{movie_id}", "delete"),
|
|
("/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",
|
|
),
|
|
(
|
|
"/api/series/{series_id}/seasons/{season_number}/releases",
|
|
"get",
|
|
),
|
|
(
|
|
"/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab",
|
|
"post",
|
|
),
|
|
(
|
|
"/api/series/{series_id}/seasons/{season_number}/files",
|
|
"delete",
|
|
),
|
|
("/api/episodes/{episode_id}/files", "delete"),
|
|
("/api/queues/attention", "get"),
|
|
("/api/trailer", "get"),
|
|
("/api/series", "get"),
|
|
("/api/policies", "get"),
|
|
("/api/policies", "post"),
|
|
("/api/policies/{policy_id}", "put"),
|
|
("/api/policies/{policy_id}", "delete"),
|
|
("/api/roots", "post"),
|
|
] {
|
|
assert!(
|
|
json["paths"][path][method].is_object(),
|
|
"missing {method} {path}"
|
|
);
|
|
}
|
|
for schema in [
|
|
"Movie",
|
|
"CreateMovie",
|
|
"UpdateMovie",
|
|
"Release",
|
|
"AttentionQueues",
|
|
"SeriesAttention",
|
|
"Series",
|
|
] {
|
|
assert!(
|
|
json["components"]["schemas"][schema].is_object(),
|
|
"{schema}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_document_and_the_ui_are_served() {
|
|
let state = AppState::new(Upstreams::new(
|
|
"http://127.0.0.1:1".into(),
|
|
"http://127.0.0.1:1".into(),
|
|
))
|
|
.expect("state");
|
|
let base = serve(state).await;
|
|
|
|
let document: serde_json::Value = reqwest::get(format!("{base}{OPENAPI_PATH}"))
|
|
.await
|
|
.expect("fetch document")
|
|
.json()
|
|
.await
|
|
.expect("document is json");
|
|
assert!(document["paths"]["/api/health"].is_object());
|
|
|
|
let docs = reqwest::get(format!("{base}{DOCS_PATH}"))
|
|
.await
|
|
.expect("fetch docs");
|
|
assert_eq!(docs.status(), 200);
|
|
}
|
|
}
|