From 504185f31fdf0072d67890136eb2d72213762880 Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Thu, 19 Mar 2026 22:33:40 +0100 Subject: [PATCH] feat: add editable search input to Prowlarr modal with scrollable badges - Add text input for custom search queries in Prowlarr modal - Quick search badges pre-fill the input and trigger search - Default query uses quoted series name for exact match - Add custom_query support to backend API - Limit badge area height with vertical scroll - Add debug logging for Prowlarr API responses Co-Authored-By: Claude Opus 4.6 --- apps/api/src/prowlarr.rs | 20 ++++-- .../app/components/ProwlarrSearchModal.tsx | 61 ++++++++++++++----- apps/backoffice/lib/i18n/en.ts | 2 + apps/backoffice/lib/i18n/fr.ts | 2 + 4 files changed, 67 insertions(+), 18 deletions(-) diff --git a/apps/api/src/prowlarr.rs b/apps/api/src/prowlarr.rs index ba1dcee..235c9b7 100644 --- a/apps/api/src/prowlarr.rs +++ b/apps/api/src/prowlarr.rs @@ -11,6 +11,7 @@ use crate::{error::ApiError, state::AppState}; pub struct ProwlarrSearchRequest { pub series_name: String, pub volume_number: Option, + pub custom_query: Option, } #[derive(Serialize, Deserialize, ToSchema)] @@ -104,7 +105,9 @@ pub async fn search_prowlarr( ) -> Result, ApiError> { let (url, api_key, categories) = load_prowlarr_config(&state.pool).await?; - let query = if let Some(vol) = body.volume_number { + let query = if let Some(custom) = &body.custom_query { + custom.clone() + } else if let Some(vol) = body.volume_number { format!("\"{}\" {}", body.series_name, vol) } else { format!("\"{}\"", body.series_name) @@ -139,10 +142,19 @@ pub async fn search_prowlarr( ))); } - let results: Vec = resp - .json() + let raw_text = resp + .text() .await - .map_err(|e| ApiError::internal(format!("Failed to parse Prowlarr response: {e}")))?; + .map_err(|e| ApiError::internal(format!("Failed to read Prowlarr response: {e}")))?; + + tracing::debug!("Prowlarr raw response length: {} chars", raw_text.len()); + + let results: Vec = serde_json::from_str(&raw_text) + .map_err(|e| { + tracing::error!("Failed to parse Prowlarr response: {e}"); + tracing::error!("Raw response (first 500 chars): {}", &raw_text[..raw_text.len().min(500)]); + ApiError::internal(format!("Failed to parse Prowlarr response: {e}")) + })?; Ok(Json(ProwlarrSearchResponse { results, query })) } diff --git a/apps/backoffice/app/components/ProwlarrSearchModal.tsx b/apps/backoffice/app/components/ProwlarrSearchModal.tsx index f6671ab..b24144d 100644 --- a/apps/backoffice/app/components/ProwlarrSearchModal.tsx +++ b/apps/backoffice/app/components/ProwlarrSearchModal.tsx @@ -55,13 +55,16 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch .catch(() => setIsQbConfigured(false)); }, []); - const doSearch = useCallback(async (searchSeriesName: string, volumeNumber?: number) => { + const [searchInput, setSearchInput] = useState(`"${seriesName}"`); + + const doSearch = useCallback(async (queryOverride?: string) => { + const searchQuery = queryOverride ?? searchInput; + if (!searchQuery.trim()) return; setIsSearching(true); setError(null); setResults([]); try { - const body: { series_name: string; volume_number?: number } = { series_name: searchSeriesName }; - if (volumeNumber !== undefined) body.volume_number = volumeNumber; + const body = { series_name: seriesName, custom_query: searchQuery.trim() }; const resp = await fetch("/api/prowlarr/search", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -80,15 +83,18 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch } finally { setIsSearching(false); } - }, [t]); + }, [t, seriesName, searchInput]); + + const defaultQuery = `"${seriesName}"`; function handleOpen() { setIsOpen(true); setResults([]); setError(null); setQuery(""); + setSearchInput(defaultQuery); // Auto-search the series on open - doSearch(seriesName); + doSearch(defaultQuery); } function handleClose() { @@ -143,29 +149,56 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch
- {/* Missing volumes + re-search */} -
+ {/* Search input */} +
{ + e.preventDefault(); + if (searchInput.trim()) doSearch(searchInput.trim()); + }} + className="flex items-center gap-2" + > + setSearchInput(e.target.value)} + className="flex-1 px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary" + placeholder={t("prowlarr.searchPlaceholder")} + /> + +
+ + {/* Quick search badges */} +
- {missingBooks && missingBooks.length > 0 && missingBooks.map((book, i) => ( + {missingBooks && missingBooks.length > 0 && missingBooks.map((book, i) => { + const label = book.title || `Vol. ${book.volume_number}`; + const q = book.volume_number != null ? `"${seriesName}" ${book.volume_number}` : `"${seriesName}" ${label}`; + return ( - ))} + ); + })}
{/* Error */} diff --git a/apps/backoffice/lib/i18n/en.ts b/apps/backoffice/lib/i18n/en.ts index f2f052d..2d6a34f 100644 --- a/apps/backoffice/lib/i18n/en.ts +++ b/apps/backoffice/lib/i18n/en.ts @@ -487,6 +487,8 @@ const en: Record = { "prowlarr.columnSeeders": "Seeds", "prowlarr.columnLeechers": "Peers", "prowlarr.columnProtocol": "Protocol", + "prowlarr.searchPlaceholder": "Edit search query...", + "prowlarr.searchAction": "Search", "prowlarr.searchError": "Search failed", "prowlarr.notConfigured": "Prowlarr is not configured", "prowlarr.download": "Download", diff --git a/apps/backoffice/lib/i18n/fr.ts b/apps/backoffice/lib/i18n/fr.ts index 83b5401..f6c6e08 100644 --- a/apps/backoffice/lib/i18n/fr.ts +++ b/apps/backoffice/lib/i18n/fr.ts @@ -485,6 +485,8 @@ const fr = { "prowlarr.columnSeeders": "Seeds", "prowlarr.columnLeechers": "Peers", "prowlarr.columnProtocol": "Protocole", + "prowlarr.searchPlaceholder": "Modifier la recherche...", + "prowlarr.searchAction": "Rechercher", "prowlarr.searchError": "Erreur lors de la recherche", "prowlarr.notConfigured": "Prowlarr n'est pas configuré", "prowlarr.download": "Télécharger",