feat: bouton télécharger et remplacer + fix extraction volumes UTF-8
- Ajout d'un bouton "télécharger et remplacer" avec popup de confirmation, qui passe tous les volumes du pack (pas seulement les manquants) et replace_existing=true à l'API. - Nouvelle colonne replace_existing dans torrent_downloads. - Fix critique du parseur de volumes : le pass 2 mélangeait les indices d'octets (String::find) avec les indices de caractères (Vec<char>), causant un décalage quand le titre contenait des caractères multi-octets (é, à...). "Tome #097" extrayait 9 au lieu de 97. Réécrit en indexation char pure. - Le préfixe "tome" skip désormais "#" (tome #097 → 97). - Protection intra-batch : si une destination est déjà utilisée, le fichier garde son nom original au lieu d'écraser. - Alerte WARN si N fichiers source donnent N/3 volumes uniques. - Nettoyage du répertoire sl-{id} et de la catégorie qBittorrent après import. - Badges volumes en flex-wrap dans la page downloads. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,8 @@ pub struct AvailableReleaseDto {
|
|||||||
pub indexer: Option<String>,
|
pub indexer: Option<String>,
|
||||||
pub seeders: Option<i32>,
|
pub seeders: Option<i32>,
|
||||||
pub matched_missing_volumes: Vec<i32>,
|
pub matched_missing_volumes: Vec<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub all_volumes: Vec<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -714,7 +716,8 @@ async fn search_prowlarr_for_series(
|
|||||||
.filter_map(|r| {
|
.filter_map(|r| {
|
||||||
let title_volumes = prowlarr::extract_volumes_from_title_pub(&r.title);
|
let title_volumes = prowlarr::extract_volumes_from_title_pub(&r.title);
|
||||||
let matched_vols: Vec<i32> = title_volumes
|
let matched_vols: Vec<i32> = title_volumes
|
||||||
.into_iter()
|
.iter()
|
||||||
|
.copied()
|
||||||
.filter(|v| missing_volumes.contains(v))
|
.filter(|v| missing_volumes.contains(v))
|
||||||
.collect();
|
.collect();
|
||||||
if matched_vols.is_empty() {
|
if matched_vols.is_empty() {
|
||||||
@@ -727,6 +730,7 @@ async fn search_prowlarr_for_series(
|
|||||||
indexer: r.indexer,
|
indexer: r.indexer,
|
||||||
seeders: r.seeders,
|
seeders: r.seeders,
|
||||||
matched_missing_volumes: matched_vols,
|
matched_missing_volumes: matched_vols,
|
||||||
|
all_volumes: title_volumes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -175,26 +175,36 @@ fn extract_volumes_from_title(title: &str) -> Vec<i32> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pass 2 — individual volumes not already captured by range expansion
|
// Pass 2 — individual volumes not already captured by range expansion
|
||||||
let prefixes = ["tome", "vol.", "vol ", "t", "v", "#"];
|
// Note: work entirely with char indices (not byte offsets) to avoid
|
||||||
|
// mismatches when the title contains multi-byte UTF-8 characters.
|
||||||
|
let prefixes: &[(&[char], bool)] = &[
|
||||||
|
(&['t', 'o', 'm', 'e'], false),
|
||||||
|
(&['v', 'o', 'l', '.'], false),
|
||||||
|
(&['v', 'o', 'l', ' '], false),
|
||||||
|
(&['t'], true),
|
||||||
|
(&['v'], true),
|
||||||
|
(&['#'], false),
|
||||||
|
];
|
||||||
let len = chars.len();
|
let len = chars.len();
|
||||||
|
|
||||||
for prefix in &prefixes {
|
for &(prefix, needs_boundary) in prefixes {
|
||||||
let mut start = 0;
|
let plen = prefix.len();
|
||||||
while let Some(pos) = lower[start..].find(prefix) {
|
let mut ci = 0usize;
|
||||||
let abs_pos = start + pos;
|
while ci + plen <= len {
|
||||||
let after = abs_pos + prefix.len();
|
if chars[ci..ci + plen] != *prefix {
|
||||||
|
ci += 1;
|
||||||
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For single-char prefixes (t, v), ensure it's at a word boundary
|
||||||
|
if needs_boundary && ci > 0 && chars[ci - 1].is_alphanumeric() {
|
||||||
|
ci += plen;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip optional spaces or dots after prefix
|
// Skip optional spaces, dots, or '#' after prefix
|
||||||
let mut i = after;
|
let mut i = ci + plen;
|
||||||
while i < len && (chars[i] == ' ' || chars[i] == '.') {
|
while i < len && (chars[i] == ' ' || chars[i] == '.' || chars[i] == '#') {
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,14 +215,15 @@ fn extract_volumes_from_title(title: &str) -> Vec<i32> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if i > digit_start {
|
if i > digit_start {
|
||||||
if let Ok(num) = lower[digit_start..i].parse::<i32>() {
|
let num_str: String = chars[digit_start..i].iter().collect();
|
||||||
|
if let Ok(num) = num_str.parse::<i32>() {
|
||||||
if !volumes.contains(&num) {
|
if !volumes.contains(&num) {
|
||||||
volumes.push(num);
|
volumes.push(num);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
start = after;
|
ci += plen;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,4 +546,22 @@ mod tests {
|
|||||||
let v = extract_volumes_from_title("tool v2.0 release");
|
let v = extract_volumes_from_title("tool v2.0 release");
|
||||||
assert!(!v.contains(&0) || v.len() == 1); // only v2 at most
|
assert!(!v.contains(&0) || v.len() == 1); // only v2 at most
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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.
|
||||||
|
let v = sorted(extract_volumes_from_title(
|
||||||
|
"[Compressé] One Piece [Team Chromatique] - Tome #097 - [V2].cbz",
|
||||||
|
));
|
||||||
|
assert!(v.contains(&97), "expected 97 in {:?}", v);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tome_hash_single_digit() {
|
||||||
|
let v = sorted(extract_volumes_from_title(
|
||||||
|
"[Compressé] One Piece [Team Chromatique] - Tome #003 (Perfect Edition).cbz",
|
||||||
|
));
|
||||||
|
assert!(v.contains(&3), "expected 3 in {:?}", v);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ pub struct QBittorrentAddRequest {
|
|||||||
pub library_id: Option<Uuid>,
|
pub library_id: Option<Uuid>,
|
||||||
pub series_name: Option<String>,
|
pub series_name: Option<String>,
|
||||||
pub expected_volumes: Option<Vec<i32>>,
|
pub expected_volumes: Option<Vec<i32>>,
|
||||||
|
/// When true, overwrite existing files at destination during import.
|
||||||
|
#[serde(default)]
|
||||||
|
pub replace_existing: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -203,14 +206,15 @@ pub async fn add_torrent(
|
|||||||
|
|
||||||
let id = download_id.unwrap();
|
let id = download_id.unwrap();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO torrent_downloads (id, library_id, series_name, expected_volumes, qb_hash) \
|
"INSERT INTO torrent_downloads (id, library_id, series_name, expected_volumes, qb_hash, replace_existing) \
|
||||||
VALUES ($1, $2, $3, $4, $5)",
|
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(library_id)
|
.bind(library_id)
|
||||||
.bind(series_name)
|
.bind(series_name)
|
||||||
.bind(expected_volumes)
|
.bind(expected_volumes)
|
||||||
.bind(qb_hash.as_deref())
|
.bind(qb_hash.as_deref())
|
||||||
|
.bind(body.replace_existing)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -406,7 +406,7 @@ async fn is_torrent_import_enabled(pool: &PgPool) -> bool {
|
|||||||
|
|
||||||
async fn process_torrent_import(pool: PgPool, torrent_id: Uuid) -> anyhow::Result<()> {
|
async fn process_torrent_import(pool: PgPool, torrent_id: Uuid) -> anyhow::Result<()> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT library_id, series_name, expected_volumes, content_path, qb_hash \
|
"SELECT library_id, series_name, expected_volumes, content_path, qb_hash, replace_existing \
|
||||||
FROM torrent_downloads WHERE id = $1",
|
FROM torrent_downloads WHERE id = $1",
|
||||||
)
|
)
|
||||||
.bind(torrent_id)
|
.bind(torrent_id)
|
||||||
@@ -418,6 +418,7 @@ async fn process_torrent_import(pool: PgPool, torrent_id: Uuid) -> anyhow::Resul
|
|||||||
let expected_volumes: Vec<i32> = row.get("expected_volumes");
|
let expected_volumes: Vec<i32> = row.get("expected_volumes");
|
||||||
let content_path: Option<String> = row.get("content_path");
|
let content_path: Option<String> = row.get("content_path");
|
||||||
let qb_hash: Option<String> = row.get("qb_hash");
|
let qb_hash: Option<String> = row.get("qb_hash");
|
||||||
|
let replace_existing: bool = row.get("replace_existing");
|
||||||
let content_path =
|
let content_path =
|
||||||
content_path.ok_or_else(|| anyhow::anyhow!("content_path not set on torrent_download"))?;
|
content_path.ok_or_else(|| anyhow::anyhow!("content_path not set on torrent_download"))?;
|
||||||
|
|
||||||
@@ -428,7 +429,7 @@ async fn process_torrent_import(pool: PgPool, torrent_id: Uuid) -> anyhow::Resul
|
|||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
match do_import(&pool, library_id, &series_name, &expected_volumes, &content_path).await {
|
match do_import(&pool, library_id, &series_name, &expected_volumes, &content_path, replace_existing).await {
|
||||||
Ok(imported) => {
|
Ok(imported) => {
|
||||||
let json = serde_json::to_value(&imported).unwrap_or(serde_json::json!([]));
|
let json = serde_json::to_value(&imported).unwrap_or(serde_json::json!([]));
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -526,19 +527,19 @@ async fn process_torrent_import(pool: PgPool, torrent_id: Uuid) -> anyhow::Resul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up: remove source directory if it's a subdirectory of /downloads
|
// Clean up: remove the sl-{id} category directory and all its contents
|
||||||
let physical_content = remap_downloads_path(&content_path);
|
|
||||||
let downloads_root = remap_downloads_path("/downloads");
|
let downloads_root = remap_downloads_path("/downloads");
|
||||||
let content_p = std::path::Path::new(&physical_content);
|
let category_dir = remap_downloads_path(&format!("/downloads/sl-{torrent_id}"));
|
||||||
|
let category_p = std::path::Path::new(&category_dir);
|
||||||
let downloads_p = std::path::Path::new(&downloads_root);
|
let downloads_p = std::path::Path::new(&downloads_root);
|
||||||
if content_p.is_dir() && content_p != downloads_p && content_p.starts_with(downloads_p) {
|
if category_p.is_dir() && category_p != downloads_p && category_p.starts_with(downloads_p) {
|
||||||
match std::fs::remove_dir_all(content_p) {
|
match std::fs::remove_dir_all(category_p) {
|
||||||
Ok(()) => info!("[IMPORT] Cleaned up source directory: {}", physical_content),
|
Ok(()) => info!("[IMPORT] Cleaned up category directory: {}", category_dir),
|
||||||
Err(e) => warn!("[IMPORT] Failed to clean up {}: {}", physical_content, e),
|
Err(e) => warn!("[IMPORT] Failed to clean up {}: {}", category_dir, e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove torrent from qBittorrent
|
// Remove torrent and category from qBittorrent
|
||||||
if let Some(ref hash) = qb_hash {
|
if let Some(ref hash) = qb_hash {
|
||||||
if let Ok((base_url, username, password)) = load_qbittorrent_config(&pool).await {
|
if let Ok((base_url, username, password)) = load_qbittorrent_config(&pool).await {
|
||||||
if let Ok(client) = reqwest::Client::builder().timeout(Duration::from_secs(10)).build() {
|
if let Ok(client) = reqwest::Client::builder().timeout(Duration::from_secs(10)).build() {
|
||||||
@@ -550,6 +551,15 @@ async fn process_torrent_import(pool: PgPool, torrent_id: Uuid) -> anyhow::Resul
|
|||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
info!("[IMPORT] Removed torrent {} from qBittorrent", hash);
|
info!("[IMPORT] Removed torrent {} from qBittorrent", hash);
|
||||||
|
|
||||||
|
// Remove the sl-{id} category
|
||||||
|
let cat = format!("sl-{torrent_id}");
|
||||||
|
let _ = client
|
||||||
|
.post(format!("{base_url}/api/v2/torrents/removeCategories"))
|
||||||
|
.header("Cookie", format!("SID={sid}"))
|
||||||
|
.form(&[("categories", cat.as_str())])
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -584,6 +594,7 @@ async fn do_import(
|
|||||||
series_name: &str,
|
series_name: &str,
|
||||||
expected_volumes: &[i32],
|
expected_volumes: &[i32],
|
||||||
content_path: &str,
|
content_path: &str,
|
||||||
|
replace_existing: bool,
|
||||||
) -> anyhow::Result<Vec<ImportedFile>> {
|
) -> anyhow::Result<Vec<ImportedFile>> {
|
||||||
let physical_content = remap_downloads_path(content_path);
|
let physical_content = remap_downloads_path(content_path);
|
||||||
|
|
||||||
@@ -645,6 +656,7 @@ async fn do_import(
|
|||||||
info!("[IMPORT] Final reference: {:?}", reference);
|
info!("[IMPORT] Final reference: {:?}", reference);
|
||||||
|
|
||||||
let mut imported = Vec::new();
|
let mut imported = Vec::new();
|
||||||
|
let mut used_destinations: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
|
||||||
for source_path in collect_book_files(&physical_content)? {
|
for source_path in collect_book_files(&physical_content)? {
|
||||||
let filename = std::path::Path::new(&source_path)
|
let filename = std::path::Path::new(&source_path)
|
||||||
@@ -656,26 +668,37 @@ async fn do_import(
|
|||||||
.and_then(|e| e.to_str())
|
.and_then(|e| e.to_str())
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
|
|
||||||
let matched: Vec<i32> = extract_volumes_from_title_pub(filename)
|
let all_extracted = extract_volumes_from_title_pub(filename);
|
||||||
.into_iter()
|
let matched: Vec<i32> = all_extracted
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
.filter(|v| expected_set.contains(v))
|
.filter(|v| expected_set.contains(v))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if matched.is_empty() {
|
if matched.is_empty() {
|
||||||
|
info!("[IMPORT] Skipping '{}' (extracted volumes {:?}, none in expected set)", filename, all_extracted);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let target_filename = if matched.len() == 1 {
|
let target_filename = if matched.len() == 1 {
|
||||||
// Single volume: apply naming pattern from reference
|
// Single volume: apply naming pattern from reference
|
||||||
let vol = matched[0];
|
let vol = matched[0];
|
||||||
if let Some((ref ref_path, ref_vol)) = reference {
|
let generated = if let Some((ref ref_path, ref_vol)) = reference {
|
||||||
let built = build_target_filename(ref_path, ref_vol, vol, ext);
|
let built = build_target_filename(ref_path, ref_vol, vol, ext);
|
||||||
info!("[IMPORT] build_target_filename(ref={}, ref_vol={}, new_vol={}, ext={}) => {:?}",
|
info!("[IMPORT] build_target_filename(ref={}, ref_vol={}, new_vol={}, ext={}) => {:?} (source='{}')",
|
||||||
ref_path, ref_vol, vol, ext, built);
|
ref_path, ref_vol, vol, ext, built, filename);
|
||||||
built.unwrap_or_else(|| default_filename(series_name, vol, ext))
|
built.unwrap_or_else(|| default_filename(series_name, vol, ext))
|
||||||
} else {
|
} else {
|
||||||
info!("[IMPORT] No reference, using default_filename for vol {}", vol);
|
info!("[IMPORT] No reference, using default_filename for vol {} (source='{}')", vol, filename);
|
||||||
default_filename(series_name, vol, ext)
|
default_filename(series_name, vol, ext)
|
||||||
|
};
|
||||||
|
|
||||||
|
// If this destination was already used in this batch, keep original filename
|
||||||
|
if used_destinations.contains(&generated) {
|
||||||
|
info!("[IMPORT] Destination '{}' already used in this batch, keeping original filename '{}'", generated, filename);
|
||||||
|
filename.to_string()
|
||||||
|
} else {
|
||||||
|
generated
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Multi-volume pack: keep original filename (scanner handles ranges)
|
// Multi-volume pack: keep original filename (scanner handles ranges)
|
||||||
@@ -684,13 +707,14 @@ async fn do_import(
|
|||||||
|
|
||||||
let dest = format!("{}/{}", target_dir, target_filename);
|
let dest = format!("{}/{}", target_dir, target_filename);
|
||||||
|
|
||||||
if std::path::Path::new(&dest).exists() {
|
if std::path::Path::new(&dest).exists() && !replace_existing {
|
||||||
info!("Skipping {} (already exists at destination)", dest);
|
info!("[IMPORT] Skipping '{}' → '{}' (already exists at destination)", filename, dest);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
move_file(&source_path, &dest)?;
|
move_file(&source_path, &dest)?;
|
||||||
info!("Imported {:?} → {}", matched, dest);
|
used_destinations.insert(target_filename);
|
||||||
|
info!("[IMPORT] Imported '{}' [{:?}] → {}", filename, matched, dest);
|
||||||
|
|
||||||
imported.push(ImportedFile {
|
imported.push(ImportedFile {
|
||||||
volume: *matched.iter().min().unwrap(),
|
volume: *matched.iter().min().unwrap(),
|
||||||
@@ -699,6 +723,24 @@ async fn do_import(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sanity check: warn if many source files collapsed into few volumes
|
||||||
|
// (symptom of a volume extraction bug)
|
||||||
|
let source_count = collect_book_files(&physical_content).map(|f| f.len()).unwrap_or(0);
|
||||||
|
let unique_volumes: std::collections::HashSet<i32> = imported.iter().map(|f| f.volume).collect();
|
||||||
|
if source_count > 5 && unique_volumes.len() > 0 && source_count > unique_volumes.len() * 3 {
|
||||||
|
warn!(
|
||||||
|
"[IMPORT] Suspicious: {} source files mapped to only {} unique volumes ({:?}). \
|
||||||
|
Possible volume extraction issue for series '{}'",
|
||||||
|
source_count, unique_volumes.len(),
|
||||||
|
{
|
||||||
|
let mut v: Vec<i32> = unique_volumes.into_iter().collect();
|
||||||
|
v.sort();
|
||||||
|
v
|
||||||
|
},
|
||||||
|
series_name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(imported)
|
Ok(imported)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ function AvailableLibraryCard({ lib }: { lib: LatestFoundPerLibraryDto }) {
|
|||||||
<span className="text-[10px] text-success font-medium">{release.seeders}S</span>
|
<span className="text-[10px] text-success font-medium">{release.seeders}S</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] text-muted-foreground">{(release.size / 1024 / 1024).toFixed(0)} MB</span>
|
<span className="text-[10px] text-muted-foreground">{(release.size / 1024 / 1024).toFixed(0)} MB</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
{release.matched_missing_volumes.map(vol => (
|
{release.matched_missing_volumes.map(vol => (
|
||||||
<span key={vol} className="text-[10px] px-1 py-0.5 rounded-full bg-success/20 text-success font-medium">T{vol}</span>
|
<span key={vol} className="text-[10px] px-1 py-0.5 rounded-full bg-success/20 text-success font-medium">T{vol}</span>
|
||||||
))}
|
))}
|
||||||
@@ -398,6 +398,7 @@ function AvailableLibraryCard({ lib }: { lib: LatestFoundPerLibraryDto }) {
|
|||||||
libraryId={lib.library_id}
|
libraryId={lib.library_id}
|
||||||
seriesName={r.series_name}
|
seriesName={r.series_name}
|
||||||
expectedVolumes={release.matched_missing_volumes}
|
expectedVolumes={release.matched_missing_volumes}
|
||||||
|
allVolumes={release.all_volumes}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export function DownloadDetectionResultsCard({ results, libraryId, qbConfigured,
|
|||||||
libraryId={libraryId ?? undefined}
|
libraryId={libraryId ?? undefined}
|
||||||
seriesName={r.series_name}
|
seriesName={r.series_name}
|
||||||
expectedVolumes={release.matched_missing_volumes}
|
expectedVolumes={release.matched_missing_volumes}
|
||||||
|
allVolumes={release.all_volumes}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, createContext, useContext, type ReactNode } from "react";
|
import { useState, useEffect, createContext, useContext, type ReactNode } from "react";
|
||||||
import { Icon } from "./ui";
|
import { createPortal } from "react-dom";
|
||||||
|
import { Icon, Button } from "./ui";
|
||||||
import { useTranslation } from "@/lib/i18n/context";
|
import { useTranslation } from "@/lib/i18n/context";
|
||||||
|
|
||||||
interface QbContextValue {
|
interface QbContextValue {
|
||||||
@@ -34,22 +35,28 @@ export function QbittorrentDownloadButton({
|
|||||||
libraryId,
|
libraryId,
|
||||||
seriesName,
|
seriesName,
|
||||||
expectedVolumes,
|
expectedVolumes,
|
||||||
|
allVolumes,
|
||||||
}: {
|
}: {
|
||||||
downloadUrl: string;
|
downloadUrl: string;
|
||||||
releaseId: string;
|
releaseId: string;
|
||||||
libraryId?: string;
|
libraryId?: string;
|
||||||
seriesName?: string;
|
seriesName?: string;
|
||||||
expectedVolumes?: number[];
|
expectedVolumes?: number[];
|
||||||
|
allVolumes?: number[];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { configured, onDownloadStarted } = useContext(QbConfigContext);
|
const { configured, onDownloadStarted } = useContext(QbConfigContext);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [sent, setSent] = useState(false);
|
const [sent, setSent] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
if (!configured) return null;
|
if (!configured) return null;
|
||||||
|
|
||||||
async function handleSend() {
|
const hasExistingVolumes = allVolumes && expectedVolumes
|
||||||
|
&& allVolumes.length > expectedVolumes.length;
|
||||||
|
|
||||||
|
async function handleSend(volumes?: number[], replaceExisting = false) {
|
||||||
setSending(true);
|
setSending(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -60,7 +67,8 @@ export function QbittorrentDownloadButton({
|
|||||||
url: downloadUrl,
|
url: downloadUrl,
|
||||||
...(libraryId && { library_id: libraryId }),
|
...(libraryId && { library_id: libraryId }),
|
||||||
...(seriesName && { series_name: seriesName }),
|
...(seriesName && { series_name: seriesName }),
|
||||||
...(expectedVolumes && { expected_volumes: expectedVolumes }),
|
...((volumes || expectedVolumes) && { expected_volumes: volumes || expectedVolumes }),
|
||||||
|
...(replaceExisting && { replace_existing: true }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
@@ -81,9 +89,11 @@ export function QbittorrentDownloadButton({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<div className="inline-flex items-center gap-0.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSend}
|
onClick={() => handleSend()}
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
className={`inline-flex items-center justify-center w-7 h-7 rounded-md transition-colors disabled:opacity-50 shrink-0 ${
|
className={`inline-flex items-center justify-center w-7 h-7 rounded-md transition-colors disabled:opacity-50 shrink-0 ${
|
||||||
sent
|
sent
|
||||||
@@ -104,5 +114,46 @@ export function QbittorrentDownloadButton({
|
|||||||
<Icon name="download" size="sm" />
|
<Icon name="download" size="sm" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{hasExistingVolumes && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirm(true)}
|
||||||
|
disabled={sending}
|
||||||
|
className="inline-flex items-center justify-center w-7 h-7 rounded-md transition-colors disabled:opacity-50 shrink-0 text-warning hover:bg-warning/10"
|
||||||
|
title={t("prowlarr.replaceAndDownload")}
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showConfirm && createPortal(
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50" onClick={() => setShowConfirm(false)} />
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-sm overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||||
|
{t("prowlarr.replaceAndDownload")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("prowlarr.confirmReplace")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 px-6 pb-6">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setShowConfirm(false)}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" onClick={() => { setShowConfirm(false); handleSend(allVolumes, true); }}>
|
||||||
|
{t("prowlarr.replaceAndDownload")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1162,6 +1162,7 @@ export type AvailableReleaseDto = {
|
|||||||
indexer: string | null;
|
indexer: string | null;
|
||||||
seeders: number | null;
|
seeders: number | null;
|
||||||
matched_missing_volumes: number[];
|
matched_missing_volumes: number[];
|
||||||
|
all_volumes: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DownloadDetectionReportDto = {
|
export type DownloadDetectionReportDto = {
|
||||||
|
|||||||
@@ -645,6 +645,8 @@ 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.replaceAndDownload": "Download and replace existing",
|
||||||
|
"prowlarr.confirmReplace": "This will re-download all volumes in the pack, including those already present. Continue?",
|
||||||
"prowlarr.missingVol": "Vol. {{vol}} missing",
|
"prowlarr.missingVol": "Vol. {{vol}} missing",
|
||||||
|
|
||||||
// Settings - qBittorrent
|
// Settings - qBittorrent
|
||||||
|
|||||||
@@ -643,6 +643,8 @@ 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.replaceAndDownload": "Télécharger et remplacer les existants",
|
||||||
|
"prowlarr.confirmReplace": "Cela va retélécharger tous les volumes du pack, y compris ceux déjà présents. Continuer ?",
|
||||||
"prowlarr.missingVol": "T{{vol}} manquant",
|
"prowlarr.missingVol": "T{{vol}} manquant",
|
||||||
|
|
||||||
// Settings - qBittorrent
|
// Settings - qBittorrent
|
||||||
|
|||||||
2
infra/migrations/0068_add_torrent_replace_existing.sql
Normal file
2
infra/migrations/0068_add_torrent_replace_existing.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE torrent_downloads
|
||||||
|
ADD COLUMN replace_existing BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
Reference in New Issue
Block a user