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> { /// /// Потолок на список шоу. Библиотека канала может быть в тысячах серий, а запрос читает модель /// с ограниченным контекстом: группы для сетки важнее поимённого перечисления, поэтому список /// обрезается, и об обрезке прямо написано в запросе. /// private const int ShowLimit = 300; private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("ru-RU"); public async Task> 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(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 groups, IReadOnlyDictionary 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 shows, IReadOnlyDictionary 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 names, string empty ) { text.AppendLine().AppendLine(Culture, $"## {title}"); text.AppendLine(names.Count == 0 ? empty : string.Join(", ", names)); } /// /// Рейтинг ровно так, как он пишется в файле обмена («PG-13», не «Pg13»). Модель переписывает /// в ответ то, что увидела в таблице, а формат читает написание с провода: разойдись эти два /// написания — и правило детского времени из ответа не разберётся вовсе. /// 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", _ => "—", }; /// Тип контента словами: «сериал», «полный метр» — так его читает модель. private static string KindName(ShowKind kind) => kind switch { ShowKind.Series => "сериал", ShowKind.Interstitial => "ролик", _ => "полный метр", }; /// /// Шоу, у которых есть что показывать. Считается по готовым медиа: шоу без обработанного файла /// в эфир не пойдёт, и предлагать его модели значит получить сетку под несуществующий контент. /// /// Ролики в список не идут: их бывают сотни, они съедают потолок перечисления, а главное — /// поимённый список рекламы читается моделью как материал для слотов, и она честно собирает /// из него группу. В эфире это полоса рекламы, подписанная как программа. /// private async Task> 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(); foreach (var show in shows) { var durations = show .Episodes.Select(e => ready.TryGetValue(e.MediaAssetId, out var d) ? d : (TimeSpan?)null ) .OfType() .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 ); }