diff --git a/.gitignore b/.gitignore index 059f522..5ad5a5c 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,8 @@ dist-ssr/ # Отчёты покрытия (артефакт dotnet test / CI) coverage/ TestResults/ + +# ---> TeleWave +# Отладочные дампы каналов: архивы кладёт сюда бэкенд (секция Debug:ExportDirectory). +debug-exports/* +!debug-exports/.gitkeep diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs index e941173..370a6d8 100644 --- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs @@ -8,6 +8,7 @@ using TeleWave.Application.Broadcast.ListChannels; using TeleWave.Application.Broadcast.UpdateChannelSettings; using TeleWave.Application.Broadcast.UpdateChannelTime; using TeleWave.Application.Broadcast.UpdateViewerSettings; +using TeleWave.Application.Programming.Planning.DebugExport; using TeleWave.Application.Programming.Planning.Trace; using TeleWave.Domain.Broadcast; using TeleWave.Infrastructure.Identity; @@ -45,9 +46,29 @@ public static class ChannelEndpoints // «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации. admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces(); + // Отладочный дамп: вход планировщика и результат одним архивом. + admin.MapPost("/{id:guid}/debug-export", ExportDebug).Produces(StatusCodes.Status200OK); + return app; } + /// + /// Отдаёт архив со снимком канала. POST, а не GET: команда оставляет копию дампа на сервере, + /// и кэшировать такой ответ никому не следует. + /// + private static async Task ExportDebug( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new ExportChannelDebugCommand(id), cancellationToken); + if (!result.IsSuccess) + return result.ToHttpResult(); + + return Results.File(result.Value.Content, "application/zip", result.Value.FileName); + } + private static async Task UpdateViewerSettings( Guid id, UpdateViewerSettingsBody body, diff --git a/backend/src/TeleWave.Api/appsettings.Development.json b/backend/src/TeleWave.Api/appsettings.Development.json index 60a2cdf..e951b28 100644 --- a/backend/src/TeleWave.Api/appsettings.Development.json +++ b/backend/src/TeleWave.Api/appsettings.Development.json @@ -20,5 +20,10 @@ }, "Storage": { "RootPath": ".dev-media" + }, + // Отладочные дампы каналов ложатся в папку репозитория: разбирать «почему сетка построилась + // так» приходится рядом с кодом. Путь относительный — от рабочего каталога Api (backend/src/TeleWave.Api). + "Debug": { + "ExportDirectory": "../../../debug-exports" } } diff --git a/backend/src/TeleWave.Application/Common/DebugExportOptions.cs b/backend/src/TeleWave.Application/Common/DebugExportOptions.cs new file mode 100644 index 0000000..44716dd --- /dev/null +++ b/backend/src/TeleWave.Application/Common/DebugExportOptions.cs @@ -0,0 +1,20 @@ +namespace TeleWave.Application.Common; + +/// Настройки отладочного дампа. Секция «Debug». +public sealed class DebugExportOptions +{ + public const string SectionName = "Debug"; + + /// + /// Куда класть копии архивов. Пусто — не класть никуда, отдавать только в браузер: на сервере + /// дампы на диске не нужны, а в разработке путь указывает на папку рядом с репозиторием. + /// Относительный путь считается от рабочего каталога приложения. + /// + public string? ExportDirectory { get; init; } + + /// + /// Сколько последних архивов держать в каталоге. Дамп канала — это мегабайты, а собирают их + /// пачками, отлаживая одну и ту же сетку; без потолка папка растёт молча. + /// + public int KeepLast { get; init; } = 20; +} diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IDebugExportStore.cs b/backend/src/TeleWave.Application/Common/Interfaces/IDebugExportStore.cs new file mode 100644 index 0000000..4abdafc --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/IDebugExportStore.cs @@ -0,0 +1,19 @@ +namespace TeleWave.Application.Common.Interfaces; + +/// +/// Куда ложится копия отладочного дампа на сервере. Сам архив всегда уезжает в браузер — это порт +/// для второй копии, которая остаётся рядом с репозиторием: разбирать «почему сетка построилась +/// вот так» приходится вместе с кодом, и перекладывать архивы руками из загрузок каждый раз — +/// лишний шаг. +/// +/// Каталог задаётся настройкой и по умолчанию пуст: на сервере писать дампы на диск незачем, +/// это инструмент разработки. +/// +public interface IDebugExportStore +{ + /// + /// Сохраняет архив. Возвращает путь, куда он лёг, либо null — сохранение выключено настройкой. + /// Ошибка записи наружу не идёт: дамп уже собран, и терять его из-за недоступной папки нельзя. + /// + Task SaveAsync(string fileName, byte[] content, CancellationToken cancellationToken); +} diff --git a/backend/src/TeleWave.Application/DependencyInjection.cs b/backend/src/TeleWave.Application/DependencyInjection.cs index 0b80b6b..6086016 100644 --- a/backend/src/TeleWave.Application/DependencyInjection.cs +++ b/backend/src/TeleWave.Application/DependencyInjection.cs @@ -15,6 +15,7 @@ using TeleWave.Application.Metadata; using TeleWave.Application.Programming.Groups; using TeleWave.Application.Programming.Groups.Suggest; using TeleWave.Application.Programming.Planning; +using TeleWave.Application.Programming.Planning.DebugExport; using TeleWave.Application.Programming.Templates; using TeleWave.Application.Programming.Templates.Generate; @@ -63,6 +64,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/backend/src/TeleWave.Application/Programming/Planning/DebugExport/ChannelDebugCollector.cs b/backend/src/TeleWave.Application/Programming/Planning/DebugExport/ChannelDebugCollector.cs new file mode 100644 index 0000000..ba70347 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Planning/DebugExport/ChannelDebugCollector.cs @@ -0,0 +1,465 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using TeleWave.Application.Broadcast.Scheduling; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Programming.Groups; +using TeleWave.Application.Programming.Templates; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Programming; +using TeleWave.Domain.Programming.Planning; + +namespace TeleWave.Application.Programming.Planning.DebugExport; + +/// +/// Собирает отладочный снимок канала: вход планировщика, его состояние и то, что получилось. +/// +/// Смысл дампа — отвечать на вопрос «почему сетка построилась так», не подключаясь к рабочей базе. +/// Поэтому здесь не выжимка, а полный вход: развёрнутые группы вплоть до отдельных единиц, +/// эффективная сетка по суткам, курсоры слотов, стыки с условиями, записанная лента с трейсом +/// и сухой прогон от текущего состояния. Всё, что хранится JSON-строкой (стратегия, правила, +/// условия врезки, трейс), разбирается в объекты: читать дамп глазами придётся чаще, чем кодом. +/// +public sealed class ChannelDebugCollector( + IAppDbContext dbContext, + GroupExpander expander, + GridScheduleGenerator generator, + IOptions options +) +{ + private readonly SchedulerOptions _options = options.Value; + + /// Сколько прошлого класть в дамп: сдвиги и повторы объясняются вчерашним эфиром. + private const int PastDays = 2; + + public async Task> CollectAsync( + Channel channel, + ScheduleTemplate template, + DateTimeOffset now, + CancellationToken cancellationToken + ) + { + var horizonDays = Math.Max(1, _options.HorizonDays); + var from = now.AddDays(-PastDays); + var to = now.AddDays(horizonDays); + + var grid = EffectiveGridBuilder.Build( + template, + channel.UtcOffsetMinutes, + channel.DayStartTime, + now, + to + ); + + var junctions = await dbContext + .JunctionTemplates.AsNoTracking() + .Include(j => j.Elements) + .ToListAsync(cancellationToken); + + var groups = await dbContext.Groups.AsNoTracking().ToListAsync(cancellationToken); + var groupNames = groups.ToDictionary(g => g.Id, g => g.Name); + var junctionNames = junctions.ToDictionary(j => j.Id, j => j.Name); + var bumperNames = await dbContext + .BumperTemplates.AsNoTracking() + .ToDictionaryAsync(b => b.Id, b => b.Name, cancellationToken); + + // Разворачиваем ровно те группы, что участвуют в эфире канала: чужие только раздули бы дамп. + var usedGroupIds = grid + .Slots.Concat(grid.Background) + .Select(s => s.Slot.GroupId) + .Concat(junctions.SelectMany(j => j.Elements).Select(e => e.GroupId)) + .Append(template.FallbackGroupId) + .Where(id => id is not null) + .Select(id => id!.Value) + .Distinct() + .ToList(); + + var expanded = await expander.ExpandAsync(usedGroupIds, channel.Id, cancellationToken); + var showNames = await ShowNamesAsync(expanded, cancellationToken); + + var slotIds = grid.Slots.Concat(grid.Background).Select(s => s.Slot.Id).Distinct().ToList(); + var slotTitles = template + .Layers.SelectMany(l => l.Slots) + .ToDictionary(s => s.Id, s => s.Title); + var states = await dbContext + .SlotStates.AsNoTracking() + .Where(s => slotIds.Contains(s.SlotId)) + .ToListAsync(cancellationToken); + + var entries = await dbContext + .ScheduleEntries.AsNoTracking() + .Where(e => e.ChannelId == channel.Id && e.StartsAtUtc < to && e.EndsAtUtc > from) + .OrderBy(e => e.StartsAtUtc) + .ToListAsync(cancellationToken); + + var entryShowNames = await dbContext + .Shows.AsNoTracking() + .Where(s => entries.Select(e => e.ShowId).Contains(s.Id)) + .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); + + // Сухой прогон от текущего состояния: то, что планировщик построил бы прямо сейчас. Рядом + // с записанной лентой по нему видно, расходится ли расчёт с тем, что уже в эфире. + var preview = await generator.PreviewAsync(channel.Id, now, horizonDays, cancellationToken); + + return + [ + new DebugFile("README.md", Readme(channel, now, horizonDays)), + new DebugFile( + "summary.json", + Summary(channel, template, now, horizonDays, grid, entries, preview) + ), + new DebugFile("channel.json", ChannelFacts(channel)), + new DebugFile("template.json", TemplateFacts(template, groupNames, junctionNames)), + new DebugFile("slot-states.json", StateFacts(states, slotTitles)), + new DebugFile("effective-grid.json", GridFacts(grid, groupNames)), + new DebugFile("groups.json", GroupFacts(expanded, groupNames, showNames)), + new DebugFile("junctions.json", JunctionFacts(junctions, groupNames, bumperNames)), + new DebugFile("schedule.json", ScheduleFacts(entries, slotTitles, entryShowNames)), + new DebugFile("plan-preview.json", PreviewFacts(preview, slotTitles, showNames)), + ]; + } + + private static string Readme(Channel channel, DateTimeOffset now, int horizonDays) => + $""" + # Отладочный дамп канала «{channel.Name}» + + Собран: {now:yyyy-MM-dd HH:mm:ss} UTC. Горизонт планирования: {horizonDays} сут., + прошлое в дампе: {PastDays} сут. + + - `summary.json` — что за канал, сколько чего собралось, свод предупреждений. + - `channel.json` — настройки канала: время, начало вещательных суток, эпоха, филлер. + - `template.json` — сетка: слои, слоты, стратегии, правила канала. JSON-поля разобраны. + - `slot-states.json` — курсоры слотов: на чём каждый слот остановился. + - `effective-grid.json` — эффективная сетка на горизонт: экземпляры слотов по суткам + с временами в UTC. `background` — перекрытые слоты фонового слоя. + - `groups.json` — развёрнутые группы вплоть до единиц воспроизведения: что именно + планировщик мог поставить и в каком порядке. + - `junctions.json` — стыки: врезки, условия показа, развилки. + - `schedule.json` — записанная лента с трейсом «почему это здесь». + - `plan-preview.json` — сухой прогон от текущего состояния: что построилось бы сейчас. + + Времена везде UTC. Время канала = UTC{( + channel.UtcOffsetMinutes < 0 ? "-" : "+" + )}{Math.Abs(channel.UtcOffsetMinutes) / 60:00}:{Math.Abs(channel.UtcOffsetMinutes) + % 60:00}. + """; + + private object Summary( + Channel channel, + ScheduleTemplate template, + DateTimeOffset now, + int horizonDays, + EffectiveGrid grid, + IReadOnlyList entries, + PlanningResult? preview + ) => + new + { + CollectedAtUtc = now, + Channel = new + { + channel.Id, + channel.Name, + channel.Slug, + channel.IsEnabled, + }, + Scheduler = new + { + HorizonDays = horizonDays, + _options.RetentionDays, + _options.TickMinutes, + }, + Template = new + { + template.Id, + template.Revision, + template.HasPendingChanges, + Layers = template.Layers.Count, + Slots = template.Layers.Sum(l => l.Slots.Count), + }, + Grid = new { Instances = grid.Slots.Count, Background = grid.Background.Count }, + Tape = new + { + Entries = entries.Count, + ByKind = entries + .GroupBy(e => e.Kind) + .ToDictionary(g => g.Key.ToString(), g => g.Count()), + FirstUtc = entries.Count > 0 ? entries[0].StartsAtUtc : (DateTimeOffset?)null, + LastUtc = entries.Count > 0 ? entries[^1].EndsAtUtc : (DateTimeOffset?)null, + }, + Preview = new + { + Items = preview?.Items.Count ?? 0, + Warnings = preview + ?.Warnings.GroupBy(w => w.Kind) + .ToDictionary(g => g.Key.ToString(), g => g.Count()), + }, + }; + + private static object ChannelFacts(Channel channel) => + new + { + channel.Id, + channel.Name, + channel.Slug, + channel.Number, + channel.IsEnabled, + channel.UtcOffsetMinutes, + channel.DayStartTime, + channel.EpochUtc, + channel.TemplateId, + channel.FillerAssetId, + channel.LogoImageId, + channel.ShowClock, + channel.AnalogFilterStrength, + }; + + private static object TemplateFacts( + ScheduleTemplate template, + IReadOnlyDictionary groupNames, + IReadOnlyDictionary junctionNames + ) => + new + { + template.Id, + template.Name, + template.Revision, + template.HasPendingChanges, + FallbackGroup = Named(template.FallbackGroupId, groupNames), + DefaultJunction = Named(template.DefaultJunctionId, junctionNames), + Rules = PlanningRules.FromJson(template.RulesJson), + Layers = template + .Layers.OrderByDescending(l => l.Priority) + .Select(layer => new + { + layer.Id, + layer.Name, + layer.Priority, + layer.IsEnabled, + layer.IsBackground, + Applicability = LayerApplicability.FromJson(layer.ApplicabilityJson), + Slots = layer + .Slots.OrderBy(s => s.Weekday) + .ThenBy(s => s.TargetStart) + .Select(slot => new + { + slot.Id, + slot.Title, + slot.Weekday, + slot.TargetStart, + slot.TargetDurationMinutes, + slot.Daypart, + slot.SlotKind, + Group = Named(slot.GroupId, groupNames), + Strategy = SlotStrategy.FromJson(slot.StrategyJson), + Repeat = RepeatSource.FromJson(slot.RepeatSourceJson), + slot.BlockMode, + slot.BlockValue, + slot.OverflowPolicy, + slot.IsAnchor, + slot.MaxDriftMinutes, + slot.SnapToMinutes, + JunctionBetween = Named(slot.JunctionBetweenId, junctionNames), + JunctionAfter = Named(slot.JunctionAfterId, junctionNames), + }), + }), + }; + + private static object StateFacts( + IReadOnlyList states, + IReadOnlyDictionary slotTitles + ) => + states.Select(state => new + { + state.SlotId, + Slot = slotTitles.GetValueOrDefault(state.SlotId), + state.CurrentElementKind, + state.CurrentElementId, + state.NextUnitIndex, + }); + + private static object GridFacts( + EffectiveGrid grid, + IReadOnlyDictionary groupNames + ) => + new + { + Slots = grid.Slots.Select(s => Instance(s, groupNames)), + Background = grid.Background.Select(s => Instance(s, groupNames)), + }; + + private static object Instance( + ScheduledSlot scheduled, + IReadOnlyDictionary groupNames + ) => + new + { + scheduled.Slot.Id, + scheduled.Slot.Title, + scheduled.BroadcastDate, + StartUtc = scheduled.StartUtc, + EndUtc = scheduled.StartUtc.AddMinutes(scheduled.Slot.TargetDurationMinutes), + scheduled.Slot.TargetDurationMinutes, + scheduled.Slot.SlotKind, + scheduled.Slot.IsAnchor, + Group = Named(scheduled.Slot.GroupId, groupNames), + }; + + private static object GroupFacts( + IReadOnlyDictionary> expanded, + IReadOnlyDictionary groupNames, + IReadOnlyDictionary showNames + ) => + expanded.Select(pair => new + { + GroupId = pair.Key, + Name = groupNames.GetValueOrDefault(pair.Key), + Elements = pair.Value.Select(element => new + { + element.Kind, + element.ElementId, + element.Weight, + element.Position, + element.Audience, + element.LastPlayedUtc, + UnitCount = element.Units.Count, + TotalMinutes = Math.Round(element.Units.Sum(u => u.Duration.TotalMinutes), 1), + Units = element.Units.Select(unit => new + { + unit.MediaAssetId, + unit.ShowId, + Show = showNames.GetValueOrDefault(unit.ShowId), + unit.UnitIndex, + Minutes = Math.Round(unit.Duration.TotalMinutes, 2), + }), + }), + }); + + private static object JunctionFacts( + IReadOnlyList junctions, + IReadOnlyDictionary groupNames, + IReadOnlyDictionary bumperNames + ) => + junctions.Select(junction => new + { + junction.Id, + junction.Name, + junction.MaxTotalSeconds, + Elements = junction + .Elements.OrderBy(e => e.Position) + .Select(element => new + { + element.Id, + element.Position, + element.Kind, + Group = Named(element.GroupId, groupNames), + BumperTemplate = Named(element.BumperTemplateId, bumperNames), + element.BumperVariantId, + element.AmountMode, + element.AmountValue, + element.IsRequired, + element.ChoiceKey, + element.ChoiceWeight, + Conditions = JunctionConditions.FromJson(element.ConditionsJson), + }), + }); + + private static object ScheduleFacts( + IReadOnlyList entries, + IReadOnlyDictionary slotTitles, + IReadOnlyDictionary showNames + ) => + entries.Select(entry => new + { + entry.Id, + entry.StartsAtUtc, + entry.EndsAtUtc, + Minutes = Math.Round((entry.EndsAtUtc - entry.StartsAtUtc).TotalMinutes, 2), + entry.Kind, + entry.ShowId, + Show = entry.ShowId is { } showId ? showNames.GetValueOrDefault(showId) : null, + entry.EpisodeIndex, + entry.CollectionId, + entry.SlotId, + Slot = entry.SlotId is { } slotId ? slotTitles.GetValueOrDefault(slotId) : null, + entry.MediaAssetId, + Trace = Trace(entry.TraceJson), + }); + + /// + /// Трейс записи как объект, а не строкой в строке. Разбирается в узел, а не в PlanTrace: + /// в старых записях полей могло быть меньше, и терять их при типизации — ровно тот случай, + /// когда дамп перестаёт объяснять прошлое. + /// + private static JsonNode? Trace(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + return JsonNode.Parse(json); + } + catch (JsonException) + { + return null; + } + } + + private static object? PreviewFacts( + PlanningResult? preview, + IReadOnlyDictionary slotTitles, + IReadOnlyDictionary showNames + ) => + preview is null + ? null + : new + { + Warnings = preview.Warnings.Select(w => new + { + w.Kind, + w.SlotId, + Slot = w.SlotId is { } id ? slotTitles.GetValueOrDefault(id) : null, + w.Details, + }), + Cursors = preview.Cursors, + Items = preview.Items.Select(item => new + { + item.StartsAtUtc, + item.EndsAtUtc, + Minutes = Math.Round((item.EndsAtUtc - item.StartsAtUtc).TotalMinutes, 2), + item.Kind, + item.ShowId, + Show = item.ShowId is { } showId ? showNames.GetValueOrDefault(showId) : null, + item.UnitIndex, + item.CollectionId, + item.SlotId, + Slot = item.SlotId is { } slotId ? slotTitles.GetValueOrDefault(slotId) : null, + item.MediaAssetId, + item.Trace, + }), + }; + + /// Ссылка «идентификатор + имя»: по одному GUID в дампе ничего не понять. + private static object? Named(Guid? id, IReadOnlyDictionary names) => + id is not { } value ? null : new { Id = value, Name = names.GetValueOrDefault(value) }; + + private async Task> ShowNamesAsync( + IReadOnlyDictionary> expanded, + CancellationToken cancellationToken + ) + { + var showIds = expanded + .Values.SelectMany(elements => elements) + .SelectMany(element => element.Units) + .Select(unit => unit.ShowId) + .Distinct() + .ToList(); + + return await dbContext + .Shows.AsNoTracking() + .Where(s => showIds.Contains(s.Id)) + .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Planning/DebugExport/DebugExportDto.cs b/backend/src/TeleWave.Application/Programming/Planning/DebugExport/DebugExportDto.cs new file mode 100644 index 0000000..05f51f3 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Planning/DebugExport/DebugExportDto.cs @@ -0,0 +1,7 @@ +namespace TeleWave.Application.Programming.Planning.DebugExport; + +/// +/// Готовый дамп: имя архива, его содержимое и путь сохранённой на сервере копии (null — каталог +/// не настроен, копии нет). +/// +public sealed record DebugExportDto(string FileName, byte[] Content, string? SavedTo); diff --git a/backend/src/TeleWave.Application/Programming/Planning/DebugExport/DebugFile.cs b/backend/src/TeleWave.Application/Programming/Planning/DebugExport/DebugFile.cs new file mode 100644 index 0000000..05a0f86 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Planning/DebugExport/DebugFile.cs @@ -0,0 +1,7 @@ +namespace TeleWave.Application.Programming.Planning.DebugExport; + +/// +/// Один файл будущего архива. — либо объект под сериализацию в JSON, либо +/// готовая строка (README): решает по типу упаковщик, чтобы сборщик снимка не знал про формат. +/// +public sealed record DebugFile(string Name, object? Payload); diff --git a/backend/src/TeleWave.Application/Programming/Planning/DebugExport/ExportChannelDebugCommand.cs b/backend/src/TeleWave.Application/Programming/Planning/DebugExport/ExportChannelDebugCommand.cs new file mode 100644 index 0000000..b0d736b --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Planning/DebugExport/ExportChannelDebugCommand.cs @@ -0,0 +1,13 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Programming.Planning.DebugExport; + +/// +/// Собрать отладочный дамп канала: всё, из чего строится сетка, плюс то, что из неё получилось. +/// +/// Команда, а не запрос, хотя в БД ничего не меняет: копия архива ложится на диск, а побочный +/// эффект в запросе — это ровно то место, где потом ищут «почему после обновления страницы +/// появился файл». +/// +public sealed record ExportChannelDebugCommand(Guid ChannelId) : ICommand>; diff --git a/backend/src/TeleWave.Application/Programming/Planning/DebugExport/ExportChannelDebugCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Planning/DebugExport/ExportChannelDebugCommandHandler.cs new file mode 100644 index 0000000..153e007 --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Planning/DebugExport/ExportChannelDebugCommandHandler.cs @@ -0,0 +1,94 @@ +using System.Globalization; +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Broadcast; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Programming; + +namespace TeleWave.Application.Programming.Planning.DebugExport; + +/// +/// Пакует снимок канала в архив и, если настроен каталог, оставляет копию на сервере. +/// +/// Zip, а не один большой JSON: файлов десяток, каждый читают по отдельности, а лента и группы +/// сжимаются в разы — дамп канала на неделю это мегабайты текста. +/// +public sealed class ExportChannelDebugCommandHandler( + IAppDbContext dbContext, + ChannelDebugCollector collector, + IDebugExportStore store +) : ICommandHandler> +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + // Циклы в дампе возможны (навигации EF), и падать на них нельзя: дамп собирают тогда, + // когда уже что-то не так. + ReferenceHandler = ReferenceHandler.IgnoreCycles, + Converters = { new JsonStringEnumConverter() }, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + public async Task> Handle( + ExportChannelDebugCommand command, + CancellationToken cancellationToken + ) + { + var channel = await dbContext + .Channels.AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); + if (channel is null) + return Result.Failure(ChannelErrors.NotFound); + + var template = channel.TemplateId is not { } templateId + ? null + : await dbContext + .ScheduleTemplates.AsNoTracking() + .Include(t => t.Layers) + .ThenInclude(l => l.Slots) + .AsSplitQuery() + .FirstOrDefaultAsync(t => t.Id == templateId, cancellationToken); + if (template is null) + return Result.Failure(ChannelErrors.TemplateNotFound); + + var now = DateTimeOffset.UtcNow; + var files = await collector.CollectAsync(channel, template, now, cancellationToken); + + var fileName = FileName(channel.Slug, now); + var content = Pack(files); + var savedTo = await store.SaveAsync(fileName, content, cancellationToken); + + return Result.Success(new DebugExportDto(fileName, content, savedTo)); + } + + /// Имя архива: канал и время сборки — дампы одного канала копятся десятками. + private static string FileName(string slug, DateTimeOffset now) => + $"telewave-debug-{slug}-{now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture)}.zip"; + + private static byte[] Pack(IReadOnlyList files) + { + using var buffer = new MemoryStream(); + using (var archive = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var file in files) + { + var entry = archive.CreateEntry(file.Name, CompressionLevel.Optimal); + using var stream = entry.Open(); + using var writer = new StreamWriter(stream, Encoding.UTF8); + writer.Write( + file.Payload is string text + ? text + : JsonSerializer.Serialize(file.Payload, JsonOptions) + ); + } + } + + return buffer.ToArray(); + } +} diff --git a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs index 16be7ab..06c2634 100644 --- a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs +++ b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Broadcast.Scheduling; +using TeleWave.Application.Common; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Streaming; using TeleWave.Domain.Broadcast.Scheduling; @@ -18,6 +19,7 @@ using TeleWave.Infrastructure.Metadata; using TeleWave.Infrastructure.Persistence; using TeleWave.Infrastructure.Settings; using TeleWave.Infrastructure.Streaming; +using TeleWave.Infrastructure.Support; namespace TeleWave.Infrastructure; @@ -149,6 +151,10 @@ public static class DependencyInjection private static void AddMedia(IServiceCollection services, IConfiguration configuration) { services.Configure(configuration.GetSection(StorageOptions.SectionName)); + services.Configure( + configuration.GetSection(DebugExportOptions.SectionName) + ); + services.AddSingleton(); services.Configure(configuration.GetSection(MediaOptions.SectionName)); services.AddSingleton(); diff --git a/backend/src/TeleWave.Infrastructure/Support/DebugExportStore.cs b/backend/src/TeleWave.Infrastructure/Support/DebugExportStore.cs new file mode 100644 index 0000000..8d6b157 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Support/DebugExportStore.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TeleWave.Application.Common; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Infrastructure.Support; + +/// +/// Складывает копии отладочных дампов в настроенный каталог и подрезает его до последних N архивов. +/// +/// Ошибки записи только логируются: дамп к этому моменту уже собран и уходит в браузер, и валить +/// запрос из-за того, что папка недоступна, значит терять данные ровно тогда, когда они нужны. +/// +public sealed class DebugExportStore( + IOptions options, + ILogger logger +) : IDebugExportStore +{ + private const string ArchivePattern = "*.zip"; + + private readonly DebugExportOptions _options = options.Value; + + public async Task SaveAsync( + string fileName, + byte[] content, + CancellationToken cancellationToken + ) + { + if (string.IsNullOrWhiteSpace(_options.ExportDirectory)) + return null; + + try + { + var directory = Path.GetFullPath(_options.ExportDirectory); + Directory.CreateDirectory(directory); + + var path = Path.Combine(directory, Path.GetFileName(fileName)); + await File.WriteAllBytesAsync(path, content, cancellationToken); + + Prune(directory); + return path; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + logger.LogWarning( + exception, + "Не удалось сохранить отладочный дамп в {Directory}", + _options.ExportDirectory + ); + return null; + } + } + + /// Оставляет последние архивов по времени записи. + private void Prune(string directory) + { + var keep = Math.Max(1, _options.KeepLast); + var stale = new DirectoryInfo(directory) + .GetFiles(ArchivePattern) + .OrderByDescending(file => file.LastWriteTimeUtc) + .Skip(keep) + .ToList(); + + foreach (var file in stale) + { + try + { + file.Delete(); + } + catch (Exception exception) + when (exception is IOException or UnauthorizedAccessException) + { + // Файл держит открытым архиватор или антивирус — не повод срывать экспорт. + logger.LogDebug(exception, "Не удалось удалить старый дамп {File}", file.FullName); + } + } + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Programming/ChannelDebugExportTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/ChannelDebugExportTests.cs new file mode 100644 index 0000000..94d5836 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Programming/ChannelDebugExportTests.cs @@ -0,0 +1,236 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using NSubstitute; +using TeleWave.Application.Broadcast.Bumpers; +using TeleWave.Application.Broadcast.Scheduling; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Programming.Groups; +using TeleWave.Application.Programming.Planning; +using TeleWave.Application.Programming.Planning.DebugExport; +using TeleWave.Application.Programming.Templates; +using TeleWave.Application.Streaming; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Broadcast.Scheduling; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using TeleWave.Domain.Programming; +using TeleWave.Infrastructure.Persistence; +using Xunit; + +namespace TeleWave.Application.Tests.Programming; + +/// +/// Отладочный дамп канала. Проверяется не текст файлов, а то, ради чего он существует: архив +/// собирается целиком, в нём есть все части входа планировщика, и по ним можно узнать конкретное +/// шоу — дамп из одних идентификаторов бесполезен ровно тогда, когда его открывают. +/// +public class ChannelDebugExportTests +{ + private static readonly DateTimeOffset T0 = new(2026, 4, 6, 6, 0, 0, TimeSpan.Zero); + + private sealed class FirstAlways : IRandomSource + { + public int Next(int maxExclusive) => 0; + } + + private static MediaAsset ReadyAsset(string name, TimeSpan duration) + { + var asset = MediaAsset.Register(name, ".mkv", MediaSource.Upload); + asset.MarkProcessing(); + asset.MarkReady( + new MediaReadyInfo( + duration, + 2, + (int)(duration.TotalSeconds / 2), + 1920, + 1080, + "h264", + "aac", + "assets/x" + ) + ); + return asset; + } + + private static async Task<(TestDb Fixture, Guid ChannelId)> SeedAsync() + { + var fixture = new TestDb(); + var channel = Channel.Create("Первый", "one", T0); + + var show = Show.Create("Симпсоны", ShowKind.Series); + var episodes = new[] + { + ReadyAsset("s01e01.mkv", TimeSpan.FromMinutes(20)), + ReadyAsset("s01e02.mkv", TimeSpan.FromMinutes(20)), + }; + foreach (var episode in episodes) + show.AddEpisode(episode.Id); + + var group = Group.Create("Мультсериалы"); + group.AddElement(GroupElementKind.Show, show.Id); + + var template = ScheduleTemplate.Create(channel.Id, "Сетка"); + var slot = template.Background!.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60); + slot.UpdateContent( + new SlotContent( + slot.Title, + SlotKind.Content, + group.Id, + new SlotStrategy(SlotStrategyType.Sequential).ToJson(), + null, + SlotBlockMode.Count, + 2, + OverflowPolicy.ContinueNext + ) + ); + channel.SetTemplate(template.Id); + + await using var seed = fixture.New(); + seed.MediaAssets.AddRange(episodes); + seed.Shows.Add(show); + seed.Groups.Add(group); + seed.Channels.Add(channel); + seed.ScheduleTemplates.Add(template); + await seed.SaveChangesAsync(CancellationToken.None); + + return (fixture, channel.Id); + } + + private static ExportChannelDebugCommandHandler Handler( + AppDbContext db, + IDebugExportStore store + ) + { + var random = new FirstAlways(); + var expander = new GroupExpander(db, GroupServices.Dynamic(db)); + var generator = new GridScheduleGenerator( + db, + expander, + new BumperResolver(db, Substitute.For(), random), + new PostCheckRunner(db), + random, + Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }), + Options.Create(new StreamingOptions()) + ); + + return new ExportChannelDebugCommandHandler( + db, + new ChannelDebugCollector( + db, + expander, + generator, + Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }) + ), + store + ); + } + + private static Dictionary Unpack(byte[] archive) + { + using var buffer = new MemoryStream(archive); + using var zip = new ZipArchive(buffer, ZipArchiveMode.Read); + + var files = new Dictionary(StringComparer.Ordinal); + foreach (var entry in zip.Entries) + { + using var stream = entry.Open(); + using var reader = new StreamReader(stream, Encoding.UTF8); + files[entry.FullName] = reader.ReadToEnd(); + } + + return files; + } + + [Fact] + public async Task Export_PacksEveryPartOfThePlannerInput() + { + var (fixture, channelId) = await SeedAsync(); + var store = Substitute.For(); + store + .SaveAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("D:/debug-exports/dump.zip"); + + await using var db = fixture.New(); + var result = await Handler(db, store) + .Handle(new ExportChannelDebugCommand(channelId), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.EndsWith(".zip", result.Value.FileName, StringComparison.Ordinal); + Assert.Equal("D:/debug-exports/dump.zip", result.Value.SavedTo); + + var files = Unpack(result.Value.Content); + Assert.Equal( + [ + "README.md", + "channel.json", + "effective-grid.json", + "groups.json", + "junctions.json", + "plan-preview.json", + "schedule.json", + "slot-states.json", + "summary.json", + "template.json", + ], + files.Keys.Order(StringComparer.Ordinal) + ); + + // Дамп обязан читаться глазами: рядом с идентификаторами стоят имена, а единицы группы + // развёрнуты поштучно — иначе «почему вышла эта серия» по нему не разобрать. + Assert.Contains("Симпсоны", files["groups.json"], StringComparison.Ordinal); + Assert.Contains("Дневной блок", files["template.json"], StringComparison.Ordinal); + Assert.Contains("Дневной блок", files["effective-grid.json"], StringComparison.Ordinal); + + using var groups = JsonDocument.Parse(files["groups.json"]); + var units = groups + .RootElement[0] + .GetProperty("elements")[0] + .GetProperty("units") + .EnumerateArray() + .ToList(); + Assert.Equal(2, units.Count); + Assert.Equal("Симпсоны", units[0].GetProperty("show").GetString()); + } + + [Fact] + public async Task Export_DryRunGoesIntoTheDump() + { + // Сухой прогон — половина ценности дампа: он показывает, что планировщик построил бы + // сейчас, и без него по архиву не отличить «данные плохие» от «расчёт плохой». + var (fixture, channelId) = await SeedAsync(); + // Каталог не настроен — хранилище так и отвечает: копии нет. + var store = Substitute.For(); + store + .SaveAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((string?)null); + + await using var db = fixture.New(); + var result = await Handler(db, store) + .Handle(new ExportChannelDebugCommand(channelId), CancellationToken.None); + + Assert.True(result.IsSuccess); + var files = Unpack(result.Value.Content); + + using var preview = JsonDocument.Parse(files["plan-preview.json"]); + Assert.NotEqual(JsonValueKind.Null, preview.RootElement.ValueKind); + Assert.NotEmpty(preview.RootElement.GetProperty("items").EnumerateArray()); + + // Копии нет, но архив всё равно собран и уезжает в браузер. + Assert.Null(result.Value.SavedTo); + } + + [Fact] + public async Task Export_FailsWithoutChannel() + { + var (fixture, _) = await SeedAsync(); + await using var db = fixture.New(); + + var result = await Handler(db, Substitute.For()) + .Handle(new ExportChannelDebugCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.False(result.IsSuccess); + } +} diff --git a/debug-exports/.gitkeep b/debug-exports/.gitkeep new file mode 100644 index 0000000..28fd582 --- /dev/null +++ b/debug-exports/.gitkeep @@ -0,0 +1,2 @@ +# Сюда складываются архивы отладочного дампа канала (кнопка «Дебаг-экспорт» в админке). +# Содержимое в гит не едет — см. .gitignore. diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index 31be9f8..77b5812 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -767,6 +767,19 @@ ScheduleEntry — существующая сущность, } ``` +**Отладочный дамп канала.** Трейс объясняет одну запись, но «сетка собралась не так» — вопрос +не про запись, а про весь вход планировщика сразу. Кнопка в карточке канала собирает его архивом: +настройки канала, шаблон со слотами и разобранными стратегиями, курсоры слотов, эффективная сетка +на горизонт (включая перекрытые слоты фона), развёрнутые группы **вплоть до отдельных единиц**, +стыки с условиями, записанная лента с трейсами и сухой прогон от текущего состояния. Всё, что +хранится JSON-строкой, разбирается в объекты, а рядом с каждым идентификатором стоит имя: дамп +читают глазами чаще, чем кодом. + +Сухой прогон в дампе принципиален: рядом с записанной лентой он отвечает на первый вопрос +разбора — данные плохие или расчёт. Архив всегда уезжает в браузер, а копия ложится в каталог +`Debug:ExportDirectory` (в разработке — папка `debug-exports/` репозитория, в гите игнорируется); +пустая настройка означает «копию не хранить», и на сервере это состояние по умолчанию. + --- ## 4. Алгоритм генерации diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index fd6a64a..3295a88 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' -import { ChevronLeft, Send, Undo2 } from 'lucide-react' +import { Bug, ChevronLeft, Send, Undo2 } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { listAllMedia } from '@/features/admin/media/api' @@ -12,7 +12,13 @@ import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' import { Card, CardContent } from '@/shared/ui/card' import { toast } from '@/shared/ui/toast-store' -import { applyChannelTemplate, getChannel, getChannelTemplate, restoreChannelTemplate } from './api' +import { + applyChannelTemplate, + exportChannelDebug, + getChannel, + getChannelTemplate, + restoreChannelTemplate, +} from './api' import { AirSchedule } from './components/AirSchedule' import { ApplyDialog } from './components/ApplyDialog' import { ApplyReportDialog } from './components/ApplyReportDialog' @@ -55,6 +61,12 @@ export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) { } const onError = useApiError() + const debugMutation = useMutation({ + mutationFn: () => exportChannelDebug(channelId), + onSuccess: (fileName) => toast.success(fileName), + onError, + }) + const applyMutation = useMutation({ mutationFn: () => applyChannelTemplate(channelId), onSuccess: (result) => { @@ -96,6 +108,17 @@ export function ChannelDetail({ channelId }: Readonly<{ channelId: string }>) {

