diff --git a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs index 56f4bfc..ec05501 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs @@ -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++; diff --git a/backend/src/TeleWave.Application/Programming/Planning/Trace/GetEntryTraceQuery.cs b/backend/src/TeleWave.Application/Programming/Planning/Trace/GetEntryTraceQuery.cs index 5b7f018..0c64eb0 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/Trace/GetEntryTraceQuery.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/Trace/GetEntryTraceQuery.cs @@ -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; - -/// -/// Цепочка происхождения записи — «почему это здесь» (см. 6.5). Трейс пишется в момент генерации -/// и хранится в самой записи: восстановить его потом невозможно, состав групп и правила меняются. -/// -public sealed record GetEntryTraceQuery(Guid EntryId) : IQuery>; - -public sealed record EntryTraceDto( - Guid EntryId, - DateTimeOffset StartsAtUtc, - DateTimeOffset EndsAtUtc, - string? ShowName, - int? EpisodeIndex, - /// Слой и слот, из которых выросла запись; null — трейс не писался (старая запись). - string? LayerName, - int? LayerPriority, - string? SlotTitle, - SlotKind? SlotKind, - int? SlotWeekday, - TimeOnly? SlotTargetStart, - int? SlotDurationMinutes, - string? GroupName, - int? GroupItemCount, - SlotStrategyKind? Strategy, - int? CooldownDays, - /// Сколько кандидатов осталось после остывания (null — выбор шёл без него). - 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; + +/// +/// Цепочка происхождения записи — «почему это здесь» (см. 6.5). Трейс пишется в момент генерации +/// и хранится в самой записи: восстановить его потом невозможно, состав групп и правила меняются. +/// +public sealed record GetEntryTraceQuery(Guid EntryId) : IQuery>; + +public sealed record EntryTraceDto( + Guid EntryId, + DateTimeOffset StartsAtUtc, + DateTimeOffset EndsAtUtc, + string? ShowName, + int? EpisodeIndex, + /// Слой и слот, из которых выросла запись; null — трейс не писался (старая запись). + string? LayerName, + int? LayerPriority, + string? SlotTitle, + SlotKind? SlotKind, + int? SlotWeekday, + TimeOnly? SlotTargetStart, + int? SlotDurationMinutes, + string? GroupName, + int? GroupItemCount, + /// Коллекция, частью которой шла запись, — если в эфир шла франшиза, а не одиночное шоу. + string? CollectionName, + SlotStrategyKind? Strategy, + int? CooldownDays, + /// Сколько кандидатов осталось после остывания (null — выбор шёл без него). + int? CandidatesAfterCooldown, + int DriftMinutes, + bool Snapped, + string? JunctionName +); diff --git a/backend/src/TeleWave.Application/Programming/Planning/Trace/GetEntryTraceQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Planning/Trace/GetEntryTraceQueryHandler.cs index 47f2d6a..acef815 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/Trace/GetEntryTraceQueryHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/Trace/GetEntryTraceQueryHandler.cs @@ -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> -{ - /// Те же настройки, что при записи трейса генератором. - private static readonly JsonSerializerOptions TraceJsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - Converters = { new JsonStringEnumConverter() }, - }; - - public async Task> 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(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(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> +{ + /// Те же настройки, что при записи трейса генератором. + private static readonly JsonSerializerOptions TraceJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() }, + }; + + public async Task> 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(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(json, TraceJsonOptions); + } + catch (JsonException) + { + return null; + } + } +} diff --git a/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs b/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs index cbeb1a6..69504b6 100644 --- a/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs +++ b/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs @@ -1,119 +1,127 @@ -namespace TeleWave.Domain.Broadcast; - -/// -/// Материализованная запись расписания канала: конкретный ассет в конкретное время. Программы и -/// реклама идут встык ( одной равен следующей). -/// -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; } - - /// Шоу (для ) — для EPG. - public Guid? ShowId { get; private set; } - - /// Индекс серии в упорядоченном списке шоу (для EPG). - public int? EpisodeIndex { get; private set; } - - /// Подблок заставки (), которым отрендерена запись — для метки в админ-расписании. - public Guid? BumperVariantId { get; private set; } - - /// Слот сетки, породивший запись (null — старая ротация либо служебная запись). - public Guid? SlotId { get; private set; } - - /// - /// Цепочка происхождения (JSON): слой, слот, группа, стратегия, дрейф. Пишется в момент - /// генерации — восстановить её потом невозможно, а без неё отладка сетки превращается - /// в угадывание. - /// - public string? TraceJson { get; private set; } - - private ScheduleEntry() { } - - /// Запись, порождённая слотом сетки: программа, заполнитель или конец вещания. - 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, - }; - - /// Заставка-переход. — следующее шоу (для EPG/справки). - 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; + +/// +/// Материализованная запись расписания канала: конкретный ассет в конкретное время. Программы и +/// реклама идут встык ( одной равен следующей). +/// +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; } + + /// Шоу (для ) — для EPG. + public Guid? ShowId { get; private set; } + + /// Индекс серии в упорядоченном списке шоу (для EPG). + public int? EpisodeIndex { get; private set; } + + /// Подблок заставки (), которым отрендерена запись — для метки в админ-расписании. + public Guid? BumperVariantId { get; private set; } + + /// Слот сетки, породивший запись (null — служебная запись вне слотов). + public Guid? SlotId { get; private set; } + + /// + /// Коллекция (франшиза), частью которой шла запись, или null. Из шоу её не вывести: одно и то же + /// шоу попадает в эфир и само по себе, и внутри коллекции, а группа хранит только ссылку. + /// + public Guid? CollectionId { get; private set; } + + /// + /// Цепочка происхождения (JSON): слой, слот, группа, стратегия, дрейф. Пишется в момент + /// генерации — восстановить её потом невозможно, а без неё отладка сетки превращается + /// в угадывание. + /// + public string? TraceJson { get; private set; } + + private ScheduleEntry() { } + + /// Запись, порождённая слотом сетки: программа, заполнитель или конец вещания. + 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, + }; + + /// Заставка-переход. — следующее шоу (для EPG/справки). + 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, + }; +} diff --git a/backend/src/TeleWave.Domain/Library/Show.cs b/backend/src/TeleWave.Domain/Library/Show.cs index 9d99c57..3ada40d 100644 --- a/backend/src/TeleWave.Domain/Library/Show.cs +++ b/backend/src/TeleWave.Domain/Library/Show.cs @@ -1,173 +1,174 @@ -namespace TeleWave.Domain.Library; - -/// -/// Переиспользуемое шоу в общей библиотеке: сериал (упорядоченные серии) либо полнометражка. -/// Серии идут строго в порядке ; курсор показа на канале хранится -/// отдельно на связке канал↔шоу (см. Broadcast/ChannelShow). -/// -public class Show -{ - private readonly List _episodes = new(); - private readonly List _genres = new(); - - public Guid Id { get; private set; } - public string Name { get; private set; } = string.Empty; - - /// Оригинальное название (обычно на английском) — по нему ищутся метаданные; на экранах - /// продолжаем показывать . Null/пусто — ищем по . - public string? OriginalName { get; private set; } - - public string? Description { get; private set; } - public ShowKind Kind { get; private set; } - - /// Категория аудитории (обычное/детское/взрослое) — под будущие фильтры показа. - public ShowAudience Audience { get; private set; } - - public DateTimeOffset CreatedAt { get; private set; } - - // ── Метаданные (TMDb/OMDb/вручную) ── - /// Источник метаданных: «tmdb»/«omdb»/«manual» или null, если не заданы. - public string? MetadataProvider { get; private set; } - - /// Идентификатор шоу во внешнем источнике (для довыгрузки серий). - public string? MetadataExternalId { get; private set; } - - public int? Year { get; private set; } - - /// Постер шоу — ссылка на запись общего реестра изображений (Domain/Images) или null. - public Guid? PosterImageId { get; private set; } - - /// Серии шоу (backing-field для EF). Порядок показа — по ; - /// потребители сортируют явно (см. загрузчик планировщика). - public IReadOnlyList Episodes => _episodes; - - /// Жанры шоу (backing-field для EF). Ровно один помечен основным, если список не пуст. - public IReadOnlyList 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, - }; - - /// Задать категорию аудитории. - public void SetAudience(ShowAudience audience) => Audience = audience; - - /// Основной жанр или null, если жанры не проставлены. - public Guid? PrimaryGenreId => _genres.FirstOrDefault(g => g.IsPrimary)?.GenreId; - - /// - /// Полностью заменяет набор жанров; дубликаты и пустые идентификаторы отбрасываются. Основным - /// становится , если он попал в набор, иначе первый в списке — - /// так шоу с жанрами никогда не остаётся без основного. - /// - public void SetGenres(IEnumerable 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; - } - - /// Изменить отображаемое название (на экранах). Метаданные ищутся по . - public void SetName(string name) => Name = name; - - /// Задать/снять оригинальное название (пустая строка трактуется как отсутствие). - public void SetOriginalName(string? originalName) => OriginalName = Normalize(originalName); - - private static string? Normalize(string? value) => - string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - - /// Добавляет серию в конец. Для допустима ровно одна серия - /// (инвариант защищён самим агрегатом; вызывающий обычно проверяет заранее - /// и возвращает управляемую ошибку — исключение здесь лишь страховка от обхода). - 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; - } - - /// Несколько серий бывает только у сериала. У полнометражки и у ролика-врезки серия ровно - /// одна: ролик с тремя сериями вёл бы себя в планировщике как мини-сериал, а задуман как единица. - public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0; - - /// Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null. - 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; - } - - /// Ручная правка метаданных (без внешнего источника). - public void UpdateMetadataManual(string? description, int? year) - { - MetadataProvider = "manual"; - MetadataExternalId = null; - Description = description; - Year = year; - } - - /// Привязать/снять постер шоу (ссылка на запись реестра изображений). - public void SetPosterImage(Guid? imageId) => PosterImageId = imageId; - - /// Сбросить все метаданные и отвязать постер (сама картинка остаётся в галерее). - public void ClearMetadata() - { - MetadataProvider = null; - MetadataExternalId = null; - Year = null; - PosterImageId = null; - Description = null; - } -} +namespace TeleWave.Domain.Library; + +/// +/// Переиспользуемое шоу в общей библиотеке: сериал (упорядоченные серии) либо полнометражка. +/// Серии идут строго в порядке ; где остановился показ — знает +/// состояние слота планировщика (Programming/SlotState), а не само шоу: одно шоу играет +/// на нескольких каналах и в нескольких слотах, и курсор у каждого свой. +/// +public class Show +{ + private readonly List _episodes = new(); + private readonly List _genres = new(); + + public Guid Id { get; private set; } + public string Name { get; private set; } = string.Empty; + + /// Оригинальное название (обычно на английском) — по нему ищутся метаданные; на экранах + /// продолжаем показывать . Null/пусто — ищем по . + public string? OriginalName { get; private set; } + + public string? Description { get; private set; } + public ShowKind Kind { get; private set; } + + /// Категория аудитории (обычное/детское/взрослое) — под будущие фильтры показа. + public ShowAudience Audience { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + // ── Метаданные (TMDb/OMDb/вручную) ── + /// Источник метаданных: «tmdb»/«omdb»/«manual» или null, если не заданы. + public string? MetadataProvider { get; private set; } + + /// Идентификатор шоу во внешнем источнике (для довыгрузки серий). + public string? MetadataExternalId { get; private set; } + + public int? Year { get; private set; } + + /// Постер шоу — ссылка на запись общего реестра изображений (Domain/Images) или null. + public Guid? PosterImageId { get; private set; } + + /// Серии шоу (backing-field для EF). Порядок показа — по ; + /// потребители сортируют явно (см. загрузчик планировщика). + public IReadOnlyList Episodes => _episodes; + + /// Жанры шоу (backing-field для EF). Ровно один помечен основным, если список не пуст. + public IReadOnlyList 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, + }; + + /// Задать категорию аудитории. + public void SetAudience(ShowAudience audience) => Audience = audience; + + /// Основной жанр или null, если жанры не проставлены. + public Guid? PrimaryGenreId => _genres.FirstOrDefault(g => g.IsPrimary)?.GenreId; + + /// + /// Полностью заменяет набор жанров; дубликаты и пустые идентификаторы отбрасываются. Основным + /// становится , если он попал в набор, иначе первый в списке — + /// так шоу с жанрами никогда не остаётся без основного. + /// + public void SetGenres(IEnumerable 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; + } + + /// Изменить отображаемое название (на экранах). Метаданные ищутся по . + public void SetName(string name) => Name = name; + + /// Задать/снять оригинальное название (пустая строка трактуется как отсутствие). + public void SetOriginalName(string? originalName) => OriginalName = Normalize(originalName); + + private static string? Normalize(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + /// Добавляет серию в конец. Для допустима ровно одна серия + /// (инвариант защищён самим агрегатом; вызывающий обычно проверяет заранее + /// и возвращает управляемую ошибку — исключение здесь лишь страховка от обхода). + 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; + } + + /// Несколько серий бывает только у сериала. У полнометражки и у ролика-врезки серия ровно + /// одна: ролик с тремя сериями вёл бы себя в планировщике как мини-сериал, а задуман как единица. + public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0; + + /// Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null. + 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; + } + + /// Ручная правка метаданных (без внешнего источника). + public void UpdateMetadataManual(string? description, int? year) + { + MetadataProvider = "manual"; + MetadataExternalId = null; + Description = description; + Year = year; + } + + /// Привязать/снять постер шоу (ссылка на запись реестра изображений). + public void SetPosterImage(Guid? imageId) => PosterImageId = imageId; + + /// Сбросить все метаданные и отвязать постер (сама картинка остаётся в галерее). + public void ClearMetadata() + { + MetadataProvider = null; + MetadataExternalId = null; + Year = null; + PosterImageId = null; + Description = null; + } +} diff --git a/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs b/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs index 481d7cf..4a9d010 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs @@ -135,7 +135,9 @@ public sealed record PlannedItem( /// Для заставки: блок, пара «из/в» и место под ассет, который отрендерят позже. Guid? BumperTemplateId = null, Guid? FromShowId = null, - Guid? ToShowId = null + Guid? ToShowId = null, + /// Коллекция, частью которой шла единица (null — шоу играло само по себе). + Guid? CollectionId = null ); public enum PlannedItemKind diff --git a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs index 5a89429..16ca63a 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs @@ -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; } + /// Коллекция элемента или null, если в эфир шло отдельное шоу. + 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( diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726115300_ScheduleEntryCollection.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726115300_ScheduleEntryCollection.Designer.cs new file mode 100644 index 0000000..5861f51 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726115300_ScheduleEntryCollection.Designer.cs @@ -0,0 +1,1389 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260726115300_ScheduleEntryCollection")] + partial class ScheduleEntryCollection + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.Property("VariantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Line2") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NextLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NowLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AnalogFilterStrength") + .HasColumnType("double precision"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DayStartTime") + .HasColumnType("time without time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LogoCorner") + .HasColumnType("integer"); + + b.Property("LogoImageId") + .HasColumnType("uuid"); + + b.Property("LogoOpacity") + .HasColumnType("double precision"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("ShowClock") + .HasColumnType("boolean"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UtcOffsetMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique() + .HasFilter("\"Number\" IS NOT NULL"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TraceJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.HasIndex("ChannelId", "ShowId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Images.Image", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Category", "CreatedAt"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Collections"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ShowId"); + + b.HasIndex("CollectionId", "Position"); + + b.HasIndex("CollectionId", "ShowId") + .IsUnique(); + + b.ToTable("CollectionItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("Value") + .IsUnique(); + + b.ToTable("GenreAliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Audience") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillImageId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.HasKey("ShowId", "GenreId"); + + b.HasIndex("GenreId"); + + b.ToTable("ShowGenres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProcessingDuration") + .HasColumnType("interval"); + + b.Property("ProcessingStartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicabilityJson") + .HasColumnType("jsonb"); + + b.Property("IsBackground") + .HasColumnType("boolean"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Priority"); + + b.ToTable("GridLayers"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FilterJson") + .HasColumnType("jsonb"); + + b.Property("ItemCount") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StatsComputedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalDuration") + .HasColumnType("interval"); + + b.Property("UnitCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ElementId") + .HasColumnType("uuid"); + + b.Property("ElementKind") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ElementKind", "ElementId"); + + b.HasIndex("GroupId", "Position"); + + b.HasIndex("GroupId", "ElementKind", "ElementId") + .IsUnique(); + + b.ToTable("GroupItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AmountMode") + .HasColumnType("integer"); + + b.Property("AmountValue") + .HasColumnType("integer"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("ConditionsJson") + .HasColumnType("jsonb"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsRequired") + .HasColumnType("boolean"); + + b.Property("JunctionTemplateId") + .HasColumnType("uuid"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("JunctionTemplateId", "Position"); + + b.ToTable("JunctionElements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("JunctionTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppliedRevision") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultJunctionId") + .HasColumnType("uuid"); + + b.Property("FallbackGroupId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("RulesJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("ScheduleTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("Daypart") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsAnchor") + .HasColumnType("boolean"); + + b.Property("JunctionAfterId") + .HasColumnType("uuid"); + + b.Property("JunctionBetweenId") + .HasColumnType("uuid"); + + b.Property("LayerId") + .HasColumnType("uuid"); + + b.Property("MaxDriftMinutes") + .HasColumnType("integer"); + + b.Property("OverflowPolicy") + .HasColumnType("integer"); + + b.Property("RepeatSourceJson") + .HasColumnType("jsonb"); + + b.Property("SlotKind") + .HasColumnType("integer"); + + b.Property("SnapToMinutes") + .HasColumnType("integer"); + + b.Property("StrategyJson") + .HasColumnType("jsonb"); + + b.Property("TargetDurationMinutes") + .HasColumnType("integer"); + + b.Property("TargetStart") + .HasColumnType("time without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Weekday") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("LayerId", "TargetStart"); + + b.ToTable("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("CurrentElementId") + .HasColumnType("uuid"); + + b.Property("CurrentElementKind") + .HasColumnType("integer"); + + b.Property("NextUnitIndex") + .HasColumnType("integer"); + + b.HasKey("SlotId"); + + b.ToTable("SlotStates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("BumperTemplates") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.HasOne("TeleWave.Domain.Library.Collection", null) + .WithMany("Items") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany() + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany("Aliases") + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Genres") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.HasOne("TeleWave.Domain.Programming.ScheduleTemplate", null) + .WithMany("Layers") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.JunctionTemplate", null) + .WithMany("Elements") + .HasForeignKey("JunctionTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.GridLayer", null) + .WithMany("Slots") + .HasForeignKey("LayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.HasOne("TeleWave.Domain.Programming.Slot", null) + .WithOne() + .HasForeignKey("TeleWave.Domain.Programming.SlotState", "SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("BumperTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Navigation("Aliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Navigation("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Navigation("Elements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Navigation("Layers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726115300_ScheduleEntryCollection.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726115300_ScheduleEntryCollection.cs new file mode 100644 index 0000000..ddcbb33 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726115300_ScheduleEntryCollection.cs @@ -0,0 +1,29 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class ScheduleEntryCollection : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CollectionId", + table: "ScheduleEntries", + type: "uuid", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CollectionId", + table: "ScheduleEntries"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 2f67d65..7a320a6 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -399,6 +399,9 @@ namespace TeleWave.Infrastructure.Migrations b.Property("ChannelId") .HasColumnType("uuid"); + b.Property("CollectionId") + .HasColumnType("uuid"); + b.Property("EndsAtUtc") .HasColumnType("timestamp with time zone"); diff --git a/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs new file mode 100644 index 0000000..729dbdd --- /dev/null +++ b/backend/tests/TeleWave.Integration.Tests/GridScheduleGeneratorIntegrationTests.cs @@ -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; + +/// +/// Конвейер генерации против настоящей БД: разворот групп, наполнение слотов, запись ленты, +/// продвижение курсоров. Юнит-тесты покрывают чистую математику планировщика, а здесь проверяется +/// то, чего они не видят, — реальные запросы EF, транзакция, advisory-lock и ExecuteDelete. +/// +[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(), 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 + ); + + /// + /// Минимальный работающий канал: шоу с готовыми сериями, группа, шаблон со слотом на весь день + /// и филлер для пауз. кладёт в группу коллекцию, а не шоу. + /// + private static async Task 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); + } + + /// Ассет, доведённый до Ready: в эфир попадают только такие. + 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; + } +} diff --git a/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs new file mode 100644 index 0000000..56ff4eb --- /dev/null +++ b/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs @@ -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; + +/// +/// Операции над шаблоном против настоящей БД: копирование на другой канал и проверки по правилам. +/// Обе штуки — сплошные запросы EF и перекладывание графов, в юнит-тестах их не поймать. +/// +[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); + } + + /// Два канала: у источника слой со слотом, группой и стыком; у приёмника — пустая сетка. + 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); + } +} diff --git a/docs/tv-scheduler-tasks.md b/docs/tv-scheduler-tasks.md index 5cd4232..3497a1e 100644 --- a/docs/tv-scheduler-tasks.md +++ b/docs/tv-scheduler-tasks.md @@ -32,7 +32,12 @@ переключение по номерам стрелками (глобальный флаг рядом с флагом регистрации), логотип-оверлей, часы, плашка «Далее» и аналоговый фильтр. Всё опционально и по умолчанию выключено. -Ничего из этого не проверялось на живой базе: только сборка, юнит-тесты и typecheck. +**Проверено:** 142 доменных, 72 прикладных и 11 интеграционных тестов. Интеграционные поднимают +настоящий Postgres в контейнере, применяют к нему все миграции и гоняют конвейер генерации, +копирование шаблона и проверки сетки — то есть миграции и слой «Application ↔ EF» работают. + +**Не проверено:** приложение ни разу не поднималось целиком, ни один экран не открывался в браузере, +на реальной базе с реальным контентом ничего не запускалось. --- diff --git a/frontend/src/features/admin/channels/components/EntryTraceDialog.tsx b/frontend/src/features/admin/channels/components/EntryTraceDialog.tsx index 1fe55a1..18bfb7f 100644 --- a/frontend/src/features/admin/channels/components/EntryTraceDialog.tsx +++ b/frontend/src/features/admin/channels/components/EntryTraceDialog.tsx @@ -1,104 +1,105 @@ -import { useQuery } from '@tanstack/react-query' -import { useTranslation } from 'react-i18next' -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from '@/shared/ui/dialog' -import { getEntryTrace } from '../api' -import { formatChannelTime } from '../lib/format' - -/** - * «Почему это здесь» (см. 6.5): цепочка происхождения записи. Трейс пишется в момент генерации — - * восстановить его потом нельзя, поэтому у старых записей часть строк будет пустой. - */ -export function EntryTraceDialog({ - entryId, - utcOffsetMinutes, - onClose, -}: { - entryId: string - utcOffsetMinutes: number - onClose: () => void -}) { - const { t } = useTranslation() - const { data } = useQuery({ - queryKey: ['admin', 'entries', entryId, 'trace'], - queryFn: () => getEntryTrace(entryId), - }) - - return ( - !open && onClose()}> - - - - {data - ? `${data.showName ?? '—'} · ${formatChannelTime(data.startsAtUtc, utcOffsetMinutes)}` - : t('common.loading')} - - - - {data && ( -
- - {data.layerName - ? `${data.layerName}${data.layerPriority !== null ? ` (${t('admin.channels.priority')} ${data.layerPriority})` : ''}` - : null} - - - {data.slotTitle - ? [ - data.slotTitle, - data.slotWeekday === null - ? t('admin.channels.everyDay') - : t(`admin.channels.weekdays.${data.slotWeekday}`), - data.slotTargetStart?.slice(0, 5), - data.slotDurationMinutes ? `${data.slotDurationMinutes} мин` : null, - data.driftMinutes !== 0 - ? t('admin.channels.traceDrift', { minutes: data.driftMinutes }) - : null, - data.snapped ? t('admin.channels.traceSnapped') : null, - ] - .filter(Boolean) - .join(' · ') - : null} - - - {data.groupName - ? `${data.groupName}${data.groupItemCount !== null ? ` (${data.groupItemCount})` : ''}` - : null} - - - {data.strategy - ? [ - t(`admin.channels.strategies.${data.strategy}`), - data.cooldownDays - ? t('admin.channels.traceCooldown', { days: data.cooldownDays }) - : null, - data.candidatesAfterCooldown !== null - ? t('admin.channels.traceCandidates', { - count: data.candidatesAfterCooldown, - }) - : null, - ] - .filter(Boolean) - .join(' · ') - : null} - - {data.junctionName} -
- )} -
-
- ) -} - -function Row({ label, children }: { label: string; children: React.ReactNode }) { - return ( - <> -
{label}
-
{children || '—'}
- - ) -} +import { useQuery } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/shared/ui/dialog' +import { getEntryTrace } from '../api' +import { formatChannelTime } from '../lib/format' + +/** + * «Почему это здесь» (см. 6.5): цепочка происхождения записи. Трейс пишется в момент генерации — + * восстановить его потом нельзя, поэтому у старых записей часть строк будет пустой. + */ +export function EntryTraceDialog({ + entryId, + utcOffsetMinutes, + onClose, +}: { + entryId: string + utcOffsetMinutes: number + onClose: () => void +}) { + const { t } = useTranslation() + const { data } = useQuery({ + queryKey: ['admin', 'entries', entryId, 'trace'], + queryFn: () => getEntryTrace(entryId), + }) + + return ( + !open && onClose()}> + + + + {data + ? `${data.showName ?? '—'} · ${formatChannelTime(data.startsAtUtc, utcOffsetMinutes)}` + : t('common.loading')} + + + + {data && ( +
+ + {data.layerName + ? `${data.layerName}${data.layerPriority !== null ? ` (${t('admin.channels.priority')} ${data.layerPriority})` : ''}` + : null} + + + {data.slotTitle + ? [ + data.slotTitle, + data.slotWeekday === null + ? t('admin.channels.everyDay') + : t(`admin.channels.weekdays.${data.slotWeekday}`), + data.slotTargetStart?.slice(0, 5), + data.slotDurationMinutes ? `${data.slotDurationMinutes} мин` : null, + data.driftMinutes !== 0 + ? t('admin.channels.traceDrift', { minutes: data.driftMinutes }) + : null, + data.snapped ? t('admin.channels.traceSnapped') : null, + ] + .filter(Boolean) + .join(' · ') + : null} + + + {data.groupName + ? `${data.groupName}${data.groupItemCount !== null ? ` (${data.groupItemCount})` : ''}` + : null} + + {data.collectionName} + + {data.strategy + ? [ + t(`admin.channels.strategies.${data.strategy}`), + data.cooldownDays + ? t('admin.channels.traceCooldown', { days: data.cooldownDays }) + : null, + data.candidatesAfterCooldown !== null + ? t('admin.channels.traceCandidates', { + count: data.candidatesAfterCooldown, + }) + : null, + ] + .filter(Boolean) + .join(' · ') + : null} + + {data.junctionName} +
+ )} +
+
+ ) +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( + <> +
{label}
+
{children || '—'}
+ + ) +} diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 0f335aa..daad4ea 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -559,6 +559,8 @@ export type EntryTraceDto = { slotDurationMinutes: number | null groupName: string | null groupItemCount: number | null + /** Коллекция, частью которой шла запись, — если в эфир шла франшиза, а не одиночное шоу. */ + collectionName: string | null strategy: SlotStrategyType | null cooldownDays: number | null candidatesAfterCooldown: number | null diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 1c0b75d..746cf16 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -417,6 +417,7 @@ const resources = { traceLayer: 'Слой', traceSlot: 'Слот', traceGroup: 'Группа', + traceCollection: 'Коллекция', traceStrategy: 'Стратегия', traceJunction: 'Врезки', traceDrift: 'дрейф {{minutes}} мин', @@ -1077,6 +1078,7 @@ const resources = { traceLayer: 'Layer', traceSlot: 'Slot', traceGroup: 'Group', + traceCollection: 'Collection', traceStrategy: 'Strategy', traceJunction: 'Breaks', traceDrift: 'drift {{minutes}} min',