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