Implement bulk tagging and enriching of shows with new API endpoints and frontend integration
Added new API endpoints for bulk tagging and enriching shows, allowing for mass updates of genres and audience ratings. Implemented backend logic to handle bulk operations and updated the Dependency Injection configuration to include necessary services. Enhanced the frontend with new components for selecting shows and applying bulk actions, improving the user experience for managing multiple shows simultaneously. Localization updates were made to support these new features in both English and Russian.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Library.BulkTagShows;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Library;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Library;
|
||||
|
||||
/// <summary>
|
||||
/// Массовая разметка: жанры и рейтинг — то, на что опираются группы контента, и проставлять их
|
||||
/// по одному шоу нереально. Проверяем, что пакет не затирает лишнего.
|
||||
/// </summary>
|
||||
public class BulkTagShowsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AddsGenres_WithoutErasingExisting()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var existing = Genre.Create("Комедия", "comedy");
|
||||
var added = Genre.Create("Мультфильм", "animation");
|
||||
var show = Show.Create("A", ShowKind.Series);
|
||||
show.SetGenres([existing.Id]);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Genres.AddRange(existing, added);
|
||||
seed.Shows.Add(show);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await RunAsync(fixture, new BulkTagShowsCommand([show.Id], GenreIds: [added.Id]));
|
||||
|
||||
await using var check = fixture.New();
|
||||
var stored = check.Shows.Include(s => s.Genres).Single();
|
||||
Assert.Equal(2, stored.Genres.Count);
|
||||
// Основной жанр не должен незаметно переехать: его показывает список шоу.
|
||||
Assert.Equal(existing.Id, stored.PrimaryGenreId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplacesGenres_WhenAsked()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var old = Genre.Create("Комедия", "comedy");
|
||||
var fresh = Genre.Create("Мультфильм", "animation");
|
||||
var show = Show.Create("A", ShowKind.Series);
|
||||
show.SetGenres([old.Id]);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Genres.AddRange(old, fresh);
|
||||
seed.Shows.Add(show);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
fixture,
|
||||
new BulkTagShowsCommand([show.Id], GenreIds: [fresh.Id], ReplaceGenres: true)
|
||||
);
|
||||
|
||||
await using var check = fixture.New();
|
||||
var stored = check.Shows.Include(s => s.Genres).Single();
|
||||
Assert.Equal(fresh.Id, stored.Genres.Single().GenreId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetsAudience_AndLeavesGenresAlone()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var genre = Genre.Create("Комедия", "comedy");
|
||||
var show = Show.Create("A", ShowKind.Series);
|
||||
show.SetGenres([genre.Id]);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Genres.Add(genre);
|
||||
seed.Shows.Add(show);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
fixture,
|
||||
new BulkTagShowsCommand([show.Id], Audience: ShowAudience.Pg13, SetAudience: true)
|
||||
);
|
||||
|
||||
await using var check = fixture.New();
|
||||
var stored = check.Shows.Include(s => s.Genres).Single();
|
||||
Assert.Equal(ShowAudience.Pg13, stored.Audience);
|
||||
Assert.Single(stored.Genres);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FailsForUnknownGenre()
|
||||
{
|
||||
// Ссылка на несуществующий жанр упала бы нарушением внешнего ключа посреди пакета.
|
||||
var fixture = new TestDb();
|
||||
var show = Show.Create("A", ShowKind.Series);
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Shows.Add(show);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new BulkTagShowsCommandHandler(db).Handle(
|
||||
new BulkTagShowsCommand([show.Id], GenreIds: [Guid.NewGuid()]),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipsMissingShows_WithoutFailing()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var show = Show.Create("A", ShowKind.Series);
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Shows.Add(show);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var updated = await RunAsync(
|
||||
fixture,
|
||||
new BulkTagShowsCommand(
|
||||
[show.Id, Guid.NewGuid()],
|
||||
Audience: ShowAudience.R,
|
||||
SetAudience: true
|
||||
)
|
||||
);
|
||||
|
||||
// Пока собирали галочки, шоу могли удалить — это не повод валить всю операцию.
|
||||
Assert.Equal(1, updated);
|
||||
}
|
||||
|
||||
private static async Task<int> RunAsync(TestDb fixture, BulkTagShowsCommand command)
|
||||
{
|
||||
await using var db = fixture.New();
|
||||
var result = await new BulkTagShowsCommandHandler(db).Handle(
|
||||
command,
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(result.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return result.Value;
|
||||
}
|
||||
}
|
||||
@@ -77,10 +77,13 @@ public class ApplyShowMetadataTests
|
||||
await using var db = arranged.Fixture.New();
|
||||
var handler = new ApplyShowMetadataCommandHandler(
|
||||
db,
|
||||
arranged.Resolver,
|
||||
Substitute.For<IImageDownloader>(),
|
||||
Substitute.For<IImageStore>(),
|
||||
new GenreMatcher(db)
|
||||
new ShowMetadataApplier(
|
||||
db,
|
||||
arranged.Resolver,
|
||||
Substitute.For<IImageDownloader>(),
|
||||
Substitute.For<IImageStore>(),
|
||||
new GenreMatcher(db)
|
||||
)
|
||||
);
|
||||
|
||||
var result = await handler.Handle(
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
using NSubstitute;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Library.Genres;
|
||||
using TeleWave.Application.Metadata;
|
||||
using TeleWave.Application.Metadata.EnrichShows;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Library;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Metadata;
|
||||
|
||||
/// <summary>
|
||||
/// Массовое обогащение метаданными. Главное свойство — осторожность: чужой постер и чужой рейтинг
|
||||
/// у шоу в эфире хуже, чем непроставленные метаданные.
|
||||
/// </summary>
|
||||
public class EnrichShowsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Applies_WhenTitleMatchesExactly()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var show = Show.Create("Гравити Фолз", ShowKind.Series);
|
||||
show.SetOriginalName("Gravity Falls");
|
||||
await SeedAsync(fixture, show);
|
||||
|
||||
var provider = ProviderWith([
|
||||
new MetadataCandidate("42", "Gravity Falls", 2012, null, null, ShowKind.Series),
|
||||
]);
|
||||
provider
|
||||
.GetShowAsync("42", Arg.Any<ShowKind>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ShowMetadata("42", "Gravity Falls", 2012, "desc", null, [], null));
|
||||
|
||||
var result = await RunAsync(fixture, provider, show.Id);
|
||||
|
||||
Assert.Equal(1, result.Applied);
|
||||
Assert.Empty(result.Skipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Skips_WhenSeveralCandidatesShareTheTitle()
|
||||
{
|
||||
// Два одноимённых — угадывать нельзя: у них разные постеры и разные рейтинги.
|
||||
var fixture = new TestDb();
|
||||
var show = Show.Create("Gravity Falls", ShowKind.Series);
|
||||
await SeedAsync(fixture, show);
|
||||
|
||||
var provider = ProviderWith([
|
||||
new MetadataCandidate("1", "Gravity Falls", null, null, null, ShowKind.Series),
|
||||
new MetadataCandidate("2", "Gravity Falls", null, null, null, ShowKind.Series),
|
||||
]);
|
||||
|
||||
var result = await RunAsync(fixture, provider, show.Id);
|
||||
|
||||
Assert.Equal(0, result.Applied);
|
||||
Assert.Single(result.Skipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Skips_WhenOnlyLooseMatchFound()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var show = Show.Create("Пилот", ShowKind.Series);
|
||||
await SeedAsync(fixture, show);
|
||||
|
||||
var provider = ProviderWith([
|
||||
new MetadataCandidate("7", "Совсем другое шоу", null, null, null, ShowKind.Series),
|
||||
]);
|
||||
|
||||
var result = await RunAsync(fixture, provider, show.Id);
|
||||
|
||||
Assert.Equal(0, result.Applied);
|
||||
Assert.Single(result.Skipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Skips_AlreadyLinked()
|
||||
{
|
||||
// Ручной выбор источника массовая операция переписывать не должна.
|
||||
var fixture = new TestDb();
|
||||
var show = Show.Create("Gravity Falls", ShowKind.Series);
|
||||
show.ApplyMetadata("tmdb", "999", null, null, null);
|
||||
await SeedAsync(fixture, show);
|
||||
|
||||
var provider = ProviderWith([
|
||||
new MetadataCandidate("42", "Gravity Falls", null, null, null, ShowKind.Series),
|
||||
]);
|
||||
|
||||
var result = await RunAsync(fixture, provider, show.Id);
|
||||
|
||||
Assert.Equal(0, result.Applied);
|
||||
Assert.Single(result.Skipped);
|
||||
}
|
||||
|
||||
private static async Task SeedAsync(TestDb fixture, Show show)
|
||||
{
|
||||
await using var seed = fixture.New();
|
||||
seed.Shows.Add(show);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static IMetadataProvider ProviderWith(IReadOnlyList<MetadataCandidate> candidates)
|
||||
{
|
||||
var provider = Substitute.For<IMetadataProvider>();
|
||||
provider
|
||||
.SearchShowsAsync(Arg.Any<string>(), Arg.Any<ShowKind>(), Arg.Any<CancellationToken>())
|
||||
.Returns(candidates);
|
||||
return provider;
|
||||
}
|
||||
|
||||
private static async Task<EnrichShowsResultDto> RunAsync(
|
||||
TestDb fixture,
|
||||
IMetadataProvider provider,
|
||||
Guid showId
|
||||
)
|
||||
{
|
||||
var resolver = Substitute.For<IMetadataProviderResolver>();
|
||||
resolver.Resolve("tmdb").Returns(provider);
|
||||
|
||||
await using var db = fixture.New();
|
||||
var applier = new ShowMetadataApplier(
|
||||
db,
|
||||
resolver,
|
||||
Substitute.For<IImageDownloader>(),
|
||||
Substitute.For<IImageStore>(),
|
||||
new GenreMatcher(db)
|
||||
);
|
||||
|
||||
var result = await new EnrichShowsMetadataCommandHandler(db, resolver, applier).Handle(
|
||||
new EnrichShowsMetadataCommand([showId], "tmdb"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return result.Value;
|
||||
}
|
||||
}
|
||||
@@ -55,13 +55,20 @@ public class ValidateTemplateTests
|
||||
)
|
||||
);
|
||||
|
||||
/// <summary>Наполняет группу позициями: проверке важно их число, а не что за ними стоит.</summary>
|
||||
private static void FillGroup(Group group, int count)
|
||||
{
|
||||
for (var i = 0; i < count; i++)
|
||||
group.AddElement(GroupElementKind.Show, Guid.NewGuid());
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<TemplateIssueDto>> ValidateAsync(
|
||||
TestDb fixture,
|
||||
Guid channelId
|
||||
)
|
||||
{
|
||||
await using var db = fixture.New();
|
||||
var result = await new ValidateTemplateQueryHandler(db).Handle(
|
||||
var result = await new ValidateTemplateQueryHandler(db, GroupServices.Dynamic(db)).Handle(
|
||||
new ValidateTemplateQuery(channelId),
|
||||
CancellationToken.None
|
||||
);
|
||||
@@ -75,7 +82,7 @@ public class ValidateTemplateTests
|
||||
var fixture = new TestDb();
|
||||
await using var db = fixture.New();
|
||||
|
||||
var result = await new ValidateTemplateQueryHandler(db).Handle(
|
||||
var result = await new ValidateTemplateQueryHandler(db, GroupServices.Dynamic(db)).Handle(
|
||||
new ValidateTemplateQuery(Guid.NewGuid()),
|
||||
CancellationToken.None
|
||||
);
|
||||
@@ -95,7 +102,7 @@ public class ValidateTemplateTests
|
||||
}
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new ValidateTemplateQueryHandler(db).Handle(
|
||||
var result = await new ValidateTemplateQueryHandler(db, GroupServices.Dynamic(db)).Handle(
|
||||
new ValidateTemplateQuery(channel.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
@@ -177,7 +184,9 @@ public class ValidateTemplateTests
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Первый", "one", T0);
|
||||
var group = Group.Create("Маленькая");
|
||||
group.UpdateStats(3, 3, TimeSpan.FromHours(3), T0);
|
||||
// Позиции настоящие: проверка считает состав, а не кэш статистики, — у динамической группы
|
||||
// кэш отстаёт от библиотеки, и полагаться на него нельзя.
|
||||
FillGroup(group, 3);
|
||||
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
var slot = FullDaySlot(template.Background!, group.Id, channel.DayStartTime);
|
||||
@@ -212,7 +221,7 @@ public class ValidateTemplateTests
|
||||
var fixture = new TestDb();
|
||||
var channel = Channel.Create("Первый", "one", T0);
|
||||
var group = Group.Create("Кино");
|
||||
group.UpdateStats(50, 50, TimeSpan.FromHours(50), T0);
|
||||
FillGroup(group, 50);
|
||||
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
FullDaySlot(template.Background!, group.Id, channel.DayStartTime);
|
||||
@@ -255,7 +264,7 @@ public class ValidateTemplateTests
|
||||
var group = Group.Create("Вечернее");
|
||||
group.AddElement(GroupElementKind.Show, mild.Id);
|
||||
group.AddElement(GroupElementKind.Collection, collection.Id);
|
||||
group.UpdateStats(2, 2, TimeSpan.FromHours(2), T0);
|
||||
FillGroup(group, 2);
|
||||
|
||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||
var slot = FullDaySlot(template.Background!, group.Id, channel.DayStartTime);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Application.Programming.Templates.CopyTemplate;
|
||||
using TeleWave.Application.Programming.Templates.CreateTemplate;
|
||||
@@ -81,10 +82,10 @@ public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture)
|
||||
var (sourceId, _, _) = await SeedPairAsync(seedDb);
|
||||
|
||||
await using var db = fixture.CreateContext();
|
||||
var result = await new ValidateTemplateQueryHandler(db).Handle(
|
||||
new ValidateTemplateQuery(sourceId),
|
||||
default
|
||||
);
|
||||
var result = await new ValidateTemplateQueryHandler(
|
||||
db,
|
||||
new DynamicGroupResolver(new GroupFilterMatcher(db), new GroupElementResolver(db))
|
||||
).Handle(new ValidateTemplateQuery(sourceId), default);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
// Группа в сиде пустая, а слот занимает лишь два часа суток — обе проверки должны сработать.
|
||||
|
||||
Reference in New Issue
Block a user