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