From 9fa1f0cc51b8083863dbe6c975c24159a23d674e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 14:56:51 +0100 Subject: [PATCH] feat(api): policy and root CRUD endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Policies and roots were editable only via SQL inside the container. Full CRUD over both, validated against the vocabulary the policy engine knows — unknown resolutions or sources answer 422 naming the field, malformed JSON answers 422, deleting a referenced policy or an occupied root answers 409. Closes #116 (API half). --- ...f54c4e4a343c4b56c4a44eb56d1a26c2aea4b.json | 12 + ...2ca5b86e68b7529b7ff1440015b3c2e3cd975.json | 12 + ...e6086f857f6c7efff660ae4a958a9f4ce033a.json | 20 + ...a03b642654d2d99a23b4319bd018e7389cdff.json | 50 ++ ...0c454f20b778878f13bba8e4ab261d43c7480.json | 12 + ...16583c0dd62e9a25e955866c3b8690489443e.json | 68 ++ ...a56981adbecbb4722305cdad7241f4f0d8fec.json | 20 + ...bd17ad32bc3611a5f9dc98944591d9a596f26.json | 12 + ...0b625b86d84662f9d74ca3b65f60ac1661d61.json | 12 + ...4db5fa3008b08e8e173b5ca21f51cca1dd1ee.json | 62 ++ ...40e9247723f3ce10ca95114bb2bee849957a0.json | 20 + ...5a1bd7dc57165d5353c2169210aafe2153a93.json | 12 + ...b315d3ce220f99dca9a727bb6e4b2eaa45edb.json | 20 + crates/arr-api/src/lib.rs | 17 +- crates/arr-api/src/movies.rs | 4 + crates/arr-api/src/policies.rs | 674 ++++++++++++++++++ crates/arr-api/src/roots.rs | 435 ++++++++++- 17 files changed, 1450 insertions(+), 12 deletions(-) create mode 100644 .sqlx/query-0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b.json create mode 100644 .sqlx/query-0ab0de146b1cc4689359d64b9bc2ca5b86e68b7529b7ff1440015b3c2e3cd975.json create mode 100644 .sqlx/query-0b17cf3d6cc89efacd65d11d15ee6086f857f6c7efff660ae4a958a9f4ce033a.json create mode 100644 .sqlx/query-115eae1f44b4eed8ea9e75666f1a03b642654d2d99a23b4319bd018e7389cdff.json create mode 100644 .sqlx/query-20ba3c269b7dab2f6cc624ffec40c454f20b778878f13bba8e4ab261d43c7480.json create mode 100644 .sqlx/query-3437ff6e5b0262e3a298d3883ef16583c0dd62e9a25e955866c3b8690489443e.json create mode 100644 .sqlx/query-4492267683b60d3104c3bf315cca56981adbecbb4722305cdad7241f4f0d8fec.json create mode 100644 .sqlx/query-5cc813fb311c8d582b97320b0bebd17ad32bc3611a5f9dc98944591d9a596f26.json create mode 100644 .sqlx/query-61a350e4122f566b91d26b7195a0b625b86d84662f9d74ca3b65f60ac1661d61.json create mode 100644 .sqlx/query-6b34f7c8d536d505c166f5b9b5a4db5fa3008b08e8e173b5ca21f51cca1dd1ee.json create mode 100644 .sqlx/query-cbaa326819ded9bf6e74654b4ef40e9247723f3ce10ca95114bb2bee849957a0.json create mode 100644 .sqlx/query-ec2c22c78df99b937819263742c5a1bd7dc57165d5353c2169210aafe2153a93.json create mode 100644 .sqlx/query-fd07d4f50079cc11154a6b1a605b315d3ce220f99dca9a727bb6e4b2eaa45edb.json create mode 100644 crates/arr-api/src/policies.rs diff --git a/.sqlx/query-0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b.json b/.sqlx/query-0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b.json new file mode 100644 index 0000000..dd8746a --- /dev/null +++ b/.sqlx/query-0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b" +} diff --git a/.sqlx/query-0ab0de146b1cc4689359d64b9bc2ca5b86e68b7529b7ff1440015b3c2e3cd975.json b/.sqlx/query-0ab0de146b1cc4689359d64b9bc2ca5b86e68b7529b7ff1440015b3c2e3cd975.json new file mode 100644 index 0000000..212d8ea --- /dev/null +++ b/.sqlx/query-0ab0de146b1cc4689359d64b9bc2ca5b86e68b7529b7ff1440015b3c2e3cd975.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO roots (kind, audience, path, policy_id) VALUES (?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "0ab0de146b1cc4689359d64b9bc2ca5b86e68b7529b7ff1440015b3c2e3cd975" +} diff --git a/.sqlx/query-0b17cf3d6cc89efacd65d11d15ee6086f857f6c7efff660ae4a958a9f4ce033a.json b/.sqlx/query-0b17cf3d6cc89efacd65d11d15ee6086f857f6c7efff660ae4a958a9f4ce033a.json new file mode 100644 index 0000000..ec29074 --- /dev/null +++ b/.sqlx/query-0b17cf3d6cc89efacd65d11d15ee6086f857f6c7efff660ae4a958a9f4ce033a.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT count(*) AS \"count!: i64\" FROM roots WHERE policy_id = ?", + "describe": { + "columns": [ + { + "name": "count!: i64", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "0b17cf3d6cc89efacd65d11d15ee6086f857f6c7efff660ae4a958a9f4ce033a" +} diff --git a/.sqlx/query-115eae1f44b4eed8ea9e75666f1a03b642654d2d99a23b4319bd018e7389cdff.json b/.sqlx/query-115eae1f44b4eed8ea9e75666f1a03b642654d2d99a23b4319bd018e7389cdff.json new file mode 100644 index 0000000..dba0c19 --- /dev/null +++ b/.sqlx/query-115eae1f44b4eed8ea9e75666f1a03b642654d2d99a23b4319bd018e7389cdff.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "SELECT r.id AS \"id!: i64\",\n r.kind AS \"kind!: String\",\n r.audience AS \"audience!: String\",\n r.path AS \"path!: String\",\n p.id AS \"policy_id!: i64\",\n p.name AS \"policy_name!: String\"\n FROM roots r JOIN policies p ON p.id = r.policy_id\n WHERE r.id = ?", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "kind!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "audience!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "path!: String", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "policy_id!: i64", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "policy_name!: String", + "ordinal": 5, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "115eae1f44b4eed8ea9e75666f1a03b642654d2d99a23b4319bd018e7389cdff" +} diff --git a/.sqlx/query-20ba3c269b7dab2f6cc624ffec40c454f20b778878f13bba8e4ab261d43c7480.json b/.sqlx/query-20ba3c269b7dab2f6cc624ffec40c454f20b778878f13bba8e4ab261d43c7480.json new file mode 100644 index 0000000..373372c --- /dev/null +++ b/.sqlx/query-20ba3c269b7dab2f6cc624ffec40c454f20b778878f13bba8e4ab261d43c7480.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO policies (\n name, required_audio, dub_blacklist, hdr_rules,\n size_bands, resolution_pref, source_weights, score_weights\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 8 + }, + "nullable": [] + }, + "hash": "20ba3c269b7dab2f6cc624ffec40c454f20b778878f13bba8e4ab261d43c7480" +} diff --git a/.sqlx/query-3437ff6e5b0262e3a298d3883ef16583c0dd62e9a25e955866c3b8690489443e.json b/.sqlx/query-3437ff6e5b0262e3a298d3883ef16583c0dd62e9a25e955866c3b8690489443e.json new file mode 100644 index 0000000..867379a --- /dev/null +++ b/.sqlx/query-3437ff6e5b0262e3a298d3883ef16583c0dd62e9a25e955866c3b8690489443e.json @@ -0,0 +1,68 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: i64\",\n name AS \"name!: String\",\n required_audio AS \"required_audio!: String\",\n dub_blacklist AS \"dub_blacklist!: String\",\n hdr_rules AS \"hdr_rules!: String\",\n size_bands AS \"size_bands!: String\",\n resolution_pref AS \"resolution_pref!: String\",\n source_weights AS \"source_weights!: String\",\n score_weights AS \"score_weights!: String\"\n FROM policies ORDER BY id", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "required_audio!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "dub_blacklist!: String", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "hdr_rules!: String", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "size_bands!: String", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "resolution_pref!: String", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "source_weights!: String", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "score_weights!: String", + "ordinal": 8, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "3437ff6e5b0262e3a298d3883ef16583c0dd62e9a25e955866c3b8690489443e" +} diff --git a/.sqlx/query-4492267683b60d3104c3bf315cca56981adbecbb4722305cdad7241f4f0d8fec.json b/.sqlx/query-4492267683b60d3104c3bf315cca56981adbecbb4722305cdad7241f4f0d8fec.json new file mode 100644 index 0000000..dce9b1e --- /dev/null +++ b/.sqlx/query-4492267683b60d3104c3bf315cca56981adbecbb4722305cdad7241f4f0d8fec.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT count(*) AS \"count!: i64\" FROM movies WHERE root_id = ?", + "describe": { + "columns": [ + { + "name": "count!: i64", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "4492267683b60d3104c3bf315cca56981adbecbb4722305cdad7241f4f0d8fec" +} diff --git a/.sqlx/query-5cc813fb311c8d582b97320b0bebd17ad32bc3611a5f9dc98944591d9a596f26.json b/.sqlx/query-5cc813fb311c8d582b97320b0bebd17ad32bc3611a5f9dc98944591d9a596f26.json new file mode 100644 index 0000000..485ff23 --- /dev/null +++ b/.sqlx/query-5cc813fb311c8d582b97320b0bebd17ad32bc3611a5f9dc98944591d9a596f26.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM policies WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "5cc813fb311c8d582b97320b0bebd17ad32bc3611a5f9dc98944591d9a596f26" +} diff --git a/.sqlx/query-61a350e4122f566b91d26b7195a0b625b86d84662f9d74ca3b65f60ac1661d61.json b/.sqlx/query-61a350e4122f566b91d26b7195a0b625b86d84662f9d74ca3b65f60ac1661d61.json new file mode 100644 index 0000000..5a517fe --- /dev/null +++ b/.sqlx/query-61a350e4122f566b91d26b7195a0b625b86d84662f9d74ca3b65f60ac1661d61.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE policies SET\n name = ?, required_audio = ?, dub_blacklist = ?, hdr_rules = ?,\n size_bands = ?, resolution_pref = ?, source_weights = ?,\n score_weights = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 9 + }, + "nullable": [] + }, + "hash": "61a350e4122f566b91d26b7195a0b625b86d84662f9d74ca3b65f60ac1661d61" +} diff --git a/.sqlx/query-6b34f7c8d536d505c166f5b9b5a4db5fa3008b08e8e173b5ca21f51cca1dd1ee.json b/.sqlx/query-6b34f7c8d536d505c166f5b9b5a4db5fa3008b08e8e173b5ca21f51cca1dd1ee.json new file mode 100644 index 0000000..692277d --- /dev/null +++ b/.sqlx/query-6b34f7c8d536d505c166f5b9b5a4db5fa3008b08e8e173b5ca21f51cca1dd1ee.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT name AS \"name!: String\",\n required_audio AS \"required_audio!: String\",\n dub_blacklist AS \"dub_blacklist!: String\",\n hdr_rules AS \"hdr_rules!: String\",\n size_bands AS \"size_bands!: String\",\n resolution_pref AS \"resolution_pref!: String\",\n source_weights AS \"source_weights!: String\",\n score_weights AS \"score_weights!: String\"\n FROM policies WHERE id = ?", + "describe": { + "columns": [ + { + "name": "name!: String", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "required_audio!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "dub_blacklist!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "hdr_rules!: String", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "size_bands!: String", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "resolution_pref!: String", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "source_weights!: String", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "score_weights!: String", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "6b34f7c8d536d505c166f5b9b5a4db5fa3008b08e8e173b5ca21f51cca1dd1ee" +} diff --git a/.sqlx/query-cbaa326819ded9bf6e74654b4ef40e9247723f3ce10ca95114bb2bee849957a0.json b/.sqlx/query-cbaa326819ded9bf6e74654b4ef40e9247723f3ce10ca95114bb2bee849957a0.json new file mode 100644 index 0000000..c906e94 --- /dev/null +++ b/.sqlx/query-cbaa326819ded9bf6e74654b4ef40e9247723f3ce10ca95114bb2bee849957a0.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT count(*) AS \"count!: i64\" FROM series WHERE root_id = ?", + "describe": { + "columns": [ + { + "name": "count!: i64", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "cbaa326819ded9bf6e74654b4ef40e9247723f3ce10ca95114bb2bee849957a0" +} diff --git a/.sqlx/query-ec2c22c78df99b937819263742c5a1bd7dc57165d5353c2169210aafe2153a93.json b/.sqlx/query-ec2c22c78df99b937819263742c5a1bd7dc57165d5353c2169210aafe2153a93.json new file mode 100644 index 0000000..2531658 --- /dev/null +++ b/.sqlx/query-ec2c22c78df99b937819263742c5a1bd7dc57165d5353c2169210aafe2153a93.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM roots WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "ec2c22c78df99b937819263742c5a1bd7dc57165d5353c2169210aafe2153a93" +} diff --git a/.sqlx/query-fd07d4f50079cc11154a6b1a605b315d3ce220f99dca9a727bb6e4b2eaa45edb.json b/.sqlx/query-fd07d4f50079cc11154a6b1a605b315d3ce220f99dca9a727bb6e4b2eaa45edb.json new file mode 100644 index 0000000..9d4cbc0 --- /dev/null +++ b/.sqlx/query-fd07d4f50079cc11154a6b1a605b315d3ce220f99dca9a727bb6e4b2eaa45edb.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT id FROM policies WHERE id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "fd07d4f50079cc11154a6b1a605b315d3ce220f99dca9a727bb6e4b2eaa45edb" +} diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index 8a5bc90..8262e69 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -9,6 +9,7 @@ mod health; mod movies; mod owners; +mod policies; mod roots; mod search; mod series; @@ -26,7 +27,10 @@ pub use movies::{ Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, MovieFile, Release, UpdateMovie, }; pub use owners::{CreateOwner, Owner, UpdateOwner}; -pub use roots::Root; +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, @@ -55,6 +59,7 @@ pub const DOCS_PATH: &str = "/api/docs"; (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") ), @@ -88,7 +93,10 @@ fn api_router() -> OpenApiRouter { .routes(routes!(owners::get, owners::update, owners::delete)) .routes(routes!(search::search)) .routes(routes!(search::releases)) - .routes(routes!(roots::list)) + .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. @@ -301,6 +309,11 @@ mod tests { ("/api/movies/{movie_id}/releases/{release_id}/grab", "post"), ("/api/queues/attention", "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(), diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 6ba56d0..3770ac0 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -112,6 +112,8 @@ pub enum ApiError { SeasonNotFound, EpisodeNotFound, OwnerNotFound, + PolicyNotFound, + RootNotFound, Conflict(String), Invalid(String), Unavailable, @@ -130,6 +132,8 @@ impl IntoResponse for ApiError { Self::SeasonNotFound => (StatusCode::NOT_FOUND, "season not found".to_string()), Self::EpisodeNotFound => (StatusCode::NOT_FOUND, "episode not found".to_string()), Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string()), + Self::PolicyNotFound => (StatusCode::NOT_FOUND, "policy not found".to_string()), + Self::RootNotFound => (StatusCode::NOT_FOUND, "root not found".to_string()), Self::Conflict(error) => (StatusCode::CONFLICT, error), Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error), Self::Unavailable => ( diff --git a/crates/arr-api/src/policies.rs b/crates/arr-api/src/policies.rs new file mode 100644 index 0000000..552d0b8 --- /dev/null +++ b/crates/arr-api/src/policies.rs @@ -0,0 +1,674 @@ +//! Policy CRUD (`DESIGN.md` §5.1, §10). Policies are DB rows tuned by hand; +//! this surface replaces editing SQL inside the container. Payloads mirror +//! the JSON columns the migrations store, validated against the shapes +//! `arr-core` evaluates — an unknown resolution or a malformed field is a +//! 422 naming the field, never a 500. + +use std::collections::BTreeMap; + +use axum::extract::rejection::JsonRejection; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::Json; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::movies::{pool, ApiError, ErrorBody}; +use crate::state::AppState; + +/// Which audio track a release must carry (`DESIGN.md` §5.2), expressed +/// against the title's original language rather than a fixed list. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(tag = "require", rename_all = "snake_case")] +pub enum RequiredAudioSpec { + OriginalLanguage, + AnyOf { langs: Vec }, +} + +/// Dolby Vision profiles rejected post-probe (`DESIGN.md` §5.3). Profile +/// numbers stay strings to match the stored column. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct HdrRulesSpec { + #[serde(default)] + pub dv_profile_reject: Vec, +} + +/// One resolution's size band (`DESIGN.md` §5.5), sizes in gibibytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct SizeBandSpec { + pub floor_gib: u64, + pub target_gib: u64, + pub penalty_points_per_gib_over: i32, +} + +/// The scoring weights (`DESIGN.md` §5.5). `resolution_step` defaults to the +/// engine's own value when absent, matching how `arr-db` reads older rows. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct ScoreWeightsSpec { + pub size_at_target: i32, + pub source_tier: i32, + pub seeder_doubling: i32, + #[serde(default = "default_resolution_step")] + pub resolution_step: i32, +} + +fn default_resolution_step() -> i32 { + arr_core::ScoreWeights::default().resolution_step +} + +/// A full policy document — one row of the `policies` table. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)] +pub struct Policy { + pub id: i64, + pub name: String, + pub required_audio: RequiredAudioSpec, + pub dub_blacklist: Vec, + pub hdr_rules: HdrRulesSpec, + pub size_bands: BTreeMap, + pub resolution_pref: Vec, + pub source_weights: BTreeMap, + pub score_weights: ScoreWeightsSpec, +} + +/// The payload for creating or replacing a policy. Same shape as [`Policy`] +/// minus the id. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct PolicyInput { + pub name: String, + pub required_audio: RequiredAudioSpec, + #[serde(default)] + pub dub_blacklist: Vec, + #[serde(default)] + pub hdr_rules: HdrRulesSpec, + #[serde(default)] + pub size_bands: BTreeMap, + #[serde(default)] + pub resolution_pref: Vec, + #[serde(default)] + pub source_weights: BTreeMap, + pub score_weights: ScoreWeightsSpec, +} + +impl PolicyInput { + /// Validate against the vocabulary the policy engine knows. Every + /// failure names its field so a rejected edit is fixable without + /// reading the schema. + fn validate(&self) -> Result<(), String> { + if self.name.trim().is_empty() { + return Err("name: must not be empty".into()); + } + if let RequiredAudioSpec::AnyOf { langs } = &self.required_audio { + if langs.is_empty() { + return Err("required_audio.langs: any_of needs at least one language".into()); + } + if langs.iter().any(String::is_empty) { + return Err("required_audio.langs: language tags must not be empty".into()); + } + } + for lang in &self.dub_blacklist { + if lang.is_empty() { + return Err("dub_blacklist: language tags must not be empty".into()); + } + } + for profile in &self.hdr_rules.dv_profile_reject { + if profile.parse::().is_err() { + return Err(format!( + "hdr_rules.dv_profile_reject: '{profile}' is not a profile number" + )); + } + } + for resolution in self.size_bands.keys() { + if arr_db::policy::resolution_value(resolution).is_none() { + return Err(format!("size_bands: unknown resolution '{resolution}'")); + } + } + let mut seen = std::collections::BTreeSet::new(); + for resolution in &self.resolution_pref { + if arr_db::policy::resolution_value(resolution).is_none() { + return Err(format!( + "resolution_pref: unknown resolution '{resolution}'" + )); + } + if !seen.insert(resolution.as_str()) { + return Err(format!("resolution_pref: '{resolution}' appears twice")); + } + } + for source in self.source_weights.keys() { + if arr_db::policy::source_value(source).is_none() { + return Err(format!("source_weights: unknown source tier '{source}'")); + } + } + Ok(()) + } + + fn to_row(self, name: String) -> Result { + fn json(value: impl serde::Serialize) -> Result { + serde_json::to_string(&value).map_err(|error| { + tracing::error!(%error, "policy serialisation failed"); + ApiError::Database("serialisation failed".into()) + }) + } + Ok(PolicyColumns { + name, + required_audio: json(&self.required_audio)?, + dub_blacklist: json(&self.dub_blacklist)?, + hdr_rules: json(&self.hdr_rules)?, + size_bands: json(&self.size_bands)?, + resolution_pref: json(&self.resolution_pref)?, + source_weights: json(&self.source_weights)?, + score_weights: json(&self.score_weights)?, + }) + } +} + +/// A policy row as the table stores it, before JSON parsing. +struct PolicyColumns { + name: String, + required_audio: String, + dub_blacklist: String, + hdr_rules: String, + size_bands: String, + resolution_pref: String, + source_weights: String, + score_weights: String, +} + +fn column( + column: &'static str, + value: &str, +) -> Result { + serde_json::from_str(value).map_err(|error| { + tracing::error!(column, %error, "policy column holds unexpected JSON"); + ApiError::Database(format!("policy column {column} is not valid")) + }) +} + +impl PolicyColumns { + fn into_policy(self, id: i64) -> Result { + Ok(Policy { + id, + name: self.name, + required_audio: column("required_audio", &self.required_audio)?, + dub_blacklist: column("dub_blacklist", &self.dub_blacklist)?, + hdr_rules: column("hdr_rules", &self.hdr_rules)?, + size_bands: column("size_bands", &self.size_bands)?, + resolution_pref: column("resolution_pref", &self.resolution_pref)?, + source_weights: column("source_weights", &self.source_weights)?, + score_weights: column("score_weights", &self.score_weights)?, + }) + } +} + +/// The extracted body, whether or not it parsed. Both malformed JSON and a +/// shape mismatch answer 422 with the parser's own field-naming message. +pub(crate) fn parsed(payload: Result, JsonRejection>) -> Result { + payload + .map(|Json(value)| value) + .map_err(|rejection| ApiError::Invalid(rejection.body_text())) +} + +fn is_unique_violation(error: &sqlx::Error) -> bool { + error + .as_database_error() + .is_some_and(sqlx::error::DatabaseError::is_unique_violation) +} + +async fn load_policy(state: &AppState, id: i64) -> Result { + let row = sqlx::query_as!( + PolicyColumns, + r#"SELECT name AS "name!: String", + required_audio AS "required_audio!: String", + dub_blacklist AS "dub_blacklist!: String", + hdr_rules AS "hdr_rules!: String", + size_bands AS "size_bands!: String", + resolution_pref AS "resolution_pref!: String", + source_weights AS "source_weights!: String", + score_weights AS "score_weights!: String" + FROM policies WHERE id = ?"#, + id + ) + .fetch_optional(pool(state)?) + .await? + .ok_or(ApiError::PolicyNotFound)?; + row.into_policy(id) +} + +#[utoipa::path( + get, path = "/api/policies", tag = "policies", + responses( + (status = 200, body = [Policy]), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn list(State(state): State) -> Result>, ApiError> { + let rows = sqlx::query_as!( + PolicyRow, + r#"SELECT id AS "id!: i64", + name AS "name!: String", + required_audio AS "required_audio!: String", + dub_blacklist AS "dub_blacklist!: String", + hdr_rules AS "hdr_rules!: String", + size_bands AS "size_bands!: String", + resolution_pref AS "resolution_pref!: String", + source_weights AS "source_weights!: String", + score_weights AS "score_weights!: String" + FROM policies ORDER BY id"# + ) + .fetch_all(pool(&state)?) + .await?; + let mut policies = Vec::with_capacity(rows.len()); + for row in rows { + policies.push(row.into_policy()?); + } + Ok(Json(policies)) +} + +#[derive(Debug, sqlx::FromRow)] +struct PolicyRow { + id: i64, + name: String, + required_audio: String, + dub_blacklist: String, + hdr_rules: String, + size_bands: String, + resolution_pref: String, + source_weights: String, + score_weights: String, +} + +impl PolicyRow { + fn into_policy(self) -> Result { + PolicyColumns { + name: self.name, + required_audio: self.required_audio, + dub_blacklist: self.dub_blacklist, + hdr_rules: self.hdr_rules, + size_bands: self.size_bands, + resolution_pref: self.resolution_pref, + source_weights: self.source_weights, + score_weights: self.score_weights, + } + .into_policy(self.id) + } +} + +#[utoipa::path( + get, path = "/api/policies/{policy_id}", tag = "policies", + params(("policy_id" = i64, Path, description = "Policy row id")), + responses( + (status = 200, body = Policy), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn get( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + Ok(Json(load_policy(&state, id).await?)) +} + +#[utoipa::path( + post, path = "/api/policies", tag = "policies", request_body = PolicyInput, + responses( + (status = 201, body = Policy), + (status = 409, body = ErrorBody), + (status = 422, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn create( + State(state): State, + body: Result, JsonRejection>, +) -> Result<(StatusCode, Json), ApiError> { + let input = parsed(body)?; + input.validate().map_err(ApiError::Invalid)?; + let name = input.name.trim().to_owned(); + let columns = input.to_row(name)?; + let result = sqlx::query!( + r#"INSERT INTO policies ( + name, required_audio, dub_blacklist, hdr_rules, + size_bands, resolution_pref, source_weights, score_weights + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"#, + columns.name, + columns.required_audio, + columns.dub_blacklist, + columns.hdr_rules, + columns.size_bands, + columns.resolution_pref, + columns.source_weights, + columns.score_weights, + ) + .execute(pool(&state)?) + .await + .map_err(|error| { + if is_unique_violation(&error) { + ApiError::Conflict("a policy with this name already exists".into()) + } else { + ApiError::from(error) + } + })?; + let policy = load_policy(&state, result.last_insert_rowid()).await?; + Ok((StatusCode::CREATED, Json(policy))) +} + +#[utoipa::path( + put, path = "/api/policies/{policy_id}", tag = "policies", request_body = PolicyInput, + params(("policy_id" = i64, Path, description = "Policy row id")), + responses( + (status = 200, body = Policy), + (status = 404, body = ErrorBody), + (status = 409, body = ErrorBody), + (status = 422, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn update( + State(state): State, + Path(id): Path, + body: Result, JsonRejection>, +) -> Result, ApiError> { + let input = parsed(body)?; + input.validate().map_err(ApiError::Invalid)?; + let name = input.name.trim().to_owned(); + let columns = input.to_row(name)?; + let result = sqlx::query!( + r#"UPDATE policies SET + name = ?, required_audio = ?, dub_blacklist = ?, hdr_rules = ?, + size_bands = ?, resolution_pref = ?, source_weights = ?, + score_weights = ?, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = ?"#, + columns.name, + columns.required_audio, + columns.dub_blacklist, + columns.hdr_rules, + columns.size_bands, + columns.resolution_pref, + columns.source_weights, + columns.score_weights, + id, + ) + .execute(pool(&state)?) + .await + .map_err(|error| { + if is_unique_violation(&error) { + ApiError::Conflict("a policy with this name already exists".into()) + } else { + ApiError::from(error) + } + })?; + if result.rows_affected() == 0 { + return Err(ApiError::PolicyNotFound); + } + Ok(Json(load_policy(&state, id).await?)) +} + +#[utoipa::path( + delete, path = "/api/policies/{policy_id}", tag = "policies", + params(("policy_id" = i64, Path, description = "Policy row id")), + responses( + (status = 204), + (status = 404, body = ErrorBody), + (status = 409, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn delete( + State(state): State, + Path(id): Path, +) -> Result { + let references: i64 = sqlx::query_scalar!( + r#"SELECT count(*) AS "count!: i64" FROM roots WHERE policy_id = ?"#, + id + ) + .fetch_one(pool(&state)?) + .await?; + if references > 0 { + return Err(ApiError::Conflict(format!( + "this policy is attached to {references} root{} — detach it first", + if references == 1 { "" } else { "s" } + ))); + } + let result = sqlx::query!("DELETE FROM policies WHERE id = ?", id) + .execute(pool(&state)?) + .await?; + if result.rows_affected() == 0 { + return Err(ApiError::PolicyNotFound); + } + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{router, Upstreams}; + use axum::http::StatusCode; + + async fn application() -> (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(), + )) + .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"); + let app = router(state); + tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") }); + (dir, format!("http://{address}")) + } + + fn valid_input(name: &str) -> serde_json::Value { + serde_json::json!({ + "name": name, + "required_audio": { "require": "any_of", "langs": ["pt-PT"] }, + "dub_blacklist": ["pt-BR"], + "hdr_rules": { "dv_profile_reject": ["5", "7"] }, + "size_bands": { + "2160p": { "floor_gib": 8, "target_gib": 22, "penalty_points_per_gib_over": 60 }, + "1080p": { "floor_gib": 3, "target_gib": 8, "penalty_points_per_gib_over": 60 } + }, + "resolution_pref": ["2160p", "1080p"], + "source_weights": { "WEB-DL": 4, "Remux": 1 }, + "score_weights": { + "size_at_target": 1000, "source_tier": 25, + "seeder_doubling": 8, "resolution_step": 300 + } + }) + } + + async fn create(base: &str, payload: serde_json::Value) -> reqwest::Response { + reqwest::Client::new() + .post(format!("{base}/api/policies")) + .json(&payload) + .send() + .await + .expect("create policy") + } + + #[tokio::test] + async fn crud_round_trips_a_policy() { + let (_dir, base) = application().await; + + let created: serde_json::Value = create(&base, valid_input("test policy")) + .await + .json() + .await + .expect("created json"); + assert_eq!(created["name"], "test policy"); + assert_eq!(created["required_audio"]["require"], "any_of"); + assert_eq!(created["score_weights"]["resolution_step"], 300); + let id = created["id"].as_i64().expect("id"); + + let listed: Vec = reqwest::get(format!("{base}/api/policies")) + .await + .expect("list") + .json() + .await + .expect("list json"); + assert_eq!(listed.len(), 5, "four seeded policies plus the new one"); + + let fetched: serde_json::Value = reqwest::get(format!("{base}/api/policies/{id}")) + .await + .expect("get") + .json() + .await + .expect("get json"); + assert_eq!(fetched, created); + + let mut replacement = valid_input("renamed"); + replacement["resolution_pref"] = serde_json::json!(["1080p"]); + let updated: serde_json::Value = reqwest::Client::new() + .put(format!("{base}/api/policies/{id}")) + .json(&replacement) + .send() + .await + .expect("update") + .json() + .await + .expect("updated json"); + assert_eq!(updated["name"], "renamed"); + assert_eq!(updated["resolution_pref"], serde_json::json!(["1080p"])); + + let deleted = reqwest::Client::new() + .delete(format!("{base}/api/policies/{id}")) + .send() + .await + .expect("delete"); + assert_eq!(deleted.status(), StatusCode::NO_CONTENT); + let gone = reqwest::get(format!("{base}/api/policies/{id}")) + .await + .expect("get deleted"); + assert_eq!(gone.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn a_referenced_policy_refuses_to_die() { + let (_dir, base) = application().await; + let roots: Vec = reqwest::get(format!("{base}/api/roots")) + .await + .expect("roots") + .json() + .await + .expect("roots json"); + let policy_id = roots[0]["policy_id"].as_i64().expect("policy id"); + + let response = reqwest::Client::new() + .delete(format!("{base}/api/policies/{policy_id}")) + .send() + .await + .expect("delete referenced policy"); + + assert_eq!(response.status(), StatusCode::CONFLICT); + let body: serde_json::Value = response.json().await.expect("error body"); + assert!( + body["error"].as_str().expect("error text").contains("root"), + "the error names the problem: {body}" + ); + } + + #[tokio::test] + async fn an_unknown_resolution_is_a_422_naming_the_field() { + let (_dir, base) = application().await; + let mut payload = valid_input("bad bands"); + payload["size_bands"]["1440p"] = serde_json::json!({ "floor_gib": 2, "target_gib": 6, "penalty_points_per_gib_over": 60 }); + + let response = create(&base, payload).await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + let body: serde_json::Value = response.json().await.expect("error body"); + let error = body["error"].as_str().expect("error text"); + assert!(error.contains("size_bands"), "{error}"); + assert!(error.contains("1440p"), "{error}"); + } + + #[tokio::test] + async fn every_field_validates_by_name() { + let (_dir, base) = application().await; + let with = |patch: &dyn Fn(&mut serde_json::Value)| { + let mut payload = valid_input("validation probe"); + patch(&mut payload); + payload + }; + let cases: Vec<(serde_json::Value, &str)> = vec![ + ( + with(&|payload| payload["name"] = serde_json::json!("")), + "name", + ), + ( + with(&|payload| { + payload["required_audio"] = + serde_json::json!({ "require": "any_of", "langs": [] }); + }), + "required_audio", + ), + ( + with(&|payload| { + payload["hdr_rules"] = serde_json::json!({ "dv_profile_reject": ["nine"] }); + }), + "dv_profile_reject", + ), + ( + with(&|payload| { + payload["resolution_pref"] = serde_json::json!(["2160p", "2160p"]); + }), + "resolution_pref", + ), + ( + with(&|payload| { + payload["source_weights"] = serde_json::json!({ "LaserDisc": 3 }); + }), + "source_weights", + ), + ]; + + for (payload, field) in cases { + let response = create(&base, payload).await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + let body: serde_json::Value = response.json().await.expect("error body"); + let error = body["error"].as_str().expect("error text"); + assert!(error.contains(field), "{field}: {error}"); + } + } + + #[tokio::test] + async fn malformed_json_is_422_not_400_or_500() { + let (_dir, base) = application().await; + let client = reqwest::Client::new(); + let response = client + .post(format!("{base}/api/policies")) + .header("content-type", "application/json") + .body("{not json") + .send() + .await + .expect("malformed create"); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let response = client + .post(format!("{base}/api/policies")) + .json(&serde_json::json!({ "name": 42 })) + .send() + .await + .expect("wrong-shape create"); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + } + + #[tokio::test] + async fn a_duplicate_name_conflicts() { + let (_dir, base) = application().await; + let response = create(&base, valid_input("Movies — main")).await; + assert_eq!(response.status(), StatusCode::CONFLICT); + } +} diff --git a/crates/arr-api/src/roots.rs b/crates/arr-api/src/roots.rs index e0ab5f0..d193843 100644 --- a/crates/arr-api/src/roots.rs +++ b/crates/arr-api/src/roots.rs @@ -1,9 +1,15 @@ -use axum::extract::State; +//! Root folders (`DESIGN.md` §5.1): kind, audience, path and the one policy +//! attached. Reads serve the add flow (§9.2); writes are the settings view's. + +use axum::extract::rejection::JsonRejection; +use axum::extract::{Path, State}; +use axum::http::StatusCode; use axum::Json; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use crate::movies::{ApiError, ErrorBody}; +use crate::movies::{pool, ApiError, ErrorBody}; +use crate::policies::parsed; use crate::state::AppState; /// A root folder joined with its policy, enough for the add flow to pre-fill @@ -18,6 +24,67 @@ pub struct Root { pub policy_name: String, } +/// The payload for creating or replacing a root. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct RootInput { + /// `movie` or `tv` — the Transmission label and layout prefix (§7.1, §7.4). + pub kind: String, + /// `main` or `kids`. + pub audience: String, + pub path: String, + pub policy_id: i64, +} + +impl RootInput { + fn validate(&self) -> Result<(), String> { + if self.kind != "movie" && self.kind != "tv" { + return Err(format!("kind: '{}' is neither movie nor tv", self.kind)); + } + if self.audience != "main" && self.audience != "kids" { + return Err(format!( + "audience: '{}' is neither main nor kids", + self.audience + )); + } + if self.path.trim().is_empty() { + return Err("path: must not be empty".into()); + } + Ok(()) + } + + async fn policy_exists(&self, state: &AppState) -> Result<(), ApiError> { + let exists: Option = + sqlx::query_scalar!("SELECT id FROM policies WHERE id = ?", self.policy_id) + .fetch_optional(pool(state)?) + .await?; + if exists.is_none() { + return Err(ApiError::Invalid(format!( + "policy_id: no policy {}", + self.policy_id + ))); + } + Ok(()) + } +} + +async fn load_root(state: &AppState, id: i64) -> Result { + sqlx::query_as!( + Root, + r#"SELECT r.id AS "id!: i64", + r.kind AS "kind!: String", + r.audience AS "audience!: String", + r.path AS "path!: String", + p.id AS "policy_id!: i64", + p.name AS "policy_name!: String" + FROM roots r JOIN policies p ON p.id = r.policy_id + WHERE r.id = ?"#, + id + ) + .fetch_optional(pool(state)?) + .await? + .ok_or(ApiError::RootNotFound) +} + #[utoipa::path( get, path = "/api/roots", tag = "roots", responses( @@ -27,23 +94,171 @@ pub struct Root { ) )] pub async fn list(State(state): State) -> Result>, ApiError> { - let database = state.database().ok_or(ApiError::Unavailable)?; let roots = sqlx::query_as!( Root, r#"SELECT r.id AS "id!: i64", r.kind AS "kind!: String", r.audience AS "audience!: String", r.path AS "path!: String", p.id AS "policy_id!: i64", p.name AS "policy_name!: String" FROM roots r JOIN policies p ON p.id = r.policy_id ORDER BY r.id"# ) - .fetch_all(database.pool()) + .fetch_all(pool(&state)?) .await?; Ok(Json(roots)) } +#[utoipa::path( + get, path = "/api/roots/{root_id}", tag = "roots", + params(("root_id" = i64, Path, description = "Root row id")), + responses( + (status = 200, body = Root), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn get( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + Ok(Json(load_root(&state, id).await?)) +} + +#[utoipa::path( + post, path = "/api/roots", tag = "roots", request_body = RootInput, + responses( + (status = 201, body = Root), + (status = 409, body = ErrorBody), + (status = 422, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn create( + State(state): State, + body: Result, JsonRejection>, +) -> Result<(StatusCode, Json), ApiError> { + let input = parsed(body)?; + input.validate().map_err(ApiError::Invalid)?; + input.policy_exists(&state).await?; + let path = input.path.trim().to_owned(); + let result = sqlx::query!( + "INSERT INTO roots (kind, audience, path, policy_id) VALUES (?, ?, ?, ?)", + input.kind, + input.audience, + path, + input.policy_id, + ) + .execute(pool(&state)?) + .await + .map_err(root_conflict)?; + let root = load_root(&state, result.last_insert_rowid()).await?; + Ok((StatusCode::CREATED, Json(root))) +} + +#[utoipa::path( + put, path = "/api/roots/{root_id}", tag = "roots", request_body = RootInput, + params(("root_id" = i64, Path, description = "Root row id")), + responses( + (status = 200, body = Root), + (status = 404, body = ErrorBody), + (status = 409, body = ErrorBody), + (status = 422, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn update( + State(state): State, + Path(id): Path, + body: Result, JsonRejection>, +) -> Result, ApiError> { + let input = parsed(body)?; + input.validate().map_err(ApiError::Invalid)?; + input.policy_exists(&state).await?; + let path = input.path.trim().to_owned(); + let result = sqlx::query!( + r#"UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = ?"#, + input.kind, + input.audience, + path, + input.policy_id, + id, + ) + .execute(pool(&state)?) + .await + .map_err(root_conflict)?; + if result.rows_affected() == 0 { + return Err(ApiError::RootNotFound); + } + Ok(Json(load_root(&state, id).await?)) +} + +/// A duplicate path or a duplicate (kind, audience) pair is a settings +/// mistake the operator can fix, not a server fault. +fn root_conflict(error: sqlx::Error) -> ApiError { + if error + .as_database_error() + .is_some_and(sqlx::error::DatabaseError::is_unique_violation) + { + return ApiError::Conflict( + "a root with this path or this kind and audience already exists".into(), + ); + } + ApiError::from(error) +} + +#[utoipa::path( + delete, path = "/api/roots/{root_id}", tag = "roots", + params(("root_id" = i64, Path, description = "Root row id")), + responses( + (status = 204), + (status = 404, body = ErrorBody), + (status = 409, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn delete( + State(state): State, + Path(id): Path, +) -> Result { + let movies: i64 = sqlx::query_scalar!( + r#"SELECT count(*) AS "count!: i64" FROM movies WHERE root_id = ?"#, + id + ) + .fetch_one(pool(&state)?) + .await?; + if movies > 0 { + return Err(ApiError::Conflict(format!( + "this root still owns {movies} movie{} — move them first", + if movies == 1 { "" } else { "s" } + ))); + } + let series: i64 = sqlx::query_scalar!( + r#"SELECT count(*) AS "count!: i64" FROM series WHERE root_id = ?"#, + id + ) + .fetch_one(pool(&state)?) + .await?; + if series > 0 { + return Err(ApiError::Conflict(format!( + "this root still owns {series} series — move them first" + ))); + } + let result = sqlx::query!("DELETE FROM roots WHERE id = ?", id) + .execute(pool(&state)?) + .await?; + if result.rows_affected() == 0 { + return Err(ApiError::RootNotFound); + } + Ok(StatusCode::NO_CONTENT) +} + #[cfg(test)] mod tests { - use crate::state::Upstreams; - use crate::{router, AppState}; + use crate::{router, AppState, Upstreams}; + use axum::http::StatusCode; - #[tokio::test] - async fn roots_carry_their_policy_name() { + async fn application() -> (tempfile::TempDir, String) { let dir = tempfile::tempdir().expect("tempdir"); let database = arr_db::Db::connect(dir.path().join("arr.db")) .await @@ -61,8 +276,13 @@ mod tests { let address = listener.local_addr().expect("address"); let app = router(state); tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") }); + (dir, format!("http://{address}")) + } - let roots: Vec = reqwest::get(format!("http://{address}/api/roots")) + #[tokio::test] + async fn roots_carry_their_policy_name() { + let (_dir, base) = application().await; + let roots: Vec = reqwest::get(format!("{base}/api/roots")) .await .expect("roots") .json() @@ -80,4 +300,199 @@ mod tests { assert_eq!(roots[3]["kind"], "tv"); assert_eq!(roots[3]["policy_name"], "TV — kids"); } + + fn root_input(policy_id: i64) -> serde_json::Value { + serde_json::json!({ + "kind": "movie", + "audience": "archive", + "path": "/mnt/media/movies/archive", + "policy_id": policy_id, + }) + } + + async fn first_policy_ids(base: &str) -> Vec { + let policies: Vec = reqwest::get(format!("{base}/api/policies")) + .await + .expect("policies") + .json() + .await + .expect("policies json"); + policies + .into_iter() + .map(|policy| policy["id"].as_i64().expect("policy id")) + .collect() + } + + #[tokio::test] + async fn a_root_round_trips_through_create_and_update() { + let (_dir, base) = application().await; + let policy_ids = first_policy_ids(&base).await; + let roots: Vec = reqwest::get(format!("{base}/api/roots")) + .await + .expect("roots") + .json() + .await + .expect("roots json"); + + // the schema only knows movie and tv, main and kids + let rejected = reqwest::Client::new() + .post(format!("{base}/api/roots")) + .json(&root_input(policy_ids[0])) + .send() + .await + .expect("create root"); + assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY); + + // (movie, kids) is taken by a seeded root, so free the pair first + let kids_root = roots[1]["id"].as_i64().expect("movie kids root id"); + let freed = reqwest::Client::new() + .delete(format!("{base}/api/roots/{kids_root}")) + .send() + .await + .expect("free the seeded movie kids root"); + assert_eq!(freed.status(), StatusCode::NO_CONTENT); + + let mut payload = root_input(policy_ids[0]); + payload["audience"] = serde_json::json!("kids"); + payload["path"] = serde_json::json!("/mnt/media/movies/archive"); + let created: serde_json::Value = reqwest::Client::new() + .post(format!("{base}/api/roots")) + .json(&payload) + .send() + .await + .expect("create root") + .json() + .await + .expect("created json"); + assert_eq!(created["audience"], "kids"); + assert_eq!(created["policy_name"], "Movies — main"); + let id = created["id"].as_i64().expect("id"); + + payload["path"] = serde_json::json!("/mnt/media/movies/archive-4k"); + payload["policy_id"] = policy_ids[1].into(); + let updated: serde_json::Value = reqwest::Client::new() + .put(format!("{base}/api/roots/{id}")) + .json(&payload) + .send() + .await + .expect("update root") + .json() + .await + .expect("updated json"); + assert_eq!(updated["path"], "/mnt/media/movies/archive-4k"); + assert_eq!(updated["policy_name"], "Movies — kids"); + + let deleted = reqwest::Client::new() + .delete(format!("{base}/api/roots/{id}")) + .send() + .await + .expect("delete root"); + assert_eq!(deleted.status(), StatusCode::NO_CONTENT); + assert_eq!( + reqwest::get(format!("{base}/api/roots/{id}")) + .await + .expect("get deleted") + .status(), + StatusCode::NOT_FOUND + ); + } + + #[tokio::test] + async fn an_unknown_kind_or_policy_is_a_422_naming_the_field() { + let (_dir, base) = application().await; + let policy_ids = first_policy_ids(&base).await; + + for (mut payload, field) in [ + (root_input(999), "policy_id"), + (root_input(policy_ids[0]), "kind"), + ] { + payload["audience"] = serde_json::json!("main"); + if field == "kind" { + payload["kind"] = serde_json::json!("book"); + payload["path"] = serde_json::json!("/mnt/media/movies/elsewhere"); + } + let response = reqwest::Client::new() + .post(format!("{base}/api/roots")) + .json(&payload) + .send() + .await + .expect("create root"); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + let body: serde_json::Value = response.json().await.expect("error body"); + assert!( + body["error"].as_str().expect("text").starts_with(field), + "{field}: {body}" + ); + } + } + + #[tokio::test] + async fn a_duplicate_path_or_pair_conflicts() { + let (_dir, base) = application().await; + let mut path = root_input(1); + path["audience"] = serde_json::json!("kids"); + path["path"] = serde_json::json!("/mnt/media/movies/kids"); + let response = reqwest::Client::new() + .post(format!("{base}/api/roots")) + .json(&path) + .send() + .await + .expect("duplicate path create"); + assert_eq!(response.status(), StatusCode::CONFLICT); + + let mut pair = root_input(1); + pair["audience"] = serde_json::json!("main"); + pair["path"] = serde_json::json!("/mnt/media/movies/somewhere-new"); + let response = reqwest::Client::new() + .post(format!("{base}/api/roots")) + .json(&pair) + .send() + .await + .expect("duplicate pair create"); + assert_eq!(response.status(), StatusCode::CONFLICT); + } + + #[tokio::test] + async fn a_root_with_titles_refuses_to_die() { + let (_dir, base) = application().await; + let roots: Vec = reqwest::get(format!("{base}/api/roots")) + .await + .expect("roots") + .json() + .await + .expect("roots json"); + let main_root = roots[0]["id"].as_i64().expect("root id"); + + let created = 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": main_root, + })) + .send() + .await + .expect("create movie"); + assert_eq!(created.status(), StatusCode::CREATED); + + let response = reqwest::Client::new() + .delete(format!("{base}/api/roots/{main_root}")) + .send() + .await + .expect("delete occupied root"); + assert_eq!(response.status(), StatusCode::CONFLICT); + let body: serde_json::Value = response.json().await.expect("error body"); + assert!( + body["error"].as_str().expect("text").contains("movie"), + "{body}" + ); + + // an empty root deletes fine + let empty_root = roots[3]["id"].as_i64().expect("tv kids root id"); + let response = reqwest::Client::new() + .delete(format!("{base}/api/roots/{empty_root}")) + .send() + .await + .expect("delete empty root"); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } }