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();
|
||||
}
|
||||
|
||||
/// <param name="withMedia">Снести заодно файлы шоу — иначе они останутся осиротевшими.</param>
|
||||
private static async Task<IResult> 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ShowMetadataApplier>();
|
||||
services.AddScoped<MovieMatcher>();
|
||||
services.AddScoped<ManualInboxTaker>();
|
||||
services.AddScoped<ShowMediaEraser>();
|
||||
services.AddScoped<CollectionSuggestionBuilder>();
|
||||
services.AddScoped<GroupElementResolver>();
|
||||
services.AddScoped<GroupFilterMatcher>();
|
||||
|
||||
@@ -3,4 +3,8 @@ using TeleWave.Application.Common.Models;
|
||||
|
||||
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 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<DeleteShowCommand, Result>
|
||||
{
|
||||
public async Task<Result> 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();
|
||||
}
|
||||
}
|
||||
|
||||
+7
-25
@@ -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<DeleteShowMediaCommand, Result<int>>
|
||||
{
|
||||
public async Task<Result<int>> Handle(
|
||||
@@ -21,37 +22,18 @@ public sealed class DeleteShowMediaCommandHandler(IAppDbContext dbContext, IMedi
|
||||
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);
|
||||
|
||||
// Все три операции БД (удаление записей расписания, ассетов, отвязка серий) — атомарно в одной
|
||||
// транзакции; файлы удаляем только после успешного коммита (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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+39
-21
@@ -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);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Шоу по названиям. Одинаковые названия схлопываются в первое найденное.</summary>
|
||||
private Task<Dictionary<string, Guid>> ShowNamesAsync(CancellationToken cancellationToken) =>
|
||||
NamesAsync(
|
||||
|
||||
Reference in New Issue
Block a user