chore: bump version to 2.12.1
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 55s

This commit is contained in:
2026-03-26 21:46:58 +01:00
parent ad05f10ab2
commit ac53bd950b
14 changed files with 425 additions and 39 deletions

View File

@@ -1,6 +1,7 @@
"use client";
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 { useTranslation } from "@/lib/i18n/context";
@@ -43,6 +44,22 @@ function formatDate(iso: string): string {
});
}
function formatSpeed(bytesPerSec: number): string {
if (bytesPerSec < 1024) return `${bytesPerSec} B/s`;
if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`;
return `${(bytesPerSec / 1024 / 1024).toFixed(1)} MB/s`;
}
function formatEta(seconds: number): string {
if (seconds <= 0 || seconds >= 8640000) return "";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}h${String(m).padStart(2, "0")}m`;
if (m > 0) return `${m}m${String(s).padStart(2, "0")}s`;
return `${s}s`;
}
interface DownloadsPageProps {
initialDownloads: TorrentDownloadDto[];
}
@@ -67,7 +84,7 @@ export function DownloadsPage({ initialDownloads }: DownloadsPageProps) {
const hasActive = downloads.some(d => STATUS_ACTIVE.has(d.status));
useEffect(() => {
if (!hasActive) return;
const id = setInterval(() => refresh(false), 5000);
const id = setInterval(() => refresh(false), 2000);
return () => clearInterval(id);
}, [hasActive, refresh]);
@@ -133,7 +150,7 @@ export function DownloadsPage({ initialDownloads }: DownloadsPageProps) {
) : (
<div className="space-y-3">
{visible.map(dl => (
<DownloadCard key={dl.id} dl={dl} />
<DownloadCard key={dl.id} dl={dl} onDeleted={() => refresh(false)} />
))}
</div>
)}
@@ -141,10 +158,23 @@ export function DownloadsPage({ initialDownloads }: DownloadsPageProps) {
);
}
function DownloadCard({ dl }: { dl: TorrentDownloadDto }) {
function DownloadCard({ dl, onDeleted }: { dl: TorrentDownloadDto; onDeleted: () => void }) {
const { t } = useTranslation();
const [deleting, setDeleting] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const importedCount = Array.isArray(dl.imported_files) ? dl.imported_files.length : 0;
async function handleDelete() {
setDeleting(true);
setShowConfirm(false);
try {
const resp = await fetch(`/api/torrent-downloads/${dl.id}`, { method: "DELETE" });
if (resp.ok) onDeleted();
} finally {
setDeleting(false);
}
}
return (
<Card>
<CardContent className="pt-4">
@@ -187,6 +217,28 @@ function DownloadCard({ dl }: { dl: TorrentDownloadDto }) {
)}
</div>
{dl.status === "downloading" && (
<div className="mt-2">
<div className="flex items-center gap-2 mb-1">
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-500"
style={{ width: `${Math.round(dl.progress * 100)}%` }}
/>
</div>
<span className="text-xs font-medium text-foreground tabular-nums w-10 text-right">
{Math.round(dl.progress * 100)}%
</span>
</div>
{(dl.download_speed > 0 || dl.eta > 0) && (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{dl.download_speed > 0 && <span>{formatSpeed(dl.download_speed)}</span>}
{dl.eta > 0 && dl.eta < 8640000 && <span>ETA {formatEta(dl.eta)}</span>}
</div>
)}
</div>
)}
{dl.content_path && dl.status !== "imported" && (
<p className="mt-1 text-xs font-mono text-muted-foreground truncate" title={dl.content_path}>
{dl.content_path}
@@ -208,15 +260,57 @@ function DownloadCard({ dl }: { dl: TorrentDownloadDto }) {
)}
</div>
{/* Timestamp */}
<div className="text-xs text-muted-foreground shrink-0 text-right">
<p>{formatDate(dl.created_at)}</p>
{dl.updated_at !== dl.created_at && (
<p className="opacity-60">maj {formatDate(dl.updated_at)}</p>
)}
{/* Actions */}
<div className="flex flex-col items-end gap-2 shrink-0">
<div className="text-xs text-muted-foreground text-right">
<p>{formatDate(dl.created_at)}</p>
{dl.updated_at !== dl.created_at && (
<p className="opacity-60">maj {formatDate(dl.updated_at)}</p>
)}
</div>
<button
type="button"
onClick={() => setShowConfirm(true)}
disabled={deleting || dl.status === "importing"}
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-30"
title={dl.status === "downloading" ? t("downloads.cancel") : t("downloads.delete")}
>
{deleting ? (
<Icon name="spinner" size="sm" className="animate-spin" />
) : (
<Icon name="trash" size="sm" />
)}
</button>
</div>
</div>
</CardContent>
{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">
{dl.status === "downloading" ? t("downloads.cancel") : t("downloads.delete")}
</h3>
<p className="text-sm text-muted-foreground">
{dl.status === "downloading" ? t("downloads.confirmCancel") : t("downloads.confirmDelete")}
</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={handleDelete}>
{dl.status === "downloading" ? t("downloads.cancel") : t("downloads.delete")}
</Button>
</div>
</div>
</div>
</>,
document.body
)}
</Card>
);
}

