From 4a500583e70e9e9ea4f154b9ca7ef911ad439e9d Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Tue, 28 Jul 2026 12:07:00 +0300 Subject: [PATCH] Add bulk poster filling endpoint and enhance show deletion options Introduced a new endpoint for bulk filling collection posters, allowing for automatic assignment of posters to collections without existing images. Updated the DeleteShowCommand to include an option for cutting shows from future airings, enhancing the deletion process. Refactored related components and API calls to support these features, ensuring a seamless user experience. Additionally, updated localization strings to reflect the new functionalities and adjusted tests to verify correct behavior. --- .../Endpoints/CollectionEndpoints.cs | 18 ++ .../TeleWave.Api/Endpoints/ShowEndpoints.cs | 9 +- .../DependencyInjection.cs | 2 + .../Collections/CollectionPosterPicker.cs | 72 +++++++ .../FillCollectionPostersCommand.cs | 11 ++ .../FillCollectionPostersCommandHandler.cs | 28 +++ ...eCollectionFromSuggestionCommandHandler.cs | 8 +- .../Library/DeleteShow/DeleteShowCommand.cs | 11 +- .../DeleteShow/DeleteShowCommandHandler.cs | 71 ++++++- .../Library/ShowErrors.cs | 14 +- .../Planning/GridScheduleGenerator.cs | 17 +- .../Library/CollectionPosterTests.cs | 177 ++++++++++++++++++ .../Library/DeleteGuardsTests.cs | 8 +- .../Programming/SuggestionQueriesTests.cs | 7 +- .../TransactionIntegrationTests.cs | 60 ++++++ .../admin/collections/CollectionsPanel.tsx | 25 ++- .../src/features/admin/collections/api.ts | 11 ++ .../features/admin/shows/DeleteShowDialog.tsx | 23 ++- .../src/features/admin/shows/ShowsPanel.tsx | 13 +- frontend/src/features/admin/shows/api.ts | 15 +- frontend/src/shared/lib/locales/en.ts | 7 + frontend/src/shared/lib/locales/ru.ts | 7 + 22 files changed, 581 insertions(+), 33 deletions(-) create mode 100644 backend/src/TeleWave.Application/Library/Collections/CollectionPosterPicker.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/FillCollectionPosters/FillCollectionPostersCommand.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/FillCollectionPosters/FillCollectionPostersCommandHandler.cs create mode 100644 backend/tests/TeleWave.Application.Tests/Library/CollectionPosterTests.cs diff --git a/backend/src/TeleWave.Api/Endpoints/CollectionEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/CollectionEndpoints.cs index d9dd61b..b708183 100644 --- a/backend/src/TeleWave.Api/Endpoints/CollectionEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/CollectionEndpoints.cs @@ -5,6 +5,7 @@ using TeleWave.Application.Library.Collections.AddCollectionShow; using TeleWave.Application.Library.Collections.AddShowsToCollection; using TeleWave.Application.Library.Collections.CreateCollection; using TeleWave.Application.Library.Collections.DeleteCollection; +using TeleWave.Application.Library.Collections.FillCollectionPosters; using TeleWave.Application.Library.Collections.GetCollection; using TeleWave.Application.Library.Collections.ListCollections; using TeleWave.Application.Library.Collections.RemoveCollectionShow; @@ -40,6 +41,8 @@ public static class CollectionEndpoints // Сборка франшиз: массовое добавление отмеченных шоу и предложения по библиотеке. admin.MapPost("/bulk/shows", AddShows).Produces(); + // Постеры по частям: без списка — всем коллекциям, у которых постера ещё нет. + admin.MapPost("/bulk/posters", FillPosters).Produces(); admin .MapGet("/suggestions", Suggestions) .Produces>(); @@ -50,6 +53,19 @@ public static class CollectionEndpoints return app; } + private static async Task FillPosters( + FillCollectionPostersBody? body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new FillCollectionPostersCommand(body?.CollectionIds), + cancellationToken + ); + return result.ToHttpResult(); + } + private static async Task AddShows( AddShowsToCollectionBody body, ISender sender, @@ -225,3 +241,5 @@ public sealed record AddShowsToCollectionBody( ); public sealed record CreateCollectionFromSuggestionBody(string Key); + +public sealed record FillCollectionPostersBody(IReadOnlyList? CollectionIds); diff --git a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs index 024ab5d..35c321a 100644 --- a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs @@ -145,14 +145,19 @@ public static class ShowEndpoints } /// Снести заодно файлы шоу — иначе они останутся осиротевшими. + /// Вырезать шоу из будущего эфира вместо отказа. private static async Task DeleteShow( Guid id, ISender sender, CancellationToken cancellationToken, - bool withMedia = false + bool withMedia = false, + bool withSchedule = false ) { - var result = await sender.Send(new DeleteShowCommand(id, withMedia), cancellationToken); + var result = await sender.Send( + new DeleteShowCommand(id, withMedia, withSchedule), + cancellationToken + ); return result.ToHttpResult(); } diff --git a/backend/src/TeleWave.Application/DependencyInjection.cs b/backend/src/TeleWave.Application/DependencyInjection.cs index 85717fd..06bdd99 100644 --- a/backend/src/TeleWave.Application/DependencyInjection.cs +++ b/backend/src/TeleWave.Application/DependencyInjection.cs @@ -5,6 +5,7 @@ using LiteCqrs.DependencyInjection; using Microsoft.Extensions.DependencyInjection; using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Common.Behaviors; +using TeleWave.Application.Library.Collections; using TeleWave.Application.Library.Collections.Suggest; using TeleWave.Application.Library.Genres; using TeleWave.Application.Media; @@ -47,6 +48,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/Collections/CollectionPosterPicker.cs b/backend/src/TeleWave.Application/Library/Collections/CollectionPosterPicker.cs new file mode 100644 index 0000000..c09f90f --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/CollectionPosterPicker.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Library.Collections; + +/// +/// Подбирает коллекции постер по её частям: берётся первая часть с постером — обычно это первый +/// фильм цикла, и он же узнаётся с обложки. Отдельный тип, потому что подбор нужен и при создании +/// коллекции, и по кнопке для уже заведённых. +/// +/// Своего изображения коллекция не заводит: постер — это тот же из реестра, +/// на который ссылается шоу. Копировать файл ради второй ссылки незачем. +/// +public sealed class CollectionPosterPicker(IAppDbContext dbContext) +{ + /// + /// Проставляет постер коллекциям, у которых его нет. Уже выбранный не трогаем: его мог + /// поставить человек, и перебивать такой выбор автоматикой нельзя. Возвращает, скольким + /// коллекциям постер нашёлся. + /// + public async Task FillAsync( + IReadOnlyList collections, + CancellationToken cancellationToken + ) + { + var pending = collections.Where(c => c.PosterImageId is null).ToList(); + if (pending.Count == 0) + return 0; + + var posters = await PostersAsync(pending, cancellationToken); + var filled = 0; + + foreach (var collection in pending) + { + if (Pick(collection, posters) is not { } imageId) + continue; + + collection.SetPosterImage(imageId); + filled++; + } + + return filled; + } + + /// Постер первой по порядку части, у которой он есть. + private static Guid? Pick(Collection collection, IReadOnlyDictionary posters) => + collection + .Items.OrderBy(i => i.Position) + .Select(i => posters.TryGetValue(i.ShowId, out var image) ? image : (Guid?)null) + .FirstOrDefault(image => image is not null); + + /// Постеры шоу, входящих в эти коллекции, одним запросом на всю пачку. + private async Task> PostersAsync( + IReadOnlyList collections, + CancellationToken cancellationToken + ) + { + var showIds = collections + .SelectMany(c => c.Items.Select(i => i.ShowId)) + .Distinct() + .ToList(); + + var shows = await dbContext + .Shows.AsNoTracking() + .Where(s => showIds.Contains(s.Id) && s.PosterImageId != null) + .Select(s => new { s.Id, PosterImageId = s.PosterImageId!.Value }) + .ToListAsync(cancellationToken); + + return shows.ToDictionary(s => s.Id, s => s.PosterImageId); + } +} diff --git a/backend/src/TeleWave.Application/Library/Collections/FillCollectionPosters/FillCollectionPostersCommand.cs b/backend/src/TeleWave.Application/Library/Collections/FillCollectionPosters/FillCollectionPostersCommand.cs new file mode 100644 index 0000000..6abde94 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/FillCollectionPosters/FillCollectionPostersCommand.cs @@ -0,0 +1,11 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Collections.FillCollectionPosters; + +/// +/// Проставляет постеры коллекциям по их частям. Пустой список — всем коллекциям без постера: +/// после массового импорта фильмов их сразу десятки, и обходить каждую руками бессмысленно. +/// +public sealed record FillCollectionPostersCommand(IReadOnlyList? CollectionIds = null) + : ICommand>; diff --git a/backend/src/TeleWave.Application/Library/Collections/FillCollectionPosters/FillCollectionPostersCommandHandler.cs b/backend/src/TeleWave.Application/Library/Collections/FillCollectionPosters/FillCollectionPostersCommandHandler.cs new file mode 100644 index 0000000..ccc6082 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/FillCollectionPosters/FillCollectionPostersCommandHandler.cs @@ -0,0 +1,28 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Collections.FillCollectionPosters; + +public sealed class FillCollectionPostersCommandHandler( + IAppDbContext dbContext, + CollectionPosterPicker picker +) : ICommandHandler> +{ + public async Task> Handle( + FillCollectionPostersCommand command, + CancellationToken cancellationToken + ) + { + var query = dbContext.Collections.Include(c => c.Items).AsQueryable(); + + if (command.CollectionIds is { Count: > 0 } ids) + query = query.Where(c => ids.Contains(c.Id)); + + var collections = await query.ToListAsync(cancellationToken); + var filled = await picker.FillAsync(collections, cancellationToken); + + return Result.Success(filled); + } +} diff --git a/backend/src/TeleWave.Application/Library/Collections/Suggest/CreateCollectionFromSuggestionCommandHandler.cs b/backend/src/TeleWave.Application/Library/Collections/Suggest/CreateCollectionFromSuggestionCommandHandler.cs index f75e3f7..785e898 100644 --- a/backend/src/TeleWave.Application/Library/Collections/Suggest/CreateCollectionFromSuggestionCommandHandler.cs +++ b/backend/src/TeleWave.Application/Library/Collections/Suggest/CreateCollectionFromSuggestionCommandHandler.cs @@ -8,7 +8,8 @@ namespace TeleWave.Application.Library.Collections.Suggest; public sealed class CreateCollectionFromSuggestionCommandHandler( IAppDbContext dbContext, - CollectionSuggestionBuilder builder + CollectionSuggestionBuilder builder, + CollectionPosterPicker posters ) : ICommandHandler> { public async Task> Handle( @@ -42,6 +43,11 @@ public sealed class CreateCollectionFromSuggestionCommandHandler( collection.AddShow(part.ShowId); dbContext.Collections.Add(collection); + + // Постер берём у первой части: франшиза узнаётся по обложке первого фильма, а коллекция + // без картинки теряется в списке среди таких же безымянных прямоугольников. + await posters.FillAsync([collection], cancellationToken); + return Result.Success(collection.Id); } } diff --git a/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommand.cs b/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommand.cs index f5bd60b..851aa96 100644 --- a/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommand.cs +++ b/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommand.cs @@ -7,4 +7,13 @@ namespace TeleWave.Application.Library.DeleteShow; /// Удаляет шоу. — снести заодно его файлы: сами по себе они пережили /// бы шоу и остались бы в списке медиа осиротевшими, занимая место. /// -public sealed record DeleteShowCommand(Guid ShowId, bool WithMedia = false) : ICommand; +/// +/// Вырезать шоу из будущего эфира вместо отказа. Нужен там, где пересборка канала не спасает: +/// запись уже идёт, канал выключен или шоу стоит на нескольких каналах. На месте вырезанного +/// зритель увидит фон, пока хвост не пересоберут. +/// +public sealed record DeleteShowCommand( + Guid ShowId, + bool WithMedia = false, + bool WithSchedule = false +) : ICommand; diff --git a/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommandHandler.cs b/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommandHandler.cs index 2dcce79..e445b56 100644 --- a/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommandHandler.cs +++ b/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommandHandler.cs @@ -25,12 +25,11 @@ public sealed class DeleteShowCommandHandler( // Уже сгенерированный эфир ссылается на шоу без внешнего ключа: удали его — и зритель // получит запись без названия, а раздача упрётся в удалённые каскадом серии. Прошедшие // записи не держим: они лишь теряют название в EPG, а иначе шоу было бы не удалить никогда. - var onAir = await dbContext.ScheduleEntries.AnyAsync( - e => e.ShowId == command.ShowId && e.EndsAtUtc > DateTimeOffset.UtcNow, - cancellationToken - ); - if (onAir) - return Result.Failure(ShowErrors.InFutureSchedule); + if ( + !command.WithSchedule + && await OnAirAsync(command.ShowId, cancellationToken) is { Length: > 0 } channels + ) + return Result.Failure(ShowErrors.InFutureSchedule(channels)); // Позиции коллекций уходят каскадом БД, позиции групп — вручную: ссылка группы полиморфна. await groupCleaner.RemoveElementAsync( @@ -39,16 +38,21 @@ public sealed class DeleteShowCommandHandler( cancellationToken ); - if (!command.WithMedia) + if (!command.WithMedia && !command.WithSchedule) { dbContext.Shows.Remove(show); return Result.Success(); } - // Удаление ассетов идёт мимо SaveChanges (ExecuteDelete), поэтому одной транзакцией - // с удалением шоу их связывает только явная транзакция. Файлы — после коммита. + // Удаление записей и ассетов идёт мимо SaveChanges (ExecuteDelete), поэтому одной + // транзакцией с удалением шоу их связывает только явная транзакция. Файлы — после коммита. await using var transaction = await dbContext.BeginTransactionAsync(cancellationToken); - var erased = await eraser.EraseAsync(show, cancellationToken); + + if (command.WithSchedule) + await CutFromAirAsync(command.ShowId, cancellationToken); + + var erased = command.WithMedia ? await eraser.EraseAsync(show, cancellationToken) : []; + dbContext.Shows.Remove(show); await dbContext.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); @@ -56,4 +60,51 @@ public sealed class DeleteShowCommandHandler( await eraser.DeleteFilesAsync(erased, cancellationToken); return Result.Success(); } + + /// + /// Вырезает шоу из будущего эфира всех каналов. Прошлое не трогаем: там запись уже отыграна, + /// и её удаление только стёрло бы название в архиве EPG. Идущую сейчас — трогаем: пересборка + /// её не берёт, а без этого шоу нельзя было бы удалить до конца текущей серии. + /// + private Task CutFromAirAsync(Guid showId, CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + + return dbContext + .ScheduleEntries.Where(e => e.ShowId == showId && e.EndsAtUtc > now) + .ExecuteDeleteAsync(cancellationToken); + } + + /// + /// Каналы, у которых шоу стоит в ещё не отыгранном эфире, — строкой «Первый (до 29.07 14:20)». + /// Пусто — шоу свободно. Идущая сейчас запись тоже держит: пересборка её не трогает, и убрать + /// шоу из-под зрителя нельзя. + /// + private async Task OnAirAsync(Guid showId, CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + + var busy = await dbContext + .ScheduleEntries.AsNoTracking() + .Where(e => e.ShowId == showId && e.EndsAtUtc > now) + .GroupBy(e => e.ChannelId) + .Select(g => new { ChannelId = g.Key, Until = g.Max(e => e.EndsAtUtc) }) + .ToListAsync(cancellationToken); + + if (busy.Count == 0) + return string.Empty; + + var channelIds = busy.Select(b => b.ChannelId).ToList(); + var names = await dbContext + .Channels.AsNoTracking() + .Where(c => channelIds.Contains(c.Id)) + .ToDictionaryAsync(c => c.Id, c => c.Name, cancellationToken); + + return string.Join( + ", ", + busy.Select(b => + $"{names.GetValueOrDefault(b.ChannelId, "канал")} — до {b.Until.ToLocalTime():dd.MM HH:mm}" + ) + ); + } } diff --git a/backend/src/TeleWave.Application/Library/ShowErrors.cs b/backend/src/TeleWave.Application/Library/ShowErrors.cs index 3875ed2..52df7d5 100644 --- a/backend/src/TeleWave.Application/Library/ShowErrors.cs +++ b/backend/src/TeleWave.Application/Library/ShowErrors.cs @@ -16,10 +16,16 @@ public static class ShowErrors "Больше одной серии бывает только у сериала." ); - public static readonly Error InFutureSchedule = Error.Conflict( - "Shows.InFutureSchedule", - "Шоу стоит в уже сгенерированном эфире. Пересоберите расписание канала и повторите." - ); + /// + /// Шоу занято будущим эфиром. Каналы и время перечисляем в тексте: «пересоберите расписание» + /// без ответа на вопрос «какого канала» заставляет обходить их все и гадать, где именно оно. + /// + public static Error InFutureSchedule(string channels) => + Error.Conflict( + "Shows.InFutureSchedule", + $"Шоу стоит в уже сгенерированном эфире ({channels}). Откройте канал, нажмите " + + "«Применить» — хвост ленты пересоберётся по текущей сетке — и повторите удаление." + ); public static readonly Error AssetNotFound = Error.NotFound( "Shows.AssetNotFound", diff --git a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs index 837cee5..0c74072 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs @@ -61,9 +61,24 @@ public sealed class GridScheduleGenerator( c => c.Id == channelId, cancellationToken ); - if (channel is null || !channel.IsEnabled || channel.TemplateId is null) + if (channel is null) return new GenerationReport(0, [], ChannelSkipped: true); + // Канал выключен или без сетки — вещать нечем. По таймеру это просто пропуск, но по кнопке + // «Применить» админ ждёт, что лента придёт в соответствие с настройками: раз эфира нет, + // не должно остаться и будущих записей. Иначе они висят вечно — пересобрать их нечем, + // а удалить шоу, которое в них стоит, невозможно. + if (!channel.IsEnabled || channel.TemplateId is null) + { + if (!rebuildFuture) + return new GenerationReport(0, [], ChannelSkipped: true); + + await dbContext + .ScheduleEntries.Where(e => e.ChannelId == channelId && e.StartsAtUtc >= now) + .ExecuteDeleteAsync(cancellationToken); + return new GenerationReport(0, []); + } + await using var transaction = await dbContext.BeginTransactionAsync(cancellationToken); await dbContext.AcquireChannelLockAsync(channelId, cancellationToken); diff --git a/backend/tests/TeleWave.Application.Tests/Library/CollectionPosterTests.cs b/backend/tests/TeleWave.Application.Tests/Library/CollectionPosterTests.cs new file mode 100644 index 0000000..3378beb --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Library/CollectionPosterTests.cs @@ -0,0 +1,177 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Library.Collections; +using TeleWave.Application.Library.Collections.FillCollectionPosters; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Images; +using TeleWave.Domain.Library; +using Xunit; + +namespace TeleWave.Application.Tests.Library; + +/// +/// Постер коллекции по её частям. Правило простое и объяснимое: обложка первой части — франшиза +/// узнаётся именно по ней, а коллекция без картинки теряется в списке. +/// +public class CollectionPosterTests +{ + private static Show WithPoster(string name, Guid? imageId) + { + var show = Show.Create(name, ShowKind.Single); + if (imageId is { } id) + show.ApplyMetadata("tmdb", name, null, 1990, id); + return show; + } + + private static async Task FillAsync(TestDb fixture, params Guid[] ids) + { + await using var db = fixture.New(); + var result = await new FillCollectionPostersCommandHandler( + db, + new CollectionPosterPicker(db) + ).Handle( + new FillCollectionPostersCommand(ids.Length > 0 ? ids : null), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + await db.SaveChangesAsync(CancellationToken.None); + return result.Value; + } + + [Fact] + public async Task TakesPosterOfTheFirstPart() + { + var fixture = new TestDb(); + var poster = Image.Create(ImageCategory.ShowPoster, ".jpg", "Терминатор"); + var second = Image.Create(ImageCategory.ShowPoster, ".jpg", "Терминатор 2"); + + var first = WithPoster("Терминатор", poster.Id); + var sequel = WithPoster("Терминатор 2", second.Id); + var collection = Collection.Create("Терминатор"); + collection.AddShow(first.Id); + collection.AddShow(sequel.Id); + + await using (var seed = fixture.New()) + { + seed.Images.AddRange(poster, second); + seed.Shows.AddRange(first, sequel); + seed.Collections.Add(collection); + await seed.SaveChangesAsync(CancellationToken.None); + } + + Assert.Equal(1, await FillAsync(fixture)); + + await using var db = fixture.New(); + var stored = await db.Collections.SingleAsync(CancellationToken.None); + Assert.Equal(poster.Id, stored.PosterImageId); + } + + [Fact] + public async Task SkipsFirstPartWithoutPoster() + { + // У первой части постера нет — берём следующую с обложкой, а не оставляем коллекцию пустой. + var fixture = new TestDb(); + var poster = Image.Create(ImageCategory.ShowPoster, ".jpg", "Вторая часть"); + + var first = WithPoster("Первая часть", null); + var second = WithPoster("Вторая часть", poster.Id); + var collection = Collection.Create("Цикл"); + collection.AddShow(first.Id); + collection.AddShow(second.Id); + + await using (var seed = fixture.New()) + { + seed.Images.Add(poster); + seed.Shows.AddRange(first, second); + seed.Collections.Add(collection); + await seed.SaveChangesAsync(CancellationToken.None); + } + + Assert.Equal(1, await FillAsync(fixture)); + + await using var db = fixture.New(); + Assert.Equal( + poster.Id, + (await db.Collections.SingleAsync(CancellationToken.None)).PosterImageId + ); + } + + [Fact] + public async Task KeepsPosterChosenByHand() + { + // Выбор человека автоматика не перебивает: он мог поставить кадр, а не обложку первой части. + var fixture = new TestDb(); + var chosen = Image.Create(ImageCategory.ShowPoster, ".jpg", "Выбранный"); + var showPoster = Image.Create(ImageCategory.ShowPoster, ".jpg", "Часть"); + + var show = WithPoster("Часть", showPoster.Id); + var collection = Collection.Create("Цикл"); + collection.AddShow(show.Id); + collection.SetPosterImage(chosen.Id); + + await using (var seed = fixture.New()) + { + seed.Images.AddRange(chosen, showPoster); + seed.Shows.Add(show); + seed.Collections.Add(collection); + await seed.SaveChangesAsync(CancellationToken.None); + } + + Assert.Equal(0, await FillAsync(fixture)); + + await using var db = fixture.New(); + Assert.Equal( + chosen.Id, + (await db.Collections.SingleAsync(CancellationToken.None)).PosterImageId + ); + } + + [Fact] + public async Task LeavesCollectionWithoutPostersAlone() + { + // Ни у одной части нет обложки — ставить нечего, и это не ошибка. + var fixture = new TestDb(); + var show = WithPoster("Часть", null); + var collection = Collection.Create("Цикл"); + collection.AddShow(show.Id); + + await using (var seed = fixture.New()) + { + seed.Shows.Add(show); + seed.Collections.Add(collection); + await seed.SaveChangesAsync(CancellationToken.None); + } + + Assert.Equal(0, await FillAsync(fixture)); + + await using var db = fixture.New(); + Assert.Null((await db.Collections.SingleAsync(CancellationToken.None)).PosterImageId); + } + + [Fact] + public async Task FillsOnlyRequestedCollections() + { + var fixture = new TestDb(); + var poster = Image.Create(ImageCategory.ShowPoster, ".jpg", "Часть"); + var show = WithPoster("Часть", poster.Id); + + var wanted = Collection.Create("Нужная"); + wanted.AddShow(show.Id); + var other = Collection.Create("Другая"); + other.AddShow(show.Id); + + await using (var seed = fixture.New()) + { + seed.Images.Add(poster); + seed.Shows.Add(show); + seed.Collections.AddRange(wanted, other); + await seed.SaveChangesAsync(CancellationToken.None); + } + + Assert.Equal(1, await FillAsync(fixture, wanted.Id)); + + await using var db = fixture.New(); + Assert.NotNull((await db.Collections.SingleAsync(c => c.Id == wanted.Id)).PosterImageId); + Assert.Null((await db.Collections.SingleAsync(c => c.Id == other.Id)).PosterImageId); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs b/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs index 6140a5c..d6dc7c8 100644 --- a/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs @@ -37,15 +37,17 @@ public class DeleteGuardsTests var fixture = new TestDb(); var show = Show.Create("A", ShowKind.Series); var asset = MediaAsset.Register("a.mkv", ".mkv", MediaSource.Upload); + var channel = Channel.Create("Первый", "first", T0); var future = DateTimeOffset.UtcNow.AddHours(1); await using (var seed = fixture.New()) { seed.Shows.Add(show); seed.MediaAssets.Add(asset); + seed.Channels.Add(channel); seed.ScheduleEntries.Add( ScheduleEntry.Program( - Guid.NewGuid(), + channel.Id, asset.Id, future, future.AddMinutes(20), @@ -63,7 +65,9 @@ public class DeleteGuardsTests ); Assert.False(result.IsSuccess); - Assert.Equal(ShowErrors.InFutureSchedule, result.Error); + Assert.Equal(ShowErrors.InFutureSchedule(string.Empty).Code, result.Error.Code); + // В тексте — канал и время: по ним видно, что именно пересобирать. + Assert.Contains("Первый", result.Error.Message, StringComparison.Ordinal); } /// diff --git a/backend/tests/TeleWave.Application.Tests/Programming/SuggestionQueriesTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/SuggestionQueriesTests.cs index 0843b4f..7ff9574 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/SuggestionQueriesTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/SuggestionQueriesTests.cs @@ -1,3 +1,4 @@ +using TeleWave.Application.Library.Collections; using TeleWave.Application.Library.Collections.Suggest; using TeleWave.Application.Programming.Groups; using TeleWave.Application.Programming.Groups.Suggest; @@ -142,7 +143,8 @@ public class SuggestionQueriesTests await using var db = fixture.New(); var result = await new CreateCollectionFromSuggestionCommandHandler( db, - new CollectionSuggestionBuilder(db) + new CollectionSuggestionBuilder(db), + new CollectionPosterPicker(db) ).Handle( new CreateCollectionFromSuggestionCommand("name:нет-такого"), CancellationToken.None @@ -166,7 +168,8 @@ public class SuggestionQueriesTests await using var db = fixture.New(); var handler = new CreateCollectionFromSuggestionCommandHandler( db, - new CollectionSuggestionBuilder(db) + new CollectionSuggestionBuilder(db), + new CollectionPosterPicker(db) ); var created = await handler.Handle( diff --git a/backend/tests/TeleWave.Integration.Tests/TransactionIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/TransactionIntegrationTests.cs index 0656ffa..dd5abd9 100644 --- a/backend/tests/TeleWave.Integration.Tests/TransactionIntegrationTests.cs +++ b/backend/tests/TeleWave.Integration.Tests/TransactionIntegrationTests.cs @@ -114,6 +114,66 @@ public sealed class TransactionIntegrationTests(PostgresFixture fixture) .DeleteAssetArtifactsAsync(asset.Id, ".mkv", Arg.Any()); } + /// + /// Шоу, стоящее в будущем эфире, удаляется с флагом «вырезать из эфира»: пересборка канала + /// спасает не всегда — идущую запись она не трогает, а выключенный канал вещать не начнёт. + /// + [SkippableFact] + public async Task DeleteShow_WithSchedule_CutsFutureEntries() + { + var channel = Channel.Create("Канал", "kanal", DateTimeOffset.UnixEpoch); + var show = Show.Create("Шоу", ShowKind.Single); + var asset = MediaAsset.Register("live.mkv", ".mkv", MediaSource.Upload); + show.AddEpisode(asset.Id); + + // Запись идёт прямо сейчас: пересборка её не удаляет, а значит и шоу не освобождает. + var running = ScheduleEntry.Program( + channel.Id, + asset.Id, + DateTimeOffset.UtcNow.AddMinutes(-5), + DateTimeOffset.UtcNow.AddMinutes(15), + show.Id, + 0 + ); + + await using (var seed = fixture.CreateContext()) + { + seed.Channels.Add(channel); + seed.Shows.Add(show); + seed.MediaAssets.Add(asset); + seed.ScheduleEntries.Add(running); + await seed.SaveChangesAsync(CancellationToken.None); + } + + 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, Substitute.For()) + ).Handle(new DeleteShowCommand(show.Id, WithSchedule: 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.ScheduleEntries.AnyAsync(e => e.ShowId == show.Id)); + // Медиа не просили — оно осталось. + Assert.True(await verify.MediaAssets.AnyAsync(a => a.Id == asset.Id)); + } + /// /// Удаление шоу «вместе с медиа»: ассеты, записи расписания и файлы уходят одной операцией. /// Проверяется на настоящем Postgres — ExecuteDelete и транзакции InMemory не умеет. diff --git a/frontend/src/features/admin/collections/CollectionsPanel.tsx b/frontend/src/features/admin/collections/CollectionsPanel.tsx index 59b2b0e..2576c07 100644 --- a/frontend/src/features/admin/collections/CollectionsPanel.tsx +++ b/frontend/src/features/admin/collections/CollectionsPanel.tsx @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' +import { Image as ImageIcon } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { imageUrl } from '@/features/admin/images/api' @@ -7,9 +8,10 @@ import { qk } from '@/shared/api/query-keys' import { useApiError } from '@/shared/lib/use-api-error' import { Button } from '@/shared/ui/button' import { Input } from '@/shared/ui/input' +import { toast } from '@/shared/ui/toast-store' import { sortRows, useTableSort } from '@/shared/lib/table-sort' import { SortHeader } from '@/shared/ui/sortable' -import { createCollection, deleteCollection, listCollections } from './api' +import { createCollection, deleteCollection, fillCollectionPosters, listCollections } from './api' import { CollectionSuggestions } from './CollectionSuggestions' export function CollectionsPanel() { @@ -39,6 +41,14 @@ export function CollectionsPanel() { onSuccess: invalidate, onError, }) + const postersMutation = useMutation({ + mutationFn: () => fillCollectionPosters(), + onSuccess: (filled) => { + toast.success(t('admin.collections.postersDone', { count: filled })) + invalidate() + }, + onError, + }) const rows = sortRows(data ?? [], sort, { name: (c) => c.name.toLowerCase(), @@ -67,6 +77,19 @@ export function CollectionsPanel() { > {t('common.create')} + + {/* Постер коллекции — обложка её первой части: своей картинки у франшизы нет, а без + обложки она теряется в списке одинаковых строк. */} + diff --git a/frontend/src/features/admin/collections/api.ts b/frontend/src/features/admin/collections/api.ts index dbcead1..729dc06 100644 --- a/frontend/src/features/admin/collections/api.ts +++ b/frontend/src/features/admin/collections/api.ts @@ -39,6 +39,17 @@ export function addShowsToCollection(body: { } /** Какие франшизы имеет смысл собрать из уже загруженных полнометражек. */ +/** + * Проставляет постеры по частям коллекций. Без списка — всем, у кого постера ещё нет: после + * массового импорта фильмов франшиз сразу десятки. Возвращает, скольким постер нашёлся. + */ +export function fillCollectionPosters(collectionIds?: string[]) { + return apiRequest('/admin/collections/bulk/posters', { + method: 'POST', + body: { collectionIds: collectionIds ?? null }, + }) +} + export function suggestCollections() { return apiRequest('/admin/collections/suggestions') } diff --git a/frontend/src/features/admin/shows/DeleteShowDialog.tsx b/frontend/src/features/admin/shows/DeleteShowDialog.tsx index 3ad1b74..bffdf5a 100644 --- a/frontend/src/features/admin/shows/DeleteShowDialog.tsx +++ b/frontend/src/features/admin/shows/DeleteShowDialog.tsx @@ -26,11 +26,12 @@ export function DeleteShowDialog({ }: Readonly<{ show: ShowSummaryDto pending: boolean - onConfirm: (withMedia: boolean) => void + onConfirm: (options: { withMedia: boolean; withSchedule: boolean }) => void onClose: () => void }>) { const { t } = useTranslation() const [withMedia, setWithMedia] = useState(false) + const [withSchedule, setWithSchedule] = useState(false) const hasMedia = show.episodeCount > 0 return ( @@ -41,6 +42,24 @@ export function DeleteShowDialog({ {t('admin.shows.deleteHint', { name: show.name })} + {/* Занятость эфиром — самая частая причина отказа, и лечится она не всегда пересборкой: + идущую запись она не трогает, а выключенный канал вещать и не начнёт. Поэтому здесь + не только объяснение, но и способ довести удаление до конца. */} + + {hasMedia && (