- Change all instances of AppState to reference the new state module across multiple files for consistency. - Clean up imports in auth, books, index_jobs, libraries, pages, search, settings, thumbnails, and tokens modules. - Simplify main.rs by removing unused code and organizing middleware and route handlers under the new handlers module.
102 lines
3.6 KiB
Rust
102 lines
3.6 KiB
Rust
use axum::{extract::{Query, State}, Json};
|
|
use serde::{Deserialize, Serialize};
|
|
use utoipa::ToSchema;
|
|
|
|
use crate::{error::ApiError, state::AppState};
|
|
|
|
#[derive(Deserialize, ToSchema)]
|
|
pub struct SearchQuery {
|
|
#[schema(value_type = String, example = "batman")]
|
|
pub q: String,
|
|
#[schema(value_type = Option<String>)]
|
|
pub library_id: Option<String>,
|
|
#[schema(value_type = Option<String>, example = "cbz")]
|
|
pub r#type: Option<String>,
|
|
#[schema(value_type = Option<String>, example = "cbz")]
|
|
pub kind: Option<String>,
|
|
#[schema(value_type = Option<usize>, example = 20)]
|
|
pub limit: Option<usize>,
|
|
}
|
|
|
|
#[derive(Serialize, ToSchema)]
|
|
pub struct SearchResponse {
|
|
pub hits: serde_json::Value,
|
|
pub estimated_total_hits: Option<u64>,
|
|
pub processing_time_ms: Option<u64>,
|
|
}
|
|
|
|
/// Search books across all libraries using Meilisearch
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/search",
|
|
tag = "books",
|
|
params(
|
|
("q" = String, Query, description = "Search query"),
|
|
("library_id" = Option<String>, Query, description = "Filter by library ID"),
|
|
("type" = Option<String>, Query, description = "Filter by type (cbz, cbr, pdf)"),
|
|
("kind" = Option<String>, Query, description = "Filter by kind (alias for type)"),
|
|
("limit" = Option<usize>, Query, description = "Max results (max 100)"),
|
|
),
|
|
responses(
|
|
(status = 200, body = SearchResponse),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("Bearer" = []))
|
|
)]
|
|
pub async fn search_books(
|
|
State(state): State<AppState>,
|
|
Query(query): Query<SearchQuery>,
|
|
) -> Result<Json<SearchResponse>, ApiError> {
|
|
if query.q.trim().is_empty() {
|
|
return Err(ApiError::bad_request("q is required"));
|
|
}
|
|
|
|
let mut filters: Vec<String> = Vec::new();
|
|
if let Some(library_id) = query.library_id.as_deref() {
|
|
filters.push(format!("library_id = '{}'", library_id.replace('"', "")));
|
|
}
|
|
let kind_filter = query.r#type.as_deref().or(query.kind.as_deref());
|
|
if let Some(kind) = kind_filter {
|
|
filters.push(format!("kind = '{}'", kind.replace('"', "")));
|
|
}
|
|
|
|
let body = serde_json::json!({
|
|
"q": query.q,
|
|
"limit": query.limit.unwrap_or(20).clamp(1, 100),
|
|
"filter": if filters.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(filters.join(" AND ")) }
|
|
});
|
|
|
|
let client = reqwest::Client::new();
|
|
let url = format!("{}/indexes/books/search", state.meili_url.trim_end_matches('/'));
|
|
let response = client
|
|
.post(url)
|
|
.header("Authorization", format!("Bearer {}", state.meili_master_key))
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| ApiError::internal(format!("meili request failed: {e}")))?;
|
|
|
|
if !response.status().is_success() {
|
|
let body = response.text().await.unwrap_or_else(|_| "unknown meili error".to_string());
|
|
if body.contains("index_not_found") {
|
|
return Ok(Json(SearchResponse {
|
|
hits: serde_json::json!([]),
|
|
estimated_total_hits: Some(0),
|
|
processing_time_ms: Some(0),
|
|
}));
|
|
}
|
|
return Err(ApiError::internal(format!("meili error: {body}")));
|
|
}
|
|
|
|
let payload: serde_json::Value = response
|
|
.json()
|
|
.await
|
|
.map_err(|e| ApiError::internal(format!("invalid meili response: {e}")))?;
|
|
|
|
Ok(Json(SearchResponse {
|
|
hits: payload.get("hits").cloned().unwrap_or_else(|| serde_json::json!([])),
|
|
estimated_total_hits: payload.get("estimatedTotalHits").and_then(|v| v.as_u64()),
|
|
processing_time_ms: payload.get("processingTimeMs").and_then(|v| v.as_u64()),
|
|
}))
|
|
}
|