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

This commit is contained in:
Leonid Pershin
2026-07-26 14:59:54 +03:00
parent f699576582
commit 2445ba56b5
16 changed files with 2467 additions and 580 deletions
@@ -142,7 +142,8 @@ public sealed class GridScheduleGenerator(
item.SlotId,
item.Trace is null
? null
: JsonSerializer.Serialize(item.Trace, TraceJsonOptions)
: JsonSerializer.Serialize(item.Trace, TraceJsonOptions),
item.CollectionId
)
);
added++;
@@ -1,37 +1,39 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
/// <summary>
/// Цепочка происхождения записи — «почему это здесь» (см. 6.5). Трейс пишется в момент генерации
/// и хранится в самой записи: восстановить его потом невозможно, состав групп и правила меняются.
/// </summary>
public sealed record GetEntryTraceQuery(Guid EntryId) : IQuery<Result<EntryTraceDto>>;
public sealed record EntryTraceDto(
Guid EntryId,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
string? ShowName,
int? EpisodeIndex,
/// <summary>Слой и слот, из которых выросла запись; null — трейс не писался (старая запись).</summary>
string? LayerName,
int? LayerPriority,
string? SlotTitle,
SlotKind? SlotKind,
int? SlotWeekday,
TimeOnly? SlotTargetStart,
int? SlotDurationMinutes,
string? GroupName,
int? GroupItemCount,
SlotStrategyKind? Strategy,
int? CooldownDays,
/// <summary>Сколько кандидатов осталось после остывания (null — выбор шёл без него).</summary>
int? CandidatesAfterCooldown,
int DriftMinutes,
bool Snapped,
string? JunctionName
);
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
/// <summary>
/// Цепочка происхождения записи — «почему это здесь» (см. 6.5). Трейс пишется в момент генерации
/// и хранится в самой записи: восстановить его потом невозможно, состав групп и правила меняются.
/// </summary>
public sealed record GetEntryTraceQuery(Guid EntryId) : IQuery<Result<EntryTraceDto>>;
public sealed record EntryTraceDto(
Guid EntryId,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
string? ShowName,
int? EpisodeIndex,
/// <summary>Слой и слот, из которых выросла запись; null — трейс не писался (старая запись).</summary>
string? LayerName,
int? LayerPriority,
string? SlotTitle,
SlotKind? SlotKind,
int? SlotWeekday,
TimeOnly? SlotTargetStart,
int? SlotDurationMinutes,
string? GroupName,
int? GroupItemCount,
/// <summary>Коллекция, частью которой шла запись, — если в эфир шла франшиза, а не одиночное шоу.</summary>
string? CollectionName,
SlotStrategyKind? Strategy,
int? CooldownDays,
/// <summary>Сколько кандидатов осталось после остывания (null — выбор шёл без него).</summary>
int? CandidatesAfterCooldown,
int DriftMinutes,
bool Snapped,
string? JunctionName
);
@@ -1,141 +1,151 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Templates;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetEntryTraceQuery, Result<EntryTraceDto>>
{
/// <summary>Те же настройки, что при записи трейса генератором.</summary>
private static readonly JsonSerializerOptions TraceJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
};
public async Task<Result<EntryTraceDto>> Handle(
GetEntryTraceQuery query,
CancellationToken cancellationToken
)
{
var entry = await dbContext
.ScheduleEntries.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == query.EntryId, cancellationToken);
if (entry is null)
return Result.Failure<EntryTraceDto>(ChannelErrors.NotFound);
var showName =
entry.ShowId is { } showId
? await dbContext
.Shows.AsNoTracking()
.Where(s => s.Id == showId)
.Select(s => s.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var trace = Parse(entry.TraceJson);
if (trace is null)
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
0,
false,
null
)
);
// Слот мог быть удалён или изменён после генерации — трейс от этого не портится, просто
// часть подписей окажется пустой.
var slot = trace.SlotId is { } slotId
? await dbContext.Slots.AsNoTracking().FirstOrDefaultAsync(s => s.Id == slotId, cancellationToken)
: null;
var layer = slot is null
? null
: await dbContext
.GridLayers.AsNoTracking()
.FirstOrDefaultAsync(l => l.Id == slot.LayerId, cancellationToken);
var group = slot?.GroupId is { } groupId
? await dbContext
.Groups.AsNoTracking()
.Where(g => g.Id == groupId)
.Select(g => new { g.Name, g.ItemCount })
.FirstOrDefaultAsync(cancellationToken)
: null;
var strategy = SlotStrategy.FromJson(slot?.StrategyJson);
var junctionId = slot?.JunctionAfterId ?? slot?.JunctionBetweenId;
var junctionName = junctionId is { } id
? await dbContext
.JunctionTemplates.AsNoTracking()
.Where(j => j.Id == id)
.Select(j => j.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
layer?.Name,
layer?.Priority,
slot?.Title,
trace.SlotKind,
slot?.Weekday,
slot?.TargetStart,
slot?.TargetDurationMinutes,
group?.Name,
group?.ItemCount,
trace.Strategy,
strategy?.CooldownDays,
trace.CandidatesAfterCooldown,
trace.DriftMinutes,
trace.Snapped,
junctionName
)
);
}
private static PlanTrace? Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<PlanTrace>(json, TraceJsonOptions);
}
catch (JsonException)
{
return null;
}
}
}
using System.Text.Json;
using System.Text.Json.Serialization;
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Templates;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetEntryTraceQuery, Result<EntryTraceDto>>
{
/// <summary>Те же настройки, что при записи трейса генератором.</summary>
private static readonly JsonSerializerOptions TraceJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
};
public async Task<Result<EntryTraceDto>> Handle(
GetEntryTraceQuery query,
CancellationToken cancellationToken
)
{
var entry = await dbContext
.ScheduleEntries.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == query.EntryId, cancellationToken);
if (entry is null)
return Result.Failure<EntryTraceDto>(ChannelErrors.NotFound);
var showName =
entry.ShowId is { } showId
? await dbContext
.Shows.AsNoTracking()
.Where(s => s.Id == showId)
.Select(s => s.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var collectionName = entry.CollectionId is { } collectionId
? await dbContext
.Collections.AsNoTracking()
.Where(c => c.Id == collectionId)
.Select(c => c.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var trace = Parse(entry.TraceJson);
if (trace is null)
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
null,
null,
null,
null,
null,
null,
null,
null,
null,
collectionName,
null,
null,
null,
0,
false,
null
)
);
// Слот мог быть удалён или изменён после генерации — трейс от этого не портится, просто
// часть подписей окажется пустой.
var slot = trace.SlotId is { } slotId
? await dbContext.Slots.AsNoTracking().FirstOrDefaultAsync(s => s.Id == slotId, cancellationToken)
: null;
var layer = slot is null
? null
: await dbContext
.GridLayers.AsNoTracking()
.FirstOrDefaultAsync(l => l.Id == slot.LayerId, cancellationToken);
var group = slot?.GroupId is { } groupId
? await dbContext
.Groups.AsNoTracking()
.Where(g => g.Id == groupId)
.Select(g => new { g.Name, g.ItemCount })
.FirstOrDefaultAsync(cancellationToken)
: null;
var strategy = SlotStrategy.FromJson(slot?.StrategyJson);
var junctionId = slot?.JunctionAfterId ?? slot?.JunctionBetweenId;
var junctionName = junctionId is { } id
? await dbContext
.JunctionTemplates.AsNoTracking()
.Where(j => j.Id == id)
.Select(j => j.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
layer?.Name,
layer?.Priority,
slot?.Title,
trace.SlotKind,
slot?.Weekday,
slot?.TargetStart,
slot?.TargetDurationMinutes,
group?.Name,
group?.ItemCount,
collectionName,
trace.Strategy,
strategy?.CooldownDays,
trace.CandidatesAfterCooldown,
trace.DriftMinutes,
trace.Snapped,
junctionName
)
);
}
private static PlanTrace? Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<PlanTrace>(json, TraceJsonOptions);
}
catch (JsonException)
{
return null;
}
}
}
@@ -1,119 +1,127 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Материализованная запись расписания канала: конкретный ассет в конкретное время. Программы и
/// реклама идут встык (<see cref="EndsAtUtc"/> одной равен <see cref="StartsAtUtc"/> следующей).
/// </summary>
public class ScheduleEntry
{
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public Guid MediaAssetId { get; private set; }
public ScheduleEntryKind Kind { get; private set; }
public DateTimeOffset StartsAtUtc { get; private set; }
public DateTimeOffset EndsAtUtc { get; private set; }
/// <summary>Шоу (для <see cref="ScheduleEntryKind.Program"/>) — для EPG.</summary>
public Guid? ShowId { get; private set; }
/// <summary>Индекс серии в упорядоченном списке шоу (для EPG).</summary>
public int? EpisodeIndex { get; private set; }
/// <summary>Подблок заставки (<see cref="BumperTextVariant"/>), которым отрендерена запись — для метки в админ-расписании.</summary>
public Guid? BumperVariantId { get; private set; }
/// <summary>Слот сетки, породивший запись (null — старая ротация либо служебная запись).</summary>
public Guid? SlotId { get; private set; }
/// <summary>
/// Цепочка происхождения (JSON): слой, слот, группа, стратегия, дрейф. Пишется в момент
/// генерации — восстановить её потом невозможно, а без неё отладка сетки превращается
/// в угадывание.
/// </summary>
public string? TraceJson { get; private set; }
private ScheduleEntry() { }
/// <summary>Запись, порождённая слотом сетки: программа, заполнитель или конец вещания.</summary>
public static ScheduleEntry FromSlot(
Guid channelId,
Guid mediaAssetId,
ScheduleEntryKind kind,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid? showId,
int? episodeIndex,
Guid? slotId,
string? traceJson
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = kind,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
EpisodeIndex = episodeIndex,
SlotId = slotId,
TraceJson = traceJson,
};
public static ScheduleEntry Program(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid showId,
int episodeIndex
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Program,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
EpisodeIndex = episodeIndex,
};
public static ScheduleEntry Ad(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Ad,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
};
/// <summary>Заставка-переход. <paramref name="showId"/> — следующее шоу (для EPG/справки).</summary>
public static ScheduleEntry Bumper(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid? showId,
Guid? bumperVariantId
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Bumper,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
BumperVariantId = bumperVariantId,
};
}
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Материализованная запись расписания канала: конкретный ассет в конкретное время. Программы и
/// реклама идут встык (<see cref="EndsAtUtc"/> одной равен <see cref="StartsAtUtc"/> следующей).
/// </summary>
public class ScheduleEntry
{
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public Guid MediaAssetId { get; private set; }
public ScheduleEntryKind Kind { get; private set; }
public DateTimeOffset StartsAtUtc { get; private set; }
public DateTimeOffset EndsAtUtc { get; private set; }
/// <summary>Шоу (для <see cref="ScheduleEntryKind.Program"/>) — для EPG.</summary>
public Guid? ShowId { get; private set; }
/// <summary>Индекс серии в упорядоченном списке шоу (для EPG).</summary>
public int? EpisodeIndex { get; private set; }
/// <summary>Подблок заставки (<see cref="BumperTextVariant"/>), которым отрендерена запись — для метки в админ-расписании.</summary>
public Guid? BumperVariantId { get; private set; }
/// <summary>Слот сетки, породивший запись (null — служебная запись вне слотов).</summary>
public Guid? SlotId { get; private set; }
/// <summary>
/// Коллекция (франшиза), частью которой шла запись, или null. Из шоу её не вывести: одно и то же
/// шоу попадает в эфир и само по себе, и внутри коллекции, а группа хранит только ссылку.
/// </summary>
public Guid? CollectionId { get; private set; }
/// <summary>
/// Цепочка происхождения (JSON): слой, слот, группа, стратегия, дрейф. Пишется в момент
/// генерации — восстановить её потом невозможно, а без неё отладка сетки превращается
/// в угадывание.
/// </summary>
public string? TraceJson { get; private set; }
private ScheduleEntry() { }
/// <summary>Запись, порождённая слотом сетки: программа, заполнитель или конец вещания.</summary>
public static ScheduleEntry FromSlot(
Guid channelId,
Guid mediaAssetId,
ScheduleEntryKind kind,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid? showId,
int? episodeIndex,
Guid? slotId,
string? traceJson,
Guid? collectionId = null
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = kind,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
EpisodeIndex = episodeIndex,
SlotId = slotId,
TraceJson = traceJson,
CollectionId = collectionId,
};
public static ScheduleEntry Program(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid showId,
int episodeIndex
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Program,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
EpisodeIndex = episodeIndex,
};
public static ScheduleEntry Ad(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Ad,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
};
/// <summary>Заставка-переход. <paramref name="showId"/> — следующее шоу (для EPG/справки).</summary>
public static ScheduleEntry Bumper(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid? showId,
Guid? bumperVariantId
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Bumper,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
BumperVariantId = bumperVariantId,
};
}
+174 -173
View File
@@ -1,173 +1,174 @@
namespace TeleWave.Domain.Library;
/// <summary>
/// Переиспользуемое шоу в общей библиотеке: сериал (упорядоченные серии) либо полнометражка.
/// Серии идут строго в порядке <see cref="ShowEpisode.Position"/>; курсор показа на канале хранится
/// отдельно на связке канал↔шоу (см. Broadcast/ChannelShow).
/// </summary>
public class Show
{
private readonly List<ShowEpisode> _episodes = new();
private readonly List<ShowGenre> _genres = new();
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
/// <summary>Оригинальное название (обычно на английском) — по нему ищутся метаданные; на экранах
/// продолжаем показывать <see cref="Name"/>. Null/пусто — ищем по <see cref="Name"/>.</summary>
public string? OriginalName { get; private set; }
public string? Description { get; private set; }
public ShowKind Kind { get; private set; }
/// <summary>Категория аудитории (обычное/детское/взрослое) — под будущие фильтры показа.</summary>
public ShowAudience Audience { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
// ── Метаданные (TMDb/OMDb/вручную) ──
/// <summary>Источник метаданных: «tmdb»/«omdb»/«manual» или null, если не заданы.</summary>
public string? MetadataProvider { get; private set; }
/// <summary>Идентификатор шоу во внешнем источнике (для довыгрузки серий).</summary>
public string? MetadataExternalId { get; private set; }
public int? Year { get; private set; }
/// <summary>Постер шоу — ссылка на запись общего реестра изображений (<c>Domain/Images</c>) или null.</summary>
public Guid? PosterImageId { get; private set; }
/// <summary>Серии шоу (backing-field для EF). Порядок показа — по <see cref="ShowEpisode.Position"/>;
/// потребители сортируют явно (см. загрузчик планировщика).</summary>
public IReadOnlyList<ShowEpisode> Episodes => _episodes;
/// <summary>Жанры шоу (backing-field для EF). Ровно один помечен основным, если список не пуст.</summary>
public IReadOnlyList<ShowGenre> Genres => _genres;
private Show() { }
public static Show Create(
string name,
ShowKind kind,
string? description = null,
string? originalName = null,
ShowAudience audience = ShowAudience.General
) =>
new()
{
Id = Guid.NewGuid(),
Name = name,
OriginalName = Normalize(originalName),
Kind = kind,
Description = description,
Audience = audience,
CreatedAt = DateTimeOffset.UtcNow,
};
/// <summary>Задать категорию аудитории.</summary>
public void SetAudience(ShowAudience audience) => Audience = audience;
/// <summary>Основной жанр или null, если жанры не проставлены.</summary>
public Guid? PrimaryGenreId => _genres.FirstOrDefault(g => g.IsPrimary)?.GenreId;
/// <summary>
/// Полностью заменяет набор жанров; дубликаты и пустые идентификаторы отбрасываются. Основным
/// становится <paramref name="primaryGenreId"/>, если он попал в набор, иначе первый в списке —
/// так шоу с жанрами никогда не остаётся без основного.
/// </summary>
public void SetGenres(IEnumerable<Guid> genreIds, Guid? primaryGenreId = null)
{
var ids = genreIds.Where(id => id != Guid.Empty).Distinct().ToList();
_genres.Clear();
if (ids.Count == 0)
return;
var primary = primaryGenreId is { } candidate && ids.Contains(candidate) ? candidate : ids[0];
foreach (var id in ids)
_genres.Add(ShowGenre.Create(Id, id, id == primary));
}
public void Rename(string name, string? description)
{
Name = name;
Description = description;
}
/// <summary>Изменить отображаемое название (на экранах). Метаданные ищутся по <see cref="OriginalName"/>.</summary>
public void SetName(string name) => Name = name;
/// <summary>Задать/снять оригинальное название (пустая строка трактуется как отсутствие).</summary>
public void SetOriginalName(string? originalName) => OriginalName = Normalize(originalName);
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
/// <summary>Добавляет серию в конец. Для <see cref="ShowKind.Single"/> допустима ровно одна серия
/// (инвариант защищён самим агрегатом; вызывающий обычно проверяет <see cref="CanAddEpisode"/> заранее
/// и возвращает управляемую ошибку — исключение здесь лишь страховка от обхода).</summary>
public ShowEpisode AddEpisode(Guid mediaAssetId)
{
if (!CanAddEpisode)
throw new InvalidOperationException(
"Только сериал может содержать больше одной серии."
);
var nextPosition = _episodes.Count == 0 ? 0 : _episodes.Max(e => e.Position) + 1;
var episode = ShowEpisode.Create(Id, mediaAssetId, nextPosition);
_episodes.Add(episode);
return episode;
}
public bool RemoveEpisode(Guid episodeId)
{
var episode = _episodes.FirstOrDefault(e => e.Id == episodeId);
if (episode is null)
return false;
_episodes.Remove(episode);
return true;
}
/// <summary>Несколько серий бывает только у сериала. У полнометражки и у ролика-врезки серия ровно
/// одна: ролик с тремя сериями вёл бы себя в планировщике как мини-сериал, а задуман как единица.</summary>
public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0;
/// <summary>Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null.</summary>
public void ApplyMetadata(
string provider,
string externalId,
string? description,
int? year,
Guid? posterImageId
)
{
MetadataProvider = provider;
MetadataExternalId = externalId;
if (!string.IsNullOrWhiteSpace(description))
Description = description;
Year = year;
if (posterImageId is not null)
PosterImageId = posterImageId;
}
/// <summary>Ручная правка метаданных (без внешнего источника).</summary>
public void UpdateMetadataManual(string? description, int? year)
{
MetadataProvider = "manual";
MetadataExternalId = null;
Description = description;
Year = year;
}
/// <summary>Привязать/снять постер шоу (ссылка на запись реестра изображений).</summary>
public void SetPosterImage(Guid? imageId) => PosterImageId = imageId;
/// <summary>Сбросить все метаданные и отвязать постер (сама картинка остаётся в галерее).</summary>
public void ClearMetadata()
{
MetadataProvider = null;
MetadataExternalId = null;
Year = null;
PosterImageId = null;
Description = null;
}
}
namespace TeleWave.Domain.Library;
/// <summary>
/// Переиспользуемое шоу в общей библиотеке: сериал (упорядоченные серии) либо полнометражка.
/// Серии идут строго в порядке <see cref="ShowEpisode.Position"/>; где остановился показ — знает
/// состояние слота планировщика (<c>Programming/SlotState</c>), а не само шоу: одно шоу играет
/// на нескольких каналах и в нескольких слотах, и курсор у каждого свой.
/// </summary>
public class Show
{
private readonly List<ShowEpisode> _episodes = new();
private readonly List<ShowGenre> _genres = new();
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
/// <summary>Оригинальное название (обычно на английском) — по нему ищутся метаданные; на экранах
/// продолжаем показывать <see cref="Name"/>. Null/пусто — ищем по <see cref="Name"/>.</summary>
public string? OriginalName { get; private set; }
public string? Description { get; private set; }
public ShowKind Kind { get; private set; }
/// <summary>Категория аудитории (обычное/детское/взрослое) — под будущие фильтры показа.</summary>
public ShowAudience Audience { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
// ── Метаданные (TMDb/OMDb/вручную) ──
/// <summary>Источник метаданных: «tmdb»/«omdb»/«manual» или null, если не заданы.</summary>
public string? MetadataProvider { get; private set; }
/// <summary>Идентификатор шоу во внешнем источнике (для довыгрузки серий).</summary>
public string? MetadataExternalId { get; private set; }
public int? Year { get; private set; }
/// <summary>Постер шоу — ссылка на запись общего реестра изображений (<c>Domain/Images</c>) или null.</summary>
public Guid? PosterImageId { get; private set; }
/// <summary>Серии шоу (backing-field для EF). Порядок показа — по <see cref="ShowEpisode.Position"/>;
/// потребители сортируют явно (см. загрузчик планировщика).</summary>
public IReadOnlyList<ShowEpisode> Episodes => _episodes;
/// <summary>Жанры шоу (backing-field для EF). Ровно один помечен основным, если список не пуст.</summary>
public IReadOnlyList<ShowGenre> Genres => _genres;
private Show() { }
public static Show Create(
string name,
ShowKind kind,
string? description = null,
string? originalName = null,
ShowAudience audience = ShowAudience.General
) =>
new()
{
Id = Guid.NewGuid(),
Name = name,
OriginalName = Normalize(originalName),
Kind = kind,
Description = description,
Audience = audience,
CreatedAt = DateTimeOffset.UtcNow,
};
/// <summary>Задать категорию аудитории.</summary>
public void SetAudience(ShowAudience audience) => Audience = audience;
/// <summary>Основной жанр или null, если жанры не проставлены.</summary>
public Guid? PrimaryGenreId => _genres.FirstOrDefault(g => g.IsPrimary)?.GenreId;
/// <summary>
/// Полностью заменяет набор жанров; дубликаты и пустые идентификаторы отбрасываются. Основным
/// становится <paramref name="primaryGenreId"/>, если он попал в набор, иначе первый в списке —
/// так шоу с жанрами никогда не остаётся без основного.
/// </summary>
public void SetGenres(IEnumerable<Guid> genreIds, Guid? primaryGenreId = null)
{
var ids = genreIds.Where(id => id != Guid.Empty).Distinct().ToList();
_genres.Clear();
if (ids.Count == 0)
return;
var primary = primaryGenreId is { } candidate && ids.Contains(candidate) ? candidate : ids[0];
foreach (var id in ids)
_genres.Add(ShowGenre.Create(Id, id, id == primary));
}
public void Rename(string name, string? description)
{
Name = name;
Description = description;
}
/// <summary>Изменить отображаемое название (на экранах). Метаданные ищутся по <see cref="OriginalName"/>.</summary>
public void SetName(string name) => Name = name;
/// <summary>Задать/снять оригинальное название (пустая строка трактуется как отсутствие).</summary>
public void SetOriginalName(string? originalName) => OriginalName = Normalize(originalName);
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
/// <summary>Добавляет серию в конец. Для <see cref="ShowKind.Single"/> допустима ровно одна серия
/// (инвариант защищён самим агрегатом; вызывающий обычно проверяет <see cref="CanAddEpisode"/> заранее
/// и возвращает управляемую ошибку — исключение здесь лишь страховка от обхода).</summary>
public ShowEpisode AddEpisode(Guid mediaAssetId)
{
if (!CanAddEpisode)
throw new InvalidOperationException(
"Только сериал может содержать больше одной серии."
);
var nextPosition = _episodes.Count == 0 ? 0 : _episodes.Max(e => e.Position) + 1;
var episode = ShowEpisode.Create(Id, mediaAssetId, nextPosition);
_episodes.Add(episode);
return episode;
}
public bool RemoveEpisode(Guid episodeId)
{
var episode = _episodes.FirstOrDefault(e => e.Id == episodeId);
if (episode is null)
return false;
_episodes.Remove(episode);
return true;
}
/// <summary>Несколько серий бывает только у сериала. У полнометражки и у ролика-врезки серия ровно
/// одна: ролик с тремя сериями вёл бы себя в планировщике как мини-сериал, а задуман как единица.</summary>
public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0;
/// <summary>Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null.</summary>
public void ApplyMetadata(
string provider,
string externalId,
string? description,
int? year,
Guid? posterImageId
)
{
MetadataProvider = provider;
MetadataExternalId = externalId;
if (!string.IsNullOrWhiteSpace(description))
Description = description;
Year = year;
if (posterImageId is not null)
PosterImageId = posterImageId;
}
/// <summary>Ручная правка метаданных (без внешнего источника).</summary>
public void UpdateMetadataManual(string? description, int? year)
{
MetadataProvider = "manual";
MetadataExternalId = null;
Description = description;
Year = year;
}
/// <summary>Привязать/снять постер шоу (ссылка на запись реестра изображений).</summary>
public void SetPosterImage(Guid? imageId) => PosterImageId = imageId;
/// <summary>Сбросить все метаданные и отвязать постер (сама картинка остаётся в галерее).</summary>
public void ClearMetadata()
{
MetadataProvider = null;
MetadataExternalId = null;
Year = null;
PosterImageId = null;
Description = null;
}
}
@@ -135,7 +135,9 @@ public sealed record PlannedItem(
/// <summary>Для заставки: блок, пара «из/в» и место под ассет, который отрендерят позже.</summary>
Guid? BumperTemplateId = null,
Guid? FromShowId = null,
Guid? ToShowId = null
Guid? ToShowId = null,
/// <summary>Коллекция, частью которой шла единица (null — шоу играло само по себе).</summary>
Guid? CollectionId = null
);
public enum PlannedItemKind
@@ -333,7 +333,7 @@ public static class SchedulePlanner
if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd))
break;
items.Add(Program(unit, cursor, slot.SlotId, slotTrace));
items.Add(Program(unit, cursor, slot.SlotId, slotTrace, CollectionOf(element)));
cursor += unit.Duration;
accumulated += unit.Duration;
unitIndex++;
@@ -451,11 +451,16 @@ public static class SchedulePlanner
return cursor;
}
/// <summary>Коллекция элемента или null, если в эфир шло отдельное шоу.</summary>
private static Guid? CollectionOf(PlanningElement element) =>
element.Kind == GroupElementKind.Collection ? element.ElementId : null;
private static PlannedItem Program(
PlanningUnit unit,
DateTimeOffset start,
Guid slotId,
PlanTrace trace
PlanTrace trace,
Guid? collectionId = null
) =>
new(
unit.MediaAssetId,
@@ -465,7 +470,8 @@ public static class SchedulePlanner
unit.UnitIndex,
slotId,
PlannedItemKind.Program,
trace
trace,
CollectionId: collectionId
);
private static PlanningCursorUpdate CursorUpdate(
@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ScheduleEntryCollection : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "CollectionId",
table: "ScheduleEntries",
type: "uuid",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CollectionId",
table: "ScheduleEntries");
}
}
}
@@ -399,6 +399,9 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<Guid?>("CollectionId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");