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]]
|
[[package]]
|
||||||
name = "api"
|
name = "api"
|
||||||
version = "1.12.0"
|
version = "1.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -1232,7 +1232,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexer"
|
name = "indexer"
|
||||||
version = "1.12.0"
|
version = "1.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1771,7 +1771,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parsers"
|
name = "parsers"
|
||||||
version = "1.12.0"
|
version = "1.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -2906,7 +2906,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stripstream-core"
|
name = "stripstream-core"
|
||||||
version = "1.12.0"
|
version = "1.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ resolver = "2"
|
|||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
version = "1.12.0"
|
version = "1.14.0"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
|||||||
@@ -7,15 +7,39 @@ use crate::{error::ApiError, state::AppState};
|
|||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct MissingVolumeInput {
|
||||||
|
pub volume_number: Option<i32>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub struct ProwlarrSearchRequest {
|
pub struct ProwlarrSearchRequest {
|
||||||
pub series_name: String,
|
pub series_name: String,
|
||||||
pub volume_number: Option<i32>,
|
pub volume_number: Option<i32>,
|
||||||
pub custom_query: Option<String>,
|
pub custom_query: Option<String>,
|
||||||
|
pub missing_volumes: Option<Vec<MissingVolumeInput>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[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 struct ProwlarrRelease {
|
||||||
pub guid: String,
|
pub guid: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
@@ -28,6 +52,8 @@ pub struct ProwlarrRelease {
|
|||||||
pub protocol: Option<String>,
|
pub protocol: Option<String>,
|
||||||
pub info_url: Option<String>,
|
pub info_url: Option<String>,
|
||||||
pub categories: Option<Vec<ProwlarrCategory>>,
|
pub categories: Option<Vec<ProwlarrCategory>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub matched_missing_volumes: Option<Vec<i32>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
@@ -83,6 +109,107 @@ async fn load_prowlarr_config(
|
|||||||
Ok((url, config.api_key, categories))
|
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 ───────────────────────────────────────────────────────────────
|
// ─── Handlers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Search for releases on Prowlarr
|
/// Search for releases on Prowlarr
|
||||||
@@ -149,13 +276,35 @@ pub async fn search_prowlarr(
|
|||||||
|
|
||||||
tracing::debug!("Prowlarr raw response length: {} chars", raw_text.len());
|
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| {
|
.map_err(|e| {
|
||||||
tracing::error!("Failed to parse Prowlarr response: {e}");
|
tracing::error!("Failed to parse Prowlarr response: {e}");
|
||||||
tracing::error!("Raw response (first 500 chars): {}", &raw_text[..raw_text.len().min(500)]);
|
tracing::error!("Raw response (first 500 chars): {}", &raw_text[..raw_text.len().min(500)]);
|
||||||
ApiError::internal(format!("Failed to parse Prowlarr response: {e}"))
|
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 }))
|
Ok(Json(ProwlarrSearchResponse { results, query }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,11 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch
|
|||||||
setError(null);
|
setError(null);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
try {
|
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", {
|
const resp = await fetch("/api/prowlarr/search", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -237,12 +241,23 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{results.map((release, i) => (
|
{results.map((release, i) => {
|
||||||
<tr key={release.guid || i} className="hover:bg-muted/20 transition-colors">
|
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]">
|
<td className="px-3 py-2 max-w-[400px]">
|
||||||
<span className="truncate block" title={release.title}>
|
<span className="truncate block" title={release.title}>
|
||||||
{release.title}
|
{release.title}
|
||||||
</span>
|
</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>
|
||||||
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
|
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
|
||||||
{release.indexer || "—"}
|
{release.indexer || "—"}
|
||||||
@@ -325,7 +340,8 @@ export function ProwlarrSearchModal({ seriesName, missingBooks }: ProwlarrSearch
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -939,6 +939,7 @@ export type ProwlarrRelease = {
|
|||||||
protocol: string | null;
|
protocol: string | null;
|
||||||
infoUrl: string | null;
|
infoUrl: string | null;
|
||||||
categories: ProwlarrCategory[] | null;
|
categories: ProwlarrCategory[] | null;
|
||||||
|
matchedMissingVolumes: number[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProwlarrSearchResponse = {
|
export type ProwlarrSearchResponse = {
|
||||||
|
|||||||
@@ -514,6 +514,7 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"prowlarr.sending": "Sending...",
|
"prowlarr.sending": "Sending...",
|
||||||
"prowlarr.sentSuccess": "Sent to qBittorrent",
|
"prowlarr.sentSuccess": "Sent to qBittorrent",
|
||||||
"prowlarr.sentError": "Failed to send to qBittorrent",
|
"prowlarr.sentError": "Failed to send to qBittorrent",
|
||||||
|
"prowlarr.missingVol": "Vol. {{vol}} missing",
|
||||||
|
|
||||||
// Settings - qBittorrent
|
// Settings - qBittorrent
|
||||||
"settings.qbittorrent": "qBittorrent",
|
"settings.qbittorrent": "qBittorrent",
|
||||||
|
|||||||
@@ -512,6 +512,7 @@ const fr = {
|
|||||||
"prowlarr.sending": "Envoi...",
|
"prowlarr.sending": "Envoi...",
|
||||||
"prowlarr.sentSuccess": "Envoyé à qBittorrent",
|
"prowlarr.sentSuccess": "Envoyé à qBittorrent",
|
||||||
"prowlarr.sentError": "Échec de l'envoi à qBittorrent",
|
"prowlarr.sentError": "Échec de l'envoi à qBittorrent",
|
||||||
|
"prowlarr.missingVol": "T{{vol}} manquant",
|
||||||
|
|
||||||
// Settings - qBittorrent
|
// Settings - qBittorrent
|
||||||
"settings.qbittorrent": "qBittorrent",
|
"settings.qbittorrent": "qBittorrent",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "stripstream-backoffice",
|
"name": "stripstream-backoffice",
|
||||||
"version": "1.12.0",
|
"version": "1.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 7082",
|
"dev": "next dev -p 7082",
|
||||||
|
|||||||
Reference in New Issue
Block a user