diff --git a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs index 483d46a..024ab5d 100644 --- a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs @@ -144,13 +144,15 @@ public static class ShowEndpoints return result.ToHttpResult(); } + /// Снести заодно файлы шоу — иначе они останутся осиротевшими. private static async Task DeleteShow( Guid id, ISender sender, - CancellationToken cancellationToken + CancellationToken cancellationToken, + bool withMedia = false ) { - var result = await sender.Send(new DeleteShowCommand(id), cancellationToken); + var result = await sender.Send(new DeleteShowCommand(id, withMedia), cancellationToken); return result.ToHttpResult(); } diff --git a/backend/src/TeleWave.Application/DependencyInjection.cs b/backend/src/TeleWave.Application/DependencyInjection.cs index 3914036..85717fd 100644 --- a/backend/src/TeleWave.Application/DependencyInjection.cs +++ b/backend/src/TeleWave.Application/DependencyInjection.cs @@ -7,6 +7,7 @@ using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Common.Behaviors; using TeleWave.Application.Library.Collections.Suggest; using TeleWave.Application.Library.Genres; +using TeleWave.Application.Media; using TeleWave.Application.Media.ManualInbox; using TeleWave.Application.Media.MovieImport; using TeleWave.Application.Metadata; @@ -44,6 +45,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommand.cs b/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommand.cs index 69951b9..f5bd60b 100644 --- a/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommand.cs +++ b/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommand.cs @@ -3,4 +3,8 @@ using TeleWave.Application.Common.Models; namespace TeleWave.Application.Library.DeleteShow; -public sealed record DeleteShowCommand(Guid ShowId) : ICommand; +/// +/// Удаляет шоу. — снести заодно его файлы: сами по себе они пережили +/// бы шоу и остались бы в списке медиа осиротевшими, занимая место. +/// +public sealed record DeleteShowCommand(Guid ShowId, bool WithMedia = false) : ICommand; diff --git a/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommandHandler.cs b/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommandHandler.cs index a97eb65..2dcce79 100644 --- a/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommandHandler.cs +++ b/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommandHandler.cs @@ -2,6 +2,7 @@ using LiteCqrs; using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; +using TeleWave.Application.Media; using TeleWave.Application.Programming.Groups; using TeleWave.Domain.Programming; @@ -9,15 +10,15 @@ namespace TeleWave.Application.Library.DeleteShow; public sealed class DeleteShowCommandHandler( IAppDbContext dbContext, - GroupMembershipCleaner groupCleaner + GroupMembershipCleaner groupCleaner, + ShowMediaEraser eraser ) : ICommandHandler { public async Task Handle(DeleteShowCommand command, CancellationToken cancellationToken) { - var show = await dbContext.Shows.FirstOrDefaultAsync( - s => s.Id == command.ShowId, - 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); @@ -38,7 +39,21 @@ public sealed class DeleteShowCommandHandler( cancellationToken ); + if (!command.WithMedia) + { + dbContext.Shows.Remove(show); + return Result.Success(); + } + + // Удаление ассетов идёт мимо SaveChanges (ExecuteDelete), поэтому одной транзакцией + // с удалением шоу их связывает только явная транзакция. Файлы — после коммита. + await using var transaction = await dbContext.BeginTransactionAsync(cancellationToken); + var erased = await eraser.EraseAsync(show, cancellationToken); dbContext.Shows.Remove(show); + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + await eraser.DeleteFilesAsync(erased, cancellationToken); return Result.Success(); } } diff --git a/backend/src/TeleWave.Application/Maintenance/DeleteShowMedia/DeleteShowMediaCommandHandler.cs b/backend/src/TeleWave.Application/Maintenance/DeleteShowMedia/DeleteShowMediaCommandHandler.cs index daf65ba..e70df13 100644 --- a/backend/src/TeleWave.Application/Maintenance/DeleteShowMedia/DeleteShowMediaCommandHandler.cs +++ b/backend/src/TeleWave.Application/Maintenance/DeleteShowMedia/DeleteShowMediaCommandHandler.cs @@ -3,10 +3,11 @@ using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; using TeleWave.Application.Library; +using TeleWave.Application.Media; namespace TeleWave.Application.Maintenance.DeleteShowMedia; -public sealed class DeleteShowMediaCommandHandler(IAppDbContext dbContext, IMediaStorage storage) +public sealed class DeleteShowMediaCommandHandler(IAppDbContext dbContext, ShowMediaEraser eraser) : ICommandHandler> { public async Task> Handle( @@ -21,37 +22,18 @@ public sealed class DeleteShowMediaCommandHandler(IAppDbContext dbContext, IMedi 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); - - // Все три операции БД (удаление записей расписания, ассетов, отвязка серий) — атомарно в одной - // транзакции; файлы удаляем только после успешного коммита (best-effort), чтобы при падении не - // остаться с сериями, указывающими на уже удалённые с диска ассеты. + // Всё, что в БД, — одной транзакцией; файлы удаляются после коммита. await using var transaction = await dbContext.BeginTransactionAsync(cancellationToken); - await dbContext - .ScheduleEntries.Where(e => assetIds.Contains(e.MediaAssetId)) - .ExecuteDeleteAsync(cancellationToken); - await dbContext - .MediaAssets.Where(a => assetIds.Contains(a.Id)) - .ExecuteDeleteAsync(cancellationToken); + var erased = await eraser.EraseAsync(show, cancellationToken); - // Серии шоу теперь указывают на удалённые ассеты — убираем их. + // Шоу остаётся, а его серии теперь указывали бы на удалённые ассеты — убираем их. foreach (var episode in episodes) show.RemoveEpisode(episode.Id); await dbContext.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); - foreach (var asset in assets) - await storage.DeleteAssetArtifactsAsync( - asset.Id, - asset.OriginalExtension, - cancellationToken - ); - - return Result.Success(assets.Count); + await eraser.DeleteFilesAsync(erased, cancellationToken); + return Result.Success(erased.Count); } } diff --git a/backend/src/TeleWave.Application/Media/ShowMediaEraser.cs b/backend/src/TeleWave.Application/Media/ShowMediaEraser.cs new file mode 100644 index 0000000..cbc77e2 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/ShowMediaEraser.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Media; + +/// +/// Сносит медиа шоу: записи расписания по его ассетам, сами ассеты и файлы на диске. +/// +/// Порядок жёсткий и общий для всех, кто этим пользуется: сначала БД в транзакции, файлы — только +/// после успешного коммита. Наоборот было бы хуже всего: при падении остались бы серии, ссылающиеся +/// на удалённые с диска файлы, и раздача упёрлась бы в пустоту. +/// +public sealed class ShowMediaEraser(IAppDbContext dbContext, IMediaStorage storage) +{ + /// Ассет к удалению с диска: идентификатор и расширение оригинала. + public readonly record struct ErasedAsset(Guid Id, string Extension); + + /// + /// Удаляет из БД ассеты шоу и связанные записи расписания. Возвращает то, что предстоит убрать + /// с диска, — вызывающий делает это после коммита своей транзакции. + /// + public async Task> EraseAsync( + Show show, + CancellationToken cancellationToken + ) + { + var assetIds = show.Episodes.Select(e => e.MediaAssetId).Distinct().ToList(); + if (assetIds.Count == 0) + return []; + + var assets = await dbContext + .MediaAssets.Where(a => assetIds.Contains(a.Id)) + .Select(a => new ErasedAsset(a.Id, a.OriginalExtension)) + .ToListAsync(cancellationToken); + + await dbContext + .ScheduleEntries.Where(e => assetIds.Contains(e.MediaAssetId)) + .ExecuteDeleteAsync(cancellationToken); + await dbContext + .MediaAssets.Where(a => assetIds.Contains(a.Id)) + .ExecuteDeleteAsync(cancellationToken); + + return assets; + } + + /// Убирает файлы (оригинал и нарезку). Best-effort: строки в БД уже удалены. + public async Task DeleteFilesAsync( + IReadOnlyList assets, + CancellationToken cancellationToken + ) + { + foreach (var asset in assets) + await storage.DeleteAssetArtifactsAsync(asset.Id, asset.Extension, cancellationToken); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs index b3ae96f..52e35bc 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Transfer/ImportGridCommandHandler.cs @@ -198,29 +198,13 @@ public sealed class ImportGridCommandHandler( foreach (var configGroup in wanted) { - if (string.IsNullOrWhiteSpace(configGroup.Name)) - continue; - if (names.ContainsKey(configGroup.Name.Trim())) + if ( + string.IsNullOrWhiteSpace(configGroup.Name) + || names.ContainsKey(configGroup.Name.Trim()) + ) continue; - var group = Group.Create(configGroup.Name, configGroup.Description); - - foreach (var showName in configGroup.Shows ?? []) - { - if (Resolve(showName, shows, "Шоу", warnings) is { } showId) - group.AddElement(GroupElementKind.Show, showId); - } - - foreach (var collectionName in configGroup.Collections ?? []) - { - if (Resolve(collectionName, collections, "Коллекция", warnings) is { } id) - group.AddElement(GroupElementKind.Collection, id); - } - - if (group.Items.Count == 0) - warnings.Add( - $"Группа «{group.Name}» создана пустой — слоты по ней закроет аварийный фон." - ); + var group = BuildGroup(configGroup, shows, collections, warnings); dbContext.Groups.Add(group); // Статистика — кэш для карточки группы. Коллекция, заведённая этим же импортом, ещё не @@ -233,6 +217,40 @@ public sealed class ImportGridCommandHandler( return (names, created); } + /// + /// Собирает состав группы из имён файла: что нашлось — в позиции, чего нет — в замечания. + /// Группа, где не нашлось ничего, всё равно создаётся, но с оговоркой: пустая группа — это + /// дыра в эфире, и узнать о ней надо на импорте, а не после применения сетки. + /// + private static Group BuildGroup( + GridConfigGroup configGroup, + IReadOnlyDictionary shows, + IReadOnlyDictionary collections, + List warnings + ) + { + var group = Group.Create(configGroup.Name, configGroup.Description); + + foreach (var showName in configGroup.Shows ?? []) + { + if (Resolve(showName, shows, "Шоу", warnings) is { } showId) + group.AddElement(GroupElementKind.Show, showId); + } + + foreach (var collectionName in configGroup.Collections ?? []) + { + if (Resolve(collectionName, collections, "Коллекция", warnings) is { } id) + group.AddElement(GroupElementKind.Collection, id); + } + + if (group.Items.Count == 0) + warnings.Add( + $"Группа «{group.Name}» создана пустой — слоты по ней закроет аварийный фон." + ); + + return group; + } + /// Шоу по названиям. Одинаковые названия схлопываются в первое найденное. private Task> ShowNamesAsync(CancellationToken cancellationToken) => NamesAsync( diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/QueryHandlersTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/QueryHandlersTests.cs index 53ac6cb..3c25100 100644 --- a/backend/tests/TeleWave.Application.Tests/Broadcast/QueryHandlersTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/QueryHandlersTests.cs @@ -8,6 +8,7 @@ using TeleWave.Application.Broadcast.ListChannels; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Library.DeleteShow; using TeleWave.Application.Library.GetShow; +using TeleWave.Application.Media; using TeleWave.Application.Programming.Groups; using TeleWave.Application.Tests.Support; using TeleWave.Domain.Broadcast; @@ -24,6 +25,9 @@ public class QueryHandlersTests private static GroupMembershipCleaner GroupCleaner(IAppDbContext db) => new(db, GroupServices.Stats(db)); + private static ShowMediaEraser Eraser(IAppDbContext db) => + new(db, Substitute.For()); + [Fact] public async Task GetChannel_UnknownId_ReturnsNotFound() { @@ -153,13 +157,13 @@ public class QueryHandlersTests } await using var db = fixture.New(); - var missing = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle( + var missing = await new DeleteShowCommandHandler(db, GroupCleaner(db), Eraser(db)).Handle( new DeleteShowCommand(Guid.NewGuid()), CancellationToken.None ); Assert.False(missing.IsSuccess); - var ok = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle( + var ok = await new DeleteShowCommandHandler(db, GroupCleaner(db), Eraser(db)).Handle( new DeleteShowCommand(show.Id), CancellationToken.None ); diff --git a/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs b/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs index 0a0bba2..6140a5c 100644 --- a/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore; using NSubstitute; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Library; @@ -27,6 +28,9 @@ public class DeleteGuardsTests private static GroupMembershipCleaner GroupCleaner(IAppDbContext db) => new(db, GroupServices.Stats(db)); + private static ShowMediaEraser Eraser(IAppDbContext db) => + new(db, Substitute.For()); + [Fact] public async Task DeleteShow_InFutureSchedule_Fails() { @@ -53,7 +57,7 @@ public class DeleteGuardsTests } await using var db = fixture.New(); - var result = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle( + var result = await new DeleteShowCommandHandler(db, GroupCleaner(db), Eraser(db)).Handle( new DeleteShowCommand(show.Id), CancellationToken.None ); @@ -62,6 +66,41 @@ public class DeleteGuardsTests Assert.Equal(ShowErrors.InFutureSchedule, result.Error); } + /// + /// Без флага медиа переживает шоу: серия — это ссылка на ассет по значению, внешнего ключа + /// между ними нет, и каскад БД до файлов не доходит. + /// + [Fact] + public async Task DeleteShow_KeepsMedia_ByDefault() + { + var fixture = new TestDb(); + var show = Show.Create("A", ShowKind.Single); + var asset = MediaAsset.Register("a.mkv", ".mkv", MediaSource.Upload); + show.AddEpisode(asset.Id); + + await using (var seed = fixture.New()) + { + seed.Shows.Add(show); + seed.MediaAssets.Add(asset); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using (var db = fixture.New()) + { + var result = await new DeleteShowCommandHandler( + db, + GroupCleaner(db), + Eraser(db) + ).Handle(new DeleteShowCommand(show.Id), CancellationToken.None); + Assert.True(result.IsSuccess); + await db.SaveChangesAsync(CancellationToken.None); + } + + await using var check = fixture.New(); + Assert.Empty(await check.Shows.ToListAsync(CancellationToken.None)); + Assert.Single(await check.MediaAssets.ToListAsync(CancellationToken.None)); + } + /// Отыгранный эфир шоу не держит: иначе его нельзя было бы удалить никогда. [Fact] public async Task DeleteShow_OnlyPastSchedule_Succeeds() @@ -81,7 +120,7 @@ public class DeleteGuardsTests } await using var db = fixture.New(); - var result = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle( + var result = await new DeleteShowCommandHandler(db, GroupCleaner(db), Eraser(db)).Handle( new DeleteShowCommand(show.Id), CancellationToken.None ); diff --git a/backend/tests/TeleWave.Integration.Tests/TransactionIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/TransactionIntegrationTests.cs index 53aad73..0656ffa 100644 --- a/backend/tests/TeleWave.Integration.Tests/TransactionIntegrationTests.cs +++ b/backend/tests/TeleWave.Integration.Tests/TransactionIntegrationTests.cs @@ -1,8 +1,11 @@ using Microsoft.EntityFrameworkCore; using NSubstitute; using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Library.DeleteShow; using TeleWave.Application.Maintenance.DeleteShowMedia; +using TeleWave.Application.Media; using TeleWave.Application.Media.Register; +using TeleWave.Application.Programming.Groups; using TeleWave.Domain.Broadcast; using TeleWave.Domain.Library; using TeleWave.Domain.Media; @@ -94,10 +97,10 @@ public sealed class TransactionIntegrationTests(PostgresFixture fixture) await using (var db = fixture.CreateContext()) { - var result = await new DeleteShowMediaCommandHandler(db, storage).Handle( - new DeleteShowMediaCommand(show.Id), - CancellationToken.None - ); + var result = await new DeleteShowMediaCommandHandler( + db, + new ShowMediaEraser(db, storage) + ).Handle(new DeleteShowMediaCommand(show.Id), CancellationToken.None); Assert.True(result.IsSuccess); } @@ -110,4 +113,66 @@ public sealed class TransactionIntegrationTests(PostgresFixture fixture) .Received() .DeleteAssetArtifactsAsync(asset.Id, ".mkv", Arg.Any()); } + + /// + /// Удаление шоу «вместе с медиа»: ассеты, записи расписания и файлы уходят одной операцией. + /// Проверяется на настоящем Postgres — ExecuteDelete и транзакции InMemory не умеет. + /// + [SkippableFact] + public async Task DeleteShow_WithMedia_RemovesEverything() + { + var channel = Channel.Create("Канал", "kanal", DateTimeOffset.UnixEpoch); + var show = Show.Create("Шоу", ShowKind.Single); + var asset = MediaAsset.Register("movie.mkv", ".mkv", MediaSource.Upload); + show.AddEpisode(asset.Id); + var entry = ScheduleEntry.Program( + channel.Id, + asset.Id, + DateTimeOffset.UnixEpoch, + DateTimeOffset.UnixEpoch.AddMinutes(20), + show.Id, + 0 + ); + + await using (var seed = fixture.CreateContext()) + { + seed.Channels.Add(channel); + seed.Shows.Add(show); + seed.MediaAssets.Add(asset); + seed.ScheduleEntries.Add(entry); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var storage = Substitute.For(); + + await using (var db = fixture.CreateContext()) + { + var result = await new DeleteShowCommandHandler( + db, + new GroupMembershipCleaner( + db, + new GroupStatsService( + db, + new GroupElementResolver(db), + new DynamicGroupResolver( + new GroupFilterMatcher(db), + new GroupElementResolver(db) + ) + ) + ), + new ShowMediaEraser(db, storage) + ).Handle(new DeleteShowCommand(show.Id, WithMedia: true), CancellationToken.None); + + Assert.True(result.IsSuccess); + await db.SaveChangesAsync(CancellationToken.None); + } + + await using var verify = fixture.CreateContext(); + Assert.False(await verify.Shows.AnyAsync(s => s.Id == show.Id)); + Assert.False(await verify.MediaAssets.AnyAsync(a => a.Id == asset.Id)); + Assert.False(await verify.ScheduleEntries.AnyAsync(e => e.MediaAssetId == asset.Id)); + await storage + .Received() + .DeleteAssetArtifactsAsync(asset.Id, ".mkv", Arg.Any()); + } } diff --git a/frontend/src/features/admin/interstitials/InterstitialsPanel.tsx b/frontend/src/features/admin/interstitials/InterstitialsPanel.tsx index a52fd1f..b57e2c1 100644 --- a/frontend/src/features/admin/interstitials/InterstitialsPanel.tsx +++ b/frontend/src/features/admin/interstitials/InterstitialsPanel.tsx @@ -55,7 +55,11 @@ export function InterstitialsPanel() { }, onError, }) - const deleteClipMutation = useMutation({ mutationFn: deleteShow, onSuccess: invalidate, onError }) + const deleteClipMutation = useMutation({ + mutationFn: (id: string) => deleteShow(id), + onSuccess: invalidate, + onError, + }) const deleteBlockMutation = useMutation({ mutationFn: deleteCollection, onSuccess: invalidate, diff --git a/frontend/src/features/admin/shows/DeleteShowDialog.tsx b/frontend/src/features/admin/shows/DeleteShowDialog.tsx new file mode 100644 index 0000000..3ad1b74 --- /dev/null +++ b/frontend/src/features/admin/shows/DeleteShowDialog.tsx @@ -0,0 +1,77 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { ShowSummaryDto } from '@/shared/api/types' +import { Button } from '@/shared/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/shared/ui/dialog' + +/** + * Подтверждение удаления шоу с выбором, что делать с его файлами. + * + * Галочка нужна потому, что медиа переживает шоу: серия ссылается на ассет по значению, внешнего + * ключа между ними нет, и после удаления файлы остались бы в библиотеке ничьими, занимая место. + * Спросить об этом здесь дешевле, чем потом искать осиротевшие гигабайты в списке медиа. + */ +export function DeleteShowDialog({ + show, + pending, + onConfirm, + onClose, +}: Readonly<{ + show: ShowSummaryDto + pending: boolean + onConfirm: (withMedia: boolean) => void + onClose: () => void +}>) { + const { t } = useTranslation() + const [withMedia, setWithMedia] = useState(false) + const hasMedia = show.episodeCount > 0 + + return ( + !open && onClose()}> + + + {t('admin.shows.deleteTitle')} + {t('admin.shows.deleteHint', { name: show.name })} + + + {hasMedia && ( + + )} + + + + + + + + ) +} diff --git a/frontend/src/features/admin/shows/ShowsPanel.tsx b/frontend/src/features/admin/shows/ShowsPanel.tsx index 8eaba21..b59e8ac 100644 --- a/frontend/src/features/admin/shows/ShowsPanel.tsx +++ b/frontend/src/features/admin/shows/ShowsPanel.tsx @@ -8,6 +8,7 @@ import { SHOW_AUDIENCES, type ShowAudience, type ShowKind, + type ShowSummaryDto, } from '@/shared/api/types' import { useApiError } from '@/shared/lib/use-api-error' import { Badge } from '@/shared/ui/badge' @@ -19,6 +20,7 @@ import { sortRows, useTableSort } from '@/shared/lib/table-sort' import { SortHeader } from '@/shared/ui/sortable' import { listGenres } from '@/features/admin/genres/api' import { createShow, deleteShow, listShows } from './api' +import { DeleteShowDialog } from './DeleteShowDialog' import { BulkTagBar } from './BulkTagBar' const PAGE_SIZE = 20 @@ -110,7 +112,17 @@ export function ShowsPanel() { }, onError, }) - const deleteMutation = useMutation({ mutationFn: deleteShow, onSuccess: invalidate, onError }) + // Шоу к удалению: сначала спрашиваем про файлы, и только потом удаляем. + const [toDelete, setToDelete] = useState(null) + const deleteMutation = useMutation({ + mutationFn: ({ id, withMedia }: { id: string; withMedia: boolean }) => + deleteShow(id, withMedia), + onSuccess: () => { + setToDelete(null) + invalidate() + }, + onError, + }) return (
@@ -309,11 +321,7 @@ export function ShowsPanel() { {show.seasonCount} {show.episodeCount} - @@ -324,6 +332,15 @@ export function ShowsPanel() {
+ + {toDelete && ( + deleteMutation.mutate({ id: toDelete.id, withMedia })} + onClose={() => setToDelete(null)} + /> + )} ) } diff --git a/frontend/src/features/admin/shows/api.ts b/frontend/src/features/admin/shows/api.ts index d5131cc..abac2be 100644 --- a/frontend/src/features/admin/shows/api.ts +++ b/frontend/src/features/admin/shows/api.ts @@ -79,8 +79,10 @@ export function getShowUsage(id: string) { return apiRequest(`/admin/shows/${id}/usage`) } -export function deleteShow(id: string) { - return apiRequest(`/admin/shows/${id}`, { method: 'DELETE' }) +/** `withMedia` — снести заодно файлы шоу: иначе они переживут его осиротевшими. */ +export function deleteShow(id: string, withMedia = false) { + const query = withMedia ? '?withMedia=true' : '' + return apiRequest(`/admin/shows/${id}${query}`, { method: 'DELETE' }) } export function addEpisode(showId: string, mediaAssetId: string) { diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index e10ba9d..14cec80 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -412,6 +412,11 @@ export const en = { genre: 'Genre', allGenres: 'All genres', allKinds: 'All kinds', + deleteTitle: 'Delete the show?', + deleteHint: '“{{name}}” will be removed from the library. This cannot be undone.', + deleteWithMedia: 'Delete its media too: {{count}} file(s)', + deleteWithMediaHint: + 'Otherwise the files stay in the media list with no owner and keep taking space.', otherGenres: 'Other genres', bulk: { selected: 'Selected: {{count}}', diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index 1540276..5d1028a 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -409,6 +409,11 @@ export const ru = { genre: 'Жанр', allGenres: 'Все жанры', allKinds: 'Все типы', + deleteTitle: 'Удалить шоу?', + deleteHint: '«{{name}}» будет удалено из библиотеки. Отменить это нельзя.', + deleteWithMedia: 'Также удалить медиа: файлов {{count}}', + deleteWithMediaHint: + 'Иначе файлы останутся в списке медиа ничьими и продолжат занимать место.', otherGenres: 'Остальные жанры', bulk: { selected: 'Отмечено: {{count}}',