feat: modale Prowlarr avec bouton remplacer + fix parseur volumes
- Modale Prowlarr (page série) : remplacé le bouton qBittorrent brut par QbittorrentDownloadButton avec suivi managé (libraryId, seriesName, expectedVolumes) et bouton "télécharger et remplacer". - Ajout de alwaysShowReplace pour la modale Prowlarr (toujours montrer le bouton remplacer) vs la page downloads (seulement si allVolumes > expectedVolumes). - Fix parseur : les tags de version entre crochets [V2], [V3] ne sont plus extraits comme volumes (le préfixe "v" est ignoré après "["). - Progression qBittorrent : utilise directement le champ progress (completed et amount_left sont non-fiables sur qBittorrent 4.3.2). - Référence import : ne plus exclure les volumes attendus de la recherche de référence (corrige le mauvais dossier/nommage quand tous les volumes sont dans expected_volumes). - allVolumes ajouté à ProwlarrRelease (backend + frontend). - flex-wrap sur les pastilles volumes dans la modale Prowlarr. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -54,6 +54,9 @@ pub struct ProwlarrRelease {
|
||||
pub categories: Option<Vec<ProwlarrCategory>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub matched_missing_volumes: Option<Vec<i32>>,
|
||||
/// All volumes extracted from the release title (not just missing ones).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub all_volumes: Vec<i32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
@@ -202,6 +205,12 @@ fn extract_volumes_from_title(title: &str) -> Vec<i32> {
|
||||
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<i32> = 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]
|
||||
|
||||
@@ -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<bool> {
|
||||
});
|
||||
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<Vec<ImportedFile>> {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user