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 },
})
}
+21
View File
@@ -17,6 +17,7 @@ import { Route as RegisterRouteImport } from './routes/register'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as AdminIndexRouteImport } from './routes/admin/index'
import { Route as AdminChannelsRouteImport } from './routes/admin/channels'
import { Route as AdminDownloadsRouteImport } from './routes/admin/downloads'
import { Route as AdminMediaRouteImport } from './routes/admin/media'
import { Route as AdminRolesRouteImport } from './routes/admin/roles'
import { Route as AdminShowsRouteImport } from './routes/admin/shows'
@@ -64,6 +65,11 @@ const AdminChannelsRoute = AdminChannelsRouteImport.update({
path: '/channels',
getParentRoute: () => AdminRoute,
} as any)
const AdminDownloadsRoute = AdminDownloadsRouteImport.update({
id: '/downloads',
path: '/downloads',
getParentRoute: () => AdminRoute,
} as any)
const AdminMediaRoute = AdminMediaRouteImport.update({
id: '/media',
path: '/media',
@@ -103,6 +109,7 @@ export interface FileRoutesByFullPath {
'/register': typeof RegisterRoute
'/settings': typeof SettingsRoute
'/admin/channels': typeof AdminChannelsRouteWithChildren
'/admin/downloads': typeof AdminDownloadsRoute
'/admin/media': typeof AdminMediaRoute
'/admin/roles': typeof AdminRolesRoute
'/admin/shows': typeof AdminShowsRouteWithChildren
@@ -118,6 +125,7 @@ export interface FileRoutesByTo {
'/register': typeof RegisterRoute
'/settings': typeof SettingsRoute
'/admin/channels': typeof AdminChannelsRouteWithChildren
'/admin/downloads': typeof AdminDownloadsRoute
'/admin/media': typeof AdminMediaRoute
'/admin/roles': typeof AdminRolesRoute
'/admin/shows': typeof AdminShowsRouteWithChildren
@@ -135,6 +143,7 @@ export interface FileRoutesById {
'/register': typeof RegisterRoute
'/settings': typeof SettingsRoute
'/admin/channels': typeof AdminChannelsRouteWithChildren
'/admin/downloads': typeof AdminDownloadsRoute
'/admin/media': typeof AdminMediaRoute
'/admin/roles': typeof AdminRolesRoute
'/admin/shows': typeof AdminShowsRouteWithChildren
@@ -153,6 +162,7 @@ export interface FileRouteTypes {
| '/register'
| '/settings'
| '/admin/channels'
| '/admin/downloads'
| '/admin/media'
| '/admin/roles'
| '/admin/shows'
@@ -168,6 +178,7 @@ export interface FileRouteTypes {
| '/register'
| '/settings'
| '/admin/channels'
| '/admin/downloads'
| '/admin/media'
| '/admin/roles'
| '/admin/shows'
@@ -184,6 +195,7 @@ export interface FileRouteTypes {
| '/register'
| '/settings'
| '/admin/channels'
| '/admin/downloads'
| '/admin/media'
| '/admin/roles'
| '/admin/shows'
@@ -260,6 +272,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminChannelsRouteImport
parentRoute: typeof AdminRoute
}
'/admin/downloads': {
id: '/admin/downloads'
path: '/downloads'
fullPath: '/admin/downloads'
preLoaderRoute: typeof AdminDownloadsRouteImport
parentRoute: typeof AdminRoute
}
'/admin/media': {
id: '/admin/media'
path: '/media'
@@ -331,6 +350,7 @@ const AdminShowsRouteWithChildren = AdminShowsRoute._addFileChildren(
interface AdminRouteChildren {
AdminChannelsRoute: typeof AdminChannelsRouteWithChildren
AdminDownloadsRoute: typeof AdminDownloadsRoute
AdminMediaRoute: typeof AdminMediaRoute
AdminRolesRoute: typeof AdminRolesRoute
AdminShowsRoute: typeof AdminShowsRouteWithChildren
@@ -340,6 +360,7 @@ interface AdminRouteChildren {
const AdminRouteChildren: AdminRouteChildren = {
AdminChannelsRoute: AdminChannelsRouteWithChildren,
AdminDownloadsRoute: AdminDownloadsRoute,
AdminMediaRoute: AdminMediaRoute,
AdminRolesRoute: AdminRolesRoute,
AdminShowsRoute: AdminShowsRouteWithChildren,
+7
View File
@@ -22,6 +22,13 @@ function AdminLayout() {
>
{t('admin.media.title')}
</Link>
<Link
to="/admin/downloads"
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
>
{t('admin.downloads.title')}
</Link>
<Link
to="/admin/shows"
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
+4
View File
@@ -0,0 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { DownloadsPanel } from '@/features/admin/downloads/DownloadsPanel'
export const Route = createFileRoute('/admin/downloads')({ component: DownloadsPanel })
+3
View File
@@ -151,5 +151,8 @@ export type ScheduleEntryDto = {
episodeIndex: number | null
}
// ── Загрузки (торрент) ─────────────────────────────────────────────────────
export type DownloadFileDto = { relativePath: string; name: string; sizeBytes: number }
// ── Публичный эфир ─────────────────────────────────────────────────────────
export type PublicChannelDto = { id: string; slug: string; name: string }
+22
View File
@@ -117,6 +117,17 @@ const resources = {
Failed: 'Ошибка',
},
},
downloads: {
title: 'Загрузки',
empty: 'Папка загрузок пуста. Добавьте торрент в клиенте.',
import: 'Импортировать',
imported: 'Файл импортирован, идёт обработка',
size: 'Размер',
path: 'Файл',
refresh: 'Обновить',
openClient: 'Открыть qBittorrent',
hint: 'Торрент-клиент качает в downloads/. Выберите готовый файл и импортируйте — он скопируется в библиотеку и уйдёт на обработку, торрент продолжит сидироваться.',
},
shows: {
title: 'Шоу',
name: 'Название',
@@ -285,6 +296,17 @@ const resources = {
Failed: 'Failed',
},
},
downloads: {
title: 'Downloads',
empty: 'Downloads folder is empty. Add a torrent in the client.',
import: 'Import',
imported: 'File imported, processing started',
size: 'Size',
path: 'File',
refresh: 'Refresh',
openClient: 'Open qBittorrent',
hint: 'The torrent client downloads into downloads/. Pick a finished file and import it — it is copied into the library and queued for processing, the torrent keeps seeding.',
},
shows: {
title: 'Shows',
name: 'Name',