Implement dynamic group management features with exclusions and mode handling
ci / build-backend (push) Successful in 2m30s
ci / build-frontend (push) Successful in 43s
ci / tests (push) Successful in 2m36s
ci / sonar (push) Failing after 33s

Enhanced the group management system by introducing dynamic group capabilities, allowing for the exclusion of elements from dynamic groups. Updated the Group and GroupItem models to support a new GroupMode, enabling the distinction between static and dynamic groups. Implemented API endpoints for excluding elements and updated related services to handle group composition based on the selected mode. Improved the frontend to accommodate these changes, including new API functions and UI components for managing exclusions and group modes, thereby enhancing the overall user experience in group management.
This commit is contained in:
Leonid Pershin
2026-07-27 05:03:09 +03:00
parent 24fdbdf680
commit 91af589547
35 changed files with 2571 additions and 219 deletions
@@ -4,6 +4,7 @@ using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Groups.AddGroupElements;
using TeleWave.Application.Programming.Groups.CreateGroup;
using TeleWave.Application.Programming.Groups.DeleteGroup;
using TeleWave.Application.Programming.Groups.ExcludeGroupElement;
using TeleWave.Application.Programming.Groups.FindGroupCandidates;
using TeleWave.Application.Programming.Groups.GetGroup;
using TeleWave.Application.Programming.Groups.ListGroups;
@@ -12,6 +13,7 @@ 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.Domain.Programming;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
@@ -50,6 +52,9 @@ public static class GroupEndpoints
.Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
// Поправки к вычисленному составу динамической группы.
admin.MapPost("/{id:guid}/exclusions", Exclude).Produces(StatusCodes.Status204NoContent);
return app;
}
@@ -122,7 +127,7 @@ public static class GroupEndpoints
)
{
var result = await sender.Send(
new UpdateGroupCommand(id, body.Name, body.Description, body.Filter),
new UpdateGroupCommand(id, body.Name, body.Description, body.Filter, body.Mode),
cancellationToken
);
return result.ToHttpResult();
@@ -194,6 +199,20 @@ public static class GroupEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> Exclude(
Guid id,
ExcludeElementBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ExcludeGroupElementCommand(id, body.ElementKind, body.ElementId, body.Excluded),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Reorder(
Guid id,
ReorderGroupBody body,
@@ -209,7 +228,19 @@ public static class GroupEndpoints
}
}
public sealed record UpdateGroupBody(string Name, string? Description, GroupFilter? Filter);
public sealed record UpdateGroupBody(
string Name,
string? Description,
GroupFilter? Filter,
GroupMode? Mode = null
);
/// <summary>Исключить элемент из динамической группы либо вернуть его правилу.</summary>
public sealed record ExcludeElementBody(
GroupElementKind ElementKind,
Guid ElementId,
bool Excluded
);
public sealed record FindCandidatesBody(GroupFilter? Filter);
@@ -37,6 +37,8 @@ public static class DependencyInjection
services.AddScoped<BumperSpecLoader>();
services.AddScoped<GenreMatcher>();
services.AddScoped<GroupElementResolver>();
services.AddScoped<GroupFilterMatcher>();
services.AddScoped<DynamicGroupResolver>();
services.AddScoped<GroupStatsService>();
services.AddScoped<GroupSuggestionBuilder>();
services.AddScoped<GroupMembershipCleaner>();
@@ -0,0 +1,95 @@
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups;
/// <summary>Элемент вычисленного состава: чем он там оказался и с какими порядком/весом идёт в эфир.</summary>
/// <param name="Pinned">Закреплён руками (иначе — найден правилом).</param>
public sealed record ResolvedElement(
GroupElementKind Kind,
Guid Id,
int Position,
int Weight,
bool Pinned
);
/// <summary>
/// Считает состав группы. Статическая отдаёт свой список как есть; динамическая — то, что нашло
/// правило, плюс закреплённое и минус исключённое.
///
/// Порядок динамической группы: сначала закреплённое (в своём порядке — его задавали руками),
/// затем найденное правилом по алфавиту. Последовательным стратегиям нужен детерминированный
/// порядок, а у найденного правилом своего <c>Position</c> нет и быть не может: позиция живёт
/// на строке таблицы, а строки для него нет. Вес у найденного — единица: взвешивать имеет смысл
/// то, что человек выбрал руками.
/// </summary>
public sealed class DynamicGroupResolver(
GroupFilterMatcher matcher,
GroupElementResolver elementResolver
)
{
public async Task<IReadOnlyList<ResolvedElement>> ResolveAsync(
Group group,
CancellationToken cancellationToken
)
{
if (group.Mode != GroupMode.Dynamic)
return
[
.. group
.Items.OrderBy(i => i.Position)
.Select(i => new ResolvedElement(
i.ElementKind,
i.ElementId,
i.Position,
i.Weight,
false
)),
];
var pinned = group
.Items.Where(i => i.Role == GroupItemRole.Pinned)
.OrderBy(i => i.Position)
.ToList();
var excluded = group
.Items.Where(i => i.Role == GroupItemRole.Excluded)
.Select(i => (i.ElementKind, i.ElementId))
.ToHashSet();
var result = new List<ResolvedElement>();
var position = 0;
foreach (var item in pinned)
{
if (excluded.Contains((item.ElementKind, item.ElementId)))
continue;
result.Add(
new ResolvedElement(item.ElementKind, item.ElementId, position++, item.Weight, true)
);
}
// Без правила динамическая группа сводится к своим закреплённым — это не ошибка, а честное
// «правило ещё не задано»; пустоту потом покажет постпроверка расписания.
if (GroupFilter.FromJson(group.FilterJson) is not { } filter)
return result;
var taken = result.Select(r => (r.Kind, r.Id)).ToHashSet();
var matched = await matcher.MatchAsync(filter, cancellationToken);
var fresh = matched.Where(e => !excluded.Contains(e) && !taken.Contains(e)).ToList();
if (fresh.Count == 0)
return result;
// Разворот нужен и для отсева по длительности единицы, и для алфавитного порядка.
var info = await elementResolver.ResolveAsync(fresh, cancellationToken);
var ordered = fresh
.Where(e =>
info.TryGetValue(e, out var element)
&& GroupFilterMatcher.MatchesUnitDuration(filter, element)
)
.OrderBy(e => info[e].Name, StringComparer.CurrentCultureIgnoreCase)
.ToList();
foreach (var (kind, id) in ordered)
result.Add(new ResolvedElement(kind, id, position++, GroupItem.DefaultWeight, false));
return result;
}
}
@@ -0,0 +1,17 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups.ExcludeGroupElement;
/// <summary>
/// Исключить элемент из динамической группы (<paramref name="Excluded"/> = true) либо вернуть его
/// правилу. Удалить найденное правилом «насовсем» нельзя — на следующем обращении оно вернулось бы;
/// именно поэтому поправка хранится, а не применяется разово.
/// </summary>
public sealed record ExcludeGroupElementCommand(
Guid GroupId,
GroupElementKind ElementKind,
Guid ElementId,
bool Excluded
) : ICommand<Result>;
@@ -0,0 +1,36 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups.ExcludeGroupElement;
public sealed class ExcludeGroupElementCommandHandler(
IAppDbContext dbContext,
GroupStatsService stats
) : ICommandHandler<ExcludeGroupElementCommand, Result>
{
public async Task<Result> Handle(
ExcludeGroupElementCommand command,
CancellationToken cancellationToken
)
{
var group = await dbContext
.Groups.Include(g => g.Items)
.FirstOrDefaultAsync(g => g.Id == command.GroupId, cancellationToken);
if (group is null)
return Result.Failure(GroupErrors.NotFound);
if (group.Mode != GroupMode.Dynamic)
return Result.Failure(GroupErrors.ExclusionsForDynamicOnly);
if (command.Excluded)
group.Exclude(command.ElementKind, command.ElementId);
else
group.RemoveExclusion(command.ElementKind, command.ElementId);
await stats.RecomputeAsync(group, cancellationToken);
return Result.Success();
}
}
@@ -2,12 +2,12 @@ using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups.FindGroupCandidates;
public sealed class FindGroupCandidatesQueryHandler(
IAppDbContext dbContext,
GroupFilterMatcher matcher,
GroupElementResolver resolver
) : IQueryHandler<FindGroupCandidatesQuery, Result<IReadOnlyList<GroupCandidateDto>>>
{
@@ -27,36 +27,7 @@ public sealed class FindGroupCandidatesQueryHandler(
if (filter is null)
return Result.Failure<IReadOnlyList<GroupCandidateDto>>(GroupErrors.FilterNotSet);
var wantsShows =
filter.ElementKinds is null or { Count: 0 }
|| filter.ElementKinds.Contains(GroupElementKind.Show);
var wantsCollections =
filter.ElementKinds is null or { Count: 0 }
|| filter.ElementKinds.Contains(GroupElementKind.Collection);
var matchingShowIds = await MatchShowIdsAsync(filter, cancellationToken);
var elements = new List<(GroupElementKind Kind, Guid Id)>();
if (wantsShows)
elements.AddRange(matchingShowIds.Select(id => (GroupElementKind.Show, id)));
if (wantsCollections)
{
// Коллекция подходит, только если подходят все её части: иначе во «взрослую ночь» через
// франшизу просочилось бы детское, а в «боевики» — комедия из той же серии фильмов.
var collections = await dbContext
.Collections.AsNoTracking()
.Select(c => new { c.Id, ShowIds = c.Items.Select(i => i.ShowId).ToList() })
.ToListAsync(cancellationToken);
var matching = matchingShowIds.ToHashSet();
elements.AddRange(
collections
.Where(c => c.ShowIds.Count > 0 && c.ShowIds.All(matching.Contains))
.Select(c => (GroupElementKind.Collection, c.Id))
);
}
var elements = await matcher.MatchAsync(filter, cancellationToken);
var info = await resolver.ResolveAsync(elements, cancellationToken);
var inGroup = group.Items.Select(i => (i.ElementKind, i.ElementId)).ToHashSet();
@@ -65,7 +36,7 @@ public sealed class FindGroupCandidatesQueryHandler(
{
if (!info.TryGetValue((kind, id), out var element))
continue;
if (!MatchesUnitDuration(filter, element))
if (!GroupFilterMatcher.MatchesUnitDuration(filter, element))
continue;
result.Add(
@@ -87,54 +58,4 @@ public sealed class FindGroupCandidatesQueryHandler(
result.OrderBy(c => c.ElementName, StringComparer.CurrentCultureIgnoreCase).ToList()
);
}
private async Task<List<Guid>> MatchShowIdsAsync(
GroupFilter filter,
CancellationToken cancellationToken
)
{
var shows = dbContext.Shows.AsNoTracking();
if (filter.ShowKinds is { Count: > 0 } kinds)
shows = shows.Where(s => kinds.Contains(s.Kind));
// Рейтинги упорядочены по возрастанию строгости, поэтому «не строже» — обычное сравнение.
// Шоу без рейтинга остаются в выборке — ровно как в планировщике (ElementSelector): источники
// проставляют рейтинг далеко не всему, и молчаливое выбрасывание непроставленного оставило бы
// от фильтра «мультики не строже PG» горстку голливудского.
if (filter.MaxAudience is { } maxAudience)
shows = shows.Where(s => s.Audience == null || s.Audience <= maxAudience);
if (filter.YearMin is { } yearMin)
shows = shows.Where(s => s.Year != null && s.Year >= yearMin);
if (filter.YearMax is { } yearMax)
shows = shows.Where(s => s.Year != null && s.Year <= yearMax);
// Жанры — «любой из»: шоу редко подходит под все перечисленные сразу.
if (filter.GenreIds is { Count: > 0 } genreIds)
shows = shows.Where(s => s.Genres.Any(g => genreIds.Contains(g.GenreId)));
return await shows.Select(s => s.Id).ToListAsync(cancellationToken);
}
/// <summary>
/// Длительность проверяется по средней единице, а не по сумме: у сериала в фильтре «серии
/// по 20–25 минут» осмысленна именно длина серии. Элементы без готовых ассетов длительности
/// не имеют — их этот фильтр не отбрасывает, иначе из группы выпадало бы всё, что ещё
/// обрабатывается.
/// </summary>
private static bool MatchesUnitDuration(GroupFilter filter, GroupElementInfo element)
{
if (filter.UnitMinutesMin is null && filter.UnitMinutesMax is null)
return true;
if (element.UnitCount == 0 || element.TotalDuration <= TimeSpan.Zero)
return true;
var averageMinutes = element.TotalDuration.TotalMinutes / element.UnitCount;
if (filter.UnitMinutesMin is { } min && averageMinutes < min)
return false;
if (filter.UnitMinutesMax is { } max && averageMinutes > max)
return false;
return true;
}
}
@@ -2,11 +2,15 @@ using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups.GetGroup;
public sealed class GetGroupQueryHandler(IAppDbContext dbContext, GroupElementResolver resolver)
: IQueryHandler<GetGroupQuery, Result<GroupDto>>
public sealed class GetGroupQueryHandler(
IAppDbContext dbContext,
GroupElementResolver resolver,
DynamicGroupResolver dynamicResolver
) : IQueryHandler<GetGroupQuery, Result<GroupDto>>
{
public async Task<Result<GroupDto>> Handle(
GetGroupQuery query,
@@ -20,47 +24,98 @@ public sealed class GetGroupQueryHandler(IAppDbContext dbContext, GroupElementRe
if (group is null)
return Result.Failure<GroupDto>(GroupErrors.NotFound);
// Состав считаем на месте: у динамической группы он живёт в правиле, и показать список
// позиций вместо него значило бы показать одни поправки.
var composition = await dynamicResolver.ResolveAsync(group, cancellationToken);
var info = await resolver.ResolveAsync(
group.Items.Select(i => (i.ElementKind, i.ElementId)),
composition.Select(e => (e.Kind, e.Id)),
cancellationToken
);
var items = group
.Items.OrderBy(i => i.Position)
.Select(i =>
// Идентификатор позиции есть только у ручных поправок: найденному правилом строки в таблице
// не отвечает, и вес с порядком ему не правят — у него их нет.
var itemIds = group
.Items.Where(i => i.Role != GroupItemRole.Excluded)
.ToDictionary(i => (i.ElementKind, i.ElementId), i => i.Id);
var items = composition
.Select(element =>
{
info.TryGetValue((i.ElementKind, i.ElementId), out var element);
info.TryGetValue((element.Kind, element.Id), out var resolved);
return new GroupItemDto(
i.Id,
i.ElementKind,
i.ElementId,
itemIds.TryGetValue((element.Kind, element.Id), out var itemId) ? itemId : null,
element.Kind,
element.Id,
// Элемент мог исчезнуть из библиотеки между чисткой и чтением — показываем прочерк,
// а не роняем весь экран группы.
element?.Name
resolved?.Name
?? "—",
i.Weight,
i.Position,
element?.UnitCount ?? 0,
element?.ShowKind,
element?.Audience,
element?.Year,
element?.PosterImageId
element.Weight,
element.Position,
resolved?.UnitCount ?? 0,
resolved?.ShowKind,
resolved?.Audience,
resolved?.Year,
resolved?.PosterImageId,
element.Pinned
);
})
.ToList();
var excluded = await ExcludedAsync(group, cancellationToken);
return Result.Success(
new GroupDto(
group.Id,
group.Name,
group.Description,
group.Mode,
GroupFilter.FromJson(group.FilterJson),
group.ItemCount,
group.UnitCount,
group.TotalDuration.TotalSeconds,
items.Count,
items.Sum(i => i.UnitCount),
// Цифры карточки считаем по свежему составу, а не по кэшу: кэш пишут команды,
// запросу состояние менять нельзя, а показывать устаревшее рядом с актуальным
// списком — вводить в заблуждение.
composition
.Select(e =>
info.TryGetValue((e.Kind, e.Id), out var r)
? r.TotalDuration
: TimeSpan.Zero
)
.Aggregate(TimeSpan.Zero, (sum, d) => sum + d)
.TotalSeconds,
group.StatsComputedAt,
items
items,
excluded
)
);
}
/// <summary>Исключённое показываем отдельным списком: иначе непонятно, почему шоу нет в составе.</summary>
private async Task<IReadOnlyList<GroupExcludedDto>> ExcludedAsync(
Domain.Programming.Group group,
CancellationToken cancellationToken
)
{
var excluded = group
.Items.Where(i => i.Role == GroupItemRole.Excluded)
.OrderBy(i => i.Position)
.ToList();
if (excluded.Count == 0)
return [];
var info = await resolver.ResolveAsync(
excluded.Select(i => (i.ElementKind, i.ElementId)),
cancellationToken
);
return
[
.. excluded.Select(i =>
{
info.TryGetValue((i.ElementKind, i.ElementId), out var element);
return new GroupExcludedDto(i.ElementKind, i.ElementId, element?.Name ?? "—");
}),
];
}
}
@@ -12,16 +12,22 @@ public sealed record GroupSummaryDto(
int UnitCount,
double TotalDurationSeconds,
bool HasFilter,
GroupMode Mode,
DateTimeOffset? StatsComputedAt,
DateTimeOffset CreatedAt
);
/// <summary>
/// Позиция группы с данными элемента. <paramref name="UnitCount"/> — сколько единиц воспроизведения
/// Позиция состава с данными элемента. <paramref name="UnitCount"/> — сколько единиц воспроизведения
/// она даёт: у фильма одна, у сериала и коллекции больше.
/// </summary>
/// <param name="Id">
/// Идентификатор строки в таблице позиций; null — элемент найден правилом динамической группы,
/// своей строки у него нет (а значит, нет и веса с порядком, которые можно править).
/// </param>
/// <param name="Pinned">Закреплён руками в динамической группе — правило его не выбрасывает.</param>
public sealed record GroupItemDto(
Guid Id,
Guid? Id,
GroupElementKind ElementKind,
Guid ElementId,
string ElementName,
@@ -31,19 +37,29 @@ public sealed record GroupItemDto(
ShowKind? ShowKind,
ShowAudience? Audience,
int? Year,
Guid? PosterImageId
Guid? PosterImageId,
bool Pinned
);
/// <summary>Элемент, исключённый из динамической группы вручную.</summary>
public sealed record GroupExcludedDto(
GroupElementKind ElementKind,
Guid ElementId,
string ElementName
);
public sealed record GroupDto(
Guid Id,
string Name,
string? Description,
GroupMode Mode,
GroupFilter? Filter,
int ItemCount,
int UnitCount,
double TotalDurationSeconds,
DateTimeOffset? StatsComputedAt,
IReadOnlyList<GroupItemDto> Items
IReadOnlyList<GroupItemDto> Items,
IReadOnlyList<GroupExcludedDto> Excluded
);
/// <summary>Кандидат, найденный правилом набора: ещё не в группе, но подходит под фильтр.</summary>
@@ -26,6 +26,11 @@ public static class GroupErrors
"У группы не задано правило набора."
);
public static readonly Error ExclusionsForDynamicOnly = Error.Validation(
"Groups.ExclusionsForDynamicOnly",
"Исключения есть только у динамической группы — в статической состав правится списком."
);
public static readonly Error SuggestionNotFound = Error.NotFound(
"Groups.SuggestionNotFound",
"Предложение устарело — библиотека изменилась. Обновите список предложений."
@@ -0,0 +1,106 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups;
/// <summary>
/// Применяет правило набора к библиотеке. Одно место на всех: по нему ищет кандидатов подбор в
/// карточке группы и по нему же считается состав динамической группы — расхождение между «что
/// показал подбор» и «что ушло в эфир» было бы неотлаживаемым.
///
/// Длительность единицы правило проверяет уже по развёрнутым данным
/// (<see cref="GroupElementResolver"/>), поэтому этот шаг вынесен в <see cref="MatchesUnitDuration"/>
/// и применяется вызывающей стороной после разворота.
/// </summary>
public sealed class GroupFilterMatcher(IAppDbContext dbContext)
{
/// <summary>Элементы (шоу и коллекции), подходящие под правило, — без проверки длительности.</summary>
public async Task<IReadOnlyList<(GroupElementKind Kind, Guid Id)>> MatchAsync(
GroupFilter filter,
CancellationToken cancellationToken
)
{
var wantsShows =
filter.ElementKinds is null or { Count: 0 }
|| filter.ElementKinds.Contains(GroupElementKind.Show);
var wantsCollections =
filter.ElementKinds is null or { Count: 0 }
|| filter.ElementKinds.Contains(GroupElementKind.Collection);
var showIds = await MatchShowIdsAsync(filter, cancellationToken);
var elements = new List<(GroupElementKind, Guid)>();
if (wantsShows)
elements.AddRange(showIds.Select(id => (GroupElementKind.Show, id)));
if (wantsCollections)
{
// Коллекция подходит, только если подходят все её части: иначе во «взрослую ночь» через
// франшизу просочилось бы детское, а в «боевики» — комедия из той же серии фильмов.
var collections = await dbContext
.Collections.AsNoTracking()
.Select(c => new { c.Id, ShowIds = c.Items.Select(i => i.ShowId).ToList() })
.ToListAsync(cancellationToken);
var matching = showIds.ToHashSet();
elements.AddRange(
collections
.Where(c => c.ShowIds.Count > 0 && c.ShowIds.All(matching.Contains))
.Select(c => (GroupElementKind.Collection, c.Id))
);
}
return elements;
}
private async Task<List<Guid>> MatchShowIdsAsync(
GroupFilter filter,
CancellationToken cancellationToken
)
{
var shows = dbContext.Shows.AsNoTracking();
if (filter.ShowKinds is { Count: > 0 } kinds)
shows = shows.Where(s => kinds.Contains(s.Kind));
// Рейтинги упорядочены по возрастанию строгости, поэтому «не строже» — обычное сравнение.
// Шоу без рейтинга остаются в выборке — ровно как в планировщике (ElementSelector): источники
// проставляют рейтинг далеко не всему, и молчаливое выбрасывание непроставленного оставило бы
// от фильтра «мультики не строже PG» горстку голливудского.
if (filter.MaxAudience is { } maxAudience)
shows = shows.Where(s => s.Audience == null || s.Audience <= maxAudience);
if (filter.YearMin is { } yearMin)
shows = shows.Where(s => s.Year != null && s.Year >= yearMin);
if (filter.YearMax is { } yearMax)
shows = shows.Where(s => s.Year != null && s.Year <= yearMax);
// Жанры — «любой из»: шоу редко подходит под все перечисленные сразу.
if (filter.GenreIds is { Count: > 0 } genreIds)
shows = shows.Where(s => s.Genres.Any(g => genreIds.Contains(g.GenreId)));
return await shows.Select(s => s.Id).ToListAsync(cancellationToken);
}
/// <summary>
/// Длительность проверяется по средней единице, а не по сумме: у сериала в фильтре «серии
/// по 20–25 минут» осмысленна именно длина серии. Элементы без готовых ассетов длительности
/// не имеют — их этот фильтр не отбрасывает, иначе из группы выпадало бы всё, что ещё
/// обрабатывается.
/// </summary>
public static bool MatchesUnitDuration(GroupFilter filter, GroupElementInfo element)
{
if (filter.UnitMinutesMin is null && filter.UnitMinutesMax is null)
return true;
if (element.UnitCount == 0 || element.TotalDuration <= TimeSpan.Zero)
return true;
var averageMinutes = element.TotalDuration.TotalMinutes / element.UnitCount;
if (filter.UnitMinutesMin is { } min && averageMinutes < min)
return false;
if (filter.UnitMinutesMax is { } max && averageMinutes > max)
return false;
return true;
}
}
@@ -8,26 +8,33 @@ namespace TeleWave.Application.Programming.Groups;
/// Пересчёт кэша статистики группы. Вызывается при каждой правке состава: цифры «342 позиции ·
/// 118 ч» видны прямо в инспекторе слота, и устаревшая статистика там хуже, чем её отсутствие.
/// </summary>
public sealed class GroupStatsService(IAppDbContext dbContext, GroupElementResolver resolver)
public sealed class GroupStatsService(
IAppDbContext dbContext,
GroupElementResolver resolver,
DynamicGroupResolver dynamicResolver
)
{
public async Task RecomputeAsync(Group group, CancellationToken cancellationToken)
{
// Считаем по вычисленному составу, а не по списку позиций: у динамической группы список —
// это только поправки, и «342 позиции» из него получились бы неправдой.
var composition = await dynamicResolver.ResolveAsync(group, cancellationToken);
var info = await resolver.ResolveAsync(
group.Items.Select(i => (i.ElementKind, i.ElementId)),
composition.Select(e => (e.Kind, e.Id)),
cancellationToken
);
var unitCount = 0;
var totalDuration = TimeSpan.Zero;
foreach (var item in group.Items)
foreach (var element in composition)
{
if (!info.TryGetValue((item.ElementKind, item.ElementId), out var element))
if (!info.TryGetValue((element.Kind, element.Id), out var resolved))
continue;
unitCount += element.UnitCount;
totalDuration += element.TotalDuration;
unitCount += resolved.UnitCount;
totalDuration += resolved.TotalDuration;
}
group.UpdateStats(group.Items.Count, unitCount, totalDuration, DateTimeOffset.UtcNow);
group.UpdateStats(composition.Count, unitCount, totalDuration, DateTimeOffset.UtcNow);
}
/// <summary>Пересчитать по идентификатору, загрузив состав. Возвращает false, если группы нет.</summary>
@@ -12,7 +12,10 @@ public sealed class ListGroupsQueryHandler(IAppDbContext dbContext)
CancellationToken cancellationToken
)
{
// Список читает кэш статистики, не пересчитывая его: пересчёт идёт при правке состава.
// Список читает кэш статистики, не пересчитывая его: пересчёт идёт при правке состава и при
// открытии карточки. У динамической группы кэш поэтому может отставать от библиотеки —
// рядом с цифрами показывается время расчёта (StatsComputedAt), и это честнее, чем гонять
// правила всех групп на каждый показ списка.
return await dbContext
.Groups.AsNoTracking()
.OrderBy(g => g.Name)
@@ -24,6 +27,7 @@ public sealed class ListGroupsQueryHandler(IAppDbContext dbContext)
g.UnitCount,
g.TotalDuration.TotalSeconds,
g.FilterJson != null,
g.Mode,
g.StatsComputedAt,
g.CreatedAt
))
@@ -1,13 +1,17 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups.UpdateGroup;
/// <summary>Имя, описание и правило набора. <paramref name="Filter"/> = null снимает правило;
/// состав группы при этом не меняется.</summary>
/// <summary>
/// Имя, описание, правило набора и режим. <paramref name="Filter"/> = null снимает правило;
/// состав группы при этом не меняется. <paramref name="Mode"/> = null оставляет режим как есть.
/// </summary>
public sealed record UpdateGroupCommand(
Guid GroupId,
string Name,
string? Description,
GroupFilter? Filter
GroupFilter? Filter,
GroupMode? Mode = null
) : ICommand<Result>;
@@ -2,26 +2,57 @@ using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups.UpdateGroup;
public sealed class UpdateGroupCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateGroupCommand, Result>
public sealed class UpdateGroupCommandHandler(
IAppDbContext dbContext,
DynamicGroupResolver dynamicResolver,
GroupStatsService stats
) : ICommandHandler<UpdateGroupCommand, Result>
{
public async Task<Result> Handle(
UpdateGroupCommand command,
CancellationToken cancellationToken
)
{
var group = await dbContext.Groups.FirstOrDefaultAsync(
g => g.Id == command.GroupId,
cancellationToken
);
var group = await dbContext
.Groups.Include(g => g.Items)
.FirstOrDefaultAsync(g => g.Id == command.GroupId, cancellationToken);
if (group is null)
return Result.Failure(GroupErrors.NotFound);
group.Rename(command.Name, command.Description);
group.SetFilter(command.Filter?.ToJson());
if (command.Mode is { } mode && mode != group.Mode)
await SwitchModeAsync(group, mode, cancellationToken);
await stats.RecomputeAsync(group, cancellationToken);
return Result.Success();
}
/// <summary>
/// Переход в статический режим материализует вычисленный состав: правило домен не понимает,
/// а группа не должна опустеть от смены режима — на неё уже ссылаются слоты. Обратный переход
/// домен делает сам (список становится закреплённым).
/// </summary>
private async Task SwitchModeAsync(
Domain.Programming.Group group,
GroupMode mode,
CancellationToken cancellationToken
)
{
if (mode == GroupMode.Dynamic)
{
group.SetMode(mode);
return;
}
var composition = await dynamicResolver.ResolveAsync(group, cancellationToken);
group.SetMode(mode);
foreach (var element in composition)
group.AddElement(element.Kind, element.Id);
}
}
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Groups;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
@@ -16,7 +17,7 @@ namespace TeleWave.Application.Programming.Planning;
/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё
/// обрабатывается, значит получить дыру в раздаче.
/// </summary>
public sealed class GroupExpander(IAppDbContext dbContext)
public sealed class GroupExpander(IAppDbContext dbContext, DynamicGroupResolver dynamicResolver)
{
/// <param name="historyFrom">С какого момента нужна история показов для потолка повторов;
/// null — история не нужна и не загружается.</param>
@@ -37,10 +38,17 @@ public sealed class GroupExpander(IAppDbContext dbContext)
.Where(g => groupIds.Contains(g.Id))
.ToListAsync(cancellationToken);
var collectionIds = groups
.SelectMany(g => g.Items)
.Where(i => i.ElementKind == GroupElementKind.Collection)
.Select(i => i.ElementId)
// Состав считается здесь, перед самым разворотом: у динамической группы он живёт не в
// списке позиций, а в правиле, и должен быть свежим на момент прогона.
var composition = new Dictionary<Guid, IReadOnlyList<ResolvedElement>>();
foreach (var group in groups)
composition[group.Id] = await dynamicResolver.ResolveAsync(group, cancellationToken);
var elements = composition.Values.SelectMany(c => c).ToList();
var collectionIds = elements
.Where(e => e.Kind == GroupElementKind.Collection)
.Select(e => e.Id)
.Distinct()
.ToList();
@@ -51,10 +59,9 @@ public sealed class GroupExpander(IAppDbContext dbContext)
.Select(i => new { i.CollectionId, i.ShowId })
.ToListAsync(cancellationToken);
var showIds = groups
.SelectMany(g => g.Items)
.Where(i => i.ElementKind == GroupElementKind.Show)
.Select(i => i.ElementId)
var showIds = elements
.Where(e => e.Kind == GroupElementKind.Show)
.Select(e => e.Id)
.Concat(collectionParts.Select(p => p.ShowId))
.Distinct()
.ToList();
@@ -96,15 +103,17 @@ public sealed class GroupExpander(IAppDbContext dbContext)
collectionParts.ToLookup(p => p.CollectionId, p => p.ShowId)
);
foreach (var group in groups)
result[group.Id] = group
.Items.OrderBy(i => i.Position)
.Select(item =>
item.ElementKind == GroupElementKind.Show
? data.ShowElement(item)
: data.CollectionElement(item)
)
.ToList();
foreach (var (groupId, resolved) in composition)
result[groupId] =
[
.. resolved
.OrderBy(e => e.Position)
.Select(element =>
element.Kind == GroupElementKind.Show
? data.ShowElement(element)
: data.CollectionElement(element)
),
];
return result;
}
@@ -125,16 +134,16 @@ public sealed class GroupExpander(IAppDbContext dbContext)
ILookup<Guid, Guid> CollectionParts
)
{
public PlanningElement ShowElement(GroupItem item) =>
public PlanningElement ShowElement(ResolvedElement element) =>
new(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
UnitsOf(item.ElementId),
LastPlayedOf(item.ElementId),
AudienceOf(item.ElementId),
RecentPlaysOf(item.ElementId)
element.Kind,
element.Id,
element.Weight,
element.Position,
UnitsOf(element.Id),
LastPlayedOf(element.Id),
AudienceOf(element.Id),
RecentPlaysOf(element.Id)
);
/// <summary>
@@ -142,14 +151,14 @@ public sealed class GroupExpander(IAppDbContext dbContext)
/// части (показ любой означает, что франшиза недавно была в эфире); рейтинг — строжайший
/// (франшиза идёт целиком, и одна взрослая часть делает взрослой всю).
/// </summary>
public PlanningElement CollectionElement(GroupItem item)
public PlanningElement CollectionElement(ResolvedElement element)
{
var parts = CollectionParts[item.ElementId].ToList();
var parts = CollectionParts[element.Id].ToList();
return new PlanningElement(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
element.Kind,
element.Id,
element.Weight,
element.Position,
parts.SelectMany(UnitsOf).ToList(),
parts.Select(LastPlayedOf).Where(p => p is not null).DefaultIfEmpty(null).Max(),
parts.Select(AudienceOf).Where(a => a is not null).DefaultIfEmpty(null).Max(),
@@ -4,10 +4,20 @@ namespace TeleWave.Domain.Programming;
/// Группа контента — «что может попасть в эфир». Слот планировщика ссылается на группу, стратегия
/// выбирает из неё элемент. Группы общие для всех каналов и переиспользуются.
///
/// Состав — всегда явный список (<see cref="Items"/>), а <see cref="FilterJson"/> — лишь правило
/// быстрого набора: «применить фильтр» добавляет найденное в список, дальше состав правится руками.
/// Живой запрос при генерации был бы проще в коде, но тогда добавление одного шоу в библиотеку
/// перетасовывало бы всё будущее расписание всех каналов.
/// Откуда берётся состав, решает <see cref="Mode"/>:
///
/// • <see cref="GroupMode.Static"/> — состав это явный список (<see cref="Items"/>), а
/// <see cref="FilterJson"/> лишь помогает его наполнить: «применить фильтр» добавляет найденное
/// в список, дальше он правится руками.
///
/// • <see cref="GroupMode.Dynamic"/> — состав вычисляет правило в момент обращения, а список хранит
/// только ручные поправки к нему: закреплённое (<see cref="GroupItemRole.Pinned"/>) и исключённое
/// (<see cref="GroupItemRole.Excluded"/>). Само правило домен по-прежнему не интерпретирует —
/// вычисление живёт в Application.
///
/// Уже материализованное расписание в обоих режимах не переписывается: изменение состава проявится
/// на следующем прогоне для ещё не сгенерированных суток. Иначе добавление одного шоу в библиотеку
/// перетасовывало бы будущий эфир всех каналов.
/// </summary>
public class Group
{
@@ -20,6 +30,9 @@ public class Group
/// <summary>Правило набора (JSON) или null. Домен его не интерпретирует — схема живёт в Application.</summary>
public string? FilterJson { get; private set; }
/// <summary>Откуда берётся состав: явный список либо вычисление правилом.</summary>
public GroupMode Mode { get; private set; }
// ── Кэш статистики: считается при правке состава и фоново, нужен UI («342 позиции · 118 ч») ──
/// <summary>Число позиций в группе.</summary>
@@ -59,6 +72,33 @@ public class Group
public void SetFilter(string? filterJson) =>
FilterJson = string.IsNullOrWhiteSpace(filterJson) ? null : filterJson;
/// <summary>
/// Сменить режим. Переключения не должны менять того, что уже идёт в эфире, поэтому состав
/// переносится, а не теряется: при переходе в динамический список позиций становится
/// закреплённым (правило добавит к нему остальное), при обратном переходе закреплённое
/// становится обычными позициями, а исключения снимаются — исключать больше нечего.
///
/// Вычисленный правилом состав при переходе в статический режим сюда не переносится: домен
/// правило не интерпретирует. Материализует его вызывающая сторона до смены режима.
/// </summary>
public void SetMode(GroupMode mode)
{
if (Mode == mode)
return;
Mode = mode;
if (mode == GroupMode.Dynamic)
{
foreach (var item in _items)
item.SetRole(GroupItemRole.Pinned);
return;
}
_items.RemoveAll(i => i.Role == GroupItemRole.Excluded);
foreach (var item in _items)
item.SetRole(GroupItemRole.Member);
}
/// <summary>Записать пересчитанную статистику.</summary>
public void UpdateStats(int itemCount, int unitCount, TimeSpan totalDuration, DateTimeOffset at)
{
@@ -71,18 +111,55 @@ public class Group
public bool Contains(GroupElementKind kind, Guid elementId) =>
_items.Any(i => i.ElementKind == kind && i.ElementId == elementId);
/// <summary>Добавляет элемент в конец. Повторное добавление игнорируется — возвращает null.</summary>
public GroupItem? AddElement(GroupElementKind kind, Guid elementId)
/// <summary>
/// Добавляет элемент в конец. Повторное добавление игнорируется — возвращает null. Роль по
/// умолчанию зависит от режима: в динамической группе ручное добавление означает «закрепить»,
/// иначе правило вымело бы позицию на первом же обращении.
/// </summary>
public GroupItem? AddElement(GroupElementKind kind, Guid elementId, GroupItemRole? role = null)
{
if (Contains(kind, elementId))
return null;
var nextPosition = _items.Count == 0 ? 0 : _items.Max(i => i.Position) + 1;
var item = GroupItem.Create(Id, kind, elementId, nextPosition);
var item = GroupItem.Create(Id, kind, elementId, nextPosition, role ?? DefaultRole);
_items.Add(item);
return item;
}
private GroupItemRole DefaultRole =>
Mode == GroupMode.Dynamic ? GroupItemRole.Pinned : GroupItemRole.Member;
/// <summary>
/// Исключить элемент из динамической группы. Закрепление, если оно было, снимается: держать
/// на один элемент обе противоположные поправки нельзя. Возвращает false, если группа
/// статическая — там состав правится списком, а исключать из списка нечего.
/// </summary>
public bool Exclude(GroupElementKind kind, Guid elementId)
{
if (Mode != GroupMode.Dynamic)
return false;
var existing = _items.FirstOrDefault(i =>
i.ElementKind == kind && i.ElementId == elementId
);
if (existing is not null)
{
existing.SetRole(GroupItemRole.Excluded);
return true;
}
var nextPosition = _items.Count == 0 ? 0 : _items.Max(i => i.Position) + 1;
_items.Add(GroupItem.Create(Id, kind, elementId, nextPosition, GroupItemRole.Excluded));
return true;
}
/// <summary>Снять исключение — элемент вернётся в состав, если его находит правило.</summary>
public bool RemoveExclusion(GroupElementKind kind, Guid elementId) =>
_items.RemoveAll(i =>
i.Role == GroupItemRole.Excluded && i.ElementKind == kind && i.ElementId == elementId
) > 0;
public bool RemoveItem(Guid itemId)
{
var item = _items.FirstOrDefault(i => i.Id == itemId);
@@ -13,6 +13,9 @@ public class GroupItem
public GroupElementKind ElementKind { get; private set; }
public Guid ElementId { get; private set; }
/// <summary>Чем эта позиция является для группы — см. <see cref="GroupItemRole"/>.</summary>
public GroupItemRole Role { get; private set; }
/// <summary>
/// Вес при случайном выборе внутри группы (по умолчанию 1). Не конфликтует с остыванием, если
/// применять их в правильном порядке: сначала остывание отсекает недавно показанное, затем
@@ -31,7 +34,8 @@ public class GroupItem
Guid groupId,
GroupElementKind elementKind,
Guid elementId,
int position
int position,
GroupItemRole role = GroupItemRole.Member
) =>
new()
{
@@ -39,10 +43,13 @@ public class GroupItem
GroupId = groupId,
ElementKind = elementKind,
ElementId = elementId,
Role = role,
Weight = DefaultWeight,
Position = position,
};
internal void SetRole(GroupItemRole role) => Role = role;
internal void SetPosition(int position) => Position = position;
internal void SetWeight(int weight) => Weight = Math.Max(0, weight);
@@ -0,0 +1,17 @@
namespace TeleWave.Domain.Programming;
/// <summary>
/// Роль позиции в группе. У статической группы все позиции — <see cref="Member"/>: список и есть
/// состав. У динамической состав считает правило, а позиции хранят ручные поправки к нему.
/// </summary>
public enum GroupItemRole
{
/// <summary>Обычная позиция статической группы.</summary>
Member = 0,
/// <summary>Всегда в составе динамической группы, даже если правило её не находит.</summary>
Pinned = 1,
/// <summary>Никогда не в составе динамической группы, даже если правило её находит.</summary>
Excluded = 2,
}
@@ -0,0 +1,14 @@
namespace TeleWave.Domain.Programming;
/// <summary>Откуда группа берёт состав.</summary>
public enum GroupMode
{
/// <summary>Явный список позиций. Правило набора — лишь помощник наполнения.</summary>
Static = 0,
/// <summary>
/// Состав вычисляется правилом в момент обращения (генерация расписания, показ карточки).
/// Позиции при этом хранят только ручные поправки: закреплённое и исключённое.
/// </summary>
Dynamic = 1,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class DynamicGroups : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Mode",
table: "Groups",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<int>(
name: "Role",
table: "GroupItems",
type: "integer",
nullable: false,
defaultValue: 0
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "Mode", table: "Groups");
migrationBuilder.DropColumn(name: "Role", table: "GroupItems");
}
}
}
@@ -803,6 +803,11 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<int>("ItemCount")
.HasColumnType("integer");
b.Property<int>("Mode")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
@@ -839,6 +844,11 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("Role")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.Property<int>("Weight")
.HasColumnType("integer");
@@ -15,6 +15,8 @@ public class GroupConfiguration : IEntityTypeConfiguration<Group>
// Правило набора хранится как jsonb: схему знает Application, а домен передаёт строку как есть.
builder.Property(x => x.FilterJson).HasColumnType("jsonb");
builder.Property(x => x.Mode).HasDefaultValue(GroupMode.Static);
builder
.HasMany(x => x.Items)
.WithOne()
@@ -42,5 +44,9 @@ public class GroupItemConfiguration : IEntityTypeConfiguration<GroupItem>
// По этому индексу чистятся позиции при удалении шоу/коллекции: внешнего ключа на
// полиморфную ссылку нет, удаление идёт командой.
builder.HasIndex(x => new { x.ElementKind, x.ElementId });
// Роль читается при каждом развороте динамической группы — отдельным индексом не выделяем,
// выборка всегда идёт по группе целиком.
builder.Property(x => x.Role).HasDefaultValue(GroupItemRole.Member);
}
}
@@ -22,7 +22,7 @@ public class QueryHandlersTests
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static GroupMembershipCleaner GroupCleaner(IAppDbContext db) =>
new(db, new GroupStatsService(db, new GroupElementResolver(db)));
new(db, GroupServices.Stats(db));
[Fact]
public async Task GetChannel_UnknownId_ReturnsNotFound()
@@ -25,7 +25,7 @@ public class DeleteGuardsTests
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static GroupMembershipCleaner GroupCleaner(IAppDbContext db) =>
new(db, new GroupStatsService(db, new GroupElementResolver(db)));
new(db, GroupServices.Stats(db));
[Fact]
public async Task DeleteShow_InFutureSchedule_Fails()
@@ -0,0 +1,237 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Groups.ExcludeGroupElement;
using TeleWave.Application.Programming.Groups.UpdateGroup;
using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
/// <summary>
/// Динамическая группа: состав считает правило, а список позиций хранит только ручные поправки.
/// Проверяем именно то, что уходит в эфир, — иначе расхождение между показанным и сыгранным.
/// </summary>
public class DynamicGroupsTests
{
[Fact]
public async Task Composition_IsRuleResult_PinnedFirstThenAlphabetical()
{
var fixture = new TestDb();
var genre = Genre.Create("Мультфильм", "animation");
var beta = WithGenre(Show.Create("Бета", ShowKind.Series), genre);
var alpha = WithGenre(Show.Create("Альфа", ShowKind.Series), genre);
// Закреплённое правилу не подходит, но остаётся в составе — и идёт первым.
var pinned = Show.Create("Закреплённое", ShowKind.Single);
var group = Group.Create("Мультфильмы");
group.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson());
group.AddElement(GroupElementKind.Show, pinned.Id);
group.SetMode(GroupMode.Dynamic);
await using (var seed = fixture.New())
{
seed.Genres.Add(genre);
seed.Shows.AddRange(alpha, beta, pinned);
seed.Groups.Add(group);
await seed.SaveChangesAsync(CancellationToken.None);
}
var composition = await ResolveAsync(fixture, group.Id);
Assert.Equal([pinned.Id, alpha.Id, beta.Id], composition.Select(e => e.Id));
Assert.True(composition[0].Pinned);
Assert.All(composition.Skip(1), e => Assert.False(e.Pinned));
}
[Fact]
public async Task Excluded_DropsOutOfComposition()
{
var fixture = new TestDb();
var genre = Genre.Create("Мультфильм", "animation");
var keep = WithGenre(Show.Create("Остаётся", ShowKind.Series), genre);
var drop = WithGenre(Show.Create("Выкинуть", ShowKind.Series), genre);
var group = Group.Create("Мультфильмы");
group.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson());
group.SetMode(GroupMode.Dynamic);
await using (var seed = fixture.New())
{
seed.Genres.Add(genre);
seed.Shows.AddRange(keep, drop);
seed.Groups.Add(group);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using (var db = fixture.New())
{
var result = await new ExcludeGroupElementCommandHandler(
db,
GroupServices.Stats(db)
).Handle(
new ExcludeGroupElementCommand(group.Id, GroupElementKind.Show, drop.Id, true),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
var composition = await ResolveAsync(fixture, group.Id);
Assert.Equal([keep.Id], composition.Select(e => e.Id));
// Снятие исключения возвращает элемент правилу.
await using (var db = fixture.New())
{
await new ExcludeGroupElementCommandHandler(db, GroupServices.Stats(db)).Handle(
new ExcludeGroupElementCommand(group.Id, GroupElementKind.Show, drop.Id, false),
CancellationToken.None
);
await db.SaveChangesAsync(CancellationToken.None);
}
Assert.Equal(2, (await ResolveAsync(fixture, group.Id)).Count);
}
[Fact]
public async Task SwitchToDynamic_KeepsExistingItemsAsPinned()
{
// Смена режима не должна вымывать из эфира то, что уже стояло в слотах.
var fixture = new TestDb();
var show = Show.Create("A", ShowKind.Series);
var group = Group.Create("Группа");
group.AddElement(GroupElementKind.Show, show.Id);
await using (var seed = fixture.New())
{
seed.Shows.Add(show);
seed.Groups.Add(group);
await seed.SaveChangesAsync(CancellationToken.None);
}
await UpdateModeAsync(fixture, group.Id, GroupMode.Dynamic);
await using var check = fixture.New();
var stored = check.Groups.Include(g => g.Items).Single();
Assert.Equal(GroupMode.Dynamic, stored.Mode);
Assert.Equal(GroupItemRole.Pinned, stored.Items.Single().Role);
}
[Fact]
public async Task SwitchToStatic_MaterializesComputedComposition()
{
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);
var group = Group.Create("Мультфильмы");
group.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson());
group.SetMode(GroupMode.Dynamic);
await using (var seed = fixture.New())
{
seed.Genres.Add(genre);
seed.Shows.AddRange(first, second);
seed.Groups.Add(group);
await seed.SaveChangesAsync(CancellationToken.None);
}
await UpdateModeAsync(fixture, group.Id, GroupMode.Static);
await using var check = fixture.New();
var stored = check.Groups.Include(g => g.Items).Single();
Assert.Equal(GroupMode.Static, stored.Mode);
Assert.Equal(2, stored.Items.Count);
Assert.All(stored.Items, item => Assert.Equal(GroupItemRole.Member, item.Role));
}
[Fact]
public async Task Planner_ExpandsDynamicGroupByRule()
{
// Главное свойство: правило применяется на самой генерации, а не когда-то до неё.
var fixture = new TestDb();
var genre = Genre.Create("Мультфильм", "animation");
var asset = MediaAsset.Register("a.mkv", ".mkv", MediaSource.Upload);
asset.MarkProcessing();
asset.MarkReady(
new MediaReadyInfo(
TimeSpan.FromMinutes(22),
2,
660,
1920,
1080,
"h264",
"aac",
"assets/a"
)
);
var show = WithGenre(Show.Create("A", ShowKind.Series), genre);
show.AddEpisode(asset.Id);
var group = Group.Create("Мультфильмы");
group.SetFilter(new GroupFilter(GenreIds: [genre.Id]).ToJson());
group.SetMode(GroupMode.Dynamic);
await using (var seed = fixture.New())
{
seed.Genres.Add(genre);
seed.MediaAssets.Add(asset);
seed.Shows.Add(show);
seed.Groups.Add(group);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var expanded = await new GroupExpander(db, GroupServices.Dynamic(db)).ExpandAsync(
[group.Id],
Guid.NewGuid(),
CancellationToken.None
);
var element = Assert.Single(expanded[group.Id]);
Assert.Equal(show.Id, element.ElementId);
Assert.Single(element.Units);
}
private static Show WithGenre(Show show, Genre genre)
{
show.SetGenres([genre.Id]);
return show;
}
private static async Task<IReadOnlyList<ResolvedElement>> ResolveAsync(
TestDb fixture,
Guid groupId
)
{
await using var db = fixture.New();
var group = await db.Groups.Include(g => g.Items).FirstAsync(g => g.Id == groupId);
return await GroupServices.Dynamic(db).ResolveAsync(group, CancellationToken.None);
}
private static async Task UpdateModeAsync(TestDb fixture, Guid groupId, GroupMode mode)
{
await using var db = fixture.New();
var group = await db.Groups.FirstAsync(g => g.Id == groupId);
var result = await new UpdateGroupCommandHandler(
db,
GroupServices.Dynamic(db),
GroupServices.Stats(db)
).Handle(
new UpdateGroupCommand(
groupId,
group.Name,
group.Description,
GroupFilter.FromJson(group.FilterJson),
mode
),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
}
@@ -129,7 +129,7 @@ public class GroupSuggestionsTests
var handler = new CreateGroupFromSuggestionCommandHandler(
db,
Builder(db),
new GroupStatsService(db, new GroupElementResolver(db))
GroupServices.Stats(db)
);
var created = await handler.Handle(
@@ -168,7 +168,7 @@ public class GroupSuggestionsTests
var result = await new CreateGroupFromSuggestionCommandHandler(
db,
Builder(db),
new GroupStatsService(db, new GroupElementResolver(db))
GroupServices.Stats(db)
).Handle(new CreateGroupFromSuggestionCommand("genre:нет-такого"), CancellationToken.None);
Assert.False(result.IsSuccess);
@@ -0,0 +1,17 @@
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Groups;
namespace TeleWave.Application.Tests.Support;
/// <summary>
/// Сборка сервисов группы для тестов: их четыре и они цепляются друг за друга, а в тестах нужен
/// каждый раз один и тот же набор. В приложении это делает контейнер.
/// </summary>
internal static class GroupServices
{
public static DynamicGroupResolver Dynamic(IAppDbContext db) =>
new(new GroupFilterMatcher(db), new GroupElementResolver(db));
public static GroupStatsService Stats(IAppDbContext db) =>
new(db, new GroupElementResolver(db), Dynamic(db));
}
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Options;
using NSubstitute;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Streaming;
@@ -192,7 +193,10 @@ public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixtur
var random = new SequenceRandom(0);
return new GridScheduleGenerator(
db,
new GroupExpander(db),
new GroupExpander(
db,
new DynamicGroupResolver(new GroupFilterMatcher(db), new GroupElementResolver(db))
),
new BumperResolver(db, Substitute.For<IBumperRenderQueue>(), random),
new PostCheckRunner(db),
random,
@@ -1,18 +1,25 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { ChevronLeft, GripVertical, Search, Sparkles, Trash2 } from 'lucide-react'
import { ChevronLeft, GripVertical, Search, Sparkles, Trash2, Undo2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
import type { GroupCandidateDto, GroupFilter } from '@/shared/api/types'
import type {
GroupCandidateDto,
GroupElementKind,
GroupFilter,
GroupMode,
} 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 { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { toast } from '@/shared/ui/toast-store'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import {
addGroupElements,
excludeGroupElement,
findGroupCandidates,
getGroup,
removeGroupItem,
@@ -60,21 +67,35 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) {
const { data: pending } = useQuery({
queryKey: qk.groups.pending(groupId),
queryFn: () => findGroupCandidates(groupId, null),
enabled: !!group?.filter,
// Динамической группе подсказка не нужна: правило применяется само при каждом обращении.
enabled: !!group?.filter && group?.mode === 'Static',
})
const pendingFresh = (pending ?? []).filter((c) => !c.alreadyInGroup)
const saveMutation = useMutation({
mutationFn: () =>
mutationFn: (mode?: GroupMode) =>
updateGroup(groupId, {
name: (name ?? group?.name ?? '').trim(),
description: description ?? group?.description ?? null,
filter: filter ?? null,
mode,
}),
onSuccess: invalidate,
onError,
})
const excludeMutation = useMutation({
mutationFn: ({
element,
excluded,
}: Readonly<{
element: { elementKind: GroupElementKind; elementId: string }
excluded: boolean
}>) => excludeGroupElement(groupId, element, excluded),
onSuccess: invalidate,
onError,
})
const findMutation = useMutation({
mutationFn: () => findGroupCandidates(groupId, filter ?? EMPTY_FILTER),
onSuccess: setCandidates,
@@ -116,13 +137,18 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) {
const dropOn = (targetItemId: string) => {
if (!dragged || dragged === targetItemId) return
const order = group.items.map((i) => i.id).filter((id) => id !== dragged)
// Порядок задаётся идентификаторами позиций, поэтому найденное правилом (без своей строки)
// в перестановку не входит — его место определяет алфавит.
const order = group.items
.map((i) => i.id)
.filter((id): id is string => id !== null && id !== dragged)
order.splice(order.indexOf(targetItemId), 0, dragged)
setDragged(null)
reorderMutation.mutate(order)
}
const fresh = (candidates ?? []).filter((c) => !c.alreadyInGroup)
const isDynamic = group.mode === 'Dynamic'
return (
<div className="flex flex-col gap-6">
@@ -153,11 +179,35 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) {
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<Button size="sm" disabled={saveMutation.isPending} onClick={() => saveMutation.mutate()}>
<Button
size="sm"
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate(undefined)}
>
{t('common.save')}
</Button>
</div>
{/* Режим — первое, что нужно понять про группу: от него зависит, чем является список ниже. */}
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className="font-medium">{t('admin.groups.mode.label')}:</span>
<Select
value={group.mode}
onValueChange={(value) => saveMutation.mutate(value as GroupMode)}
>
<SelectTrigger className="h-8 w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Static">{t('admin.groups.mode.Static')}</SelectItem>
<SelectItem value="Dynamic">{t('admin.groups.mode.Dynamic')}</SelectItem>
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">
{t(`admin.groups.mode.${group.mode}Hint`)}
</span>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<Badge variant="muted">
{t('admin.groups.items')}: {group.itemCount}
@@ -263,31 +313,46 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) {
{showWeights ? t('admin.groups.hideAdvanced') : t('admin.groups.showAdvanced')}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('admin.groups.orderHint')}</p>
<p className="text-xs text-muted-foreground">
{isDynamic ? t('admin.groups.dynamicOrderHint') : t('admin.groups.orderHint')}
</p>
{group.items.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('admin.groups.empty')}</p>
) : (
<ul className="divide-y divide-border text-sm">
{group.items.map((item, index) => (
{group.items.map((item, index) => {
// Перетаскивать и взвешивать можно только то, у чего есть своя строка: найденному
// правилом порядок задаёт алфавит, а вес всегда единица.
const editable = item.id !== null
return (
<li
key={item.id}
draggable
onDragStart={() => setDragged(item.id)}
key={`${item.elementKind}:${item.elementId}`}
draggable={editable && !isDynamic}
onDragStart={() => item.id && setDragged(item.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => dropOn(item.id)}
onDrop={() => item.id && dropOn(item.id)}
className="flex items-center gap-2 py-2"
>
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
<GripVertical
className={`h-4 w-4 shrink-0 text-muted-foreground ${
editable && !isDynamic ? 'cursor-grab' : 'opacity-30'
}`}
/>
<span className="w-6 shrink-0 text-muted-foreground">{index + 1}</span>
<span className="min-w-0 flex-1 truncate">{item.elementName}</span>
{isDynamic && (
<Badge variant={item.pinned ? 'default' : 'muted'}>
{item.pinned ? t('admin.groups.pinned') : t('admin.groups.byRule')}
</Badge>
)}
<Badge variant="muted">
{t(`admin.groups.elementKinds.${item.elementKind}`)}
</Badge>
<span className="text-muted-foreground">
{t('admin.groups.units')}: {item.unitCount}
</span>
{showWeights && (
{showWeights && editable && (
<Input
type="number"
min={0}
@@ -295,17 +360,70 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) {
defaultValue={item.weight}
onBlur={(e) => {
const weight = Number(e.target.value)
if (Number.isFinite(weight) && weight !== item.weight)
if (item.id && Number.isFinite(weight) && weight !== item.weight)
weightMutation.mutate({ itemId: item.id, weight })
}}
/>
)}
<Button size="sm" variant="ghost" onClick={() => removeMutation.mutate(item.id)}>
<Button
size="sm"
variant="ghost"
title={isDynamic ? t('admin.groups.excludeHint') : undefined}
onClick={() =>
isDynamic
? excludeMutation.mutate({
element: {
elementKind: item.elementKind,
elementId: item.elementId,
},
excluded: true,
})
: item.id && removeMutation.mutate(item.id)
}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
)
})}
</ul>
)}
{/* Исключённое видно списком: иначе непонятно, почему шоу нет в составе. */}
{isDynamic && group.excluded.length > 0 && (
<div className="flex flex-col gap-1 border-t border-border pt-2">
<span className="text-xs uppercase tracking-wide text-muted-foreground">
{t('admin.groups.excluded')}
</span>
<ul className="flex flex-col gap-1 text-sm">
{group.excluded.map((element) => (
<li
key={`${element.elementKind}:${element.elementId}`}
className="flex items-center gap-2"
>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{element.elementName}
</span>
<Button
size="sm"
variant="ghost"
onClick={() =>
excludeMutation.mutate({
element: {
elementKind: element.elementKind,
elementId: element.elementId,
},
excluded: false,
})
}
>
<Undo2 className="h-4 w-4" />
{t('admin.groups.restore')}
</Button>
</li>
))}
</ul>
</div>
)}
</div>
</div>
@@ -116,8 +116,12 @@ export function GroupsPanel() {
>
{group.name}
</Link>
{group.hasFilter && (
{group.mode === 'Dynamic' ? (
<Badge>{t('admin.groups.mode.Dynamic')}</Badge>
) : (
group.hasFilter && (
<Badge variant="muted">{t('admin.groups.hasFilter')}</Badge>
)
)}
</div>
</td>
+20 -1
View File
@@ -5,6 +5,7 @@ import type {
GroupDto,
GroupElementKind,
GroupFilter,
GroupMode,
GroupSuggestionDto,
GroupSummaryDto,
} from '@/shared/api/types'
@@ -23,7 +24,13 @@ export function createGroup(body: { name: string; description?: string }) {
export function updateGroup(
id: string,
body: { name: string; description: string | null; filter: GroupFilter | null },
body: {
name: string
description: string | null
filter: GroupFilter | null
/** Не передан — режим остаётся прежним. */
mode?: GroupMode
},
) {
return apiRequest<void>(`/admin/groups/${id}`, { method: 'PUT', body })
}
@@ -63,6 +70,18 @@ export function addGroupElements(
})
}
/** Исключить элемент из вычисленного состава динамической группы либо вернуть его правилу. */
export function excludeGroupElement(
id: string,
element: { elementKind: GroupElementKind; elementId: string },
excluded: boolean,
) {
return apiRequest<void>(`/admin/groups/${id}/exclusions`, {
method: 'POST',
body: { ...element, excluded },
})
}
export function removeGroupItem(id: string, itemId: string) {
return apiRequest<void>(`/admin/groups/${id}/items/${itemId}`, { method: 'DELETE' })
}
+17 -1
View File
@@ -248,12 +248,17 @@ export type GroupSummaryDto = {
unitCount: number
totalDurationSeconds: number
hasFilter: boolean
mode: GroupMode
statsComputedAt: string | null
createdAt: string
}
/** Откуда группа берёт состав: явный список либо вычисление правилом. */
export type GroupMode = 'Static' | 'Dynamic'
type GroupItemDto = {
id: string
/** null — элемент найден правилом динамической группы: своей строки, веса и порядка у него нет. */
id: string | null
elementKind: GroupElementKind
elementId: string
elementName: string
@@ -264,18 +269,29 @@ type GroupItemDto = {
audience: ShowAudience | null
year: number | null
posterImageId: string | null
/** Закреплён руками в динамической группе — правило его не выбрасывает. */
pinned: boolean
}
/** Элемент, исключённый из динамической группы вручную. */
export type GroupExcludedDto = {
elementKind: GroupElementKind
elementId: string
elementName: string
}
export type GroupDto = {
id: string
name: string
description: string | null
mode: GroupMode
filter: GroupFilter | null
itemCount: number
unitCount: number
totalDurationSeconds: number
statsComputedAt: string | null
items: GroupItemDto[]
excluded: GroupExcludedDto[]
}
/** Кандидат, найденный правилом набора. */
+15
View File
@@ -101,6 +101,21 @@ export const en = {
found: 'Found: {{total}}, new: {{fresh}}',
added: 'Items added: {{count}}',
alreadyIn: 'already in group',
mode: {
label: 'Composition',
Static: 'Explicit list',
Dynamic: 'By rule',
StaticHint: 'Items are added by hand; the rule only helps to find them.',
DynamicHint:
'Composition is computed by the rule on every read — new library items go on air by themselves.',
},
pinned: 'pinned',
byRule: 'by rule',
excluded: 'Excluded',
excludeHint: 'Exclude from the composition — the rule will stop adding it back',
restore: 'Restore',
dynamicOrderHint:
'Order: pinned items first (drag to arrange), then rule matches alphabetically.',
pendingFound: 'New matches for this rule: {{count}} —',
pendingAdd: 'Add all ({{count}})',
elementKinds: { Show: 'Show', Collection: 'Collection' },
+15
View File
@@ -101,6 +101,21 @@ export const ru = {
found: 'Найдено: {{total}}, новых: {{fresh}}',
added: 'Добавлено позиций: {{count}}',
alreadyIn: 'уже в группе',
mode: {
label: 'Состав',
Static: 'Явный список',
Dynamic: 'По правилу',
StaticHint: 'Позиции добавляются вручную; правило только помогает их найти.',
DynamicHint:
'Состав считается правилом при каждом обращении — новое из библиотеки попадает в эфир само.',
},
pinned: 'закреплено',
byRule: 'по правилу',
excluded: 'Исключено',
excludeHint: 'Исключить из состава — правило больше не будет его подставлять',
restore: 'Вернуть',
dynamicOrderHint:
'Порядок: сначала закреплённое (перетаскиванием), затем найденное правилом по алфавиту.',
pendingFound: 'Под правило группы подходит нового: {{count}} —',
pendingAdd: 'Добавить все ({{count}})',
elementKinds: { Show: 'Шоу', Collection: 'Коллекция' },