Files
TeleWave/backend/src/TeleWave.Application/Programming/Templates/Transfer/BuildGridPromptQueryHandler.cs
T
Leonid Pershin 926a20020f
ci / build-backend (push) Successful in 1m18s
ci / build-frontend (push) Successful in 43s
ci / tests (push) Successful in 3m38s
ci / sonar (push) Successful in 3m38s
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.
2026-07-30 12:48:51 +03:00

288 lines
12 KiB
C#

using System.Globalization;
using System.Text;
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Templates.Generate;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
namespace TeleWave.Application.Programming.Templates.Transfer;
public sealed class BuildGridPromptQueryHandler(IAppDbContext dbContext, GroupCatalog catalog)
: IQueryHandler<BuildGridPromptQuery, Result<GridPromptDto>>
{
/// <summary>
/// Потолок на список шоу. Библиотека канала может быть в тысячах серий, а запрос читает модель
/// с ограниченным контекстом: группы для сетки важнее поимённого перечисления, поэтому список
/// обрезается, и об обрезке прямо написано в запросе.
/// </summary>
private const int ShowLimit = 300;
private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("ru-RU");
public async Task<Result<GridPromptDto>> Handle(
BuildGridPromptQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<GridPromptDto>(ChannelErrors.NotFound);
var groups = await catalog.LoadAsync(cancellationToken);
var genres = await dbContext
.Genres.AsNoTracking()
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
var shows = await LoadShowsAsync(cancellationToken);
var junctions = await dbContext
.JunctionTemplates.AsNoTracking()
.OrderBy(j => j.Name)
.Select(j => j.Name)
.ToListAsync(cancellationToken);
var bumpers = await dbContext
.BumperTemplates.AsNoTracking()
.OrderBy(b => b.Name)
.Select(b => b.Name)
.ToListAsync(cancellationToken);
var text = new StringBuilder();
text.AppendLine(GridPromptText.Task).AppendLine();
text.AppendLine("## Канал");
text.AppendLine(
Culture,
$"- Название: {channel.Name}\n"
+ $"- Вещательные сутки начинаются в {channel.DayStartTime:HH\\:mm} (dayStart)\n"
+ $"- Время канала: UTC{(channel.UtcOffsetMinutes < 0 ? "-" : "+")}{Math.Abs(channel.UtcOffsetMinutes) / 60}:{Math.Abs(channel.UtcOffsetMinutes) % 60:00}"
);
AppendReferences(text, query);
AppendGroups(text, groups, genres);
AppendShows(text, shows, genres);
AppendNames(
text,
"Стыки",
junctions,
"стыков ещё нет — поля junctionAfter/junctionBetween оставь пустыми"
);
AppendNames(text, "Заставки", bumpers, "заставок ещё нет");
text.AppendLine().AppendLine(GridPromptText.Rules);
text.AppendLine().AppendLine(GridPromptText.Schema);
text.AppendLine().AppendLine(GridPromptText.Answer);
var prompt = text.ToString();
return Result.Success(new GridPromptDto(prompt, groups.Count, shows.Count, prompt.Length));
}
private static void AppendReferences(StringBuilder text, BuildGridPromptQuery query)
{
var references = query.References.Where(r => !string.IsNullOrWhiteSpace(r)).ToList();
if (references.Count > 0)
{
text.AppendLine().AppendLine("## Референсы");
text.AppendLine(
"Равняйся на ритм этих каналов — длину блоков, что стоит в прайме, чем закрыта ночь:"
);
foreach (var reference in references)
text.AppendLine(Culture, $"- {reference.Trim()}");
}
if (string.IsNullOrWhiteSpace(query.Notes))
return;
text.AppendLine().AppendLine("## Пожелания");
text.AppendLine(query.Notes.Trim());
}
private static void AppendGroups(
StringBuilder text,
IReadOnlyList<GroupCandidate> groups,
IReadOnlyDictionary<Guid, string> genres
)
{
text.AppendLine().AppendLine("## Доступные группы");
if (groups.Count == 0)
{
text.AppendLine(
"Готовых групп нет — сетку по ним собрать нельзя. Сначала собери группы в библиотеке."
);
return;
}
text.AppendLine(
"Эти группы уже собраны — их имена можно ставить в слоты как есть. «Единиц» — сколько "
+ "выходов группа даёт без повторов, «средняя» — длительность одной единицы. "
+ "Если нужного разреза библиотеки здесь нет, собери свою группу в разделе groups."
);
text.AppendLine();
text.AppendLine("| Группа | Тип | Единиц | Средняя | Рейтинг | Жанр |");
text.AppendLine("|---|---|---|---|---|---|");
foreach (var group in groups.OrderByDescending(g => g.UnitCount))
{
var genre = group.PrimaryGenreId is { } id ? genres.GetValueOrDefault(id) : null;
text.AppendLine(
Culture,
$"| {group.Name} | {KindName(group.DominantKind)} | {group.UnitCount} | "
+ $"{(int)Math.Round(group.AverageUnitMinutes)} мин | "
+ $"{AudienceName(group.Strictest)} | {genre ?? ""} |"
);
}
}
private static void AppendShows(
StringBuilder text,
IReadOnlyList<ShowFact> shows,
IReadOnlyDictionary<Guid, string> genres
)
{
text.AppendLine().AppendLine("## Библиотека");
if (shows.Count == 0)
{
text.AppendLine("Библиотека пуста.");
return;
}
text.AppendLine(
$"Всего готового к эфиру: {shows.Count}. Названия — ключ: собирая свои группы, пиши их "
+ "дословно отсюда, иначе шоу не найдётся при загрузке."
);
text.AppendLine();
text.AppendLine("| Шоу | Тип | Год | Единиц | Средняя | Рейтинг | Жанр |");
text.AppendLine("|---|---|---|---|---|---|---|");
foreach (var show in shows.Take(ShowLimit))
{
var genre = show.PrimaryGenreId is { } id ? genres.GetValueOrDefault(id) : null;
text.AppendLine(
Culture,
$"| {show.Name} | {KindName(show.Kind)} | {show.Year?.ToString(Culture) ?? ""} | "
+ $"{show.Units} | {show.AverageMinutes} мин | "
+ $"{AudienceName(show.Audience)} | {genre ?? ""} |"
);
}
if (shows.Count > ShowLimit)
text.AppendLine(
Culture,
$"\n…и ещё {shows.Count - ShowLimit} — список обрезан, полный состав виден по группам выше."
);
}
private static void AppendNames(
StringBuilder text,
string title,
IReadOnlyList<string> names,
string empty
)
{
text.AppendLine().AppendLine(Culture, $"## {title}");
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
{
ShowKind.Series => "сериал",
ShowKind.Interstitial => "ролик",
_ => "полный метр",
};
/// <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);
var assetIds = shows
.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId))
.Distinct()
.ToList();
var ready = await dbContext
.MediaAssets.AsNoTracking()
.Where(a => assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready)
.ToDictionaryAsync(a => a.Id, a => a.Duration ?? TimeSpan.Zero, cancellationToken);
var primaryGenres = await dbContext
.ShowGenres.AsNoTracking()
.Where(g => g.IsPrimary)
.ToDictionaryAsync(g => g.ShowId, g => g.GenreId, cancellationToken);
var facts = new List<ShowFact>();
foreach (var show in shows)
{
var durations = show
.Episodes.Select(e =>
ready.TryGetValue(e.MediaAssetId, out var d) ? d : (TimeSpan?)null
)
.OfType<TimeSpan>()
.ToList();
if (durations.Count == 0)
continue;
facts.Add(
new ShowFact(
show.Name,
show.Kind,
show.Year,
show.Audience,
durations.Count,
(int)Math.Round(durations.Average(d => d.TotalMinutes)),
primaryGenres.TryGetValue(show.Id, out var genreId) ? genreId : null
)
);
}
return
[
.. facts
.OrderByDescending(s => s.Units)
.ThenBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase),
];
}
private sealed record ShowFact(
string Name,
ShowKind Kind,
int? Year,
ShowAudience? Audience,
int Units,
int AverageMinutes,
Guid? PrimaryGenreId
);
}