Implement bulk tagging and enriching of shows with new API endpoints and frontend integration
ci / build-backend (push) Successful in 2m4s
ci / build-frontend (push) Successful in 53s
ci / tests (push) Successful in 1m59s
ci / sonar (push) Successful in 6m6s

Added new API endpoints for bulk tagging and enriching shows, allowing for mass updates of genres and audience ratings. Implemented backend logic to handle bulk operations and updated the Dependency Injection configuration to include necessary services. Enhanced the frontend with new components for selecting shows and applying bulk actions, improving the user experience for managing multiple shows simultaneously. Localization updates were made to support these new features in both English and Russian.
This commit is contained in:
Leonid Pershin
2026-07-27 05:18:18 +03:00
parent 91af589547
commit 1a7f73a5bd
25 changed files with 1011 additions and 107 deletions
@@ -6,14 +6,17 @@ using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups;
/// <summary>
/// Правило быстрого набора состава группы. Это не запрос, выполняемый при генерации: фильтр находит
/// подходящие позиции, а добавляет их в группу отдельная команда. Пустые поля не ограничивают.
/// Правило набора состава группы. Пустые поля не ограничивают.
///
/// В статической группе это помощник наполнения: правило находит подходящие позиции, а добавляет их
/// отдельная команда. В динамической — оно и есть состав, вычисляемый при каждом обращении.
/// </summary>
public sealed record GroupFilter(
IReadOnlyList<GroupElementKind>? ElementKinds = null,
IReadOnlyList<ShowKind>? ShowKinds = null,
IReadOnlyList<Guid>? GenreIds = null,
ShowAudience? MaxAudience = null,
ShowAudience? MinAudience = null,
int? YearMin = null,
int? YearMax = null,
int? UnitMinutesMin = null,
@@ -71,6 +71,12 @@ public sealed class GroupFilterMatcher(IAppDbContext dbContext)
if (filter.MaxAudience is { } maxAudience)
shows = shows.Where(s => s.Audience == null || s.Audience <= maxAudience);
// Нижняя граница ведёт себя иначе, чем верхняя: непроставленный рейтинг из выборки уходит.
// «Не строже PG» с неизвестным рейтингом — допустимый риск, а «R и старше» с ним означало бы
// мультики в ночном слоте, то есть ровно ту ошибку, ради которой границу и заводят.
if (filter.MinAudience is { } minAudience)
shows = shows.Where(s => s.Audience != null && s.Audience >= minAudience);
if (filter.YearMin is { } yearMin)
shows = shows.Where(s => s.Year != null && s.Year >= yearMin);
if (filter.YearMax is { } yearMax)
@@ -48,6 +48,12 @@ public sealed class GroupSuggestionBuilder(IAppDbContext dbContext, GroupElement
(ShowAudience.Pg13, "Семейное (не строже PG-13)"),
];
/// <summary>Нижний порог — под ночные слоты. Непроставленный рейтинг сюда не попадает.</summary>
private static readonly (ShowAudience Min, string Name) AdultTier = (
ShowAudience.R,
"Взрослое (R и старше)"
);
private static readonly (ShowKind Kind, string Name)[] ShowKindNames =
[
(ShowKind.Series, "Сериалы"),
@@ -166,6 +172,17 @@ public sealed class GroupSuggestionBuilder(IAppDbContext dbContext, GroupElement
stats
);
}
var adult = shows.Where(s => s.Audience >= AdultTier.Min).ToList();
if (adult.Count >= MinShows)
yield return Build(
$"audience-min:{AdultTier.Min}",
GroupSuggestionKind.Audience,
AdultTier.Name,
new GroupFilter(MinAudience: AdultTier.Min),
adult,
stats
);
}
/// <summary>
@@ -3,13 +3,16 @@ using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Groups;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Validate;
public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
public sealed class ValidateTemplateQueryHandler(
IAppDbContext dbContext,
DynamicGroupResolver dynamicResolver
) : IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
{
private const int MinutesInDay = 24 * 60;
@@ -42,14 +45,26 @@ public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
.Distinct()
.ToList();
var groups = await dbContext
// Состав считаем, а не читаем кэш ItemCount: у группы по правилу кэш отстаёт от библиотеки,
// и проверка «группа пуста» врала бы в обе стороны — и пугала бы зря, и пропускала бы
// действительно опустевшую группу.
var loaded = await dbContext
.Groups.AsNoTracking()
.Include(g => g.Items)
.Where(g => groupIds.Contains(g.Id))
.Select(g => new GroupStats(g.Id, g.Name, g.ItemCount))
.ToDictionaryAsync(g => g.Id, cancellationToken);
.ToListAsync(cancellationToken);
var composition = new Dictionary<Guid, IReadOnlyList<ResolvedElement>>();
foreach (var group in loaded)
composition[group.Id] = await dynamicResolver.ResolveAsync(group, cancellationToken);
var groups = loaded.ToDictionary(
g => g.Id,
g => new GroupStats(g.Id, g.Name, composition[g.Id].Count)
);
var rules = PlanningRules.FromJson(template.RulesJson);
var strictest = await LoadStrictestAudienceAsync(groupIds, cancellationToken);
var strictest = await LoadStrictestAudienceAsync(composition, cancellationToken);
var dayStart = channel.DayStartTime;
var issues = new List<TemplateIssueDto>();
@@ -256,32 +271,32 @@ public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
/// Коллекция берётся по строжайшей части — франшиза идёт целиком. Шоу без рейтинга в расчёт не
/// идут: планировщик их не отсекает, значит и предупреждать не о чем.
/// </summary>
/// <summary>
/// Самый строгий рейтинг в каждой группе — по вычисленному составу, а не по строкам таблицы:
/// иначе у динамической группы в расчёт попало бы исключённое и не попало бы найденное правилом.
/// </summary>
private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
IReadOnlyCollection<Guid> groupIds,
IReadOnlyDictionary<Guid, IReadOnlyList<ResolvedElement>> composition,
CancellationToken cancellationToken
)
{
if (groupIds.Count == 0)
var items = composition
.SelectMany(pair =>
pair.Value.Select(e => new
{
GroupId = pair.Key,
e.Kind,
e.Id,
})
)
.ToList();
if (items.Count == 0)
return [];
var items = await dbContext
.GroupItems.AsNoTracking()
.Where(i => groupIds.Contains(i.GroupId))
.Select(i => new
{
i.GroupId,
i.ElementKind,
i.ElementId,
})
.ToListAsync(cancellationToken);
var showIds = items
.Where(i => i.ElementKind == GroupElementKind.Show)
.Select(i => i.ElementId)
.ToList();
var showIds = items.Where(i => i.Kind == GroupElementKind.Show).Select(i => i.Id).ToList();
var collectionIds = items
.Where(i => i.ElementKind == GroupElementKind.Collection)
.Select(i => i.ElementId)
.Where(i => i.Kind == GroupElementKind.Collection)
.Select(i => i.Id)
.ToList();
var partsByCollection = await dbContext
@@ -307,10 +322,10 @@ public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
foreach (var item in items)
{
var candidates =
item.ElementKind == GroupElementKind.Show
? [item.ElementId]
item.Kind == GroupElementKind.Show
? [item.Id]
: partsByCollection
.Where(p => p.CollectionId == item.ElementId)
.Where(p => p.CollectionId == item.Id)
.Select(p => p.ShowId)
.ToList();