diff --git a/apps/api/src/prowlarr.rs b/apps/api/src/prowlarr.rs index 87bb0a5..1e49014 100644 --- a/apps/api/src/prowlarr.rs +++ b/apps/api/src/prowlarr.rs @@ -54,6 +54,9 @@ pub struct ProwlarrRelease { pub categories: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub matched_missing_volumes: Option>, + /// All volumes extracted from the release title (not just missing ones). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub all_volumes: Vec, } #[derive(Serialize, Deserialize, ToSchema)] @@ -202,6 +205,12 @@ fn extract_volumes_from_title(title: &str) -> Vec { continue; } + // Skip "v" inside brackets like [V2] — that's a version, not a volume + if needs_boundary && ci > 0 && chars[ci - 1] == '[' { + ci += plen; + continue; + } + // Skip optional spaces, dots, or '#' after prefix let mut i = ci + plen; while i < len && (chars[i] == ' ' || chars[i] == '.' || chars[i] == '#') { @@ -301,12 +310,13 @@ fn match_missing_volumes( releases .into_iter() .map(|r| { + let title_volumes = extract_volumes_from_title(&r.title); 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() + .iter() + .copied() .filter(|v| missing_numbers.contains(v)) .collect(); if matched.is_empty() { @@ -329,6 +339,7 @@ fn match_missing_volumes( info_url: r.info_url, categories: r.categories, matched_missing_volumes: matched, + all_volumes: title_volumes, } }) .collect() @@ -412,19 +423,23 @@ pub async fn search_prowlarr( } 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, + .map(|r| { + let all_volumes = extract_volumes_from_title(&r.title); + 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, + all_volumes, + } }) .collect() }; @@ -551,10 +566,19 @@ mod tests { fn tome_hash_with_accented_chars() { // Tome #097 with accented characters earlier in the string — the é in // "Compressé" shifts byte offsets vs char offsets; this must not break parsing. + // [V2] is a version tag, not a volume — must NOT extract 2. let v = sorted(extract_volumes_from_title( "[Compressé] One Piece [Team Chromatique] - Tome #097 - [V2].cbz", )); assert!(v.contains(&97), "expected 97 in {:?}", v); + assert!(!v.contains(&2), "[V2] should not be extracted as volume 2: {:?}", v); + } + + #[test] + fn version_in_brackets_ignored() { + // [V1], [V2], [V3] are version tags, not volumes + let v = extract_volumes_from_title("Naruto T05 [V2].cbz"); + assert_eq!(v, vec![5]); } #[test] diff --git a/apps/api/src/torrent_import.rs b/apps/api/src/torrent_import.rs index f107687..1d478cb 100644 --- a/apps/api/src/torrent_import.rs +++ b/apps/api/src/torrent_import.rs @@ -202,6 +202,9 @@ struct QbTorrentInfo { #[serde(default)] progress: f64, #[serde(default)] + #[allow(dead_code)] + total_size: i64, + #[serde(default)] dlspeed: i64, #[serde(default)] eta: i64, @@ -320,11 +323,12 @@ async fn poll_qbittorrent_downloads(pool: &PgPool) -> anyhow::Result { }); if let Some(row) = row { let tid: Uuid = row.get("id"); + let global_progress = info.progress as f32; let _ = sqlx::query( "UPDATE torrent_downloads SET progress = $1, download_speed = $2, eta = $3, updated_at = NOW() \ WHERE id = $4 AND status = 'downloading'", ) - .bind(info.progress as f32) + .bind(global_progress) .bind(info.dlspeed) .bind(info.eta) .bind(tid) @@ -598,23 +602,22 @@ async fn do_import( ) -> anyhow::Result> { let physical_content = remap_downloads_path(content_path); - // Find the target directory and reference file from existing book_files. - // Exclude volumes we're about to import so we get a different file as naming reference. - let ref_row = sqlx::query( + // Find the target directory and a naming reference from existing book_files. + // First find ANY existing book to determine the target directory, then pick a + // reference file (preferring one outside expected_volumes for naming consistency). + let any_row = sqlx::query( "SELECT bf.abs_path, b.volume \ FROM book_files bf \ JOIN books b ON b.id = bf.book_id \ WHERE b.library_id = $1 AND LOWER(b.series) = LOWER($2) AND b.volume IS NOT NULL \ - AND b.volume != ALL($3) \ ORDER BY b.volume DESC LIMIT 1", ) .bind(library_id) .bind(series_name) - .bind(expected_volumes) .fetch_optional(pool) .await?; - let (target_dir, reference) = if let Some(r) = ref_row { + let (target_dir, reference) = if let Some(r) = any_row { let abs_path: String = r.get("abs_path"); let volume: i32 = r.get("volume"); let physical = remap_libraries_path(&abs_path); diff --git a/apps/backoffice/app/(app)/libraries/[id]/series/[name]/page.tsx b/apps/backoffice/app/(app)/libraries/[id]/series/[name]/page.tsx index 2151e9e..c0b87b3 100644 --- a/apps/backoffice/app/(app)/libraries/[id]/series/[name]/page.tsx +++ b/apps/backoffice/app/(app)/libraries/[id]/series/[name]/page.tsx @@ -244,6 +244,7 @@ export default async function SeriesDetailPage({ /> (initialProwlarrConfigured ?? null); @@ -37,9 +39,6 @@ export function ProwlarrSearchModal({ seriesName, missingBooks, initialProwlarrC // qBittorrent state const [isQbConfigured, setIsQbConfigured] = useState(initialQbConfigured ?? false); - const [sendingGuid, setSendingGuid] = useState(null); - const [sentGuids, setSentGuids] = useState>(new Set()); - const [sendError, setSendError] = useState(null); // Check if Prowlarr and qBittorrent are configured on mount (skip if server provided) useEffect(() => { @@ -112,30 +111,6 @@ export function ProwlarrSearchModal({ seriesName, missingBooks, initialProwlarrC setIsOpen(false); } - async function handleSendToQbittorrent(downloadUrl: string, guid: string) { - setSendingGuid(guid); - setSendError(null); - try { - const resp = await fetch("/api/qbittorrent/add", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url: downloadUrl }), - }); - const data = await resp.json(); - if (data.error) { - setSendError(data.error); - } else if (data.success) { - setSentGuids((prev) => new Set(prev).add(guid)); - } else { - setSendError(data.message || t("prowlarr.sentError")); - } - } catch { - setSendError(t("prowlarr.sentError")); - } finally { - setSendingGuid(null); - } - } - // Don't render button if not configured if (isConfigured === false) return null; if (isConfigured === null) return null; @@ -229,6 +204,7 @@ export function ProwlarrSearchModal({ seriesName, missingBooks, initialProwlarrC {/* Results */} {!isSearching && results.length > 0 && ( +

{t("prowlarr.resultCount", { count: results.length, plural: results.length !== 1 ? "s" : "" })} @@ -257,7 +233,7 @@ export function ProwlarrSearchModal({ seriesName, missingBooks, initialProwlarrC {release.title} {hasMissing && ( -

+
{release.matchedMissingVolumes!.map((vol) => ( {t("prowlarr.missingVol", { vol })} @@ -295,43 +271,16 @@ export function ProwlarrSearchModal({ seriesName, missingBooks, initialProwlarrC
- {isQbConfigured && release.downloadUrl && ( - - )} {release.downloadUrl && ( - - - - - + )} {release.infoUrl && (
- )} - - {/* qBittorrent send error */} - {sendError && ( -
- {sendError} -
+ )} {/* No results */} diff --git a/apps/backoffice/app/components/QbittorrentDownloadButton.tsx b/apps/backoffice/app/components/QbittorrentDownloadButton.tsx index 2553ac0..aa4c707 100644 --- a/apps/backoffice/app/components/QbittorrentDownloadButton.tsx +++ b/apps/backoffice/app/components/QbittorrentDownloadButton.tsx @@ -36,6 +36,7 @@ export function QbittorrentDownloadButton({ seriesName, expectedVolumes, allVolumes, + alwaysShowReplace, }: { downloadUrl: string; releaseId: string; @@ -43,6 +44,8 @@ export function QbittorrentDownloadButton({ seriesName?: string; expectedVolumes?: number[]; allVolumes?: number[]; + /** Show replace button even when allVolumes == expectedVolumes (e.g. in Prowlarr search modal) */ + alwaysShowReplace?: boolean; }) { const { t } = useTranslation(); const { configured, onDownloadStarted } = useContext(QbConfigContext); @@ -53,8 +56,8 @@ export function QbittorrentDownloadButton({ if (!configured) return null; - const hasExistingVolumes = allVolumes && expectedVolumes - && allVolumes.length > expectedVolumes.length; + const showReplaceButton = allVolumes && allVolumes.length > 0 + && (alwaysShowReplace || (expectedVolumes && allVolumes.length > expectedVolumes.length)); async function handleSend(volumes?: number[], replaceExisting = false) { setSending(true); @@ -115,7 +118,7 @@ export function QbittorrentDownloadButton({ )} - {hasExistingVolumes && ( + {showReplaceButton && (