Add group suggestions feature with API endpoints and frontend integration
ci / build-backend (push) Successful in 1m55s
ci / build-frontend (push) Successful in 44s
ci / tests (push) Successful in 2m8s
ci / sonar (push) Successful in 4m37s

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.
This commit is contained in:
Leonid Pershin
2026-07-27 04:18:30 +03:00
parent 4774083dc9
commit 24fdbdf680
18 changed files with 810 additions and 1 deletions
@@ -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<IReadOnlyList<GroupCandidateDto>>();
// Предложения: что имеет смысл завести группой при нынешней библиотеке.
admin.MapGet("/suggestions", Suggestions).Produces<IReadOnlyList<GroupSuggestionDto>>();
admin
.MapPost("/suggestions", CreateFromSuggestion)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPost("/{id:guid}/items", AddElements).Produces<AddedCountResponse>();
admin
.MapDelete("/{id:guid}/items/{itemId:guid}", RemoveItem)
@@ -80,6 +87,33 @@ public static class GroupEndpoints
: result.ToHttpResult();
}
private static async Task<IResult> Suggestions(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new SuggestGroupsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> 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<IResult> 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<GroupElementRef> Elements);
public sealed record GroupItemWeightBody(int Weight);
@@ -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<GenreMatcher>();
services.AddScoped<GroupElementResolver>();
services.AddScoped<GroupStatsService>();
services.AddScoped<GroupSuggestionBuilder>();
services.AddScoped<GroupMembershipCleaner>();
services.AddScoped<SlotWriter>();
services.AddScoped<GroupExpander>();
@@ -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",
"Группа с таким названием уже есть."
);
}
@@ -0,0 +1,11 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.Suggest;
/// <summary>
/// Создаёт группу по предложению: имя, правило набора и состав берутся с сервера по ключу, а не
/// приезжают с клиента. Так между показом предложения и нажатием кнопки библиотека может измениться —
/// в группу попадёт актуальное, а не то, что успело устареть на экране.
/// </summary>
public sealed record CreateGroupFromSuggestionCommand(string Key) : ICommand<Result<Guid>>;
@@ -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<CreateGroupFromSuggestionCommand, Result<Guid>>
{
public async Task<Result<Guid>> 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<Guid>(GroupErrors.SuggestionNotFound);
// Имя — ключ узнаваемости группы, и две «Мультфильм» в списке слота неразличимы.
var taken = await dbContext.Groups.AnyAsync(
g => g.Name.ToLower() == suggestion.Name.ToLower(),
cancellationToken
);
if (taken)
return Result.Failure<Guid>(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);
}
}
@@ -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;
/// <summary>
/// Предложение: готовая к созданию группа — имя, правило набора и состав. Ключ самоописателен
/// (<c>genre:{id}</c>, <c>kind:Series</c>, …), поэтому создание не тащит состав с клиента: команда
/// пересобирает предложение по ключу на свежих данных.
/// </summary>
public sealed record GroupSuggestion(
string Key,
GroupSuggestionKind Kind,
string Name,
GroupFilter? Filter,
IReadOnlyList<Guid> ShowIds,
int UnitCount,
TimeSpan TotalDuration
);
/// <summary>
/// Разбирает библиотеку и предлагает группы, которые имеет смысл завести. Правила намеренно простые
/// и объяснимые: админ должен понимать, почему ему это показали, — «12 шоу с жанром Мультфильм»
/// проверяемо, а рекомендательная магия нет.
///
/// Состав предложения — только шоу. Коллекции сюда не берутся: франшиза почти всегда состоит из тех
/// же шоу, и добавление обеих форм задваивало бы эфир. Правило набора у предложения сохраняется,
/// поэтому коллекции при желании добираются кнопкой «Подобрать» уже в самой группе.
/// </summary>
public sealed class GroupSuggestionBuilder(IAppDbContext dbContext, GroupElementResolver resolver)
{
/// <summary>Сколько шоу должно набраться, чтобы предлагать сборную группу (жанр/тип/рейтинг).</summary>
private const int MinShows = 2;
/// <summary>С какого числа серий сериал заслуживает собственной группы.</summary>
private const int BigSeriesEpisodes = 20;
/// <summary>Пороги «мягких» рейтингов: до них группа читается как «детское»/«семейное».</summary>
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<IReadOnlyList<GroupSuggestion>> 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<GroupSuggestion>();
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<GroupSuggestion> ByGenre(
List<ShowRow> shows,
IReadOnlyDictionary<Guid, string> 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<GroupSuggestion> ByShowKind(List<ShowRow> 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
);
}
}
/// <summary>
/// «Не строже X» — ровно та форма, которую понимает правило набора. Шоу без рейтинга в выборке
/// остаются (как в фильтре и в планировщике): источники проставляют рейтинг далеко не всему.
/// </summary>
private static IEnumerable<GroupSuggestion> ByAudience(List<ShowRow> 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
);
}
}
/// <summary>
/// Крупный сериал ставят в слот целиком, поэтому ему нужна собственная группа. Правила набора
/// у неё нет: «именно это шоу» фильтром не выражается, да и не нужно — состав из одной позиции.
/// </summary>
private static IEnumerable<GroupSuggestion> ByBigSeries(List<ShowRow> 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<ShowRow> 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<Guid> GenreIds,
int EpisodeCount
);
}
@@ -0,0 +1,17 @@
namespace TeleWave.Application.Programming.Groups.Suggest;
/// <summary>Откуда взялось предложение — UI по нему группирует список и подписывает происхождение.</summary>
public enum GroupSuggestionKind
{
/// <summary>Всё, что помечено одним жанром.</summary>
Genre = 0,
/// <summary>Всё одного типа: сериалы либо полнометражки.</summary>
ShowKind = 1,
/// <summary>Всё не строже указанного возрастного рейтинга.</summary>
Audience = 2,
/// <summary>Один крупный сериал, которого хватает на слот целиком.</summary>
BigSeries = 3,
}
@@ -0,0 +1,23 @@
using LiteCqrs;
namespace TeleWave.Application.Programming.Groups.Suggest;
/// <summary>Что имеет смысл завести группой при текущем составе библиотеки.</summary>
public sealed record SuggestGroupsQuery : IQuery<IReadOnlyList<GroupSuggestionDto>>;
/// <param name="Key">Ключ предложения — с ним же приходит команда создания.</param>
/// <param name="ShowCount">Сколько шоу попадёт в состав.</param>
/// <param name="UnitCount">Сколько единиц воспроизведения (серий/фильмов) они дают.</param>
/// <param name="TotalDurationSeconds">Объём готового эфира — по обработанным ассетам.</param>
/// <param name="AlreadyExists">
/// Такая группа уже есть (по имени либо по совпадающему правилу набора) — создавать повторно нечего.
/// </param>
public sealed record GroupSuggestionDto(
string Key,
GroupSuggestionKind Kind,
string Name,
int ShowCount,
int UnitCount,
double TotalDurationSeconds,
bool AlreadyExists
);
@@ -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<SuggestGroupsQuery, IReadOnlyList<GroupSuggestionDto>>
{
public async Task<IReadOnlyList<GroupSuggestionDto>> 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()))
)),
];
}
}