From 24fdbdf680b25c0b6db508468f7a7b1c07b78134 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 27 Jul 2026 04:18:30 +0300 Subject: [PATCH] Add group suggestions feature with API endpoints and frontend integration Implemented new API endpoints for suggesting groups based on the current library, allowing admins to retrieve and create groups from suggestions. Updated the backend to include error handling for suggestions and added necessary DTOs. Enhanced the frontend with new API functions and UI components to display suggestions, improving the user experience for group management. Localization updates were made to support new features in both English and Russian. --- .../TeleWave.Api/Endpoints/GroupEndpoints.cs | 36 +++ .../DependencyInjection.cs | 2 + .../Programming/Groups/GroupErrors.cs | 10 + .../CreateGroupFromSuggestionCommand.cs | 11 + ...CreateGroupFromSuggestionCommandHandler.cs | 42 ++++ .../Groups/Suggest/GroupSuggestionBuilder.cs | 230 ++++++++++++++++++ .../Groups/Suggest/GroupSuggestionKind.cs | 17 ++ .../Groups/Suggest/SuggestGroupsQuery.cs | 23 ++ .../Suggest/SuggestGroupsQueryHandler.cs | 50 ++++ .../Programming/GroupSuggestionsTests.cs | 191 +++++++++++++++ .../src/features/admin/groups/GroupDetail.tsx | 38 ++- .../admin/groups/GroupSuggestions.tsx | 97 ++++++++ .../src/features/admin/groups/GroupsPanel.tsx | 3 + frontend/src/features/admin/groups/api.ts | 14 ++ frontend/src/shared/api/query-keys.ts | 3 + frontend/src/shared/api/types.ts | 14 ++ frontend/src/shared/lib/locales/en.ts | 15 ++ frontend/src/shared/lib/locales/ru.ts | 15 ++ 18 files changed, 810 insertions(+), 1 deletion(-) create mode 100644 backend/src/TeleWave.Application/Programming/Groups/Suggest/CreateGroupFromSuggestionCommand.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/Suggest/CreateGroupFromSuggestionCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/Suggest/GroupSuggestionBuilder.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/Suggest/GroupSuggestionKind.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/Suggest/SuggestGroupsQuery.cs create mode 100644 backend/src/TeleWave.Application/Programming/Groups/Suggest/SuggestGroupsQueryHandler.cs create mode 100644 backend/tests/TeleWave.Application.Tests/Programming/GroupSuggestionsTests.cs create mode 100644 frontend/src/features/admin/groups/GroupSuggestions.tsx diff --git a/backend/src/TeleWave.Api/Endpoints/GroupEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/GroupEndpoints.cs index 441a5ae..81643a1 100644 --- a/backend/src/TeleWave.Api/Endpoints/GroupEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/GroupEndpoints.cs @@ -10,6 +10,7 @@ using TeleWave.Application.Programming.Groups.ListGroups; using TeleWave.Application.Programming.Groups.RemoveGroupItem; using TeleWave.Application.Programming.Groups.ReorderGroup; using TeleWave.Application.Programming.Groups.SetGroupItemWeight; +using TeleWave.Application.Programming.Groups.Suggest; using TeleWave.Application.Programming.Groups.UpdateGroup; using TeleWave.Infrastructure.Identity; @@ -34,6 +35,12 @@ public static class GroupEndpoints .MapPost("/{id:guid}/candidates", FindCandidates) .Produces>(); + // Предложения: что имеет смысл завести группой при нынешней библиотеке. + admin.MapGet("/suggestions", Suggestions).Produces>(); + admin + .MapPost("/suggestions", CreateFromSuggestion) + .Produces(StatusCodes.Status201Created); + admin.MapPost("/{id:guid}/items", AddElements).Produces(); admin .MapDelete("/{id:guid}/items/{itemId:guid}", RemoveItem) @@ -80,6 +87,33 @@ public static class GroupEndpoints : result.ToHttpResult(); } + private static async Task Suggestions( + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new SuggestGroupsQuery(), cancellationToken); + return Results.Ok(result); + } + + private static async Task CreateFromSuggestion( + CreateGroupFromSuggestionBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new CreateGroupFromSuggestionCommand(body.Key), + cancellationToken + ); + return result.IsSuccess + ? Results.Created( + $"/api/admin/groups/{result.Value}", + new CreatedIdResponse(result.Value) + ) + : result.ToHttpResult(); + } + private static async Task UpdateGroup( Guid id, UpdateGroupBody body, @@ -179,6 +213,8 @@ public sealed record UpdateGroupBody(string Name, string? Description, GroupFilt public sealed record FindCandidatesBody(GroupFilter? Filter); +public sealed record CreateGroupFromSuggestionBody(string Key); + public sealed record AddGroupElementsBody(IReadOnlyList Elements); public sealed record GroupItemWeightBody(int Weight); diff --git a/backend/src/TeleWave.Application/DependencyInjection.cs b/backend/src/TeleWave.Application/DependencyInjection.cs index 6572d46..c26e6cd 100644 --- a/backend/src/TeleWave.Application/DependencyInjection.cs +++ b/backend/src/TeleWave.Application/DependencyInjection.cs @@ -7,6 +7,7 @@ using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Common.Behaviors; using TeleWave.Application.Library.Genres; using TeleWave.Application.Programming.Groups; +using TeleWave.Application.Programming.Groups.Suggest; using TeleWave.Application.Programming.Planning; using TeleWave.Application.Programming.Templates; @@ -37,6 +38,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/Programming/Groups/GroupErrors.cs b/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs index a8c0c1e..4a37810 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs @@ -25,4 +25,14 @@ public static class GroupErrors "Groups.FilterNotSet", "У группы не задано правило набора." ); + + public static readonly Error SuggestionNotFound = Error.NotFound( + "Groups.SuggestionNotFound", + "Предложение устарело — библиотека изменилась. Обновите список предложений." + ); + + public static readonly Error SuggestionAlreadyCreated = Error.Conflict( + "Groups.SuggestionAlreadyCreated", + "Группа с таким названием уже есть." + ); } diff --git a/backend/src/TeleWave.Application/Programming/Groups/Suggest/CreateGroupFromSuggestionCommand.cs b/backend/src/TeleWave.Application/Programming/Groups/Suggest/CreateGroupFromSuggestionCommand.cs new file mode 100644 index 0000000..2464baf --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/Suggest/CreateGroupFromSuggestionCommand.cs @@ -0,0 +1,11 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Groups.Suggest; + +/// +/// Создаёт группу по предложению: имя, правило набора и состав берутся с сервера по ключу, а не +/// приезжают с клиента. Так между показом предложения и нажатием кнопки библиотека может измениться — +/// в группу попадёт актуальное, а не то, что успело устареть на экране. +/// +public sealed record CreateGroupFromSuggestionCommand(string Key) : ICommand>; diff --git a/backend/src/TeleWave.Application/Programming/Groups/Suggest/CreateGroupFromSuggestionCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/Suggest/CreateGroupFromSuggestionCommandHandler.cs new file mode 100644 index 0000000..45ff68f --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/Suggest/CreateGroupFromSuggestionCommandHandler.cs @@ -0,0 +1,42 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Groups.Suggest; + +public sealed class CreateGroupFromSuggestionCommandHandler( + IAppDbContext dbContext, + GroupSuggestionBuilder builder, + GroupStatsService stats +) : ICommandHandler> +{ + public async Task> Handle( + CreateGroupFromSuggestionCommand command, + CancellationToken cancellationToken + ) + { + var suggestions = await builder.BuildAsync(cancellationToken); + var suggestion = suggestions.FirstOrDefault(s => s.Key == command.Key); + if (suggestion is null) + return Result.Failure(GroupErrors.SuggestionNotFound); + + // Имя — ключ узнаваемости группы, и две «Мультфильм» в списке слота неразличимы. + var taken = await dbContext.Groups.AnyAsync( + g => g.Name.ToLower() == suggestion.Name.ToLower(), + cancellationToken + ); + if (taken) + return Result.Failure(GroupErrors.SuggestionAlreadyCreated); + + var group = Group.Create(suggestion.Name); + group.SetFilter(suggestion.Filter?.ToJson()); + foreach (var showId in suggestion.ShowIds) + group.AddElement(GroupElementKind.Show, showId); + + dbContext.Groups.Add(group); + await stats.RecomputeAsync(group, cancellationToken); + return Result.Success(group.Id); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/Suggest/GroupSuggestionBuilder.cs b/backend/src/TeleWave.Application/Programming/Groups/Suggest/GroupSuggestionBuilder.cs new file mode 100644 index 0000000..a8af6ef --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/Suggest/GroupSuggestionBuilder.cs @@ -0,0 +1,230 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Library; +using TeleWave.Domain.Programming; +using ElementStats = System.Collections.Generic.IReadOnlyDictionary< + (TeleWave.Domain.Programming.GroupElementKind Kind, System.Guid Id), + TeleWave.Application.Programming.Groups.GroupElementInfo +>; + +namespace TeleWave.Application.Programming.Groups.Suggest; + +/// +/// Предложение: готовая к созданию группа — имя, правило набора и состав. Ключ самоописателен +/// (genre:{id}, kind:Series, …), поэтому создание не тащит состав с клиента: команда +/// пересобирает предложение по ключу на свежих данных. +/// +public sealed record GroupSuggestion( + string Key, + GroupSuggestionKind Kind, + string Name, + GroupFilter? Filter, + IReadOnlyList ShowIds, + int UnitCount, + TimeSpan TotalDuration +); + +/// +/// Разбирает библиотеку и предлагает группы, которые имеет смысл завести. Правила намеренно простые +/// и объяснимые: админ должен понимать, почему ему это показали, — «12 шоу с жанром Мультфильм» +/// проверяемо, а рекомендательная магия нет. +/// +/// Состав предложения — только шоу. Коллекции сюда не берутся: франшиза почти всегда состоит из тех +/// же шоу, и добавление обеих форм задваивало бы эфир. Правило набора у предложения сохраняется, +/// поэтому коллекции при желании добираются кнопкой «Подобрать» уже в самой группе. +/// +public sealed class GroupSuggestionBuilder(IAppDbContext dbContext, GroupElementResolver resolver) +{ + /// Сколько шоу должно набраться, чтобы предлагать сборную группу (жанр/тип/рейтинг). + private const int MinShows = 2; + + /// С какого числа серий сериал заслуживает собственной группы. + private const int BigSeriesEpisodes = 20; + + /// Пороги «мягких» рейтингов: до них группа читается как «детское»/«семейное». + private static readonly (ShowAudience Max, string Name)[] AudienceTiers = + [ + (ShowAudience.Pg, "Детское (не строже PG)"), + (ShowAudience.Pg13, "Семейное (не строже PG-13)"), + ]; + + private static readonly (ShowKind Kind, string Name)[] ShowKindNames = + [ + (ShowKind.Series, "Сериалы"), + (ShowKind.Single, "Полнометражки"), + ]; + + public async Task> BuildAsync( + CancellationToken cancellationToken + ) + { + // Ролики-врезки в группы не идут: у рекламы и заставок своя подсистема. + var shows = await dbContext + .Shows.AsNoTracking() + .Where(s => s.Kind != ShowKind.Interstitial) + .Select(s => new ShowRow( + s.Id, + s.Name, + s.Kind, + s.Audience, + s.Genres.Select(g => g.GenreId).ToList(), + s.Episodes.Count + )) + .ToListAsync(cancellationToken); + if (shows.Count == 0) + return []; + + var stats = await resolver.ResolveAsync( + shows.Select(s => (GroupElementKind.Show, s.Id)), + cancellationToken + ); + + var genres = await dbContext + .Genres.AsNoTracking() + .ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken); + + var suggestions = new List(); + suggestions.AddRange(ByGenre(shows, genres, stats)); + suggestions.AddRange(ByShowKind(shows, stats)); + suggestions.AddRange(ByAudience(shows, stats)); + suggestions.AddRange(ByBigSeries(shows, stats)); + + // Внутри вида — сначала самые ёмкие: с них и начинают собирать сетку. + return + [ + .. suggestions + .OrderBy(s => s.Kind) + .ThenByDescending(s => s.UnitCount) + .ThenBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase), + ]; + } + + private static IEnumerable ByGenre( + List shows, + IReadOnlyDictionary genres, + ElementStats stats + ) + { + foreach (var (genreId, name) in genres) + { + var matched = shows.Where(s => s.GenreIds.Contains(genreId)).ToList(); + if (matched.Count < MinShows) + continue; + + yield return Build( + $"genre:{genreId:N}", + GroupSuggestionKind.Genre, + name, + new GroupFilter(GenreIds: [genreId]), + matched, + stats + ); + } + } + + private static IEnumerable ByShowKind(List shows, ElementStats stats) + { + foreach (var (kind, name) in ShowKindNames) + { + var matched = shows.Where(s => s.Kind == kind).ToList(); + if (matched.Count < MinShows) + continue; + + yield return Build( + $"kind:{kind}", + GroupSuggestionKind.ShowKind, + name, + new GroupFilter(ShowKinds: [kind]), + matched, + stats + ); + } + } + + /// + /// «Не строже X» — ровно та форма, которую понимает правило набора. Шоу без рейтинга в выборке + /// остаются (как в фильтре и в планировщике): источники проставляют рейтинг далеко не всему. + /// + private static IEnumerable ByAudience(List shows, ElementStats stats) + { + foreach (var (max, name) in AudienceTiers) + { + var matched = shows.Where(s => s.Audience == null || s.Audience <= max).ToList(); + if (matched.Count < MinShows) + continue; + + // Если под порог попадает вообще всё, группа повторяет библиотеку — предлагать нечего. + if (matched.Count == shows.Count) + continue; + + yield return Build( + $"audience:{max}", + GroupSuggestionKind.Audience, + name, + new GroupFilter(MaxAudience: max), + matched, + stats + ); + } + } + + /// + /// Крупный сериал ставят в слот целиком, поэтому ему нужна собственная группа. Правила набора + /// у неё нет: «именно это шоу» фильтром не выражается, да и не нужно — состав из одной позиции. + /// + private static IEnumerable ByBigSeries(List shows, ElementStats stats) + { + foreach ( + var show in shows.Where(s => + s.Kind == ShowKind.Series && s.EpisodeCount >= BigSeriesEpisodes + ) + ) + yield return Build( + $"show:{show.Id:N}", + GroupSuggestionKind.BigSeries, + show.Name, + null, + [show], + stats + ); + } + + private static GroupSuggestion Build( + string key, + GroupSuggestionKind kind, + string name, + GroupFilter? filter, + IReadOnlyList matched, + ElementStats stats + ) + { + var units = 0; + var duration = TimeSpan.Zero; + foreach (var show in matched) + { + if (!stats.TryGetValue((GroupElementKind.Show, show.Id), out var info)) + continue; + units += info.UnitCount; + duration += info.TotalDuration; + } + + return new GroupSuggestion( + key, + kind, + name, + filter, + [.. matched.Select(s => s.Id)], + units, + duration + ); + } + + private sealed record ShowRow( + Guid Id, + string Name, + ShowKind Kind, + ShowAudience? Audience, + List GenreIds, + int EpisodeCount + ); +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/Suggest/GroupSuggestionKind.cs b/backend/src/TeleWave.Application/Programming/Groups/Suggest/GroupSuggestionKind.cs new file mode 100644 index 0000000..2c27ecc --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/Suggest/GroupSuggestionKind.cs @@ -0,0 +1,17 @@ +namespace TeleWave.Application.Programming.Groups.Suggest; + +/// Откуда взялось предложение — UI по нему группирует список и подписывает происхождение. +public enum GroupSuggestionKind +{ + /// Всё, что помечено одним жанром. + Genre = 0, + + /// Всё одного типа: сериалы либо полнометражки. + ShowKind = 1, + + /// Всё не строже указанного возрастного рейтинга. + Audience = 2, + + /// Один крупный сериал, которого хватает на слот целиком. + BigSeries = 3, +} diff --git a/backend/src/TeleWave.Application/Programming/Groups/Suggest/SuggestGroupsQuery.cs b/backend/src/TeleWave.Application/Programming/Groups/Suggest/SuggestGroupsQuery.cs new file mode 100644 index 0000000..62c5f14 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/Suggest/SuggestGroupsQuery.cs @@ -0,0 +1,23 @@ +using LiteCqrs; + +namespace TeleWave.Application.Programming.Groups.Suggest; + +/// Что имеет смысл завести группой при текущем составе библиотеки. +public sealed record SuggestGroupsQuery : IQuery>; + +/// Ключ предложения — с ним же приходит команда создания. +/// Сколько шоу попадёт в состав. +/// Сколько единиц воспроизведения (серий/фильмов) они дают. +/// Объём готового эфира — по обработанным ассетам. +/// +/// Такая группа уже есть (по имени либо по совпадающему правилу набора) — создавать повторно нечего. +/// +public sealed record GroupSuggestionDto( + string Key, + GroupSuggestionKind Kind, + string Name, + int ShowCount, + int UnitCount, + double TotalDurationSeconds, + bool AlreadyExists +); diff --git a/backend/src/TeleWave.Application/Programming/Groups/Suggest/SuggestGroupsQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/Suggest/SuggestGroupsQueryHandler.cs new file mode 100644 index 0000000..de0cba8 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Groups/Suggest/SuggestGroupsQueryHandler.cs @@ -0,0 +1,50 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Application.Programming.Groups.Suggest; + +public sealed class SuggestGroupsQueryHandler( + IAppDbContext dbContext, + GroupSuggestionBuilder builder +) : IQueryHandler> +{ + public async Task> Handle( + SuggestGroupsQuery query, + CancellationToken cancellationToken + ) + { + var suggestions = await builder.BuildAsync(cancellationToken); + if (suggestions.Count == 0) + return []; + + // Уже созданное отмечаем, но из списка не убираем: пропавшее без следа предложение читается + // как сбой, а «уже есть» объясняет, почему кнопки нет. + var existing = await dbContext + .Groups.AsNoTracking() + .Select(g => new { g.Name, g.FilterJson }) + .ToListAsync(cancellationToken); + + var names = existing + .Select(g => g.Name.Trim()) + .ToHashSet(StringComparer.CurrentCultureIgnoreCase); + var filters = existing + .Select(g => g.FilterJson) + .Where(json => !string.IsNullOrWhiteSpace(json)) + .ToHashSet(StringComparer.Ordinal); + + return + [ + .. suggestions.Select(s => new GroupSuggestionDto( + s.Key, + s.Kind, + s.Name, + s.ShowIds.Count, + s.UnitCount, + s.TotalDuration.TotalSeconds, + names.Contains(s.Name) + || (s.Filter is not null && filters.Contains(s.Filter.ToJson())) + )), + ]; + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GroupSuggestionsTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GroupSuggestionsTests.cs new file mode 100644 index 0000000..3051bc7 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Programming/GroupSuggestionsTests.cs @@ -0,0 +1,191 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Programming.Groups; +using TeleWave.Application.Programming.Groups.Suggest; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Library; +using TeleWave.Domain.Programming; +using Xunit; + +namespace TeleWave.Application.Tests.Programming; + +/// +/// Предложения групп: разбор библиотеки должен быть объяснимым — что показали, то и создастся. +/// +public class GroupSuggestionsTests +{ + [Fact] + public async Task SuggestsGenre_WhenAtLeastTwoShowsShareIt() + { + var fixture = new TestDb(); + var cartoons = Genre.Create("Мультфильм", "animation"); + var drama = Genre.Create("Драма", "drama"); + var first = WithGenre(Show.Create("A", ShowKind.Series), cartoons); + var second = WithGenre(Show.Create("B", ShowKind.Series), cartoons); + // Один драматический — на группу не тянет. + var lonely = WithGenre(Show.Create("C", ShowKind.Single), drama); + + await using (var seed = fixture.New()) + { + seed.Genres.AddRange(cartoons, drama); + seed.Shows.AddRange(first, second, lonely); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var suggestions = await BuildAsync(fixture); + + var genre = Assert.Single(suggestions, s => s.Kind == GroupSuggestionKind.Genre); + Assert.Equal("Мультфильм", genre.Name); + Assert.Equal(2, genre.ShowIds.Count); + Assert.Equal([cartoons.Id], genre.Filter!.GenreIds); + } + + [Fact] + public async Task SuggestsBigSeries_Separately() + { + var fixture = new TestDb(); + var big = Show.Create("Гравити Фолз", ShowKind.Series); + for (var i = 0; i < 20; i++) + big.AddEpisode(Guid.NewGuid()); + var small = Show.Create("Короткое", ShowKind.Series); + small.AddEpisode(Guid.NewGuid()); + + await using (var seed = fixture.New()) + { + seed.Shows.AddRange(big, small); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var suggestions = await BuildAsync(fixture); + + var series = Assert.Single(suggestions, s => s.Kind == GroupSuggestionKind.BigSeries); + Assert.Equal("Гравити Фолз", series.Name); + Assert.Equal([big.Id], series.ShowIds); + // Правила у одиночного шоу нет: «именно это шоу» фильтром не выражается. + Assert.Null(series.Filter); + } + + [Fact] + public async Task SkipsAudienceTier_WhenItCoversEverything() + { + // Если под «не строже PG» попадает вся библиотека, группа повторяет её — предлагать нечего. + var fixture = new TestDb(); + var first = Show.Create("A", ShowKind.Series); + first.SetAudience(ShowAudience.G); + var second = Show.Create("B", ShowKind.Series); + second.SetAudience(ShowAudience.Pg); + + await using (var seed = fixture.New()) + { + seed.Shows.AddRange(first, second); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var suggestions = await BuildAsync(fixture); + + Assert.DoesNotContain(suggestions, s => s.Kind == GroupSuggestionKind.Audience); + } + + [Fact] + public async Task SuggestsAudienceTier_WhenStricterContentExists() + { + var fixture = new TestDb(); + var kids = Show.Create("A", ShowKind.Series); + kids.SetAudience(ShowAudience.G); + var adult = Show.Create("B", ShowKind.Series); + adult.SetAudience(ShowAudience.R); + var unrated = Show.Create("C", ShowKind.Series); + + await using (var seed = fixture.New()) + { + seed.Shows.AddRange(kids, adult, unrated); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var suggestions = await BuildAsync(fixture); + + var tier = suggestions.First(s => s.Kind == GroupSuggestionKind.Audience); + // Непроставленный рейтинг остаётся в выборке — как в правиле набора и в планировщике. + Assert.Equal(2, tier.ShowIds.Count); + Assert.Contains(kids.Id, tier.ShowIds); + Assert.Contains(unrated.Id, tier.ShowIds); + } + + [Fact] + public async Task CreateFromSuggestion_SavesFilterAndComposition() + { + var fixture = new TestDb(); + var genre = Genre.Create("Мультфильм", "animation"); + var first = WithGenre(Show.Create("A", ShowKind.Series), genre); + var second = WithGenre(Show.Create("B", ShowKind.Series), genre); + + await using (var seed = fixture.New()) + { + seed.Genres.Add(genre); + seed.Shows.AddRange(first, second); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var handler = new CreateGroupFromSuggestionCommandHandler( + db, + Builder(db), + new GroupStatsService(db, new GroupElementResolver(db)) + ); + + var created = await handler.Handle( + new CreateGroupFromSuggestionCommand($"genre:{genre.Id:N}"), + CancellationToken.None + ); + Assert.True(created.IsSuccess); + await db.SaveChangesAsync(CancellationToken.None); + + await using var check = fixture.New(); + var group = check.Groups.Include(g => g.Items).Single(); + Assert.Equal("Мультфильм", group.Name); + Assert.Equal(2, group.Items.Count); + Assert.All(group.Items, item => Assert.Equal(GroupElementKind.Show, item.ElementKind)); + Assert.Equal([genre.Id], GroupFilter.FromJson(group.FilterJson)!.GenreIds); + + // Повторное создание того же — конфликт: две одноимённые группы в слоте неразличимы. + var again = await handler.Handle( + new CreateGroupFromSuggestionCommand($"genre:{genre.Id:N}"), + CancellationToken.None + ); + Assert.False(again.IsSuccess); + } + + [Fact] + public async Task CreateFromSuggestion_FailsForUnknownKey() + { + var fixture = new TestDb(); + await using (var seed = fixture.New()) + { + seed.Shows.Add(Show.Create("A", ShowKind.Series)); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new CreateGroupFromSuggestionCommandHandler( + db, + Builder(db), + new GroupStatsService(db, new GroupElementResolver(db)) + ).Handle(new CreateGroupFromSuggestionCommand("genre:нет-такого"), CancellationToken.None); + + Assert.False(result.IsSuccess); + } + + private static Show WithGenre(Show show, Genre genre) + { + show.SetGenres([genre.Id]); + return show; + } + + private static GroupSuggestionBuilder Builder(Infrastructure.Persistence.AppDbContext db) => + new(db, new GroupElementResolver(db)); + + private static async Task> BuildAsync(TestDb fixture) + { + await using var db = fixture.New(); + return await Builder(db).BuildAsync(CancellationToken.None); + } +} diff --git a/frontend/src/features/admin/groups/GroupDetail.tsx b/frontend/src/features/admin/groups/GroupDetail.tsx index 6095ea3..a6455c5 100644 --- a/frontend/src/features/admin/groups/GroupDetail.tsx +++ b/frontend/src/features/admin/groups/GroupDetail.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' -import { ChevronLeft, GripVertical, Search, Trash2 } from 'lucide-react' +import { ChevronLeft, GripVertical, Search, Sparkles, Trash2 } from 'lucide-react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { qk } from '@/shared/api/query-keys' @@ -51,6 +51,19 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) { } const onError = useApiError() + /** + * Что подходит под сохранённое правило, но в группу ещё не попало: библиотека пополняется после + * того, как группа собрана, и без этой проверки новое шоу лежало бы мимо эфира, пока кто-нибудь + * не вспомнит нажать «Подобрать». Спрашиваем именно сохранённое правило (фильтр не передаём — + * сервер берёт его сам), а не черновик формы: подсказка не должна прыгать, пока крутят поля. + */ + const { data: pending } = useQuery({ + queryKey: qk.groups.pending(groupId), + queryFn: () => findGroupCandidates(groupId, null), + enabled: !!group?.filter, + }) + const pendingFresh = (pending ?? []).filter((c) => !c.alreadyInGroup) + const saveMutation = useMutation({ mutationFn: () => updateGroup(groupId, { @@ -157,6 +170,29 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) { + {pendingFresh.length > 0 && ( +
+ + + {t('admin.groups.pendingFound', { count: pendingFresh.length })}{' '} + + {pendingFresh + .slice(0, 3) + .map((c) => c.elementName) + .join(', ')} + {pendingFresh.length > 3 && '…'} + + + +
+ )} +
{/* Левая панель — конструктор правила набора */}
diff --git a/frontend/src/features/admin/groups/GroupSuggestions.tsx b/frontend/src/features/admin/groups/GroupSuggestions.tsx new file mode 100644 index 0000000..2e77856 --- /dev/null +++ b/frontend/src/features/admin/groups/GroupSuggestions.tsx @@ -0,0 +1,97 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { ChevronDown, ChevronRight, Plus, Wand2 } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { qk } from '@/shared/api/query-keys' +import type { GroupSuggestionDto } from '@/shared/api/types' +import { useApiError } from '@/shared/lib/use-api-error' +import { Badge } from '@/shared/ui/badge' +import { Button } from '@/shared/ui/button' +import { toast } from '@/shared/ui/toast-store' +import { createGroupFromSuggestion, suggestGroups } from './api' +import { DurationLabel } from './DurationLabel' + +/** + * Что имеет смысл завести группой при нынешней библиотеке. Разбор делает сервер (жанры, типы, + * рейтинги, крупные сериалы), здесь — только показ и кнопка. Уже созданное остаётся в списке + * помеченным: исчезнувшее без следа предложение читается как сбой, а «уже есть» объясняет, + * почему кнопки нет. + */ +export function GroupSuggestions() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const onError = useApiError() + const [collapsed, setCollapsed] = useState(false) + + const { data, isLoading } = useQuery({ + queryKey: qk.groups.suggestions, + queryFn: suggestGroups, + }) + + const create = useMutation({ + mutationFn: (suggestion: GroupSuggestionDto) => createGroupFromSuggestion(suggestion.key), + onSuccess: () => { + toast.success(t('admin.groups.suggestions.created')) + void queryClient.invalidateQueries({ queryKey: qk.groups.all }) + }, + onError, + }) + + // Пока предлагать нечего (пустая библиотека или всё уже создано) — секции нет вовсе. + if (isLoading || !data || data.length === 0) return null + + return ( +
+
+ +
+ + {!collapsed && ( + <> +

{t('admin.groups.suggestions.hint')}

+
    + {data.map((suggestion) => ( +
  • + {suggestion.name} + + {t(`admin.groups.suggestions.kinds.${suggestion.kind}`)} + + + {t('admin.groups.suggestions.stats', { + shows: suggestion.showCount, + units: suggestion.unitCount, + })} + + + + + {suggestion.alreadyExists ? ( + {t('admin.groups.suggestions.exists')} + ) : ( + + )} +
  • + ))} +
+ + )} +
+ ) +} diff --git a/frontend/src/features/admin/groups/GroupsPanel.tsx b/frontend/src/features/admin/groups/GroupsPanel.tsx index 208af53..a7387a6 100644 --- a/frontend/src/features/admin/groups/GroupsPanel.tsx +++ b/frontend/src/features/admin/groups/GroupsPanel.tsx @@ -11,6 +11,7 @@ import { sortRows, useTableSort } from '@/shared/lib/table-sort' import { SortHeader } from '@/shared/ui/sortable' import { createGroup, deleteGroup, listGroups } from './api' import { DurationLabel } from './DurationLabel' +import { GroupSuggestions } from './GroupSuggestions' export function GroupsPanel() { const { t } = useTranslation() @@ -63,6 +64,8 @@ export function GroupsPanel() {
+ +
diff --git a/frontend/src/features/admin/groups/api.ts b/frontend/src/features/admin/groups/api.ts index d996f4c..309ba2b 100644 --- a/frontend/src/features/admin/groups/api.ts +++ b/frontend/src/features/admin/groups/api.ts @@ -5,6 +5,7 @@ import type { GroupDto, GroupElementKind, GroupFilter, + GroupSuggestionDto, GroupSummaryDto, } from '@/shared/api/types' @@ -39,6 +40,19 @@ export function findGroupCandidates(id: string, filter: GroupFilter | null) { }) } +/** Что имеет смысл завести группой при нынешней библиотеке. */ +export function suggestGroups() { + return apiRequest('/admin/groups/suggestions') +} + +/** Создаёт группу по предложению: имя, правило и состав сервер берёт по ключу сам. */ +export function createGroupFromSuggestion(key: string) { + return apiRequest('/admin/groups/suggestions', { + method: 'POST', + body: { key }, + }) +} + export function addGroupElements( id: string, elements: { elementKind: GroupElementKind; elementId: string }[], diff --git a/frontend/src/shared/api/query-keys.ts b/frontend/src/shared/api/query-keys.ts index b48c3a2..86e071b 100644 --- a/frontend/src/shared/api/query-keys.ts +++ b/frontend/src/shared/api/query-keys.ts @@ -39,6 +39,9 @@ export const qk = { groups: { all: ['admin', 'groups'] as const, detail: (id: string) => ['admin', 'groups', id] as const, + suggestions: ['admin', 'groups', 'suggestions'] as const, + /** Подходящее по сохранённому правилу, но ещё не добавленное — проверяется при открытии группы. */ + pending: (id: string) => ['admin', 'groups', id, 'pending'] as const, }, collections: { diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 02a4e27..8fcf6db 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -291,6 +291,20 @@ export type GroupCandidateDto = { alreadyInGroup: boolean } +/** Откуда взялось предложение группы — по нему подписывается происхождение. */ +export type GroupSuggestionKind = 'Genre' | 'ShowKind' | 'Audience' | 'BigSeries' + +/** Предложение завести группу: имя, объём и признак «уже создана». */ +export type GroupSuggestionDto = { + key: string + kind: GroupSuggestionKind + name: string + showCount: number + unitCount: number + totalDurationSeconds: number + alreadyExists: boolean +} + export type MetadataCandidate = { externalId: string title: string diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index c3d8a7b..3cdc8b9 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -101,7 +101,22 @@ export const en = { found: 'Found: {{total}}, new: {{fresh}}', added: 'Items added: {{count}}', alreadyIn: 'already in group', + pendingFound: 'New matches for this rule: {{count}} —', + pendingAdd: 'Add all ({{count}})', elementKinds: { Show: 'Show', Collection: 'Collection' }, + suggestions: { + title: 'Suggestions', + hint: 'Built from the current library. The button creates a group with these items and saves the rule — later additions are then one click away.', + stats: '{{shows}} shows · {{units}} units', + exists: 'already exists', + created: 'Group created', + kinds: { + Genre: 'genre', + ShowKind: 'kind', + Audience: 'rating', + BigSeries: 'big series', + }, + }, filter: { title: 'Selection rule', hint: 'The rule only finds candidates — the group composition stays an explicit list.', diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index b5fc1e0..9927243 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -101,7 +101,22 @@ export const ru = { found: 'Найдено: {{total}}, новых: {{fresh}}', added: 'Добавлено позиций: {{count}}', alreadyIn: 'уже в группе', + pendingFound: 'Под правило группы подходит нового: {{count}} —', + pendingAdd: 'Добавить все ({{count}})', elementKinds: { Show: 'Шоу', Collection: 'Коллекция' }, + suggestions: { + title: 'Предложения', + hint: 'Собрано по текущей библиотеке. Кнопка создаёт группу с этим составом и сохраняет правило набора — новое из библиотеки потом добавится в один клик.', + stats: '{{shows}} шоу · {{units}} ед.', + exists: 'уже есть', + created: 'Группа создана', + kinds: { + Genre: 'жанр', + ShowKind: 'тип', + Audience: 'рейтинг', + BigSeries: 'крупный сериал', + }, + }, filter: { title: 'Правило набора', hint: 'Правило только ищет кандидатов — состав группы остаётся явным списком.',