diff --git a/.sqlx/query-fa84977e249cb6f3b95d5f3486346450066ac2997437175e30c60d6364701dcf.json b/.sqlx/query-fa84977e249cb6f3b95d5f3486346450066ac2997437175e30c60d6364701dcf.json new file mode 100644 index 0000000..b66fc84 --- /dev/null +++ b/.sqlx/query-fa84977e249cb6f3b95d5f3486346450066ac2997437175e30c60d6364701dcf.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "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", + "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": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "fa84977e249cb6f3b95d5f3486346450066ac2997437175e30c60d6364701dcf" +} diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index 6650e2f..540e8c8 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -8,6 +8,7 @@ mod health; mod movies; +mod roots; mod search; mod state; @@ -20,6 +21,7 @@ use utoipa_scalar::{Scalar, Servable}; pub use health::{Check, Health, HealthReport, Status}; pub use movies::{Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, Release, UpdateMovie}; +pub use roots::Root; pub use search::{ClassifiedRelease, SearchResponse}; pub use state::{AppState, MovieCommand, Upstreams, DEFAULT_TMDB_URL}; @@ -42,7 +44,8 @@ pub const DOCS_PATH: &str = "/api/docs"; tags( (name = "system", description = "Service health and metadata"), (name = "movies", description = "Movie library and actions"), - (name = "search", description = "Unified title and release search") + (name = "search", description = "Unified title and release search"), + (name = "roots", description = "Root folders and their policies") ), )] struct ApiDoc; @@ -59,6 +62,7 @@ fn api_router() -> OpenApiRouter { .routes(routes!(movies::attention)) .routes(routes!(search::search)) .routes(routes!(search::releases)) + .routes(routes!(roots::list)) } /// The generated `OpenAPI` document. diff --git a/crates/arr-api/src/roots.rs b/crates/arr-api/src/roots.rs new file mode 100644 index 0000000..7f86586 --- /dev/null +++ b/crates/arr-api/src/roots.rs @@ -0,0 +1,79 @@ +use axum::extract::State; +use axum::Json; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::movies::{ApiError, ErrorBody}; +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, +} + +#[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 database = state.database().ok_or(ApiError::Unavailable)?; + let roots = sqlx::query_as!( + Root, + r#"SELECT r.id AS "id!: i64", r.kind AS "kind!: String", r.audience AS "audience!: String", r.path AS "path!: String", p.id AS "policy_id!: i64", p.name AS "policy_name!: String" FROM roots r JOIN policies p ON p.id = r.policy_id ORDER BY r.id"# + ) + .fetch_all(database.pool()) + .await?; + Ok(Json(roots)) +} + +#[cfg(test)] +mod tests { + use crate::state::Upstreams; + use crate::{router, AppState}; + + #[tokio::test] + async fn roots_carry_their_policy_name() { + 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") }); + + let roots: Vec = reqwest::get(format!("http://{address}/api/roots")) + .await + .expect("roots") + .json() + .await + .expect("json"); + assert_eq!(roots.len(), 2, "the two seeded movie 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"))); + } +}