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 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 22:33:40 +01:00
parent acd0cce3f8
commit 504185f31f
4 changed files with 67 additions and 18 deletions

View File

@@ -11,6 +11,7 @@ use crate::{error::ApiError, state::AppState};
pub struct ProwlarrSearchRequest {
pub series_name: String,
pub volume_number: Option<i32>,
pub custom_query: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
@@ -104,7 +105,9 @@ pub async fn search_prowlarr(
) -> Result<Json<ProwlarrSearchResponse>, 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<ProwlarrRelease> = 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<ProwlarrRelease> = 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 }))
}

View File

@@ -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
</div>
<div className="p-5 space-y-4">
{/* Missing volumes + re-search */}
<div className="flex flex-wrap items-center gap-2">
{/* Search input */}
<form
onSubmit={(e) => {
e.preventDefault();
if (searchInput.trim()) doSearch(searchInput.trim());
}}
className="flex items-center gap-2"
>
<input
type="text"
value={searchInput}
onChange={(e) => 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")}
/>
<button
type="submit"
disabled={isSearching || !searchInput.trim()}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
<Icon name="search" size="sm" />
{t("prowlarr.searchAction")}
</button>
</form>
{/* Quick search badges */}
<div className="flex flex-wrap items-center gap-2 max-h-24 overflow-y-auto">
<button
type="button"
onClick={() => doSearch(seriesName)}
onClick={() => { setSearchInput(defaultQuery); doSearch(defaultQuery); }}
disabled={isSearching}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-primary/50 bg-primary/10 text-primary hover:bg-primary/20 disabled:opacity-50 transition-colors"
>
<Icon name="search" size="sm" />
{seriesName}
</button>
{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 (
<button
key={i}
type="button"
onClick={() => doSearch(seriesName, book.volume_number ?? undefined)}
onClick={() => { setSearchInput(q); doSearch(q); }}
disabled={isSearching}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-border bg-muted/30 hover:bg-muted/50 disabled:opacity-50 transition-colors"
>
<Icon name="search" size="sm" />
{book.title || `Vol. ${book.volume_number}`}
{label}
</button>
))}
);
})}
</div>
{/* Error */}

View File

@@ -487,6 +487,8 @@ const en: Record<TranslationKey, string> = {
"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",

View File

@@ -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",