feat: regroupement des releases par titre identique dans la UI

- Prowlarr search modal : les releases avec le même titre sont groupées
  avec rowSpan sur la colonne titre, sources listées en sous-lignes
- Downloads page : même logique, titre + volumes affichés une seule fois,
  sources (indexer, seeders, taille, boutons) en lignes compactes
- Fonction utilitaire groupReleasesByTitle() dans les deux composants

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 17:17:24 +02:00
parent f8f2e9fe71
commit 3d2c8c78bf
2 changed files with 99 additions and 57 deletions

View File

@@ -13,6 +13,21 @@ type TFunction = (key: TranslationKey, vars?: Record<string, string | number>) =
const STATUS_ACTIVE = new Set(["downloading", "completed", "importing"]); const STATUS_ACTIVE = new Set(["downloading", "completed", "importing"]);
/** Group releases by identical title, preserving order of first occurrence. */
function groupReleasesByTitle<T extends { title: string }>(releases: T[]): { title: string; items: T[]; originalIndices: number[] }[] {
const groups: Map<string, { items: T[]; originalIndices: number[] }> = new Map();
releases.forEach((r, idx) => {
const existing = groups.get(r.title);
if (existing) {
existing.items.push(r);
existing.originalIndices.push(idx);
} else {
groups.set(r.title, { items: [r], originalIndices: [idx] });
}
});
return Array.from(groups.entries()).map(([title, { items, originalIndices }]) => ({ title, items, originalIndices }));
}
function statusLabel(status: string, t: TFunction): string { function statusLabel(status: string, t: TFunction): string {
const map: Record<string, TranslationKey> = { const map: Record<string, TranslationKey> = {
downloading: "downloads.status.downloading", downloading: "downloads.status.downloading",
@@ -385,46 +400,55 @@ function AvailableLibraryCard({ lib, onDeleted }: { lib: LatestFoundPerLibraryDt
</div> </div>
{r.available_releases && r.available_releases.length > 0 && ( {r.available_releases && r.available_releases.length > 0 && (
<div className="space-y-1.5 sm:space-y-1"> <div className="space-y-1.5 sm:space-y-1">
{r.available_releases.map((release, idx) => ( {groupReleasesByTitle(r.available_releases).map((group) => (
<div key={idx} className="flex flex-col sm:flex-row sm:items-center gap-1.5 sm:gap-2 py-1 px-2 rounded bg-muted/30"> <div key={group.title} className="rounded bg-muted/30 overflow-hidden">
<div className="flex-1 min-w-0"> {/* Title + matched volumes (shown once) */}
<p className="text-[11px] sm:text-xs font-mono text-foreground truncate" title={release.title}>{release.title}</p> <div className="flex items-center gap-2 py-1 px-2">
<div className="flex items-center gap-2 sm:gap-3 mt-0.5 flex-wrap"> <p className="text-[11px] sm:text-xs font-mono text-foreground truncate flex-1" title={group.title}>{group.title}</p>
{release.indexer && <span className="text-[10px] text-muted-foreground">{release.indexer}</span>} <div className="flex flex-wrap items-center gap-1 shrink-0">
{release.seeders != null && ( {group.items[0].matched_missing_volumes.map(vol => (
<span className="text-[10px] text-success font-medium">{release.seeders}S</span> <span key={vol} className="text-[10px] px-1 py-0.5 rounded-full bg-success/20 text-success font-medium">T{vol}</span>
)} ))}
<span className="text-[10px] text-muted-foreground">{(release.size / 1024 / 1024).toFixed(0)} MB</span>
<div className="flex flex-wrap items-center gap-1">
{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>
))}
</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-1 self-end sm:self-auto shrink-0"> {/* Sources */}
{release.download_url && ( {group.items.map((release, si) => {
<QbittorrentDownloadButton const idx = group.originalIndices[si];
downloadUrl={release.download_url} return (
releaseId={`${r.id}-${idx}`} <div key={idx} className={`flex items-center gap-2 sm:gap-3 py-0.5 px-2 ${si > 0 ? "border-t border-border/20" : ""}`}>
libraryId={lib.library_id} <div className="flex items-center gap-2 sm:gap-3 flex-1 min-w-0 flex-wrap">
seriesName={r.series_name} {release.indexer && <span className="text-[10px] text-muted-foreground">{release.indexer}</span>}
expectedVolumes={release.matched_missing_volumes} {release.seeders != null && (
allVolumes={release.all_volumes} <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>
<button </div>
type="button" <div className="flex items-center gap-1 shrink-0">
onClick={() => handleDeleteRelease(r.id, idx)} {release.download_url && (
disabled={deletingKey === `${r.id}-${idx}`} <QbittorrentDownloadButton
className="inline-flex items-center justify-center w-6 h-6 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-30" downloadUrl={release.download_url}
title={t("downloads.delete")} releaseId={`${r.id}-${idx}`}
> libraryId={lib.library_id}
{deletingKey === `${r.id}-${idx}` seriesName={r.series_name}
? <Icon name="spinner" size="sm" className="animate-spin" /> expectedVolumes={release.matched_missing_volumes}
: <Icon name="trash" size="sm" />} allVolumes={release.all_volumes}
</button> />
</div> )}
<button
type="button"
onClick={() => handleDeleteRelease(r.id, idx)}
disabled={deletingKey === `${r.id}-${idx}`}
className="inline-flex items-center justify-center w-6 h-6 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-30"
title={t("downloads.delete")}
>
{deletingKey === `${r.id}-${idx}`
? <Icon name="spinner" size="sm" className="animate-spin" />
: <Icon name="trash" size="sm" />}
</button>
</div>
</div>
);
})}
</div> </div>
))} ))}
</div> </div>

View File

@@ -28,6 +28,21 @@ function formatSize(bytes: number): string {
return bytes + " B"; return bytes + " B";
} }
/** Group releases by identical title, preserving order of first occurrence. */
function groupReleasesByTitle<T extends { title: string }>(releases: T[]): { title: string; items: T[] }[] {
const groups: Map<string, T[]> = new Map();
for (const r of releases) {
const key = r.title;
const existing = groups.get(key);
if (existing) {
existing.push(r);
} else {
groups.set(key, [r]);
}
}
return Array.from(groups.entries()).map(([title, items]) => ({ title, items }));
}
export function ProwlarrSearchModal({ seriesName, libraryId, missingBooks, initialProwlarrConfigured, initialQbConfigured }: ProwlarrSearchModalProps) { export function ProwlarrSearchModal({ seriesName, libraryId, missingBooks, initialProwlarrConfigured, initialQbConfigured }: ProwlarrSearchModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -224,24 +239,27 @@ export function ProwlarrSearchModal({ seriesName, libraryId, missingBooks, initi
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{results.map((release, i) => { {groupReleasesByTitle(results).map((group) => {
const hasMissing = release.matchedMissingVolumes && release.matchedMissingVolumes.length > 0; const first = group.items[0];
return ( const hasMissing = first.matchedMissingVolumes && first.matchedMissingVolumes.length > 0;
<tr key={release.guid || i} className={`transition-colors ${hasMissing ? "bg-green-500/10 hover:bg-green-500/20 border-l-2 border-l-green-500" : "hover:bg-muted/20"}`}> return group.items.map((release, si) => (
<td className="px-3 py-2 max-w-[400px]"> <tr key={release.guid || `${group.title}-${si}`} className={`transition-colors ${hasMissing ? "bg-green-500/10 hover:bg-green-500/20 border-l-2 border-l-green-500" : "hover:bg-muted/20"}`}>
<span className="truncate block" title={release.title}> {si === 0 ? (
{release.title} <td className="px-3 py-2 max-w-[400px]" rowSpan={group.items.length}>
</span> <span className="truncate block" title={release.title}>
{hasMissing && ( {release.title}
<div className="flex flex-wrap items-center gap-1 mt-1"> </span>
{release.matchedMissingVolumes!.map((vol) => ( {hasMissing && (
<span key={vol} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-500/20 text-green-600"> <div className="flex flex-wrap items-center gap-1 mt-1">
{t("prowlarr.missingVol", { vol })} {first.matchedMissingVolumes!.map((vol) => (
</span> <span key={vol} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-500/20 text-green-600">
))} {t("prowlarr.missingVol", { vol })}
</div> </span>
)} ))}
</td> </div>
)}
</td>
) : null}
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap"> <td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
{release.indexer || "—"} {release.indexer || "—"}
</td> </td>
@@ -296,7 +314,7 @@ export function ProwlarrSearchModal({ seriesName, libraryId, missingBooks, initi
</div> </div>
</td> </td>
</tr> </tr>
); ));
})} })}
</tbody> </tbody>
</table> </table>