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

@@ -28,6 +28,21 @@ function formatSize(bytes: number): string {
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) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
@@ -224,24 +239,27 @@ export function ProwlarrSearchModal({ seriesName, libraryId, missingBooks, initi
</tr>
</thead>
<tbody className="divide-y divide-border">
{results.map((release, i) => {
const hasMissing = release.matchedMissingVolumes && release.matchedMissingVolumes.length > 0;
return (
<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"}`}>
<td className="px-3 py-2 max-w-[400px]">
<span className="truncate block" title={release.title}>
{release.title}
</span>
{hasMissing && (
<div className="flex flex-wrap items-center gap-1 mt-1">
{release.matchedMissingVolumes!.map((vol) => (
<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 })}
</span>
))}
</div>
)}
</td>
{groupReleasesByTitle(results).map((group) => {
const first = group.items[0];
const hasMissing = first.matchedMissingVolumes && first.matchedMissingVolumes.length > 0;
return group.items.map((release, si) => (
<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"}`}>
{si === 0 ? (
<td className="px-3 py-2 max-w-[400px]" rowSpan={group.items.length}>
<span className="truncate block" title={release.title}>
{release.title}
</span>
{hasMissing && (
<div className="flex flex-wrap items-center gap-1 mt-1">
{first.matchedMissingVolumes!.map((vol) => (
<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 })}
</span>
))}
</div>
)}
</td>
) : null}
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
{release.indexer || "—"}
</td>
@@ -296,7 +314,7 @@ export function ProwlarrSearchModal({ seriesName, libraryId, missingBooks, initi
</div>
</td>
</tr>
);
));
})}
</tbody>
</table>