feat(api): policy and root CRUD endpoints
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).
This commit is contained in:
+12
@@ -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"
|
||||||
|
}
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
+20
@@ -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"
|
||||||
|
}
|
||||||
+50
@@ -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"
|
||||||
|
}
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
+68
@@ -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"
|
||||||
|
}
|
||||||
+20
@@ -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"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM policies WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "5cc813fb311c8d582b97320b0bebd17ad32bc3611a5f9dc98944591d9a596f26"
|
||||||
|
}
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
+62
@@ -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"
|
||||||
|
}
|
||||||
+20
@@ -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"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM roots WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "ec2c22c78df99b937819263742c5a1bd7dc57165d5353c2169210aafe2153a93"
|
||||||
|
}
|
||||||
+20
@@ -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"
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
mod health;
|
mod health;
|
||||||
mod movies;
|
mod movies;
|
||||||
mod owners;
|
mod owners;
|
||||||
|
mod policies;
|
||||||
mod roots;
|
mod roots;
|
||||||
mod search;
|
mod search;
|
||||||
mod series;
|
mod series;
|
||||||
@@ -26,7 +27,10 @@ pub use movies::{
|
|||||||
Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, MovieFile, Release, UpdateMovie,
|
Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, MovieFile, Release, UpdateMovie,
|
||||||
};
|
};
|
||||||
pub use owners::{CreateOwner, Owner, UpdateOwner};
|
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 search::{ClassifiedRelease, SearchResponse};
|
||||||
pub use series::{
|
pub use series::{
|
||||||
CreateEpisode, CreateSeason, CreateSeries, Episode, Season, Series, UpdateEpisode,
|
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 = "movies", description = "Movie library and actions"),
|
||||||
(name = "series", description = "Series, seasons and episodes (DESIGN.md §4.1, §4.2)"),
|
(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 = "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 = "search", description = "Unified title and release search"),
|
||||||
(name = "roots", description = "Root folders and their policies")
|
(name = "roots", description = "Root folders and their policies")
|
||||||
),
|
),
|
||||||
@@ -88,7 +93,10 @@ fn api_router() -> OpenApiRouter<AppState> {
|
|||||||
.routes(routes!(owners::get, owners::update, owners::delete))
|
.routes(routes!(owners::get, owners::update, owners::delete))
|
||||||
.routes(routes!(search::search))
|
.routes(routes!(search::search))
|
||||||
.routes(routes!(search::releases))
|
.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.
|
/// The generated `OpenAPI` document.
|
||||||
@@ -301,6 +309,11 @@ mod tests {
|
|||||||
("/api/movies/{movie_id}/releases/{release_id}/grab", "post"),
|
("/api/movies/{movie_id}/releases/{release_id}/grab", "post"),
|
||||||
("/api/queues/attention", "get"),
|
("/api/queues/attention", "get"),
|
||||||
("/api/series", "get"),
|
("/api/series", "get"),
|
||||||
|
("/api/policies", "get"),
|
||||||
|
("/api/policies", "post"),
|
||||||
|
("/api/policies/{policy_id}", "put"),
|
||||||
|
("/api/policies/{policy_id}", "delete"),
|
||||||
|
("/api/roots", "post"),
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
json["paths"][path][method].is_object(),
|
json["paths"][path][method].is_object(),
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ pub enum ApiError {
|
|||||||
SeasonNotFound,
|
SeasonNotFound,
|
||||||
EpisodeNotFound,
|
EpisodeNotFound,
|
||||||
OwnerNotFound,
|
OwnerNotFound,
|
||||||
|
PolicyNotFound,
|
||||||
|
RootNotFound,
|
||||||
Conflict(String),
|
Conflict(String),
|
||||||
Invalid(String),
|
Invalid(String),
|
||||||
Unavailable,
|
Unavailable,
|
||||||
@@ -130,6 +132,8 @@ impl IntoResponse for ApiError {
|
|||||||
Self::SeasonNotFound => (StatusCode::NOT_FOUND, "season not found".to_string()),
|
Self::SeasonNotFound => (StatusCode::NOT_FOUND, "season not found".to_string()),
|
||||||
Self::EpisodeNotFound => (StatusCode::NOT_FOUND, "episode 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::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::Conflict(error) => (StatusCode::CONFLICT, error),
|
||||||
Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error),
|
Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error),
|
||||||
Self::Unavailable => (
|
Self::Unavailable => (
|
||||||
|
|||||||
@@ -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<String> },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
pub hdr_rules: HdrRulesSpec,
|
||||||
|
pub size_bands: BTreeMap<String, SizeBandSpec>,
|
||||||
|
pub resolution_pref: Vec<String>,
|
||||||
|
pub source_weights: BTreeMap<String, i32>,
|
||||||
|
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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub hdr_rules: HdrRulesSpec,
|
||||||
|
#[serde(default)]
|
||||||
|
pub size_bands: BTreeMap<String, SizeBandSpec>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub resolution_pref: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub source_weights: BTreeMap<String, i32>,
|
||||||
|
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::<u8>().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<PolicyColumns, ApiError> {
|
||||||
|
fn json(value: impl serde::Serialize) -> Result<String, ApiError> {
|
||||||
|
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<T: serde::de::DeserializeOwned>(
|
||||||
|
column: &'static str,
|
||||||
|
value: &str,
|
||||||
|
) -> Result<T, ApiError> {
|
||||||
|
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<Policy, ApiError> {
|
||||||
|
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<T>(payload: Result<Json<T>, JsonRejection>) -> Result<T, ApiError> {
|
||||||
|
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<Policy, ApiError> {
|
||||||
|
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<AppState>) -> Result<Json<Vec<Policy>>, 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<Policy, ApiError> {
|
||||||
|
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<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<Json<Policy>, 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<AppState>,
|
||||||
|
body: Result<Json<PolicyInput>, JsonRejection>,
|
||||||
|
) -> Result<(StatusCode, Json<Policy>), 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<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
body: Result<Json<PolicyInput>, JsonRejection>,
|
||||||
|
) -> Result<Json<Policy>, 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<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
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<serde_json::Value> = 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<serde_json::Value> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+425
-10
@@ -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 axum::Json;
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::movies::{ApiError, ErrorBody};
|
use crate::movies::{pool, ApiError, ErrorBody};
|
||||||
|
use crate::policies::parsed;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
/// A root folder joined with its policy, enough for the add flow to pre-fill
|
/// 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,
|
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<i64> =
|
||||||
|
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<Root, ApiError> {
|
||||||
|
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(
|
#[utoipa::path(
|
||||||
get, path = "/api/roots", tag = "roots",
|
get, path = "/api/roots", tag = "roots",
|
||||||
responses(
|
responses(
|
||||||
@@ -27,23 +94,171 @@ pub struct Root {
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub async fn list(State(state): State<AppState>) -> Result<Json<Vec<Root>>, ApiError> {
|
pub async fn list(State(state): State<AppState>) -> Result<Json<Vec<Root>>, ApiError> {
|
||||||
let database = state.database().ok_or(ApiError::Unavailable)?;
|
|
||||||
let roots = sqlx::query_as!(
|
let roots = sqlx::query_as!(
|
||||||
Root,
|
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"#
|
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?;
|
.await?;
|
||||||
Ok(Json(roots))
|
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<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<Json<Root>, 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<AppState>,
|
||||||
|
body: Result<Json<RootInput>, JsonRejection>,
|
||||||
|
) -> Result<(StatusCode, Json<Root>), 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<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
body: Result<Json<RootInput>, JsonRejection>,
|
||||||
|
) -> Result<Json<Root>, 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<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::state::Upstreams;
|
use crate::{router, AppState, Upstreams};
|
||||||
use crate::{router, AppState};
|
use axum::http::StatusCode;
|
||||||
|
|
||||||
#[tokio::test]
|
async fn application() -> (tempfile::TempDir, String) {
|
||||||
async fn roots_carry_their_policy_name() {
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let database = arr_db::Db::connect(dir.path().join("arr.db"))
|
let database = arr_db::Db::connect(dir.path().join("arr.db"))
|
||||||
.await
|
.await
|
||||||
@@ -61,8 +276,13 @@ mod tests {
|
|||||||
let address = listener.local_addr().expect("address");
|
let address = listener.local_addr().expect("address");
|
||||||
let app = router(state);
|
let app = router(state);
|
||||||
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
|
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
|
||||||
|
(dir, format!("http://{address}"))
|
||||||
|
}
|
||||||
|
|
||||||
let roots: Vec<serde_json::Value> = reqwest::get(format!("http://{address}/api/roots"))
|
#[tokio::test]
|
||||||
|
async fn roots_carry_their_policy_name() {
|
||||||
|
let (_dir, base) = application().await;
|
||||||
|
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
||||||
.await
|
.await
|
||||||
.expect("roots")
|
.expect("roots")
|
||||||
.json()
|
.json()
|
||||||
@@ -80,4 +300,199 @@ mod tests {
|
|||||||
assert_eq!(roots[3]["kind"], "tv");
|
assert_eq!(roots[3]["kind"], "tv");
|
||||||
assert_eq!(roots[3]["policy_name"], "TV — kids");
|
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<i64> {
|
||||||
|
let policies: Vec<serde_json::Value> = 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<serde_json::Value> = 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<serde_json::Value> = 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user