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:
@@ -13,6 +13,21 @@ type TFunction = (key: TranslationKey, vars?: Record<string, string | number>) =
|
||||
|
||||
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 {
|
||||
const map: Record<string, TranslationKey> = {
|
||||
downloading: "downloads.status.downloading",
|
||||
@@ -385,46 +400,55 @@ function AvailableLibraryCard({ lib, onDeleted }: { lib: LatestFoundPerLibraryDt
|
||||
</div>
|
||||
{r.available_releases && r.available_releases.length > 0 && (
|
||||
<div className="space-y-1.5 sm:space-y-1">
|
||||
{r.available_releases.map((release, idx) => (
|
||||
<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 className="flex-1 min-w-0">
|
||||
<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 sm: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}S</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>
|
||||
{groupReleasesByTitle(r.available_releases).map((group) => (
|
||||
<div key={group.title} className="rounded bg-muted/30 overflow-hidden">
|
||||
{/* Title + matched volumes (shown once) */}
|
||||
<div className="flex items-center gap-2 py-1 px-2">
|
||||
<p className="text-[11px] sm:text-xs font-mono text-foreground truncate flex-1" title={group.title}>{group.title}</p>
|
||||
<div className="flex flex-wrap items-center gap-1 shrink-0">
|
||||
{group.items[0].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 className="flex items-center gap-1 self-end sm:self-auto shrink-0">
|
||||
{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}
|
||||
allVolumes={release.all_volumes}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
{/* Sources */}
|
||||
{group.items.map((release, si) => {
|
||||
const idx = group.originalIndices[si];
|
||||
return (
|
||||
<div key={idx} className={`flex items-center gap-2 sm:gap-3 py-0.5 px-2 ${si > 0 ? "border-t border-border/20" : ""}`}>
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-1 min-w-0 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}S</span>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground">{(release.size / 1024 / 1024).toFixed(0)} MB</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{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}
|
||||
allVolumes={release.all_volumes}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user