Add torrent client integration and downloads management: introduce qbittorrent service in Docker setup, implement downloads endpoint in API, and enhance media storage to handle imported files. Update frontend routes and translations for downloads management.

This commit is contained in:
Leonid Pershin
2026-07-24 16:42:14 +03:00
parent 5853009d71
commit e7840ba919
18 changed files with 382 additions and 6 deletions
@@ -0,0 +1,112 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { ExternalLink, RotateCw } from 'lucide-react'
import { HttpError } from '@/shared/api/client'
import { Button } from '@/shared/ui/button'
import { toast } from '@/shared/ui/toast-store'
import { importDownload, listDownloads } from './api'
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB', 'TB']
let value = bytes / 1024
let i = 0
while (value >= 1024 && i < units.length - 1) {
value /= 1024
i++
}
return `${value.toFixed(1)} ${units[i]}`
}
export function DownloadsPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data, isLoading, refetch, isFetching } = useQuery({
queryKey: ['admin', 'downloads'],
queryFn: listDownloads,
refetchInterval: 15_000,
})
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const importMutation = useMutation({
mutationFn: importDownload,
onSuccess: () => {
toast.success(t('admin.downloads.imported'))
void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
},
onError,
})
const clientUrl =
typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}:8090` : '#'
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="crt-glow text-xl font-semibold">{t('admin.downloads.title')}</h2>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" asChild>
<a href={clientUrl} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
{t('admin.downloads.openClient')}
</a>
</Button>
<Button size="sm" variant="outline" disabled={isFetching} onClick={() => void refetch()}>
<RotateCw className="h-4 w-4" />
{t('admin.downloads.refresh')}
</Button>
</div>
</div>
<p className="text-sm text-muted-foreground">{t('admin.downloads.hint')}</p>
<div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">{t('admin.downloads.path')}</th>
<th className="px-4 py-2 font-medium">{t('admin.downloads.size')}</th>
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={3}>
{t('common.loading')}
</td>
</tr>
)}
{data?.map((file) => (
<tr key={file.relativePath} className="border-b border-border last:border-0">
<td className="px-4 py-2" title={file.relativePath}>
{file.relativePath}
</td>
<td className="px-4 py-2 text-muted-foreground">{formatBytes(file.sizeBytes)}</td>
<td className="px-4 py-2">
<Button
size="sm"
disabled={importMutation.isPending}
onClick={() => importMutation.mutate(file.relativePath)}
>
{t('admin.downloads.import')}
</Button>
</td>
</tr>
))}
{data && data.length === 0 && !isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={3}>
{t('admin.downloads.empty')}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,13 @@
import { apiRequest } from '@/shared/api/client'
import type { CreatedIdResponse, DownloadFileDto } from '@/shared/api/types'
export function listDownloads() {
return apiRequest<DownloadFileDto[]>('/admin/downloads')
}
export function importDownload(relativePath: string) {
return apiRequest<CreatedIdResponse>('/admin/downloads/import', {
method: 'POST',
body: { relativePath },
})
}