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:
2026-03-27 16:01:08 +01:00
parent 9c18802864
commit eabb88eb9d
11 changed files with 205 additions and 66 deletions

View File

@@ -1,7 +1,8 @@
"use client";
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";
interface QbContextValue {
@@ -34,22 +35,28 @@ export function QbittorrentDownloadButton({
libraryId,
seriesName,
expectedVolumes,
allVolumes,
}: {
downloadUrl: string;
releaseId: string;
libraryId?: string;
seriesName?: string;
expectedVolumes?: number[];
allVolumes?: number[];
}) {
const { t } = useTranslation();
const { configured, onDownloadStarted } = useContext(QbConfigContext);
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showConfirm, setShowConfirm] = useState(false);
if (!configured) return null;
async function handleSend() {
const hasExistingVolumes = allVolumes && expectedVolumes
&& allVolumes.length > expectedVolumes.length;
async function handleSend(volumes?: number[], replaceExisting = false) {
setSending(true);
setError(null);
try {
@@ -60,7 +67,8 @@ export function QbittorrentDownloadButton({
url: downloadUrl,
...(libraryId && { library_id: libraryId }),
...(seriesName && { series_name: seriesName }),
...(expectedVolumes && { expected_volumes: expectedVolumes }),
...((volumes || expectedVolumes) && { expected_volumes: volumes || expectedVolumes }),
...(replaceExisting && { replace_existing: true }),
}),
});
const data = await resp.json();
@@ -81,28 +89,71 @@ export function QbittorrentDownloadButton({
}
return (
<button
type="button"
onClick={handleSend}
disabled={sending}
className={`inline-flex items-center justify-center w-7 h-7 rounded-md transition-colors disabled:opacity-50 shrink-0 ${
sent
? "text-green-500"
: error
? "text-destructive"
: "text-primary hover:bg-primary/10"
}`}
title={sent ? t("prowlarr.sentSuccess") : error || t("prowlarr.sendToQbittorrent")}
>
{sending ? (
<Icon name="spinner" size="sm" className="animate-spin" />
) : sent ? (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 8l4 4 6-7" />
</svg>
) : (
<Icon name="download" size="sm" />
<>
<div className="inline-flex items-center gap-0.5">
<button
type="button"
onClick={() => handleSend()}
disabled={sending}
className={`inline-flex items-center justify-center w-7 h-7 rounded-md transition-colors disabled:opacity-50 shrink-0 ${
sent
? "text-green-500"
: error
? "text-destructive"
: "text-primary hover:bg-primary/10"
}`}
title={sent ? t("prowlarr.sentSuccess") : error || t("prowlarr.sendToQbittorrent")}
>
{sending ? (
<Icon name="spinner" size="sm" className="animate-spin" />
) : sent ? (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 8l4 4 6-7" />
</svg>
) : (
<Icon name="download" size="sm" />
)}
</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
)}
</button>
</>
);
}