Merge branch 'issue/116-settings'
ci / web (push) Successful in 31s
e2e / e2e (push) Successful in 59s
ci / rust (push) Successful in 1m3s

This commit is contained in:
Miguel Palhas
2026-08-23 15:15:09 +01:00
22 changed files with 2332 additions and 13 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM policies WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "5cc813fb311c8d582b97320b0bebd17ad32bc3611a5f9dc98944591d9a596f26"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM roots WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "ec2c22c78df99b937819263742c5a1bd7dc57165d5353c2169210aafe2153a93"
}
@@ -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"
}
+15 -2
View File
@@ -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(),
+4
View File
@@ -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 => (
+674
View File
@@ -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 into_columns(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.into_columns(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.into_columns(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
View File
@@ -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);
}
} }
+32
View File
@@ -76,6 +76,9 @@
<button type="button" class="control" id="nav-queues" aria-pressed="false"> <button type="button" class="control" id="nav-queues" aria-pressed="false">
queues<span class="nav-count readout" id="queues-count" hidden></span> queues<span class="nav-count readout" id="queues-count" hidden></span>
</button> </button>
<button type="button" class="control" id="nav-settings" aria-pressed="false">
settings
</button>
</nav> </nav>
<div class="rail-search"> <div class="rail-search">
<input <input
@@ -274,6 +277,35 @@
</section> </section>
</main> </main>
<main class="deck" id="settings" hidden aria-label="settings">
<header class="deck-head library-bar">
<h2 class="deck-label">settings</h2>
<span class="deck-count readout" id="settings-summary"></span>
</header>
<p class="deck-status readout" id="settings-status" role="status" hidden></p>
<section class="deck-group" id="group-roots" hidden aria-labelledby="label-roots">
<header class="deck-head bucket-head">
<h3 class="deck-label" id="label-roots">roots</h3>
<button type="button" class="control control-quiet readout" id="root-add">add root</button>
</header>
<ul class="deck-rows" id="rows-roots"></ul>
</section>
<section class="deck-group" id="group-policies" hidden aria-labelledby="label-policies">
<header class="deck-head bucket-head">
<h3 class="deck-label" id="label-policies">policies</h3>
<button type="button" class="control control-quiet readout" id="policy-add">
add policy
</button>
</header>
<p class="queue-note readout dim">
a policy edit rescores search decks immediately — open any title's releases to check it.
</p>
<ul class="deck-rows" id="rows-policies"></ul>
</section>
</main>
<main class="board"> <main class="board">
<section class="chain" aria-label="signal chain"> <section class="chain" aria-label="signal chain">
<article class="module area-tmdb" data-check="tmdb"> <article class="module area-tmdb" data-check="tmdb">
+6 -1
View File
@@ -52,6 +52,7 @@ import {
searchTitles, searchTitles,
type TmdbMovie, type TmdbMovie,
} from "./search"; } from "./search";
import { settingsMain } from "./settings";
import "./style.css"; import "./style.css";
const POLL_MS = 15_000; const POLL_MS = 15_000;
@@ -207,7 +208,8 @@ function main() {
const releases = releasesMain(); const releases = releasesMain();
const library = libraryMain(board, releases, views); const library = libraryMain(board, releases, views);
const queues = queuesMain(board, releases, views); const queues = queuesMain(board, releases, views);
views.push(library, queues); const settings = settingsMain(board, views);
views.push(library, queues, settings);
const search = searchMain(board, releases, views); const search = searchMain(board, releases, views);
// a removed title must not survive on the surface the deck opened over // a removed title must not survive on the surface the deck opened over
releases.setRemoved((parent) => { releases.setRemoved((parent) => {
@@ -244,6 +246,9 @@ function main() {
case "queues": case "queues":
queues.open(); queues.open();
break; break;
case "settings":
settings.open();
break;
case "search": case "search":
search.restore(route.query); search.restore(route.query);
break; break;
+6
View File
@@ -6,6 +6,7 @@ export type Route =
| { kind: "board" } | { kind: "board" }
| { kind: "library" } | { kind: "library" }
| { kind: "queues" } | { kind: "queues" }
| { kind: "settings" }
| { kind: "search"; query: string } | { kind: "search"; query: string }
| { kind: "releases"; movieId: number }; | { kind: "releases"; movieId: number };
@@ -17,6 +18,9 @@ export function parseRoute(url: URL): Route {
if (segments.length === 1 && segments[0] === "queues") { if (segments.length === 1 && segments[0] === "queues") {
return { kind: "queues" }; return { kind: "queues" };
} }
if (segments.length === 1 && segments[0] === "settings") {
return { kind: "settings" };
}
if (segments.length === 1 && segments[0] === "search") { if (segments.length === 1 && segments[0] === "search") {
const query = url.searchParams.get("q") ?? ""; const query = url.searchParams.get("q") ?? "";
return query === "" ? { kind: "board" } : { kind: "search", query }; return query === "" ? { kind: "board" } : { kind: "search", query };
@@ -38,6 +42,8 @@ export function routePath(route: Route): string {
return "/library"; return "/library";
case "queues": case "queues":
return "/queues"; return "/queues";
case "settings":
return "/settings";
case "search": case "search":
return `/search?q=${encodeURIComponent(route.query)}`; return `/search?q=${encodeURIComponent(route.query)}`;
case "releases": case "releases":
+759
View File
@@ -0,0 +1,759 @@
// Hand-written mirror of arr-api's /api/policies and /api/roots schemas,
// plus the settings view over them — same reasoning as search.ts: the
// generated client (src/api/) is uncommitted, so CI's tsc cannot see it.
import { navigate } from "./router";
import type { Root } from "./search";
/** One policy row: the JSON columns as the API returns them. */
export interface PolicyDoc {
id: number;
name: string;
required_audio: { require: "original_language" } | { require: "any_of"; langs: string[] };
dub_blacklist: string[];
hdr_rules: { dv_profile_reject: string[] };
size_bands: Record<
string,
{ floor_gib: number; target_gib: number; penalty_points_per_gib_over: number }
>;
resolution_pref: string[];
source_weights: Record<string, number>;
score_weights: {
size_at_target: number;
source_tier: number;
seeder_doubling: number;
resolution_step: number;
};
}
export interface RootInput {
kind: string;
audience: string;
path: string;
policy_id: number;
}
const RESOLUTIONS = ["2160p", "1080p", "720p"] as const;
const SOURCES = ["Remux", "BluRay", "WEB-DL", "WEBRip", "HDTV"] as const;
async function errorDetail(response: Response): Promise<string> {
try {
const body = (await response.json()) as { error?: string };
return body.error ?? `http ${response.status}`;
} catch {
return `http ${response.status}`;
}
}
async function fetchPolicies(): Promise<PolicyDoc[]> {
const response = await fetch("/api/policies");
if (!response.ok) {
throw new Error(await errorDetail(response));
}
return (await response.json()) as PolicyDoc[];
}
async function fetchRoots(): Promise<Root[]> {
const response = await fetch("/api/roots");
if (!response.ok) {
throw new Error(await errorDetail(response));
}
return (await response.json()) as Root[];
}
type SaveOutcome = { ok: true; json: unknown } | { ok: false; detail: string };
async function sendJson(url: string, method: string, body: unknown): Promise<SaveOutcome> {
try {
const response = await fetch(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
return { ok: false, detail: await errorDetail(response) };
}
return { ok: true, json: await response.json() };
} catch {
return { ok: false, detail: "daemon unreachable" };
}
}
function savePolicy(id: number | null, policy: Omit<PolicyDoc, "id">): Promise<SaveOutcome> {
return id === null
? sendJson("/api/policies", "POST", policy)
: sendJson(`/api/policies/${id}`, "PUT", policy);
}
function saveRoot(id: number | null, root: RootInput): Promise<SaveOutcome> {
return id === null
? sendJson("/api/roots", "POST", root)
: sendJson(`/api/roots/${id}`, "PUT", root);
}
async function deleteRow(kind: "policies" | "roots", id: number): Promise<string | null> {
try {
const response = await fetch(`/api/${kind}/${id}`, { method: "DELETE" });
if (!response.ok) {
return await errorDetail(response);
}
return null;
} catch {
return "daemon unreachable";
}
}
/* ---- form primitives ---------------------------------------------------- */
function must<T extends Element>(selector: string): T {
const element = document.querySelector<T>(selector);
if (!element) {
throw new Error(`settings markup is missing ${selector}`);
}
return element;
}
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
className: string,
text?: string,
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
node.className = className;
if (text !== undefined) {
node.textContent = text;
}
return node;
}
function chip(text: string): HTMLSpanElement {
return el("span", "chip readout", text);
}
function rowTitle(title: string): HTMLElement {
const wrap = el("span", "row-id");
wrap.append(el("span", "row-title", title));
return wrap;
}
function field(labelText: string, control: HTMLElement): HTMLDivElement {
const wrap = el("div", "field");
wrap.append(el("span", "field-label readout dim", labelText), control);
return wrap;
}
function textControl(value: string, placeholder = ""): HTMLInputElement {
const input = document.createElement("input");
input.type = "text";
input.className = "form-input readout";
input.value = value;
input.placeholder = placeholder;
input.spellcheck = false;
input.autocomplete = "off";
return input;
}
function numberControl(value: number): HTMLInputElement {
const input = document.createElement("input");
input.type = "number";
input.className = "form-input readout form-num";
input.value = String(value);
return input;
}
function selectControl(options: readonly string[], value: string): HTMLSelectElement {
const select = document.createElement("select");
select.className = "form-input readout";
for (const option of options) {
const item = document.createElement("option");
item.value = option;
item.textContent = option;
if (option === value) {
item.selected = true;
}
select.append(item);
}
select.value = value;
return select;
}
/** `a, b, c` ⇄ parts. Empty entries dropped; validation happens at save. */
function listValue(input: HTMLInputElement): string[] {
return input.value
.split(",")
.map((part) => part.trim())
.filter((part) => part !== "");
}
/**
* First click arms the destructive action, second confirms. The armed state
* clears on blur or after a few seconds, so an accidental double click
* never deletes.
*/
function armedDelete(label: string, execute: () => void): HTMLButtonElement {
const button = el("button", "control control-quiet readout", label);
button.type = "button";
let armed = false;
let resetTimer: number | undefined;
const disarm = () => {
armed = false;
window.clearTimeout(resetTimer);
delete button.dataset.armed;
button.textContent = label;
};
button.addEventListener("click", () => {
if (armed) {
disarm();
button.disabled = true;
execute();
return;
}
armed = true;
button.dataset.armed = "true";
button.textContent = `confirm ${label}`;
resetTimer = window.setTimeout(disarm, 4000);
});
button.addEventListener("blur", () => {
if (armed) {
disarm();
}
});
return button;
}
interface SettingsView {
hide: () => void;
open: () => void;
}
export function settingsMain(board: HTMLElement, views: { hide: () => void }[]): SettingsView {
const view = must<HTMLElement>("#settings");
const deck = must<HTMLElement>("#deck");
const nav = must<HTMLButtonElement>("#nav-settings");
const summary = must<HTMLElement>("#settings-summary");
const status = must<HTMLElement>("#settings-status");
const rootsSection = must<HTMLElement>("#group-roots");
const rootsRows = must<HTMLUListElement>("#rows-roots");
const policiesSection = must<HTMLElement>("#group-policies");
const policiesRows = must<HTMLUListElement>("#rows-policies");
let roots: Root[] = [];
let policies: PolicyDoc[] = [];
// guards a stale fetch from painting over a newer view
let sequence = 0;
function setStatus(text: string | null, tone?: "fault") {
status.hidden = text === null;
status.textContent = text ?? "";
if (tone) {
status.dataset.tone = tone;
} else {
delete status.dataset.tone;
}
}
function clearGroups() {
for (const section of [rootsSection, policiesSection]) {
section.hidden = true;
}
rootsRows.replaceChildren();
policiesRows.replaceChildren();
summary.textContent = "";
}
async function load() {
sequence += 1;
const ticket = sequence;
setStatus("reading settings…");
try {
const [fetchedRoots, fetchedPolicies] = await Promise.all([fetchRoots(), fetchPolicies()]);
if (ticket !== sequence) {
return;
}
roots = fetchedRoots;
policies = fetchedPolicies;
} catch (error) {
if (ticket !== sequence) {
return;
}
clearGroups();
setStatus(`settings unavailable — ${(error as Error).message}`, "fault");
return;
}
render();
}
function render() {
clearGroups();
setStatus(null);
rootsSection.hidden = false;
policiesSection.hidden = false;
summary.textContent = `${roots.length} roots · ${policies.length} policies`;
for (const root of roots) {
rootsRows.append(rootRow(root));
}
for (const policy of policies) {
policiesRows.append(policyRow(policy));
}
}
/* -- roots -------------------------------------------------------------- */
/** A delete/edit action pair sharing the row's feedback line. */
function rowActions(
item: HTMLLIElement,
del: HTMLButtonElement,
edit: HTMLButtonElement,
): HTMLParagraphElement {
const actions = el("div", "settings-row-actions");
actions.append(edit, del);
item.append(actions);
const note = el("p", "add-note readout");
note.setAttribute("role", "status");
note.hidden = true;
item.append(note);
return note;
}
function rootRow(root: Root): HTMLLIElement {
const item = el("li", "queue-item");
const row = el("div", "row");
const chips = el("span", "row-chips");
chips.append(chip(root.kind), chip(root.audience), chip(root.policy_name));
row.append(rowTitle(root.path), chips);
item.append(row);
const del = armedDelete("delete", () => {
void deleteRow("roots", root.id).then((detail) => {
if (detail !== null) {
note.hidden = false;
note.dataset.tone = "fault";
note.textContent = detail;
return;
}
void load();
});
});
const edit = el("button", "control control-quiet readout", "edit");
edit.type = "button";
edit.setAttribute("aria-expanded", "false");
edit.addEventListener("click", () => {
const existing = item.querySelector(".edit-panel");
if (existing) {
existing.remove();
edit.setAttribute("aria-expanded", "false");
return;
}
item.append(rootPanel(root));
edit.setAttribute("aria-expanded", "true");
});
const note = rowActions(item, del, edit);
return item;
}
function rootPanel(root: Root | null): HTMLElement {
const panel = el("div", "edit-panel add-panel");
const grid = el("div", "form-grid");
const kind = selectControl(["movie", "tv"], root?.kind ?? "movie");
const audience = selectControl(["main", "kids"], root?.audience ?? "main");
const path = textControl(root?.path ?? "", "/mnt/media/…");
const policyId = selectControl(
policies.map((policy) => String(policy.id)),
String(root?.policy_id ?? policies[0]?.id ?? ""),
);
// readable option labels without coupling value → name lookups
for (const [index, policy] of policies.entries()) {
const option = policyId.options[index];
if (option) {
option.textContent = policy.name;
}
}
grid.append(
field("kind", kind),
field("audience", audience),
field("path", path),
field("policy", policyId),
);
const note = el("p", "add-note readout");
note.setAttribute("role", "status");
note.hidden = true;
const save = el("button", "control readout", root === null ? "create root" : "save root");
save.type = "button";
save.addEventListener("click", () => {
save.disabled = true;
note.hidden = false;
delete note.dataset.tone;
note.textContent = "saving…";
const input: RootInput = {
kind: kind.value,
audience: audience.value,
path: path.value.trim(),
policy_id: Number(policyId.value),
};
void saveRoot(root?.id ?? null, input).then((outcome) => {
if (outcome.ok) {
void load();
return;
}
save.disabled = false;
note.hidden = false;
note.dataset.tone = "fault";
note.textContent = outcome.detail;
});
});
const cancel = el("button", "control control-quiet readout", "cancel");
cancel.type = "button";
cancel.addEventListener("click", () => {
panel.remove();
});
const actions = el("div", "add-actions");
actions.append(save, cancel, note);
panel.append(grid, actions);
queueMicrotask(() => (root === null ? kind : path).focus());
return panel;
}
/* -- policies ------------------------------------------------------------ */
function requiredAudioLabel(policy: PolicyDoc): string {
return policy.required_audio.require === "original_language"
? "original language"
: `any of ${policy.required_audio.langs.join(", ")}`;
}
function policyRow(policy: PolicyDoc): HTMLLIElement {
const item = el("li", "queue-item");
const row = el("div", "row");
const chips = el("span", "row-chips");
chips.append(chip(policy.resolution_pref.join(" › ") || "no resolutions"));
chips.append(chip(requiredAudioLabel(policy)));
const bandCount = Object.keys(policy.size_bands).length;
chips.append(chip(`${bandCount} band${bandCount === 1 ? "" : "s"}`));
row.append(rowTitle(policy.name), chips);
item.append(row);
const del = armedDelete("delete", () => {
void deleteRow("policies", policy.id).then((detail) => {
if (detail !== null) {
note.hidden = false;
note.dataset.tone = "fault";
note.textContent = detail;
return;
}
void load();
});
});
const edit = el("button", "control control-quiet readout", "edit");
edit.type = "button";
edit.setAttribute("aria-expanded", "false");
edit.addEventListener("click", () => {
const existing = item.querySelector(".edit-panel");
if (existing) {
existing.remove();
edit.setAttribute("aria-expanded", "false");
return;
}
item.append(policyPanel(policy));
edit.setAttribute("aria-expanded", "true");
});
const note = rowActions(item, del, edit);
return item;
}
/**
* Comma-list editor that checks its vocabulary while typing, so an unknown
* resolution or profile is named before a round trip.
*/
function knownList(
labelText: string,
values: string[],
known: readonly string[],
invalid: (message: string | null) => void,
): HTMLDivElement {
const input = textControl(values.join(", "), known.join(", "));
input.addEventListener("input", () => {
const unknown = listValue(input).find((part) => !known.includes(part));
if (unknown !== undefined) {
input.dataset.invalid = "true";
invalid(`${labelText}: unknown value '${unknown}'`);
} else if (input.dataset.invalid !== undefined) {
delete input.dataset.invalid;
invalid(null);
}
});
return field(labelText, input);
}
function newPolicyDoc(): Omit<PolicyDoc, "id"> {
return {
name: "",
required_audio: { require: "original_language" },
dub_blacklist: ["pt-BR"],
hdr_rules: { dv_profile_reject: ["5", "7"] },
size_bands: {},
resolution_pref: ["2160p", "1080p"],
source_weights: {},
score_weights: {
size_at_target: 1000,
source_tier: 25,
seeder_doubling: 8,
resolution_step: 300,
},
};
}
function policyPanel(policy: PolicyDoc | null): HTMLElement {
const current: Omit<PolicyDoc, "id"> = policy ?? newPolicyDoc();
const panel = el("div", "edit-panel add-panel");
const grid = el("div", "form-grid");
const invalidate = (message: string | null) => {
note.hidden = message === null || note.textContent === "";
if (message !== null) {
note.dataset.tone = "fault";
note.textContent = message;
} else if (note.dataset.tone === "fault") {
note.hidden = true;
}
};
const note = el("p", "add-note readout");
note.setAttribute("role", "status");
note.hidden = true;
const name = textControl(current.name, "policy name");
grid.append(field("name", name));
const audioMode = selectControl(
["original_language", "any_of"],
current.required_audio.require,
);
const audioLangs = textControl(
current.required_audio.require === "any_of" ? current.required_audio.langs.join(", ") : "",
"pt-PT, …",
);
audioMode.addEventListener("change", () => {
audioLangs.disabled = audioMode.value === "original_language";
});
audioLangs.disabled = audioMode.value === "original_language";
const blacklist = textControl(current.dub_blacklist.join(", "), "pt-BR");
grid.append(field("required audio", audioMode), field("required langs", audioLangs));
grid.append(field("dub blacklist", blacklist));
let profilesInput: HTMLInputElement;
const profiles = knownList(
"rejected dv profiles",
current.hdr_rules.dv_profile_reject,
["5", "7", "8"],
invalidate,
);
profilesInput = profiles.querySelector("input") ?? document.createElement("input");
grid.append(profiles);
// one line per resolution: floor, target, penalty — numbers stay numbers
for (const resolution of RESOLUTIONS) {
const band = current.size_bands[resolution];
const line = el("div", "band-line");
line.dataset.resolution = resolution;
line.append(el("span", "field-label readout dim", resolution));
const floor = numberControl(band?.floor_gib ?? 0);
const target = numberControl(band?.target_gib ?? 0);
const penalty = numberControl(band?.penalty_points_per_gib_over ?? 0);
floor.setAttribute("aria-label", `${resolution} floor GiB`);
target.setAttribute("aria-label", `${resolution} target GiB`);
penalty.setAttribute("aria-label", `${resolution} penalty points per GiB over`);
for (const input of [floor, target, penalty]) {
input.classList.add("band-num");
line.append(input);
}
grid.append(line);
}
let prefInput: HTMLInputElement;
const pref = knownList(
"resolution preference (first wins)",
current.resolution_pref,
RESOLUTIONS,
invalidate,
);
prefInput = pref.querySelector("input") ?? document.createElement("input");
grid.append(pref);
const WEIGHT_KEYS = [
"size_at_target",
"source_tier",
"seeder_doubling",
"resolution_step",
] as const;
type SourceKey = (typeof SOURCES)[number];
type WeightKey = (typeof WEIGHT_KEYS)[number];
const sourceInputs = {} as Record<SourceKey, HTMLInputElement>;
for (const source of SOURCES) {
const weight = numberControl(current.source_weights[source] ?? 0);
weight.setAttribute("aria-label", `${source} source weight`);
grid.append(field(`source · ${source.toLowerCase()}`, weight));
sourceInputs[source] = weight;
}
const scoreInputs = {} as Record<WeightKey, HTMLInputElement>;
for (const key of WEIGHT_KEYS) {
const input = numberControl(current.score_weights[key]);
input.setAttribute("aria-label", key.replaceAll("_", " "));
grid.append(field(key.replaceAll("_", " "), input));
scoreInputs[key] = input;
}
const save = el("button", "control readout", policy === null ? "create policy" : "save policy");
save.type = "button";
save.addEventListener("click", () => {
const bad = panel.querySelector<HTMLElement>("[data-invalid]");
if (bad !== null) {
note.hidden = false;
note.dataset.tone = "fault";
note.textContent = "fix the highlighted fields first";
return;
}
const emptyWeight = [...Object.values(sourceInputs), ...Object.values(scoreInputs)].find(
(input) => input.value === "",
);
if (name.value.trim() === "" || emptyWeight !== undefined) {
note.hidden = false;
note.dataset.tone = "fault";
note.textContent = "name and every scoring weight are required";
return;
}
const sizeBands: PolicyDoc["size_bands"] = {};
for (const line of panel.querySelectorAll<HTMLDivElement>(".band-line")) {
const [floor, target, penalty] = [...line.querySelectorAll<HTMLInputElement>("input")];
if (!floor || !target || !penalty) {
continue;
}
sizeBands[line.dataset.resolution ?? ""] = {
floor_gib: Number(floor.value),
target_gib: Number(target.value),
penalty_points_per_gib_over: Number(penalty.value),
};
}
save.disabled = true;
note.hidden = false;
delete note.dataset.tone;
note.textContent = "saving…";
const payload: Omit<PolicyDoc, "id"> = {
name: name.value.trim(),
required_audio:
audioMode.value === "any_of"
? { require: "any_of", langs: listValue(audioLangs) }
: { require: "original_language" },
dub_blacklist: listValue(blacklist),
hdr_rules: { dv_profile_reject: listValue(profilesInput) },
size_bands: sizeBands,
resolution_pref: listValue(prefInput),
source_weights: Object.fromEntries(
SOURCES.filter((source) => sourceInputs[source].value !== "").map((source) => [
source,
Number(sourceInputs[source].value),
]),
),
score_weights: Object.fromEntries(
WEIGHT_KEYS.map((key) => [key, Number(scoreInputs[key].value)]),
) as PolicyDoc["score_weights"],
};
void savePolicy(policy?.id ?? null, payload).then((outcome) => {
if (outcome.ok) {
void load();
return;
}
save.disabled = false;
note.hidden = false;
note.dataset.tone = "fault";
note.textContent = outcome.detail;
});
});
const cancel = el("button", "control control-quiet readout", "cancel");
cancel.type = "button";
cancel.addEventListener("click", () => {
panel.remove();
});
const actions = el("div", "add-actions");
actions.append(save, cancel, note);
panel.append(grid, actions);
queueMicrotask(() => name.focus());
return panel;
}
/* -- shell ---------------------------------------------------------------- */
function open() {
for (const sibling of views) {
sibling.hide();
}
board.hidden = true;
deck.hidden = true;
view.hidden = false;
nav.setAttribute("aria-pressed", "true");
void load();
}
function hide() {
view.hidden = true;
nav.setAttribute("aria-pressed", "false");
sequence += 1;
}
function close() {
hide();
board.hidden = false;
nav.focus();
}
nav.addEventListener("click", () => {
if (view.hidden) {
navigate({ kind: "settings" });
open();
} else {
navigate({ kind: "board" });
close();
}
});
must<HTMLButtonElement>("#root-add").addEventListener("click", () => {
rootsSection.append(rootPanel(null));
});
must<HTMLButtonElement>("#policy-add").addEventListener("click", () => {
policiesSection.append(policyPanel(null));
});
// capture, like the other decks: one Esc steps back one layer
window.addEventListener(
"keydown",
(event) => {
if (event.key === "Escape" && !view.hidden) {
event.stopImmediatePropagation();
navigate({ kind: "board" });
close();
}
},
true,
);
return { hide, open };
}
+79
View File
@@ -1270,3 +1270,82 @@ body {
width: 2.5rem; width: 2.5rem;
} }
} }
/* ---- settings (issue #116) -------------------------------------------- */
/* rows follow the queues pattern: line, then an action pair */
.settings-row-actions {
display: flex;
gap: var(--space-2);
margin: var(--space-1) 0 var(--space-2);
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
gap: var(--space-3);
align-items: end;
}
.field {
display: grid;
gap: var(--space-1);
}
.field-label {
font-size: var(--text-2xs);
text-transform: uppercase;
letter-spacing: 0.12em;
}
.form-input {
min-height: 2.25rem;
width: 100%;
padding: 0 var(--space-2);
color: var(--ink);
font-family: var(--font-readout);
font-size: var(--text-xs);
background: var(--ground);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: inset 0 1px var(--lowlight);
caret-color: var(--accent);
transition: border-color 150ms var(--ease-out);
}
.form-input:hover {
border-color: var(--line-strong);
}
.form-input:focus {
border-color: var(--accent);
outline: none;
}
.form-input:disabled {
color: var(--ink-faint);
border-color: var(--line);
}
select.form-input {
appearance: none;
}
.form-input[data-invalid] {
border-color: var(--signal-fault);
}
/* one resolution's size band: label, floor, target, penalty on a line */
.band-line {
display: flex;
align-items: center;
gap: var(--space-2);
}
.band-line .field-label {
width: 3.5rem;
}
.band-num {
width: 4.5rem;
}