View File

@@ -106,7 +106,13 @@ export function DownloadDetectionResultsCard({ results, libraryId, t }: {
</div>
</div>
{release.download_url && (
<QbittorrentDownloadButton downloadUrl={release.download_url} releaseId={`${r.id}-${idx}`} />
<QbittorrentDownloadButton
downloadUrl={release.download_url}
releaseId={`${r.id}-${idx}`}
libraryId={libraryId ?? undefined}
seriesName={r.series_name}
expectedVolumes={release.matched_missing_volumes}
/>
)}
</div>
))}

View File

@@ -0,0 +1,13 @@
import { NextResponse, NextRequest } from "next/server";
import { apiFetch } from "@/lib/api";
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const data = await apiFetch(`/torrent-downloads/${id}`, { method: "DELETE" });
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to delete torrent download";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -21,7 +21,19 @@ export function QbittorrentProvider({ children }: { children: ReactNode }) {
return <QbConfigContext.Provider value={configured}>{children}</QbConfigContext.Provider>;
}
export function QbittorrentDownloadButton({ downloadUrl, releaseId }: { downloadUrl: string; releaseId: string }) {
export function QbittorrentDownloadButton({
downloadUrl,
releaseId,
libraryId,
seriesName,
expectedVolumes,
}: {
downloadUrl: string;
releaseId: string;
libraryId?: string;
seriesName?: string;
expectedVolumes?: number[];
}) {
const { t } = useTranslation();
const configured = useContext(QbConfigContext);
const [sending, setSending] = useState(false);
@@ -37,7 +49,12 @@ export function QbittorrentDownloadButton({ downloadUrl, releaseId }: { download
const resp = await fetch("/api/qbittorrent/add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: downloadUrl }),
body: JSON.stringify({
url: downloadUrl,
...(libraryId && { library_id: libraryId }),
...(seriesName && { series_name: seriesName }),
...(expectedVolumes && { expected_volumes: expectedVolumes }),
}),
});
const data = await resp.json();
if (data.error) {

View File

@@ -1295,6 +1295,9 @@ export type TorrentDownloadDto = {
status: "downloading" | "completed" | "importing" | "imported" | "error";
imported_files: Array<{ volume: number; source: string; destination: string }> | null;
error_message: string | null;
progress: number;
download_speed: number;
eta: number;
created_at: string;
updated_at: string;
};

View File

@@ -898,6 +898,10 @@ const en: Record<TranslationKey, string> = {
"downloads.status.importing": "Importing",
"downloads.status.imported": "Imported",
"downloads.status.error": "Error",
"downloads.delete": "Delete",
"downloads.cancel": "Cancel download",
"downloads.confirmDelete": "Delete this download?",
"downloads.confirmCancel": "Cancel this download? The torrent will also be removed from qBittorrent.",
// Settings - Torrent Import
"settings.torrentImport": "Auto import",

View File

@@ -896,6 +896,10 @@ const fr = {
"downloads.status.importing": "Import en cours",
"downloads.status.imported": "Importé",
"downloads.status.error": "Erreur",
"downloads.delete": "Supprimer",
"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.",
// Settings - Torrent Import
"settings.torrentImport": "Import automatique",

View File

@@ -1,6 +1,6 @@
{
"name": "stripstream-backoffice",
"version": "2.12.0",
"version": "2.12.1",
"private": true,
"scripts": {
"dev": "next dev -p 7082",