feat: section disponibles au téléchargement + fix nommage import
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 43s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 43s
- Endpoint GET /download-detection/latest-found : résultats "found" du dernier job de détection par bibliothèque - Section dans la page Téléchargements avec les releases disponibles groupées par bibliothèque, bouton qBittorrent intégré - Fix nommage import : exclut les volumes importés de la recherche de référence (évite le cercle vicieux vol 8 → ref vol 8 → même nom) - Fix extraction volumes : gère "Tome.007" (point après préfixe) en plus de "Tome 007" dans extract_volumes_from_title - Fallback disque pour la référence de nommage quand la DB ne matche pas - Logging détaillé du processus d'import pour debug Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { TorrentDownloadDto } from "@/lib/api";
|
||||
import { Card, CardContent, Button, Icon } from "@/app/components/ui";
|
||||
import { TorrentDownloadDto, LatestFoundPerLibraryDto } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle, Button, Icon } from "@/app/components/ui";
|
||||
import { QbittorrentProvider, QbittorrentDownloadButton } from "@/app/components/QbittorrentDownloadButton";
|
||||
import { useTranslation } from "@/lib/i18n/context";
|
||||
import type { TranslationKey } from "@/lib/i18n/fr";
|
||||
|
||||
@@ -62,9 +63,10 @@ function formatEta(seconds: number): string {
|
||||
|
||||
interface DownloadsPageProps {
|
||||
initialDownloads: TorrentDownloadDto[];
|
||||
initialLatestFound: LatestFoundPerLibraryDto[];
|
||||
}
|
||||
|
||||
export function DownloadsPage({ initialDownloads }: DownloadsPageProps) {
|
||||
export function DownloadsPage({ initialDownloads, initialLatestFound }: DownloadsPageProps) {
|
||||
const { t } = useTranslation();
|
||||
const [downloads, setDownloads] = useState<TorrentDownloadDto[]>(initialDownloads);
|
||||
const [filter, setFilter] = useState<string>("all");
|
||||
@@ -154,6 +156,23 @@ export function DownloadsPage({ initialDownloads }: DownloadsPageProps) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available downloads from latest detection */}
|
||||
{initialLatestFound.length > 0 && (
|
||||
<QbittorrentProvider>
|
||||
<div className="mt-10">
|
||||
<h2 className="text-xl font-bold text-foreground mb-4 flex items-center gap-2">
|
||||
<Icon name="search" size="lg" />
|
||||
{t("downloads.availableTitle")}
|
||||
</h2>
|
||||
<div className="space-y-6">
|
||||
{initialLatestFound.map(lib => (
|
||||
<AvailableLibraryCard key={lib.library_id} lib={lib} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</QbittorrentProvider>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -314,3 +333,77 @@ function DownloadCard({ dl, onDeleted }: { dl: TorrentDownloadDto; onDeleted: ()
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AvailableLibraryCard({ lib }: { lib: LatestFoundPerLibraryDto }) {
|
||||
const { t } = useTranslation();
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const displayResults = collapsed ? lib.results.slice(0, 5) : lib.results;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">{lib.library_name}</CardTitle>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("downloads.detectedSeries", { count: lib.results.length })} — {formatDate(lib.job_date)}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{displayResults.map(r => (
|
||||
<div key={r.id} className="rounded-lg border border-border/40 bg-background/60 p-3">
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
<span className="font-semibold text-sm text-foreground truncate">{r.series_name}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap bg-warning/20 text-warning shrink-0">
|
||||
{r.missing_count} {t("downloads.missing")}
|
||||
</span>
|
||||
</div>
|
||||
{r.available_releases && r.available_releases.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{r.available_releases.map((release, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 py-1 pl-2 rounded bg-muted/30">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-mono text-foreground truncate" title={release.title}>{release.title}</p>
|
||||
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
|
||||
{release.indexer && <span className="text-[10px] text-muted-foreground">{release.indexer}</span>}
|
||||
{release.seeders != null && (
|
||||
<span className="text-[10px] text-success font-medium">{release.seeders} seeders</span>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground">{(release.size / 1024 / 1024).toFixed(0)} MB</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{release.matched_missing_volumes.map(vol => (
|
||||
<span key={vol} className="text-[10px] px-1.5 py-0.5 rounded-full bg-success/20 text-success font-medium">T.{vol}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{release.download_url && (
|
||||
<QbittorrentDownloadButton
|
||||
downloadUrl={release.download_url}
|
||||
releaseId={`${r.id}-${idx}`}
|
||||
libraryId={lib.library_id}
|
||||
seriesName={r.series_name}
|
||||
expectedVolumes={release.matched_missing_volumes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{lib.results.length > 5 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
className="text-xs text-primary hover:underline w-full text-center py-1"
|
||||
>
|
||||
{collapsed
|
||||
? t("downloads.showMore", { count: lib.results.length - 5 })
|
||||
: t("downloads.showLess")}
|
||||
</button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { fetchTorrentDownloads, TorrentDownloadDto } from "@/lib/api";
|
||||
import { fetchTorrentDownloads, TorrentDownloadDto, LatestFoundPerLibraryDto, apiFetch } from "@/lib/api";
|
||||
import { DownloadsPage } from "./DownloadsPage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Page() {
|
||||
const downloads = await fetchTorrentDownloads().catch(() => [] as TorrentDownloadDto[]);
|
||||
return <DownloadsPage initialDownloads={downloads} />;
|
||||
const [downloads, latestFound] = await Promise.all([
|
||||
fetchTorrentDownloads().catch(() => [] as TorrentDownloadDto[]),
|
||||
apiFetch<LatestFoundPerLibraryDto[]>("/download-detection/latest-found").catch(() => [] as LatestFoundPerLibraryDto[]),
|
||||
]);
|
||||
return <DownloadsPage initialDownloads={downloads} initialLatestFound={latestFound} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await apiFetch("/download-detection/latest-found");
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to fetch latest detection results" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1184,6 +1184,14 @@ export type DownloadDetectionResultDto = {
|
||||
error_message: string | null;
|
||||
};
|
||||
|
||||
export type LatestFoundPerLibraryDto = {
|
||||
library_id: string;
|
||||
library_name: string;
|
||||
job_id: string;
|
||||
job_date: string;
|
||||
results: DownloadDetectionResultDto[];
|
||||
};
|
||||
|
||||
export async function getDownloadDetectionReport(jobId: string) {
|
||||
return apiFetch<DownloadDetectionReportDto>(`/download-detection/${jobId}/report`);
|
||||
}
|
||||
|
||||
@@ -902,6 +902,11 @@ const en: Record<TranslationKey, string> = {
|
||||
"downloads.cancel": "Cancel download",
|
||||
"downloads.confirmDelete": "Delete this download?",
|
||||
"downloads.confirmCancel": "Cancel this download? The torrent will also be removed from qBittorrent.",
|
||||
"downloads.availableTitle": "Available for download",
|
||||
"downloads.detectedSeries": "{{count}} series detected",
|
||||
"downloads.missing": "missing",
|
||||
"downloads.showMore": "Show {{count}} more…",
|
||||
"downloads.showLess": "Show less",
|
||||
|
||||
// Settings - Torrent Import
|
||||
"settings.torrentImport": "Auto import",
|
||||
|
||||
@@ -900,6 +900,11 @@ const fr = {
|
||||
"downloads.cancel": "Annuler le téléchargement",
|
||||
"downloads.confirmDelete": "Supprimer ce téléchargement ?",
|
||||
"downloads.confirmCancel": "Annuler ce téléchargement ? Le torrent sera aussi supprimé de qBittorrent.",
|
||||
"downloads.availableTitle": "Disponibles au téléchargement",
|
||||
"downloads.detectedSeries": "{{count}} séries détectées",
|
||||
"downloads.missing": "manquant(s)",
|
||||
"downloads.showMore": "Voir {{count}} de plus…",
|
||||
"downloads.showLess": "Réduire",
|
||||
|
||||
// Settings - Torrent Import
|
||||
"settings.torrentImport": "Import automatique",
|
||||
|
||||
Reference in New Issue
Block a user