Unified search box (#30) (#75)
ci / rust (push) Failing after 1m26s
ci / web (push) Successful in 29s
e2e / e2e (push) Successful in 1m55s

This commit was merged in pull request #75.
This commit is contained in:
2026-08-22 22:08:32 +01:00
parent ac6ed8b798
commit bf526b9198
8 changed files with 1113 additions and 1 deletions
+5 -1
View File
@@ -9,6 +9,7 @@
mod health;
mod movies;
mod owners;
mod roots;
mod search;
mod state;
@@ -22,6 +23,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 owners::{CreateOwner, Owner, UpdateOwner};
pub use roots::Root;
pub use search::{ClassifiedRelease, SearchResponse};
pub use state::{AppState, MovieCommand, Upstreams, DEFAULT_TMDB_URL};
@@ -45,7 +47,8 @@ pub const DOCS_PATH: &str = "/api/docs";
(name = "system", description = "Service health and metadata"),
(name = "movies", description = "Movie library and actions"),
(name = "owners", description = "Owner tags and filtered views (DESIGN.md §4.3)"),
(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;
@@ -66,6 +69,7 @@ fn api_router() -> OpenApiRouter<AppState> {
.routes(routes!(owners::get, owners::update, owners::delete))
.routes(routes!(search::search))
.routes(routes!(search::releases))
.routes(routes!(roots::list))
}
/// The generated `OpenAPI` document.
+79
View File
@@ -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<AppState>) -> Result<Json<Vec<Root>>, 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<serde_json::Value> = 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")));
}
}