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.
This commit is contained in:
@@ -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<AddShowsToCollectionResultDto>();
|
||||
// Постеры по частям: без списка — всем коллекциям, у которых постера ещё нет.
|
||||
admin.MapPost("/bulk/posters", FillPosters).Produces<int>();
|
||||
admin
|
||||
.MapGet("/suggestions", Suggestions)
|
||||
.Produces<IReadOnlyList<CollectionSuggestionDto>>();
|
||||
@@ -50,6 +53,19 @@ public static class CollectionEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> FillPosters(
|
||||
FillCollectionPostersBody? body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new FillCollectionPostersCommand(body?.CollectionIds),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddShows(
|
||||
AddShowsToCollectionBody body,
|
||||
ISender sender,
|
||||
@@ -225,3 +241,5 @@ public sealed record AddShowsToCollectionBody(
|
||||
);
|
||||
|
||||
public sealed record CreateCollectionFromSuggestionBody(string Key);
|
||||
|
||||
public sealed record FillCollectionPostersBody(IReadOnlyList<Guid>? CollectionIds);
|
||||
|
||||
@@ -145,14 +145,19 @@ public static class ShowEndpoints
|
||||
}
|
||||
|
||||
/// <param name="withMedia">Снести заодно файлы шоу — иначе они останутся осиротевшими.</param>
|
||||
/// <param name="withSchedule">Вырезать шоу из будущего эфира вместо отказа.</param>
|
||||
private static async Task<IResult> 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ManualInboxTaker>();
|
||||
services.AddScoped<ShowMediaEraser>();
|
||||
services.AddScoped<CollectionSuggestionBuilder>();
|
||||
services.AddScoped<CollectionPosterPicker>();
|
||||
services.AddScoped<GroupElementResolver>();
|
||||
services.AddScoped<GroupFilterMatcher>();
|
||||
services.AddScoped<DynamicGroupResolver>();
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections;
|
||||
|
||||
/// <summary>
|
||||
/// Подбирает коллекции постер по её частям: берётся первая часть с постером — обычно это первый
|
||||
/// фильм цикла, и он же узнаётся с обложки. Отдельный тип, потому что подбор нужен и при создании
|
||||
/// коллекции, и по кнопке для уже заведённых.
|
||||
///
|
||||
/// Своего изображения коллекция не заводит: постер — это тот же <see cref="Image"/> из реестра,
|
||||
/// на который ссылается шоу. Копировать файл ради второй ссылки незачем.
|
||||
/// </summary>
|
||||
public sealed class CollectionPosterPicker(IAppDbContext dbContext)
|
||||
{
|
||||
/// <summary>
|
||||
/// Проставляет постер коллекциям, у которых его нет. Уже выбранный не трогаем: его мог
|
||||
/// поставить человек, и перебивать такой выбор автоматикой нельзя. Возвращает, скольким
|
||||
/// коллекциям постер нашёлся.
|
||||
/// </summary>
|
||||
public async Task<int> FillAsync(
|
||||
IReadOnlyList<Collection> 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;
|
||||
}
|
||||
|
||||
/// <summary>Постер первой по порядку части, у которой он есть.</summary>
|
||||
private static Guid? Pick(Collection collection, IReadOnlyDictionary<Guid, Guid> 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);
|
||||
|
||||
/// <summary>Постеры шоу, входящих в эти коллекции, одним запросом на всю пачку.</summary>
|
||||
private async Task<Dictionary<Guid, Guid>> PostersAsync(
|
||||
IReadOnlyList<Collection> 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);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.FillCollectionPosters;
|
||||
|
||||
/// <summary>
|
||||
/// Проставляет постеры коллекциям по их частям. Пустой список — всем коллекциям без постера:
|
||||
/// после массового импорта фильмов их сразу десятки, и обходить каждую руками бессмысленно.
|
||||
/// </summary>
|
||||
public sealed record FillCollectionPostersCommand(IReadOnlyList<Guid>? CollectionIds = null)
|
||||
: ICommand<Result<int>>;
|
||||
+28
@@ -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<FillCollectionPostersCommand, Result<int>>
|
||||
{
|
||||
public async Task<Result<int>> 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);
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -8,7 +8,8 @@ namespace TeleWave.Application.Library.Collections.Suggest;
|
||||
|
||||
public sealed class CreateCollectionFromSuggestionCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
CollectionSuggestionBuilder builder
|
||||
CollectionSuggestionBuilder builder,
|
||||
CollectionPosterPicker posters
|
||||
) : ICommandHandler<CreateCollectionFromSuggestionCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,13 @@ namespace TeleWave.Application.Library.DeleteShow;
|
||||
/// Удаляет шоу. <paramref name="WithMedia"/> — снести заодно его файлы: сами по себе они пережили
|
||||
/// бы шоу и остались бы в списке медиа осиротевшими, занимая место.
|
||||
/// </summary>
|
||||
public sealed record DeleteShowCommand(Guid ShowId, bool WithMedia = false) : ICommand<Result>;
|
||||
/// <param name="WithSchedule">
|
||||
/// Вырезать шоу из будущего эфира вместо отказа. Нужен там, где пересборка канала не спасает:
|
||||
/// запись уже идёт, канал выключен или шоу стоит на нескольких каналах. На месте вырезанного
|
||||
/// зритель увидит фон, пока хвост не пересоберут.
|
||||
/// </param>
|
||||
public sealed record DeleteShowCommand(
|
||||
Guid ShowId,
|
||||
bool WithMedia = false,
|
||||
bool WithSchedule = false
|
||||
) : ICommand<Result>;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Вырезает шоу из будущего эфира всех каналов. Прошлое не трогаем: там запись уже отыграна,
|
||||
/// и её удаление только стёрло бы название в архиве EPG. Идущую сейчас — трогаем: пересборка
|
||||
/// её не берёт, а без этого шоу нельзя было бы удалить до конца текущей серии.
|
||||
/// </summary>
|
||||
private Task CutFromAirAsync(Guid showId, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
return dbContext
|
||||
.ScheduleEntries.Where(e => e.ShowId == showId && e.EndsAtUtc > now)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Каналы, у которых шоу стоит в ещё не отыгранном эфире, — строкой «Первый (до 29.07 14:20)».
|
||||
/// Пусто — шоу свободно. Идущая сейчас запись тоже держит: пересборка её не трогает, и убрать
|
||||
/// шоу из-под зрителя нельзя.
|
||||
/// </summary>
|
||||
private async Task<string> 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}"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,16 @@ public static class ShowErrors
|
||||
"Больше одной серии бывает только у сериала."
|
||||
);
|
||||
|
||||
public static readonly Error InFutureSchedule = Error.Conflict(
|
||||
"Shows.InFutureSchedule",
|
||||
"Шоу стоит в уже сгенерированном эфире. Пересоберите расписание канала и повторите."
|
||||
);
|
||||
/// <summary>
|
||||
/// Шоу занято будущим эфиром. Каналы и время перечисляем в тексте: «пересоберите расписание»
|
||||
/// без ответа на вопрос «какого канала» заставляет обходить их все и гадать, где именно оно.
|
||||
/// </summary>
|
||||
public static Error InFutureSchedule(string channels) =>
|
||||
Error.Conflict(
|
||||
"Shows.InFutureSchedule",
|
||||
$"Шоу стоит в уже сгенерированном эфире ({channels}). Откройте канал, нажмите "
|
||||
+ "«Применить» — хвост ленты пересоберётся по текущей сетке — и повторите удаление."
|
||||
);
|
||||
|
||||
public static readonly Error AssetNotFound = Error.NotFound(
|
||||
"Shows.AssetNotFound",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Постер коллекции по её частям. Правило простое и объяснимое: обложка первой части — франшиза
|
||||
/// узнаётся именно по ней, а коллекция без картинки теряется в списке.
|
||||
/// </summary>
|
||||
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<int> 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -114,6 +114,66 @@ public sealed class TransactionIntegrationTests(PostgresFixture fixture)
|
||||
.DeleteAssetArtifactsAsync(asset.Id, ".mkv", Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Шоу, стоящее в будущем эфире, удаляется с флагом «вырезать из эфира»: пересборка канала
|
||||
/// спасает не всегда — идущую запись она не трогает, а выключенный канал вещать не начнёт.
|
||||
/// </summary>
|
||||
[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<IMediaStorage>())
|
||||
).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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Удаление шоу «вместе с медиа»: ассеты, записи расписания и файлы уходят одной операцией.
|
||||
/// Проверяется на настоящем Postgres — <c>ExecuteDelete</c> и транзакции InMemory не умеет.
|
||||
|
||||
@@ -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')}
|
||||
</Button>
|
||||
|
||||
{/* Постер коллекции — обложка её первой части: своей картинки у франшизы нет, а без
|
||||
обложки она теряется в списке одинаковых строк. */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={postersMutation.isPending}
|
||||
onClick={() => postersMutation.mutate()}
|
||||
title={t('admin.collections.postersHint')}
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
{t('admin.collections.posters')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CollectionSuggestions />
|
||||
|
||||
@@ -39,6 +39,17 @@ export function addShowsToCollection(body: {
|
||||
}
|
||||
|
||||
/** Какие франшизы имеет смысл собрать из уже загруженных полнометражек. */
|
||||
/**
|
||||
* Проставляет постеры по частям коллекций. Без списка — всем, у кого постера ещё нет: после
|
||||
* массового импорта фильмов франшиз сразу десятки. Возвращает, скольким постер нашёлся.
|
||||
*/
|
||||
export function fillCollectionPosters(collectionIds?: string[]) {
|
||||
return apiRequest<number>('/admin/collections/bulk/posters', {
|
||||
method: 'POST',
|
||||
body: { collectionIds: collectionIds ?? null },
|
||||
})
|
||||
}
|
||||
|
||||
export function suggestCollections() {
|
||||
return apiRequest<CollectionSuggestionDto[]>('/admin/collections/suggestions')
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<DialogDescription>{t('admin.shows.deleteHint', { name: show.name })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Занятость эфиром — самая частая причина отказа, и лечится она не всегда пересборкой:
|
||||
идущую запись она не трогает, а выключенный канал вещать и не начнёт. Поэтому здесь
|
||||
не только объяснение, но и способ довести удаление до конца. */}
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={withSchedule}
|
||||
onChange={(e) => setWithSchedule(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{t('admin.shows.deleteWithSchedule')}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('admin.shows.deleteWithScheduleHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{hasMedia && (
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
@@ -66,7 +85,7 @@ export function DeleteShowDialog({
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={pending}
|
||||
onClick={() => onConfirm(withMedia)}
|
||||
onClick={() => onConfirm({ withMedia, withSchedule })}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
|
||||
@@ -115,8 +115,15 @@ export function ShowsPanel() {
|
||||
// Шоу к удалению: сначала спрашиваем про файлы, и только потом удаляем.
|
||||
const [toDelete, setToDelete] = useState<ShowSummaryDto | null>(null)
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ id, withMedia }: { id: string; withMedia: boolean }) =>
|
||||
deleteShow(id, withMedia),
|
||||
mutationFn: ({
|
||||
id,
|
||||
withMedia,
|
||||
withSchedule,
|
||||
}: {
|
||||
id: string
|
||||
withMedia: boolean
|
||||
withSchedule: boolean
|
||||
}) => deleteShow(id, withMedia, withSchedule),
|
||||
onSuccess: () => {
|
||||
setToDelete(null)
|
||||
invalidate()
|
||||
@@ -337,7 +344,7 @@ export function ShowsPanel() {
|
||||
<DeleteShowDialog
|
||||
show={toDelete}
|
||||
pending={deleteMutation.isPending}
|
||||
onConfirm={(withMedia) => deleteMutation.mutate({ id: toDelete.id, withMedia })}
|
||||
onConfirm={(options) => deleteMutation.mutate({ id: toDelete.id, ...options })}
|
||||
onClose={() => setToDelete(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -79,10 +79,17 @@ export function getShowUsage(id: string) {
|
||||
return apiRequest<ShowUsageDto>(`/admin/shows/${id}/usage`)
|
||||
}
|
||||
|
||||
/** `withMedia` — снести заодно файлы шоу: иначе они переживут его осиротевшими. */
|
||||
export function deleteShow(id: string, withMedia = false) {
|
||||
const query = withMedia ? '?withMedia=true' : ''
|
||||
return apiRequest<void>(`/admin/shows/${id}${query}`, { method: 'DELETE' })
|
||||
/**
|
||||
* `withMedia` — снести заодно файлы шоу: иначе они переживут его осиротевшими.
|
||||
* `withSchedule` — вырезать шоу из будущего эфира вместо отказа.
|
||||
*/
|
||||
export function deleteShow(id: string, withMedia = false, withSchedule = false) {
|
||||
const query = new URLSearchParams()
|
||||
if (withMedia) query.set('withMedia', 'true')
|
||||
if (withSchedule) query.set('withSchedule', 'true')
|
||||
const suffix = query.size > 0 ? `?${query.toString()}` : ''
|
||||
|
||||
return apiRequest<void>(`/admin/shows/${id}${suffix}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function addEpisode(showId: string, mediaAssetId: string) {
|
||||
|
||||
@@ -159,6 +159,10 @@ export const en = {
|
||||
},
|
||||
},
|
||||
collections: {
|
||||
posters: 'Fill posters',
|
||||
postersHint:
|
||||
'Takes the first part’s cover for collections that have no poster yet. A hand-picked one is left alone.',
|
||||
postersDone: 'Posters filled: {{count}}',
|
||||
title: 'Collections',
|
||||
hint: 'A franchise is an ordered set of films played as a single unit.',
|
||||
name: 'Name',
|
||||
@@ -417,6 +421,9 @@ export const en = {
|
||||
deleteWithMedia: 'Delete its media too: {{count}} file(s)',
|
||||
deleteWithMediaHint:
|
||||
'Otherwise the files stay in the media list with no owner and keep taking space.',
|
||||
deleteWithSchedule: 'Cut it from the upcoming air too',
|
||||
deleteWithScheduleHint:
|
||||
'Without this the deletion is refused while the show sits in the feed. Viewers see the filler where it was until the tail is rebuilt.',
|
||||
otherGenres: 'Other genres',
|
||||
bulk: {
|
||||
selected: 'Selected: {{count}}',
|
||||
|
||||
@@ -159,6 +159,10 @@ export const ru = {
|
||||
},
|
||||
},
|
||||
collections: {
|
||||
posters: 'Проставить постеры',
|
||||
postersHint:
|
||||
'Берёт обложку первой части у коллекций, у которых постера ещё нет. Выбранный руками не трогает.',
|
||||
postersDone: 'Постеры проставлены: {{count}}',
|
||||
title: 'Коллекции',
|
||||
hint: 'Франшиза — упорядоченный набор фильмов, который играется как одно целое.',
|
||||
name: 'Название',
|
||||
@@ -414,6 +418,9 @@ export const ru = {
|
||||
deleteWithMedia: 'Также удалить медиа: файлов {{count}}',
|
||||
deleteWithMediaHint:
|
||||
'Иначе файлы останутся в списке медиа ничьими и продолжат занимать место.',
|
||||
deleteWithSchedule: 'Также вырезать из будущего эфира',
|
||||
deleteWithScheduleHint:
|
||||
'Без этого удаление отклонят, пока шоу стоит в ленте. На месте вырезанного зритель увидит фон, пока хвост не пересоберут.',
|
||||
otherGenres: 'Остальные жанры',
|
||||
bulk: {
|
||||
selected: 'Отмечено: {{count}}',
|
||||
|
||||
Reference in New Issue
Block a user