Add broadcast scheduling features: implement Show and Channel entities, enhance AppDbContext and DependencyInjection for broadcasting, and update API routing. Include migration for new database schema and update documentation for broadcast-related functionalities.

This commit is contained in:
Leonid Pershin
2026-07-24 08:57:08 +03:00
parent e15ecbdb29
commit 4fa9dae37f
86 changed files with 4094 additions and 15 deletions
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.AddChannelAd;
public sealed record AddChannelAdCommand(Guid ChannelId, Guid MediaAssetId) : ICommand<Result<Guid>>;
@@ -0,0 +1,35 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.AddChannelAd;
public sealed class AddChannelAdCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddChannelAdCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddChannelAdCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels
.Include(c => c.Ads)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<Guid>(ChannelErrors.NotFound);
var assetExists = await dbContext.MediaAssets.AnyAsync(
a => a.Id == command.MediaAssetId,
cancellationToken
);
if (!assetExists)
return Result.Failure<Guid>(ChannelErrors.AssetNotFound);
if (channel.HasAd(command.MediaAssetId))
return Result.Failure<Guid>(ChannelErrors.AdAlreadyAdded);
var ad = channel.AddAd(command.MediaAssetId);
return Result.Success(ad.Id);
}
}
@@ -0,0 +1,13 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.AddChannelShow;
public sealed record AddChannelShowCommand(
Guid ChannelId,
Guid ShowId,
int Weight,
BlockMode BlockMode,
int BlockValue
) : ICommand<Result<Guid>>;
@@ -0,0 +1,37 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.AddChannelShow;
public sealed class AddChannelShowCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddChannelShowCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddChannelShowCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels
.Include(c => c.Shows)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<Guid>(ChannelErrors.NotFound);
var showExists = await dbContext.Shows.AnyAsync(s => s.Id == command.ShowId, cancellationToken);
if (!showExists)
return Result.Failure<Guid>(ChannelErrors.ShowNotFound);
if (channel.HasShow(command.ShowId))
return Result.Failure<Guid>(ChannelErrors.ShowAlreadyAdded);
var channelShow = channel.AddShow(
command.ShowId,
command.Weight,
command.BlockMode,
command.BlockValue
);
return Result.Success(channelShow.Id);
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace TeleWave.Application.Broadcast.AddChannelShow;
public sealed class AddChannelShowCommandValidator : AbstractValidator<AddChannelShowCommand>
{
public AddChannelShowCommandValidator()
{
RuleFor(x => x.Weight).InclusiveBetween(1, 1000);
RuleFor(x => x.BlockValue).InclusiveBetween(1, 10000);
}
}
@@ -0,0 +1,41 @@
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast;
public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled);
public sealed record ChannelShowDto(
Guid Id,
Guid ShowId,
string ShowName,
int Weight,
BlockMode BlockMode,
int BlockValue,
bool IsEnabled,
int NextEpisodeIndex
);
public sealed record ChannelAdDto(Guid Id, Guid MediaAssetId, string? AssetName, int Position);
public sealed record OverrideShowDto(Guid ShowId, string ShowName, int Weight);
public sealed record ProgrammingOverrideDto(
Guid Id,
OverrideMode Mode,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
IReadOnlyList<OverrideShowDto> Shows
);
public sealed record ChannelDto(
Guid Id,
string Name,
string Slug,
bool IsEnabled,
AdInsertion AdInsertion,
int AdsPerBreak,
Guid? FillerAssetId,
IReadOnlyList<ChannelShowDto> Shows,
IReadOnlyList<ChannelAdDto> Ads,
IReadOnlyList<ProgrammingOverrideDto> Overrides
);
@@ -0,0 +1,58 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast;
public static class ChannelErrors
{
public static readonly Error NotFound = Error.NotFound("Channels.NotFound", "Канал не найден.");
public static readonly Error DuplicateSlug = Error.Conflict(
"Channels.DuplicateSlug",
"Канал с таким slug уже существует."
);
public static readonly Error ShowAlreadyAdded = Error.Conflict(
"Channels.ShowAlreadyAdded",
"Это шоу уже добавлено в канал."
);
public static readonly Error ShowNotFound = Error.NotFound(
"Channels.ShowNotFound",
"Шоу не найдено в библиотеке."
);
public static readonly Error ChannelShowNotFound = Error.NotFound(
"Channels.ChannelShowNotFound",
"Шоу не найдено в канале."
);
public static readonly Error AdAlreadyAdded = Error.Conflict(
"Channels.AdAlreadyAdded",
"Этот ролик уже в пуле рекламы канала."
);
public static readonly Error AdNotFound = Error.NotFound(
"Channels.AdNotFound",
"Реклама не найдена в пуле канала."
);
public static readonly Error AssetNotFound = Error.NotFound(
"Channels.AssetNotFound",
"Медиа-ассет не найден."
);
public static readonly Error OverrideNotFound = Error.NotFound(
"Channels.OverrideNotFound",
"Override не найден."
);
public static readonly Error InvalidOverrideWindow = Error.Validation(
"Channels.InvalidOverrideWindow",
"Окончание override должно быть позже начала."
);
public static readonly Error OverrideNeedsShow = Error.Validation(
"Channels.OverrideNeedsShow",
"Override должен ссылаться хотя бы на одно шоу."
);
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.CreateChannel;
public sealed record CreateChannelCommand(string Name, string Slug) : ICommand<Result<Guid>>;
@@ -0,0 +1,28 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.CreateChannel;
public sealed class CreateChannelCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateChannelCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
CreateChannelCommand command,
CancellationToken cancellationToken
)
{
var slugTaken = await dbContext.Channels.AnyAsync(
c => c.Slug == command.Slug,
cancellationToken
);
if (slugTaken)
return Result.Failure<Guid>(ChannelErrors.DuplicateSlug);
var channel = Channel.Create(command.Name, command.Slug, DateTimeOffset.UtcNow);
dbContext.Channels.Add(channel);
return Result.Success(channel.Id);
}
}
@@ -0,0 +1,16 @@
using FluentValidation;
namespace TeleWave.Application.Broadcast.CreateChannel;
public sealed class CreateChannelCommandValidator : AbstractValidator<CreateChannelCommand>
{
public CreateChannelCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Slug)
.NotEmpty()
.MaximumLength(128)
.Matches("^[a-z0-9]+(-[a-z0-9]+)*$")
.WithMessage("Slug — только строчные латинские буквы, цифры и дефисы.");
}
}
@@ -0,0 +1,13 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.CreateOverride;
public sealed record CreateProgrammingOverrideCommand(
Guid ChannelId,
OverrideMode Mode,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
IReadOnlyList<OverrideShowInput> Shows
) : ICommand<Result<Guid>>;
@@ -0,0 +1,42 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.CreateOverride;
public sealed class CreateProgrammingOverrideCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateProgrammingOverrideCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
CreateProgrammingOverrideCommand command,
CancellationToken cancellationToken
)
{
if (command.EndsAtUtc <= command.StartsAtUtc)
return Result.Failure<Guid>(ChannelErrors.InvalidOverrideWindow);
if (command.Shows.Count == 0)
return Result.Failure<Guid>(ChannelErrors.OverrideNeedsShow);
var channel = await dbContext.Channels
.Include(c => c.Overrides)
.ThenInclude(o => o.Shows)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<Guid>(ChannelErrors.NotFound);
var showIds = command.Shows.Select(s => s.ShowId).Distinct().ToList();
var existingCount = await dbContext.Shows.CountAsync(
s => showIds.Contains(s.Id),
cancellationToken
);
if (existingCount != showIds.Count)
return Result.Failure<Guid>(ChannelErrors.ShowNotFound);
var ovr = channel.AddOverride(command.Mode, command.StartsAtUtc, command.EndsAtUtc);
foreach (var show in command.Shows)
ovr.AddShow(show.ShowId, show.Weight);
return Result.Success(ovr.Id);
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace TeleWave.Application.Broadcast.CreateOverride;
public sealed class CreateProgrammingOverrideCommandValidator
: AbstractValidator<CreateProgrammingOverrideCommand>
{
public CreateProgrammingOverrideCommandValidator()
{
RuleFor(x => x.Shows).NotEmpty();
RuleForEach(x => x.Shows).ChildRules(s => s.RuleFor(i => i.Weight).InclusiveBetween(1, 1000));
}
}
@@ -0,0 +1,3 @@
namespace TeleWave.Application.Broadcast.CreateOverride;
public sealed record OverrideShowInput(Guid ShowId, int Weight);
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.DeleteOverride;
public sealed record DeleteProgrammingOverrideCommand(Guid ChannelId, Guid OverrideId)
: ICommand<Result>;
@@ -0,0 +1,26 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.DeleteOverride;
public sealed class DeleteProgrammingOverrideCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteProgrammingOverrideCommand, Result>
{
public async Task<Result> Handle(
DeleteProgrammingOverrideCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels
.Include(c => c.Overrides)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
return channel.RemoveOverride(command.OverrideId)
? Result.Success()
: Result.Failure(ChannelErrors.OverrideNotFound);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.GetChannel;
public sealed record GetChannelQuery(Guid Id) : IQuery<Result<ChannelDto>>;
@@ -0,0 +1,93 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.GetChannel;
public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetChannelQuery, Result<ChannelDto>>
{
public async Task<Result<ChannelDto>> Handle(
GetChannelQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels.AsNoTracking()
.Include(c => c.Shows)
.Include(c => c.Ads)
.Include(c => c.Overrides)
.ThenInclude(o => o.Shows)
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
if (channel is null)
return Result.Failure<ChannelDto>(ChannelErrors.NotFound);
var showIds = channel.Shows.Select(s => s.ShowId)
.Concat(channel.Overrides.SelectMany(o => o.Shows.Select(s => s.ShowId)))
.Distinct()
.ToList();
var showNames = await dbContext.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new { s.Id, s.Name })
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
var adAssetIds = channel.Ads.Select(a => a.MediaAssetId).ToList();
var assetNames = await dbContext.MediaAssets.AsNoTracking()
.Where(a => adAssetIds.Contains(a.Id))
.Select(a => new { a.Id, a.OriginalFileName })
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
string ShowName(Guid id) => showNames.GetValueOrDefault(id, "(удалено)");
var shows = channel.Shows
.Select(s => new ChannelShowDto(
s.Id,
s.ShowId,
ShowName(s.ShowId),
s.Weight,
s.BlockMode,
s.BlockValue,
s.IsEnabled,
s.NextEpisodeIndex
))
.ToList();
var ads = channel.Ads
.OrderBy(a => a.Position)
.Select(a => new ChannelAdDto(
a.Id,
a.MediaAssetId,
assetNames.GetValueOrDefault(a.MediaAssetId),
a.Position
))
.ToList();
var overrides = channel.Overrides
.OrderBy(o => o.StartsAtUtc)
.Select(o => new ProgrammingOverrideDto(
o.Id,
o.Mode,
o.StartsAtUtc,
o.EndsAtUtc,
o.Shows
.Select(s => new OverrideShowDto(s.ShowId, ShowName(s.ShowId), s.Weight))
.ToList()
))
.ToList();
return Result.Success(
new ChannelDto(
channel.Id,
channel.Name,
channel.Slug,
channel.IsEnabled,
channel.AdInsertion,
channel.AdsPerBreak,
channel.FillerAssetId,
shows,
ads,
overrides
)
);
}
}
@@ -0,0 +1,10 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.GetSchedule;
public sealed record GetChannelScheduleQuery(
Guid ChannelId,
DateTimeOffset FromUtc,
DateTimeOffset ToUtc
) : IQuery<Result<IReadOnlyList<ScheduleEntryDto>>>;
@@ -0,0 +1,54 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.GetSchedule;
public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetChannelScheduleQuery, Result<IReadOnlyList<ScheduleEntryDto>>>
{
public async Task<Result<IReadOnlyList<ScheduleEntryDto>>> Handle(
GetChannelScheduleQuery query,
CancellationToken cancellationToken
)
{
var channelExists = await dbContext.Channels.AnyAsync(
c => c.Id == query.ChannelId,
cancellationToken
);
if (!channelExists)
return Result.Failure<IReadOnlyList<ScheduleEntryDto>>(ChannelErrors.NotFound);
// Пересекающиеся с окном [from, to) записи.
var entries = await dbContext.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == query.ChannelId
&& e.StartsAtUtc < query.ToUtc
&& e.EndsAtUtc > query.FromUtc
)
.OrderBy(e => e.StartsAtUtc)
.ToListAsync(cancellationToken);
var showIds = entries.Where(e => e.ShowId != null).Select(e => e.ShowId!.Value).Distinct().ToList();
var showNames = await dbContext.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new { s.Id, s.Name })
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
var dtos = entries
.Select(e => new ScheduleEntryDto(
e.Id,
e.Kind,
e.MediaAssetId,
e.StartsAtUtc,
e.EndsAtUtc,
e.ShowId,
e.ShowId is { } sid ? showNames.GetValueOrDefault(sid) : null,
e.EpisodeIndex
))
.ToList();
return Result.Success<IReadOnlyList<ScheduleEntryDto>>(dtos);
}
}
@@ -0,0 +1,5 @@
using LiteCqrs;
namespace TeleWave.Application.Broadcast.ListChannels;
public sealed record ListChannelsQuery : IQuery<IReadOnlyList<ChannelSummaryDto>>;
@@ -0,0 +1,20 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Broadcast.ListChannels;
public sealed class ListChannelsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListChannelsQuery, IReadOnlyList<ChannelSummaryDto>>
{
public async Task<IReadOnlyList<ChannelSummaryDto>> Handle(
ListChannelsQuery query,
CancellationToken cancellationToken
)
{
return await dbContext.Channels.AsNoTracking()
.OrderBy(c => c.Name)
.Select(c => new ChannelSummaryDto(c.Id, c.Name, c.Slug, c.IsEnabled))
.ToListAsync(cancellationToken);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.RegenerateSchedule;
public sealed record RegenerateChannelScheduleCommand(Guid ChannelId) : ICommand<Result>;
@@ -0,0 +1,35 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.RegenerateSchedule;
public sealed class RegenerateChannelScheduleCommandHandler(
IAppDbContext dbContext,
ScheduleGenerator generator
) : ICommandHandler<RegenerateChannelScheduleCommand, Result>
{
public async Task<Result> Handle(
RegenerateChannelScheduleCommand command,
CancellationToken cancellationToken
)
{
var exists = await dbContext.Channels.AnyAsync(
c => c.Id == command.ChannelId,
cancellationToken
);
if (!exists)
return Result.Failure(ChannelErrors.NotFound);
// Генератор сам сохраняет изменения (удаление хвоста + новые записи + курсоры).
await generator.GenerateAsync(
command.ChannelId,
DateTimeOffset.UtcNow,
regenerate: true,
cancellationToken
);
return Result.Success();
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.RemoveChannelAd;
public sealed record RemoveChannelAdCommand(Guid ChannelId, Guid ChannelAdId) : ICommand<Result>;
@@ -0,0 +1,26 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.RemoveChannelAd;
public sealed class RemoveChannelAdCommandHandler(IAppDbContext dbContext)
: ICommandHandler<RemoveChannelAdCommand, Result>
{
public async Task<Result> Handle(
RemoveChannelAdCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels
.Include(c => c.Ads)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
return channel.RemoveAd(command.ChannelAdId)
? Result.Success()
: Result.Failure(ChannelErrors.AdNotFound);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.RemoveChannelShow;
public sealed record RemoveChannelShowCommand(Guid ChannelId, Guid ChannelShowId) : ICommand<Result>;
@@ -0,0 +1,26 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.RemoveChannelShow;
public sealed class RemoveChannelShowCommandHandler(IAppDbContext dbContext)
: ICommandHandler<RemoveChannelShowCommand, Result>
{
public async Task<Result> Handle(
RemoveChannelShowCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels
.Include(c => c.Shows)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
return channel.RemoveShow(command.ChannelShowId)
? Result.Success()
: Result.Failure(ChannelErrors.ChannelShowNotFound);
}
}
@@ -0,0 +1,14 @@
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast;
public sealed record ScheduleEntryDto(
Guid Id,
ScheduleEntryKind Kind,
Guid MediaAssetId,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
Guid? ShowId,
string? ShowName,
int? EpisodeIndex
);
@@ -0,0 +1,187 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Domain.Media;
namespace TeleWave.Application.Broadcast.Scheduling;
/// <summary>
/// Оркестратор планирования: загружает конфигурацию канала и готовые ассеты, вызывает чистый
/// <see cref="SchedulePlanner"/>, материализует записи и двигает курсоры. Используется фоновым
/// планировщиком (расширение горизонта) и командой перегенерации (правка конфигурации канала).
/// </summary>
public sealed class ScheduleGenerator(
IAppDbContext dbContext,
IRandomSource random,
IOptions<SchedulerOptions> options
)
{
private readonly SchedulerOptions _options = options.Value;
/// <summary>
/// Достраивает (или, при <paramref name="regenerate"/>, перестраивает будущий хвост) расписание
/// канала до горизонта. Возвращает число добавленных записей (-1 — канал не найден/выключен).
/// </summary>
public async Task<int> GenerateAsync(
Guid channelId,
DateTimeOffset now,
bool regenerate,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels
.Include(c => c.Shows)
.Include(c => c.Ads)
.Include(c => c.Overrides)
.ThenInclude(o => o.Shows)
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
if (channel is null || !channel.IsEnabled)
return -1;
var horizonEnd = now.AddDays(_options.HorizonDays);
// Чистим прошлое сверх окна ретеншна.
var retentionCutoff = now.AddHours(-_options.RetentionHours);
await dbContext.ScheduleEntries
.Where(e => e.ChannelId == channelId && e.EndsAtUtc < retentionCutoff)
.ExecuteDeleteAsync(cancellationToken);
// Точка продолжения: конец последней сохранённой записи (для regenerate — только уже стартовавшей).
var lastEnd = await dbContext.ScheduleEntries
.Where(e =>
e.ChannelId == channelId && (!regenerate || e.StartsAtUtc < now)
)
.MaxAsync(e => (DateTimeOffset?)e.EndsAtUtc, cancellationToken);
var startTime = lastEnd ?? now;
if (startTime < now)
startTime = now;
if (regenerate)
await dbContext.ScheduleEntries
.Where(e => e.ChannelId == channelId && e.StartsAtUtc >= now)
.ExecuteDeleteAsync(cancellationToken);
if (startTime >= horizonEnd)
{
await dbContext.SaveChangesAsync(cancellationToken);
return 0;
}
var input = await BuildInputAsync(channel, startTime, horizonEnd, cancellationToken);
var result = SchedulePlanner.Plan(input, random);
foreach (var entry in result.Entries)
{
var scheduleEntry = entry.Kind == ScheduleEntryKind.Program
? ScheduleEntry.Program(
channel.Id,
entry.MediaAssetId,
entry.StartsAtUtc,
entry.EndsAtUtc,
entry.ShowId!.Value,
entry.EpisodeIndex!.Value
)
: ScheduleEntry.Ad(channel.Id, entry.MediaAssetId, entry.StartsAtUtc, entry.EndsAtUtc);
dbContext.ScheduleEntries.Add(scheduleEntry);
}
foreach (var channelShow in channel.Shows)
if (result.NextEpisodeIndexByChannelShow.TryGetValue(channelShow.Id, out var idx))
channelShow.SetNextEpisodeIndex(idx);
channel.SetNextAdIndex(result.NextAdIndex);
await dbContext.SaveChangesAsync(cancellationToken);
return result.Entries.Count;
}
private async Task<PlannerInput> BuildInputAsync(
Channel channel,
DateTimeOffset startTime,
DateTimeOffset horizonEnd,
CancellationToken cancellationToken
)
{
var enabledShows = channel.Shows.Where(s => s.IsEnabled).ToList();
var showIds = enabledShows.Select(s => s.ShowId).Distinct().ToList();
var shows = await dbContext.Shows
.Include(s => s.Episodes)
.Where(s => showIds.Contains(s.Id))
.ToListAsync(cancellationToken);
var episodesByShow = shows.ToDictionary(
s => s.Id,
s => s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).ToList()
);
var candidateAssetIds = episodesByShow.Values
.SelectMany(x => x)
.Concat(channel.Ads.Select(a => a.MediaAssetId))
.Distinct()
.ToList();
var durations = await dbContext.MediaAssets
.Where(a =>
candidateAssetIds.Contains(a.Id)
&& a.Status == MediaAssetStatus.Ready
&& a.Duration != null
)
.Select(a => new { a.Id, a.Duration })
.ToDictionaryAsync(x => x.Id, x => x.Duration!.Value, cancellationToken);
var plannerShows = new List<PlannerShow>();
foreach (var channelShow in enabledShows)
{
if (!episodesByShow.TryGetValue(channelShow.ShowId, out var episodeIds))
continue;
var ready = episodeIds.Where(durations.ContainsKey).ToList();
if (ready.Count == 0)
continue;
plannerShows.Add(
new PlannerShow(
channelShow.Id,
channelShow.ShowId,
channelShow.Weight,
channelShow.BlockMode,
channelShow.BlockValue,
ready,
channelShow.NextEpisodeIndex
)
);
}
var adPool = channel.Ads
.OrderBy(a => a.Position)
.Select(a => a.MediaAssetId)
.Where(durations.ContainsKey)
.ToList();
var overrides = channel.Overrides
.Select(o => new PlannerOverride(
o.StartsAtUtc,
o.EndsAtUtc,
o.Mode,
o.Shows.Select(s => new PlannerOverrideShow(s.ShowId, s.Weight)).ToList()
))
.ToList();
return new PlannerInput(
channel.Id,
channel.AdInsertion,
channel.AdsPerBreak,
channel.NextAdIndex,
plannerShows,
adPool,
durations,
overrides,
startTime,
horizonEnd
);
}
}
@@ -0,0 +1,15 @@
namespace TeleWave.Application.Broadcast.Scheduling;
public sealed class SchedulerOptions
{
public const string SectionName = "Scheduler";
/// <summary>На сколько дней вперёд держать материализованное расписание.</summary>
public int HorizonDays { get; init; } = 3;
/// <summary>Сколько часов прошедшего расписания хранить (для EPG «что было»), затем чистить.</summary>
public int RetentionHours { get; init; } = 24;
/// <summary>Период тика фонового планировщика, минуты.</summary>
public int TickMinutes { get; init; } = 30;
}
@@ -0,0 +1,14 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed record UpdateChannelSettingsCommand(
Guid ChannelId,
string Name,
bool IsEnabled,
AdInsertion AdInsertion,
int AdsPerBreak,
Guid? FillerAssetId
) : ICommand<Result>;
@@ -0,0 +1,39 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateChannelSettingsCommand, Result>
{
public async Task<Result> Handle(
UpdateChannelSettingsCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels.FirstOrDefaultAsync(
c => c.Id == command.ChannelId,
cancellationToken
);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
if (command.FillerAssetId is { } fillerId)
{
var exists = await dbContext.MediaAssets.AnyAsync(a => a.Id == fillerId, cancellationToken);
if (!exists)
return Result.Failure(ChannelErrors.AssetNotFound);
}
channel.UpdateSettings(
command.Name,
command.IsEnabled,
command.AdInsertion,
command.AdsPerBreak,
command.FillerAssetId
);
return Result.Success();
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed class UpdateChannelSettingsCommandValidator
: AbstractValidator<UpdateChannelSettingsCommand>
{
public UpdateChannelSettingsCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.AdsPerBreak).InclusiveBetween(0, 10);
}
}
@@ -0,0 +1,14 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.UpdateChannelShow;
public sealed record UpdateChannelShowCommand(
Guid ChannelId,
Guid ChannelShowId,
int Weight,
BlockMode BlockMode,
int BlockValue,
bool IsEnabled
) : ICommand<Result>;
@@ -0,0 +1,29 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.UpdateChannelShow;
public sealed class UpdateChannelShowCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateChannelShowCommand, Result>
{
public async Task<Result> Handle(
UpdateChannelShowCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels
.Include(c => c.Shows)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
var channelShow = channel.FindShow(command.ChannelShowId);
if (channelShow is null)
return Result.Failure(ChannelErrors.ChannelShowNotFound);
channelShow.Update(command.Weight, command.BlockMode, command.BlockValue, command.IsEnabled);
return Result.Success();
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace TeleWave.Application.Broadcast.UpdateChannelShow;
public sealed class UpdateChannelShowCommandValidator : AbstractValidator<UpdateChannelShowCommand>
{
public UpdateChannelShowCommandValidator()
{
RuleFor(x => x.Weight).InclusiveBetween(1, 1000);
RuleFor(x => x.BlockValue).InclusiveBetween(1, 10000);
}
}
@@ -1,5 +1,7 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Domain.Auth;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
namespace TeleWave.Application.Common.Interfaces;
@@ -8,6 +10,9 @@ public interface IAppDbContext
{
DbSet<RefreshToken> RefreshTokens { get; }
DbSet<MediaAsset> MediaAssets { get; }
DbSet<Show> Shows { get; }
DbSet<Channel> Channels { get; }
DbSet<ScheduleEntry> ScheduleEntries { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.AddEpisode;
public sealed record AddEpisodeCommand(Guid ShowId, Guid MediaAssetId) : ICommand<Result<Guid>>;
@@ -0,0 +1,35 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.AddEpisode;
public sealed class AddEpisodeCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddEpisodeCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddEpisodeCommand command,
CancellationToken cancellationToken
)
{
var show = await dbContext.Shows
.Include(s => s.Episodes)
.FirstOrDefaultAsync(s => s.Id == command.ShowId, cancellationToken);
if (show is null)
return Result.Failure<Guid>(ShowErrors.NotFound);
if (!show.CanAddEpisode)
return Result.Failure<Guid>(ShowErrors.SingleAlreadyHasEpisode);
var assetExists = await dbContext.MediaAssets.AnyAsync(
a => a.Id == command.MediaAssetId,
cancellationToken
);
if (!assetExists)
return Result.Failure<Guid>(ShowErrors.AssetNotFound);
var episode = show.AddEpisode(command.MediaAssetId);
return Result.Success(episode.Id);
}
}
@@ -0,0 +1,8 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.CreateShow;
public sealed record CreateShowCommand(string Name, ShowKind Kind, string? Description)
: ICommand<Result<Guid>>;
@@ -0,0 +1,17 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.CreateShow;
public sealed class CreateShowCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateShowCommand, Result<Guid>>
{
public Task<Result<Guid>> Handle(CreateShowCommand command, CancellationToken cancellationToken)
{
var show = Show.Create(command.Name, command.Kind, command.Description);
dbContext.Shows.Add(show);
return Task.FromResult(Result.Success(show.Id));
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace TeleWave.Application.Library.CreateShow;
public sealed class CreateShowCommandValidator : AbstractValidator<CreateShowCommand>
{
public CreateShowCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Description).MaximumLength(2048);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.DeleteShow;
public sealed record DeleteShowCommand(Guid ShowId) : ICommand<Result>;
@@ -0,0 +1,24 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.DeleteShow;
public sealed class DeleteShowCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteShowCommand, Result>
{
public async Task<Result> Handle(DeleteShowCommand command, CancellationToken cancellationToken)
{
var show = await dbContext.Shows.FirstOrDefaultAsync(
s => s.Id == command.ShowId,
cancellationToken
);
if (show is null)
return Result.Failure(ShowErrors.NotFound);
// TODO(этап 2+): запретить удаление шоу, пока оно привязано к каналу или будущему расписанию.
dbContext.Shows.Remove(show);
return Result.Success();
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.GetShow;
public sealed record GetShowQuery(Guid Id) : IQuery<Result<ShowDto>>;
@@ -0,0 +1,51 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.GetShow;
public sealed class GetShowQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetShowQuery, Result<ShowDto>>
{
public async Task<Result<ShowDto>> Handle(GetShowQuery query, CancellationToken cancellationToken)
{
var show = await dbContext.Shows.AsNoTracking()
.Include(s => s.Episodes)
.FirstOrDefaultAsync(s => s.Id == query.Id, cancellationToken);
if (show is null)
return Result.Failure<ShowDto>(ShowErrors.NotFound);
var episodes = show.Episodes.OrderBy(e => e.Position).ToList();
var assetIds = episodes.Select(e => e.MediaAssetId).ToList();
var assets = await dbContext.MediaAssets.AsNoTracking()
.Where(a => assetIds.Contains(a.Id))
.Select(a => new
{
a.Id,
a.OriginalFileName,
a.Status,
a.Duration,
})
.ToDictionaryAsync(a => a.Id, cancellationToken);
var episodeDtos = episodes
.Select(e =>
{
assets.TryGetValue(e.MediaAssetId, out var asset);
return new EpisodeDto(
e.Id,
e.MediaAssetId,
e.Position,
asset?.OriginalFileName,
asset?.Status,
asset?.Duration?.TotalSeconds
);
})
.ToList();
return Result.Success(
new ShowDto(show.Id, show.Name, show.Kind, show.Description, episodeDtos)
);
}
}
@@ -0,0 +1,5 @@
using LiteCqrs;
namespace TeleWave.Application.Library.ListShows;
public sealed record ListShowsQuery : IQuery<IReadOnlyList<ShowSummaryDto>>;
@@ -0,0 +1,20 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Library.ListShows;
public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListShowsQuery, IReadOnlyList<ShowSummaryDto>>
{
public async Task<IReadOnlyList<ShowSummaryDto>> Handle(
ListShowsQuery query,
CancellationToken cancellationToken
)
{
return await dbContext.Shows.AsNoTracking()
.OrderBy(s => s.Name)
.Select(s => new ShowSummaryDto(s.Id, s.Name, s.Kind, s.Episodes.Count, s.CreatedAt))
.ToListAsync(cancellationToken);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.RemoveEpisode;
public sealed record RemoveEpisodeCommand(Guid ShowId, Guid EpisodeId) : ICommand<Result>;
@@ -0,0 +1,26 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.RemoveEpisode;
public sealed class RemoveEpisodeCommandHandler(IAppDbContext dbContext)
: ICommandHandler<RemoveEpisodeCommand, Result>
{
public async Task<Result> Handle(
RemoveEpisodeCommand command,
CancellationToken cancellationToken
)
{
var show = await dbContext.Shows
.Include(s => s.Episodes)
.FirstOrDefaultAsync(s => s.Id == command.ShowId, cancellationToken);
if (show is null)
return Result.Failure(ShowErrors.NotFound);
return show.RemoveEpisode(command.EpisodeId)
? Result.Success()
: Result.Failure(ShowErrors.EpisodeNotFound);
}
}
@@ -0,0 +1,29 @@
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
namespace TeleWave.Application.Library;
public sealed record ShowSummaryDto(
Guid Id,
string Name,
ShowKind Kind,
int EpisodeCount,
DateTimeOffset CreatedAt
);
public sealed record EpisodeDto(
Guid Id,
Guid MediaAssetId,
int Position,
string? AssetName,
MediaAssetStatus? AssetStatus,
double? DurationSeconds
);
public sealed record ShowDto(
Guid Id,
string Name,
ShowKind Kind,
string? Description,
IReadOnlyList<EpisodeDto> Episodes
);
@@ -0,0 +1,23 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library;
public static class ShowErrors
{
public static readonly Error NotFound = Error.NotFound("Shows.NotFound", "Шоу не найдено.");
public static readonly Error EpisodeNotFound = Error.NotFound(
"Shows.EpisodeNotFound",
"Серия не найдена."
);
public static readonly Error SingleAlreadyHasEpisode = Error.Conflict(
"Shows.SingleAlreadyHasEpisode",
"Полнометражка/разовый выпуск может содержать только одну серию."
);
public static readonly Error AssetNotFound = Error.NotFound(
"Shows.AssetNotFound",
"Медиа-ассет для серии не найден."
);
}
@@ -10,6 +10,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<PropertyGroup>