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: 'Правило только ищет кандидатов — состав группы остаётся явным списком.',