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 && (