Add maintenance management features: implement maintenance endpoints in the API, enhance media upload handling to skip duplicate files, and update frontend routes and translations for maintenance operations.

This commit is contained in:
Leonid Pershin
2026-07-24 19:52:56 +03:00
parent 6e7db4a6a9
commit 4202c51a5b
17 changed files with 411 additions and 5 deletions
@@ -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<int>();
admin.MapPost("/clear-shows", ClearShows).Produces<int>();
admin.MapPost("/shows/{showId:guid}/clear-media", ClearShowMedia).Produces<int>();
return app;
}
private static async Task<IResult> ClearMedia(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ClearAllMediaCommand(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ClearShows(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new DeleteAllShowsCommand(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ClearShowMedia(
Guid showId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteShowMediaCommand(showId), cancellationToken);
return result.ToHttpResult();
}
}
+1
View File
@@ -116,6 +116,7 @@ app.MapMediaEndpoints();
app.MapShowEndpoints();
app.MapChannelEndpoints();
app.MapStreamingEndpoints();
app.MapMaintenanceEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Maintenance.ClearAllMedia;
/// <summary>Удаляет ВСЕ медиа-ассеты (строки + файлы) и связанное расписание. Опасная операция.</summary>
public sealed record ClearAllMediaCommand : ICommand<Result<int>>;
@@ -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<ClearAllMediaCommand, Result<int>>
{
public async Task<Result<int>> 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);
}
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Maintenance.DeleteAllShows;
/// <summary>Удаляет ВСЕ шоу (с сериями через каскад). Медиа-ассеты остаются в библиотеке.</summary>
public sealed record DeleteAllShowsCommand : ICommand<Result<int>>;
@@ -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<DeleteAllShowsCommand, Result<int>>
{
public async Task<Result<int>> Handle(
DeleteAllShowsCommand command,
CancellationToken cancellationToken
)
{
// Серии удаляются каскадом (FK Show → ShowEpisode: OnDelete Cascade).
var deleted = await dbContext.Shows.ExecuteDeleteAsync(cancellationToken);
return Result.Success(deleted);
}
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Maintenance.DeleteShowMedia;
/// <summary>Удаляет медиа-ассеты (файлы + строки) всех серий указанного шоу и очищает его серии.</summary>
public sealed record DeleteShowMediaCommand(Guid ShowId) : ICommand<Result<int>>;
@@ -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<DeleteShowMediaCommand, Result<int>>
{
public async Task<Result<int>> 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<int>(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);
}
}
@@ -33,4 +33,9 @@ public static class MediaErrors
"Media.SourceNotFound",
"Исходный файл не найден в хранилище."
);
public static readonly Error DuplicateFileName = Error.Conflict(
"Media.DuplicateFileName",
"Файл с таким именем уже загружен."
);
}
@@ -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<Guid>(MediaErrors.DuplicateFileName);
var extension = Path.GetExtension(command.OriginalFileName).ToLowerInvariant();
var asset = MediaAsset.Register(command.OriginalFileName, extension, command.Source);
@@ -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 (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-500" />
<h2 className="crt-glow text-xl font-semibold">{t('admin.maintenance.title')}</h2>
</div>
<p className="text-sm text-muted-foreground">{t('admin.maintenance.warning')}</p>
<Card>
<CardHeader>
<CardTitle>{t('admin.maintenance.clearMedia')}</CardTitle>
<CardDescription>{t('admin.maintenance.clearMediaHint')}</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="destructive"
disabled={clearMedia.isPending}
onClick={() => confirmed(t('admin.maintenance.confirmClearMedia'), () => clearMedia.mutate())}
>
{t('admin.maintenance.clearMedia')}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('admin.maintenance.clearShowMedia')}</CardTitle>
<CardDescription>{t('admin.maintenance.clearShowMediaHint')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-2">
<Select value={showId} onValueChange={setShowId}>
<SelectTrigger className="max-w-xs">
<SelectValue placeholder={t('admin.maintenance.pickShow')} />
</SelectTrigger>
<SelectContent>
{shows?.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="destructive"
disabled={!showId || clearShowMedia.isPending}
onClick={() =>
confirmed(t('admin.maintenance.confirmClearShowMedia'), () => clearShowMedia.mutate())
}
>
{t('admin.maintenance.clearShowMedia')}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('admin.maintenance.deleteShows')}</CardTitle>
<CardDescription>{t('admin.maintenance.deleteShowsHint')}</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="destructive"
disabled={clearShows.isPending}
onClick={() => confirmed(t('admin.maintenance.confirmDeleteShows'), () => clearShows.mutate())}
>
{t('admin.maintenance.deleteShows')}
</Button>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,13 @@
import { apiRequest } from '@/shared/api/client'
export function clearAllMedia() {
return apiRequest<number>('/admin/maintenance/clear-media', { method: 'POST' })
}
export function deleteAllShows() {
return apiRequest<number>('/admin/maintenance/clear-shows', { method: 'POST' })
}
export function deleteShowMedia(showId: string) {
return apiRequest<number>(`/admin/maintenance/shows/${showId}/clear-media`, { method: 'POST' })
}
@@ -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<string>()
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 (
+21
View File
@@ -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,
+7
View File
@@ -50,6 +50,13 @@ function AdminLayout() {
>
{t('admin.users.title')}
</Link>
<Link
to="/admin/maintenance"
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
>
{t('admin.maintenance.title')}
</Link>
</nav>
<Outlet />
</div>
@@ -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 })
+32
View File
@@ -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}}',
},
},
},
},