feat: highlight missing volumes in Prowlarr search results
API extracts volume numbers from release titles and matches them against missing volumes sent by the frontend. Matched results are highlighted in green with badges indicating which missing volumes were found. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,15 +7,39 @@ use crate::{error::ApiError, state::AppState};
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct MissingVolumeInput {
|
||||
pub volume_number: Option<i32>,
|
||||
#[allow(dead_code)]
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ProwlarrSearchRequest {
|
||||
pub series_name: String,
|
||||
pub volume_number: Option<i32>,
|
||||
pub custom_query: Option<String>,
|
||||
pub missing_volumes: Option<Vec<MissingVolumeInput>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProwlarrRawRelease {
|
||||
pub guid: String,
|
||||
pub title: String,
|
||||
pub size: i64,
|
||||
pub download_url: Option<String>,
|
||||
pub indexer: Option<String>,
|
||||
pub seeders: Option<i32>,
|
||||
pub leechers: Option<i32>,
|
||||
pub publish_date: Option<String>,
|
||||
pub protocol: Option<String>,
|
||||
pub info_url: Option<String>,
|
||||
pub categories: Option<Vec<ProwlarrCategory>>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub info_url: Option<String>,
|
||||
pub categories: Option<Vec<ProwlarrCategory>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub matched_missing_volumes: Option<Vec<i32>>,
|
||||
}
|
||||
|
||||
#[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<i32> {
|
||||
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<char> = 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::<i32>() {
|
||||
if !volumes.contains(&num) {
|
||||
volumes.push(num);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start = after;
|
||||
}
|
||||
}
|
||||
|
||||
volumes
|
||||
}
|
||||
|
||||
/// Match releases against missing volume numbers.
|
||||
fn match_missing_volumes(
|
||||
releases: Vec<ProwlarrRawRelease>,
|
||||
missing: &[MissingVolumeInput],
|
||||
) -> Vec<ProwlarrRelease> {
|
||||
let missing_numbers: Vec<i32> = 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<i32> = 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<ProwlarrRelease> = serde_json::from_str(&raw_text)
|
||||
let raw_releases: Vec<ProwlarrRawRelease> = 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 }))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{results.map((release, i) => (
|
||||
<tr key={release.guid || i} className="hover:bg-muted/20 transition-colors">
|
||||
{results.map((release, i) => {
|
||||
const hasMissing = release.matchedMissingVolumes && release.matchedMissingVolumes.length > 0;
|
||||
return (
|
||||
<tr key={release.guid || i} className={`transition-colors ${hasMissing ? "bg-green-500/10 hover:bg-green-500/20 border-l-2 border-l-green-500" : "hover:bg-muted/20"}`}>
|
||||
<td className="px-3 py-2 max-w-[400px]">
|
||||
<span className="truncate block" title={release.title}>
|
||||
{release.title}
|
||||
</span>
|
||||
{hasMissing && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
{release.matchedMissingVolumes!.map((vol) => (
|
||||
<span key={vol} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-500/20 text-green-600">
|
||||
{t("prowlarr.missingVol", { vol })}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
|
||||
{release.indexer || "—"}
|
||||
@@ -325,7 +340,8 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -939,6 +939,7 @@ export type ProwlarrRelease = {
|
||||
protocol: string | null;
|
||||
infoUrl: string | null;
|
||||
categories: ProwlarrCategory[] | null;
|
||||
matchedMissingVolumes: number[] | null;
|
||||
};
|
||||
|
||||
export type ProwlarrSearchResponse = {
|
||||
|
||||
@@ -514,6 +514,7 @@ const en: Record<TranslationKey, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user