diff --git a/apps/api/src/prowlarr.rs b/apps/api/src/prowlarr.rs index 235c9b7..814f6d4 100644 --- a/apps/api/src/prowlarr.rs +++ b/apps/api/src/prowlarr.rs @@ -7,15 +7,39 @@ use crate::{error::ApiError, state::AppState}; // ─── Types ────────────────────────────────────────────────────────────────── +#[derive(Deserialize, ToSchema)] +pub struct MissingVolumeInput { + pub volume_number: Option, + #[allow(dead_code)] + pub title: Option, +} + #[derive(Deserialize, ToSchema)] pub struct ProwlarrSearchRequest { pub series_name: String, pub volume_number: Option, pub custom_query: Option, + pub missing_volumes: Option>, } #[derive(Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] +pub struct ProwlarrRawRelease { + pub guid: String, + pub title: String, + pub size: i64, + pub download_url: Option, + pub indexer: Option, + pub seeders: Option, + pub leechers: Option, + pub publish_date: Option, + pub protocol: Option, + pub info_url: Option, + pub categories: Option>, +} + +#[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct ProwlarrRelease { pub guid: String, pub title: String, @@ -28,6 +52,8 @@ pub struct ProwlarrRelease { pub protocol: Option, pub info_url: Option, pub categories: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub matched_missing_volumes: Option>, } #[derive(Serialize, Deserialize, ToSchema)] @@ -83,6 +109,107 @@ async fn load_prowlarr_config( Ok((url, config.api_key, categories)) } +// ─── Volume matching ───────────────────────────────────────────────────────── + +/// Extract volume numbers from a release title. +/// Looks for patterns like: T01, Tome 01, Vol. 01, v01, #01, +/// or standalone numbers that appear after common separators. +fn extract_volumes_from_title(title: &str) -> Vec { + let lower = title.to_lowercase(); + let mut volumes = Vec::new(); + + // Patterns: T01, Tome 01, Tome01, Vol 01, Vol.01, v01, #01 + let prefixes = ["tome", "vol.", "vol ", "t", "v", "#"]; + let chars: Vec = lower.chars().collect(); + let len = chars.len(); + + for prefix in &prefixes { + let mut start = 0; + while let Some(pos) = lower[start..].find(prefix) { + let abs_pos = start + pos; + let after = abs_pos + prefix.len(); + + // For single-char prefixes (t, v, #), ensure it's at a word boundary + if prefix.len() == 1 && *prefix != "#" { + if abs_pos > 0 && chars[abs_pos - 1].is_alphanumeric() { + start = after; + continue; + } + } + + // Skip optional spaces after prefix + let mut i = after; + while i < len && chars[i] == ' ' { + i += 1; + } + + // Read digits + let digit_start = i; + while i < len && chars[i].is_ascii_digit() { + i += 1; + } + + if i > digit_start { + if let Ok(num) = lower[digit_start..i].parse::() { + if !volumes.contains(&num) { + volumes.push(num); + } + } + } + + start = after; + } + } + + volumes +} + +/// Match releases against missing volume numbers. +fn match_missing_volumes( + releases: Vec, + missing: &[MissingVolumeInput], +) -> Vec { + let missing_numbers: Vec = missing + .iter() + .filter_map(|m| m.volume_number) + .collect(); + + releases + .into_iter() + .map(|r| { + let matched = if missing_numbers.is_empty() { + None + } else { + let title_volumes = extract_volumes_from_title(&r.title); + let matched: Vec = title_volumes + .into_iter() + .filter(|v| missing_numbers.contains(v)) + .collect(); + if matched.is_empty() { + None + } else { + Some(matched) + } + }; + + ProwlarrRelease { + guid: r.guid, + title: r.title, + size: r.size, + download_url: r.download_url, + indexer: r.indexer, + seeders: r.seeders, + leechers: r.leechers, + publish_date: r.publish_date, + protocol: r.protocol, + info_url: r.info_url, + categories: r.categories, + matched_missing_volumes: matched, + } + }) + .collect() +} + // ─── Handlers ─────────────────────────────────────────────────────────────── /// Search for releases on Prowlarr @@ -149,13 +276,35 @@ pub async fn search_prowlarr( tracing::debug!("Prowlarr raw response length: {} chars", raw_text.len()); - let results: Vec = serde_json::from_str(&raw_text) + let raw_releases: 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}")) })?; + let results = if let Some(missing) = &body.missing_volumes { + match_missing_volumes(raw_releases, missing) + } else { + raw_releases + .into_iter() + .map(|r| ProwlarrRelease { + guid: r.guid, + title: r.title, + size: r.size, + download_url: r.download_url, + indexer: r.indexer, + seeders: r.seeders, + leechers: r.leechers, + publish_date: r.publish_date, + protocol: r.protocol, + info_url: r.info_url, + categories: r.categories, + matched_missing_volumes: None, + }) + .collect() + }; + Ok(Json(ProwlarrSearchResponse { results, query })) } diff --git a/apps/backoffice/app/components/ProwlarrSearchModal.tsx b/apps/backoffice/app/components/ProwlarrSearchModal.tsx index b24144d..1cb8be8 100644 --- a/apps/backoffice/app/components/ProwlarrSearchModal.tsx +++ b/apps/backoffice/app/components/ProwlarrSearchModal.tsx @@ -64,7 +64,11 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch setError(null); setResults([]); try { - const body = { series_name: seriesName, custom_query: searchQuery.trim() }; + const missing_volumes = missingBooks?.map((b) => ({ + volume_number: b.volume_number, + title: b.title, + })) ?? undefined; + const body = { series_name: seriesName, custom_query: searchQuery.trim(), missing_volumes }; const resp = await fetch("/api/prowlarr/search", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -237,12 +241,23 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch - {results.map((release, i) => ( - + {results.map((release, i) => { + const hasMissing = release.matchedMissingVolumes && release.matchedMissingVolumes.length > 0; + return ( + {release.title} + {hasMissing && ( +
+ {release.matchedMissingVolumes!.map((vol) => ( + + {t("prowlarr.missingVol", { vol })} + + ))} +
+ )} {release.indexer || "—"} @@ -325,7 +340,8 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch - ))} + ); + })} diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index c77c5ab..4109fbd 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -939,6 +939,7 @@ export type ProwlarrRelease = { protocol: string | null; infoUrl: string | null; categories: ProwlarrCategory[] | null; + matchedMissingVolumes: number[] | null; }; export type ProwlarrSearchResponse = { diff --git a/apps/backoffice/lib/i18n/en.ts b/apps/backoffice/lib/i18n/en.ts index aaf3759..0039083 100644 --- a/apps/backoffice/lib/i18n/en.ts +++ b/apps/backoffice/lib/i18n/en.ts @@ -514,6 +514,7 @@ const en: Record = { "prowlarr.sending": "Sending...", "prowlarr.sentSuccess": "Sent to qBittorrent", "prowlarr.sentError": "Failed to send to qBittorrent", + "prowlarr.missingVol": "Vol. {{vol}} missing", // Settings - qBittorrent "settings.qbittorrent": "qBittorrent", diff --git a/apps/backoffice/lib/i18n/fr.ts b/apps/backoffice/lib/i18n/fr.ts index 3355053..cf3fc6b 100644 --- a/apps/backoffice/lib/i18n/fr.ts +++ b/apps/backoffice/lib/i18n/fr.ts @@ -512,6 +512,7 @@ const fr = { "prowlarr.sending": "Envoi...", "prowlarr.sentSuccess": "Envoyé à qBittorrent", "prowlarr.sentError": "Échec de l'envoi à qBittorrent", + "prowlarr.missingVol": "T{{vol}} manquant", // Settings - qBittorrent "settings.qbittorrent": "qBittorrent",