Compare commits
3 Commits
70889ca955
...
54f972db17
| Author | SHA1 | Date | |
|---|---|---|---|
| 54f972db17 | |||
| acd8b62382 | |||
| cc65e3d1ad |
8
Cargo.lock
generated
8
Cargo.lock
generated
@@ -64,7 +64,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "api"
|
||||
version = "1.12.0"
|
||||
version = "1.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -1232,7 +1232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indexer"
|
||||
version = "1.12.0"
|
||||
version = "1.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -1771,7 +1771,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "parsers"
|
||||
version = "1.12.0"
|
||||
version = "1.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"flate2",
|
||||
@@ -2906,7 +2906,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "stripstream-core"
|
||||
version = "1.12.0"
|
||||
version = "1.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
|
||||
@@ -9,7 +9,7 @@ resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "1.12.0"
|
||||
version = "1.14.0"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "stripstream-backoffice",
|
||||
"version": "1.12.0",
|
||||
"version": "1.14.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 7082",
|
||||
|
||||
Reference in New Issue
Block a user