From 7309a25764b0e636e8e6a16986e183fd1d3a0de6 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 24 Jul 2026 18:18:22 +0300 Subject: [PATCH] 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. --- .../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, 6 insertions(+), 382 deletions(-) delete mode 100644 backend/src/TeleWave.Api/Endpoints/DownloadsEndpoints.cs delete mode 100644 backend/src/TeleWave.Application/Media/Downloads/DownloadFileDto.cs delete mode 100644 backend/src/TeleWave.Application/Media/Downloads/ListDownloadsQuery.cs delete mode 100644 backend/src/TeleWave.Application/Media/Downloads/ListDownloadsQueryHandler.cs delete mode 100644 frontend/src/features/admin/downloads/DownloadsPanel.tsx delete mode 100644 frontend/src/features/admin/downloads/api.ts delete 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 deleted file mode 100644 index 9630a59..0000000 --- a/backend/src/TeleWave.Api/Endpoints/DownloadsEndpoints.cs +++ /dev/null @@ -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>(); - 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 3247245..0f41e3d 100644 --- a/backend/src/TeleWave.Api/Program.cs +++ b/backend/src/TeleWave.Api/Program.cs @@ -113,7 +113,6 @@ 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 079f760..3508d65 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs @@ -2,9 +2,6 @@ using TeleWave.Domain.Media; namespace TeleWave.Application.Common.Interfaces; -/// Готовый к импорту файл в каталоге downloads/ (скачан торрент-клиентом). -public sealed record DownloadEntry(string RelativePath, string Name, long SizeBytes); - /// /// Порт файлового хранилища медиа. Все относительные пути резолвятся строго внутри корня /// (Storage:RootPath) — защита от path traversal лежит на реализации. @@ -14,9 +11,6 @@ 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 deleted file mode 100644 index 9a8e333..0000000 --- a/backend/src/TeleWave.Application/Media/Downloads/DownloadFileDto.cs +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index b3f421f..0000000 --- a/backend/src/TeleWave.Application/Media/Downloads/ListDownloadsQuery.cs +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index de525be..0000000 --- a/backend/src/TeleWave.Application/Media/Downloads/ListDownloadsQueryHandler.cs +++ /dev/null @@ -1,20 +0,0 @@ -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 fba10ef..3bab17e 100644 --- a/backend/src/TeleWave.Domain/Media/MediaSource.cs +++ b/backend/src/TeleWave.Domain/Media/MediaSource.cs @@ -8,7 +8,4 @@ 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 e942379..43a9108 100644 --- a/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs +++ b/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs @@ -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 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, @@ -94,24 +58,16 @@ 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); + 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 f7ad4a1..d7b6254 100644 --- a/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs +++ b/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs @@ -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)); - /// Резолвит относительный путь внутри 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 c5af512..5c50d50 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/server-storage-setup.md b/docs/server-storage-setup.md index d76e6cd..f71b2a6 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,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), после увеличения на стороне diff --git a/frontend/src/features/admin/downloads/DownloadsPanel.tsx b/frontend/src/features/admin/downloads/DownloadsPanel.tsx deleted file mode 100644 index 47995b7..0000000 --- a/frontend/src/features/admin/downloads/DownloadsPanel.tsx +++ /dev/null @@ -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 ( -
-
-

{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 deleted file mode 100644 index a9c0eec..0000000 --- a/frontend/src/features/admin/downloads/api.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 4b7f353..e995bc5 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -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, diff --git a/frontend/src/routes/admin.tsx b/frontend/src/routes/admin.tsx index 4f5ff6d..ee0476d 100644 --- a/frontend/src/routes/admin.tsx +++ b/frontend/src/routes/admin.tsx @@ -22,13 +22,6 @@ function AdminLayout() { > {t('admin.media.title')} - - {t('admin.downloads.title')} -