Enhance show deletion functionality to support media removal option
Updated the DeleteShowCommand and its handler to include an optional parameter for media deletion, allowing users to remove associated media files when deleting a show. Refactored related components and API calls to accommodate this new feature, ensuring a seamless user experience. Additionally, updated localization strings to reflect the new functionality and adjusted tests to verify the correct behavior of the deletion process with and without media.
This commit is contained in:
@@ -144,13 +144,15 @@ public static class ShowEndpoints
|
|||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <param name="withMedia">Снести заодно файлы шоу — иначе они останутся осиротевшими.</param>
|
||||||
private static async Task<IResult> DeleteShow(
|
private static async Task<IResult> DeleteShow(
|
||||||
Guid id,
|
Guid id,
|
||||||
ISender sender,
|
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();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using TeleWave.Application.Broadcast.Bumpers;
|
|||||||
using TeleWave.Application.Common.Behaviors;
|
using TeleWave.Application.Common.Behaviors;
|
||||||
using TeleWave.Application.Library.Collections.Suggest;
|
using TeleWave.Application.Library.Collections.Suggest;
|
||||||
using TeleWave.Application.Library.Genres;
|
using TeleWave.Application.Library.Genres;
|
||||||
|
using TeleWave.Application.Media;
|
||||||
using TeleWave.Application.Media.ManualInbox;
|
using TeleWave.Application.Media.ManualInbox;
|
||||||
using TeleWave.Application.Media.MovieImport;
|
using TeleWave.Application.Media.MovieImport;
|
||||||
using TeleWave.Application.Metadata;
|
using TeleWave.Application.Metadata;
|
||||||
@@ -44,6 +45,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ShowMetadataApplier>();
|
services.AddScoped<ShowMetadataApplier>();
|
||||||
services.AddScoped<MovieMatcher>();
|
services.AddScoped<MovieMatcher>();
|
||||||
services.AddScoped<ManualInboxTaker>();
|
services.AddScoped<ManualInboxTaker>();
|
||||||
|
services.AddScoped<ShowMediaEraser>();
|
||||||
services.AddScoped<CollectionSuggestionBuilder>();
|
services.AddScoped<CollectionSuggestionBuilder>();
|
||||||
services.AddScoped<GroupElementResolver>();
|
services.AddScoped<GroupElementResolver>();
|
||||||
services.AddScoped<GroupFilterMatcher>();
|
services.AddScoped<GroupFilterMatcher>();
|
||||||
|
|||||||
@@ -3,4 +3,8 @@ using TeleWave.Application.Common.Models;
|
|||||||
|
|
||||||
namespace TeleWave.Application.Library.DeleteShow;
|
namespace TeleWave.Application.Library.DeleteShow;
|
||||||
|
|
||||||
public sealed record DeleteShowCommand(Guid ShowId) : ICommand<Result>;
|
/// <summary>
|
||||||
|
/// Удаляет шоу. <paramref name="WithMedia"/> — снести заодно его файлы: сами по себе они пережили
|
||||||
|
/// бы шоу и остались бы в списке медиа осиротевшими, занимая место.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DeleteShowCommand(Guid ShowId, bool WithMedia = false) : ICommand<Result>;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using LiteCqrs;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Application.Media;
|
||||||
using TeleWave.Application.Programming.Groups;
|
using TeleWave.Application.Programming.Groups;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
|
|
||||||
@@ -9,15 +10,15 @@ namespace TeleWave.Application.Library.DeleteShow;
|
|||||||
|
|
||||||
public sealed class DeleteShowCommandHandler(
|
public sealed class DeleteShowCommandHandler(
|
||||||
IAppDbContext dbContext,
|
IAppDbContext dbContext,
|
||||||
GroupMembershipCleaner groupCleaner
|
GroupMembershipCleaner groupCleaner,
|
||||||
|
ShowMediaEraser eraser
|
||||||
) : ICommandHandler<DeleteShowCommand, Result>
|
) : ICommandHandler<DeleteShowCommand, Result>
|
||||||
{
|
{
|
||||||
public async Task<Result> Handle(DeleteShowCommand command, CancellationToken cancellationToken)
|
public async Task<Result> Handle(DeleteShowCommand command, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var show = await dbContext.Shows.FirstOrDefaultAsync(
|
var show = await dbContext
|
||||||
s => s.Id == command.ShowId,
|
.Shows.Include(s => s.Episodes)
|
||||||
cancellationToken
|
.FirstOrDefaultAsync(s => s.Id == command.ShowId, cancellationToken);
|
||||||
);
|
|
||||||
if (show is null)
|
if (show is null)
|
||||||
return Result.Failure(ShowErrors.NotFound);
|
return Result.Failure(ShowErrors.NotFound);
|
||||||
|
|
||||||
@@ -38,7 +39,21 @@ public sealed class DeleteShowCommandHandler(
|
|||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!command.WithMedia)
|
||||||
|
{
|
||||||
dbContext.Shows.Remove(show);
|
dbContext.Shows.Remove(show);
|
||||||
return Result.Success();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-25
@@ -3,10 +3,11 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Application.Library;
|
using TeleWave.Application.Library;
|
||||||
|
using TeleWave.Application.Media;
|
||||||
|
|
||||||
namespace TeleWave.Application.Maintenance.DeleteShowMedia;
|
namespace TeleWave.Application.Maintenance.DeleteShowMedia;
|
||||||
|
|
||||||
public sealed class DeleteShowMediaCommandHandler(IAppDbContext dbContext, IMediaStorage storage)
|
public sealed class DeleteShowMediaCommandHandler(IAppDbContext dbContext, ShowMediaEraser eraser)
|
||||||
: ICommandHandler<DeleteShowMediaCommand, Result<int>>
|
: ICommandHandler<DeleteShowMediaCommand, Result<int>>
|
||||||
{
|
{
|
||||||
public async Task<Result<int>> Handle(
|
public async Task<Result<int>> Handle(
|
||||||
@@ -21,37 +22,18 @@ public sealed class DeleteShowMediaCommandHandler(IAppDbContext dbContext, IMedi
|
|||||||
return Result.Failure<int>(ShowErrors.NotFound);
|
return Result.Failure<int>(ShowErrors.NotFound);
|
||||||
|
|
||||||
var episodes = show.Episodes.ToList();
|
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 using var transaction = await dbContext.BeginTransactionAsync(cancellationToken);
|
||||||
await dbContext
|
var erased = await eraser.EraseAsync(show, cancellationToken);
|
||||||
.ScheduleEntries.Where(e => assetIds.Contains(e.MediaAssetId))
|
|
||||||
.ExecuteDeleteAsync(cancellationToken);
|
|
||||||
await dbContext
|
|
||||||
.MediaAssets.Where(a => assetIds.Contains(a.Id))
|
|
||||||
.ExecuteDeleteAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Серии шоу теперь указывают на удалённые ассеты — убираем их.
|
// Шоу остаётся, а его серии теперь указывали бы на удалённые ассеты — убираем их.
|
||||||
foreach (var episode in episodes)
|
foreach (var episode in episodes)
|
||||||
show.RemoveEpisode(episode.Id);
|
show.RemoveEpisode(episode.Id);
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
await transaction.CommitAsync(cancellationToken);
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
|
||||||
foreach (var asset in assets)
|
await eraser.DeleteFilesAsync(erased, cancellationToken);
|
||||||
await storage.DeleteAssetArtifactsAsync(
|
return Result.Success(erased.Count);
|
||||||
asset.Id,
|
|
||||||
asset.OriginalExtension,
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
|
|
||||||
return Result.Success(assets.Count);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Media;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Сносит медиа шоу: записи расписания по его ассетам, сами ассеты и файлы на диске.
|
||||||
|
///
|
||||||
|
/// Порядок жёсткий и общий для всех, кто этим пользуется: сначала БД в транзакции, файлы — только
|
||||||
|
/// после успешного коммита. Наоборот было бы хуже всего: при падении остались бы серии, ссылающиеся
|
||||||
|
/// на удалённые с диска файлы, и раздача упёрлась бы в пустоту.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ShowMediaEraser(IAppDbContext dbContext, IMediaStorage storage)
|
||||||
|
{
|
||||||
|
/// <summary>Ассет к удалению с диска: идентификатор и расширение оригинала.</summary>
|
||||||
|
public readonly record struct ErasedAsset(Guid Id, string Extension);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Удаляет из БД ассеты шоу и связанные записи расписания. Возвращает то, что предстоит убрать
|
||||||
|
/// с диска, — вызывающий делает это после коммита своей транзакции.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IReadOnlyList<ErasedAsset>> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Убирает файлы (оригинал и нарезку). Best-effort: строки в БД уже удалены.</summary>
|
||||||
|
public async Task DeleteFilesAsync(
|
||||||
|
IReadOnlyList<ErasedAsset> assets,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
foreach (var asset in assets)
|
||||||
|
await storage.DeleteAssetArtifactsAsync(asset.Id, asset.Extension, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-12
@@ -198,11 +198,37 @@ public sealed class ImportGridCommandHandler(
|
|||||||
|
|
||||||
foreach (var configGroup in wanted)
|
foreach (var configGroup in wanted)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(configGroup.Name))
|
if (
|
||||||
continue;
|
string.IsNullOrWhiteSpace(configGroup.Name)
|
||||||
if (names.ContainsKey(configGroup.Name.Trim()))
|
|| names.ContainsKey(configGroup.Name.Trim())
|
||||||
|
)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
var group = BuildGroup(configGroup, shows, collections, warnings);
|
||||||
|
|
||||||
|
dbContext.Groups.Add(group);
|
||||||
|
// Статистика — кэш для карточки группы. Коллекция, заведённая этим же импортом, ещё не
|
||||||
|
// сохранена и в счёт не попадёт; цифры сойдутся на первой правке состава.
|
||||||
|
await stats.RecomputeAsync(group, cancellationToken);
|
||||||
|
names[group.Name] = group.Id;
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (names, created);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Собирает состав группы из имён файла: что нашлось — в позиции, чего нет — в замечания.
|
||||||
|
/// Группа, где не нашлось ничего, всё равно создаётся, но с оговоркой: пустая группа — это
|
||||||
|
/// дыра в эфире, и узнать о ней надо на импорте, а не после применения сетки.
|
||||||
|
/// </summary>
|
||||||
|
private static Group BuildGroup(
|
||||||
|
GridConfigGroup configGroup,
|
||||||
|
IReadOnlyDictionary<string, Guid> shows,
|
||||||
|
IReadOnlyDictionary<string, Guid> collections,
|
||||||
|
List<string> warnings
|
||||||
|
)
|
||||||
|
{
|
||||||
var group = Group.Create(configGroup.Name, configGroup.Description);
|
var group = Group.Create(configGroup.Name, configGroup.Description);
|
||||||
|
|
||||||
foreach (var showName in configGroup.Shows ?? [])
|
foreach (var showName in configGroup.Shows ?? [])
|
||||||
@@ -222,15 +248,7 @@ public sealed class ImportGridCommandHandler(
|
|||||||
$"Группа «{group.Name}» создана пустой — слоты по ней закроет аварийный фон."
|
$"Группа «{group.Name}» создана пустой — слоты по ней закроет аварийный фон."
|
||||||
);
|
);
|
||||||
|
|
||||||
dbContext.Groups.Add(group);
|
return group;
|
||||||
// Статистика — кэш для карточки группы. Коллекция, заведённая этим же импортом, ещё не
|
|
||||||
// сохранена и в счёт не попадёт; цифры сойдутся на первой правке состава.
|
|
||||||
await stats.RecomputeAsync(group, cancellationToken);
|
|
||||||
names[group.Name] = group.Id;
|
|
||||||
created++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (names, created);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Шоу по названиям. Одинаковые названия схлопываются в первое найденное.</summary>
|
/// <summary>Шоу по названиям. Одинаковые названия схлопываются в первое найденное.</summary>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using TeleWave.Application.Broadcast.ListChannels;
|
|||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Library.DeleteShow;
|
using TeleWave.Application.Library.DeleteShow;
|
||||||
using TeleWave.Application.Library.GetShow;
|
using TeleWave.Application.Library.GetShow;
|
||||||
|
using TeleWave.Application.Media;
|
||||||
using TeleWave.Application.Programming.Groups;
|
using TeleWave.Application.Programming.Groups;
|
||||||
using TeleWave.Application.Tests.Support;
|
using TeleWave.Application.Tests.Support;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
@@ -24,6 +25,9 @@ public class QueryHandlersTests
|
|||||||
private static GroupMembershipCleaner GroupCleaner(IAppDbContext db) =>
|
private static GroupMembershipCleaner GroupCleaner(IAppDbContext db) =>
|
||||||
new(db, GroupServices.Stats(db));
|
new(db, GroupServices.Stats(db));
|
||||||
|
|
||||||
|
private static ShowMediaEraser Eraser(IAppDbContext db) =>
|
||||||
|
new(db, Substitute.For<IMediaStorage>());
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetChannel_UnknownId_ReturnsNotFound()
|
public async Task GetChannel_UnknownId_ReturnsNotFound()
|
||||||
{
|
{
|
||||||
@@ -153,13 +157,13 @@ public class QueryHandlersTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
await using var db = fixture.New();
|
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()),
|
new DeleteShowCommand(Guid.NewGuid()),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
Assert.False(missing.IsSuccess);
|
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),
|
new DeleteShowCommand(show.Id),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Library;
|
using TeleWave.Application.Library;
|
||||||
@@ -27,6 +28,9 @@ public class DeleteGuardsTests
|
|||||||
private static GroupMembershipCleaner GroupCleaner(IAppDbContext db) =>
|
private static GroupMembershipCleaner GroupCleaner(IAppDbContext db) =>
|
||||||
new(db, GroupServices.Stats(db));
|
new(db, GroupServices.Stats(db));
|
||||||
|
|
||||||
|
private static ShowMediaEraser Eraser(IAppDbContext db) =>
|
||||||
|
new(db, Substitute.For<IMediaStorage>());
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteShow_InFutureSchedule_Fails()
|
public async Task DeleteShow_InFutureSchedule_Fails()
|
||||||
{
|
{
|
||||||
@@ -53,7 +57,7 @@ public class DeleteGuardsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
await using var db = fixture.New();
|
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),
|
new DeleteShowCommand(show.Id),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
@@ -62,6 +66,41 @@ public class DeleteGuardsTests
|
|||||||
Assert.Equal(ShowErrors.InFutureSchedule, result.Error);
|
Assert.Equal(ShowErrors.InFutureSchedule, result.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Без флага медиа переживает шоу: серия — это ссылка на ассет по значению, внешнего ключа
|
||||||
|
/// между ними нет, и каскад БД до файлов не доходит.
|
||||||
|
/// </summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Отыгранный эфир шоу не держит: иначе его нельзя было бы удалить никогда.</summary>
|
/// <summary>Отыгранный эфир шоу не держит: иначе его нельзя было бы удалить никогда.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteShow_OnlyPastSchedule_Succeeds()
|
public async Task DeleteShow_OnlyPastSchedule_Succeeds()
|
||||||
@@ -81,7 +120,7 @@ public class DeleteGuardsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
await using var db = fixture.New();
|
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),
|
new DeleteShowCommand(show.Id),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Library.DeleteShow;
|
||||||
using TeleWave.Application.Maintenance.DeleteShowMedia;
|
using TeleWave.Application.Maintenance.DeleteShowMedia;
|
||||||
|
using TeleWave.Application.Media;
|
||||||
using TeleWave.Application.Media.Register;
|
using TeleWave.Application.Media.Register;
|
||||||
|
using TeleWave.Application.Programming.Groups;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
using TeleWave.Domain.Library;
|
using TeleWave.Domain.Library;
|
||||||
using TeleWave.Domain.Media;
|
using TeleWave.Domain.Media;
|
||||||
@@ -94,10 +97,10 @@ public sealed class TransactionIntegrationTests(PostgresFixture fixture)
|
|||||||
|
|
||||||
await using (var db = fixture.CreateContext())
|
await using (var db = fixture.CreateContext())
|
||||||
{
|
{
|
||||||
var result = await new DeleteShowMediaCommandHandler(db, storage).Handle(
|
var result = await new DeleteShowMediaCommandHandler(
|
||||||
new DeleteShowMediaCommand(show.Id),
|
db,
|
||||||
CancellationToken.None
|
new ShowMediaEraser(db, storage)
|
||||||
);
|
).Handle(new DeleteShowMediaCommand(show.Id), CancellationToken.None);
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,4 +113,66 @@ public sealed class TransactionIntegrationTests(PostgresFixture fixture)
|
|||||||
.Received()
|
.Received()
|
||||||
.DeleteAssetArtifactsAsync(asset.Id, ".mkv", Arg.Any<CancellationToken>());
|
.DeleteAssetArtifactsAsync(asset.Id, ".mkv", Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Удаление шоу «вместе с медиа»: ассеты, записи расписания и файлы уходят одной операцией.
|
||||||
|
/// Проверяется на настоящем Postgres — <c>ExecuteDelete</c> и транзакции InMemory не умеет.
|
||||||
|
/// </summary>
|
||||||
|
[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<IMediaStorage>();
|
||||||
|
|
||||||
|
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<CancellationToken>());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,11 @@ export function InterstitialsPanel() {
|
|||||||
},
|
},
|
||||||
onError,
|
onError,
|
||||||
})
|
})
|
||||||
const deleteClipMutation = useMutation({ mutationFn: deleteShow, onSuccess: invalidate, onError })
|
const deleteClipMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteShow(id),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
const deleteBlockMutation = useMutation({
|
const deleteBlockMutation = useMutation({
|
||||||
mutationFn: deleteCollection,
|
mutationFn: deleteCollection,
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('admin.shows.deleteTitle')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('admin.shows.deleteHint', { name: show.name })}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{hasMedia && (
|
||||||
|
<label className="flex items-start gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1"
|
||||||
|
checked={withMedia}
|
||||||
|
onChange={(e) => setWithMedia(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{t('admin.shows.deleteWithMedia', { count: show.episodeCount })}
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
{t('admin.shows.deleteWithMediaHint')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button size="sm" variant="outline" onClick={onClose}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => onConfirm(withMedia)}
|
||||||
|
>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
SHOW_AUDIENCES,
|
SHOW_AUDIENCES,
|
||||||
type ShowAudience,
|
type ShowAudience,
|
||||||
type ShowKind,
|
type ShowKind,
|
||||||
|
type ShowSummaryDto,
|
||||||
} from '@/shared/api/types'
|
} from '@/shared/api/types'
|
||||||
import { useApiError } from '@/shared/lib/use-api-error'
|
import { useApiError } from '@/shared/lib/use-api-error'
|
||||||
import { Badge } from '@/shared/ui/badge'
|
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 { SortHeader } from '@/shared/ui/sortable'
|
||||||
import { listGenres } from '@/features/admin/genres/api'
|
import { listGenres } from '@/features/admin/genres/api'
|
||||||
import { createShow, deleteShow, listShows } from './api'
|
import { createShow, deleteShow, listShows } from './api'
|
||||||
|
import { DeleteShowDialog } from './DeleteShowDialog'
|
||||||
import { BulkTagBar } from './BulkTagBar'
|
import { BulkTagBar } from './BulkTagBar'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
@@ -110,7 +112,17 @@ export function ShowsPanel() {
|
|||||||
},
|
},
|
||||||
onError,
|
onError,
|
||||||
})
|
})
|
||||||
const deleteMutation = useMutation({ mutationFn: deleteShow, onSuccess: invalidate, onError })
|
// Шоу к удалению: сначала спрашиваем про файлы, и только потом удаляем.
|
||||||
|
const [toDelete, setToDelete] = useState<ShowSummaryDto | null>(null)
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: ({ id, withMedia }: { id: string; withMedia: boolean }) =>
|
||||||
|
deleteShow(id, withMedia),
|
||||||
|
onSuccess: () => {
|
||||||
|
setToDelete(null)
|
||||||
|
invalidate()
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -309,11 +321,7 @@ export function ShowsPanel() {
|
|||||||
<td className="px-4 py-2 text-muted-foreground">{show.seasonCount}</td>
|
<td className="px-4 py-2 text-muted-foreground">{show.seasonCount}</td>
|
||||||
<td className="px-4 py-2 text-muted-foreground">{show.episodeCount}</td>
|
<td className="px-4 py-2 text-muted-foreground">{show.episodeCount}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<Button
|
<Button size="sm" variant="destructive" onClick={() => setToDelete(show)}>
|
||||||
size="sm"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => deleteMutation.mutate(show.id)}
|
|
||||||
>
|
|
||||||
{t('common.delete')}
|
{t('common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
@@ -324,6 +332,15 @@ export function ShowsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Pager page={page} totalPages={totalPages} onChange={setPage} />
|
<Pager page={page} totalPages={totalPages} onChange={setPage} />
|
||||||
|
|
||||||
|
{toDelete && (
|
||||||
|
<DeleteShowDialog
|
||||||
|
show={toDelete}
|
||||||
|
pending={deleteMutation.isPending}
|
||||||
|
onConfirm={(withMedia) => deleteMutation.mutate({ id: toDelete.id, withMedia })}
|
||||||
|
onClose={() => setToDelete(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,8 +79,10 @@ export function getShowUsage(id: string) {
|
|||||||
return apiRequest<ShowUsageDto>(`/admin/shows/${id}/usage`)
|
return apiRequest<ShowUsageDto>(`/admin/shows/${id}/usage`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteShow(id: string) {
|
/** `withMedia` — снести заодно файлы шоу: иначе они переживут его осиротевшими. */
|
||||||
return apiRequest<void>(`/admin/shows/${id}`, { method: 'DELETE' })
|
export function deleteShow(id: string, withMedia = false) {
|
||||||
|
const query = withMedia ? '?withMedia=true' : ''
|
||||||
|
return apiRequest<void>(`/admin/shows/${id}${query}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addEpisode(showId: string, mediaAssetId: string) {
|
export function addEpisode(showId: string, mediaAssetId: string) {
|
||||||
|
|||||||
@@ -412,6 +412,11 @@ export const en = {
|
|||||||
genre: 'Genre',
|
genre: 'Genre',
|
||||||
allGenres: 'All genres',
|
allGenres: 'All genres',
|
||||||
allKinds: 'All kinds',
|
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',
|
otherGenres: 'Other genres',
|
||||||
bulk: {
|
bulk: {
|
||||||
selected: 'Selected: {{count}}',
|
selected: 'Selected: {{count}}',
|
||||||
|
|||||||
@@ -409,6 +409,11 @@ export const ru = {
|
|||||||
genre: 'Жанр',
|
genre: 'Жанр',
|
||||||
allGenres: 'Все жанры',
|
allGenres: 'Все жанры',
|
||||||
allKinds: 'Все типы',
|
allKinds: 'Все типы',
|
||||||
|
deleteTitle: 'Удалить шоу?',
|
||||||
|
deleteHint: '«{{name}}» будет удалено из библиотеки. Отменить это нельзя.',
|
||||||
|
deleteWithMedia: 'Также удалить медиа: файлов {{count}}',
|
||||||
|
deleteWithMediaHint:
|
||||||
|
'Иначе файлы останутся в списке медиа ничьими и продолжат занимать место.',
|
||||||
otherGenres: 'Остальные жанры',
|
otherGenres: 'Остальные жанры',
|
||||||
bulk: {
|
bulk: {
|
||||||
selected: 'Отмечено: {{count}}',
|
selected: 'Отмечено: {{count}}',
|
||||||
|
|||||||
Reference in New Issue
Block a user