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:
@@ -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);
|
||||
@@ -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>>;
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
@@ -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>>;
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+16
@@ -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 — только строчные латинские буквы, цифры и дефисы.");
|
||||
}
|
||||
}
|
||||
+13
@@ -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>>;
|
||||
+42
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
@@ -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);
|
||||
+7
@@ -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>;
|
||||
+26
@@ -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>>>;
|
||||
+54
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.RegenerateSchedule;
|
||||
|
||||
public sealed record RegenerateChannelScheduleCommand(Guid ChannelId) : ICommand<Result>;
|
||||
+35
@@ -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>;
|
||||
+26
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
@@ -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>;
|
||||
+26
@@ -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;
|
||||
}
|
||||
+14
@@ -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>;
|
||||
+39
@@ -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();
|
||||
}
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
}
|
||||
+14
@@ -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>;
|
||||
+29
@@ -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();
|
||||
}
|
||||
}
|
||||
+12
@@ -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)
|
||||
{
|
||||
|
||||
Generated
+691
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+253
@@ -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)
|
||||
{
|
||||
|
||||
+68
@@ -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);
|
||||
}
|
||||
}
|
||||
+16
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -60,8 +60,9 @@
|
||||
- `Broadcast/Channel` — slug, название, вкл/выкл, `EpochUtc`, политика рекламы
|
||||
(`AdInsertion: BetweenBlocks | BetweenEpisodes`, `AdsPerBreak`), `FillerAssetId` (подстраховка).
|
||||
- `Broadcast/ChannelShow` — связка канал↔шоу: `Weight` (частота в случайном выборе), `BlockMode`
|
||||
(`Count` | `Duration`) и `BlockValue` (число серий K либо бюджет в минутах M), вкл/выкл. Для
|
||||
`Single` блок всегда = 1 серия. «Шоу по одной серии» = `Count`/K=1.
|
||||
(`Count` | `Duration`) и `BlockValue` (число серий K либо бюджет в минутах M), вкл/выкл, а также
|
||||
`NextEpisodeIndex` — **персональный для этого канала курсор серий**. Для `Single` блок = 1 серия.
|
||||
«Шоу по одной серии» = `Count`/K=1.
|
||||
- `Broadcast/ChannelAd` — связка канал↔рекламный ассет: пул врезок канала.
|
||||
- `Broadcast/ProgrammingOverride` — временный override на канале: окно `[StartsAtUtc, EndsAtUtc]`,
|
||||
режим (`Exclusive` — только это шоу; `Boost` — подменённые веса), ссылка(и) на шоу. На пересечении
|
||||
@@ -72,12 +73,15 @@
|
||||
`StartsAtUtc`, `EntryKind` (`Program | Ad`), для программ — `ShowId` и номер серии (для EPG).
|
||||
Генерируется планировщиком на несколько дней вперёд.
|
||||
|
||||
Курсоров как отдельного состояния **нет**. «Следующая серия шоу X на канале Y» — это чистая
|
||||
функция от расписания: берём последнюю запланированную серию этого шоу на этом канале, находим её
|
||||
позицию в упорядоченном списке серий, следующая — `(pos + 1) % count` (отсюда же цикл с начала).
|
||||
Первая генерация (записей ещё нет) стартует с первой серии. Рекламная ротация выводится так же —
|
||||
из последней врезки. Единственное персистентное состояние эфира — сами строки `ScheduleEntry`,
|
||||
рассинхронизироваться нечему.
|
||||
**Курсоры серий и рекламы — персистентные** (`ChannelShow.NextEpisodeIndex`, `Channel.NextAdIndex`),
|
||||
их двигает планировщик по мере постановки в расписание. Так серии идут по порядку и продолжаются
|
||||
через дни, а курсор переживает чистку старых `ScheduleEntry` по ретеншну (вывод «из хвоста» этого не
|
||||
гарантировал бы для редко играемых шоу). Заворот на первую серию — `(index + 1) % count`.
|
||||
|
||||
Компромисс: при **перегенерации** будущего хвоста (правка конфигурации канала) удалённые из будущего
|
||||
серии уже «отмотаны» курсором вперёд и однократно пропускаются — для домашнего канала это незаметно,
|
||||
зато код без сложной «перемотки» курсоров. Расширение горизонта (обычный тик) ничего не удаляет и
|
||||
пропусков не даёт.
|
||||
|
||||
## Планировщик
|
||||
|
||||
@@ -93,7 +97,7 @@
|
||||
2. **Взвешенно-случайный выбор** включённого `ChannelShow` по действующим весам (`System.Random`,
|
||||
seed на генерацию логируется для воспроизводимости; отдельного состояния не требует, т.к.
|
||||
результат материализуется).
|
||||
3. Набрать блок серий подряд от «следующей» (см. вывод из хвоста выше), с заворотом на первую по
|
||||
3. Набрать блок серий подряд от «следующей» (курсор `NextEpisodeIndex`), с заворотом на первую по
|
||||
достижении конца:
|
||||
- `BlockMode = Count` — ровно `BlockValue` серий;
|
||||
- `BlockMode = Duration` — добавлять серии, пока суммарная длительность не достигнет `BlockValue`
|
||||
@@ -109,12 +113,11 @@
|
||||
предыдущей. Так как каждая длительность кратна 2с (см. ниже), все старты автоматически кратны 2с
|
||||
от эпохи — выравнивание на сегмент держится само собой, без часовой сетки и без дыр.
|
||||
|
||||
**Правка на лету.** Меняются веса/блоки/реклама, состав шоу или добавляется/снимается override →
|
||||
перегенерируется только будущий хвост от безопасной точки (конец текущей программы + буфер), прошлое
|
||||
не трогаем. Поскольку «курсор» выводится из хвоста, обрезав будущие записи, планировщик просто
|
||||
продолжит от последней оставшейся — двойного проигрывания серий не будет. Override, начинающийся
|
||||
позже, подхватится штатным тиком; override «начиная прямо сейчас» инициирует немедленную
|
||||
перегенерацию хвоста.
|
||||
**Правка на лету.** После изменения весов/блоков/рекламы/состава шоу или добавления override админ
|
||||
дёргает перегенерацию канала (`POST /api/admin/channels/{id}/regenerate`): удаляется ещё не
|
||||
стартовавший хвост (записи с `StartsAtUtc >= now`), сохраняется текущая идущая программа, и хвост
|
||||
достраивается заново от её конца. Обычный фоновый тик только **расширяет** горизонт (без удаления).
|
||||
Override, начинающийся в будущем, подхватится и штатным тиком.
|
||||
|
||||
**Ретеншн.** Прошедшие `ScheduleEntry` старше `Scheduler__RetentionHours` можно удалять (оставляя
|
||||
небольшое окно назад для EPG «что только что было»). Сегменты ассетов при этом не трогаются —
|
||||
|
||||
Reference in New Issue
Block a user