From 4fa9dae37f7a3da71bfe0fe56394e0f02c5e4427 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 24 Jul 2026 08:57:08 +0300 Subject: [PATCH] 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. --- .../TeleWave.Api/Common/CreatedIdResponse.cs | 4 + .../Endpoints/ChannelEndpoints.cs | 280 +++++++ .../TeleWave.Api/Endpoints/ShowEndpoints.cs | 99 +++ backend/src/TeleWave.Api/Program.cs | 2 + .../AddChannelAd/AddChannelAdCommand.cs | 6 + .../AddChannelAdCommandHandler.cs | 35 + .../AddChannelShow/AddChannelShowCommand.cs | 13 + .../AddChannelShowCommandHandler.cs | 37 + .../AddChannelShowCommandValidator.cs | 12 + .../Broadcast/ChannelDtos.cs | 41 ++ .../Broadcast/ChannelErrors.cs | 58 ++ .../CreateChannel/CreateChannelCommand.cs | 6 + .../CreateChannelCommandHandler.cs | 28 + .../CreateChannelCommandValidator.cs | 16 + .../CreateProgrammingOverrideCommand.cs | 13 + ...CreateProgrammingOverrideCommandHandler.cs | 42 ++ ...eateProgrammingOverrideCommandValidator.cs | 13 + .../CreateOverride/OverrideShowInput.cs | 3 + .../DeleteProgrammingOverrideCommand.cs | 7 + ...DeleteProgrammingOverrideCommandHandler.cs | 26 + .../Broadcast/GetChannel/GetChannelQuery.cs | 6 + .../GetChannel/GetChannelQueryHandler.cs | 93 +++ .../GetSchedule/GetChannelScheduleQuery.cs | 10 + .../GetChannelScheduleQueryHandler.cs | 54 ++ .../ListChannels/ListChannelsQuery.cs | 5 + .../ListChannels/ListChannelsQueryHandler.cs | 20 + .../RegenerateChannelScheduleCommand.cs | 6 + ...RegenerateChannelScheduleCommandHandler.cs | 35 + .../RemoveChannelAd/RemoveChannelAdCommand.cs | 6 + .../RemoveChannelAdCommandHandler.cs | 26 + .../RemoveChannelShowCommand.cs | 6 + .../RemoveChannelShowCommandHandler.cs | 26 + .../Broadcast/ScheduleEntryDto.cs | 14 + .../Broadcast/Scheduling/ScheduleGenerator.cs | 187 +++++ .../Broadcast/Scheduling/SchedulerOptions.cs | 15 + .../UpdateChannelSettingsCommand.cs | 14 + .../UpdateChannelSettingsCommandHandler.cs | 39 + .../UpdateChannelSettingsCommandValidator.cs | 13 + .../UpdateChannelShowCommand.cs | 14 + .../UpdateChannelShowCommandHandler.cs | 29 + .../UpdateChannelShowCommandValidator.cs | 12 + .../Common/Interfaces/IAppDbContext.cs | 5 + .../Library/AddEpisode/AddEpisodeCommand.cs | 6 + .../AddEpisode/AddEpisodeCommandHandler.cs | 35 + .../Library/CreateShow/CreateShowCommand.cs | 8 + .../CreateShow/CreateShowCommandHandler.cs | 17 + .../CreateShow/CreateShowCommandValidator.cs | 12 + .../Library/DeleteShow/DeleteShowCommand.cs | 6 + .../DeleteShow/DeleteShowCommandHandler.cs | 24 + .../Library/GetShow/GetShowQuery.cs | 6 + .../Library/GetShow/GetShowQueryHandler.cs | 51 ++ .../Library/ListShows/ListShowsQuery.cs | 5 + .../ListShows/ListShowsQueryHandler.cs | 20 + .../RemoveEpisode/RemoveEpisodeCommand.cs | 6 + .../RemoveEpisodeCommandHandler.cs | 26 + .../TeleWave.Application/Library/ShowDtos.cs | 29 + .../Library/ShowErrors.cs | 23 + .../TeleWave.Application.csproj | 1 + .../TeleWave.Domain/Broadcast/AdInsertion.cs | 11 + .../TeleWave.Domain/Broadcast/BlockMode.cs | 11 + .../src/TeleWave.Domain/Broadcast/Channel.cs | 131 ++++ .../TeleWave.Domain/Broadcast/ChannelAd.cs | 21 + .../TeleWave.Domain/Broadcast/ChannelShow.cs | 55 ++ .../TeleWave.Domain/Broadcast/OverrideMode.cs | 11 + .../TeleWave.Domain/Broadcast/OverrideShow.cs | 21 + .../Broadcast/ProgrammingOverride.cs | 45 ++ .../Broadcast/ScheduleEntry.cs | 59 ++ .../Broadcast/ScheduleEntryKind.cs | 11 + .../Broadcast/Scheduling/IRandomSource.cs | 8 + .../Broadcast/Scheduling/SchedulePlanner.cs | 190 +++++ .../Scheduling/SchedulePlannerModels.cs | 53 ++ backend/src/TeleWave.Domain/Library/Show.cs | 59 ++ .../TeleWave.Domain/Library/ShowEpisode.cs | 26 + .../src/TeleWave.Domain/Library/ShowKind.cs | 11 + .../Broadcast/SchedulingBackgroundService.cs | 79 ++ .../Broadcast/SystemRandomSource.cs | 9 + .../DependencyInjection.cs | 14 + ...4055417_AddBroadcastAndLibrary.Designer.cs | 691 ++++++++++++++++++ .../20260724055417_AddBroadcastAndLibrary.cs | 253 +++++++ .../Migrations/AppDbContextModelSnapshot.cs | 303 ++++++++ .../Persistence/AppDbContext.cs | 5 + .../Configurations/ChannelConfiguration.cs | 68 ++ .../ScheduleEntryConfiguration.cs | 16 + .../Configurations/ShowConfiguration.cs | 31 + .../Broadcast/SchedulePlannerTests.cs | 233 ++++++ docs/media-storage-and-streaming.md | 33 +- 86 files changed, 4094 insertions(+), 15 deletions(-) create mode 100644 backend/src/TeleWave.Api/Common/CreatedIdResponse.cs create mode 100644 backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs create mode 100644 backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/AddChannelAd/AddChannelAdCommand.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/AddChannelAd/AddChannelAdCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommand.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommandValidator.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/ChannelErrors.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/CreateChannel/CreateChannelCommand.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/CreateChannel/CreateChannelCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/CreateChannel/CreateChannelCommandValidator.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommand.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandValidator.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/CreateOverride/OverrideShowInput.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/DeleteOverride/DeleteProgrammingOverrideCommand.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/DeleteOverride/DeleteProgrammingOverrideCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQuery.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQuery.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/ListChannels/ListChannelsQuery.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/ListChannels/ListChannelsQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/RegenerateSchedule/RegenerateChannelScheduleCommand.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/RegenerateSchedule/RegenerateChannelScheduleCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/RemoveChannelAd/RemoveChannelAdCommand.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/RemoveChannelAd/RemoveChannelAdCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/RemoveChannelShow/RemoveChannelShowCommand.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/RemoveChannelShow/RemoveChannelShowCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/ScheduleEntryDto.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/Scheduling/SchedulerOptions.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommand.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandValidator.cs create mode 100644 backend/src/TeleWave.Application/Library/AddEpisode/AddEpisodeCommand.cs create mode 100644 backend/src/TeleWave.Application/Library/AddEpisode/AddEpisodeCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommand.cs create mode 100644 backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommandValidator.cs create mode 100644 backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommand.cs create mode 100644 backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/GetShow/GetShowQuery.cs create mode 100644 backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/ListShows/ListShowsQuery.cs create mode 100644 backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/RemoveEpisode/RemoveEpisodeCommand.cs create mode 100644 backend/src/TeleWave.Application/Library/RemoveEpisode/RemoveEpisodeCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Library/ShowDtos.cs create mode 100644 backend/src/TeleWave.Application/Library/ShowErrors.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/AdInsertion.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/BlockMode.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/Channel.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/ChannelAd.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/ChannelShow.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/OverrideMode.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/OverrideShow.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/ScheduleEntryKind.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/Scheduling/IRandomSource.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs create mode 100644 backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs create mode 100644 backend/src/TeleWave.Domain/Library/Show.cs create mode 100644 backend/src/TeleWave.Domain/Library/ShowEpisode.cs create mode 100644 backend/src/TeleWave.Domain/Library/ShowKind.cs create mode 100644 backend/src/TeleWave.Infrastructure/Broadcast/SchedulingBackgroundService.cs create mode 100644 backend/src/TeleWave.Infrastructure/Broadcast/SystemRandomSource.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260724055417_AddBroadcastAndLibrary.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260724055417_AddBroadcastAndLibrary.cs create mode 100644 backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs create mode 100644 backend/src/TeleWave.Infrastructure/Persistence/Configurations/ScheduleEntryConfiguration.cs create mode 100644 backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs create mode 100644 backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs diff --git a/backend/src/TeleWave.Api/Common/CreatedIdResponse.cs b/backend/src/TeleWave.Api/Common/CreatedIdResponse.cs new file mode 100644 index 0000000..41dac2f --- /dev/null +++ b/backend/src/TeleWave.Api/Common/CreatedIdResponse.cs @@ -0,0 +1,4 @@ +namespace TeleWave.Api.Common; + +/// Единый ответ на создание сущности — её идентификатор. +public sealed record CreatedIdResponse(Guid Id); diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs new file mode 100644 index 0000000..37aefc1 --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs @@ -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(StatusCodes.Status201Created); + admin.MapGet("", ListChannels).Produces>(); + admin.MapGet("/{id:guid}", GetChannel).Produces(); + admin.MapPut("/{id:guid}/settings", UpdateSettings).Produces(StatusCodes.Status204NoContent); + + admin + .MapPost("/{id:guid}/shows", AddShow) + .Produces(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(StatusCodes.Status201Created); + admin + .MapDelete("/{id:guid}/ads/{channelAdId:guid}", RemoveAd) + .Produces(StatusCodes.Status204NoContent); + + admin + .MapPost("/{id:guid}/overrides", CreateOverride) + .Produces(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>(); + + return app; + } + + private static async Task 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 ListChannels(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListChannelsQuery(), cancellationToken); + return Results.Ok(result); + } + + private static async Task GetChannel( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new GetChannelQuery(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task 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 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 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 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 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 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 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 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 Regenerate( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new RegenerateChannelScheduleCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task 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 Shows +); diff --git a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs new file mode 100644 index 0000000..e5dfc5c --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs @@ -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(StatusCodes.Status201Created); + admin.MapGet("", ListShows).Produces>(); + admin.MapGet("/{id:guid}", GetShow).Produces(); + admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent); + admin + .MapPost("/{id:guid}/episodes", AddEpisode) + .Produces(StatusCodes.Status201Created); + admin + .MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode) + .Produces(StatusCodes.Status204NoContent); + + return app; + } + + private static async Task 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 ListShows(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListShowsQuery(), cancellationToken); + return Results.Ok(result); + } + + private static async Task GetShow( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new GetShowQuery(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task DeleteShow( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeleteShowCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task 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 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); diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs index 182e026..d69734f 100644 --- a/backend/src/TeleWave.Api/Program.cs +++ b/backend/src/TeleWave.Api/Program.cs @@ -113,6 +113,8 @@ app.MapAuthEndpoints(); app.MapRoleEndpoints(); app.MapAdminUserEndpoints(); app.MapMediaEndpoints(); +app.MapShowEndpoints(); +app.MapChannelEndpoints(); // Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов. app.UseDefaultFiles(); diff --git a/backend/src/TeleWave.Application/Broadcast/AddChannelAd/AddChannelAdCommand.cs b/backend/src/TeleWave.Application/Broadcast/AddChannelAd/AddChannelAdCommand.cs new file mode 100644 index 0000000..ce975e4 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/AddChannelAd/AddChannelAdCommand.cs @@ -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>; diff --git a/backend/src/TeleWave.Application/Broadcast/AddChannelAd/AddChannelAdCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/AddChannelAd/AddChannelAdCommandHandler.cs new file mode 100644 index 0000000..1f8980a --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/AddChannelAd/AddChannelAdCommandHandler.cs @@ -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> +{ + public async Task> 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(ChannelErrors.NotFound); + + var assetExists = await dbContext.MediaAssets.AnyAsync( + a => a.Id == command.MediaAssetId, + cancellationToken + ); + if (!assetExists) + return Result.Failure(ChannelErrors.AssetNotFound); + + if (channel.HasAd(command.MediaAssetId)) + return Result.Failure(ChannelErrors.AdAlreadyAdded); + + var ad = channel.AddAd(command.MediaAssetId); + return Result.Success(ad.Id); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommand.cs b/backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommand.cs new file mode 100644 index 0000000..f58ba74 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommand.cs @@ -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>; diff --git a/backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommandHandler.cs new file mode 100644 index 0000000..d5804bf --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommandHandler.cs @@ -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> +{ + public async Task> 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(ChannelErrors.NotFound); + + var showExists = await dbContext.Shows.AnyAsync(s => s.Id == command.ShowId, cancellationToken); + if (!showExists) + return Result.Failure(ChannelErrors.ShowNotFound); + + if (channel.HasShow(command.ShowId)) + return Result.Failure(ChannelErrors.ShowAlreadyAdded); + + var channelShow = channel.AddShow( + command.ShowId, + command.Weight, + command.BlockMode, + command.BlockValue + ); + return Result.Success(channelShow.Id); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommandValidator.cs new file mode 100644 index 0000000..a9158bb --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace TeleWave.Application.Broadcast.AddChannelShow; + +public sealed class AddChannelShowCommandValidator : AbstractValidator +{ + public AddChannelShowCommandValidator() + { + RuleFor(x => x.Weight).InclusiveBetween(1, 1000); + RuleFor(x => x.BlockValue).InclusiveBetween(1, 10000); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs new file mode 100644 index 0000000..3669374 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs @@ -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 Shows +); + +public sealed record ChannelDto( + Guid Id, + string Name, + string Slug, + bool IsEnabled, + AdInsertion AdInsertion, + int AdsPerBreak, + Guid? FillerAssetId, + IReadOnlyList Shows, + IReadOnlyList Ads, + IReadOnlyList Overrides +); diff --git a/backend/src/TeleWave.Application/Broadcast/ChannelErrors.cs b/backend/src/TeleWave.Application/Broadcast/ChannelErrors.cs new file mode 100644 index 0000000..6bba189 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/ChannelErrors.cs @@ -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 должен ссылаться хотя бы на одно шоу." + ); +} diff --git a/backend/src/TeleWave.Application/Broadcast/CreateChannel/CreateChannelCommand.cs b/backend/src/TeleWave.Application/Broadcast/CreateChannel/CreateChannelCommand.cs new file mode 100644 index 0000000..ee92326 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/CreateChannel/CreateChannelCommand.cs @@ -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>; diff --git a/backend/src/TeleWave.Application/Broadcast/CreateChannel/CreateChannelCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/CreateChannel/CreateChannelCommandHandler.cs new file mode 100644 index 0000000..eece1b0 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/CreateChannel/CreateChannelCommandHandler.cs @@ -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> +{ + public async Task> Handle( + CreateChannelCommand command, + CancellationToken cancellationToken + ) + { + var slugTaken = await dbContext.Channels.AnyAsync( + c => c.Slug == command.Slug, + cancellationToken + ); + if (slugTaken) + return Result.Failure(ChannelErrors.DuplicateSlug); + + var channel = Channel.Create(command.Name, command.Slug, DateTimeOffset.UtcNow); + dbContext.Channels.Add(channel); + return Result.Success(channel.Id); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/CreateChannel/CreateChannelCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/CreateChannel/CreateChannelCommandValidator.cs new file mode 100644 index 0000000..f7810c5 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/CreateChannel/CreateChannelCommandValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace TeleWave.Application.Broadcast.CreateChannel; + +public sealed class CreateChannelCommandValidator : AbstractValidator +{ + 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 — только строчные латинские буквы, цифры и дефисы."); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommand.cs b/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommand.cs new file mode 100644 index 0000000..9155a84 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommand.cs @@ -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 Shows +) : ICommand>; diff --git a/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandHandler.cs new file mode 100644 index 0000000..fc3a0f2 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandHandler.cs @@ -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> +{ + public async Task> Handle( + CreateProgrammingOverrideCommand command, + CancellationToken cancellationToken + ) + { + if (command.EndsAtUtc <= command.StartsAtUtc) + return Result.Failure(ChannelErrors.InvalidOverrideWindow); + if (command.Shows.Count == 0) + return Result.Failure(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(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(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); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandValidator.cs new file mode 100644 index 0000000..7cac867 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace TeleWave.Application.Broadcast.CreateOverride; + +public sealed class CreateProgrammingOverrideCommandValidator + : AbstractValidator +{ + public CreateProgrammingOverrideCommandValidator() + { + RuleFor(x => x.Shows).NotEmpty(); + RuleForEach(x => x.Shows).ChildRules(s => s.RuleFor(i => i.Weight).InclusiveBetween(1, 1000)); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/CreateOverride/OverrideShowInput.cs b/backend/src/TeleWave.Application/Broadcast/CreateOverride/OverrideShowInput.cs new file mode 100644 index 0000000..a1c475e --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/CreateOverride/OverrideShowInput.cs @@ -0,0 +1,3 @@ +namespace TeleWave.Application.Broadcast.CreateOverride; + +public sealed record OverrideShowInput(Guid ShowId, int Weight); diff --git a/backend/src/TeleWave.Application/Broadcast/DeleteOverride/DeleteProgrammingOverrideCommand.cs b/backend/src/TeleWave.Application/Broadcast/DeleteOverride/DeleteProgrammingOverrideCommand.cs new file mode 100644 index 0000000..7a4f60c --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/DeleteOverride/DeleteProgrammingOverrideCommand.cs @@ -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; diff --git a/backend/src/TeleWave.Application/Broadcast/DeleteOverride/DeleteProgrammingOverrideCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/DeleteOverride/DeleteProgrammingOverrideCommandHandler.cs new file mode 100644 index 0000000..b69e216 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/DeleteOverride/DeleteProgrammingOverrideCommandHandler.cs @@ -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 +{ + public async Task 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); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQuery.cs b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQuery.cs new file mode 100644 index 0000000..e0f012e --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQuery.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.GetChannel; + +public sealed record GetChannelQuery(Guid Id) : IQuery>; diff --git a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs new file mode 100644 index 0000000..5ffc78d --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs @@ -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> +{ + public async Task> 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(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 + ) + ); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQuery.cs b/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQuery.cs new file mode 100644 index 0000000..65dda4f --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQuery.cs @@ -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>>; diff --git a/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs new file mode 100644 index 0000000..edf2747 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs @@ -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>> +{ + public async Task>> Handle( + GetChannelScheduleQuery query, + CancellationToken cancellationToken + ) + { + var channelExists = await dbContext.Channels.AnyAsync( + c => c.Id == query.ChannelId, + cancellationToken + ); + if (!channelExists) + return Result.Failure>(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>(dtos); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/ListChannels/ListChannelsQuery.cs b/backend/src/TeleWave.Application/Broadcast/ListChannels/ListChannelsQuery.cs new file mode 100644 index 0000000..7f8ac5e --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/ListChannels/ListChannelsQuery.cs @@ -0,0 +1,5 @@ +using LiteCqrs; + +namespace TeleWave.Application.Broadcast.ListChannels; + +public sealed record ListChannelsQuery : IQuery>; diff --git a/backend/src/TeleWave.Application/Broadcast/ListChannels/ListChannelsQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/ListChannels/ListChannelsQueryHandler.cs new file mode 100644 index 0000000..cf143b8 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/ListChannels/ListChannelsQueryHandler.cs @@ -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> +{ + public async Task> 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); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/RegenerateSchedule/RegenerateChannelScheduleCommand.cs b/backend/src/TeleWave.Application/Broadcast/RegenerateSchedule/RegenerateChannelScheduleCommand.cs new file mode 100644 index 0000000..38f533a --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/RegenerateSchedule/RegenerateChannelScheduleCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Broadcast.RegenerateSchedule; + +public sealed record RegenerateChannelScheduleCommand(Guid ChannelId) : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/RegenerateSchedule/RegenerateChannelScheduleCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/RegenerateSchedule/RegenerateChannelScheduleCommandHandler.cs new file mode 100644 index 0000000..8893b50 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/RegenerateSchedule/RegenerateChannelScheduleCommandHandler.cs @@ -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 +{ + public async Task 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(); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/RemoveChannelAd/RemoveChannelAdCommand.cs b/backend/src/TeleWave.Application/Broadcast/RemoveChannelAd/RemoveChannelAdCommand.cs new file mode 100644 index 0000000..979b0fc --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/RemoveChannelAd/RemoveChannelAdCommand.cs @@ -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; diff --git a/backend/src/TeleWave.Application/Broadcast/RemoveChannelAd/RemoveChannelAdCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/RemoveChannelAd/RemoveChannelAdCommandHandler.cs new file mode 100644 index 0000000..369d505 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/RemoveChannelAd/RemoveChannelAdCommandHandler.cs @@ -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 +{ + public async Task 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); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/RemoveChannelShow/RemoveChannelShowCommand.cs b/backend/src/TeleWave.Application/Broadcast/RemoveChannelShow/RemoveChannelShowCommand.cs new file mode 100644 index 0000000..e702e49 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/RemoveChannelShow/RemoveChannelShowCommand.cs @@ -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; diff --git a/backend/src/TeleWave.Application/Broadcast/RemoveChannelShow/RemoveChannelShowCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/RemoveChannelShow/RemoveChannelShowCommandHandler.cs new file mode 100644 index 0000000..dd1add2 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/RemoveChannelShow/RemoveChannelShowCommandHandler.cs @@ -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 +{ + public async Task 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); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/ScheduleEntryDto.cs b/backend/src/TeleWave.Application/Broadcast/ScheduleEntryDto.cs new file mode 100644 index 0000000..62c1eb5 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/ScheduleEntryDto.cs @@ -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 +); diff --git a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs new file mode 100644 index 0000000..845fc4c --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs @@ -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; + +/// +/// Оркестратор планирования: загружает конфигурацию канала и готовые ассеты, вызывает чистый +/// , материализует записи и двигает курсоры. Используется фоновым +/// планировщиком (расширение горизонта) и командой перегенерации (правка конфигурации канала). +/// +public sealed class ScheduleGenerator( + IAppDbContext dbContext, + IRandomSource random, + IOptions options +) +{ + private readonly SchedulerOptions _options = options.Value; + + /// + /// Достраивает (или, при , перестраивает будущий хвост) расписание + /// канала до горизонта. Возвращает число добавленных записей (-1 — канал не найден/выключен). + /// + public async Task 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 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(); + 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 + ); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/Scheduling/SchedulerOptions.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/SchedulerOptions.cs new file mode 100644 index 0000000..c7dcb50 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/Scheduling/SchedulerOptions.cs @@ -0,0 +1,15 @@ +namespace TeleWave.Application.Broadcast.Scheduling; + +public sealed class SchedulerOptions +{ + public const string SectionName = "Scheduler"; + + /// На сколько дней вперёд держать материализованное расписание. + public int HorizonDays { get; init; } = 3; + + /// Сколько часов прошедшего расписания хранить (для EPG «что было»), затем чистить. + public int RetentionHours { get; init; } = 24; + + /// Период тика фонового планировщика, минуты. + public int TickMinutes { get; init; } = 30; +} diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs new file mode 100644 index 0000000..8b38c1d --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs @@ -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; diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs new file mode 100644 index 0000000..09173d8 --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs @@ -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 +{ + public async Task 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(); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs new file mode 100644 index 0000000..661c74e --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace TeleWave.Application.Broadcast.UpdateChannelSettings; + +public sealed class UpdateChannelSettingsCommandValidator + : AbstractValidator +{ + public UpdateChannelSettingsCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(256); + RuleFor(x => x.AdsPerBreak).InclusiveBetween(0, 10); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommand.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommand.cs new file mode 100644 index 0000000..904386f --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommand.cs @@ -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; diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandHandler.cs new file mode 100644 index 0000000..aeb748e --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandHandler.cs @@ -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 +{ + public async Task 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(); + } +} diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandValidator.cs new file mode 100644 index 0000000..bce812f --- /dev/null +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace TeleWave.Application.Broadcast.UpdateChannelShow; + +public sealed class UpdateChannelShowCommandValidator : AbstractValidator +{ + public UpdateChannelShowCommandValidator() + { + RuleFor(x => x.Weight).InclusiveBetween(1, 1000); + RuleFor(x => x.BlockValue).InclusiveBetween(1, 10000); + } +} diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs index 9772934..f48c29b 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs @@ -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 RefreshTokens { get; } DbSet MediaAssets { get; } + DbSet Shows { get; } + DbSet Channels { get; } + DbSet ScheduleEntries { get; } Task SaveChangesAsync(CancellationToken cancellationToken); } diff --git a/backend/src/TeleWave.Application/Library/AddEpisode/AddEpisodeCommand.cs b/backend/src/TeleWave.Application/Library/AddEpisode/AddEpisodeCommand.cs new file mode 100644 index 0000000..4e414b7 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/AddEpisode/AddEpisodeCommand.cs @@ -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>; diff --git a/backend/src/TeleWave.Application/Library/AddEpisode/AddEpisodeCommandHandler.cs b/backend/src/TeleWave.Application/Library/AddEpisode/AddEpisodeCommandHandler.cs new file mode 100644 index 0000000..44f5c22 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/AddEpisode/AddEpisodeCommandHandler.cs @@ -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> +{ + public async Task> 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(ShowErrors.NotFound); + + if (!show.CanAddEpisode) + return Result.Failure(ShowErrors.SingleAlreadyHasEpisode); + + var assetExists = await dbContext.MediaAssets.AnyAsync( + a => a.Id == command.MediaAssetId, + cancellationToken + ); + if (!assetExists) + return Result.Failure(ShowErrors.AssetNotFound); + + var episode = show.AddEpisode(command.MediaAssetId); + return Result.Success(episode.Id); + } +} diff --git a/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommand.cs b/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommand.cs new file mode 100644 index 0000000..7974734 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommand.cs @@ -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>; diff --git a/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommandHandler.cs b/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommandHandler.cs new file mode 100644 index 0000000..c4e4017 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommandHandler.cs @@ -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> +{ + public Task> 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)); + } +} diff --git a/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommandValidator.cs b/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommandValidator.cs new file mode 100644 index 0000000..36e2864 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace TeleWave.Application.Library.CreateShow; + +public sealed class CreateShowCommandValidator : AbstractValidator +{ + public CreateShowCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(256); + RuleFor(x => x.Description).MaximumLength(2048); + } +} diff --git a/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommand.cs b/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommand.cs new file mode 100644 index 0000000..69951b9 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.DeleteShow; + +public sealed record DeleteShowCommand(Guid ShowId) : ICommand; diff --git a/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommandHandler.cs b/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommandHandler.cs new file mode 100644 index 0000000..0d83e6f --- /dev/null +++ b/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommandHandler.cs @@ -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 +{ + public async Task 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(); + } +} diff --git a/backend/src/TeleWave.Application/Library/GetShow/GetShowQuery.cs b/backend/src/TeleWave.Application/Library/GetShow/GetShowQuery.cs new file mode 100644 index 0000000..f6b7aa8 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/GetShow/GetShowQuery.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.GetShow; + +public sealed record GetShowQuery(Guid Id) : IQuery>; diff --git a/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs b/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs new file mode 100644 index 0000000..bd3fcbe --- /dev/null +++ b/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs @@ -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> +{ + public async Task> 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(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) + ); + } +} diff --git a/backend/src/TeleWave.Application/Library/ListShows/ListShowsQuery.cs b/backend/src/TeleWave.Application/Library/ListShows/ListShowsQuery.cs new file mode 100644 index 0000000..acf0b80 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/ListShows/ListShowsQuery.cs @@ -0,0 +1,5 @@ +using LiteCqrs; + +namespace TeleWave.Application.Library.ListShows; + +public sealed record ListShowsQuery : IQuery>; diff --git a/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs b/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs new file mode 100644 index 0000000..4c933f7 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs @@ -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> +{ + public async Task> 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); + } +} diff --git a/backend/src/TeleWave.Application/Library/RemoveEpisode/RemoveEpisodeCommand.cs b/backend/src/TeleWave.Application/Library/RemoveEpisode/RemoveEpisodeCommand.cs new file mode 100644 index 0000000..4d31767 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/RemoveEpisode/RemoveEpisodeCommand.cs @@ -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; diff --git a/backend/src/TeleWave.Application/Library/RemoveEpisode/RemoveEpisodeCommandHandler.cs b/backend/src/TeleWave.Application/Library/RemoveEpisode/RemoveEpisodeCommandHandler.cs new file mode 100644 index 0000000..81f7afa --- /dev/null +++ b/backend/src/TeleWave.Application/Library/RemoveEpisode/RemoveEpisodeCommandHandler.cs @@ -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 +{ + public async Task 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); + } +} diff --git a/backend/src/TeleWave.Application/Library/ShowDtos.cs b/backend/src/TeleWave.Application/Library/ShowDtos.cs new file mode 100644 index 0000000..38faf2d --- /dev/null +++ b/backend/src/TeleWave.Application/Library/ShowDtos.cs @@ -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 Episodes +); diff --git a/backend/src/TeleWave.Application/Library/ShowErrors.cs b/backend/src/TeleWave.Application/Library/ShowErrors.cs new file mode 100644 index 0000000..95d592f --- /dev/null +++ b/backend/src/TeleWave.Application/Library/ShowErrors.cs @@ -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", + "Медиа-ассет для серии не найден." + ); +} diff --git a/backend/src/TeleWave.Application/TeleWave.Application.csproj b/backend/src/TeleWave.Application/TeleWave.Application.csproj index 7d2bb11..0c7edb8 100644 --- a/backend/src/TeleWave.Application/TeleWave.Application.csproj +++ b/backend/src/TeleWave.Application/TeleWave.Application.csproj @@ -10,6 +10,7 @@ + diff --git a/backend/src/TeleWave.Domain/Broadcast/AdInsertion.cs b/backend/src/TeleWave.Domain/Broadcast/AdInsertion.cs new file mode 100644 index 0000000..46b0f0d --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/AdInsertion.cs @@ -0,0 +1,11 @@ +namespace TeleWave.Domain.Broadcast; + +/// Политика вставки рекламы на канале. +public enum AdInsertion +{ + /// Реклама после целого блока серий. + BetweenBlocks, + + /// Реклама после каждой серии. + BetweenEpisodes, +} diff --git a/backend/src/TeleWave.Domain/Broadcast/BlockMode.cs b/backend/src/TeleWave.Domain/Broadcast/BlockMode.cs new file mode 100644 index 0000000..3e9cd2b --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/BlockMode.cs @@ -0,0 +1,11 @@ +namespace TeleWave.Domain.Broadcast; + +/// Как измеряется блок серий одного шоу за один выбор ротации. +public enum BlockMode +{ + /// Ровно N серий подряд. + Count, + + /// Набор серий подряд, пока не наберётся ~M минут (последняя входит целиком). + Duration, +} diff --git a/backend/src/TeleWave.Domain/Broadcast/Channel.cs b/backend/src/TeleWave.Domain/Broadcast/Channel.cs new file mode 100644 index 0000000..c28fdb9 --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/Channel.cs @@ -0,0 +1,131 @@ +namespace TeleWave.Domain.Broadcast; + +/// +/// Канал линейного эфира: базовая взвешенная ротация шоу (), пул рекламы +/// (), временные override'ы () и политика вставки рекламы. +/// Планировщик разворачивает всё это в расписание встык на несколько дней вперёд. +/// +public class Channel +{ + private readonly List _shows = new(); + private readonly List _ads = new(); + private readonly List _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; } + + /// Точка отсчёта эфирной ленты (UTC) — база для MEDIA-SEQUENCE на этапе раздачи. + public DateTimeOffset EpochUtc { get; private set; } + + public AdInsertion AdInsertion { get; private set; } + public int AdsPerBreak { get; private set; } + + /// Ассет-заглушка на случай пустого расписания (аварийная подстраховка). + public Guid? FillerAssetId { get; private set; } + + /// Курсор ротации рекламного пула. + public int NextAdIndex { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + + public IReadOnlyList Shows => _shows; + + /// Пул рекламы (backing-field для EF); порядок ротации — по . + public IReadOnlyList Ads => _ads; + public IReadOnlyList 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; + } + + /// Планировщик двигает курсор рекламы по мере вставки врезок. + public void SetNextAdIndex(int index) => NextAdIndex = index; +} diff --git a/backend/src/TeleWave.Domain/Broadcast/ChannelAd.cs b/backend/src/TeleWave.Domain/Broadcast/ChannelAd.cs new file mode 100644 index 0000000..ded9bbd --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/ChannelAd.cs @@ -0,0 +1,21 @@ +namespace TeleWave.Domain.Broadcast; + +/// Рекламный ассет в пуле канала. Врезки крутятся по кругу в порядке . +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, + }; +} diff --git a/backend/src/TeleWave.Domain/Broadcast/ChannelShow.cs b/backend/src/TeleWave.Domain/Broadcast/ChannelShow.cs new file mode 100644 index 0000000..c2aaf32 --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/ChannelShow.cs @@ -0,0 +1,55 @@ +namespace TeleWave.Domain.Broadcast; + +/// +/// Связка канал↔шоу: вес в случайной ротации, режим и размер блока, а также персональный для этого +/// канала курсор серий () — индекс следующей серии в упорядоченном +/// списке шоу. +/// +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; } + + /// Число серий () или минут (). + public int BlockValue { get; private set; } + + public bool IsEnabled { get; private set; } + + /// Индекс следующей серии для этого канала (0-based в упорядоченном списке серий шоу). + 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; + } + + /// Планировщик двигает курсор по мере постановки серий в расписание. + public void SetNextEpisodeIndex(int index) => NextEpisodeIndex = index; +} diff --git a/backend/src/TeleWave.Domain/Broadcast/OverrideMode.cs b/backend/src/TeleWave.Domain/Broadcast/OverrideMode.cs new file mode 100644 index 0000000..2e55a2a --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/OverrideMode.cs @@ -0,0 +1,11 @@ +namespace TeleWave.Domain.Broadcast; + +/// Режим временного override (марафон / кампания) поверх базовой ротации. +public enum OverrideMode +{ + /// В окне играет только одно шоу (марафон). + Exclusive, + + /// В окне действуют подменённые веса перечисленных шоу (остальные не участвуют). + Boost, +} diff --git a/backend/src/TeleWave.Domain/Broadcast/OverrideShow.cs b/backend/src/TeleWave.Domain/Broadcast/OverrideShow.cs new file mode 100644 index 0000000..d5eff62 --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/OverrideShow.cs @@ -0,0 +1,21 @@ +namespace TeleWave.Domain.Broadcast; + +/// Шоу внутри override с его подменённым весом (для Boost) или единственное шоу (для Exclusive). +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, + }; +} diff --git a/backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs b/backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs new file mode 100644 index 0000000..cc6856d --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs @@ -0,0 +1,45 @@ +namespace TeleWave.Domain.Broadcast; + +/// +/// Временный override программирования канала на окне [, +/// ). Марафон = с одним шоу и большим +/// временным блоком. Пересекающийся с генерируемым временем override заменяет базовую ротацию. +/// +public class ProgrammingOverride +{ + private readonly List _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 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; +} diff --git a/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs b/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs new file mode 100644 index 0000000..0e327dc --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs @@ -0,0 +1,59 @@ +namespace TeleWave.Domain.Broadcast; + +/// +/// Материализованная запись расписания канала: конкретный ассет в конкретное время. Программы и +/// реклама идут встык ( одной равен следующей). +/// +public class ScheduleEntry +{ + public Guid Id { get; private set; } + public Guid ChannelId { get; private set; } + public Guid MediaAssetId { get; private set; } + public ScheduleEntryKind Kind { get; private set; } + public DateTimeOffset StartsAtUtc { get; private set; } + public DateTimeOffset EndsAtUtc { get; private set; } + + /// Шоу (для ) — для EPG. + public Guid? ShowId { get; private set; } + + /// Индекс серии в упорядоченном списке шоу (для EPG). + public int? EpisodeIndex { get; private set; } + + 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, + }; +} diff --git a/backend/src/TeleWave.Domain/Broadcast/ScheduleEntryKind.cs b/backend/src/TeleWave.Domain/Broadcast/ScheduleEntryKind.cs new file mode 100644 index 0000000..49951fb --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/ScheduleEntryKind.cs @@ -0,0 +1,11 @@ +namespace TeleWave.Domain.Broadcast; + +/// Тип записи расписания. +public enum ScheduleEntryKind +{ + /// Программа (серия шоу). + Program, + + /// Рекламная врезка. + Ad, +} diff --git a/backend/src/TeleWave.Domain/Broadcast/Scheduling/IRandomSource.cs b/backend/src/TeleWave.Domain/Broadcast/Scheduling/IRandomSource.cs new file mode 100644 index 0000000..6854352 --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/Scheduling/IRandomSource.cs @@ -0,0 +1,8 @@ +namespace TeleWave.Domain.Broadcast.Scheduling; + +/// Абстракция источника случайности — чтобы планировщик оставался детерминированно тестируемым. +public interface IRandomSource +{ + /// Случайное целое в диапазоне [0, maxExclusive). + int Next(int maxExclusive); +} diff --git a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs new file mode 100644 index 0000000..5285863 --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs @@ -0,0 +1,190 @@ +namespace TeleWave.Domain.Broadcast.Scheduling; + +/// +/// Чистая эфирная математика: разворачивает конфигурацию канала в последовательность записей встык +/// от до . Без БД, ФС и +/// ffmpeg — полностью юнит-тестируемо (см. SchedulePlannerTests). +/// +/// Инварианты: серии одного шоу идут по порядку (курсор ), +/// на конце сериала — заворот на первую серию; выбор шоу — взвешенно-случайный; override на окне +/// заменяет базовую ротацию; реклама вставляется по политике канала. +/// +public static class SchedulePlanner +{ + private const int IterationBackstop = 1_000_000; + + public static PlannerResult Plan(PlannerInput input, IRandomSource random) + { + var entries = new List(); + 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 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 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 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; +} diff --git a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs new file mode 100644 index 0000000..e849157 --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs @@ -0,0 +1,53 @@ +namespace TeleWave.Domain.Broadcast.Scheduling; + +/// Шоу канала, подготовленное для планировщика: только готовые серии, с курсором. +public sealed record PlannerShow( + Guid ChannelShowId, + Guid ShowId, + int Weight, + BlockMode BlockMode, + int BlockValue, + IReadOnlyList EpisodeAssetIds, + int NextEpisodeIndex +); + +/// Override в терминах планировщика: окно + режим + шоу с весами. +public sealed record PlannerOverride( + DateTimeOffset StartsAtUtc, + DateTimeOffset EndsAtUtc, + OverrideMode Mode, + IReadOnlyList Shows +); + +public sealed record PlannerOverrideShow(Guid ShowId, int Weight); + +/// Полный вход планировщика для одного прогона по каналу. +public sealed record PlannerInput( + Guid ChannelId, + AdInsertion AdInsertion, + int AdsPerBreak, + int NextAdIndex, + IReadOnlyList Shows, + IReadOnlyList AdPool, + IReadOnlyDictionary Durations, + IReadOnlyList Overrides, + DateTimeOffset StartTime, + DateTimeOffset HorizonEnd +); + +/// Одна запланированная запись (ещё не доменная сущность). +public sealed record PlannedEntry( + Guid MediaAssetId, + ScheduleEntryKind Kind, + DateTimeOffset StartsAtUtc, + DateTimeOffset EndsAtUtc, + Guid? ShowId, + int? EpisodeIndex +); + +/// Результат прогона: новые записи + обновлённые курсоры (серий по каждому ChannelShow и рекламы). +public sealed record PlannerResult( + IReadOnlyList Entries, + IReadOnlyDictionary NextEpisodeIndexByChannelShow, + int NextAdIndex +); diff --git a/backend/src/TeleWave.Domain/Library/Show.cs b/backend/src/TeleWave.Domain/Library/Show.cs new file mode 100644 index 0000000..5436d5f --- /dev/null +++ b/backend/src/TeleWave.Domain/Library/Show.cs @@ -0,0 +1,59 @@ +namespace TeleWave.Domain.Library; + +/// +/// Переиспользуемое шоу в общей библиотеке: сериал (упорядоченные серии) либо полнометражка. +/// Серии идут строго в порядке ; курсор показа на канале хранится +/// отдельно на связке канал↔шоу (см. Broadcast/ChannelShow). +/// +public class Show +{ + private readonly List _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; } + + /// Серии шоу (backing-field для EF). Порядок показа — по ; + /// потребители сортируют явно (см. загрузчик планировщика). + public IReadOnlyList 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; + } + + /// Добавляет серию в конец. Для допустима ровно одна серия. + 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; +} diff --git a/backend/src/TeleWave.Domain/Library/ShowEpisode.cs b/backend/src/TeleWave.Domain/Library/ShowEpisode.cs new file mode 100644 index 0000000..c262980 --- /dev/null +++ b/backend/src/TeleWave.Domain/Library/ShowEpisode.cs @@ -0,0 +1,26 @@ +namespace TeleWave.Domain.Library; + +/// Одна серия шоу — ссылка на медиа-ассет и позиция в порядке показа. +public class ShowEpisode +{ + public Guid Id { get; private set; } + public Guid ShowId { get; private set; } + public Guid MediaAssetId { get; private set; } + + /// Порядковый номер внутри шоу (может иметь разрывы после удалений). + 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, + }; +} diff --git a/backend/src/TeleWave.Domain/Library/ShowKind.cs b/backend/src/TeleWave.Domain/Library/ShowKind.cs new file mode 100644 index 0000000..31db02f --- /dev/null +++ b/backend/src/TeleWave.Domain/Library/ShowKind.cs @@ -0,0 +1,11 @@ +namespace TeleWave.Domain.Library; + +/// Тип шоу в библиотеке. +public enum ShowKind +{ + /// Сериал: упорядоченный список серий, идут по порядку. + Series, + + /// Разовый выпуск/полнометражка: ровно одна «серия». + Single, +} diff --git a/backend/src/TeleWave.Infrastructure/Broadcast/SchedulingBackgroundService.cs b/backend/src/TeleWave.Infrastructure/Broadcast/SchedulingBackgroundService.cs new file mode 100644 index 0000000..05287f8 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Broadcast/SchedulingBackgroundService.cs @@ -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; + +/// +/// Периодически достраивает расписание каждого включённого канала до горизонта. Расширение хвоста — +/// без удаления существующих записей, поэтому эфир не «дёргается». +/// +public sealed class SchedulingBackgroundService( + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger 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(); + var generator = scope.ServiceProvider.GetRequiredService(); + + 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 + ); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Broadcast/SystemRandomSource.cs b/backend/src/TeleWave.Infrastructure/Broadcast/SystemRandomSource.cs new file mode 100644 index 0000000..29d7c78 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Broadcast/SystemRandomSource.cs @@ -0,0 +1,9 @@ +using TeleWave.Domain.Broadcast.Scheduling; + +namespace TeleWave.Infrastructure.Broadcast; + +/// Боевой источник случайности поверх (потокобезопасен). +public sealed class SystemRandomSource : IRandomSource +{ + public int Next(int maxExclusive) => Random.Shared.Next(maxExclusive); +} diff --git a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs index 4226e0e..58a709e 100644 --- a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs +++ b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs @@ -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(); AddMedia(services, configuration); + AddBroadcast(services, configuration); return services; } + /// Планировщик расписания: генератор, источник случайности и фоновый сервис горизонта. + private static void AddBroadcast(IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(SchedulerOptions.SectionName)); + + services.AddSingleton(); + services.AddScoped(); + services.AddHostedService(); + } + /// Хранилище медиа, обработка ffmpeg, очередь и фоновые сервисы (очередь + inbox-сканер). private static void AddMedia(IServiceCollection services, IConfiguration configuration) { diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260724055417_AddBroadcastAndLibrary.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260724055417_AddBroadcastAndLibrary.Designer.cs new file mode 100644 index 0000000..d210f93 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260724055417_AddBroadcastAndLibrary.Designer.cs @@ -0,0 +1,691 @@ +// +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 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextAdIndex") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelAd"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NextEpisodeIndex") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ShowId"); + + b.ToTable("ChannelShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ProgrammingOverrideId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProgrammingOverrideId"); + + b.ToTable("OverrideShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("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", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", 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", 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 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260724055417_AddBroadcastAndLibrary.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260724055417_AddBroadcastAndLibrary.cs new file mode 100644 index 0000000..029fb12 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260724055417_AddBroadcastAndLibrary.cs @@ -0,0 +1,253 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class AddBroadcastAndLibrary : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Channels", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Slug = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + IsEnabled = table.Column(type: "boolean", nullable: false), + EpochUtc = table.Column(type: "timestamp with time zone", nullable: false), + AdInsertion = table.Column(type: "integer", nullable: false), + AdsPerBreak = table.Column(type: "integer", nullable: false), + FillerAssetId = table.Column(type: "uuid", nullable: true), + NextAdIndex = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(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(type: "uuid", nullable: false), + ChannelId = table.Column(type: "uuid", nullable: false), + MediaAssetId = table.Column(type: "uuid", nullable: false), + Kind = table.Column(type: "integer", nullable: false), + StartsAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + EndsAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ShowId = table.Column(type: "uuid", nullable: true), + EpisodeIndex = table.Column(type: "integer", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ScheduleEntries", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Shows", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Description = table.Column(type: "character varying(2048)", maxLength: 2048, nullable: true), + Kind = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(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(type: "uuid", nullable: false), + ChannelId = table.Column(type: "uuid", nullable: false), + MediaAssetId = table.Column(type: "uuid", nullable: false), + Position = table.Column(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(type: "uuid", nullable: false), + ChannelId = table.Column(type: "uuid", nullable: false), + ShowId = table.Column(type: "uuid", nullable: false), + Weight = table.Column(type: "integer", nullable: false), + BlockMode = table.Column(type: "integer", nullable: false), + BlockValue = table.Column(type: "integer", nullable: false), + IsEnabled = table.Column(type: "boolean", nullable: false), + NextEpisodeIndex = table.Column(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(type: "uuid", nullable: false), + ChannelId = table.Column(type: "uuid", nullable: false), + Mode = table.Column(type: "integer", nullable: false), + StartsAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + EndsAtUtc = table.Column(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(type: "uuid", nullable: false), + ShowId = table.Column(type: "uuid", nullable: false), + MediaAssetId = table.Column(type: "uuid", nullable: false), + Position = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(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(type: "uuid", nullable: false), + ProgrammingOverrideId = table.Column(type: "uuid", nullable: false), + ShowId = table.Column(type: "uuid", nullable: false), + Weight = table.Column(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" }); + } + + /// + 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"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 0371c98..3afcfbd 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -160,6 +160,245 @@ namespace TeleWave.Infrastructure.Migrations b.ToTable("RefreshTokens"); }); + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextAdIndex") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelAd"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NextEpisodeIndex") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ShowId"); + + b.ToTable("ChannelShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ProgrammingOverrideId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProgrammingOverrideId"); + + b.ToTable("OverrideShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("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("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 } } diff --git a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs index e184e32..3475ebe 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs @@ -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 options) { public DbSet RefreshTokens => Set(); public DbSet MediaAssets => Set(); + public DbSet Shows => Set(); + public DbSet Channels => Set(); + public DbSet ScheduleEntries => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs new file mode 100644 index 0000000..ae465ed --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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 +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasIndex(x => new { x.ChannelId, x.ShowId }); + } +} + +public class ChannelAdConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasIndex(x => new { x.ChannelId, x.Position }); + } +} + +public class ProgrammingOverrideConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ScheduleEntryConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ScheduleEntryConfiguration.cs new file mode 100644 index 0000000..11aab4b --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ScheduleEntryConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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 }); + } +} diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs new file mode 100644 index 0000000..87ff378 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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 +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasIndex(x => new { x.ShowId, x.Position }); + builder.HasIndex(x => x.MediaAssetId); + } +} diff --git a/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs b/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs new file mode 100644 index 0000000..d27c665 --- /dev/null +++ b/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs @@ -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); + + /// Источник случайности с фиксированной (циклической) последовательностью значений. + 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 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(), Start.AddHours(1)); + + var result = SchedulePlanner.Plan(input, new FixedRandom(0)); + + Assert.Empty(result.Entries); + } + + private static PlannerInput BaseInput( + IReadOnlyList shows, + IReadOnlyDictionary durations, + DateTimeOffset horizonEnd + ) => + new( + ChannelId: Guid.NewGuid(), + AdInsertion: AdInsertion.BetweenBlocks, + AdsPerBreak: 0, + NextAdIndex: 0, + Shows: shows, + AdPool: [], + Durations: durations, + Overrides: [], + StartTime: Start, + HorizonEnd: horizonEnd + ); +} diff --git a/docs/media-storage-and-streaming.md b/docs/media-storage-and-streaming.md index 0bbd8d5..7cf806f 100644 --- a/docs/media-storage-and-streaming.md +++ b/docs/media-storage-and-streaming.md @@ -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 «что только что было»). Сегменты ассетов при этом не трогаются —