use axum::{extract::{Query, State}, Json}; use serde::{Deserialize, Serialize}; use sqlx::Row; use utoipa::ToSchema; use uuid::Uuid; 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)] pub library_id: Option, #[schema(value_type = Option, example = "cbz")] pub r#type: Option, #[schema(value_type = Option, example = "cbz")] pub kind: Option, #[schema(value_type = Option, example = 20)] pub limit: Option, } #[derive(Serialize, ToSchema)] pub struct SeriesHit { #[schema(value_type = String)] pub library_id: Uuid, pub name: String, pub book_count: i64, pub books_read_count: i64, #[schema(value_type = String)] pub first_book_id: Uuid, } #[derive(Serialize, ToSchema)] pub struct SearchResponse { pub hits: serde_json::Value, pub series_hits: Vec, pub estimated_total_hits: Option, pub processing_time_ms: Option, } /// Search books across all libraries #[utoipa::path( get, path = "/search", tag = "books", params( ("q" = String, Query, description = "Search query (books + series via PostgreSQL full-text)"), ("library_id" = Option, Query, description = "Filter by library ID"), ("type" = Option, Query, description = "Filter by type (cbz, cbr, pdf)"), ("kind" = Option, Query, description = "Filter by kind (alias for type)"), ("limit" = Option, Query, description = "Max results per type (max 100)"), ), responses( (status = 200, body = SearchResponse), (status = 401, description = "Unauthorized"), ), security(("Bearer" = [])) )] pub async fn search_books( State(state): State, Query(query): Query, ) -> Result, ApiError> { if query.q.trim().is_empty() { return Err(ApiError::bad_request("q is required")); } let limit_val = query.limit.unwrap_or(20).clamp(1, 100) as i64; let q_pattern = format!("%{}%", query.q); let library_id_uuid: Option = query.library_id.as_deref() .and_then(|s| s.parse().ok()); let kind_filter: Option<&str> = query.r#type.as_deref().or(query.kind.as_deref()); let start = std::time::Instant::now(); // Book search via PostgreSQL ILIKE on title, authors, series let books_sql = r#" SELECT b.id, b.library_id, b.kind, b.title, COALESCE(b.authors, CASE WHEN b.author IS NOT NULL AND b.author != '' THEN ARRAY[b.author] ELSE ARRAY[]::text[] END) as authors, b.series, b.volume, b.language FROM books b LEFT JOIN series_metadata sm ON sm.library_id = b.library_id AND sm.name = COALESCE(NULLIF(b.series, ''), 'unclassified') WHERE ( b.title ILIKE $1 OR b.series ILIKE $1 OR EXISTS (SELECT 1 FROM unnest( COALESCE(b.authors, CASE WHEN b.author IS NOT NULL AND b.author != '' THEN ARRAY[b.author] ELSE ARRAY[]::text[] END) || COALESCE(sm.authors, ARRAY[]::text[]) ) AS a WHERE a ILIKE $1) ) AND ($2::uuid IS NULL OR b.library_id = $2) AND ($3::text IS NULL OR b.kind = $3) ORDER BY CASE WHEN b.title ILIKE $1 THEN 0 ELSE 1 END, b.title ASC LIMIT $4 "#; let series_sql = r#" WITH sorted_books AS ( SELECT library_id, COALESCE(NULLIF(series, ''), 'unclassified') as name, id, ROW_NUMBER() OVER ( PARTITION BY library_id, COALESCE(NULLIF(series, ''), 'unclassified') ORDER BY REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'), COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0), title ASC ) as rn FROM books WHERE ($2::uuid IS NULL OR library_id = $2) ), series_counts AS ( SELECT sb.library_id, sb.name, COUNT(*) as book_count, COUNT(brp.book_id) FILTER (WHERE brp.status = 'read') as books_read_count FROM sorted_books sb LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id GROUP BY sb.library_id, sb.name ) SELECT sc.library_id, sc.name, sc.book_count, sc.books_read_count, sb.id as first_book_id FROM series_counts sc JOIN sorted_books sb ON sb.library_id = sc.library_id AND sb.name = sc.name AND sb.rn = 1 WHERE sc.name ILIKE $1 ORDER BY sc.name ASC LIMIT $4 "#; let (books_rows, series_rows) = tokio::join!( sqlx::query(books_sql) .bind(&q_pattern) .bind(library_id_uuid) .bind(kind_filter) .bind(limit_val) .fetch_all(&state.pool), sqlx::query(series_sql) .bind(&q_pattern) .bind(library_id_uuid) .bind(kind_filter) // unused in series query but keeps bind positions consistent .bind(limit_val) .fetch_all(&state.pool) ); let elapsed_ms = start.elapsed().as_millis() as u64; // Build book hits as JSON array (same shape as before) let books_rows = books_rows.map_err(|e| ApiError::internal(format!("book search failed: {e}")))?; let hits: Vec = books_rows .iter() .map(|row| { serde_json::json!({ "id": row.get::("id").to_string(), "library_id": row.get::("library_id").to_string(), "kind": row.get::("kind"), "title": row.get::("title"), "authors": row.get::, _>("authors"), "series": row.get::, _>("series"), "volume": row.get::, _>("volume"), "language": row.get::, _>("language"), }) }) .collect(); let estimated_total_hits = hits.len() as u64; // Series hits let series_hits: Vec = series_rows .unwrap_or_default() .iter() .map(|row| SeriesHit { library_id: row.get("library_id"), name: row.get("name"), book_count: row.get("book_count"), books_read_count: row.get("books_read_count"), first_book_id: row.get("first_book_id"), }) .collect(); Ok(Json(SearchResponse { hits: serde_json::Value::Array(hits), series_hits, estimated_total_hits: Some(estimated_total_hits), processing_time_ms: Some(elapsed_ms), })) }