Enhance scheduling and trace functionalities: add CollectionId to ScheduleEntry and PlannedItem models, update related query handlers and frontend components to support collection information in entry traces. Improve data handling in scheduling logic and enhance user experience in trace display.
build / backend (push) Successful in 2m4s
build / frontend (push) Successful in 39s
tests / backend-tests (push) Successful in 2m10s

This commit is contained in:
Leonid Pershin
2026-07-26 14:59:54 +03:00
parent f699576582
commit 2445ba56b5
16 changed files with 2467 additions and 580 deletions
@@ -142,7 +142,8 @@ public sealed class GridScheduleGenerator(
item.SlotId,
item.Trace is null
? null
: JsonSerializer.Serialize(item.Trace, TraceJsonOptions)
: JsonSerializer.Serialize(item.Trace, TraceJsonOptions),
item.CollectionId
)
);
added++;
@@ -1,37 +1,39 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
/// <summary>
/// Цепочка происхождения записи — «почему это здесь» (см. 6.5). Трейс пишется в момент генерации
/// и хранится в самой записи: восстановить его потом невозможно, состав групп и правила меняются.
/// </summary>
public sealed record GetEntryTraceQuery(Guid EntryId) : IQuery<Result<EntryTraceDto>>;
public sealed record EntryTraceDto(
Guid EntryId,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
string? ShowName,
int? EpisodeIndex,
/// <summary>Слой и слот, из которых выросла запись; null — трейс не писался (старая запись).</summary>
string? LayerName,
int? LayerPriority,
string? SlotTitle,
SlotKind? SlotKind,
int? SlotWeekday,
TimeOnly? SlotTargetStart,
int? SlotDurationMinutes,
string? GroupName,
int? GroupItemCount,
SlotStrategyKind? Strategy,
int? CooldownDays,
/// <summary>Сколько кандидатов осталось после остывания (null — выбор шёл без него).</summary>
int? CandidatesAfterCooldown,
int DriftMinutes,
bool Snapped,
string? JunctionName
);
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
/// <summary>
/// Цепочка происхождения записи — «почему это здесь» (см. 6.5). Трейс пишется в момент генерации
/// и хранится в самой записи: восстановить его потом невозможно, состав групп и правила меняются.
/// </summary>
public sealed record GetEntryTraceQuery(Guid EntryId) : IQuery<Result<EntryTraceDto>>;
public sealed record EntryTraceDto(
Guid EntryId,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
string? ShowName,
int? EpisodeIndex,
/// <summary>Слой и слот, из которых выросла запись; null — трейс не писался (старая запись).</summary>
string? LayerName,
int? LayerPriority,
string? SlotTitle,
SlotKind? SlotKind,
int? SlotWeekday,
TimeOnly? SlotTargetStart,
int? SlotDurationMinutes,
string? GroupName,
int? GroupItemCount,
/// <summary>Коллекция, частью которой шла запись, — если в эфир шла франшиза, а не одиночное шоу.</summary>
string? CollectionName,
SlotStrategyKind? Strategy,
int? CooldownDays,
/// <summary>Сколько кандидатов осталось после остывания (null — выбор шёл без него).</summary>
int? CandidatesAfterCooldown,
int DriftMinutes,
bool Snapped,
string? JunctionName
);
@@ -1,141 +1,151 @@
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.Application.Programming.Templates;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetEntryTraceQuery, Result<EntryTraceDto>>
{
/// <summary>Те же настройки, что при записи трейса генератором.</summary>
private static readonly JsonSerializerOptions TraceJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
};
public async Task<Result<EntryTraceDto>> Handle(
GetEntryTraceQuery query,
CancellationToken cancellationToken
)
{
var entry = await dbContext
.ScheduleEntries.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == query.EntryId, cancellationToken);
if (entry is null)
return Result.Failure<EntryTraceDto>(ChannelErrors.NotFound);
var showName =
entry.ShowId is { } showId
? await dbContext
.Shows.AsNoTracking()
.Where(s => s.Id == showId)
.Select(s => s.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var trace = Parse(entry.TraceJson);
if (trace is null)
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
0,
false,
null
)
);
// Слот мог быть удалён или изменён после генерации — трейс от этого не портится, просто
// часть подписей окажется пустой.
var slot = trace.SlotId is { } slotId
? await dbContext.Slots.AsNoTracking().FirstOrDefaultAsync(s => s.Id == slotId, cancellationToken)
: null;
var layer = slot is null
? null
: await dbContext
.GridLayers.AsNoTracking()
.FirstOrDefaultAsync(l => l.Id == slot.LayerId, cancellationToken);
var group = slot?.GroupId is { } groupId
? await dbContext
.Groups.AsNoTracking()
.Where(g => g.Id == groupId)
.Select(g => new { g.Name, g.ItemCount })
.FirstOrDefaultAsync(cancellationToken)
: null;
var strategy = SlotStrategy.FromJson(slot?.StrategyJson);
var junctionId = slot?.JunctionAfterId ?? slot?.JunctionBetweenId;
var junctionName = junctionId is { } id
? await dbContext
.JunctionTemplates.AsNoTracking()
.Where(j => j.Id == id)
.Select(j => j.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
layer?.Name,
layer?.Priority,
slot?.Title,
trace.SlotKind,
slot?.Weekday,
slot?.TargetStart,
slot?.TargetDurationMinutes,
group?.Name,
group?.ItemCount,
trace.Strategy,
strategy?.CooldownDays,
trace.CandidatesAfterCooldown,
trace.DriftMinutes,
trace.Snapped,
junctionName
)
);
}
private static PlanTrace? Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<PlanTrace>(json, TraceJsonOptions);
}
catch (JsonException)
{
return null;
}
}
}
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.Application.Programming.Templates;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetEntryTraceQuery, Result<EntryTraceDto>>
{
/// <summary>Те же настройки, что при записи трейса генератором.</summary>
private static readonly JsonSerializerOptions TraceJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
};
public async Task<Result<EntryTraceDto>> Handle(
GetEntryTraceQuery query,
CancellationToken cancellationToken
)
{
var entry = await dbContext
.ScheduleEntries.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == query.EntryId, cancellationToken);
if (entry is null)
return Result.Failure<EntryTraceDto>(ChannelErrors.NotFound);
var showName =
entry.ShowId is { } showId
? await dbContext
.Shows.AsNoTracking()
.Where(s => s.Id == showId)
.Select(s => s.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var collectionName = entry.CollectionId is { } collectionId
? await dbContext
.Collections.AsNoTracking()
.Where(c => c.Id == collectionId)
.Select(c => c.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var trace = Parse(entry.TraceJson);
if (trace is null)
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
null,
null,
null,
null,
null,
null,
null,
null,
null,
collectionName,
null,
null,
null,
0,
false,
null
)
);
// Слот мог быть удалён или изменён после генерации — трейс от этого не портится, просто
// часть подписей окажется пустой.
var slot = trace.SlotId is { } slotId
? await dbContext.Slots.AsNoTracking().FirstOrDefaultAsync(s => s.Id == slotId, cancellationToken)
: null;
var layer = slot is null
? null
: await dbContext
.GridLayers.AsNoTracking()
.FirstOrDefaultAsync(l => l.Id == slot.LayerId, cancellationToken);
var group = slot?.GroupId is { } groupId
? await dbContext
.Groups.AsNoTracking()
.Where(g => g.Id == groupId)
.Select(g => new { g.Name, g.ItemCount })
.FirstOrDefaultAsync(cancellationToken)
: null;
var strategy = SlotStrategy.FromJson(slot?.StrategyJson);
var junctionId = slot?.JunctionAfterId ?? slot?.JunctionBetweenId;
var junctionName = junctionId is { } id
? await dbContext
.JunctionTemplates.AsNoTracking()
.Where(j => j.Id == id)
.Select(j => j.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
layer?.Name,
layer?.Priority,
slot?.Title,
trace.SlotKind,
slot?.Weekday,
slot?.TargetStart,
slot?.TargetDurationMinutes,
group?.Name,
group?.ItemCount,
collectionName,
trace.Strategy,
strategy?.CooldownDays,
trace.CandidatesAfterCooldown,
trace.DriftMinutes,
trace.Snapped,
junctionName
)
);
}
private static PlanTrace? Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<PlanTrace>(json, TraceJsonOptions);
}
catch (JsonException)
{
return null;
}
}
}
@@ -1,119 +1,127 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Материализованная запись расписания канала: конкретный ассет в конкретное время. Программы и
/// реклама идут встык (<see cref="EndsAtUtc"/> одной равен <see cref="StartsAtUtc"/> следующей).
/// </summary>
public class ScheduleEntry
{
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public Guid MediaAssetId { get; private set; }
public ScheduleEntryKind Kind { get; private set; }
public DateTimeOffset StartsAtUtc { get; private set; }
public DateTimeOffset EndsAtUtc { get; private set; }
/// <summary>Шоу (для <see cref="ScheduleEntryKind.Program"/>) — для EPG.</summary>
public Guid? ShowId { get; private set; }
/// <summary>Индекс серии в упорядоченном списке шоу (для EPG).</summary>
public int? EpisodeIndex { get; private set; }
/// <summary>Подблок заставки (<see cref="BumperTextVariant"/>), которым отрендерена запись — для метки в админ-расписании.</summary>
public Guid? BumperVariantId { get; private set; }
/// <summary>Слот сетки, породивший запись (null — старая ротация либо служебная запись).</summary>
public Guid? SlotId { get; private set; }
/// <summary>
/// Цепочка происхождения (JSON): слой, слот, группа, стратегия, дрейф. Пишется в момент
/// генерации — восстановить её потом невозможно, а без неё отладка сетки превращается
/// в угадывание.
/// </summary>
public string? TraceJson { get; private set; }
private ScheduleEntry() { }
/// <summary>Запись, порождённая слотом сетки: программа, заполнитель или конец вещания.</summary>
public static ScheduleEntry FromSlot(
Guid channelId,
Guid mediaAssetId,
ScheduleEntryKind kind,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid? showId,
int? episodeIndex,
Guid? slotId,
string? traceJson
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = kind,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
EpisodeIndex = episodeIndex,
SlotId = slotId,
TraceJson = traceJson,
};
public static ScheduleEntry Program(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid showId,
int episodeIndex
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Program,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
EpisodeIndex = episodeIndex,
};
public static ScheduleEntry Ad(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Ad,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
};
/// <summary>Заставка-переход. <paramref name="showId"/> — следующее шоу (для EPG/справки).</summary>
public static ScheduleEntry Bumper(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid? showId,
Guid? bumperVariantId
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Bumper,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
BumperVariantId = bumperVariantId,
};
}
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Материализованная запись расписания канала: конкретный ассет в конкретное время. Программы и
/// реклама идут встык (<see cref="EndsAtUtc"/> одной равен <see cref="StartsAtUtc"/> следующей).
/// </summary>
public class ScheduleEntry
{
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public Guid MediaAssetId { get; private set; }
public ScheduleEntryKind Kind { get; private set; }
public DateTimeOffset StartsAtUtc { get; private set; }
public DateTimeOffset EndsAtUtc { get; private set; }
/// <summary>Шоу (для <see cref="ScheduleEntryKind.Program"/>) — для EPG.</summary>
public Guid? ShowId { get; private set; }
/// <summary>Индекс серии в упорядоченном списке шоу (для EPG).</summary>
public int? EpisodeIndex { get; private set; }
/// <summary>Подблок заставки (<see cref="BumperTextVariant"/>), которым отрендерена запись — для метки в админ-расписании.</summary>
public Guid? BumperVariantId { get; private set; }
/// <summary>Слот сетки, породивший запись (null — служебная запись вне слотов).</summary>
public Guid? SlotId { get; private set; }
/// <summary>
/// Коллекция (франшиза), частью которой шла запись, или null. Из шоу её не вывести: одно и то же
/// шоу попадает в эфир и само по себе, и внутри коллекции, а группа хранит только ссылку.
/// </summary>
public Guid? CollectionId { get; private set; }
/// <summary>
/// Цепочка происхождения (JSON): слой, слот, группа, стратегия, дрейф. Пишется в момент
/// генерации — восстановить её потом невозможно, а без неё отладка сетки превращается
/// в угадывание.
/// </summary>
public string? TraceJson { get; private set; }
private ScheduleEntry() { }
/// <summary>Запись, порождённая слотом сетки: программа, заполнитель или конец вещания.</summary>
public static ScheduleEntry FromSlot(
Guid channelId,
Guid mediaAssetId,
ScheduleEntryKind kind,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid? showId,
int? episodeIndex,
Guid? slotId,
string? traceJson,
Guid? collectionId = null
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = kind,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
EpisodeIndex = episodeIndex,
SlotId = slotId,
TraceJson = traceJson,
CollectionId = collectionId,
};
public static ScheduleEntry Program(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid showId,
int episodeIndex
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Program,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
EpisodeIndex = episodeIndex,
};
public static ScheduleEntry Ad(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Ad,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
};
/// <summary>Заставка-переход. <paramref name="showId"/> — следующее шоу (для EPG/справки).</summary>
public static ScheduleEntry Bumper(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid? showId,
Guid? bumperVariantId
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Bumper,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
BumperVariantId = bumperVariantId,
};
}
+174 -173
View File
@@ -1,173 +1,174 @@
namespace TeleWave.Domain.Library;
/// <summary>
/// Переиспользуемое шоу в общей библиотеке: сериал (упорядоченные серии) либо полнометражка.
/// Серии идут строго в порядке <see cref="ShowEpisode.Position"/>; курсор показа на канале хранится
/// отдельно на связке канал↔шоу (см. Broadcast/ChannelShow).
/// </summary>
public class Show
{
private readonly List<ShowEpisode> _episodes = new();
private readonly List<ShowGenre> _genres = new();
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
/// <summary>Оригинальное название (обычно на английском) — по нему ищутся метаданные; на экранах
/// продолжаем показывать <see cref="Name"/>. Null/пусто — ищем по <see cref="Name"/>.</summary>
public string? OriginalName { get; private set; }
public string? Description { get; private set; }
public ShowKind Kind { get; private set; }
/// <summary>Категория аудитории (обычное/детское/взрослое) — под будущие фильтры показа.</summary>
public ShowAudience Audience { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
// ── Метаданные (TMDb/OMDb/вручную) ──
/// <summary>Источник метаданных: «tmdb»/«omdb»/«manual» или null, если не заданы.</summary>
public string? MetadataProvider { get; private set; }
/// <summary>Идентификатор шоу во внешнем источнике (для довыгрузки серий).</summary>
public string? MetadataExternalId { get; private set; }
public int? Year { get; private set; }
/// <summary>Постер шоу — ссылка на запись общего реестра изображений (<c>Domain/Images</c>) или null.</summary>
public Guid? PosterImageId { get; private set; }
/// <summary>Серии шоу (backing-field для EF). Порядок показа — по <see cref="ShowEpisode.Position"/>;
/// потребители сортируют явно (см. загрузчик планировщика).</summary>
public IReadOnlyList<ShowEpisode> Episodes => _episodes;
/// <summary>Жанры шоу (backing-field для EF). Ровно один помечен основным, если список не пуст.</summary>
public IReadOnlyList<ShowGenre> Genres => _genres;
private Show() { }
public static Show Create(
string name,
ShowKind kind,
string? description = null,
string? originalName = null,
ShowAudience audience = ShowAudience.General
) =>
new()
{
Id = Guid.NewGuid(),
Name = name,
OriginalName = Normalize(originalName),
Kind = kind,
Description = description,
Audience = audience,
CreatedAt = DateTimeOffset.UtcNow,
};
/// <summary>Задать категорию аудитории.</summary>
public void SetAudience(ShowAudience audience) => Audience = audience;
/// <summary>Основной жанр или null, если жанры не проставлены.</summary>
public Guid? PrimaryGenreId => _genres.FirstOrDefault(g => g.IsPrimary)?.GenreId;
/// <summary>
/// Полностью заменяет набор жанров; дубликаты и пустые идентификаторы отбрасываются. Основным
/// становится <paramref name="primaryGenreId"/>, если он попал в набор, иначе первый в списке —
/// так шоу с жанрами никогда не остаётся без основного.
/// </summary>
public void SetGenres(IEnumerable<Guid> genreIds, Guid? primaryGenreId = null)
{
var ids = genreIds.Where(id => id != Guid.Empty).Distinct().ToList();
_genres.Clear();
if (ids.Count == 0)
return;
var primary = primaryGenreId is { } candidate && ids.Contains(candidate) ? candidate : ids[0];
foreach (var id in ids)
_genres.Add(ShowGenre.Create(Id, id, id == primary));
}
public void Rename(string name, string? description)
{
Name = name;
Description = description;
}
/// <summary>Изменить отображаемое название (на экранах). Метаданные ищутся по <see cref="OriginalName"/>.</summary>
public void SetName(string name) => Name = name;
/// <summary>Задать/снять оригинальное название (пустая строка трактуется как отсутствие).</summary>
public void SetOriginalName(string? originalName) => OriginalName = Normalize(originalName);
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
/// <summary>Добавляет серию в конец. Для <see cref="ShowKind.Single"/> допустима ровно одна серия
/// (инвариант защищён самим агрегатом; вызывающий обычно проверяет <see cref="CanAddEpisode"/> заранее
/// и возвращает управляемую ошибку — исключение здесь лишь страховка от обхода).</summary>
public ShowEpisode AddEpisode(Guid mediaAssetId)
{
if (!CanAddEpisode)
throw new InvalidOperationException(
"Только сериал может содержать больше одной серии."
);
var nextPosition = _episodes.Count == 0 ? 0 : _episodes.Max(e => e.Position) + 1;
var episode = ShowEpisode.Create(Id, mediaAssetId, nextPosition);
_episodes.Add(episode);
return episode;
}
public bool RemoveEpisode(Guid episodeId)
{
var episode = _episodes.FirstOrDefault(e => e.Id == episodeId);
if (episode is null)
return false;
_episodes.Remove(episode);
return true;
}
/// <summary>Несколько серий бывает только у сериала. У полнометражки и у ролика-врезки серия ровно
/// одна: ролик с тремя сериями вёл бы себя в планировщике как мини-сериал, а задуман как единица.</summary>
public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0;
/// <summary>Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null.</summary>
public void ApplyMetadata(
string provider,
string externalId,
string? description,
int? year,
Guid? posterImageId
)
{
MetadataProvider = provider;
MetadataExternalId = externalId;
if (!string.IsNullOrWhiteSpace(description))
Description = description;
Year = year;
if (posterImageId is not null)
PosterImageId = posterImageId;
}
/// <summary>Ручная правка метаданных (без внешнего источника).</summary>
public void UpdateMetadataManual(string? description, int? year)
{
MetadataProvider = "manual";
MetadataExternalId = null;
Description = description;
Year = year;
}
/// <summary>Привязать/снять постер шоу (ссылка на запись реестра изображений).</summary>
public void SetPosterImage(Guid? imageId) => PosterImageId = imageId;
/// <summary>Сбросить все метаданные и отвязать постер (сама картинка остаётся в галерее).</summary>
public void ClearMetadata()
{
MetadataProvider = null;
MetadataExternalId = null;
Year = null;
PosterImageId = null;
Description = null;
}
}
namespace TeleWave.Domain.Library;
/// <summary>
/// Переиспользуемое шоу в общей библиотеке: сериал (упорядоченные серии) либо полнометражка.
/// Серии идут строго в порядке <see cref="ShowEpisode.Position"/>; где остановился показ — знает
/// состояние слота планировщика (<c>Programming/SlotState</c>), а не само шоу: одно шоу играет
/// на нескольких каналах и в нескольких слотах, и курсор у каждого свой.
/// </summary>
public class Show
{
private readonly List<ShowEpisode> _episodes = new();
private readonly List<ShowGenre> _genres = new();
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
/// <summary>Оригинальное название (обычно на английском) — по нему ищутся метаданные; на экранах
/// продолжаем показывать <see cref="Name"/>. Null/пусто — ищем по <see cref="Name"/>.</summary>
public string? OriginalName { get; private set; }
public string? Description { get; private set; }
public ShowKind Kind { get; private set; }
/// <summary>Категория аудитории (обычное/детское/взрослое) — под будущие фильтры показа.</summary>
public ShowAudience Audience { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
// ── Метаданные (TMDb/OMDb/вручную) ──
/// <summary>Источник метаданных: «tmdb»/«omdb»/«manual» или null, если не заданы.</summary>
public string? MetadataProvider { get; private set; }
/// <summary>Идентификатор шоу во внешнем источнике (для довыгрузки серий).</summary>
public string? MetadataExternalId { get; private set; }
public int? Year { get; private set; }
/// <summary>Постер шоу — ссылка на запись общего реестра изображений (<c>Domain/Images</c>) или null.</summary>
public Guid? PosterImageId { get; private set; }
/// <summary>Серии шоу (backing-field для EF). Порядок показа — по <see cref="ShowEpisode.Position"/>;
/// потребители сортируют явно (см. загрузчик планировщика).</summary>
public IReadOnlyList<ShowEpisode> Episodes => _episodes;
/// <summary>Жанры шоу (backing-field для EF). Ровно один помечен основным, если список не пуст.</summary>
public IReadOnlyList<ShowGenre> Genres => _genres;
private Show() { }
public static Show Create(
string name,
ShowKind kind,
string? description = null,
string? originalName = null,
ShowAudience audience = ShowAudience.General
) =>
new()
{
Id = Guid.NewGuid(),
Name = name,
OriginalName = Normalize(originalName),
Kind = kind,
Description = description,
Audience = audience,
CreatedAt = DateTimeOffset.UtcNow,
};
/// <summary>Задать категорию аудитории.</summary>
public void SetAudience(ShowAudience audience) => Audience = audience;
/// <summary>Основной жанр или null, если жанры не проставлены.</summary>
public Guid? PrimaryGenreId => _genres.FirstOrDefault(g => g.IsPrimary)?.GenreId;
/// <summary>
/// Полностью заменяет набор жанров; дубликаты и пустые идентификаторы отбрасываются. Основным
/// становится <paramref name="primaryGenreId"/>, если он попал в набор, иначе первый в списке —
/// так шоу с жанрами никогда не остаётся без основного.
/// </summary>
public void SetGenres(IEnumerable<Guid> genreIds, Guid? primaryGenreId = null)
{
var ids = genreIds.Where(id => id != Guid.Empty).Distinct().ToList();
_genres.Clear();
if (ids.Count == 0)
return;
var primary = primaryGenreId is { } candidate && ids.Contains(candidate) ? candidate : ids[0];
foreach (var id in ids)
_genres.Add(ShowGenre.Create(Id, id, id == primary));
}
public void Rename(string name, string? description)
{
Name = name;
Description = description;
}
/// <summary>Изменить отображаемое название (на экранах). Метаданные ищутся по <see cref="OriginalName"/>.</summary>
public void SetName(string name) => Name = name;
/// <summary>Задать/снять оригинальное название (пустая строка трактуется как отсутствие).</summary>
public void SetOriginalName(string? originalName) => OriginalName = Normalize(originalName);
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
/// <summary>Добавляет серию в конец. Для <see cref="ShowKind.Single"/> допустима ровно одна серия
/// (инвариант защищён самим агрегатом; вызывающий обычно проверяет <see cref="CanAddEpisode"/> заранее
/// и возвращает управляемую ошибку — исключение здесь лишь страховка от обхода).</summary>
public ShowEpisode AddEpisode(Guid mediaAssetId)
{
if (!CanAddEpisode)
throw new InvalidOperationException(
"Только сериал может содержать больше одной серии."
);
var nextPosition = _episodes.Count == 0 ? 0 : _episodes.Max(e => e.Position) + 1;
var episode = ShowEpisode.Create(Id, mediaAssetId, nextPosition);
_episodes.Add(episode);
return episode;
}
public bool RemoveEpisode(Guid episodeId)
{
var episode = _episodes.FirstOrDefault(e => e.Id == episodeId);
if (episode is null)
return false;
_episodes.Remove(episode);
return true;
}
/// <summary>Несколько серий бывает только у сериала. У полнометражки и у ролика-врезки серия ровно
/// одна: ролик с тремя сериями вёл бы себя в планировщике как мини-сериал, а задуман как единица.</summary>
public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0;
/// <summary>Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null.</summary>
public void ApplyMetadata(
string provider,
string externalId,
string? description,
int? year,
Guid? posterImageId
)
{
MetadataProvider = provider;
MetadataExternalId = externalId;
if (!string.IsNullOrWhiteSpace(description))
Description = description;
Year = year;
if (posterImageId is not null)
PosterImageId = posterImageId;
}
/// <summary>Ручная правка метаданных (без внешнего источника).</summary>
public void UpdateMetadataManual(string? description, int? year)
{
MetadataProvider = "manual";
MetadataExternalId = null;
Description = description;
Year = year;
}
/// <summary>Привязать/снять постер шоу (ссылка на запись реестра изображений).</summary>
public void SetPosterImage(Guid? imageId) => PosterImageId = imageId;
/// <summary>Сбросить все метаданные и отвязать постер (сама картинка остаётся в галерее).</summary>
public void ClearMetadata()
{
MetadataProvider = null;
MetadataExternalId = null;
Year = null;
PosterImageId = null;
Description = null;
}
}
@@ -135,7 +135,9 @@ public sealed record PlannedItem(
/// <summary>Для заставки: блок, пара «из/в» и место под ассет, который отрендерят позже.</summary>
Guid? BumperTemplateId = null,
Guid? FromShowId = null,
Guid? ToShowId = null
Guid? ToShowId = null,
/// <summary>Коллекция, частью которой шла единица (null — шоу играло само по себе).</summary>
Guid? CollectionId = null
);
public enum PlannedItemKind
@@ -333,7 +333,7 @@ public static class SchedulePlanner
if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd))
break;
items.Add(Program(unit, cursor, slot.SlotId, slotTrace));
items.Add(Program(unit, cursor, slot.SlotId, slotTrace, CollectionOf(element)));
cursor += unit.Duration;
accumulated += unit.Duration;
unitIndex++;
@@ -451,11 +451,16 @@ public static class SchedulePlanner
return cursor;
}
/// <summary>Коллекция элемента или null, если в эфир шло отдельное шоу.</summary>
private static Guid? CollectionOf(PlanningElement element) =>
element.Kind == GroupElementKind.Collection ? element.ElementId : null;
private static PlannedItem Program(
PlanningUnit unit,
DateTimeOffset start,
Guid slotId,
PlanTrace trace
PlanTrace trace,
Guid? collectionId = null
) =>
new(
unit.MediaAssetId,
@@ -465,7 +470,8 @@ public static class SchedulePlanner
unit.UnitIndex,
slotId,
PlannedItemKind.Program,
trace
trace,
CollectionId: collectionId
);
private static PlanningCursorUpdate CursorUpdate(
@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ScheduleEntryCollection : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "CollectionId",
table: "ScheduleEntries",
type: "uuid",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CollectionId",
table: "ScheduleEntries");
}
}
}
@@ -399,6 +399,9 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<Guid?>("CollectionId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
@@ -0,0 +1,290 @@
using Microsoft.Extensions.Options;
using NSubstitute;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Infrastructure.Persistence;
using Xunit;
namespace TeleWave.Integration.Tests;
/// <summary>
/// Конвейер генерации против настоящей БД: разворот групп, наполнение слотов, запись ленты,
/// продвижение курсоров. Юнит-тесты покрывают чистую математику планировщика, а здесь проверяется
/// то, чего они не видят, — реальные запросы EF, транзакция, advisory-lock и ExecuteDelete.
/// </summary>
[Collection("postgres")]
public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixture)
{
private static readonly DateTimeOffset Now = new(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
[SkippableFact]
public async Task Generate_FillsSlotFromGroup_AndAdvancesCursor()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var db = fixture.CreateContext();
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
Assert.False(report.ChannelSkipped);
Assert.True(report.Added > 0);
await using var verify = fixture.CreateContext();
var entries = verify
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId)
.OrderBy(e => e.StartsAtUtc)
.ToList();
Assert.NotEmpty(entries);
// Лента обязана быть непрерывной: живой край иначе упрётся в дыру.
for (var i = 1; i < entries.Count; i++)
Assert.Equal(entries[i - 1].EndsAtUtc, entries[i].StartsAtUtc);
var programs = entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList();
Assert.NotEmpty(programs);
Assert.All(programs, e => Assert.Equal(world.SlotId, e.SlotId));
Assert.All(programs, e => Assert.NotNull(e.TraceJson));
// Курсор слота сдвинулся — следующий прогон продолжит с той же серии, а не с первой.
var state = verify.SlotStates.Single(s => s.SlotId == world.SlotId);
Assert.Equal(world.ShowId, state.CurrentElementId);
Assert.True(state.NextUnitIndex > 0);
// И шаблон помечен применённым: баннер «правила изменены» должен погаснуть.
Assert.False(verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges);
}
[SkippableFact]
public async Task Generate_IsIdempotentWithinHorizon()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var first = fixture.CreateContext();
await Generator(first).GenerateAsync(world.ChannelId, Now, false, default);
await using var second = fixture.CreateContext();
var again = await Generator(second).GenerateAsync(world.ChannelId, Now, false, default);
// Горизонт уже заполнен — второй прогон не должен дописывать хвост поверх существующего.
Assert.Equal(0, again.Added);
}
[SkippableFact]
public async Task Generate_Rebuild_KeepsPastAndReplacesFuture()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var first = fixture.CreateContext();
await Generator(first).GenerateAsync(world.ChannelId, Now.AddHours(-2), false, default);
await using var beforeDb = fixture.CreateContext();
var past = beforeDb
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && e.EndsAtUtc <= Now)
.Select(e => e.Id)
.ToHashSet();
Assert.NotEmpty(past);
await using var rebuild = fixture.CreateContext();
await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default);
await using var verify = fixture.CreateContext();
var survived = verify
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && past.Contains(e.Id))
.Select(e => e.Id)
.ToList();
// Прошлое неприкосновенно: зритель не должен обнаружить, что у него вырезали программу.
Assert.Equal(past.Count, survived.Count);
}
[SkippableFact]
public async Task Generate_CollectionInGroup_StampsCollectionOnEntries()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 2, asCollection: true);
await using var db = fixture.CreateContext();
await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
await using var verify = fixture.CreateContext();
var programs = verify
.ScheduleEntries.Where(e =>
e.ChannelId == world.ChannelId && e.Kind == ScheduleEntryKind.Program
)
.ToList();
Assert.NotEmpty(programs);
// Из шоу коллекцию не вывести — она должна прийти из плана и осесть в ленте.
Assert.All(programs, e => Assert.Equal(world.CollectionId, e.CollectionId));
}
[SkippableFact]
public async Task Preview_WritesNothing()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var db = fixture.CreateContext();
var result = await Generator(db).PreviewAsync(world.ChannelId, Now, 1, default);
Assert.NotNull(result);
Assert.NotEmpty(result.Items);
await using var verify = fixture.CreateContext();
// Сухой прогон: ни ленты, ни курсоров, ни отметки о применении.
Assert.Empty(verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId));
Assert.Empty(verify.SlotStates.Where(s => s.SlotId == world.SlotId));
// Отметку о применении сухой прогон тоже не ставит: применять по-прежнему есть что.
Assert.True(verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges);
}
[SkippableFact]
public async Task Generate_EmptyGroup_FallsBackAndWarns()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 0);
await using var db = fixture.CreateContext();
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
// Пустая группа — не ошибка генерации: слот закрывает фон, а админ получает предупреждение.
Assert.Contains(
report.Warnings,
w => w.Kind == Domain.Programming.Planning.PlanningWarningKind.SlotEmpty
);
await using var verify = fixture.CreateContext();
Assert.All(
verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId).ToList(),
e => Assert.NotEqual(ScheduleEntryKind.Program, e.Kind)
);
}
// ── Обвязка ─────────────────────────────────────────────────────────────
private static GridScheduleGenerator Generator(AppDbContext db)
{
var random = new SequenceRandom(0);
return new GridScheduleGenerator(
db,
new GroupExpander(db),
new BumperResolver(db, Substitute.For<IBumperRenderQueue>(), random),
new PostCheckRunner(db),
random,
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }),
Options.Create(new StreamingOptions())
);
}
private sealed record World(
Guid ChannelId,
Guid TemplateId,
Guid SlotId,
Guid ShowId,
Guid? CollectionId
);
/// <summary>
/// Минимальный работающий канал: шоу с готовыми сериями, группа, шаблон со слотом на весь день
/// и филлер для пауз. <paramref name="asCollection"/> кладёт в группу коллекцию, а не шоу.
/// </summary>
private static async Task<World> SeedAsync(
AppDbContext db,
int episodes,
bool asCollection = false
)
{
var suffix = Guid.NewGuid().ToString("N")[..8];
var show = Show.Create($"Шоу {suffix}", ShowKind.Series);
for (var i = 0; i < episodes; i++)
{
var asset = ReadyAsset(db, $"ep{i}-{suffix}.mkv", TimeSpan.FromMinutes(30));
show.AddEpisode(asset.Id);
}
db.Shows.Add(show);
Collection? collection = null;
if (asCollection)
{
collection = Collection.Create($"Франшиза {suffix}");
collection.AddShow(show.Id);
db.Collections.Add(collection);
}
var group = Group.Create($"Группа {suffix}");
group.AddElement(
asCollection ? GroupElementKind.Collection : GroupElementKind.Show,
asCollection ? collection!.Id : show.Id
);
db.Groups.Add(group);
var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(1));
var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7));
channel.UpdateSettings(channel.Name, isEnabled: true, bumpersEnabled: false, filler.Id);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var layer = template.AddLayer("Базовый", 10);
var slot = layer.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60);
slot.UpdateContent(
slot.Title,
SlotKind.Content,
group.Id,
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
null,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext
);
channel.SetTemplate(template.Id);
// Правка правил помечает шаблон изменённым — воспроизводим состояние «есть что применить».
template.MarkChanged();
db.Channels.Add(channel);
db.ScheduleTemplates.Add(template);
await db.SaveChangesAsync();
return new World(channel.Id, template.Id, slot.Id, show.Id, collection?.Id);
}
/// <summary>Ассет, доведённый до Ready: в эфир попадают только такие.</summary>
private static MediaAsset ReadyAsset(AppDbContext db, string fileName, TimeSpan duration)
{
var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload);
asset.MarkProcessing();
asset.MarkReady(
duration,
segmentSeconds: 2,
segmentCount: (int)(duration.TotalSeconds / 2),
width: 1920,
height: 1080,
videoCodec: "h264",
audioCodec: "aac",
relativePath: $"segments/{asset.Id}"
);
db.MediaAssets.Add(asset);
return asset;
}
}
@@ -0,0 +1,136 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.CopyTemplate;
using TeleWave.Application.Programming.Templates.Validate;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming;
using TeleWave.Infrastructure.Persistence;
using Xunit;
namespace TeleWave.Integration.Tests;
/// <summary>
/// Операции над шаблоном против настоящей БД: копирование на другой канал и проверки по правилам.
/// Обе штуки — сплошные запросы EF и перекладывание графов, в юнит-тестах их не поймать.
/// </summary>
[Collection("postgres")]
public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture)
{
[SkippableFact]
public async Task Copy_MovesLayersSlotsAndJunctions_AndReplacesTargetGrid()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var (sourceId, targetId, groupId) = await SeedPairAsync(seedDb);
await using var db = fixture.CreateContext();
var result = await new CopyTemplateCommandHandler(db).Handle(
new CopyTemplateCommand(sourceId, targetId),
default
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync();
Assert.Equal(1, result.Value.Layers);
Assert.Equal(1, result.Value.Slots);
Assert.Equal(1, result.Value.Junctions);
await using var verify = fixture.CreateContext();
var target = verify.Channels.Single(c => c.Id == targetId);
var copied = verify
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.Single(t => t.ChannelId == targetId);
// У приёмника ровно один шаблон, и канал смотрит именно на него.
Assert.Equal(copied.Id, target.TemplateId);
Assert.Single(verify.ScheduleTemplates.Where(t => t.ChannelId == targetId));
var slot = copied.Layers.SelectMany(l => l.Slots).Single();
// Группы общие — ссылка переносится как есть, а не копией группы.
Assert.Equal(groupId, slot.GroupId);
Assert.Single(verify.Groups.Where(g => g.Id == groupId));
// Стык переехал своей копией, и слот ссылается на неё, а не на стык чужого канала.
var junction = verify.JunctionTemplates.Single(j => j.ChannelId == targetId);
Assert.Equal(junction.Id, slot.JunctionAfterId);
Assert.NotEqual(
verify.JunctionTemplates.Single(j => j.ChannelId == sourceId).Id,
slot.JunctionAfterId
);
}
[Fact]
public void Copy_ToItself_IsRejectedByValidator()
{
var validator = new CopyTemplateCommandValidator();
var id = Guid.NewGuid();
Assert.False(validator.Validate(new CopyTemplateCommand(id, id)).IsValid);
Assert.True(validator.Validate(new CopyTemplateCommand(id, Guid.NewGuid())).IsValid);
}
[SkippableFact]
public async Task Validate_ReportsEmptyGroupAndGridGap()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var (sourceId, _, _) = await SeedPairAsync(seedDb);
await using var db = fixture.CreateContext();
var result = await new ValidateTemplateQueryHandler(db).Handle(
new ValidateTemplateQuery(sourceId),
default
);
Assert.True(result.IsSuccess);
// Группа в сиде пустая, а слот занимает лишь два часа суток — обе проверки должны сработать.
Assert.Contains(result.Value, i => i.Kind == TemplateIssueKind.GroupEmpty);
Assert.Contains(result.Value, i => i.Kind == TemplateIssueKind.GridGap);
}
/// <summary>Два канала: у источника слой со слотом, группой и стыком; у приёмника — пустая сетка.</summary>
private static async Task<(Guid Source, Guid Target, Guid GroupId)> SeedPairAsync(AppDbContext db)
{
var suffix = Guid.NewGuid().ToString("N")[..8];
var group = Group.Create($"Группа {suffix}");
db.Groups.Add(group);
var source = Channel.Create($"Источник {suffix}", $"src-{suffix}", DateTimeOffset.UtcNow);
var target = Channel.Create($"Приёмник {suffix}", $"dst-{suffix}", DateTimeOffset.UtcNow);
var junction = JunctionTemplate.Create(source.Id, "Прайм");
var ad = junction.AddElement(JunctionElementKind.Ad);
ad.Update(JunctionElementKind.Ad, group.Id, null, JunctionAmountMode.Count, 2, true, null);
db.JunctionTemplates.Add(junction);
var sourceTemplate = ScheduleTemplate.Create(source.Id, "Сетка источника");
var layer = sourceTemplate.AddLayer("Прайм", 10);
var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120);
slot.UpdateContent(
slot.Title,
SlotKind.Content,
group.Id,
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
null,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext,
junctionAfterId: junction.Id
);
sourceTemplate.SetDefaultJunction(junction.Id);
source.SetTemplate(sourceTemplate.Id);
var targetTemplate = ScheduleTemplate.Create(target.Id, "Сетка приёмника");
target.SetTemplate(targetTemplate.Id);
db.Channels.AddRange(source, target);
db.ScheduleTemplates.AddRange(sourceTemplate, targetTemplate);
await db.SaveChangesAsync();
return (source.Id, target.Id, group.Id);
}
}