chore: bump version to 2.12.1
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 55s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 55s
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
13
apps/backoffice/app/api/torrent-downloads/[id]/route.ts
Normal file
13
apps/backoffice/app/api/torrent-downloads/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "stripstream-backoffice",
|
||||
"version": "2.12.0",
|
||||
"version": "2.12.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 7082",
|
||||
|
||||
Reference in New Issue
Block a user