//! Root folders (`DESIGN.md` §5.1): kind, audience, path and the one policy //! attached. Reads serve the add flow (§9.2); writes are the settings view's. use axum::extract::rejection::JsonRejection; use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::Json; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use crate::movies::{pool, ApiError, ErrorBody}; use crate::policies::parsed; use crate::state::AppState; /// A root folder joined with its policy, enough for the add flow to pre-fill /// root and policy (DESIGN.md §9.2) without a second request. #[derive(Debug, Clone, Serialize, ToSchema)] pub struct Root { pub id: i64, pub kind: String, pub audience: String, pub path: String, pub policy_id: i64, pub policy_name: String, } /// The payload for creating or replacing a root. #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct RootInput { /// `movie` or `tv` — the Transmission label and layout prefix (§7.1, §7.4). pub kind: String, /// `main` or `kids`. pub audience: String, pub path: String, pub policy_id: i64, } impl RootInput { fn validate(&self) -> Result<(), String> { if self.kind != "movie" && self.kind != "tv" { return Err(format!("kind: '{}' is neither movie nor tv", self.kind)); } if self.audience != "main" && self.audience != "kids" { return Err(format!( "audience: '{}' is neither main nor kids", self.audience )); } if self.path.trim().is_empty() { return Err("path: must not be empty".into()); } Ok(()) } async fn policy_exists(&self, state: &AppState) -> Result<(), ApiError> { let exists: Option = sqlx::query_scalar!("SELECT id FROM policies WHERE id = ?", self.policy_id) .fetch_optional(pool(state)?) .await?; if exists.is_none() { return Err(ApiError::Invalid(format!( "policy_id: no policy {}", self.policy_id ))); } Ok(()) } } async fn load_root(state: &AppState, id: i64) -> Result { sqlx::query_as!( Root, r#"SELECT r.id AS "id!: i64", r.kind AS "kind!: String", r.audience AS "audience!: String", r.path AS "path!: String", p.id AS "policy_id!: i64", p.name AS "policy_name!: String" FROM roots r JOIN policies p ON p.id = r.policy_id WHERE r.id = ?"#, id ) .fetch_optional(pool(state)?) .await? .ok_or(ApiError::RootNotFound) } #[utoipa::path( get, path = "/api/roots", tag = "roots", responses( (status = 200, body = [Root]), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn list(State(state): State) -> Result>, ApiError> { let roots = sqlx::query_as!( Root, r#"SELECT r.id AS "id!: i64", r.kind AS "kind!: String", r.audience AS "audience!: String", r.path AS "path!: String", p.id AS "policy_id!: i64", p.name AS "policy_name!: String" FROM roots r JOIN policies p ON p.id = r.policy_id ORDER BY r.id"# ) .fetch_all(pool(&state)?) .await?; Ok(Json(roots)) } #[utoipa::path( get, path = "/api/roots/{root_id}", tag = "roots", params(("root_id" = i64, Path, description = "Root row id")), responses( (status = 200, body = Root), (status = 404, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn get( State(state): State, Path(id): Path, ) -> Result, ApiError> { Ok(Json(load_root(&state, id).await?)) } #[utoipa::path( post, path = "/api/roots", tag = "roots", request_body = RootInput, responses( (status = 201, body = Root), (status = 409, body = ErrorBody), (status = 422, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn create( State(state): State, body: Result, JsonRejection>, ) -> Result<(StatusCode, Json), ApiError> { let input = parsed(body)?; input.validate().map_err(ApiError::Invalid)?; input.policy_exists(&state).await?; let path = input.path.trim().to_owned(); let result = sqlx::query!( "INSERT INTO roots (kind, audience, path, policy_id) VALUES (?, ?, ?, ?)", input.kind, input.audience, path, input.policy_id, ) .execute(pool(&state)?) .await .map_err(root_conflict)?; let root = load_root(&state, result.last_insert_rowid()).await?; Ok((StatusCode::CREATED, Json(root))) } #[utoipa::path( put, path = "/api/roots/{root_id}", tag = "roots", request_body = RootInput, params(("root_id" = i64, Path, description = "Root row id")), responses( (status = 200, body = Root), (status = 404, body = ErrorBody), (status = 409, body = ErrorBody), (status = 422, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn update( State(state): State, Path(id): Path, body: Result, JsonRejection>, ) -> Result, ApiError> { let input = parsed(body)?; input.validate().map_err(ApiError::Invalid)?; input.policy_exists(&state).await?; let path = input.path.trim().to_owned(); let result = sqlx::query!( r#"UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"#, input.kind, input.audience, path, input.policy_id, id, ) .execute(pool(&state)?) .await .map_err(root_conflict)?; if result.rows_affected() == 0 { return Err(ApiError::RootNotFound); } Ok(Json(load_root(&state, id).await?)) } /// A duplicate path or a duplicate (kind, audience) pair is a settings /// mistake the operator can fix, not a server fault. fn root_conflict(error: sqlx::Error) -> ApiError { if error .as_database_error() .is_some_and(sqlx::error::DatabaseError::is_unique_violation) { return ApiError::Conflict( "a root with this path or this kind and audience already exists".into(), ); } ApiError::from(error) } #[utoipa::path( delete, path = "/api/roots/{root_id}", tag = "roots", params(("root_id" = i64, Path, description = "Root row id")), responses( (status = 204), (status = 404, body = ErrorBody), (status = 409, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn delete( State(state): State, Path(id): Path, ) -> Result { let movies: i64 = sqlx::query_scalar!( r#"SELECT count(*) AS "count!: i64" FROM movies WHERE root_id = ?"#, id ) .fetch_one(pool(&state)?) .await?; if movies > 0 { return Err(ApiError::Conflict(format!( "this root still owns {movies} movie{} — move them first", if movies == 1 { "" } else { "s" } ))); } let series: i64 = sqlx::query_scalar!( r#"SELECT count(*) AS "count!: i64" FROM series WHERE root_id = ?"#, id ) .fetch_one(pool(&state)?) .await?; if series > 0 { return Err(ApiError::Conflict(format!( "this root still owns {series} series — move them first" ))); } let result = sqlx::query!("DELETE FROM roots WHERE id = ?", id) .execute(pool(&state)?) .await?; if result.rows_affected() == 0 { return Err(ApiError::RootNotFound); } Ok(StatusCode::NO_CONTENT) } #[cfg(test)] mod tests { use crate::{router, AppState, 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("database"); database.migrate().await.expect("migrate"); 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}")) } #[tokio::test] async fn roots_carry_their_policy_name() { let (_dir, base) = application().await; let roots: Vec = reqwest::get(format!("{base}/api/roots")) .await .expect("roots") .json() .await .expect("json"); assert_eq!(roots.len(), 4, "the seeded movie and TV roots: {roots:?}"); assert_eq!(roots[0]["audience"], "main"); assert_eq!(roots[0]["policy_name"], "Movies — main"); assert_eq!(roots[1]["audience"], "kids"); assert!(roots[1]["path"] .as_str() .is_some_and(|p| p.contains("kids"))); assert_eq!(roots[2]["kind"], "tv"); assert_eq!(roots[2]["policy_name"], "TV — main"); assert_eq!(roots[3]["kind"], "tv"); assert_eq!(roots[3]["policy_name"], "TV — kids"); } fn root_input(policy_id: i64) -> serde_json::Value { serde_json::json!({ "kind": "movie", "audience": "archive", "path": "/mnt/media/movies/archive", "policy_id": policy_id, }) } async fn first_policy_ids(base: &str) -> Vec { let policies: Vec = reqwest::get(format!("{base}/api/policies")) .await .expect("policies") .json() .await .expect("policies json"); policies .into_iter() .map(|policy| policy["id"].as_i64().expect("policy id")) .collect() } #[tokio::test] async fn a_root_round_trips_through_create_and_update() { let (_dir, base) = application().await; let policy_ids = first_policy_ids(&base).await; let roots: Vec = reqwest::get(format!("{base}/api/roots")) .await .expect("roots") .json() .await .expect("roots json"); // the schema only knows movie and tv, main and kids let rejected = reqwest::Client::new() .post(format!("{base}/api/roots")) .json(&root_input(policy_ids[0])) .send() .await .expect("create root"); assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY); // (movie, kids) is taken by a seeded root, so free the pair first let kids_root = roots[1]["id"].as_i64().expect("movie kids root id"); let freed = reqwest::Client::new() .delete(format!("{base}/api/roots/{kids_root}")) .send() .await .expect("free the seeded movie kids root"); assert_eq!(freed.status(), StatusCode::NO_CONTENT); let mut payload = root_input(policy_ids[0]); payload["audience"] = serde_json::json!("kids"); payload["path"] = serde_json::json!("/mnt/media/movies/archive"); let created: serde_json::Value = reqwest::Client::new() .post(format!("{base}/api/roots")) .json(&payload) .send() .await .expect("create root") .json() .await .expect("created json"); assert_eq!(created["audience"], "kids"); assert_eq!(created["policy_name"], "Movies — main"); let id = created["id"].as_i64().expect("id"); payload["path"] = serde_json::json!("/mnt/media/movies/archive-4k"); payload["policy_id"] = policy_ids[1].into(); let updated: serde_json::Value = reqwest::Client::new() .put(format!("{base}/api/roots/{id}")) .json(&payload) .send() .await .expect("update root") .json() .await .expect("updated json"); assert_eq!(updated["path"], "/mnt/media/movies/archive-4k"); assert_eq!(updated["policy_name"], "Movies — kids"); let deleted = reqwest::Client::new() .delete(format!("{base}/api/roots/{id}")) .send() .await .expect("delete root"); assert_eq!(deleted.status(), StatusCode::NO_CONTENT); assert_eq!( reqwest::get(format!("{base}/api/roots/{id}")) .await .expect("get deleted") .status(), StatusCode::NOT_FOUND ); } #[tokio::test] async fn an_unknown_kind_or_policy_is_a_422_naming_the_field() { let (_dir, base) = application().await; let policy_ids = first_policy_ids(&base).await; for (mut payload, field) in [ (root_input(999), "policy_id"), (root_input(policy_ids[0]), "kind"), ] { payload["audience"] = serde_json::json!("main"); if field == "kind" { payload["kind"] = serde_json::json!("book"); payload["path"] = serde_json::json!("/mnt/media/movies/elsewhere"); } let response = reqwest::Client::new() .post(format!("{base}/api/roots")) .json(&payload) .send() .await .expect("create root"); assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); let body: serde_json::Value = response.json().await.expect("error body"); assert!( body["error"].as_str().expect("text").starts_with(field), "{field}: {body}" ); } } #[tokio::test] async fn a_duplicate_path_or_pair_conflicts() { let (_dir, base) = application().await; let mut path = root_input(1); path["audience"] = serde_json::json!("kids"); path["path"] = serde_json::json!("/mnt/media/movies/kids"); let response = reqwest::Client::new() .post(format!("{base}/api/roots")) .json(&path) .send() .await .expect("duplicate path create"); assert_eq!(response.status(), StatusCode::CONFLICT); let mut pair = root_input(1); pair["audience"] = serde_json::json!("main"); pair["path"] = serde_json::json!("/mnt/media/movies/somewhere-new"); let response = reqwest::Client::new() .post(format!("{base}/api/roots")) .json(&pair) .send() .await .expect("duplicate pair create"); assert_eq!(response.status(), StatusCode::CONFLICT); } #[tokio::test] async fn a_root_with_titles_refuses_to_die() { let (_dir, base) = application().await; let roots: Vec = reqwest::get(format!("{base}/api/roots")) .await .expect("roots") .json() .await .expect("roots json"); let main_root = roots[0]["id"].as_i64().expect("root id"); let created = reqwest::Client::new() .post(format!("{base}/api/movies")) .json(&serde_json::json!({ "tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024, "original_language": "en", "root_id": main_root, })) .send() .await .expect("create movie"); assert_eq!(created.status(), StatusCode::CREATED); let response = reqwest::Client::new() .delete(format!("{base}/api/roots/{main_root}")) .send() .await .expect("delete occupied root"); assert_eq!(response.status(), StatusCode::CONFLICT); let body: serde_json::Value = response.json().await.expect("error body"); assert!( body["error"].as_str().expect("text").contains("movie"), "{body}" ); // an empty root deletes fine let empty_root = roots[3]["id"].as_i64().expect("tv kids root id"); let response = reqwest::Client::new() .delete(format!("{base}/api/roots/{empty_root}")) .send() .await .expect("delete empty root"); assert_eq!(response.status(), StatusCode::NO_CONTENT); } }