Remove downloads management features: delete DownloadsEndpoints and related API logic, remove associated frontend components and routes, and update documentation to reflect the removal of the downloads directory from the application structure.
This commit is contained in:
@@ -1,52 +0,0 @@
|
||||
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<IReadOnlyList<DownloadFileDto>>();
|
||||
admin.MapPost("/import", Import).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> List(ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new ListDownloadsQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Импорт выбранного файла из downloads/: регистрирует ассет (копией) и ставит в обработку.</summary>
|
||||
private static async Task<IResult> 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);
|
||||
@@ -113,7 +113,6 @@ app.MapAuthEndpoints();
|
||||
app.MapRoleEndpoints();
|
||||
app.MapAdminUserEndpoints();
|
||||
app.MapMediaEndpoints();
|
||||
app.MapDownloadsEndpoints();
|
||||
app.MapShowEndpoints();
|
||||
app.MapChannelEndpoints();
|
||||
app.MapStreamingEndpoints();
|
||||
|
||||
@@ -2,9 +2,6 @@ using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>Готовый к импорту файл в каталоге downloads/ (скачан торрент-клиентом).</summary>
|
||||
public sealed record DownloadEntry(string RelativePath, string Name, long SizeBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Порт файлового хранилища медиа. Все относительные пути резолвятся строго внутри корня
|
||||
/// (<c>Storage:RootPath</c>) — защита от path traversal лежит на реализации.
|
||||
@@ -14,9 +11,6 @@ public interface IMediaStorage
|
||||
/// <summary>Свободное место на томе хранилища, байт.</summary>
|
||||
long GetAvailableFreeSpaceBytes();
|
||||
|
||||
/// <summary>Список видеофайлов в downloads/ (рекурсивно), пригодных к импорту.</summary>
|
||||
IReadOnlyList<DownloadEntry> ListDownloads();
|
||||
|
||||
/// <summary>
|
||||
/// Стримит загружаемый контент во временный файл в <c>uploads/</c> без буферизации в память.
|
||||
/// Возвращает непрозрачный токен (имя временного файла) для последующего <see cref="PromoteToOriginalAsync"/>.
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace TeleWave.Application.Media.Downloads;
|
||||
|
||||
public sealed record DownloadFileDto(string RelativePath, string Name, long SizeBytes);
|
||||
@@ -1,5 +0,0 @@
|
||||
using LiteCqrs;
|
||||
|
||||
namespace TeleWave.Application.Media.Downloads;
|
||||
|
||||
public sealed record ListDownloadsQuery : IQuery<IReadOnlyList<DownloadFileDto>>;
|
||||
@@ -1,20 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Application.Media.Downloads;
|
||||
|
||||
public sealed class ListDownloadsQueryHandler(IMediaStorage storage)
|
||||
: IQueryHandler<ListDownloadsQuery, IReadOnlyList<DownloadFileDto>>
|
||||
{
|
||||
public Task<IReadOnlyList<DownloadFileDto>> Handle(
|
||||
ListDownloadsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
IReadOnlyList<DownloadFileDto> files = storage
|
||||
.ListDownloads()
|
||||
.Select(d => new DownloadFileDto(d.RelativePath, d.Name, d.SizeBytes))
|
||||
.ToList();
|
||||
return Task.FromResult(files);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,4 @@ public enum MediaSource
|
||||
|
||||
/// <summary>Положен вручную в inbox/ и подобран сканером.</summary>
|
||||
Inbox,
|
||||
|
||||
/// <summary>Скачан торрент-клиентом в downloads/ и импортирован вручную (копированием).</summary>
|
||||
Download,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Media;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Infrastructure.Media;
|
||||
@@ -22,41 +21,6 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<DownloadEntry> ListDownloads()
|
||||
{
|
||||
if (!Directory.Exists(paths.DownloadsDir))
|
||||
return [];
|
||||
|
||||
var result = new List<DownloadEntry>();
|
||||
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<string> SaveUploadAsync(
|
||||
Stream content,
|
||||
string extension,
|
||||
@@ -94,23 +58,15 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var sourcePath = source switch
|
||||
{
|
||||
MediaSource.Inbox => paths.InboxPath(sourceToken),
|
||||
MediaSource.Download => paths.DownloadPath(sourceToken),
|
||||
_ => paths.UploadPath(sourceToken),
|
||||
};
|
||||
var sourcePath = source == MediaSource.Inbox
|
||||
? paths.InboxPath(sourceToken)
|
||||
: paths.UploadPath(sourceToken);
|
||||
|
||||
if (!File.Exists(sourcePath))
|
||||
throw new FileNotFoundException("Исходный файл не найден в хранилище.", sourcePath);
|
||||
|
||||
Directory.CreateDirectory(paths.OriginalsDir);
|
||||
var destination = paths.OriginalPath(assetId, extension);
|
||||
|
||||
// Download копируем (торрент продолжает сидировать); остальное переносим.
|
||||
if (source == MediaSource.Download)
|
||||
File.Copy(sourcePath, destination, overwrite: true);
|
||||
else
|
||||
File.Move(sourcePath, destination, overwrite: true);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -17,14 +17,12 @@ 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()
|
||||
{
|
||||
@@ -32,7 +30,6 @@ public sealed class MediaPathResolver
|
||||
Directory.CreateDirectory(UploadsDir);
|
||||
Directory.CreateDirectory(OriginalsDir);
|
||||
Directory.CreateDirectory(AssetsDir);
|
||||
Directory.CreateDirectory(DownloadsDir);
|
||||
}
|
||||
|
||||
public string OriginalPath(Guid assetId, string extension) =>
|
||||
@@ -58,10 +55,6 @@ public sealed class MediaPathResolver
|
||||
public string InboxPath(string fileName) =>
|
||||
EnsureWithin(InboxDir, Path.Combine(InboxDir, fileName));
|
||||
|
||||
/// <summary>Резолвит относительный путь внутри downloads/ (может содержать подкаталоги сезона).</summary>
|
||||
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)
|
||||
|
||||
@@ -30,31 +30,3 @@ 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
|
||||
# host-сеть: bridge Docker ломает DHT/UDP торрент-клиента (NAT исходящего UDP + DNAT входящего
|
||||
# на том же порту рвут обратные DHT-пакеты → «Узлы DHT: 0»). В host-режиме клиент слушает
|
||||
# порты 8090 (WebUI) и 6881 (torrent) прямо на хосте — порты не конфликтуют с app (8085).
|
||||
network_mode: host
|
||||
environment:
|
||||
PUID: '0'
|
||||
PGID: '0'
|
||||
TZ: Etc/UTC
|
||||
WEBUI_PORT: '8090' # WebUI (пароль первого входа — в логах: docker compose logs qbittorrent)
|
||||
volumes:
|
||||
- qbittorrent-config:/config
|
||||
- /srv/telewave/media/downloads:/downloads
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: '10m'
|
||||
max-file: '3'
|
||||
|
||||
volumes:
|
||||
qbittorrent-config:
|
||||
|
||||
@@ -100,10 +100,10 @@ findmnt /srv/telewave/media
|
||||
|
||||
## 5. Рабочие каталоги и права
|
||||
|
||||
Создать структуру, ожидаемую приложением (`downloads` — для торрент-клиента qBittorrent, см. ниже):
|
||||
Создать структуру, ожидаемую приложением:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /srv/telewave/media/{inbox,uploads,originals,assets,downloads}
|
||||
sudo mkdir -p /srv/telewave/media/{inbox,uploads,originals,assets}
|
||||
```
|
||||
|
||||
**Владелец.** Контейнер сейчас работает под `root` (в Dockerfile нет `USER`), а bind-mount
|
||||
@@ -188,31 +188,6 @@ 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), после увеличения на стороне
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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,7 +17,6 @@ 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'
|
||||
@@ -65,11 +64,6 @@ 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',
|
||||
@@ -109,7 +103,6 @@ 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
|
||||
@@ -125,7 +118,6 @@ 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
|
||||
@@ -143,7 +135,6 @@ 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
|
||||
@@ -162,7 +153,6 @@ export interface FileRouteTypes {
|
||||
| '/register'
|
||||
| '/settings'
|
||||
| '/admin/channels'
|
||||
| '/admin/downloads'
|
||||
| '/admin/media'
|
||||
| '/admin/roles'
|
||||
| '/admin/shows'
|
||||
@@ -178,7 +168,6 @@ export interface FileRouteTypes {
|
||||
| '/register'
|
||||
| '/settings'
|
||||
| '/admin/channels'
|
||||
| '/admin/downloads'
|
||||
| '/admin/media'
|
||||
| '/admin/roles'
|
||||
| '/admin/shows'
|
||||
@@ -195,7 +184,6 @@ export interface FileRouteTypes {
|
||||
| '/register'
|
||||
| '/settings'
|
||||
| '/admin/channels'
|
||||
| '/admin/downloads'
|
||||
| '/admin/media'
|
||||
| '/admin/roles'
|
||||
| '/admin/shows'
|
||||
@@ -272,13 +260,6 @@ 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'
|
||||
@@ -350,7 +331,6 @@ const AdminShowsRouteWithChildren = AdminShowsRoute._addFileChildren(
|
||||
|
||||
interface AdminRouteChildren {
|
||||
AdminChannelsRoute: typeof AdminChannelsRouteWithChildren
|
||||
AdminDownloadsRoute: typeof AdminDownloadsRoute
|
||||
AdminMediaRoute: typeof AdminMediaRoute
|
||||
AdminRolesRoute: typeof AdminRolesRoute
|
||||
AdminShowsRoute: typeof AdminShowsRouteWithChildren
|
||||
@@ -360,7 +340,6 @@ interface AdminRouteChildren {
|
||||
|
||||
const AdminRouteChildren: AdminRouteChildren = {
|
||||
AdminChannelsRoute: AdminChannelsRouteWithChildren,
|
||||
AdminDownloadsRoute: AdminDownloadsRoute,
|
||||
AdminMediaRoute: AdminMediaRoute,
|
||||
AdminRolesRoute: AdminRolesRoute,
|
||||
AdminShowsRoute: AdminShowsRouteWithChildren,
|
||||
|
||||
@@ -22,13 +22,6 @@ 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')}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { DownloadsPanel } from '@/features/admin/downloads/DownloadsPanel'
|
||||
|
||||
export const Route = createFileRoute('/admin/downloads')({ component: DownloadsPanel })
|
||||
@@ -151,8 +151,5 @@ 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,17 +117,6 @@ const resources = {
|
||||
Failed: 'Ошибка',
|
||||
},
|
||||
},
|
||||
downloads: {
|
||||
title: 'Загрузки',
|
||||
empty: 'Папка загрузок пуста. Добавьте торрент в клиенте.',
|
||||
import: 'Импортировать',
|
||||
imported: 'Файл импортирован, идёт обработка',
|
||||
size: 'Размер',
|
||||
path: 'Файл',
|
||||
refresh: 'Обновить',
|
||||
openClient: 'Открыть qBittorrent',
|
||||
hint: 'Торрент-клиент качает в downloads/. Выберите готовый файл и импортируйте — он скопируется в библиотеку и уйдёт на обработку, торрент продолжит сидироваться.',
|
||||
},
|
||||
shows: {
|
||||
title: 'Шоу',
|
||||
name: 'Название',
|
||||
@@ -296,17 +285,6 @@ 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