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,4 @@
namespace TeleWave.Api.Common;
/// <summary>Единый ответ на создание сущности — её идентификатор.</summary>
public sealed record CreatedIdResponse(Guid Id);
@@ -0,0 +1,280 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.AddChannelAd;
using TeleWave.Application.Broadcast.AddChannelShow;
using TeleWave.Application.Broadcast.CreateChannel;
using TeleWave.Application.Broadcast.CreateOverride;
using TeleWave.Application.Broadcast.DeleteOverride;
using TeleWave.Application.Broadcast.GetChannel;
using TeleWave.Application.Broadcast.GetSchedule;
using TeleWave.Application.Broadcast.ListChannels;
using TeleWave.Application.Broadcast.RegenerateSchedule;
using TeleWave.Application.Broadcast.RemoveChannelAd;
using TeleWave.Application.Broadcast.RemoveChannelShow;
using TeleWave.Application.Broadcast.UpdateChannelSettings;
using TeleWave.Application.Broadcast.UpdateChannelShow;
using TeleWave.Domain.Broadcast;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class ChannelEndpoints
{
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/channels")
.WithTags("Admin.Channels")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", CreateChannel).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListChannels).Produces<IReadOnlyList<ChannelSummaryDto>>();
admin.MapGet("/{id:guid}", GetChannel).Produces<ChannelDto>();
admin.MapPut("/{id:guid}/settings", UpdateSettings).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/shows", AddShow)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/{id:guid}/shows/{channelShowId:guid}", UpdateShow)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/shows/{channelShowId:guid}", RemoveShow)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/ads", AddAd)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapDelete("/{id:guid}/ads/{channelAdId:guid}", RemoveAd)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/overrides", CreateOverride)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapDelete("/{id:guid}/overrides/{overrideId:guid}", DeleteOverride)
.Produces(StatusCodes.Status204NoContent);
admin.MapPost("/{id:guid}/regenerate", Regenerate).Produces(StatusCodes.Status204NoContent);
admin
.MapGet("/{id:guid}/schedule", GetSchedule)
.Produces<IReadOnlyList<ScheduleEntryDto>>();
return app;
}
private static async Task<IResult> CreateChannel(
CreateChannelCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/channels/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ListChannels(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListChannelsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetChannel(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetChannelQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateSettings(
Guid id,
UpdateChannelSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateChannelSettingsCommand(
id,
body.Name,
body.IsEnabled,
body.AdInsertion,
body.AdsPerBreak,
body.FillerAssetId
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> AddShow(
Guid id,
AddChannelShowBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddChannelShowCommand(id, body.ShowId, body.Weight, body.BlockMode, body.BlockValue),
cancellationToken
);
return result.IsSuccess
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> UpdateShow(
Guid id,
Guid channelShowId,
UpdateChannelShowBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateChannelShowCommand(
id,
channelShowId,
body.Weight,
body.BlockMode,
body.BlockValue,
body.IsEnabled
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> RemoveShow(
Guid id,
Guid channelShowId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RemoveChannelShowCommand(id, channelShowId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> AddAd(
Guid id,
AddChannelAdBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new AddChannelAdCommand(id, body.MediaAssetId), cancellationToken);
return result.IsSuccess
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> RemoveAd(
Guid id,
Guid channelAdId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RemoveChannelAdCommand(id, channelAdId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateOverride(
Guid id,
CreateOverrideBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateProgrammingOverrideCommand(
id,
body.Mode,
body.StartsAtUtc,
body.EndsAtUtc,
body.Shows
),
cancellationToken
);
return result.IsSuccess
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> DeleteOverride(
Guid id,
Guid overrideId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new DeleteProgrammingOverrideCommand(id, overrideId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Regenerate(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RegenerateChannelScheduleCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetSchedule(
Guid id,
DateTimeOffset? from,
DateTimeOffset? to,
ISender sender,
CancellationToken cancellationToken
)
{
var fromUtc = from ?? DateTimeOffset.UtcNow;
var toUtc = to ?? fromUtc.AddDays(1);
var result = await sender.Send(
new GetChannelScheduleQuery(id, fromUtc, toUtc),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record UpdateChannelSettingsBody(
string Name,
bool IsEnabled,
AdInsertion AdInsertion,
int AdsPerBreak,
Guid? FillerAssetId
);
public sealed record AddChannelShowBody(Guid ShowId, int Weight, BlockMode BlockMode, int BlockValue);
public sealed record UpdateChannelShowBody(
int Weight,
BlockMode BlockMode,
int BlockValue,
bool IsEnabled
);
public sealed record AddChannelAdBody(Guid MediaAssetId);
public sealed record CreateOverrideBody(
OverrideMode Mode,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
IReadOnlyList<OverrideShowInput> Shows
);
@@ -0,0 +1,99 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Library;
using TeleWave.Application.Library.AddEpisode;
using TeleWave.Application.Library.CreateShow;
using TeleWave.Application.Library.DeleteShow;
using TeleWave.Application.Library.GetShow;
using TeleWave.Application.Library.ListShows;
using TeleWave.Application.Library.RemoveEpisode;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class ShowEndpoints
{
public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/shows")
.WithTags("Admin.Shows")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", CreateShow).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>();
admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>();
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/episodes", AddEpisode)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> CreateShow(
CreateShowCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created($"/api/admin/shows/{result.Value}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> ListShows(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListShowsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetShow(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetShowQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteShow(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteShowCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> AddEpisode(
Guid id,
AddEpisodeBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new AddEpisodeCommand(id, body.MediaAssetId), cancellationToken);
return result.IsSuccess
? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> RemoveEpisode(
Guid id,
Guid episodeId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record AddEpisodeBody(Guid MediaAssetId);
+2
View File
@@ -113,6 +113,8 @@ app.MapAuthEndpoints();
app.MapRoleEndpoints();
app.MapAdminUserEndpoints();
app.MapMediaEndpoints();
app.MapShowEndpoints();
app.MapChannelEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
@@ -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>
@@ -0,0 +1,11 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Политика вставки рекламы на канале.</summary>
public enum AdInsertion
{
/// <summary>Реклама после целого блока серий.</summary>
BetweenBlocks,
/// <summary>Реклама после каждой серии.</summary>
BetweenEpisodes,
}
@@ -0,0 +1,11 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Как измеряется блок серий одного шоу за один выбор ротации.</summary>
public enum BlockMode
{
/// <summary>Ровно N серий подряд.</summary>
Count,
/// <summary>Набор серий подряд, пока не наберётся ~M минут (последняя входит целиком).</summary>
Duration,
}
@@ -0,0 +1,131 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Канал линейного эфира: базовая взвешенная ротация шоу (<see cref="Shows"/>), пул рекламы
/// (<see cref="Ads"/>), временные override'ы (<see cref="Overrides"/>) и политика вставки рекламы.
/// Планировщик разворачивает всё это в расписание встык на несколько дней вперёд.
/// </summary>
public class Channel
{
private readonly List<ChannelShow> _shows = new();
private readonly List<ChannelAd> _ads = new();
private readonly List<ProgrammingOverride> _overrides = new();
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public string Slug { get; private set; } = string.Empty;
public bool IsEnabled { get; private set; }
/// <summary>Точка отсчёта эфирной ленты (UTC) — база для MEDIA-SEQUENCE на этапе раздачи.</summary>
public DateTimeOffset EpochUtc { get; private set; }
public AdInsertion AdInsertion { get; private set; }
public int AdsPerBreak { get; private set; }
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
public Guid? FillerAssetId { get; private set; }
/// <summary>Курсор ротации рекламного пула.</summary>
public int NextAdIndex { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public IReadOnlyList<ChannelShow> Shows => _shows;
/// <summary>Пул рекламы (backing-field для EF); порядок ротации — по <see cref="ChannelAd.Position"/>.</summary>
public IReadOnlyList<ChannelAd> Ads => _ads;
public IReadOnlyList<ProgrammingOverride> Overrides => _overrides;
private Channel() { }
public static Channel Create(string name, string slug, DateTimeOffset epochUtc) =>
new()
{
Id = Guid.NewGuid(),
Name = name,
Slug = slug,
IsEnabled = true,
EpochUtc = epochUtc,
AdInsertion = AdInsertion.BetweenBlocks,
AdsPerBreak = 1,
NextAdIndex = 0,
CreatedAt = DateTimeOffset.UtcNow,
};
public void UpdateSettings(
string name,
bool isEnabled,
AdInsertion adInsertion,
int adsPerBreak,
Guid? fillerAssetId
)
{
Name = name;
IsEnabled = isEnabled;
AdInsertion = adInsertion;
AdsPerBreak = adsPerBreak;
FillerAssetId = fillerAssetId;
}
public ChannelShow? FindShow(Guid channelShowId) => _shows.FirstOrDefault(s => s.Id == channelShowId);
public ChannelShow AddShow(Guid showId, int weight, BlockMode blockMode, int blockValue)
{
var channelShow = ChannelShow.Create(Id, showId, weight, blockMode, blockValue);
_shows.Add(channelShow);
return channelShow;
}
public bool RemoveShow(Guid channelShowId)
{
var channelShow = _shows.FirstOrDefault(s => s.Id == channelShowId);
if (channelShow is null)
return false;
_shows.Remove(channelShow);
return true;
}
public bool HasShow(Guid showId) => _shows.Any(s => s.ShowId == showId);
public ChannelAd AddAd(Guid mediaAssetId)
{
var nextPosition = _ads.Count == 0 ? 0 : _ads.Max(a => a.Position) + 1;
var ad = ChannelAd.Create(Id, mediaAssetId, nextPosition);
_ads.Add(ad);
return ad;
}
public bool RemoveAd(Guid channelAdId)
{
var ad = _ads.FirstOrDefault(a => a.Id == channelAdId);
if (ad is null)
return false;
_ads.Remove(ad);
return true;
}
public bool HasAd(Guid mediaAssetId) => _ads.Any(a => a.MediaAssetId == mediaAssetId);
public ProgrammingOverride AddOverride(
OverrideMode mode,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc
)
{
var ovr = ProgrammingOverride.Create(Id, mode, startsAtUtc, endsAtUtc);
_overrides.Add(ovr);
return ovr;
}
public bool RemoveOverride(Guid overrideId)
{
var ovr = _overrides.FirstOrDefault(o => o.Id == overrideId);
if (ovr is null)
return false;
_overrides.Remove(ovr);
return true;
}
/// <summary>Планировщик двигает курсор рекламы по мере вставки врезок.</summary>
public void SetNextAdIndex(int index) => NextAdIndex = index;
}
@@ -0,0 +1,21 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Рекламный ассет в пуле канала. Врезки крутятся по кругу в порядке <see cref="Position"/>.</summary>
public class ChannelAd
{
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public Guid MediaAssetId { get; private set; }
public int Position { get; private set; }
private ChannelAd() { }
internal static ChannelAd Create(Guid channelId, Guid mediaAssetId, int position) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Position = position,
};
}
@@ -0,0 +1,55 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Связка канал↔шоу: вес в случайной ротации, режим и размер блока, а также персональный для этого
/// канала курсор серий (<see cref="NextEpisodeIndex"/>) — индекс следующей серии в упорядоченном
/// списке шоу.
/// </summary>
public class ChannelShow
{
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public Guid ShowId { get; private set; }
public int Weight { get; private set; }
public BlockMode BlockMode { get; private set; }
/// <summary>Число серий (<see cref="BlockMode.Count"/>) или минут (<see cref="BlockMode.Duration"/>).</summary>
public int BlockValue { get; private set; }
public bool IsEnabled { get; private set; }
/// <summary>Индекс следующей серии для этого канала (0-based в упорядоченном списке серий шоу).</summary>
public int NextEpisodeIndex { get; private set; }
private ChannelShow() { }
internal static ChannelShow Create(
Guid channelId,
Guid showId,
int weight,
BlockMode blockMode,
int blockValue
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
ShowId = showId,
Weight = weight,
BlockMode = blockMode,
BlockValue = blockValue,
IsEnabled = true,
NextEpisodeIndex = 0,
};
public void Update(int weight, BlockMode blockMode, int blockValue, bool isEnabled)
{
Weight = weight;
BlockMode = blockMode;
BlockValue = blockValue;
IsEnabled = isEnabled;
}
/// <summary>Планировщик двигает курсор по мере постановки серий в расписание.</summary>
public void SetNextEpisodeIndex(int index) => NextEpisodeIndex = index;
}
@@ -0,0 +1,11 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Режим временного override (марафон / кампания) поверх базовой ротации.</summary>
public enum OverrideMode
{
/// <summary>В окне играет только одно шоу (марафон).</summary>
Exclusive,
/// <summary>В окне действуют подменённые веса перечисленных шоу (остальные не участвуют).</summary>
Boost,
}
@@ -0,0 +1,21 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Шоу внутри override с его подменённым весом (для Boost) или единственное шоу (для Exclusive).</summary>
public class OverrideShow
{
public Guid Id { get; private set; }
public Guid ProgrammingOverrideId { get; private set; }
public Guid ShowId { get; private set; }
public int Weight { get; private set; }
private OverrideShow() { }
internal static OverrideShow Create(Guid overrideId, Guid showId, int weight) =>
new()
{
Id = Guid.NewGuid(),
ProgrammingOverrideId = overrideId,
ShowId = showId,
Weight = weight,
};
}
@@ -0,0 +1,45 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Временный override программирования канала на окне [<see cref="StartsAtUtc"/>,
/// <see cref="EndsAtUtc"/>). Марафон = <see cref="OverrideMode.Exclusive"/> с одним шоу и большим
/// временным блоком. Пересекающийся с генерируемым временем override заменяет базовую ротацию.
/// </summary>
public class ProgrammingOverride
{
private readonly List<OverrideShow> _shows = new();
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public OverrideMode Mode { get; private set; }
public DateTimeOffset StartsAtUtc { get; private set; }
public DateTimeOffset EndsAtUtc { get; private set; }
public IReadOnlyList<OverrideShow> Shows => _shows;
private ProgrammingOverride() { }
internal static ProgrammingOverride Create(
Guid channelId,
OverrideMode mode,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
Mode = mode,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
};
public OverrideShow AddShow(Guid showId, int weight)
{
var entry = OverrideShow.Create(Id, showId, weight);
_shows.Add(entry);
return entry;
}
public bool Covers(DateTimeOffset moment) => moment >= StartsAtUtc && moment < EndsAtUtc;
}
@@ -0,0 +1,59 @@
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; }
private ScheduleEntry() { }
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,
};
}
@@ -0,0 +1,11 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Тип записи расписания.</summary>
public enum ScheduleEntryKind
{
/// <summary>Программа (серия шоу).</summary>
Program,
/// <summary>Рекламная врезка.</summary>
Ad,
}
@@ -0,0 +1,8 @@
namespace TeleWave.Domain.Broadcast.Scheduling;
/// <summary>Абстракция источника случайности — чтобы планировщик оставался детерминированно тестируемым.</summary>
public interface IRandomSource
{
/// <summary>Случайное целое в диапазоне [0, maxExclusive).</summary>
int Next(int maxExclusive);
}
@@ -0,0 +1,190 @@
namespace TeleWave.Domain.Broadcast.Scheduling;
/// <summary>
/// Чистая эфирная математика: разворачивает конфигурацию канала в последовательность записей встык
/// от <see cref="PlannerInput.StartTime"/> до <see cref="PlannerInput.HorizonEnd"/>. Без БД, ФС и
/// ffmpeg — полностью юнит-тестируемо (см. SchedulePlannerTests).
///
/// Инварианты: серии одного шоу идут по порядку (курсор <see cref="PlannerShow.NextEpisodeIndex"/>),
/// на конце сериала — заворот на первую серию; выбор шоу — взвешенно-случайный; override на окне
/// заменяет базовую ротацию; реклама вставляется по политике канала.
/// </summary>
public static class SchedulePlanner
{
private const int IterationBackstop = 1_000_000;
public static PlannerResult Plan(PlannerInput input, IRandomSource random)
{
var entries = new List<PlannedEntry>();
var byShowId = input.Shows.ToDictionary(s => s.ShowId);
var nextEpisode = input.Shows.ToDictionary(s => s.ChannelShowId, s => s.NextEpisodeIndex);
var nextAd = input.NextAdIndex;
// Есть ли вообще из чего строить эфир.
var anyPlayable = input.Shows.Any(s => s.Weight > 0 && s.EpisodeAssetIds.Count > 0);
if (!anyPlayable)
return new PlannerResult(entries, nextEpisode, nextAd);
var cursor = input.StartTime;
var iterations = 0;
while (cursor < input.HorizonEnd && iterations++ < IterationBackstop)
{
var candidates = ResolvePolicy(cursor, input, byShowId);
if (candidates.Count == 0)
break;
var pick = WeightedPick(candidates, random);
var blockStart = cursor;
var block = CollectBlock(pick, nextEpisode, input, cursor);
foreach (var episode in block)
{
var duration = DurationOf(episode.AssetId, input);
var end = cursor + duration;
entries.Add(
new PlannedEntry(
episode.AssetId,
ScheduleEntryKind.Program,
cursor,
end,
pick.ShowId,
episode.Index
)
);
cursor = end;
if (input.AdInsertion == AdInsertion.BetweenEpisodes)
cursor = InsertAds(entries, input, cursor, ref nextAd);
}
if (input.AdInsertion == AdInsertion.BetweenBlocks)
cursor = InsertAds(entries, input, cursor, ref nextAd);
// Защита от зацикливания, если длительности нулевые/отсутствуют — эфир не сдвинулся.
if (cursor <= blockStart)
break;
}
return new PlannerResult(entries, nextEpisode, nextAd);
}
private static List<(PlannerShow Show, int Weight)> ResolvePolicy(
DateTimeOffset moment,
PlannerInput input,
IReadOnlyDictionary<Guid, PlannerShow> byShowId
)
{
var ovr = input.Overrides.FirstOrDefault(o => moment >= o.StartsAtUtc && moment < o.EndsAtUtc);
if (ovr is not null)
{
var overridden = new List<(PlannerShow, int)>();
foreach (var os in ovr.Shows)
{
if (!byShowId.TryGetValue(os.ShowId, out var show) || show.EpisodeAssetIds.Count == 0)
continue;
var weight = ovr.Mode == OverrideMode.Exclusive ? 1 : os.Weight;
if (weight > 0)
overridden.Add((show, weight));
}
if (overridden.Count > 0)
return overridden;
// Override ссылается на пустые/неготовые шоу — откатываемся к базовой ротации.
}
return input.Shows
.Where(s => s.Weight > 0 && s.EpisodeAssetIds.Count > 0)
.Select(s => (s, s.Weight))
.ToList();
}
private static PlannerShow WeightedPick(
List<(PlannerShow Show, int Weight)> candidates,
IRandomSource random
)
{
var total = candidates.Sum(c => c.Weight);
if (total <= 0)
return candidates[0].Show;
var roll = random.Next(total);
var acc = 0;
foreach (var (show, weight) in candidates)
{
acc += weight;
if (roll < acc)
return show;
}
return candidates[^1].Show;
}
private static List<(Guid AssetId, int Index)> CollectBlock(
PlannerShow show,
Dictionary<Guid, int> nextEpisode,
PlannerInput input,
DateTimeOffset cursor
)
{
var result = new List<(Guid, int)>();
var count = show.EpisodeAssetIds.Count;
var idx = ((nextEpisode[show.ChannelShowId] % count) + count) % count;
if (show.BlockMode == BlockMode.Count)
{
var n = Math.Max(1, show.BlockValue);
for (var i = 0; i < n; i++)
{
result.Add((show.EpisodeAssetIds[idx], idx));
idx = (idx + 1) % count;
}
}
else
{
var budget = TimeSpan.FromMinutes(Math.Max(1, show.BlockValue));
var accumulated = TimeSpan.Zero;
var guard = 0;
do
{
var assetId = show.EpisodeAssetIds[idx];
result.Add((assetId, idx));
accumulated += DurationOf(assetId, input);
idx = (idx + 1) % count;
guard++;
} while (
accumulated < budget
&& cursor + accumulated < input.HorizonEnd
&& guard < IterationBackstop
);
}
nextEpisode[show.ChannelShowId] = idx;
return result;
}
private static DateTimeOffset InsertAds(
List<PlannedEntry> entries,
PlannerInput input,
DateTimeOffset cursor,
ref int nextAd
)
{
if (input.AdPool.Count == 0 || input.AdsPerBreak <= 0)
return cursor;
for (var i = 0; i < input.AdsPerBreak; i++)
{
var assetId = input.AdPool[((nextAd % input.AdPool.Count) + input.AdPool.Count) % input.AdPool.Count];
nextAd++;
var end = cursor + DurationOf(assetId, input);
entries.Add(new PlannedEntry(assetId, ScheduleEntryKind.Ad, cursor, end, null, null));
cursor = end;
}
return cursor;
}
private static TimeSpan DurationOf(Guid assetId, PlannerInput input) =>
input.Durations.TryGetValue(assetId, out var duration) ? duration : TimeSpan.Zero;
}
@@ -0,0 +1,53 @@
namespace TeleWave.Domain.Broadcast.Scheduling;
/// <summary>Шоу канала, подготовленное для планировщика: только готовые серии, с курсором.</summary>
public sealed record PlannerShow(
Guid ChannelShowId,
Guid ShowId,
int Weight,
BlockMode BlockMode,
int BlockValue,
IReadOnlyList<Guid> EpisodeAssetIds,
int NextEpisodeIndex
);
/// <summary>Override в терминах планировщика: окно + режим + шоу с весами.</summary>
public sealed record PlannerOverride(
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
OverrideMode Mode,
IReadOnlyList<PlannerOverrideShow> Shows
);
public sealed record PlannerOverrideShow(Guid ShowId, int Weight);
/// <summary>Полный вход планировщика для одного прогона по каналу.</summary>
public sealed record PlannerInput(
Guid ChannelId,
AdInsertion AdInsertion,
int AdsPerBreak,
int NextAdIndex,
IReadOnlyList<PlannerShow> Shows,
IReadOnlyList<Guid> AdPool,
IReadOnlyDictionary<Guid, TimeSpan> Durations,
IReadOnlyList<PlannerOverride> Overrides,
DateTimeOffset StartTime,
DateTimeOffset HorizonEnd
);
/// <summary>Одна запланированная запись (ещё не доменная сущность).</summary>
public sealed record PlannedEntry(
Guid MediaAssetId,
ScheduleEntryKind Kind,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
Guid? ShowId,
int? EpisodeIndex
);
/// <summary>Результат прогона: новые записи + обновлённые курсоры (серий по каждому ChannelShow и рекламы).</summary>
public sealed record PlannerResult(
IReadOnlyList<PlannedEntry> Entries,
IReadOnlyDictionary<Guid, int> NextEpisodeIndexByChannelShow,
int NextAdIndex
);
@@ -0,0 +1,59 @@
namespace TeleWave.Domain.Library;
/// <summary>
/// Переиспользуемое шоу в общей библиотеке: сериал (упорядоченные серии) либо полнометражка.
/// Серии идут строго в порядке <see cref="ShowEpisode.Position"/>; курсор показа на канале хранится
/// отдельно на связке канал↔шоу (см. Broadcast/ChannelShow).
/// </summary>
public class Show
{
private readonly List<ShowEpisode> _episodes = new();
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public string? Description { get; private set; }
public ShowKind Kind { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
/// <summary>Серии шоу (backing-field для EF). Порядок показа — по <see cref="ShowEpisode.Position"/>;
/// потребители сортируют явно (см. загрузчик планировщика).</summary>
public IReadOnlyList<ShowEpisode> Episodes => _episodes;
private Show() { }
public static Show Create(string name, ShowKind kind, string? description = null) =>
new()
{
Id = Guid.NewGuid(),
Name = name,
Kind = kind,
Description = description,
CreatedAt = DateTimeOffset.UtcNow,
};
public void Rename(string name, string? description)
{
Name = name;
Description = description;
}
/// <summary>Добавляет серию в конец. Для <see cref="ShowKind.Single"/> допустима ровно одна серия.</summary>
public ShowEpisode AddEpisode(Guid mediaAssetId)
{
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.Single || _episodes.Count == 0;
}
@@ -0,0 +1,26 @@
namespace TeleWave.Domain.Library;
/// <summary>Одна серия шоу — ссылка на медиа-ассет и позиция в порядке показа.</summary>
public class ShowEpisode
{
public Guid Id { get; private set; }
public Guid ShowId { get; private set; }
public Guid MediaAssetId { get; private set; }
/// <summary>Порядковый номер внутри шоу (может иметь разрывы после удалений).</summary>
public int Position { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
private ShowEpisode() { }
internal static ShowEpisode Create(Guid showId, Guid mediaAssetId, int position) =>
new()
{
Id = Guid.NewGuid(),
ShowId = showId,
MediaAssetId = mediaAssetId,
Position = position,
CreatedAt = DateTimeOffset.UtcNow,
};
}
@@ -0,0 +1,11 @@
namespace TeleWave.Domain.Library;
/// <summary>Тип шоу в библиотеке.</summary>
public enum ShowKind
{
/// <summary>Сериал: упорядоченный список серий, идут по порядку.</summary>
Series,
/// <summary>Разовый выпуск/полнометражка: ровно одна «серия».</summary>
Single,
}
@@ -0,0 +1,79 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Broadcast;
/// <summary>
/// Периодически достраивает расписание каждого включённого канала до горизонта. Расширение хвоста —
/// без удаления существующих записей, поэтому эфир не «дёргается».
/// </summary>
public sealed class SchedulingBackgroundService(
IServiceScopeFactory scopeFactory,
IOptions<SchedulerOptions> options,
ILogger<SchedulingBackgroundService> logger
) : BackgroundService
{
private readonly SchedulerOptions _options = options.Value;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Небольшая задержка на старте — дать примениться миграциям/сидингу.
try
{
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
using var timer = new PeriodicTimer(
TimeSpan.FromMinutes(Math.Max(1, _options.TickMinutes))
);
do
{
try
{
await TickAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка тика планировщика");
}
} while (await timer.WaitForNextTickAsync(stoppingToken));
}
private async Task TickAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var generator = scope.ServiceProvider.GetRequiredService<ScheduleGenerator>();
var channelIds = await db.Channels
.Where(c => c.IsEnabled)
.Select(c => c.Id)
.ToListAsync(cancellationToken);
var now = DateTimeOffset.UtcNow;
foreach (var channelId in channelIds)
{
var added = await generator.GenerateAsync(channelId, now, regenerate: false, cancellationToken);
if (added > 0)
logger.LogInformation(
"Канал {ChannelId}: добавлено {Count} записей расписания",
channelId,
added
);
}
}
}
@@ -0,0 +1,9 @@
using TeleWave.Domain.Broadcast.Scheduling;
namespace TeleWave.Infrastructure.Broadcast;
/// <summary>Боевой источник случайности поверх <see cref="Random.Shared"/> (потокобезопасен).</summary>
public sealed class SystemRandomSource : IRandomSource
{
public int Next(int maxExclusive) => Random.Shared.Next(maxExclusive);
}
@@ -5,7 +5,10 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Infrastructure.Broadcast;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Media;
using TeleWave.Infrastructure.Persistence;
@@ -87,10 +90,21 @@ public static class DependencyInjection
services.AddScoped<DbInitializer>();
AddMedia(services, configuration);
AddBroadcast(services, configuration);
return services;
}
/// <summary>Планировщик расписания: генератор, источник случайности и фоновый сервис горизонта.</summary>
private static void AddBroadcast(IServiceCollection services, IConfiguration configuration)
{
services.Configure<SchedulerOptions>(configuration.GetSection(SchedulerOptions.SectionName));
services.AddSingleton<IRandomSource, SystemRandomSource>();
services.AddScoped<ScheduleGenerator>();
services.AddHostedService<SchedulingBackgroundService>();
}
/// <summary>Хранилище медиа, обработка ffmpeg, очередь и фоновые сервисы (очередь + inbox-сканер).</summary>
private static void AddMedia(IServiceCollection services, IConfiguration configuration)
{
@@ -0,0 +1,691 @@
// <auto-generated />
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("20260724055417_AddBroadcastAndLibrary")]
partial class AddBroadcastAndLibrary
{
/// <inheritdoc />
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<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReplacedByTokenHash")
.HasColumnType("text");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AdInsertion")
.HasColumnType("integer");
b.Property<int>("AdsPerBreak")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("EpochUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("FillerAssetId")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("NextAdIndex")
.HasColumnType("integer");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "Position");
b.ToTable("ChannelAd");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("BlockMode")
.HasColumnType("integer");
b.Property<int>("BlockValue")
.HasColumnType("integer");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<int>("NextEpisodeIndex")
.HasColumnType("integer");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "ShowId");
b.ToTable("ChannelShow");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ProgrammingOverrideId")
.HasColumnType("uuid");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ProgrammingOverrideId");
b.ToTable("OverrideShow");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Mode")
.HasColumnType("integer");
b.Property<DateTimeOffset>("StartsAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc");
b.ToTable("ProgrammingOverride");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int?>("EpisodeIndex")
.HasColumnType("integer");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<Guid?>("ShowId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("StartsAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ChannelId", "EndsAtUtc");
b.HasIndex("ChannelId", "ShowId");
b.HasIndex("ChannelId", "StartsAtUtc");
b.ToTable("ScheduleEntries");
});
modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.ToTable("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MediaAssetId");
b.HasIndex("ShowId", "Position");
b.ToTable("ShowEpisode");
});
modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AudioCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<TimeSpan?>("Duration")
.HasColumnType("interval");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int?>("Height")
.HasColumnType("integer");
b.Property<string>("OriginalExtension")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("RelativePath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int?>("SegmentCount")
.HasColumnType("integer");
b.Property<int?>("SegmentSeconds")
.HasColumnType("integer");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("VideoCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int?>("Width")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Status");
b.ToTable("MediaAssets");
});
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsBlocked")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Ads")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Shows")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null)
.WithMany("Shows")
.HasForeignKey("ProgrammingOverrideId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Overrides")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
{
b.HasOne("TeleWave.Domain.Library.Show", null)
.WithMany("Episodes")
.HasForeignKey("ShowId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
{
b.Navigation("Ads");
b.Navigation("Overrides");
b.Navigation("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.Navigation("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
{
b.Navigation("Episodes");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,253 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddBroadcastAndLibrary : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Channels",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Slug = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
EpochUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
AdInsertion = table.Column<int>(type: "integer", nullable: false),
AdsPerBreak = table.Column<int>(type: "integer", nullable: false),
FillerAssetId = table.Column<Guid>(type: "uuid", nullable: true),
NextAdIndex = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Channels", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ScheduleEntries",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Kind = table.Column<int>(type: "integer", nullable: false),
StartsAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
EndsAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
ShowId = table.Column<Guid>(type: "uuid", nullable: true),
EpisodeIndex = table.Column<int>(type: "integer", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ScheduleEntries", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Shows",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Description = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
Kind = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Shows", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ChannelAd",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelAd", x => x.Id);
table.ForeignKey(
name: "FK_ChannelAd_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ChannelShow",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
Weight = table.Column<int>(type: "integer", nullable: false),
BlockMode = table.Column<int>(type: "integer", nullable: false),
BlockValue = table.Column<int>(type: "integer", nullable: false),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
NextEpisodeIndex = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelShow", x => x.Id);
table.ForeignKey(
name: "FK_ChannelShow_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ProgrammingOverride",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
Mode = table.Column<int>(type: "integer", nullable: false),
StartsAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
EndsAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ProgrammingOverride", x => x.Id);
table.ForeignKey(
name: "FK_ProgrammingOverride_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ShowEpisode",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ShowEpisode", x => x.Id);
table.ForeignKey(
name: "FK_ShowEpisode_Shows_ShowId",
column: x => x.ShowId,
principalTable: "Shows",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "OverrideShow",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ProgrammingOverrideId = table.Column<Guid>(type: "uuid", nullable: false),
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
Weight = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OverrideShow", x => x.Id);
table.ForeignKey(
name: "FK_OverrideShow_ProgrammingOverride_ProgrammingOverrideId",
column: x => x.ProgrammingOverrideId,
principalTable: "ProgrammingOverride",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ChannelAd_ChannelId_Position",
table: "ChannelAd",
columns: new[] { "ChannelId", "Position" });
migrationBuilder.CreateIndex(
name: "IX_Channels_Slug",
table: "Channels",
column: "Slug",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ChannelShow_ChannelId_ShowId",
table: "ChannelShow",
columns: new[] { "ChannelId", "ShowId" });
migrationBuilder.CreateIndex(
name: "IX_OverrideShow_ProgrammingOverrideId",
table: "OverrideShow",
column: "ProgrammingOverrideId");
migrationBuilder.CreateIndex(
name: "IX_ProgrammingOverride_ChannelId_StartsAtUtc_EndsAtUtc",
table: "ProgrammingOverride",
columns: new[] { "ChannelId", "StartsAtUtc", "EndsAtUtc" });
migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_ChannelId_EndsAtUtc",
table: "ScheduleEntries",
columns: new[] { "ChannelId", "EndsAtUtc" });
migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_ChannelId_ShowId",
table: "ScheduleEntries",
columns: new[] { "ChannelId", "ShowId" });
migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_ChannelId_StartsAtUtc",
table: "ScheduleEntries",
columns: new[] { "ChannelId", "StartsAtUtc" });
migrationBuilder.CreateIndex(
name: "IX_ShowEpisode_MediaAssetId",
table: "ShowEpisode",
column: "MediaAssetId");
migrationBuilder.CreateIndex(
name: "IX_ShowEpisode_ShowId_Position",
table: "ShowEpisode",
columns: new[] { "ShowId", "Position" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ChannelAd");
migrationBuilder.DropTable(
name: "ChannelShow");
migrationBuilder.DropTable(
name: "OverrideShow");
migrationBuilder.DropTable(
name: "ScheduleEntries");
migrationBuilder.DropTable(
name: "ShowEpisode");
migrationBuilder.DropTable(
name: "ProgrammingOverride");
migrationBuilder.DropTable(
name: "Shows");
migrationBuilder.DropTable(
name: "Channels");
}
}
}
@@ -160,6 +160,245 @@ namespace TeleWave.Infrastructure.Migrations
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AdInsertion")
.HasColumnType("integer");
b.Property<int>("AdsPerBreak")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("EpochUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("FillerAssetId")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("NextAdIndex")
.HasColumnType("integer");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "Position");
b.ToTable("ChannelAd");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("BlockMode")
.HasColumnType("integer");
b.Property<int>("BlockValue")
.HasColumnType("integer");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<int>("NextEpisodeIndex")
.HasColumnType("integer");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "ShowId");
b.ToTable("ChannelShow");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ProgrammingOverrideId")
.HasColumnType("uuid");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ProgrammingOverrideId");
b.ToTable("OverrideShow");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Mode")
.HasColumnType("integer");
b.Property<DateTimeOffset>("StartsAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc");
b.ToTable("ProgrammingOverride");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int?>("EpisodeIndex")
.HasColumnType("integer");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<Guid?>("ShowId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("StartsAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ChannelId", "EndsAtUtc");
b.HasIndex("ChannelId", "ShowId");
b.HasIndex("ChannelId", "StartsAtUtc");
b.ToTable("ScheduleEntries");
});
modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.ToTable("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MediaAssetId");
b.HasIndex("ShowId", "Position");
b.ToTable("ShowEpisode");
});
modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b =>
{
b.Property<Guid>("Id")
@@ -379,6 +618,70 @@ namespace TeleWave.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Ads")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Shows")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null)
.WithMany("Shows")
.HasForeignKey("ProgrammingOverrideId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Overrides")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
{
b.HasOne("TeleWave.Domain.Library.Show", null)
.WithMany("Episodes")
.HasForeignKey("ShowId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
{
b.Navigation("Ads");
b.Navigation("Overrides");
b.Navigation("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.Navigation("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
{
b.Navigation("Episodes");
});
#pragma warning restore 612, 618
}
}
@@ -2,6 +2,8 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Auth;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Identity;
@@ -17,6 +19,9 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
{
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<MediaAsset> MediaAssets => Set<MediaAsset>();
public DbSet<Show> Shows => Set<Show>();
public DbSet<Channel> Channels => Set<Channel>();
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Infrastructure.Persistence.Configurations;
public class ChannelConfiguration : IEntityTypeConfiguration<Channel>
{
public void Configure(EntityTypeBuilder<Channel> builder)
{
builder.Property(x => x.Name).IsRequired().HasMaxLength(256);
builder.Property(x => x.Slug).IsRequired().HasMaxLength(128);
builder.HasIndex(x => x.Slug).IsUnique();
builder
.HasMany(x => x.Shows)
.WithOne()
.HasForeignKey(s => s.ChannelId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Shows).UsePropertyAccessMode(PropertyAccessMode.Field);
builder
.HasMany(x => x.Ads)
.WithOne()
.HasForeignKey(a => a.ChannelId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Ads).UsePropertyAccessMode(PropertyAccessMode.Field);
builder
.HasMany(x => x.Overrides)
.WithOne()
.HasForeignKey(o => o.ChannelId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Overrides).UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
public class ChannelShowConfiguration : IEntityTypeConfiguration<ChannelShow>
{
public void Configure(EntityTypeBuilder<ChannelShow> builder)
{
builder.HasIndex(x => new { x.ChannelId, x.ShowId });
}
}
public class ChannelAdConfiguration : IEntityTypeConfiguration<ChannelAd>
{
public void Configure(EntityTypeBuilder<ChannelAd> builder)
{
builder.HasIndex(x => new { x.ChannelId, x.Position });
}
}
public class ProgrammingOverrideConfiguration : IEntityTypeConfiguration<ProgrammingOverride>
{
public void Configure(EntityTypeBuilder<ProgrammingOverride> builder)
{
builder.HasIndex(x => new { x.ChannelId, x.StartsAtUtc, x.EndsAtUtc });
builder
.HasMany(x => x.Shows)
.WithOne()
.HasForeignKey(s => s.ProgrammingOverrideId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Shows).UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
@@ -0,0 +1,16 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Infrastructure.Persistence.Configurations;
public class ScheduleEntryConfiguration : IEntityTypeConfiguration<ScheduleEntry>
{
public void Configure(EntityTypeBuilder<ScheduleEntry> builder)
{
// Основные запросы эфира/EPG — по каналу и времени.
builder.HasIndex(x => new { x.ChannelId, x.StartsAtUtc });
builder.HasIndex(x => new { x.ChannelId, x.EndsAtUtc });
builder.HasIndex(x => new { x.ChannelId, x.ShowId });
}
}
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TeleWave.Domain.Library;
namespace TeleWave.Infrastructure.Persistence.Configurations;
public class ShowConfiguration : IEntityTypeConfiguration<Show>
{
public void Configure(EntityTypeBuilder<Show> builder)
{
builder.Property(x => x.Name).IsRequired().HasMaxLength(256);
builder.Property(x => x.Description).HasMaxLength(2048);
builder
.HasMany(x => x.Episodes)
.WithOne()
.HasForeignKey(e => e.ShowId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Episodes).UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
public class ShowEpisodeConfiguration : IEntityTypeConfiguration<ShowEpisode>
{
public void Configure(EntityTypeBuilder<ShowEpisode> builder)
{
builder.HasIndex(x => new { x.ShowId, x.Position });
builder.HasIndex(x => x.MediaAssetId);
}
}
@@ -0,0 +1,233 @@
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Broadcast.Scheduling;
using Xunit;
namespace TeleWave.Domain.Tests.Broadcast;
public class SchedulePlannerTests
{
private static readonly DateTimeOffset Start = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
/// <summary>Источник случайности с фиксированной (циклической) последовательностью значений.</summary>
private sealed class FixedRandom(params int[] values) : IRandomSource
{
private int _i;
public int Next(int maxExclusive) =>
values.Length == 0 ? 0 : values[_i++ % values.Length] % maxExclusive;
}
private static Dictionary<Guid, TimeSpan> Durations(params (Guid Id, int Minutes)[] items) =>
items.ToDictionary(x => x.Id, x => TimeSpan.FromMinutes(x.Minutes));
[Fact]
public void Count_Block_ProducesConsecutiveEpisodes_BackToBack()
{
var cs = Guid.NewGuid();
var show = Guid.NewGuid();
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()];
var input = new PlannerInput(
ChannelId: Guid.NewGuid(),
AdInsertion: AdInsertion.BetweenBlocks,
AdsPerBreak: 1,
NextAdIndex: 0,
Shows: [new PlannerShow(cs, show, 1, BlockMode.Count, 3, eps, 0)],
AdPool: [],
Durations: Durations((eps[0], 2), (eps[1], 2), (eps[2], 2), (eps[3], 2)),
Overrides: [],
StartTime: Start,
HorizonEnd: Start.AddSeconds(1)
);
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
Assert.Equal(3, result.Entries.Count);
Assert.All(result.Entries, e => Assert.Equal(ScheduleEntryKind.Program, e.Kind));
Assert.Equal([0, 1, 2], result.Entries.Select(e => e.EpisodeIndex));
Assert.Equal(Start, result.Entries[0].StartsAtUtc);
Assert.Equal(result.Entries[0].EndsAtUtc, result.Entries[1].StartsAtUtc);
Assert.Equal(result.Entries[1].EndsAtUtc, result.Entries[2].StartsAtUtc);
Assert.Equal(3, result.NextEpisodeIndexByChannelShow[cs]);
}
[Fact]
public void Count_Block_WrapsAtEndOfSeries()
{
var cs = Guid.NewGuid();
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()];
var input = BaseInput(
[new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 3, eps, 0)],
Durations((eps[0], 2), (eps[1], 2)),
horizonEnd: Start.AddSeconds(1)
);
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
Assert.Equal([0, 1, 0], result.Entries.Select(e => e.EpisodeIndex));
Assert.Equal(1, result.NextEpisodeIndexByChannelShow[cs]);
}
[Fact]
public void Duration_Block_TakesEpisodesUntilBudgetReached()
{
var cs = Guid.NewGuid();
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()];
var input = BaseInput(
[new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Duration, 25, eps, 0)],
Durations((eps[0], 10), (eps[1], 10), (eps[2], 10), (eps[3], 10)),
horizonEnd: Start.AddMinutes(30)
);
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
// 10+10+10 = 30 >= 25 → три серии.
Assert.Equal(3, result.Entries.Count);
Assert.Equal([0, 1, 2], result.Entries.Select(e => e.EpisodeIndex));
Assert.Equal(3, result.NextEpisodeIndexByChannelShow[cs]);
}
[Fact]
public void AdsBetweenBlocks_InsertsAdAfterBlock()
{
var cs = Guid.NewGuid();
Guid ep = Guid.NewGuid(),
ad0 = Guid.NewGuid(),
ad1 = Guid.NewGuid();
var input = new PlannerInput(
Guid.NewGuid(),
AdInsertion.BetweenBlocks,
AdsPerBreak: 1,
NextAdIndex: 0,
Shows: [new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 1, [ep], 0)],
AdPool: [ad0, ad1],
Durations: Durations((ep, 20), (ad0, 1), (ad1, 1)),
Overrides: [],
StartTime: Start,
HorizonEnd: Start.AddSeconds(1)
);
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
Assert.Equal(2, result.Entries.Count);
Assert.Equal(ScheduleEntryKind.Program, result.Entries[0].Kind);
Assert.Equal(ScheduleEntryKind.Ad, result.Entries[1].Kind);
Assert.Equal(ad0, result.Entries[1].MediaAssetId);
Assert.Equal(1, result.NextAdIndex);
}
[Fact]
public void AdsBetweenEpisodes_InsertsAdAfterEachEpisode()
{
var cs = Guid.NewGuid();
Guid[] eps = [Guid.NewGuid(), Guid.NewGuid()];
Guid ad = Guid.NewGuid();
var input = new PlannerInput(
Guid.NewGuid(),
AdInsertion.BetweenEpisodes,
AdsPerBreak: 1,
NextAdIndex: 0,
Shows: [new PlannerShow(cs, Guid.NewGuid(), 1, BlockMode.Count, 2, eps, 0)],
AdPool: [ad],
Durations: Durations((eps[0], 20), (eps[1], 20), (ad, 1)),
Overrides: [],
StartTime: Start,
HorizonEnd: Start.AddSeconds(1)
);
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
Assert.Equal(
[
ScheduleEntryKind.Program,
ScheduleEntryKind.Ad,
ScheduleEntryKind.Program,
ScheduleEntryKind.Ad,
],
result.Entries.Select(e => e.Kind)
);
Assert.Equal(2, result.NextAdIndex);
}
[Fact]
public void WeightedPick_RespectsRoll()
{
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 3, BlockMode.Count, 1, [Guid.NewGuid()], 0);
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
var durations = Durations(
(a.EpisodeAssetIds[0], 20),
(b.EpisodeAssetIds[0], 20)
);
var pickA = SchedulePlanner.Plan(BaseInput([a, b], durations, Start.AddSeconds(1)), new FixedRandom(0));
var pickB = SchedulePlanner.Plan(BaseInput([a, b], durations, Start.AddSeconds(1)), new FixedRandom(3));
Assert.Equal(a.ShowId, pickA.Entries[0].ShowId);
Assert.Equal(b.ShowId, pickB.Entries[0].ShowId);
}
[Fact]
public void ExclusiveOverride_ForcesSingleShow()
{
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 10, BlockMode.Count, 1, [Guid.NewGuid()], 0);
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
var input = new PlannerInput(
Guid.NewGuid(),
AdInsertion.BetweenBlocks,
0,
0,
[a, b],
[],
durations,
Overrides:
[
new PlannerOverride(
Start,
Start.AddHours(1),
OverrideMode.Exclusive,
[new PlannerOverrideShow(b.ShowId, 1)]
),
],
StartTime: Start,
HorizonEnd: Start.AddSeconds(1)
);
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
Assert.Equal(b.ShowId, result.Entries[0].ShowId);
}
[Fact]
public void NoPlayableShows_ReturnsEmpty()
{
var input = BaseInput([], new Dictionary<Guid, TimeSpan>(), Start.AddHours(1));
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
Assert.Empty(result.Entries);
}
private static PlannerInput BaseInput(
IReadOnlyList<PlannerShow> shows,
IReadOnlyDictionary<Guid, TimeSpan> durations,
DateTimeOffset horizonEnd
) =>
new(
ChannelId: Guid.NewGuid(),
AdInsertion: AdInsertion.BetweenBlocks,
AdsPerBreak: 0,
NextAdIndex: 0,
Shows: shows,
AdPool: [],
Durations: durations,
Overrides: [],
StartTime: Start,
HorizonEnd: horizonEnd
);
}