From e7840ba91935a925e0db3b4c8b6747c76a70c4a9 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 24 Jul 2026 16:42:14 +0300 Subject: [PATCH] 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. --- .../Endpoints/DownloadsEndpoints.cs | 52 ++++++++ backend/src/TeleWave.Api/Program.cs | 1 + .../Common/Interfaces/IMediaStorage.cs | 6 + .../Media/Downloads/DownloadFileDto.cs | 3 + .../Media/Downloads/ListDownloadsQuery.cs | 5 + .../Downloads/ListDownloadsQueryHandler.cs | 20 ++++ .../src/TeleWave.Domain/Media/MediaSource.cs | 3 + .../Media/FileSystemMediaStorage.cs | 52 +++++++- .../Media/MediaPathResolver.cs | 7 ++ docker-compose.yml | 28 +++++ docs/server-storage-setup.md | 29 ++++- .../admin/downloads/DownloadsPanel.tsx | 112 ++++++++++++++++++ frontend/src/features/admin/downloads/api.ts | 13 ++ frontend/src/routeTree.gen.ts | 21 ++++ frontend/src/routes/admin.tsx | 7 ++ frontend/src/routes/admin/downloads.tsx | 4 + frontend/src/shared/api/types.ts | 3 + frontend/src/shared/lib/i18n.ts | 22 ++++ 18 files changed, 382 insertions(+), 6 deletions(-) create mode 100644 backend/src/TeleWave.Api/Endpoints/DownloadsEndpoints.cs create mode 100644 backend/src/TeleWave.Application/Media/Downloads/DownloadFileDto.cs create mode 100644 backend/src/TeleWave.Application/Media/Downloads/ListDownloadsQuery.cs create mode 100644 backend/src/TeleWave.Application/Media/Downloads/ListDownloadsQueryHandler.cs create mode 100644 frontend/src/features/admin/downloads/DownloadsPanel.tsx create mode 100644 frontend/src/features/admin/downloads/api.ts create mode 100644 frontend/src/routes/admin/downloads.tsx diff --git a/backend/src/TeleWave.Api/Endpoints/DownloadsEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/DownloadsEndpoints.cs new file mode 100644 index 0000000..9630a59 --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/DownloadsEndpoints.cs @@ -0,0 +1,52 @@ +using LiteCqrs; +using TeleWave.Api.Common; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Media.Downloads; +using TeleWave.Application.Media.Register; +using TeleWave.Domain.Media; +using TeleWave.Infrastructure.Identity; + +namespace TeleWave.Api.Endpoints; + +public static class DownloadsEndpoints +{ + public static IEndpointRouteBuilder MapDownloadsEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/downloads") + .WithTags("Admin.Downloads") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("", List).Produces>(); + admin.MapPost("/import", Import).Produces(StatusCodes.Status201Created); + + return app; + } + + private static async Task List(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListDownloadsQuery(), cancellationToken); + return Results.Ok(result); + } + + /// Импорт выбранного файла из downloads/: регистрирует ассет (копией) и ставит в обработку. + private static async Task Import( + ImportDownloadBody body, + ISender sender, + IMediaProcessingQueue queue, + CancellationToken cancellationToken + ) + { + var fileName = Path.GetFileName(body.RelativePath); + var result = await sender.Send( + new RegisterMediaAssetCommand(body.RelativePath, MediaSource.Download, fileName), + cancellationToken + ); + if (!result.IsSuccess) + return result.ToHttpResult(); + + queue.Enqueue(result.Value); + return Results.Created($"/api/admin/media/{result.Value}", new CreatedIdResponse(result.Value)); + } +} + +public sealed record ImportDownloadBody(string RelativePath); diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs index 0f41e3d..3247245 100644 --- a/backend/src/TeleWave.Api/Program.cs +++ b/backend/src/TeleWave.Api/Program.cs @@ -113,6 +113,7 @@ app.MapAuthEndpoints(); app.MapRoleEndpoints(); app.MapAdminUserEndpoints(); app.MapMediaEndpoints(); +app.MapDownloadsEndpoints(); app.MapShowEndpoints(); app.MapChannelEndpoints(); app.MapStreamingEndpoints(); diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs b/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs index 3508d65..079f760 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs @@ -2,6 +2,9 @@ using TeleWave.Domain.Media; namespace TeleWave.Application.Common.Interfaces; +/// Готовый к импорту файл в каталоге downloads/ (скачан торрент-клиентом). +public sealed record DownloadEntry(string RelativePath, string Name, long SizeBytes); + /// /// Порт файлового хранилища медиа. Все относительные пути резолвятся строго внутри корня /// (Storage:RootPath) — защита от path traversal лежит на реализации. @@ -11,6 +14,9 @@ public interface IMediaStorage /// Свободное место на томе хранилища, байт. long GetAvailableFreeSpaceBytes(); + /// Список видеофайлов в downloads/ (рекурсивно), пригодных к импорту. + IReadOnlyList ListDownloads(); + /// /// Стримит загружаемый контент во временный файл в uploads/ без буферизации в память. /// Возвращает непрозрачный токен (имя временного файла) для последующего . diff --git a/backend/src/TeleWave.Application/Media/Downloads/DownloadFileDto.cs b/backend/src/TeleWave.Application/Media/Downloads/DownloadFileDto.cs new file mode 100644 index 0000000..9a8e333 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/Downloads/DownloadFileDto.cs @@ -0,0 +1,3 @@ +namespace TeleWave.Application.Media.Downloads; + +public sealed record DownloadFileDto(string RelativePath, string Name, long SizeBytes); diff --git a/backend/src/TeleWave.Application/Media/Downloads/ListDownloadsQuery.cs b/backend/src/TeleWave.Application/Media/Downloads/ListDownloadsQuery.cs new file mode 100644 index 0000000..b3f421f --- /dev/null +++ b/backend/src/TeleWave.Application/Media/Downloads/ListDownloadsQuery.cs @@ -0,0 +1,5 @@ +using LiteCqrs; + +namespace TeleWave.Application.Media.Downloads; + +public sealed record ListDownloadsQuery : IQuery>; diff --git a/backend/src/TeleWave.Application/Media/Downloads/ListDownloadsQueryHandler.cs b/backend/src/TeleWave.Application/Media/Downloads/ListDownloadsQueryHandler.cs new file mode 100644 index 0000000..de525be --- /dev/null +++ b/backend/src/TeleWave.Application/Media/Downloads/ListDownloadsQueryHandler.cs @@ -0,0 +1,20 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Application.Media.Downloads; + +public sealed class ListDownloadsQueryHandler(IMediaStorage storage) + : IQueryHandler> +{ + public Task> Handle( + ListDownloadsQuery query, + CancellationToken cancellationToken + ) + { + IReadOnlyList files = storage + .ListDownloads() + .Select(d => new DownloadFileDto(d.RelativePath, d.Name, d.SizeBytes)) + .ToList(); + return Task.FromResult(files); + } +} diff --git a/backend/src/TeleWave.Domain/Media/MediaSource.cs b/backend/src/TeleWave.Domain/Media/MediaSource.cs index 3bab17e..fba10ef 100644 --- a/backend/src/TeleWave.Domain/Media/MediaSource.cs +++ b/backend/src/TeleWave.Domain/Media/MediaSource.cs @@ -8,4 +8,7 @@ public enum MediaSource /// Положен вручную в inbox/ и подобран сканером. Inbox, + + /// Скачан торрент-клиентом в downloads/ и импортирован вручную (копированием). + Download, } diff --git a/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs b/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs index 43a9108..e942379 100644 --- a/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs +++ b/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs @@ -1,4 +1,5 @@ using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Media; using TeleWave.Domain.Media; namespace TeleWave.Infrastructure.Media; @@ -21,6 +22,41 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor } } + public IReadOnlyList ListDownloads() + { + if (!Directory.Exists(paths.DownloadsDir)) + return []; + + var result = new List(); + foreach (var file in Directory.EnumerateFiles(paths.DownloadsDir, "*", SearchOption.AllDirectories)) + { + var name = Path.GetFileName(file); + if (!MediaFormats.IsAllowed(name)) + continue; + // Пропускаем недокачанное: маркеры qBittorrent (.!qB) и каталог незавершённых. + if (name.EndsWith(".!qb", StringComparison.OrdinalIgnoreCase) + || file.Contains($"{Path.DirectorySeparatorChar}incomplete{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)) + continue; + + long size; + try + { + size = new FileInfo(file).Length; + } + catch (IOException) + { + continue; + } + if (size <= 0) + continue; + + var relative = Path.GetRelativePath(paths.DownloadsDir, file).Replace('\\', '/'); + result.Add(new DownloadEntry(relative, name, size)); + } + + return result.OrderBy(e => e.RelativePath, StringComparer.Ordinal).ToList(); + } + public async Task SaveUploadAsync( Stream content, string extension, @@ -58,16 +94,24 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor CancellationToken cancellationToken ) { - var sourcePath = source == MediaSource.Inbox - ? paths.InboxPath(sourceToken) - : paths.UploadPath(sourceToken); + var sourcePath = source switch + { + MediaSource.Inbox => paths.InboxPath(sourceToken), + MediaSource.Download => paths.DownloadPath(sourceToken), + _ => paths.UploadPath(sourceToken), + }; if (!File.Exists(sourcePath)) throw new FileNotFoundException("Исходный файл не найден в хранилище.", sourcePath); Directory.CreateDirectory(paths.OriginalsDir); var destination = paths.OriginalPath(assetId, extension); - File.Move(sourcePath, destination, overwrite: true); + + // Download копируем (торрент продолжает сидировать); остальное переносим. + if (source == MediaSource.Download) + File.Copy(sourcePath, destination, overwrite: true); + else + File.Move(sourcePath, destination, overwrite: true); return Task.CompletedTask; } diff --git a/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs b/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs index d7b6254..f7ad4a1 100644 --- a/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs +++ b/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs @@ -17,12 +17,14 @@ public sealed class MediaPathResolver UploadsDir = Path.Combine(_root, "uploads"); OriginalsDir = Path.Combine(_root, "originals"); AssetsDir = Path.Combine(_root, "assets"); + DownloadsDir = Path.Combine(_root, "downloads"); } public string InboxDir { get; } public string UploadsDir { get; } public string OriginalsDir { get; } public string AssetsDir { get; } + public string DownloadsDir { get; } public void EnsureDirectories() { @@ -30,6 +32,7 @@ public sealed class MediaPathResolver Directory.CreateDirectory(UploadsDir); Directory.CreateDirectory(OriginalsDir); Directory.CreateDirectory(AssetsDir); + Directory.CreateDirectory(DownloadsDir); } public string OriginalPath(Guid assetId, string extension) => @@ -55,6 +58,10 @@ public sealed class MediaPathResolver public string InboxPath(string fileName) => EnsureWithin(InboxDir, Path.Combine(InboxDir, fileName)); + /// Резолвит относительный путь внутри downloads/ (может содержать подкаталоги сезона). + public string DownloadPath(string relativePath) => + EnsureWithin(DownloadsDir, Path.Combine(DownloadsDir, relativePath)); + private string EnsureWithinRoot(string candidate) => EnsureWithin(_root, candidate); private static string EnsureWithin(string baseDir, string candidate) diff --git a/docker-compose.yml b/docker-compose.yml index 5c50d50..01c852f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,3 +30,31 @@ services: options: max-size: '10m' max-file: '3' + + # Торрент-клиент: качает в /downloads → это /srv/telewave/media/downloads на хосте, который app + # видит как /media/downloads. TeleWave не общается с клиентом по API — вы добавляете торренты в его + # WebUI, а готовые файлы импортируете в разделе «Загрузки» админки (копированием, сидирование живёт). + # PUID/PGID=0 — файлы root-владельца, как и у app, чтобы импорт мог их прочитать. + qbittorrent: + image: lscr.io/linuxserver/qbittorrent:latest + environment: + PUID: '0' + PGID: '0' + TZ: Etc/UTC + WEBUI_PORT: '8080' + volumes: + - qbittorrent-config:/config + - /srv/telewave/media/downloads:/downloads + ports: + - '8090:8080' # WebUI (пароль первого входа — в логах: docker compose logs qbittorrent) + - '6881:6881' + - '6881:6881/udp' + restart: unless-stopped + logging: + driver: json-file + options: + max-size: '10m' + max-file: '3' + +volumes: + qbittorrent-config: diff --git a/docs/server-storage-setup.md b/docs/server-storage-setup.md index f71b2a6..d76e6cd 100644 --- a/docs/server-storage-setup.md +++ b/docs/server-storage-setup.md @@ -100,10 +100,10 @@ findmnt /srv/telewave/media ## 5. Рабочие каталоги и права -Создать структуру, ожидаемую приложением: +Создать структуру, ожидаемую приложением (`downloads` — для торрент-клиента qBittorrent, см. ниже): ```bash -sudo mkdir -p /srv/telewave/media/{inbox,uploads,originals,assets} +sudo mkdir -p /srv/telewave/media/{inbox,uploads,originals,assets,downloads} ``` **Владелец.** Контейнер сейчас работает под `root` (в Dockerfile нет `USER`), а bind-mount @@ -188,6 +188,31 @@ docker compose exec app sh -c 'touch /media/.wtest && ls -l /media/.wtest && rm --- +## 8. Торрент-клиент qBittorrent (опционально) + +Сервис `qbittorrent` уже описан в `docker-compose.yml`. Он качает в `/downloads` → это хостовый +`/srv/telewave/media/downloads`, который приложение видит как `/media/downloads`. TeleWave по API с +клиентом не общается: торренты добавляются в его WebUI, а готовые файлы импортируются в разделе +**«Загрузки»** админки (копированием — торрент продолжает сидироваться). + +После `docker compose up -d --build`: + +1. Узнать временный пароль WebUI первого входа: + ```bash + docker compose logs qbittorrent | grep -i password + ``` +2. Открыть WebUI: `http://<хост>:8090`, логин `admin` + пароль из логов. Сменить пароль в настройках. +3. В настройках qBittorrent задать пути (важно, чтобы недокачанное не мозолило глаза в «Загрузках»): + - **Save files to**: `/downloads` + - **Keep incomplete torrents in**: `/downloads/incomplete` (включить) — приложение пропускает + этот подкаталог и маркеры `.!qB`. + +Права: контейнер qBittorrent работает с `PUID/PGID=0`, поэтому файлы создаются root-владельцем — как +и у `app`, и импорт-копирование их читает без проблем. Порты `6881` (TCP/UDP) — для входящих +пиров; при NAT пробросьте их на роутере, иначе будет только исходящий обмен. + +--- + ## Приложение: если диск нужно расширить в будущем Если VM отдаст диску больше места (например `sdb` вырастет с 700G), после увеличения на стороне diff --git a/frontend/src/features/admin/downloads/DownloadsPanel.tsx b/frontend/src/features/admin/downloads/DownloadsPanel.tsx new file mode 100644 index 0000000..47995b7 --- /dev/null +++ b/frontend/src/features/admin/downloads/DownloadsPanel.tsx @@ -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 ( +
+
+

{t('admin.downloads.title')}

+
+ + +
+
+ +

{t('admin.downloads.hint')}

+ +
+ + + + + + + + + + {isLoading && ( + + + + )} + {data?.map((file) => ( + + + + + + ))} + {data && data.length === 0 && !isLoading && ( + + + + )} + +
{t('admin.downloads.path')}{t('admin.downloads.size')}{t('common.actions')}
+ {t('common.loading')} +
+ {file.relativePath} + {formatBytes(file.sizeBytes)} + +
+ {t('admin.downloads.empty')} +
+
+
+ ) +} diff --git a/frontend/src/features/admin/downloads/api.ts b/frontend/src/features/admin/downloads/api.ts new file mode 100644 index 0000000..a9c0eec --- /dev/null +++ b/frontend/src/features/admin/downloads/api.ts @@ -0,0 +1,13 @@ +import { apiRequest } from '@/shared/api/client' +import type { CreatedIdResponse, DownloadFileDto } from '@/shared/api/types' + +export function listDownloads() { + return apiRequest('/admin/downloads') +} + +export function importDownload(relativePath: string) { + return apiRequest('/admin/downloads/import', { + method: 'POST', + body: { relativePath }, + }) +} diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index e995bc5..4b7f353 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -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, diff --git a/frontend/src/routes/admin.tsx b/frontend/src/routes/admin.tsx index ee0476d..4f5ff6d 100644 --- a/frontend/src/routes/admin.tsx +++ b/frontend/src/routes/admin.tsx @@ -22,6 +22,13 @@ function AdminLayout() { > {t('admin.media.title')} + + {t('admin.downloads.title')} +