Files
arr/crates/arr-api/src/lib.rs
T
Miguel Palhas d8797e1996 feat(daemon): refresh metadata on add
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>
2026-08-24 18:12:10 +01:00

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);
}
}