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:
@@ -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 },
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 })
|
||||
@@ -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 }
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user