Implement template export/import endpoints and enhance grid generation logic
Added new endpoints for exporting and importing grid configurations in TemplateEndpoints, allowing for better management of template data. Enhanced the GenerateGridCommandHandler to support seasonal layers in grid generation, improving scheduling accuracy during holiday periods. Updated related classes and records to accommodate these changes, ensuring a cohesive integration of new features. Improved documentation for clarity and maintainability.
This commit is contained in:
+264
@@ -0,0 +1,264 @@
|
||||
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 = 150;
|
||||
|
||||
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(
|
||||
"Имя группы в слотах должно совпадать с этим списком дословно. «Единиц» — сколько "
|
||||
+ "выходов группа даёт без повторов, «средняя» — длительность одной единицы."
|
||||
);
|
||||
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} | {Kind(group.DominantKind)} | {group.UnitCount} | "
|
||||
+ $"{(int)Math.Round(group.AverageUnitMinutes)} мин | "
|
||||
+ $"{group.Strictest?.ToString() ?? "—"} | {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} | {Kind(show.Kind)} | {show.Year?.ToString(Culture) ?? "—"} | "
|
||||
+ $"{show.Units} | {show.AverageMinutes} мин | "
|
||||
+ $"{show.Audience?.ToString() ?? "—"} | {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));
|
||||
}
|
||||
|
||||
private static string Kind(ShowKind kind) =>
|
||||
kind switch
|
||||
{
|
||||
ShowKind.Series => "сериал",
|
||||
ShowKind.Interstitial => "ролик",
|
||||
_ => "полный метр",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Шоу, у которых есть что показывать. Считается по готовым медиа: шоу без обработанного файла
|
||||
/// в эфир не пойдёт, и предлагать его модели значит получить сетку под несуществующий контент.
|
||||
/// </summary>
|
||||
private async Task<List<ShowFact>> LoadShowsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var shows = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user