Implement dynamic group management features with exclusions and mode handling
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
+17
@@ -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>;
|
||||
+36
@@ -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();
|
||||
}
|
||||
}
|
||||
+3
-82
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+77
-22
@@ -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>
|
||||
|
||||
+5
-1
@@ -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
|
||||
))
|
||||
|
||||
+7
-3
@@ -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>;
|
||||
|
||||
+37
-6
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user