Add InterstitialGroups service and enhance SlotWriter and related components for interstitial handling
Introduced the InterstitialGroups service to manage interstitial group logic, preventing their inclusion in slots and fallback groups. Updated the SlotWriter class to utilize this service, ensuring proper validation during slot creation and updates. Enhanced error handling for interstitial groups in the ImportGridCommandHandler and UpdateTemplateCommandHandler, providing warnings instead of failures when interstitials are detected. Updated related tests to verify the correct behavior of these changes, ensuring robust handling of interstitials in the scheduling process.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups;
|
||||
|
||||
/// <summary>
|
||||
/// Отличает группу роликов от группы контента.
|
||||
///
|
||||
/// Ролик — то же шоу с одной «серией» (см. 6.7), поэтому «Реклама» технически неотличима от
|
||||
/// «Сериалов»: те же позиции, тот же разворот, то же остывание. В эфире разница огромная — слот
|
||||
/// по роликам выдаёт полосу рекламы, подписанную как программа, а аварийная группа из роликов
|
||||
/// превращает в рекламу каждую паузу. Оба места спрашивают одно и то же, поэтому признак живёт
|
||||
/// здесь, а не в проверке каждого из них.
|
||||
///
|
||||
/// Признак — по позициям, а не по единицам: группа роликов состоит из роликов, и сотня коротких
|
||||
/// единиц против одного длинного сериала здесь ничего не решает.
|
||||
/// </summary>
|
||||
public sealed class InterstitialGroups(IAppDbContext dbContext)
|
||||
{
|
||||
/// <summary>
|
||||
/// Собрана ли группа из роликов. Группа, которой нет, роликовой не считается: её отсутствие —
|
||||
/// отдельная ошибка, и подменять её этой значило бы врать в сообщении.
|
||||
/// </summary>
|
||||
public async Task<bool> IsInterstitialAsync(Guid groupId, CancellationToken cancellationToken)
|
||||
{
|
||||
// Заведённая тут же группа ещё не в базе: импорт сетки приносит группы с собой и ставит
|
||||
// слоты в том же прогоне — так же, как это делает проверка «группа существует».
|
||||
var group =
|
||||
dbContext.Groups.Local.FirstOrDefault(g => g.Id == groupId)
|
||||
?? await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Include(g => g.Items)
|
||||
.FirstOrDefaultAsync(g => g.Id == groupId, cancellationToken);
|
||||
if (group is null)
|
||||
return false;
|
||||
|
||||
// Правило без указанного типа означает «кино и сериалы»: ролики в динамический состав
|
||||
// сами не попадают (см. GroupFilterMatcher), и разбирать позиции незачем.
|
||||
if (group.Mode == GroupMode.Dynamic)
|
||||
return GroupFilter.FromJson(group.FilterJson) is { ShowKinds: { Count: > 0 } kinds }
|
||||
&& kinds.All(kind => kind == ShowKind.Interstitial);
|
||||
|
||||
var items = group.Items.Where(i => i.Role != GroupItemRole.Excluded).ToList();
|
||||
if (items.Count == 0)
|
||||
return false;
|
||||
|
||||
var showIds = items
|
||||
.Where(i => i.ElementKind == GroupElementKind.Show)
|
||||
.Select(i => i.ElementId)
|
||||
.ToList();
|
||||
var collectionIds = items
|
||||
.Where(i => i.ElementKind == GroupElementKind.Collection)
|
||||
.Select(i => i.ElementId)
|
||||
.ToList();
|
||||
|
||||
// Коллекции разворачиваются, а не пропускаются: рекламный блок собирают и коллекцией —
|
||||
// так у него получается фиксированный порядок роликов.
|
||||
var parts =
|
||||
collectionIds.Count == 0
|
||||
? []
|
||||
: await dbContext
|
||||
.CollectionItems.AsNoTracking()
|
||||
.Where(i => collectionIds.Contains(i.CollectionId))
|
||||
.Select(i => new { i.CollectionId, i.ShowId })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var knownIds = showIds.Concat(parts.Select(p => p.ShowId)).Distinct().ToList();
|
||||
var interstitialIds = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => knownIds.Contains(s.Id) && s.Kind == ShowKind.Interstitial)
|
||||
.Select(s => s.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var rollers = interstitialIds.ToHashSet();
|
||||
|
||||
var byCollection = parts.ToLookup(p => p.CollectionId, p => p.ShowId);
|
||||
var interstitialItems =
|
||||
showIds.Count(rollers.Contains)
|
||||
// Коллекция считается роликовой, только если из роликов собрана целиком: одна серия
|
||||
// внутри франшизы делает её обычным контентом.
|
||||
+ collectionIds.Count(id =>
|
||||
byCollection[id].Any() && byCollection[id].All(rollers.Contains)
|
||||
);
|
||||
|
||||
// Ровно половина уже считается роликовой: смешанная группа в слоте ведёт себя так же плохо,
|
||||
// а собрать её осмысленно нельзя — это либо недоразумение, либо забытая позиция.
|
||||
return interstitialItems * 2 >= items.Count;
|
||||
}
|
||||
}
|
||||
+16
-7
@@ -2,6 +2,7 @@ using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Layers;
|
||||
@@ -82,8 +83,10 @@ public sealed class DeleteLayerCommandHandler(IAppDbContext dbContext)
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateTemplateCommand, Result>
|
||||
public sealed class UpdateTemplateCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
InterstitialGroups interstitials
|
||||
) : ICommandHandler<UpdateTemplateCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateTemplateCommand command,
|
||||
@@ -97,11 +100,17 @@ public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext)
|
||||
if (template is null)
|
||||
return Result.Failure(TemplateErrors.NotFound);
|
||||
|
||||
if (
|
||||
command.FallbackGroupId is { } groupId
|
||||
&& !await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)
|
||||
)
|
||||
return Result.Failure(TemplateErrors.GroupNotFound);
|
||||
if (command.FallbackGroupId is { } groupId)
|
||||
{
|
||||
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
|
||||
return Result.Failure(TemplateErrors.GroupNotFound);
|
||||
|
||||
// Аварийная группа закрывает каждую паузу: остаток слота, добор до якоря, пустой повтор.
|
||||
// Из роликов она превращает всё это в рекламу — ровно то, что видно в расписании
|
||||
// строками «аварийный запас» подряд.
|
||||
if (await interstitials.IsInterstitialAsync(groupId, cancellationToken))
|
||||
return Result.Failure(TemplateErrors.InterstitialFallbackGroup);
|
||||
}
|
||||
|
||||
template.Rename(command.Name);
|
||||
template.SetFallbackGroup(command.FallbackGroupId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
@@ -9,7 +10,7 @@ namespace TeleWave.Application.Programming.Templates;
|
||||
/// Общая часть создания и правки слота: проверки, которые нельзя доверить валидатору, потому что
|
||||
/// им нужны соседние слоты и справочник групп.
|
||||
/// </summary>
|
||||
public sealed class SlotWriter(IAppDbContext dbContext)
|
||||
public sealed class SlotWriter(IAppDbContext dbContext, InterstitialGroups interstitials)
|
||||
{
|
||||
/// <summary>
|
||||
/// Проверяет вход и применяет его к слоту. <paramref name="slot"/> = null — проверка перед
|
||||
@@ -45,6 +46,11 @@ public sealed class SlotWriter(IAppDbContext dbContext)
|
||||
|| await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken);
|
||||
if (!known)
|
||||
return Result.Failure(TemplateErrors.GroupNotFound);
|
||||
|
||||
// Ролики в слоте — не вкусовщина, а поломка эфира: планировщик поставит их подряд
|
||||
// как программы, и зритель получит полосу рекламы вместо передачи.
|
||||
if (await interstitials.IsInterstitialAsync(groupId, cancellationToken))
|
||||
return Result.Failure(TemplateErrors.InterstitialGroupInSlot);
|
||||
}
|
||||
|
||||
if (input.SlotKind == SlotKind.Repeat && input.RepeatSource is null)
|
||||
|
||||
@@ -39,6 +39,18 @@ public static class TemplateErrors
|
||||
"Группа не найдена."
|
||||
);
|
||||
|
||||
public static readonly Error InterstitialGroupInSlot = Error.Validation(
|
||||
"Templates.InterstitialGroupInSlot",
|
||||
"В слоте группа роликов — в эфир уйдёт полоса рекламы, подписанная как программа. "
|
||||
+ "Ролики ставятся врезками стыка, а не слотом."
|
||||
);
|
||||
|
||||
public static readonly Error InterstitialFallbackGroup = Error.Validation(
|
||||
"Templates.InterstitialFallbackGroup",
|
||||
"Аварийной группой выбрана группа роликов — тогда каждая пауза в эфире станет рекламой. "
|
||||
+ "Выберите группу контента или снимите её вовсе."
|
||||
);
|
||||
|
||||
public static readonly Error JunctionNotFound = Error.NotFound(
|
||||
"Templates.JunctionNotFound",
|
||||
"Шаблон стыка не найден."
|
||||
|
||||
+23
-2
@@ -131,7 +131,7 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa
|
||||
Culture,
|
||||
$"| {group.Name} | {KindName(group.DominantKind)} | {group.UnitCount} | "
|
||||
+ $"{(int)Math.Round(group.AverageUnitMinutes)} мин | "
|
||||
+ $"{group.Strictest?.ToString() ?? "—"} | {genre ?? "—"} |"
|
||||
+ $"{AudienceName(group.Strictest)} | {genre ?? "—"} |"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -164,7 +164,7 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa
|
||||
Culture,
|
||||
$"| {show.Name} | {KindName(show.Kind)} | {show.Year?.ToString(Culture) ?? "—"} | "
|
||||
+ $"{show.Units} | {show.AverageMinutes} мин | "
|
||||
+ $"{show.Audience?.ToString() ?? "—"} | {genre ?? "—"} |"
|
||||
+ $"{AudienceName(show.Audience)} | {genre ?? "—"} |"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -186,6 +186,22 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa
|
||||
text.AppendLine(names.Count == 0 ? empty : string.Join(", ", names));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Рейтинг ровно так, как он пишется в файле обмена («PG-13», не «Pg13»). Модель переписывает
|
||||
/// в ответ то, что увидела в таблице, а формат читает написание с провода: разойдись эти два
|
||||
/// написания — и правило детского времени из ответа не разберётся вовсе.
|
||||
/// </summary>
|
||||
private static string AudienceName(ShowAudience? audience) =>
|
||||
audience switch
|
||||
{
|
||||
ShowAudience.G => "G",
|
||||
ShowAudience.Pg => "PG",
|
||||
ShowAudience.Pg13 => "PG-13",
|
||||
ShowAudience.R => "R",
|
||||
ShowAudience.Nc17 => "NC-17",
|
||||
_ => "—",
|
||||
};
|
||||
|
||||
/// <summary>Тип контента словами: «сериал», «полный метр» — так его читает модель.</summary>
|
||||
private static string KindName(ShowKind kind) =>
|
||||
kind switch
|
||||
@@ -198,11 +214,16 @@ public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCa
|
||||
/// <summary>
|
||||
/// Шоу, у которых есть что показывать. Считается по готовым медиа: шоу без обработанного файла
|
||||
/// в эфир не пойдёт, и предлагать его модели значит получить сетку под несуществующий контент.
|
||||
///
|
||||
/// Ролики в список не идут: их бывают сотни, они съедают потолок перечисления, а главное —
|
||||
/// поимённый список рекламы читается моделью как материал для слотов, и она честно собирает
|
||||
/// из него группу. В эфире это полоса рекламы, подписанная как программа.
|
||||
/// </summary>
|
||||
private async Task<List<ShowFact>> LoadShowsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var shows = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => s.Kind != ShowKind.Interstitial)
|
||||
.Include(s => s.Episodes)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
|
||||
@@ -29,8 +29,9 @@ public static class GridPromptText
|
||||
- Названия шоу в группах и коллекциях обязаны дословно совпадать с библиотекой ниже. Шоу
|
||||
импорт не создаёт: их приносит загрузка медиа, и придуманного названия в эфире не будет.
|
||||
- Стыки и заставки придумывать нельзя — только имена из списков ниже.
|
||||
- Группы типа «ролик» — это реклама и джинглы для стыков. В слоты их ставить нельзя:
|
||||
в эфир уйдёт полоса роликов, подписанная как программа.
|
||||
- Группы типа «ролик» — это реклама и джинглы для стыков. Ни в слот, ни в fallbackGroup их
|
||||
ставить нельзя: в эфир уйдёт полоса роликов, подписанная как программа. Рекламы в списке
|
||||
библиотеки нет по той же причине — собирать группы из неё не нужно.
|
||||
- Слоты внутри одного слоя не пересекаются. Слоты разных слоёв пересекаться могут: слой
|
||||
с большим priority перекрывает меньший (например «Выходные» поверх «Основной сетки»).
|
||||
- weekday: 0=воскресенье .. 6=суббота, либо null — слот идёт каждый день. Предпочитай
|
||||
|
||||
+17
-5
@@ -22,7 +22,8 @@ namespace TeleWave.Application.Programming.Templates.Transfer;
|
||||
public sealed class ImportGridCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
SlotWriter writer,
|
||||
GroupStatsService stats
|
||||
GroupStatsService stats,
|
||||
InterstitialGroups interstitials
|
||||
) : ICommandHandler<ImportGridCommand, Result<GridImportResultDto>>
|
||||
{
|
||||
public async Task<Result<GridImportResultDto>> Handle(
|
||||
@@ -74,7 +75,7 @@ public sealed class ImportGridCommandHandler(
|
||||
skipped += dropped;
|
||||
}
|
||||
|
||||
Apply(template, command.Config, groups, junctions, warnings);
|
||||
await ApplyAsync(template, command.Config, groups, junctions, warnings, cancellationToken);
|
||||
template.MarkChanged();
|
||||
|
||||
return Result.Success(
|
||||
@@ -303,12 +304,13 @@ public sealed class ImportGridCommandHandler(
|
||||
}
|
||||
|
||||
/// <summary>Настройки шаблона из файла. Пустые поля не трогают то, что уже стоит на канале.</summary>
|
||||
private static void Apply(
|
||||
private async Task ApplyAsync(
|
||||
ScheduleTemplate template,
|
||||
GridConfig config,
|
||||
IReadOnlyDictionary<string, Guid> groups,
|
||||
IReadOnlyDictionary<string, Guid> junctions,
|
||||
List<string> warnings
|
||||
List<string> warnings,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (config.Rules is { } rules)
|
||||
@@ -318,7 +320,17 @@ public sealed class ImportGridCommandHandler(
|
||||
config.FallbackGroup is { Length: > 0 } fallback
|
||||
&& Resolve(fallback, groups, "Группа", warnings) is { } groupId
|
||||
)
|
||||
template.SetFallbackGroup(groupId);
|
||||
{
|
||||
// Аварийной группой из роликов файл превращает в рекламу каждую паузу эфира. Замечанием,
|
||||
// а не отказом: остальная сетка из файла корректна, и терять её из-за одного поля нельзя.
|
||||
if (await interstitials.IsInterstitialAsync(groupId, cancellationToken))
|
||||
warnings.Add(
|
||||
$"Аварийная группа «{fallback}» собрана из роликов — она не проставлена: "
|
||||
+ "паузы эфира стали бы рекламой."
|
||||
);
|
||||
else
|
||||
template.SetFallbackGroup(groupId);
|
||||
}
|
||||
|
||||
if (
|
||||
config.DefaultJunction is { Length: > 0 } junction
|
||||
|
||||
Reference in New Issue
Block a user