{channel.name}

{channel.number !== null && № {channel.number}} {!channel.isEnabled && {t('admin.channels.disabled')}} + {/* Дамп для разбора «почему сетка построилась так»: архив уезжает в браузер, а копия + остаётся на сервере, если каталог настроен. */} + diff --git a/frontend/src/features/admin/channels/api.ts b/frontend/src/features/admin/channels/api.ts index 27aba3e..1c6805d 100644 --- a/frontend/src/features/admin/channels/api.ts +++ b/frontend/src/features/admin/channels/api.ts @@ -1,4 +1,4 @@ -import { apiRequest } from '@/shared/api/client' +import { apiDownload, apiRequest } from '@/shared/api/client' import type { ApplyResultDto, ChannelDto, @@ -245,3 +245,25 @@ export function getSchedule(id: string, from: Date, to: Date) { const query = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() }) return apiRequest(`/admin/channels/${id}/schedule?${query.toString()}`) } + +/** + * Отладочный дамп канала: вход планировщика, состояние слотов, лента и сухой прогон одним архивом. + * Тянем через fetch, а не ссылкой: эндпоинт закрыт Bearer'ом, и `` заголовок не отправит. + */ +export async function exportChannelDebug(channelId: string) { + const { blob, fileName } = await apiDownload( + `/admin/channels/${channelId}/debug-export`, + 'telewave-debug.zip', + { method: 'POST' }, + ) + const url = URL.createObjectURL(blob) + try { + const link = document.createElement('a') + link.href = url + link.download = fileName + link.click() + } finally { + URL.revokeObjectURL(url) + } + return fileName +} diff --git a/frontend/src/shared/api/client.ts b/frontend/src/shared/api/client.ts index bef3503..7f14544 100644 --- a/frontend/src/shared/api/client.ts +++ b/frontend/src/shared/api/client.ts @@ -74,15 +74,19 @@ async function parseError(response: Response): Promise { export async function apiDownload( path: string, fallbackName: string, - skipRefresh = false, + options: { method?: 'GET' | 'POST'; skipRefresh?: boolean } = {}, ): Promise<{ blob: Blob; fileName: string }> { const headers: Record = {} if (accessToken) headers.Authorization = `Bearer ${accessToken}` - const response = await fetch(`/api${path}`, { headers, credentials: 'include' }) + const response = await fetch(`/api${path}`, { + method: options.method ?? 'GET', + headers, + credentials: 'include', + }) - if (response.status === 401 && !skipRefresh && (await refreshAccessToken())) - return apiDownload(path, fallbackName, true) + if (response.status === 401 && !options.skipRefresh && (await refreshAccessToken())) + return apiDownload(path, fallbackName, { ...options, skipRefresh: true }) if (!response.ok) throw await parseError(response) diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index e924fa4..899c64d 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -783,6 +783,9 @@ export const en = { noSchedule: 'Schedule not built yet', airToday: 'Today', airCount: 'Entries for the day: {{count}}', + debugExport: 'Debug export', + debugExportHint: + 'An archive with a snapshot of the channel: grid, groups, slot cursors, tape and a dry run — to work out why the schedule came out the way it did.', airHidePast: 'Hide past entries', airHideBreaks: 'Hide breaks', }, diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index 6979534..ffbd62c 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -778,6 +778,9 @@ export const ru = { noSchedule: 'Расписание ещё не построено', airToday: 'Сегодня', airCount: 'Записей за сутки: {{count}}', + debugExport: 'Дебаг-экспорт', + debugExportHint: + 'Архив со снимком канала: сетка, группы, курсоры слотов, лента и сухой прогон — для разбора, почему эфир собрался именно так.', airHidePast: 'Скрывать прошедшее', airHideBreaks: 'Скрывать врезки', },