diff --git a/backend/src/TeleWave.Api/Endpoints/MaintenanceEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MaintenanceEndpoints.cs new file mode 100644 index 0000000..a7ffaa9 --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/MaintenanceEndpoints.cs @@ -0,0 +1,46 @@ +using LiteCqrs; +using TeleWave.Api.Common; +using TeleWave.Application.Maintenance.ClearAllMedia; +using TeleWave.Application.Maintenance.DeleteAllShows; +using TeleWave.Application.Maintenance.DeleteShowMedia; +using TeleWave.Infrastructure.Identity; + +namespace TeleWave.Api.Endpoints; + +public static class MaintenanceEndpoints +{ + public static IEndpointRouteBuilder MapMaintenanceEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/maintenance") + .WithTags("Admin.Maintenance") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapPost("/clear-media", ClearMedia).Produces(); + admin.MapPost("/clear-shows", ClearShows).Produces(); + admin.MapPost("/shows/{showId:guid}/clear-media", ClearShowMedia).Produces(); + + return app; + } + + private static async Task ClearMedia(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ClearAllMediaCommand(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task ClearShows(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new DeleteAllShowsCommand(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task ClearShowMedia( + Guid showId, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeleteShowMediaCommand(showId), cancellationToken); + return result.ToHttpResult(); + } +} diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs index 0f41e3d..1f821d9 100644 --- a/backend/src/TeleWave.Api/Program.cs +++ b/backend/src/TeleWave.Api/Program.cs @@ -116,6 +116,7 @@ app.MapMediaEndpoints(); app.MapShowEndpoints(); app.MapChannelEndpoints(); app.MapStreamingEndpoints(); +app.MapMaintenanceEndpoints(); // Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов. app.UseDefaultFiles(); diff --git a/backend/src/TeleWave.Application/Maintenance/ClearAllMedia/ClearAllMediaCommand.cs b/backend/src/TeleWave.Application/Maintenance/ClearAllMedia/ClearAllMediaCommand.cs new file mode 100644 index 0000000..1009cb8 --- /dev/null +++ b/backend/src/TeleWave.Application/Maintenance/ClearAllMedia/ClearAllMediaCommand.cs @@ -0,0 +1,7 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Maintenance.ClearAllMedia; + +/// Удаляет ВСЕ медиа-ассеты (строки + файлы) и связанное расписание. Опасная операция. +public sealed record ClearAllMediaCommand : ICommand>; diff --git a/backend/src/TeleWave.Application/Maintenance/ClearAllMedia/ClearAllMediaCommandHandler.cs b/backend/src/TeleWave.Application/Maintenance/ClearAllMedia/ClearAllMediaCommandHandler.cs new file mode 100644 index 0000000..ded58f3 --- /dev/null +++ b/backend/src/TeleWave.Application/Maintenance/ClearAllMedia/ClearAllMediaCommandHandler.cs @@ -0,0 +1,29 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Maintenance.ClearAllMedia; + +public sealed class ClearAllMediaCommandHandler(IAppDbContext dbContext, IMediaStorage storage) + : ICommandHandler> +{ + public async Task> Handle( + ClearAllMediaCommand command, + CancellationToken cancellationToken + ) + { + var assets = await dbContext.MediaAssets + .Select(a => new { a.Id, a.OriginalExtension }) + .ToListAsync(cancellationToken); + + foreach (var asset in assets) + storage.DeleteAssetArtifacts(asset.Id, asset.OriginalExtension); + + // Расписание ссылается на удаляемые ассеты — чистим его тоже. + await dbContext.ScheduleEntries.ExecuteDeleteAsync(cancellationToken); + var deleted = await dbContext.MediaAssets.ExecuteDeleteAsync(cancellationToken); + + return Result.Success(deleted); + } +} diff --git a/backend/src/TeleWave.Application/Maintenance/DeleteAllShows/DeleteAllShowsCommand.cs b/backend/src/TeleWave.Application/Maintenance/DeleteAllShows/DeleteAllShowsCommand.cs new file mode 100644 index 0000000..b0e5017 --- /dev/null +++ b/backend/src/TeleWave.Application/Maintenance/DeleteAllShows/DeleteAllShowsCommand.cs @@ -0,0 +1,7 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Maintenance.DeleteAllShows; + +/// Удаляет ВСЕ шоу (с сериями через каскад). Медиа-ассеты остаются в библиотеке. +public sealed record DeleteAllShowsCommand : ICommand>; diff --git a/backend/src/TeleWave.Application/Maintenance/DeleteAllShows/DeleteAllShowsCommandHandler.cs b/backend/src/TeleWave.Application/Maintenance/DeleteAllShows/DeleteAllShowsCommandHandler.cs new file mode 100644 index 0000000..1085ee5 --- /dev/null +++ b/backend/src/TeleWave.Application/Maintenance/DeleteAllShows/DeleteAllShowsCommandHandler.cs @@ -0,0 +1,20 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Maintenance.DeleteAllShows; + +public sealed class DeleteAllShowsCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + public async Task> Handle( + DeleteAllShowsCommand command, + CancellationToken cancellationToken + ) + { + // Серии удаляются каскадом (FK Show → ShowEpisode: OnDelete Cascade). + var deleted = await dbContext.Shows.ExecuteDeleteAsync(cancellationToken); + return Result.Success(deleted); + } +} diff --git a/backend/src/TeleWave.Application/Maintenance/DeleteShowMedia/DeleteShowMediaCommand.cs b/backend/src/TeleWave.Application/Maintenance/DeleteShowMedia/DeleteShowMediaCommand.cs new file mode 100644 index 0000000..0b2f779 --- /dev/null +++ b/backend/src/TeleWave.Application/Maintenance/DeleteShowMedia/DeleteShowMediaCommand.cs @@ -0,0 +1,7 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Maintenance.DeleteShowMedia; + +/// Удаляет медиа-ассеты (файлы + строки) всех серий указанного шоу и очищает его серии. +public sealed record DeleteShowMediaCommand(Guid ShowId) : ICommand>; diff --git a/backend/src/TeleWave.Application/Maintenance/DeleteShowMedia/DeleteShowMediaCommandHandler.cs b/backend/src/TeleWave.Application/Maintenance/DeleteShowMedia/DeleteShowMediaCommandHandler.cs new file mode 100644 index 0000000..b632494 --- /dev/null +++ b/backend/src/TeleWave.Application/Maintenance/DeleteShowMedia/DeleteShowMediaCommandHandler.cs @@ -0,0 +1,47 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Library; + +namespace TeleWave.Application.Maintenance.DeleteShowMedia; + +public sealed class DeleteShowMediaCommandHandler(IAppDbContext dbContext, IMediaStorage storage) + : ICommandHandler> +{ + public async Task> Handle( + DeleteShowMediaCommand command, + CancellationToken cancellationToken + ) + { + var show = await dbContext.Shows + .Include(s => s.Episodes) + .FirstOrDefaultAsync(s => s.Id == command.ShowId, cancellationToken); + if (show is null) + return Result.Failure(ShowErrors.NotFound); + + var episodes = show.Episodes.ToList(); + var assetIds = episodes.Select(e => e.MediaAssetId).Distinct().ToList(); + + var assets = await dbContext.MediaAssets + .Where(a => assetIds.Contains(a.Id)) + .Select(a => new { a.Id, a.OriginalExtension }) + .ToListAsync(cancellationToken); + + foreach (var asset in assets) + storage.DeleteAssetArtifacts(asset.Id, asset.OriginalExtension); + + await dbContext.ScheduleEntries + .Where(e => assetIds.Contains(e.MediaAssetId)) + .ExecuteDeleteAsync(cancellationToken); + await dbContext.MediaAssets + .Where(a => assetIds.Contains(a.Id)) + .ExecuteDeleteAsync(cancellationToken); + + // Серии шоу теперь указывают на удалённые ассеты — убираем их (сохранится через UnitOfWork). + foreach (var episode in episodes) + show.RemoveEpisode(episode.Id); + + return Result.Success(assets.Count); + } +} diff --git a/backend/src/TeleWave.Application/Media/MediaErrors.cs b/backend/src/TeleWave.Application/Media/MediaErrors.cs index a2d3d45..ebaa324 100644 --- a/backend/src/TeleWave.Application/Media/MediaErrors.cs +++ b/backend/src/TeleWave.Application/Media/MediaErrors.cs @@ -33,4 +33,9 @@ public static class MediaErrors "Media.SourceNotFound", "Исходный файл не найден в хранилище." ); + + public static readonly Error DuplicateFileName = Error.Conflict( + "Media.DuplicateFileName", + "Файл с таким именем уже загружен." + ); } diff --git a/backend/src/TeleWave.Application/Media/Register/RegisterMediaAssetCommandHandler.cs b/backend/src/TeleWave.Application/Media/Register/RegisterMediaAssetCommandHandler.cs index 4427cc9..1ebc6e6 100644 --- a/backend/src/TeleWave.Application/Media/Register/RegisterMediaAssetCommandHandler.cs +++ b/backend/src/TeleWave.Application/Media/Register/RegisterMediaAssetCommandHandler.cs @@ -1,4 +1,5 @@ using LiteCqrs; +using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; using TeleWave.Domain.Media; @@ -15,6 +16,16 @@ public sealed class RegisterMediaAssetCommandHandler( CancellationToken cancellationToken ) { + // Дедуп по имени: тот же файл уже в библиотеке (кроме проваленных — их разрешаем перезалить). + var duplicate = await dbContext.MediaAssets.AnyAsync( + x => + x.OriginalFileName == command.OriginalFileName + && x.Status != MediaAssetStatus.Failed, + cancellationToken + ); + if (duplicate) + return Result.Failure(MediaErrors.DuplicateFileName); + var extension = Path.GetExtension(command.OriginalFileName).ToLowerInvariant(); var asset = MediaAsset.Register(command.OriginalFileName, extension, command.Source); diff --git a/frontend/src/features/admin/maintenance/MaintenancePanel.tsx b/frontend/src/features/admin/maintenance/MaintenancePanel.tsx new file mode 100644 index 0000000..e54c1a4 --- /dev/null +++ b/frontend/src/features/admin/maintenance/MaintenancePanel.tsx @@ -0,0 +1,132 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { AlertTriangle } from 'lucide-react' +import { HttpError } from '@/shared/api/client' +import { Button } from '@/shared/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' +import { toast } from '@/shared/ui/toast-store' +import { listShows } from '@/features/admin/shows/api' +import { clearAllMedia, deleteAllShows, deleteShowMedia } from './api' + +export function MaintenancePanel() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [showId, setShowId] = useState('') + + const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows }) + + const onError = (error: unknown) => + toast.error(error instanceof HttpError ? error.detail : t('common.error')) + + // Затрагиваются медиа/шоу/каналы — сбрасываем все связанные кэши. + const invalidateAll = () => { + for (const key of [['admin', 'media'], ['admin', 'shows'], ['admin', 'channels']]) + void queryClient.invalidateQueries({ queryKey: key }) + } + + const clearMedia = useMutation({ + mutationFn: clearAllMedia, + onSuccess: (count) => { + toast.success(t('admin.maintenance.doneCount', { count })) + invalidateAll() + }, + onError, + }) + + const clearShows = useMutation({ + mutationFn: deleteAllShows, + onSuccess: (count) => { + toast.success(t('admin.maintenance.doneCount', { count })) + invalidateAll() + }, + onError, + }) + + const clearShowMedia = useMutation({ + mutationFn: () => deleteShowMedia(showId), + onSuccess: (count) => { + setShowId('') + toast.success(t('admin.maintenance.doneCount', { count })) + invalidateAll() + }, + onError, + }) + + const confirmed = (message: string, run: () => void) => { + if (window.confirm(message)) run() + } + + return ( +
+
+ +

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

+
+

{t('admin.maintenance.warning')}

+ + + + {t('admin.maintenance.clearMedia')} + {t('admin.maintenance.clearMediaHint')} + + + + + + + + + {t('admin.maintenance.clearShowMedia')} + {t('admin.maintenance.clearShowMediaHint')} + + + + + + + + + + {t('admin.maintenance.deleteShows')} + {t('admin.maintenance.deleteShowsHint')} + + + + + +
+ ) +} diff --git a/frontend/src/features/admin/maintenance/api.ts b/frontend/src/features/admin/maintenance/api.ts new file mode 100644 index 0000000..5adfd52 --- /dev/null +++ b/frontend/src/features/admin/maintenance/api.ts @@ -0,0 +1,13 @@ +import { apiRequest } from '@/shared/api/client' + +export function clearAllMedia() { + return apiRequest('/admin/maintenance/clear-media', { method: 'POST' }) +} + +export function deleteAllShows() { + return apiRequest('/admin/maintenance/clear-shows', { method: 'POST' }) +} + +export function deleteShowMedia(showId: string) { + return apiRequest(`/admin/maintenance/shows/${showId}/clear-media`, { method: 'POST' }) +} diff --git a/frontend/src/features/admin/media/MediaPanel.tsx b/frontend/src/features/admin/media/MediaPanel.tsx index 8902ef6..b52a534 100644 --- a/frontend/src/features/admin/media/MediaPanel.tsx +++ b/frontend/src/features/admin/media/MediaPanel.tsx @@ -64,15 +64,31 @@ export function MediaPanel() { const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError }) // Массовая загрузка: файлы отправляются по одному (обработка всё равно в очереди по одному), - // прогресс — «текущий/всего · %». Список обновляется после каждого файла. + // прогресс — «текущий/всего · %». Дубликаты по имени пропускаются заранее (сервер тоже отклонит). const handleFiles = async (files: FileList) => { const list = Array.from(files) + + let existing = new Set() + try { + const all = await listMedia({ + page: 1, + pageSize: 1000, + statuses: ['Pending', 'Processing', 'Ready'], + }) + existing = new Set(all.items.map((a) => a.originalFileName)) + } catch { + // Не удалось получить список — положимся на серверную проверку дубликатов. + } + + const toUpload = list.filter((f) => !existing.has(f.name)) + const skipped = list.length - toUpload.length + let uploaded = 0 - for (let i = 0; i < list.length; i++) { - setUpload({ current: i + 1, total: list.length, percent: 0 }) + for (let i = 0; i < toUpload.length; i++) { + setUpload({ current: i + 1, total: toUpload.length, percent: 0 }) try { - await uploadMedia(list[i], (percent) => - setUpload({ current: i + 1, total: list.length, percent }), + await uploadMedia(toUpload[i], (percent) => + setUpload({ current: i + 1, total: toUpload.length, percent }), ) uploaded++ invalidate() @@ -83,6 +99,7 @@ export function MediaPanel() { setUpload(null) if (fileInput.current) fileInput.current.value = '' if (uploaded > 0) toast.success(t('admin.media.uploadedCount', { count: uploaded })) + if (skipped > 0) toast.message(t('admin.media.skippedDuplicates', { count: skipped })) } return ( diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 35b4996..4195c6b 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 AdminMaintenanceRouteImport } from './routes/admin/maintenance' import { Route as AdminMediaRouteImport } from './routes/admin/media' import { Route as AdminRolesRouteImport } from './routes/admin/roles' import { Route as AdminShowsRouteImport } from './routes/admin/shows' @@ -66,6 +67,11 @@ const AdminChannelsRoute = AdminChannelsRouteImport.update({ path: '/channels', getParentRoute: () => AdminRoute, } as any) +const AdminMaintenanceRoute = AdminMaintenanceRouteImport.update({ + id: '/maintenance', + path: '/maintenance', + getParentRoute: () => AdminRoute, +} as any) const AdminMediaRoute = AdminMediaRouteImport.update({ id: '/media', path: '/media', @@ -115,6 +121,7 @@ export interface FileRoutesByFullPath { '/register': typeof RegisterRoute '/settings': typeof SettingsRoute '/admin/channels': typeof AdminChannelsRouteWithChildren + '/admin/maintenance': typeof AdminMaintenanceRoute '/admin/media': typeof AdminMediaRoute '/admin/roles': typeof AdminRolesRoute '/admin/shows': typeof AdminShowsRouteWithChildren @@ -131,6 +138,7 @@ export interface FileRoutesByTo { '/login': typeof LoginRoute '/register': typeof RegisterRoute '/settings': typeof SettingsRoute + '/admin/maintenance': typeof AdminMaintenanceRoute '/admin/media': typeof AdminMediaRoute '/admin/roles': typeof AdminRolesRoute '/admin/users': typeof AdminUsersRoute @@ -149,6 +157,7 @@ export interface FileRoutesById { '/register': typeof RegisterRoute '/settings': typeof SettingsRoute '/admin/channels': typeof AdminChannelsRouteWithChildren + '/admin/maintenance': typeof AdminMaintenanceRoute '/admin/media': typeof AdminMediaRoute '/admin/roles': typeof AdminRolesRoute '/admin/shows': typeof AdminShowsRouteWithChildren @@ -169,6 +178,7 @@ export interface FileRouteTypes { | '/register' | '/settings' | '/admin/channels' + | '/admin/maintenance' | '/admin/media' | '/admin/roles' | '/admin/shows' @@ -185,6 +195,7 @@ export interface FileRouteTypes { | '/login' | '/register' | '/settings' + | '/admin/maintenance' | '/admin/media' | '/admin/roles' | '/admin/users' @@ -202,6 +213,7 @@ export interface FileRouteTypes { | '/register' | '/settings' | '/admin/channels' + | '/admin/maintenance' | '/admin/media' | '/admin/roles' | '/admin/shows' @@ -280,6 +292,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminChannelsRouteImport parentRoute: typeof AdminRoute } + '/admin/maintenance': { + id: '/admin/maintenance' + path: '/maintenance' + fullPath: '/admin/maintenance' + preLoaderRoute: typeof AdminMaintenanceRouteImport + parentRoute: typeof AdminRoute + } '/admin/media': { id: '/admin/media' path: '/media' @@ -369,6 +388,7 @@ const AdminShowsRouteWithChildren = AdminShowsRoute._addFileChildren( interface AdminRouteChildren { AdminChannelsRoute: typeof AdminChannelsRouteWithChildren + AdminMaintenanceRoute: typeof AdminMaintenanceRoute AdminMediaRoute: typeof AdminMediaRoute AdminRolesRoute: typeof AdminRolesRoute AdminShowsRoute: typeof AdminShowsRouteWithChildren @@ -378,6 +398,7 @@ interface AdminRouteChildren { const AdminRouteChildren: AdminRouteChildren = { AdminChannelsRoute: AdminChannelsRouteWithChildren, + AdminMaintenanceRoute: AdminMaintenanceRoute, AdminMediaRoute: AdminMediaRoute, AdminRolesRoute: AdminRolesRoute, AdminShowsRoute: AdminShowsRouteWithChildren, diff --git a/frontend/src/routes/admin.tsx b/frontend/src/routes/admin.tsx index ee0476d..142da5d 100644 --- a/frontend/src/routes/admin.tsx +++ b/frontend/src/routes/admin.tsx @@ -50,6 +50,13 @@ function AdminLayout() { > {t('admin.users.title')} + + {t('admin.maintenance.title')} + diff --git a/frontend/src/routes/admin/maintenance.tsx b/frontend/src/routes/admin/maintenance.tsx new file mode 100644 index 0000000..621c125 --- /dev/null +++ b/frontend/src/routes/admin/maintenance.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +import { MaintenancePanel } from '@/features/admin/maintenance/MaintenancePanel' + +export const Route = createFileRoute('/admin/maintenance')({ component: MaintenancePanel }) diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index ad6839c..ad836dc 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -106,6 +106,7 @@ const resources = { upload: 'Загрузить', uploaded: 'Файл загружен, идёт обработка', uploadedCount: 'Загружено файлов: {{count}}', + skippedDuplicates: 'Пропущено дубликатов: {{count}}', filterActive: 'Активные', filterAll: 'Все', name: 'Файл', @@ -176,6 +177,21 @@ const resources = { schedule: 'Расписание (12 ч)', noSchedule: 'Расписание ещё не построено — нажмите «Пересобрать»', }, + maintenance: { + title: 'Обслуживание', + warning: 'Операции необратимы — удаляют данные и файлы навсегда.', + clearMedia: 'Очистить все медиа', + clearMediaHint: 'Удалит все загруженные файлы и их нарезку, а также расписание каналов.', + confirmClearMedia: 'Удалить ВСЕ медиа-файлы безвозвратно?', + clearShowMedia: 'Удалить медиа шоу', + clearShowMediaHint: 'Удалит файлы всех серий выбранного шоу и очистит его серии.', + confirmClearShowMedia: 'Удалить все медиа выбранного шоу безвозвратно?', + pickShow: 'Выберите шоу', + deleteShows: 'Удалить все шоу', + deleteShowsHint: 'Удалит все шоу и их серии. Медиа-файлы останутся в библиотеке.', + confirmDeleteShows: 'Удалить ВСЕ шоу безвозвратно?', + doneCount: 'Удалено: {{count}}', + }, }, }, }, @@ -283,6 +299,7 @@ const resources = { upload: 'Upload', uploaded: 'File uploaded, processing started', uploadedCount: 'Uploaded files: {{count}}', + skippedDuplicates: 'Skipped duplicates: {{count}}', filterActive: 'Active', filterAll: 'All', name: 'File', @@ -353,6 +370,21 @@ const resources = { schedule: 'Schedule (12h)', noSchedule: 'Schedule not built yet — click “Rebuild”', }, + maintenance: { + title: 'Maintenance', + warning: 'These actions are irreversible — data and files are deleted permanently.', + clearMedia: 'Clear all media', + clearMediaHint: 'Deletes all uploaded files and their segments, plus channel schedules.', + confirmClearMedia: 'Permanently delete ALL media files?', + clearShowMedia: 'Delete show media', + clearShowMediaHint: "Deletes files of the selected show's episodes and clears its episodes.", + confirmClearShowMedia: 'Permanently delete all media of the selected show?', + pickShow: 'Pick a show', + deleteShows: 'Delete all shows', + deleteShowsHint: 'Deletes all shows and their episodes. Media files stay in the library.', + confirmDeleteShows: 'Permanently delete ALL shows?', + doneCount: 'Deleted: {{count}}', + }, }, }, },