diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Overrides.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Overrides.cs
deleted file mode 100644
index 538323d..0000000
--- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Overrides.cs
+++ /dev/null
@@ -1,62 +0,0 @@
-using LiteCqrs;
-using TeleWave.Api.Common;
-using TeleWave.Application.Broadcast.CreateOverride;
-using TeleWave.Application.Broadcast.DeleteOverride;
-using TeleWave.Domain.Broadcast;
-
-namespace TeleWave.Api.Endpoints;
-
-/// Эндпоинты временных override'ов / марафонов канала (разовые и еженедельные).
-public static partial class ChannelEndpoints
-{
- private static async Task CreateOverride(
- Guid id,
- CreateOverrideBody body,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(
- new CreateProgrammingOverrideCommand(
- id,
- body.Mode,
- body.Recurrence,
- body.StartsAtUtc,
- body.EndsAtUtc,
- body.DayOfWeek,
- body.StartMinute,
- body.EndMinute,
- 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();
- }
-}
-
-public sealed record CreateOverrideBody(
- OverrideMode Mode,
- OverrideRecurrence Recurrence,
- DateTimeOffset? StartsAtUtc,
- DateTimeOffset? EndsAtUtc,
- int? DayOfWeek,
- int? StartMinute,
- int? EndMinute,
- IReadOnlyList Shows
-);
diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Shows.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Shows.cs
deleted file mode 100644
index 205c88d..0000000
--- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Shows.cs
+++ /dev/null
@@ -1,122 +0,0 @@
-using LiteCqrs;
-using TeleWave.Api.Common;
-using TeleWave.Application.Broadcast.AddChannelAd;
-using TeleWave.Application.Broadcast.AddChannelShow;
-using TeleWave.Application.Broadcast.RemoveChannelAd;
-using TeleWave.Application.Broadcast.RemoveChannelShow;
-using TeleWave.Application.Broadcast.UpdateChannelShow;
-using TeleWave.Domain.Broadcast;
-
-namespace TeleWave.Api.Endpoints;
-
-/// Эндпоинты канала: шоу в ротации и рекламный пул.
-public static partial class ChannelEndpoints
-{
- 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,
- body.PreferredWeightMultiplier,
- body.PreferredHours ?? []
- ),
- 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();
- }
-}
-
-public sealed record AddChannelShowBody(
- Guid ShowId,
- int Weight,
- BlockMode BlockMode,
- int BlockValue
-);
-
-public sealed record UpdateChannelShowBody(
- int Weight,
- BlockMode BlockMode,
- int BlockValue,
- bool IsEnabled,
- int PreferredWeightMultiplier,
- IReadOnlyList PreferredHours
-);
-
-public sealed record AddChannelAdBody(Guid MediaAssetId);
diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs
index a14c77b..a38d96a 100644
--- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs
+++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs
@@ -1,217 +1,205 @@
-using LiteCqrs;
-using TeleWave.Api.Common;
-using TeleWave.Application.Broadcast;
-using TeleWave.Application.Broadcast.CreateChannel;
-using TeleWave.Application.Broadcast.GetChannel;
-using TeleWave.Application.Broadcast.GetSchedule;
-using TeleWave.Application.Broadcast.ListChannels;
-using TeleWave.Application.Broadcast.RegenerateSchedule;
-using TeleWave.Application.Broadcast.UpdateChannelSettings;
-using TeleWave.Domain.Broadcast;
-using TeleWave.Infrastructure.Identity;
-
-namespace TeleWave.Api.Endpoints;
-
-///
-/// Админ-эндпоинты канала. Реализация разнесена по partial-файлам под-ресурсов:
-/// ChannelEndpoints.Shows.cs (шоу+реклама), ChannelEndpoints.Bumpers.cs
-/// (блоки/подблоки/файлы/preview), ChannelEndpoints.Overrides.cs (override'ы). Здесь —
-/// регистрация всех маршрутов и хендлеры уровня канала (создание/список/настройки/расписание).
-///
-public static partial 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}/bumper/templates", AddBumperTemplate)
- .Produces(StatusCodes.Status201Created);
- admin
- .MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate)
- .Produces(StatusCodes.Status204NoContent);
- admin
- .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate)
- .Produces(StatusCodes.Status204NoContent);
- admin
- .MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio)
- .Produces(StatusCodes.Status204NoContent);
- admin
- .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio)
- .Produces(StatusCodes.Status204NoContent);
- admin
- .MapPut(
- "/{id:guid}/bumper/templates/{templateId:guid}/background",
- SetTemplateBackground
- )
- .Produces(StatusCodes.Status204NoContent);
- admin
- .MapDelete(
- "/{id:guid}/bumper/templates/{templateId:guid}/background",
- ClearTemplateBackground
- )
- .Produces(StatusCodes.Status204NoContent);
-
- admin
- .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview)
- .Produces(StatusCodes.Status204NoContent);
- admin.MapGet(
- "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8",
- PreviewPlaylist
- );
- admin.MapGet(
- "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}",
- PreviewSegment
- );
-
- admin
- .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant)
- .Produces(StatusCodes.Status201Created);
- admin
- .MapPut(
- "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
- UpdateBumperVariant
- )
- .Produces(StatusCodes.Status204NoContent);
- admin
- .MapDelete(
- "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
- RemoveBumperVariant
- )
- .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.BumpersEnabled,
- body.Bumper,
- body.FillerAssetId
- ),
- 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,
- bool BumpersEnabled,
- BumperSettingsInput Bumper,
- Guid? FillerAssetId
-);
+using LiteCqrs;
+using TeleWave.Api.Common;
+using TeleWave.Application.Broadcast;
+using TeleWave.Application.Broadcast.CreateChannel;
+using TeleWave.Application.Broadcast.GetChannel;
+using TeleWave.Application.Broadcast.GetSchedule;
+using TeleWave.Application.Broadcast.ListChannels;
+using TeleWave.Application.Broadcast.UpdateChannelSettings;
+using TeleWave.Application.Broadcast.UpdateChannelTime;
+using TeleWave.Domain.Broadcast;
+using TeleWave.Infrastructure.Identity;
+
+namespace TeleWave.Api.Endpoints;
+
+///
+/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки —
+/// в ChannelEndpoints.Bumpers.cs. Что и когда идёт в эфире, задаёт шаблон сетки
+/// (TemplateEndpoints).
+///
+public static partial 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.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent);
+
+ admin
+ .MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
+ .Produces(StatusCodes.Status201Created);
+ admin
+ .MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate)
+ .Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate)
+ .Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio)
+ .Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio)
+ .Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapPut(
+ "/{id:guid}/bumper/templates/{templateId:guid}/background",
+ SetTemplateBackground
+ )
+ .Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapDelete(
+ "/{id:guid}/bumper/templates/{templateId:guid}/background",
+ ClearTemplateBackground
+ )
+ .Produces(StatusCodes.Status204NoContent);
+
+ admin
+ .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview)
+ .Produces(StatusCodes.Status204NoContent);
+ admin.MapGet(
+ "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8",
+ PreviewPlaylist
+ );
+ admin.MapGet(
+ "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}",
+ PreviewSegment
+ );
+
+ admin
+ .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant)
+ .Produces(StatusCodes.Status201Created);
+ admin
+ .MapPut(
+ "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
+ UpdateBumperVariant
+ )
+ .Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapDelete(
+ "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
+ RemoveBumperVariant
+ )
+ .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 UpdateTime(
+ Guid id,
+ UpdateChannelTimeBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new UpdateChannelTimeCommand(
+ id,
+ body.Number,
+ body.UtcOffsetMinutes,
+ body.DayStartTime
+ ),
+ 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.BumpersEnabled,
+ body.Bumper,
+ body.FillerAssetId
+ ),
+ 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();
+ }
+}
+
+/// Номер канала и его время: смещение от UTC и начало вещательных суток.
+public sealed record UpdateChannelTimeBody(
+ int? Number,
+ int UtcOffsetMinutes,
+ TimeOnly DayStartTime
+);
+
+public sealed record UpdateChannelSettingsBody(
+ string Name,
+ bool IsEnabled,
+ bool BumpersEnabled,
+ BumperSettingsInput Bumper,
+ Guid? FillerAssetId
+);
diff --git a/backend/src/TeleWave.Api/Endpoints/CollectionEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/CollectionEndpoints.cs
new file mode 100644
index 0000000..8dcc52f
--- /dev/null
+++ b/backend/src/TeleWave.Api/Endpoints/CollectionEndpoints.cs
@@ -0,0 +1,163 @@
+using LiteCqrs;
+using TeleWave.Api.Common;
+using TeleWave.Application.Library.Collections;
+using TeleWave.Application.Library.Collections.AddCollectionShow;
+using TeleWave.Application.Library.Collections.CreateCollection;
+using TeleWave.Application.Library.Collections.DeleteCollection;
+using TeleWave.Application.Library.Collections.GetCollection;
+using TeleWave.Application.Library.Collections.ListCollections;
+using TeleWave.Application.Library.Collections.RemoveCollectionShow;
+using TeleWave.Application.Library.Collections.ReorderCollection;
+using TeleWave.Application.Library.Collections.SetCollectionPoster;
+using TeleWave.Application.Library.Collections.UpdateCollection;
+using TeleWave.Infrastructure.Identity;
+
+namespace TeleWave.Api.Endpoints;
+
+public static class CollectionEndpoints
+{
+ public static IEndpointRouteBuilder MapCollectionEndpoints(this IEndpointRouteBuilder app)
+ {
+ var admin = app.MapGroup("/api/admin/collections")
+ .WithTags("Admin.Collections")
+ .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
+
+ admin.MapGet("", ListCollections).Produces>();
+ admin.MapGet("/{id:guid}", GetCollection).Produces();
+ admin
+ .MapPost("", CreateCollection)
+ .Produces(StatusCodes.Status201Created);
+ admin.MapPut("/{id:guid}", UpdateCollection).Produces(StatusCodes.Status204NoContent);
+ admin.MapDelete("/{id:guid}", DeleteCollection).Produces(StatusCodes.Status204NoContent);
+ admin.MapPost("/{id:guid}/shows", AddShow).Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapDelete("/{id:guid}/shows/{showId:guid}", RemoveShow)
+ .Produces(StatusCodes.Status204NoContent);
+ admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
+ admin.MapPut("/{id:guid}/poster-image", SetPoster).Produces(StatusCodes.Status204NoContent);
+
+ return app;
+ }
+
+ private static async Task ListCollections(
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new ListCollectionsQuery(), cancellationToken);
+ return Results.Ok(result);
+ }
+
+ private static async Task GetCollection(
+ Guid id,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new GetCollectionQuery(id), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task CreateCollection(
+ CreateCollectionCommand command,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(command, cancellationToken);
+ return result.IsSuccess
+ ? Results.Created(
+ $"/api/admin/collections/{result.Value}",
+ new CreatedIdResponse(result.Value)
+ )
+ : result.ToHttpResult();
+ }
+
+ private static async Task UpdateCollection(
+ Guid id,
+ UpdateCollectionBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new UpdateCollectionCommand(id, body.Name, body.Description),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ private static async Task DeleteCollection(
+ Guid id,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new DeleteCollectionCommand(id), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task AddShow(
+ Guid id,
+ CollectionShowBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new AddCollectionShowCommand(id, body.ShowId),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ private static async Task RemoveShow(
+ Guid id,
+ Guid showId,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new RemoveCollectionShowCommand(id, showId),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ private static async Task Reorder(
+ Guid id,
+ ReorderCollectionBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new ReorderCollectionCommand(id, body.ShowIdsInOrder),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ private static async Task SetPoster(
+ Guid id,
+ CollectionPosterBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new SetCollectionPosterCommand(id, body.ImageId),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+}
+
+public sealed record UpdateCollectionBody(string Name, string? Description);
+
+public sealed record CollectionShowBody(Guid ShowId);
+
+public sealed record ReorderCollectionBody(IReadOnlyList ShowIdsInOrder);
+
+public sealed record CollectionPosterBody(Guid? ImageId);
diff --git a/backend/src/TeleWave.Api/Endpoints/GenreEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/GenreEndpoints.cs
new file mode 100644
index 0000000..87bea0e
--- /dev/null
+++ b/backend/src/TeleWave.Api/Endpoints/GenreEndpoints.cs
@@ -0,0 +1,77 @@
+using LiteCqrs;
+using TeleWave.Api.Common;
+using TeleWave.Application.Library.Genres;
+using TeleWave.Application.Library.Genres.CreateGenre;
+using TeleWave.Application.Library.Genres.DeleteGenre;
+using TeleWave.Application.Library.Genres.ListGenres;
+using TeleWave.Application.Library.Genres.UpdateGenre;
+using TeleWave.Infrastructure.Identity;
+
+namespace TeleWave.Api.Endpoints;
+
+public static class GenreEndpoints
+{
+ public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app)
+ {
+ var admin = app.MapGroup("/api/admin/genres")
+ .WithTags("Admin.Genres")
+ .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
+
+ admin.MapGet("", ListGenres).Produces>();
+ admin.MapPost("", CreateGenre).Produces(StatusCodes.Status201Created);
+ admin.MapPut("/{id:guid}", UpdateGenre).Produces(StatusCodes.Status204NoContent);
+ admin.MapDelete("/{id:guid}", DeleteGenre).Produces(StatusCodes.Status204NoContent);
+
+ return app;
+ }
+
+ private static async Task ListGenres(
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new ListGenresQuery(), cancellationToken);
+ return Results.Ok(result);
+ }
+
+ private static async Task CreateGenre(
+ CreateGenreCommand command,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(command, cancellationToken);
+ return result.IsSuccess
+ ? Results.Created(
+ $"/api/admin/genres/{result.Value}",
+ new CreatedIdResponse(result.Value)
+ )
+ : result.ToHttpResult();
+ }
+
+ private static async Task UpdateGenre(
+ Guid id,
+ UpdateGenreBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new UpdateGenreCommand(id, body.Name, body.SortOrder, body.Aliases),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ private static async Task DeleteGenre(
+ Guid id,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new DeleteGenreCommand(id), cancellationToken);
+ return result.ToHttpResult();
+ }
+}
+
+public sealed record UpdateGenreBody(string Name, int SortOrder, IReadOnlyList? Aliases);
diff --git a/backend/src/TeleWave.Api/Endpoints/GroupEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/GroupEndpoints.cs
new file mode 100644
index 0000000..3ade5bb
--- /dev/null
+++ b/backend/src/TeleWave.Api/Endpoints/GroupEndpoints.cs
@@ -0,0 +1,185 @@
+using LiteCqrs;
+using TeleWave.Api.Common;
+using TeleWave.Application.Programming.Groups;
+using TeleWave.Application.Programming.Groups.AddGroupElements;
+using TeleWave.Application.Programming.Groups.CreateGroup;
+using TeleWave.Application.Programming.Groups.DeleteGroup;
+using TeleWave.Application.Programming.Groups.FindGroupCandidates;
+using TeleWave.Application.Programming.Groups.GetGroup;
+using TeleWave.Application.Programming.Groups.ListGroups;
+using TeleWave.Application.Programming.Groups.RemoveGroupItem;
+using TeleWave.Application.Programming.Groups.ReorderGroup;
+using TeleWave.Application.Programming.Groups.SetGroupItemWeight;
+using TeleWave.Application.Programming.Groups.UpdateGroup;
+using TeleWave.Infrastructure.Identity;
+
+namespace TeleWave.Api.Endpoints;
+
+public static class GroupEndpoints
+{
+ public static IEndpointRouteBuilder MapGroupEndpoints(this IEndpointRouteBuilder app)
+ {
+ var admin = app.MapGroup("/api/admin/groups")
+ .WithTags("Admin.Groups")
+ .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
+
+ admin.MapGet("", ListGroups).Produces>();
+ admin.MapGet("/{id:guid}", GetGroup).Produces();
+ admin.MapPost("", CreateGroup).Produces(StatusCodes.Status201Created);
+ admin.MapPut("/{id:guid}", UpdateGroup).Produces(StatusCodes.Status204NoContent);
+ admin.MapDelete("/{id:guid}", DeleteGroup).Produces(StatusCodes.Status204NoContent);
+
+ // Подбор по правилу набора: правило можно передать в теле, чтобы крутить его до сохранения.
+ admin.MapPost("/{id:guid}/candidates", FindCandidates)
+ .Produces>();
+
+ admin.MapPost("/{id:guid}/items", AddElements).Produces();
+ admin
+ .MapDelete("/{id:guid}/items/{itemId:guid}", RemoveItem)
+ .Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapPut("/{id:guid}/items/{itemId:guid}/weight", SetWeight)
+ .Produces(StatusCodes.Status204NoContent);
+ admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
+
+ return app;
+ }
+
+ private static async Task ListGroups(ISender sender, CancellationToken cancellationToken)
+ {
+ var result = await sender.Send(new ListGroupsQuery(), cancellationToken);
+ return Results.Ok(result);
+ }
+
+ private static async Task GetGroup(
+ Guid id,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new GetGroupQuery(id), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task CreateGroup(
+ CreateGroupCommand command,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(command, cancellationToken);
+ return result.IsSuccess
+ ? Results.Created(
+ $"/api/admin/groups/{result.Value}",
+ new CreatedIdResponse(result.Value)
+ )
+ : result.ToHttpResult();
+ }
+
+ private static async Task UpdateGroup(
+ Guid id,
+ UpdateGroupBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new UpdateGroupCommand(id, body.Name, body.Description, body.Filter),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ private static async Task DeleteGroup(
+ Guid id,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new DeleteGroupCommand(id), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task FindCandidates(
+ Guid id,
+ FindCandidatesBody? body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new FindGroupCandidatesQuery(id, body?.Filter),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ private static async Task AddElements(
+ Guid id,
+ AddGroupElementsBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new AddGroupElementsCommand(id, body.Elements),
+ cancellationToken
+ );
+ return result.IsSuccess
+ ? Results.Ok(new AddedCountResponse(result.Value))
+ : result.ToHttpResult();
+ }
+
+ private static async Task RemoveItem(
+ Guid id,
+ Guid itemId,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new RemoveGroupItemCommand(id, itemId), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task SetWeight(
+ Guid id,
+ Guid itemId,
+ GroupItemWeightBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new SetGroupItemWeightCommand(id, itemId, body.Weight),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ private static async Task Reorder(
+ Guid id,
+ ReorderGroupBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new ReorderGroupCommand(id, body.ItemIdsInOrder),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+}
+
+public sealed record UpdateGroupBody(string Name, string? Description, GroupFilter? Filter);
+
+public sealed record FindCandidatesBody(GroupFilter? Filter);
+
+public sealed record AddGroupElementsBody(IReadOnlyList Elements);
+
+public sealed record GroupItemWeightBody(int Weight);
+
+public sealed record ReorderGroupBody(IReadOnlyList ItemIdsInOrder);
+
+/// Сколько позиций реально добавлено (уже входящие в группу пропускаются).
+public sealed record AddedCountResponse(int Added);
diff --git a/backend/src/TeleWave.Api/Endpoints/JunctionEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/JunctionEndpoints.cs
new file mode 100644
index 0000000..d5ed8dd
--- /dev/null
+++ b/backend/src/TeleWave.Api/Endpoints/JunctionEndpoints.cs
@@ -0,0 +1,168 @@
+using LiteCqrs;
+using TeleWave.Api.Common;
+using TeleWave.Application.Programming.Templates.Junctions;
+using TeleWave.Domain.Programming;
+using TeleWave.Infrastructure.Identity;
+
+namespace TeleWave.Api.Endpoints;
+
+///
+/// Шаблоны стыков канала: что играет между программами. Как и правка сетки, эфира не двигают —
+/// помечают шаблон канала изменённым, а хвост пересобирается применением.
+///
+public static class JunctionEndpoints
+{
+ public static IEndpointRouteBuilder MapJunctionEndpoints(this IEndpointRouteBuilder app)
+ {
+ var admin = app.MapGroup("/api/admin")
+ .WithTags("Admin.Junctions")
+ .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
+
+ admin
+ .MapGet("/channels/{channelId:guid}/junctions", List)
+ .Produces>();
+ admin
+ .MapPost("/channels/{channelId:guid}/junctions", Create)
+ .Produces(StatusCodes.Status201Created);
+ admin.MapPut("/junctions/{junctionId:guid}", Rename).Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapDelete("/junctions/{junctionId:guid}", Delete)
+ .Produces(StatusCodes.Status204NoContent);
+
+ admin
+ .MapPost("/junctions/{junctionId:guid}/elements", AddElement)
+ .Produces(StatusCodes.Status201Created);
+ admin
+ .MapPut("/junctions/{junctionId:guid}/elements/{elementId:guid}", UpdateElement)
+ .Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapDelete("/junctions/{junctionId:guid}/elements/{elementId:guid}", RemoveElement)
+ .Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapPut("/junctions/{junctionId:guid}/order", Reorder)
+ .Produces(StatusCodes.Status204NoContent);
+
+ return app;
+ }
+
+ private static async Task List(
+ Guid channelId,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new ListJunctionsQuery(channelId), cancellationToken);
+ return Results.Ok(result);
+ }
+
+ private static async Task Create(
+ Guid channelId,
+ JunctionNameBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new CreateJunctionCommand(channelId, body.Name),
+ cancellationToken
+ );
+ return result.IsSuccess
+ ? Results.Created(
+ $"/api/admin/junctions/{result.Value}",
+ new CreatedIdResponse(result.Value)
+ )
+ : result.ToHttpResult();
+ }
+
+ private static async Task Rename(
+ Guid junctionId,
+ JunctionNameBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new RenameJunctionCommand(junctionId, body.Name),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ private static async Task Delete(
+ Guid junctionId,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new DeleteJunctionCommand(junctionId), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task AddElement(
+ Guid junctionId,
+ JunctionElementKindBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new AddJunctionElementCommand(junctionId, body.Kind),
+ cancellationToken
+ );
+ return result.IsSuccess
+ ? Results.Created(
+ $"/api/admin/junctions/{junctionId}",
+ new CreatedIdResponse(result.Value)
+ )
+ : result.ToHttpResult();
+ }
+
+ private static async Task UpdateElement(
+ Guid junctionId,
+ Guid elementId,
+ JunctionElementInput input,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new UpdateJunctionElementCommand(junctionId, elementId, input),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ private static async Task RemoveElement(
+ Guid junctionId,
+ Guid elementId,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new RemoveJunctionElementCommand(junctionId, elementId),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ private static async Task Reorder(
+ Guid junctionId,
+ ReorderJunctionBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new ReorderJunctionCommand(junctionId, body.ElementIdsInOrder),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+}
+
+public sealed record JunctionNameBody(string Name);
+
+public sealed record JunctionElementKindBody(JunctionElementKind Kind);
+
+public sealed record ReorderJunctionBody(IReadOnlyList ElementIdsInOrder);
diff --git a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs
index 1a0680b..7a7fc61 100644
--- a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs
+++ b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs
@@ -1,162 +1,182 @@
-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.Application.Library.RenameShow;
-using TeleWave.Application.Library.SetShowAudience;
-using TeleWave.Application.Library.SetShowOriginalName;
-using TeleWave.Domain.Library;
-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.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent);
- admin
- .MapPut("/{id:guid}/original-name", SetOriginalName)
- .Produces(StatusCodes.Status204NoContent);
- admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
- 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 Rename(
- Guid id,
- RenameShowBody body,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken);
- return result.ToHttpResult();
- }
-
- private static async Task SetOriginalName(
- Guid id,
- SetShowOriginalNameBody body,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(
- new SetShowOriginalNameCommand(id, body.OriginalName),
- cancellationToken
- );
- return result.ToHttpResult();
- }
-
- private static async Task SetAudience(
- Guid id,
- SetShowAudienceBody body,
- ISender sender,
- CancellationToken cancellationToken
- )
- {
- var result = await sender.Send(
- new SetShowAudienceCommand(id, body.Audience),
- 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);
-
-public sealed record RenameShowBody(string Name);
-
-public sealed record SetShowOriginalNameBody(string? OriginalName);
-
-public sealed record SetShowAudienceBody(ShowAudience Audience);
+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.Application.Library.RenameShow;
+using TeleWave.Application.Library.SetShowAudience;
+using TeleWave.Application.Library.SetShowGenres;
+using TeleWave.Application.Library.SetShowOriginalName;
+using TeleWave.Domain.Library;
+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.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapPut("/{id:guid}/original-name", SetOriginalName)
+ .Produces(StatusCodes.Status204NoContent);
+ admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
+ admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent);
+ 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,
+ Guid? genreId = null,
+ bool interstitials = false
+ )
+ {
+ var result = await sender.Send(new ListShowsQuery(genreId, interstitials), 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 Rename(
+ Guid id,
+ RenameShowBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task SetOriginalName(
+ Guid id,
+ SetShowOriginalNameBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new SetShowOriginalNameCommand(id, body.OriginalName),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ private static async Task SetAudience(
+ Guid id,
+ SetShowAudienceBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new SetShowAudienceCommand(id, body.Audience),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ private static async Task SetGenres(
+ Guid id,
+ SetShowGenresBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new SetShowGenresCommand(id, body.GenreIds, body.PrimaryGenreId),
+ 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);
+
+public sealed record RenameShowBody(string Name);
+
+public sealed record SetShowOriginalNameBody(string? OriginalName);
+
+public sealed record SetShowAudienceBody(ShowAudience Audience);
+
+public sealed record SetShowGenresBody(IReadOnlyList GenreIds, Guid? PrimaryGenreId);
diff --git a/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs
new file mode 100644
index 0000000..8acc6cc
--- /dev/null
+++ b/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs
@@ -0,0 +1,191 @@
+using LiteCqrs;
+using TeleWave.Api.Common;
+using TeleWave.Application.Programming.Planning.ApplyTemplate;
+using TeleWave.Application.Programming.Templates;
+using TeleWave.Application.Programming.Templates.CreateSlot;
+using TeleWave.Application.Programming.Templates.DeleteSlot;
+using TeleWave.Application.Programming.Templates.GetTemplate;
+using TeleWave.Application.Programming.Templates.Layers;
+using TeleWave.Application.Programming.Templates.UpdateSlot;
+using TeleWave.Infrastructure.Identity;
+
+namespace TeleWave.Api.Endpoints;
+
+///
+/// Сетка канала: шаблон, слои, слоты. Правка ничего не двигает в эфире — она помечает шаблон
+/// изменённым, а хвост пересобирается отдельной командой применения.
+///
+public static class TemplateEndpoints
+{
+ public static IEndpointRouteBuilder MapTemplateEndpoints(this IEndpointRouteBuilder app)
+ {
+ var admin = app.MapGroup("/api/admin")
+ .WithTags("Admin.Templates")
+ .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
+
+ admin
+ .MapGet("/channels/{channelId:guid}/template", GetTemplate)
+ .Produces();
+ admin
+ .MapPut("/templates/{templateId:guid}", UpdateTemplate)
+ .Produces(StatusCodes.Status204NoContent);
+
+ // Применение правил к эфиру — отдельным действием: правка слотов эфир не двигает.
+ admin
+ .MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate)
+ .Produces();
+
+ admin
+ .MapPost("/templates/{templateId:guid}/layers", CreateLayer)
+ .Produces(StatusCodes.Status201Created);
+ admin.MapPut("/layers/{layerId:guid}", UpdateLayer).Produces(StatusCodes.Status204NoContent);
+ admin
+ .MapDelete("/layers/{layerId:guid}", DeleteLayer)
+ .Produces(StatusCodes.Status204NoContent);
+
+ admin
+ .MapPost("/layers/{layerId:guid}/slots", CreateSlot)
+ .Produces(StatusCodes.Status201Created);
+ admin.MapPut("/slots/{slotId:guid}", UpdateSlot).Produces(StatusCodes.Status204NoContent);
+ admin.MapDelete("/slots/{slotId:guid}", DeleteSlot).Produces(StatusCodes.Status204NoContent);
+
+ return app;
+ }
+
+ private static async Task GetTemplate(
+ Guid channelId,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new GetChannelTemplateQuery(channelId), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task ApplyTemplate(
+ Guid channelId,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new ApplyChannelTemplateCommand(channelId),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ private static async Task UpdateTemplate(
+ Guid templateId,
+ UpdateTemplateBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new UpdateTemplateCommand(
+ templateId,
+ body.Name,
+ body.FallbackGroupId,
+ body.DefaultJunctionId
+ ),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ private static async Task CreateLayer(
+ Guid templateId,
+ CreateLayerBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new CreateLayerCommand(templateId, body.Name, body.Priority),
+ cancellationToken
+ );
+ return result.IsSuccess
+ ? Results.Created($"/api/admin/layers/{result.Value}", new CreatedIdResponse(result.Value))
+ : result.ToHttpResult();
+ }
+
+ private static async Task UpdateLayer(
+ Guid layerId,
+ UpdateLayerBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new UpdateLayerCommand(
+ layerId,
+ body.Name,
+ body.Priority,
+ body.Applicability,
+ body.IsEnabled
+ ),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
+ private static async Task DeleteLayer(
+ Guid layerId,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new DeleteLayerCommand(layerId), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task CreateSlot(
+ Guid layerId,
+ SlotInput input,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new CreateSlotCommand(layerId, input), cancellationToken);
+ return result.IsSuccess
+ ? Results.Created($"/api/admin/slots/{result.Value}", new CreatedIdResponse(result.Value))
+ : result.ToHttpResult();
+ }
+
+ private static async Task UpdateSlot(
+ Guid slotId,
+ SlotInput input,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new UpdateSlotCommand(slotId, input), cancellationToken);
+ return result.ToHttpResult();
+ }
+
+ private static async Task DeleteSlot(
+ Guid slotId,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new DeleteSlotCommand(slotId), cancellationToken);
+ return result.ToHttpResult();
+ }
+}
+
+public sealed record UpdateTemplateBody(
+ string Name,
+ Guid? FallbackGroupId,
+ Guid? DefaultJunctionId
+);
+
+public sealed record CreateLayerBody(string Name, int Priority);
+
+public sealed record UpdateLayerBody(
+ string Name,
+ int Priority,
+ LayerApplicability? Applicability,
+ bool IsEnabled
+);
diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs
index f04628e..1d5f07a 100644
--- a/backend/src/TeleWave.Api/Program.cs
+++ b/backend/src/TeleWave.Api/Program.cs
@@ -1,142 +1,147 @@
-using System.Net;
-using System.Text.Json.Serialization;
-using System.Threading.RateLimiting;
-using Microsoft.AspNetCore.HttpOverrides;
-using Microsoft.AspNetCore.RateLimiting;
-using Scalar.AspNetCore;
-using Serilog;
-using TeleWave.Api.Common;
-using TeleWave.Api.Endpoints;
-using TeleWave.Application;
-using TeleWave.Infrastructure;
-using TeleWave.Infrastructure.Identity;
-using TeleWave.Infrastructure.Persistence;
-
-var builder = WebApplication.CreateBuilder(args);
-
-// Загрузка медиа стримится на диск; поднимаем лимит тела запроса Kestrel до максимума загрузки
-// (иначе дефолтные ~30 МБ рубят большие файлы). Собственный контроль размера — в MediaEndpoints.
-builder.WebHost.ConfigureKestrel(options =>
- options.Limits.MaxRequestBodySize =
- builder.Configuration.GetValue("Media:MaxUploadBytes") ?? 20L * 1024 * 1024 * 1024
-);
-
-// Структурное логирование (Serilog), конфигурация из appsettings/env.
-builder.Services.AddSerilog(
- (services, configuration) =>
- configuration
- .ReadFrom.Configuration(builder.Configuration)
- .ReadFrom.Services(services)
- .Enrich.FromLogContext()
-);
-
-// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
-// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
-// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback;
-// для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
-builder.Services.Configure(options =>
-{
- options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
-
- foreach (
- var proxy in builder
- .Configuration.GetSection("ForwardedHeaders:KnownProxies")
- .Get()
- ?? []
- )
- options.KnownProxies.Add(IPAddress.Parse(proxy));
-
- foreach (
- var network in builder
- .Configuration.GetSection("ForwardedHeaders:KnownNetworks")
- .Get()
- ?? []
- )
- {
- var parts = network.Split('/');
- options.KnownIPNetworks.Add(
- new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
- );
- }
-});
-
-builder.Services.AddHttpContextAccessor();
-builder.Services.AddApplication();
-builder.Services.AddInfrastructure(builder.Configuration);
-
-var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
-builder.Services.AddRateLimiter(options =>
-{
- // Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый
- // непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех.
- options.AddPolicy(
- RateLimiting.AuthPolicy,
- httpContext =>
- RateLimitPartition.GetFixedWindowLimiter(
- httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
- _ => new FixedWindowRateLimiterOptions
- {
- PermitLimit = authPermitLimit,
- Window = TimeSpan.FromMinutes(1),
- QueueLimit = 0,
- }
- )
- );
- options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
-});
-
-// Энумы сериализуются строками, не числами — самодокументируемый JSON.
-builder.Services.ConfigureHttpJsonOptions(options =>
- options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
-);
-
-builder.Services.AddProblemDetails();
-builder.Services.AddOpenApi();
-builder.Services.AddHealthChecks().AddDbContextCheck();
-
-var app = builder.Build();
-
-// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
-await app.Services.ApplyMigrationsAsync();
-await app.Services.SeedDataAsync();
-
-app.UseForwardedHeaders();
-app.UseSerilogRequestLogging();
-app.UseExceptionHandler();
-
-app.UseRateLimiter();
-
-app.UseAuthentication();
-app.UseAuthorization();
-
-// Схему/UI API публикуем не в проде (или явным флагом Api:EnableOpenApi=true) — чтобы в продакшене
-// не раскрывать полную карту эндпоинтов без необходимости.
-if (app.Environment.IsDevelopment() || app.Configuration.GetValue("Api:EnableOpenApi", false))
-{
- app.MapOpenApi();
- app.MapScalarApiReference();
-}
-
-app.MapHealthChecks("/health");
-
-app.MapAuthEndpoints();
-app.MapRoleEndpoints();
-app.MapAdminUserEndpoints();
-app.MapMediaEndpoints();
-app.MapShowEndpoints();
-app.MapChannelEndpoints();
-app.MapStreamingEndpoints();
-app.MapMaintenanceEndpoints();
-app.MapSettingsEndpoints();
-app.MapMetadataEndpoints();
-app.MapImageEndpoints();
-
-// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
-app.UseDefaultFiles();
-app.UseStaticFiles();
-app.MapFallbackToFile("index.html");
-
-app.Run();
-
-/// Делает неявный класс Program доступным для WebApplicationFactory<Program> в интеграционных тестах.
-public partial class Program;
+using System.Net;
+using System.Text.Json.Serialization;
+using System.Threading.RateLimiting;
+using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.AspNetCore.RateLimiting;
+using Scalar.AspNetCore;
+using Serilog;
+using TeleWave.Api.Common;
+using TeleWave.Api.Endpoints;
+using TeleWave.Application;
+using TeleWave.Infrastructure;
+using TeleWave.Infrastructure.Identity;
+using TeleWave.Infrastructure.Persistence;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Загрузка медиа стримится на диск; поднимаем лимит тела запроса Kestrel до максимума загрузки
+// (иначе дефолтные ~30 МБ рубят большие файлы). Собственный контроль размера — в MediaEndpoints.
+builder.WebHost.ConfigureKestrel(options =>
+ options.Limits.MaxRequestBodySize =
+ builder.Configuration.GetValue("Media:MaxUploadBytes") ?? 20L * 1024 * 1024 * 1024
+);
+
+// Структурное логирование (Serilog), конфигурация из appsettings/env.
+builder.Services.AddSerilog(
+ (services, configuration) =>
+ configuration
+ .ReadFrom.Configuration(builder.Configuration)
+ .ReadFrom.Services(services)
+ .Enrich.FromLogContext()
+);
+
+// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
+// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
+// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback;
+// для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
+builder.Services.Configure(options =>
+{
+ options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
+
+ foreach (
+ var proxy in builder
+ .Configuration.GetSection("ForwardedHeaders:KnownProxies")
+ .Get()
+ ?? []
+ )
+ options.KnownProxies.Add(IPAddress.Parse(proxy));
+
+ foreach (
+ var network in builder
+ .Configuration.GetSection("ForwardedHeaders:KnownNetworks")
+ .Get()
+ ?? []
+ )
+ {
+ var parts = network.Split('/');
+ options.KnownIPNetworks.Add(
+ new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
+ );
+ }
+});
+
+builder.Services.AddHttpContextAccessor();
+builder.Services.AddApplication();
+builder.Services.AddInfrastructure(builder.Configuration);
+
+var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
+builder.Services.AddRateLimiter(options =>
+{
+ // Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый
+ // непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех.
+ options.AddPolicy(
+ RateLimiting.AuthPolicy,
+ httpContext =>
+ RateLimitPartition.GetFixedWindowLimiter(
+ httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
+ _ => new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = authPermitLimit,
+ Window = TimeSpan.FromMinutes(1),
+ QueueLimit = 0,
+ }
+ )
+ );
+ options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
+});
+
+// Энумы сериализуются строками, не числами — самодокументируемый JSON.
+builder.Services.ConfigureHttpJsonOptions(options =>
+ options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
+);
+
+builder.Services.AddProblemDetails();
+builder.Services.AddOpenApi();
+builder.Services.AddHealthChecks().AddDbContextCheck();
+
+var app = builder.Build();
+
+// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
+await app.Services.ApplyMigrationsAsync();
+await app.Services.SeedDataAsync();
+
+app.UseForwardedHeaders();
+app.UseSerilogRequestLogging();
+app.UseExceptionHandler();
+
+app.UseRateLimiter();
+
+app.UseAuthentication();
+app.UseAuthorization();
+
+// Схему/UI API публикуем не в проде (или явным флагом Api:EnableOpenApi=true) — чтобы в продакшене
+// не раскрывать полную карту эндпоинтов без необходимости.
+if (app.Environment.IsDevelopment() || app.Configuration.GetValue("Api:EnableOpenApi", false))
+{
+ app.MapOpenApi();
+ app.MapScalarApiReference();
+}
+
+app.MapHealthChecks("/health");
+
+app.MapAuthEndpoints();
+app.MapRoleEndpoints();
+app.MapAdminUserEndpoints();
+app.MapMediaEndpoints();
+app.MapShowEndpoints();
+app.MapGenreEndpoints();
+app.MapCollectionEndpoints();
+app.MapGroupEndpoints();
+app.MapTemplateEndpoints();
+app.MapJunctionEndpoints();
+app.MapChannelEndpoints();
+app.MapStreamingEndpoints();
+app.MapMaintenanceEndpoints();
+app.MapSettingsEndpoints();
+app.MapMetadataEndpoints();
+app.MapImageEndpoints();
+
+// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
+app.UseDefaultFiles();
+app.UseStaticFiles();
+app.MapFallbackToFile("index.html");
+
+app.Run();
+
+/// Делает неявный класс Program доступным для WebApplicationFactory<Program> в интеграционных тестах.
+public partial class Program;
diff --git a/backend/src/TeleWave.Api/appsettings.json b/backend/src/TeleWave.Api/appsettings.json
index 38b1feb..8ac69ee 100644
--- a/backend/src/TeleWave.Api/appsettings.json
+++ b/backend/src/TeleWave.Api/appsettings.json
@@ -21,8 +21,8 @@
"MinFreeSpaceBytes": 10737418240
},
"Scheduler": {
- "HorizonDays": 3,
- "RetentionHours": 24,
+ "HorizonDays": 7,
+ "RetentionDays": 90,
"TickMinutes": 30
},
"Media": {
diff --git a/backend/src/TeleWave.Application/Broadcast/AddChannelAd/AddChannelAdCommand.cs b/backend/src/TeleWave.Application/Broadcast/AddChannelAd/AddChannelAdCommand.cs
deleted file mode 100644
index 8cab3ee..0000000
--- a/backend/src/TeleWave.Application/Broadcast/AddChannelAd/AddChannelAdCommand.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-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
deleted file mode 100644
index a806da5..0000000
--- a/backend/src/TeleWave.Application/Broadcast/AddChannelAd/AddChannelAdCommandHandler.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-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
deleted file mode 100644
index f58ba74..0000000
--- a/backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommand.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-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
deleted file mode 100644
index 546d0ca..0000000
--- a/backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommandHandler.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-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
deleted file mode 100644
index a9158bb..0000000
--- a/backend/src/TeleWave.Application/Broadcast/AddChannelShow/AddChannelShowCommandValidator.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-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/Bumpers/RenderBumperPreviewCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs
index 3cfb486..9e96a91 100644
--- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs
@@ -1,123 +1,128 @@
-using LiteCqrs;
-using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Options;
-using TeleWave.Application.Common.Interfaces;
-using TeleWave.Application.Common.Models;
-using TeleWave.Application.Streaming;
-using TeleWave.Domain.Broadcast;
-
-namespace TeleWave.Application.Broadcast.Bumpers;
-
-public sealed class RenderBumperPreviewCommandHandler(
- IAppDbContext dbContext,
- IBumperRenderer renderer,
- IBumperTemplateStorage storage,
- IImageStore imageStore,
- IOptions bumperOptions,
- IOptions streamingOptions
-) : ICommandHandler
-{
- private readonly BumperOptions _bumper = bumperOptions.Value;
- private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
-
- /// Длительность заставки без загруженного звука (сек) — как в генераторе.
- private const int DefaultBumperDurationSeconds = 8;
-
- public async Task Handle(
- RenderBumperPreviewCommand query,
- CancellationToken cancellationToken
- )
- {
- var channel = await dbContext
- .Channels.AsNoTracking()
- .Include(c => c.BumperTemplates)
- .ThenInclude(t => t.Variants)
- .Include(c => c.Shows)
- .AsSplitQuery()
- .FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
- if (channel is null)
- return Result.Failure(ChannelErrors.NotFound);
-
- var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId);
- if (template is null)
- return Result.Failure(ChannelErrors.BumperTemplateNotFound);
-
- var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
- var fontFile =
- channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans;
-
- // Фон блока — из общего реестра по id (общий для всех подблоков).
- string? backgroundPath = null;
- if (template.BackgroundImageId is { } bgId)
- {
- var bgExt = await dbContext
- .Images.AsNoTracking()
- .Where(i => i.Id == bgId)
- .Select(i => i.FileExtension)
- .FirstOrDefaultAsync(cancellationToken);
- if (bgExt is not null)
- backgroundPath = imageStore.ResolvePath(bgId, bgExt);
- }
-
- var seconds = template.AudioDurationSeconds is { } d and > 0
- ? d
- : DefaultBumperDurationSeconds;
- var aligned = (int)(
- Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds
- );
- var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
-
- // Рендерим каждый подблок в свой ассет-превью (id по подблоку).
- foreach (var variant in template.Variants.OrderBy(v => v.Position))
- {
- var free = variant.Kind == BumperTextKind.Free;
- var spec = new BumperRenderSpec(
- aligned,
- _bumper.Width,
- _bumper.Height,
- template.BackgroundColor,
- template.BackgroundColor2,
- template.AccentColor,
- template.TextColor,
- fontFile,
- free ? "" : variant.NowLabel,
- free ? "" : fromName,
- free ? "" : variant.NextLabel,
- free ? "" : toName,
- backgroundPath,
- audioPath,
- // Постер зависит от конкретного «следующего» шоу — в превью не подставляем.
- null,
- free,
- variant.Line1,
- variant.Line2
- );
- await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
- }
-
- return Result.Success();
- }
-
- /// Примерные названия «из/в» — берём первые два шоу канала, иначе заглушки.
- private async Task<(string From, string To)> SampleNamesAsync(
- Channel channel,
- CancellationToken cancellationToken
- )
- {
- var showIds = channel.Shows.Select(s => s.ShowId).Distinct().Take(2).ToList();
- var names =
- showIds.Count == 0
- ? []
- : await dbContext
- .Shows.AsNoTracking()
- .Where(s => showIds.Contains(s.Id))
- .Select(s => s.Name)
- .Take(2)
- .ToListAsync(cancellationToken);
-
- return (
- names.ElementAtOrDefault(0) ?? "Первое шоу",
- names.ElementAtOrDefault(1) ?? "Второе шоу"
- );
- }
-}
+using LiteCqrs;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Application.Common.Models;
+using TeleWave.Application.Streaming;
+using TeleWave.Domain.Broadcast;
+using TeleWave.Domain.Programming;
+
+namespace TeleWave.Application.Broadcast.Bumpers;
+
+public sealed class RenderBumperPreviewCommandHandler(
+ IAppDbContext dbContext,
+ IBumperRenderer renderer,
+ IBumperTemplateStorage storage,
+ IImageStore imageStore,
+ IOptions bumperOptions,
+ IOptions streamingOptions
+) : ICommandHandler
+{
+ private readonly BumperOptions _bumper = bumperOptions.Value;
+ private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
+
+ /// Длительность заставки без загруженного звука (сек) — как в генераторе.
+ private const int DefaultBumperDurationSeconds = 8;
+
+ public async Task Handle(
+ RenderBumperPreviewCommand query,
+ CancellationToken cancellationToken
+ )
+ {
+ var channel = await dbContext
+ .Channels.AsNoTracking()
+ .Include(c => c.BumperTemplates)
+ .ThenInclude(t => t.Variants)
+ .FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
+ if (channel is null)
+ return Result.Failure(ChannelErrors.NotFound);
+
+ var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId);
+ if (template is null)
+ return Result.Failure(ChannelErrors.BumperTemplateNotFound);
+
+ var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
+ var fontFile =
+ channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans;
+
+ // Фон блока — из общего реестра по id (общий для всех подблоков).
+ string? backgroundPath = null;
+ if (template.BackgroundImageId is { } bgId)
+ {
+ var bgExt = await dbContext
+ .Images.AsNoTracking()
+ .Where(i => i.Id == bgId)
+ .Select(i => i.FileExtension)
+ .FirstOrDefaultAsync(cancellationToken);
+ if (bgExt is not null)
+ backgroundPath = imageStore.ResolvePath(bgId, bgExt);
+ }
+
+ var seconds = template.AudioDurationSeconds is { } d and > 0
+ ? d
+ : DefaultBumperDurationSeconds;
+ var aligned = (int)(
+ Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds
+ );
+ var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
+
+ // Рендерим каждый подблок в свой ассет-превью (id по подблоку).
+ foreach (var variant in template.Variants.OrderBy(v => v.Position))
+ {
+ var free = variant.Kind == BumperTextKind.Free;
+ var spec = new BumperRenderSpec(
+ aligned,
+ _bumper.Width,
+ _bumper.Height,
+ template.BackgroundColor,
+ template.BackgroundColor2,
+ template.AccentColor,
+ template.TextColor,
+ fontFile,
+ free ? "" : variant.NowLabel,
+ free ? "" : fromName,
+ free ? "" : variant.NextLabel,
+ free ? "" : toName,
+ backgroundPath,
+ audioPath,
+ // Постер зависит от конкретного «следующего» шоу — в превью не подставляем.
+ null,
+ free,
+ variant.Line1,
+ variant.Line2
+ );
+ await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
+ }
+
+ return Result.Success();
+ }
+
+ ///
+ /// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала:
+ /// так превью показывает реальные названия этого канала, а не случайные из библиотеки.
+ ///
+ private async Task<(string From, string To)> SampleNamesAsync(
+ Channel channel,
+ CancellationToken cancellationToken
+ )
+ {
+ var names = await (
+ from slot in dbContext.Slots.AsNoTracking()
+ join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
+ join item in dbContext.GroupItems.AsNoTracking()
+ on slot.GroupId equals item.GroupId
+ join show in dbContext.Shows.AsNoTracking() on item.ElementId equals show.Id
+ where layer.TemplateId == channel.TemplateId
+ && item.ElementKind == GroupElementKind.Show
+ select show.Name
+ )
+ .Distinct()
+ .Take(2)
+ .ToListAsync(cancellationToken);
+
+ return (
+ names.ElementAtOrDefault(0) ?? "Первое шоу",
+ names.ElementAtOrDefault(1) ?? "Второе шоу"
+ );
+ }
+}
diff --git a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs
index 2a7c627..4583658 100644
--- a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs
+++ b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs
@@ -1,92 +1,59 @@
-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,
- int PreferredWeightMultiplier,
- IReadOnlyList PreferredHours
-);
-
-/// Окно предпочтительных часов [StartHour, EndHour) суток (UTC).
-public sealed record HourWindowDto(int StartHour, int EndHour);
-
-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,
- OverrideRecurrence Recurrence,
- DateTimeOffset? StartsAtUtc,
- DateTimeOffset? EndsAtUtc,
- int? DayOfWeek,
- int? StartMinute,
- int? EndMinute,
- IReadOnlyList Shows
-);
-
-/// Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).
-public sealed record BumperSettingsDto(
- BumperFont Font,
- int MinIntervalMinutes,
- BumperSelection Selection,
- double ShowChangeChance,
- double EpisodeChangeChance
-);
-
-/// Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.
-public sealed record BumperTextVariantDto(
- Guid Id,
- int Position,
- string Name,
- BumperTextKind Kind,
- string NowLabel,
- string NextLabel,
- string Line1,
- string Line2,
- BumperTrigger Trigger,
- int Weight
-);
-
-/// Блок заставки: своё оформление + звук + подблоки. — длина звука (сек).
-public sealed record BumperTemplateDto(
- Guid Id,
- int Position,
- bool IsDefault,
- string Name,
- string BackgroundColor,
- string BackgroundColor2,
- string AccentColor,
- string TextColor,
- Guid? BackgroundImageId,
- bool HasAudio,
- double? AudioDurationSeconds,
- IReadOnlyList Variants
-);
-
-public sealed record ChannelDto(
- Guid Id,
- string Name,
- string Slug,
- bool IsEnabled,
- AdInsertion AdInsertion,
- int AdsPerBreak,
- bool BumpersEnabled,
- BumperSettingsDto Bumper,
- IReadOnlyList BumperTemplates,
- Guid? FillerAssetId,
- IReadOnlyList Shows,
- IReadOnlyList Ads,
- IReadOnlyList Overrides
-);
+using TeleWave.Domain.Broadcast;
+
+namespace TeleWave.Application.Broadcast;
+
+public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled);
+
+/// Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).
+public sealed record BumperSettingsDto(
+ BumperFont Font,
+ int MinIntervalMinutes,
+ BumperSelection Selection,
+ double ShowChangeChance,
+ double EpisodeChangeChance
+);
+
+/// Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.
+public sealed record BumperTextVariantDto(
+ Guid Id,
+ int Position,
+ string Name,
+ BumperTextKind Kind,
+ string NowLabel,
+ string NextLabel,
+ string Line1,
+ string Line2,
+ BumperTrigger Trigger,
+ int Weight
+);
+
+/// Блок заставки: своё оформление + звук + подблоки. — длина звука (сек).
+public sealed record BumperTemplateDto(
+ Guid Id,
+ int Position,
+ bool IsDefault,
+ string Name,
+ string BackgroundColor,
+ string BackgroundColor2,
+ string AccentColor,
+ string TextColor,
+ Guid? BackgroundImageId,
+ bool HasAudio,
+ double? AudioDurationSeconds,
+ IReadOnlyList Variants
+);
+
+public sealed record ChannelDto(
+ Guid Id,
+ string Name,
+ string Slug,
+ bool IsEnabled,
+ int? Number,
+ int UtcOffsetMinutes,
+ TimeOnly DayStartTime,
+ Guid? TemplateId,
+ bool BumpersEnabled,
+ BumperSettingsDto Bumper,
+ IReadOnlyList BumperTemplates,
+ Guid? FillerAssetId
+);
diff --git a/backend/src/TeleWave.Application/Broadcast/ChannelErrors.cs b/backend/src/TeleWave.Application/Broadcast/ChannelErrors.cs
index 121e930..7ac083c 100644
--- a/backend/src/TeleWave.Application/Broadcast/ChannelErrors.cs
+++ b/backend/src/TeleWave.Application/Broadcast/ChannelErrors.cs
@@ -1,83 +1,54 @@
-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 BumperTemplateNotFound = Error.NotFound(
- "Channels.BumperTemplateNotFound",
- "Блок заставки не найден."
- );
-
- public static readonly Error CannotRemoveDefaultBumperTemplate = Error.Validation(
- "Channels.CannotRemoveDefaultBumperTemplate",
- "Дефолтный блок заставки удалить нельзя."
- );
-
- public static readonly Error BumperTextVariantNotFound = Error.NotFound(
- "Channels.BumperTextVariantNotFound",
- "Подблок заставки не найден."
- );
-
- public static readonly Error CannotRemoveLastBumperTextVariant = Error.Validation(
- "Channels.CannotRemoveLastBumperTextVariant",
- "Нельзя удалить последний подблок — нужен хотя бы один."
- );
-
- public static readonly Error AssetNotFound = Error.NotFound(
- "Channels.AssetNotFound",
- "Медиа-ассет не найден."
- );
-
- public static readonly Error InvalidBumperFile = Error.Validation(
- "Channels.InvalidBumperFile",
- "Недопустимый файл заставки (формат или размер)."
- );
-
- 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 должен ссылаться хотя бы на одно шоу."
- );
-}
+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 NumberTaken = Error.Conflict(
+ "Channels.NumberTaken",
+ "Канал с таким номером уже есть."
+ );
+
+ public static readonly Error TemplateNotFound = Error.NotFound(
+ "Channels.TemplateNotFound",
+ "У канала нет шаблона сетки."
+ );
+
+ public static readonly Error DuplicateSlug = Error.Conflict(
+ "Channels.DuplicateSlug",
+ "Канал с таким slug уже существует."
+ );
+
+ public static readonly Error BumperTemplateNotFound = Error.NotFound(
+ "Channels.BumperTemplateNotFound",
+ "Блок заставки не найден."
+ );
+
+ public static readonly Error CannotRemoveDefaultBumperTemplate = Error.Validation(
+ "Channels.CannotRemoveDefaultBumperTemplate",
+ "Дефолтный блок заставки удалить нельзя."
+ );
+
+ public static readonly Error BumperTextVariantNotFound = Error.NotFound(
+ "Channels.BumperTextVariantNotFound",
+ "Подблок заставки не найден."
+ );
+
+ public static readonly Error CannotRemoveLastBumperTextVariant = Error.Validation(
+ "Channels.CannotRemoveLastBumperTextVariant",
+ "Нельзя удалить последний подблок — нужен хотя бы один."
+ );
+
+ public static readonly Error AssetNotFound = Error.NotFound(
+ "Channels.AssetNotFound",
+ "Медиа-ассет не найден."
+ );
+
+ public static readonly Error InvalidBumperFile = Error.Validation(
+ "Channels.InvalidBumperFile",
+ "Недопустимый файл заставки (формат или размер)."
+ );
+
+}
diff --git a/backend/src/TeleWave.Application/Broadcast/CreateChannel/CreateChannelCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/CreateChannel/CreateChannelCommandHandler.cs
index eece1b0..ce13a69 100644
--- a/backend/src/TeleWave.Application/Broadcast/CreateChannel/CreateChannelCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Broadcast/CreateChannel/CreateChannelCommandHandler.cs
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
+using TeleWave.Domain.Programming;
namespace TeleWave.Application.Broadcast.CreateChannel;
@@ -22,7 +23,13 @@ public sealed class CreateChannelCommandHandler(IAppDbContext dbContext)
return Result.Failure(ChannelErrors.DuplicateSlug);
var channel = Channel.Create(command.Name, command.Slug, DateTimeOffset.UtcNow);
+
+ // Канал без шаблона вещать не может, поэтому шаблон с фоновым слоем заводится сразу.
+ var template = ScheduleTemplate.Create(channel.Id, command.Name);
+ channel.SetTemplate(template.Id);
+
dbContext.Channels.Add(channel);
+ dbContext.ScheduleTemplates.Add(template);
return Result.Success(channel.Id);
}
}
diff --git a/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommand.cs b/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommand.cs
deleted file mode 100644
index fb0b975..0000000
--- a/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommand.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-using LiteCqrs;
-using TeleWave.Application.Common.Models;
-using TeleWave.Domain.Broadcast;
-
-namespace TeleWave.Application.Broadcast.CreateOverride;
-
-public sealed record CreateProgrammingOverrideCommand(
- Guid ChannelId,
- OverrideMode Mode,
- OverrideRecurrence Recurrence,
- DateTimeOffset? StartsAtUtc,
- DateTimeOffset? EndsAtUtc,
- int? DayOfWeek,
- int? StartMinute,
- int? EndMinute,
- IReadOnlyList Shows
-) : ICommand>;
diff --git a/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandHandler.cs
deleted file mode 100644
index 1658fd1..0000000
--- a/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandHandler.cs
+++ /dev/null
@@ -1,74 +0,0 @@
-using LiteCqrs;
-using Microsoft.EntityFrameworkCore;
-using TeleWave.Application.Common.Interfaces;
-using TeleWave.Application.Common.Models;
-using TeleWave.Domain.Broadcast;
-
-namespace TeleWave.Application.Broadcast.CreateOverride;
-
-public sealed class CreateProgrammingOverrideCommandHandler(IAppDbContext dbContext)
- : ICommandHandler>
-{
- public async Task> Handle(
- CreateProgrammingOverrideCommand command,
- CancellationToken cancellationToken
- )
- {
- var weekly = command.Recurrence == OverrideRecurrence.Weekly;
- if (weekly)
- {
- if (
- command.DayOfWeek is not (>= 0 and <= 6)
- || command.StartMinute is not { } sm
- || command.EndMinute is not { } em
- || em <= sm
- || sm < 0
- || em > 1440
- )
- return Result.Failure(ChannelErrors.InvalidOverrideWindow);
- }
- else if (
- command.StartsAtUtc is not { } start
- || command.EndsAtUtc is not { } end
- || end <= start
- )
- {
- 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 = weekly
- ? channel.AddWeeklyOverride(
- command.Mode,
- command.DayOfWeek!.Value,
- command.StartMinute!.Value,
- command.EndMinute!.Value
- )
- : channel.AddOverride(
- command.Mode,
- command.StartsAtUtc!.Value,
- command.EndsAtUtc!.Value
- );
- 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
deleted file mode 100644
index 3e49836..0000000
--- a/backend/src/TeleWave.Application/Broadcast/CreateOverride/CreateProgrammingOverrideCommandValidator.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-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
deleted file mode 100644
index a1c475e..0000000
--- a/backend/src/TeleWave.Application/Broadcast/CreateOverride/OverrideShowInput.cs
+++ /dev/null
@@ -1,3 +0,0 @@
-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
deleted file mode 100644
index 7a4f60c..0000000
--- a/backend/src/TeleWave.Application/Broadcast/DeleteOverride/DeleteProgrammingOverrideCommand.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-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
deleted file mode 100644
index 3c068bd..0000000
--- a/backend/src/TeleWave.Application/Broadcast/DeleteOverride/DeleteProgrammingOverrideCommandHandler.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-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/GetChannelQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs
index 3936a8e..27aa129 100644
--- a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs
+++ b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs
@@ -13,67 +13,16 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
CancellationToken cancellationToken
)
{
+ // Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно
+ // (GetChannelTemplateQuery) — здесь только собственные свойства канала.
var channel = await dbContext
.Channels.AsNoTracking()
- .Include(c => c.Shows)
- .ThenInclude(s => s.PreferredHours)
- .Include(c => c.Ads)
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
- .Include(c => c.Overrides)
- .ThenInclude(o => o.Shows)
- .AsSplitQuery()
.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 poolAssetIds = channel.Ads.Select(a => a.MediaAssetId).Distinct().ToList();
- var assetNames = await dbContext
- .MediaAssets.AsNoTracking()
- .Where(a => poolAssetIds.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,
- s.PreferredWeightMultiplier,
- s.PreferredHours.OrderBy(h => h.StartHour)
- .Select(h => new HourWindowDto(h.StartHour, h.EndHour))
- .ToList()
- ))
- .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 bumperTemplates = channel
.BumperTemplates.OrderBy(t => t.Position)
.Select(t => new BumperTemplateDto(
@@ -105,32 +54,16 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
))
.ToList();
- var overrides = channel
- .Overrides.OrderBy(o => o.Recurrence)
- .ThenBy(o => o.StartsAtUtc)
- .ThenBy(o => o.DayOfWeek)
- .Select(o => new ProgrammingOverrideDto(
- o.Id,
- o.Mode,
- o.Recurrence,
- o.StartsAtUtc,
- o.EndsAtUtc,
- o.DayOfWeek,
- o.StartMinute,
- o.EndMinute,
- 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.Number,
+ channel.UtcOffsetMinutes,
+ channel.DayStartTime,
+ channel.TemplateId,
channel.BumpersEnabled,
new BumperSettingsDto(
channel.BumperFont,
@@ -140,10 +73,7 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
channel.BumperEpisodeChangeChance
),
bumperTemplates,
- channel.FillerAssetId,
- shows,
- ads,
- overrides
+ channel.FillerAssetId
)
);
}
diff --git a/backend/src/TeleWave.Application/Broadcast/RegenerateSchedule/RegenerateChannelScheduleCommand.cs b/backend/src/TeleWave.Application/Broadcast/RegenerateSchedule/RegenerateChannelScheduleCommand.cs
deleted file mode 100644
index 38f533a..0000000
--- a/backend/src/TeleWave.Application/Broadcast/RegenerateSchedule/RegenerateChannelScheduleCommand.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-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
deleted file mode 100644
index 8893b50..0000000
--- a/backend/src/TeleWave.Application/Broadcast/RegenerateSchedule/RegenerateChannelScheduleCommandHandler.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-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
deleted file mode 100644
index 979b0fc..0000000
--- a/backend/src/TeleWave.Application/Broadcast/RemoveChannelAd/RemoveChannelAdCommand.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-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
deleted file mode 100644
index 58430b8..0000000
--- a/backend/src/TeleWave.Application/Broadcast/RemoveChannelAd/RemoveChannelAdCommandHandler.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-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
deleted file mode 100644
index e963b31..0000000
--- a/backend/src/TeleWave.Application/Broadcast/RemoveChannelShow/RemoveChannelShowCommand.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-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
deleted file mode 100644
index 8d121bd..0000000
--- a/backend/src/TeleWave.Application/Broadcast/RemoveChannelShow/RemoveChannelShowCommandHandler.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-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/Scheduling/ScheduleBumperResolver.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleBumperResolver.cs
deleted file mode 100644
index d20c14f..0000000
--- a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleBumperResolver.cs
+++ /dev/null
@@ -1,206 +0,0 @@
-using System.Security.Cryptography;
-using System.Text;
-using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Options;
-using TeleWave.Application.Broadcast.Bumpers;
-using TeleWave.Application.Common.Interfaces;
-using TeleWave.Application.Streaming;
-using TeleWave.Domain.Broadcast;
-using TeleWave.Domain.Broadcast.Scheduling;
-using TeleWave.Domain.Media;
-
-namespace TeleWave.Application.Broadcast.Scheduling;
-
-///
-/// Резолвит ассеты ТВ-заставок для запланированных переходов: для каждой уникальной тройки
-/// «из→в→подблок» возвращает id ассета-заставки. Сам ffmpeg НЕ запускает — если готового (или уже
-/// рендерящегося) ассета в кэше нет, создаёт в статусе Pending, кэш-строку
-/// (с id канала/блока/подблока для восстановления спеки) и ставит в очередь
-/// фонового рендерера. Так тик планировщика не блокируется на ffmpeg, а его транзакция не держится во
-/// время рендера. До готовности ассета плейлист отдаёт филлер (см. GetLivePlaylistQueryHandler).
-///
-public sealed class ScheduleBumperResolver(
- IAppDbContext dbContext,
- IBumperRenderQueue renderQueue,
- IOptions bumperOptions,
- IOptions streamingOptions
-)
-{
- private readonly BumperOptions _bumper = bumperOptions.Value;
- private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
-
- ///
- /// Для каждой уникальной тройки «из→в→подблок» из запланированных заставок возвращает id ассета:
- /// готового/рендерящегося из кэша либо только что созданного Pending (поставлен в очередь рендера).
- ///
- public async Task> ResolveAsync(
- Channel channel,
- IReadOnlyList entries,
- IReadOnlyDictionary showNames,
- CancellationToken cancellationToken
- )
- {
- var result = new Dictionary<(Guid, Guid, Guid), Guid>();
- var variantsById = channel
- .BumperTemplates.SelectMany(t => t.Variants.Select(v => (Variant: v, Template: t)))
- .ToDictionary(x => x.Variant.Id);
- var combos = entries
- .Where(e =>
- e.Kind == ScheduleEntryKind.Bumper
- && e.FromShowId is not null
- && e.ToShowId is not null
- && e.BumperVariantId is not null
- )
- .Select(e =>
- (
- From: e.FromShowId!.Value,
- To: e.ToShowId!.Value,
- Variant: e.BumperVariantId!.Value
- )
- )
- .Distinct()
- .ToList();
- if (combos.Count == 0)
- return result;
-
- var fromIds = combos.Select(c => c.From).Distinct().ToList();
- var toIds = combos.Select(c => c.To).Distinct().ToList();
-
- // Постер шоу-получателя участвует в сигнатуре (как токен id) — грузим только id картинки;
- // абсолютный путь для рендера резолвит уже фоновый рендерер.
- var showIds = fromIds.Concat(toIds).Distinct().ToList();
- var posterImageByShow = await dbContext
- .Shows.AsNoTracking()
- .Where(s => showIds.Contains(s.Id) && s.PosterImageId != null)
- .Select(s => new { s.Id, ImageId = s.PosterImageId!.Value })
- .ToDictionaryAsync(s => s.Id, s => s.ImageId, cancellationToken);
-
- // Кэш заставок (новейшие первыми) + статусы их ассетов: Ready/Pending/Processing переиспользуем,
- // Failed — рендерим заново.
- var cached = await dbContext
- .BumperAssets.AsNoTracking()
- .Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId))
- .OrderByDescending(b => b.CreatedAt)
- .Select(b => new
- {
- b.FromShowId,
- b.ToShowId,
- b.Signature,
- b.MediaAssetId,
- })
- .ToListAsync(cancellationToken);
- var cachedAssetIds = cached.Select(c => c.MediaAssetId).Distinct().ToList();
- var statusById = await dbContext
- .MediaAssets.AsNoTracking()
- .Where(a => cachedAssetIds.Contains(a.Id))
- .Select(a => new { a.Id, a.Status })
- .ToDictionaryAsync(a => a.Id, a => a.Status, cancellationToken);
-
- foreach (var combo in combos)
- {
- if (result.ContainsKey(combo))
- continue;
- if (!variantsById.TryGetValue(combo.Variant, out var pair))
- continue;
- var (variant, template) = pair;
-
- var fromName = showNames.GetValueOrDefault(combo.From, "…");
- var toName = showNames.GetValueOrDefault(combo.To, "…");
- var usePoster = variant.Kind == BumperTextKind.NowNext;
- var posterToken =
- usePoster && posterImageByShow.TryGetValue(combo.To, out var pid)
- ? pid.ToString()
- : "-";
- var aligned = BumperDuration.Aligned(
- BumperDuration.TemplateSeconds(template),
- _segmentSeconds
- );
- var signature = ComputeSignature(
- channel,
- template,
- variant,
- fromName,
- toName,
- aligned,
- posterToken
- );
-
- // Новейшая кэш-строка с этой сигнатурой (список упорядочен по CreatedAt DESC).
- var hit = cached.FirstOrDefault(c =>
- c.FromShowId == combo.From && c.ToShowId == combo.To && c.Signature == signature
- );
- if (
- hit is not null
- && statusById.TryGetValue(hit.MediaAssetId, out var status)
- && status != MediaAssetStatus.Failed
- )
- {
- result[combo] = hit.MediaAssetId; // Ready / Pending / Processing — переиспользуем
- continue;
- }
-
- // Нет кэша либо прошлый рендер провалился — создаём Pending-ассет + кэш-строку, ставим в очередь.
- var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}");
- dbContext.MediaAssets.Add(asset);
- dbContext.BumperAssets.Add(
- BumperAsset.Create(
- channel.Id,
- template.Id,
- variant.Id,
- combo.From,
- combo.To,
- signature,
- asset.Id
- )
- );
- // Сигнал — лучший случай (строка станет видимой после коммита транзакции планировщика);
- // гарантия подхвата — периодический опрос БД фоновым рендерером.
- renderQueue.Enqueue(asset.Id);
- result[combo] = asset.Id;
- }
-
- return result;
- }
-
- ///
- /// Сигнатура рендера = хэш всех входов заставки: общие настройки канала (шрифт/подписи/версия
- /// шаблона), оформление и файлы блока (цвета/фон/звук/ревизия), названия шоу и постер. Меняется —
- /// заставка пересобирается. Разделитель — unit separator (U+001F), чтобы поля не слипались.
- ///
- private string ComputeSignature(
- Channel channel,
- BumperTemplate template,
- BumperTextVariant variant,
- string fromName,
- string toName,
- int alignedDurationSeconds,
- string poster
- )
- {
- var raw = string.Join(
- '',
- _bumper.TemplateVersion,
- _bumper.Width,
- _bumper.Height,
- alignedDurationSeconds,
- channel.BumperFont,
- variant.Kind,
- variant.NowLabel,
- variant.NextLabel,
- variant.Line1,
- variant.Line2,
- template.BackgroundColor,
- template.BackgroundColor2,
- template.AccentColor,
- template.TextColor,
- template.Revision,
- template.BackgroundImageId?.ToString() ?? "-",
- template.AudioExtension ?? "-",
- fromName,
- toName,
- poster
- );
- var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
- return Convert.ToHexString(hash);
- }
-}
diff --git a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs
deleted file mode 100644
index 4ad2a41..0000000
--- a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs
+++ /dev/null
@@ -1,304 +0,0 @@
-using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Options;
-using TeleWave.Application.Common.Interfaces;
-using TeleWave.Application.Streaming;
-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,
- ScheduleBumperResolver bumperResolver,
- IOptions options,
- IOptions streamingOptions
-)
-{
- private readonly SchedulerOptions _options = options.Value;
- private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
-
- ///
- /// Достраивает (или, при , перестраивает будущий хвост) расписание
- /// канала до горизонта. Возвращает число добавленных записей (-1 — канал не найден/выключен).
- ///
- public async Task GenerateAsync(
- Guid channelId,
- DateTimeOffset now,
- bool regenerate,
- CancellationToken cancellationToken
- )
- {
- var channel = await dbContext
- .Channels.Include(c => c.Shows)
- .ThenInclude(s => s.PreferredHours)
- .Include(c => c.Ads)
- .Include(c => c.BumperTemplates)
- .ThenInclude(t => t.Variants)
- .Include(c => c.Overrides)
- .ThenInclude(o => o.Shows)
- .AsSplitQuery()
- .FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
-
- if (channel is null || !channel.IsEnabled)
- return -1;
-
- // Сериализуем генерацию одного канала: фоновый тик и ручная перегенерация не должны читать
- // одну точку продолжения и оба дописывать хвост (иначе дубли/перекрытия записей). Advisory-lock
- // держится до коммита/отката транзакции ниже; при сбое рендера заставок весь прогон откатится.
- await using var transaction = await dbContext.BeginTransactionAsync(cancellationToken);
- await dbContext.AcquireChannelLockAsync(channelId, cancellationToken);
-
- 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);
- await transaction.CommitAsync(cancellationToken);
- return 0;
- }
-
- var showNames = await LoadShowNamesAsync(channel, cancellationToken);
- var input = await BuildInputAsync(channel, startTime, horizonEnd, cancellationToken);
- var result = SchedulePlanner.Plan(input, random);
-
- // Рендерим/достаём из кэша ассеты заставок для всех переходов плана (по паре шоу + подблоку).
- var bumperAssets = await bumperResolver.ResolveAsync(
- channel,
- result.Entries,
- showNames,
- cancellationToken
- );
-
- var added = 0;
- foreach (var entry in result.Entries)
- {
- ScheduleEntry? scheduleEntry = entry.Kind switch
- {
- ScheduleEntryKind.Program => ScheduleEntry.Program(
- channel.Id,
- entry.MediaAssetId,
- entry.StartsAtUtc,
- entry.EndsAtUtc,
- entry.ShowId!.Value,
- entry.EpisodeIndex!.Value
- ),
- ScheduleEntryKind.Ad => ScheduleEntry.Ad(
- channel.Id,
- entry.MediaAssetId,
- entry.StartsAtUtc,
- entry.EndsAtUtc
- ),
- ScheduleEntryKind.Bumper => BuildBumperEntry(channel.Id, entry, bumperAssets),
- _ => null,
- };
-
- if (scheduleEntry is null)
- continue;
-
- dbContext.ScheduleEntries.Add(scheduleEntry);
- added++;
- }
-
- foreach (var channelShow in channel.Shows)
- if (result.NextEpisodeIndexByChannelShow.TryGetValue(channelShow.Id, out var idx))
- channelShow.SetNextEpisodeIndex(idx);
-
- channel.SetNextAdIndex(result.NextAdIndex);
- channel.SetNextBumperIndex(result.NextBumperIndex);
-
- await dbContext.SaveChangesAsync(cancellationToken);
- await transaction.CommitAsync(cancellationToken);
- return added;
- }
-
- private static ScheduleEntry? BuildBumperEntry(
- Guid channelId,
- PlannedEntry entry,
- IReadOnlyDictionary<(Guid From, Guid To, Guid Variant), Guid> bumperAssets
- )
- {
- // Заставка резолвится по паре шоу + выбранному подблоку (отрендерена/из кэша). Если рендер не
- // удался — пропускаем запись (слот заполнит филлер/следующая программа; длину планировщик учёл).
- if (
- entry.FromShowId is not { } from
- || entry.ToShowId is not { } to
- || entry.BumperVariantId is not { } variant
- || !bumperAssets.TryGetValue((from, to, variant), out var assetId)
- )
- return null;
-
- return ScheduleEntry.Bumper(
- channelId,
- assetId,
- entry.StartsAtUtc,
- entry.EndsAtUtc,
- entry.ToShowId,
- entry.BumperVariantId
- );
- }
-
- private async Task> LoadShowNamesAsync(
- Channel channel,
- CancellationToken cancellationToken
- )
- {
- var showIds = channel.Shows.Select(s => s.ShowId).Distinct().ToList();
- if (showIds.Count == 0)
- return new Dictionary();
-
- return 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);
- }
-
- 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,
- channelShow
- .PreferredHours.Select(h => new PlannerHourWindow(h.StartHour, h.EndHour))
- .ToList(),
- channelShow.PreferredWeightMultiplier
- )
- );
- }
-
- var adPool = channel
- .Ads.OrderBy(a => a.Position)
- .Select(a => a.MediaAssetId)
- .Where(durations.ContainsKey)
- .ToList();
-
- // Подблоки заставок (плоский список): длительность слота — по звуку блока, выровнена на сегмент.
- var bumperVariants = channel
- .BumperTemplates.OrderBy(t => t.Position)
- .SelectMany(t =>
- {
- var dur = TimeSpan.FromSeconds(
- BumperDuration.Aligned(BumperDuration.TemplateSeconds(t), _segmentSeconds)
- );
- return t
- .Variants.OrderBy(v => v.Position)
- .Select(v => new PlannerBumperVariant(v.Id, t.Id, dur, v.Trigger, v.Weight));
- })
- .ToList();
-
- var overrides = channel
- .Overrides.Select(o => new PlannerOverride(
- o.Mode,
- o.Shows.Select(s => new PlannerOverrideShow(s.ShowId, s.Weight)).ToList(),
- o.Recurrence,
- o.StartsAtUtc,
- o.EndsAtUtc,
- o.DayOfWeek,
- o.StartMinute,
- o.EndMinute
- ))
- .ToList();
-
- var bumpers = new PlannerBumperConfig(
- channel.BumpersEnabled,
- TimeSpan.FromMinutes(channel.BumperMinIntervalMinutes),
- channel.BumperSelection,
- bumperVariants,
- channel.BumperShowChangeChance,
- channel.BumperEpisodeChangeChance
- );
-
- return new PlannerInput(
- channel.Id,
- channel.AdInsertion,
- channel.AdsPerBreak,
- channel.NextAdIndex,
- plannerShows,
- adPool,
- durations,
- overrides,
- startTime,
- horizonEnd,
- bumpers,
- channel.NextBumperIndex
- );
- }
-}
diff --git a/backend/src/TeleWave.Application/Broadcast/Scheduling/SchedulerOptions.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/SchedulerOptions.cs
index c7dcb50..27f98bc 100644
--- a/backend/src/TeleWave.Application/Broadcast/Scheduling/SchedulerOptions.cs
+++ b/backend/src/TeleWave.Application/Broadcast/Scheduling/SchedulerOptions.cs
@@ -1,15 +1,21 @@
-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;
-}
+namespace TeleWave.Application.Broadcast.Scheduling;
+
+public sealed class SchedulerOptions
+{
+ public const string SectionName = "Scheduler";
+
+ ///
+ /// На сколько дней вперёд держать материализованное расписание. Неделя — часть замысла:
+ /// «знать, что мультики будут в субботу в 9:30» работает, только если программа известна заранее.
+ ///
+ public int HorizonDays { get; init; } = 7;
+
+ ///
+ /// Сколько дней прошедшего расписания хранить. Должно покрывать самое долгое остывание среди правил
+ /// канала и самый глубокий повтор: история показов берётся из самой ленты, отдельного журнала нет.
+ ///
+ public int RetentionDays { get; init; } = 90;
+
+ /// Период тика фонового планировщика, минуты.
+ 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
index e31920d..b923620 100644
--- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs
+++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs
@@ -1,25 +1,23 @@
-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,
- bool BumpersEnabled,
- BumperSettingsInput Bumper,
- Guid? FillerAssetId
-) : ICommand;
-
-/// Общие настройки ТВ-заставок канала (см. Channel.UpdateBumperSettings).
-public sealed record BumperSettingsInput(
- BumperFont Font,
- int MinIntervalMinutes,
- BumperSelection Selection,
- double ShowChangeChance,
- double EpisodeChangeChance
-);
+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,
+ bool BumpersEnabled,
+ BumperSettingsInput Bumper,
+ Guid? FillerAssetId
+) : ICommand;
+
+/// Общие настройки ТВ-заставок канала (см. Channel.UpdateBumperSettings).
+public sealed record BumperSettingsInput(
+ BumperFont Font,
+ int MinIntervalMinutes,
+ BumperSelection Selection,
+ double ShowChangeChance,
+ double EpisodeChangeChance
+);
diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs
index c4b01cf..1df2f03 100644
--- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs
@@ -1,50 +1,48 @@
-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.BumpersEnabled,
- command.FillerAssetId
- );
- channel.UpdateBumperSettings(
- command.Bumper.Font,
- command.Bumper.MinIntervalMinutes,
- command.Bumper.Selection,
- command.Bumper.ShowChangeChance,
- command.Bumper.EpisodeChangeChance
- );
- return Result.Success();
- }
-}
+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.BumpersEnabled,
+ command.FillerAssetId
+ );
+ channel.UpdateBumperSettings(
+ command.Bumper.Font,
+ command.Bumper.MinIntervalMinutes,
+ command.Bumper.Selection,
+ command.Bumper.ShowChangeChance,
+ command.Bumper.EpisodeChangeChance
+ );
+ return Result.Success();
+ }
+}
diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs
index 21d5eb3..15941fb 100644
--- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs
+++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs
@@ -1,17 +1,16 @@
-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);
-
- RuleFor(x => x.Bumper.MinIntervalMinutes).InclusiveBetween(0, 1440);
- RuleFor(x => x.Bumper.ShowChangeChance).InclusiveBetween(0.0, 1.0);
- RuleFor(x => x.Bumper.EpisodeChangeChance).InclusiveBetween(0.0, 1.0);
- }
-}
+using FluentValidation;
+
+namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
+
+public sealed class UpdateChannelSettingsCommandValidator
+ : AbstractValidator
+{
+ public UpdateChannelSettingsCommandValidator()
+ {
+ RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
+
+ RuleFor(x => x.Bumper.MinIntervalMinutes).InclusiveBetween(0, 1440);
+ RuleFor(x => x.Bumper.ShowChangeChance).InclusiveBetween(0.0, 1.0);
+ RuleFor(x => x.Bumper.EpisodeChangeChance).InclusiveBetween(0.0, 1.0);
+ }
+}
diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommand.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommand.cs
deleted file mode 100644
index 8dc101f..0000000
--- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommand.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-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,
- int PreferredWeightMultiplier,
- IReadOnlyList PreferredHours
-) : ICommand;
-
-/// Окно предпочтительных часов [StartHour, EndHour) суток (UTC).
-public sealed record HourWindowInput(int StartHour, int EndHour);
diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandHandler.cs
deleted file mode 100644
index a026e19..0000000
--- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandHandler.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-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)
- .ThenInclude(s => s.PreferredHours)
- .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
- );
- channelShow.SetPreferredHours(
- command.PreferredWeightMultiplier,
- command.PreferredHours.Select(h => (h.StartHour, h.EndHour))
- );
- return Result.Success();
- }
-}
diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandValidator.cs
deleted file mode 100644
index 726b198..0000000
--- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandValidator.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-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);
- RuleFor(x => x.PreferredWeightMultiplier).InclusiveBetween(1, 100);
- RuleForEach(x => x.PreferredHours)
- .ChildRules(w =>
- {
- w.RuleFor(h => h.StartHour).InclusiveBetween(0, 23);
- w.RuleFor(h => h.EndHour).InclusiveBetween(1, 24);
- w.RuleFor(h => h)
- .Must(h => h.StartHour < h.EndHour)
- .WithMessage("Начало окна должно быть раньше конца.");
- });
- }
-}
diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelTime/UpdateChannelTimeCommand.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelTime/UpdateChannelTimeCommand.cs
new file mode 100644
index 0000000..8ffc6f7
--- /dev/null
+++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelTime/UpdateChannelTimeCommand.cs
@@ -0,0 +1,15 @@
+using LiteCqrs;
+using TeleWave.Application.Common.Models;
+
+namespace TeleWave.Application.Broadcast.UpdateChannelTime;
+
+///
+/// Номер канала и его время: смещение от UTC и начало вещательных суток. Отдельно от общих настроек
+/// канала, потому что те завязаны на старую ротацию и уйдут вместе с ней.
+///
+public sealed record UpdateChannelTimeCommand(
+ Guid ChannelId,
+ int? Number,
+ int UtcOffsetMinutes,
+ TimeOnly DayStartTime
+) : ICommand;
diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelTime/UpdateChannelTimeCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelTime/UpdateChannelTimeCommandHandler.cs
new file mode 100644
index 0000000..670523e
--- /dev/null
+++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelTime/UpdateChannelTimeCommandHandler.cs
@@ -0,0 +1,38 @@
+using LiteCqrs;
+using Microsoft.EntityFrameworkCore;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Application.Common.Models;
+
+namespace TeleWave.Application.Broadcast.UpdateChannelTime;
+
+public sealed class UpdateChannelTimeCommandHandler(IAppDbContext dbContext)
+ : ICommandHandler
+{
+ public async Task Handle(
+ UpdateChannelTimeCommand 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.Number is { } number)
+ {
+ var taken = await dbContext.Channels.AnyAsync(
+ c => c.Id != channel.Id && c.Number == number,
+ cancellationToken
+ );
+ if (taken)
+ return Result.Failure(ChannelErrors.NumberTaken);
+ }
+
+ channel.UpdateTimeSettings(command.Number, command.UtcOffsetMinutes, command.DayStartTime);
+ return Result.Success();
+ }
+}
diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs
index e6b5a3f..24f9844 100644
--- a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs
+++ b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs
@@ -1,34 +1,48 @@
-using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.Storage;
-using TeleWave.Domain.Auth;
-using TeleWave.Domain.Broadcast;
-using TeleWave.Domain.Images;
-using TeleWave.Domain.Library;
-using TeleWave.Domain.Media;
-using TeleWave.Domain.Settings;
-
-namespace TeleWave.Application.Common.Interfaces;
-
-public interface IAppDbContext
-{
- DbSet RefreshTokens { get; }
- DbSet MediaAssets { get; }
- DbSet Shows { get; }
- DbSet Channels { get; }
- DbSet ScheduleEntries { get; }
- DbSet BumperTextVariants { get; }
- DbSet BumperAssets { get; }
- DbSet AppSettings { get; }
- DbSet Images { get; }
-
- Task SaveChangesAsync(CancellationToken cancellationToken);
-
- /// Открывает явную транзакцию БД — для команд с несколькими операциями (в т.ч.
- /// ExecuteDelete в обход change-tracker), которые должны быть атомарны.
- Task BeginTransactionAsync(CancellationToken cancellationToken);
-
- /// Берёт транзакционную advisory-блокировку по каналу (снимается при коммите/откате).
- /// Сериализует генерацию расписания одного канала между фоновым тиком и ручной перегенерацией.
- /// Вызывать внутри открытой транзакции.
- Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken);
-}
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage;
+using TeleWave.Domain.Auth;
+using TeleWave.Domain.Broadcast;
+using TeleWave.Domain.Images;
+using TeleWave.Domain.Library;
+using TeleWave.Domain.Media;
+using TeleWave.Domain.Programming;
+using TeleWave.Domain.Settings;
+
+namespace TeleWave.Application.Common.Interfaces;
+
+public interface IAppDbContext
+{
+ DbSet RefreshTokens { get; }
+ DbSet MediaAssets { get; }
+ DbSet Shows { get; }
+ DbSet Genres { get; }
+ DbSet GenreAliases { get; }
+ DbSet ShowGenres { get; }
+ DbSet Collections { get; }
+ DbSet CollectionItems { get; }
+ DbSet Groups { get; }
+ DbSet GroupItems { get; }
+ DbSet ScheduleTemplates { get; }
+ DbSet GridLayers { get; }
+ DbSet Slots { get; }
+ DbSet SlotStates { get; }
+ DbSet JunctionTemplates { get; }
+ DbSet JunctionElements { get; }
+ DbSet Channels { get; }
+ DbSet ScheduleEntries { get; }
+ DbSet BumperTextVariants { get; }
+ DbSet BumperAssets { get; }
+ DbSet AppSettings { get; }
+ DbSet Images { get; }
+
+ Task SaveChangesAsync(CancellationToken cancellationToken);
+
+ /// Открывает явную транзакцию БД — для команд с несколькими операциями (в т.ч.
+ /// ExecuteDelete в обход change-tracker), которые должны быть атомарны.
+ Task BeginTransactionAsync(CancellationToken cancellationToken);
+
+ /// Берёт транзакционную advisory-блокировку по каналу (снимается при коммите/откате).
+ /// Сериализует генерацию расписания одного канала между фоновым тиком и ручной перегенерацией.
+ /// Вызывать внутри открытой транзакции.
+ Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken);
+}
diff --git a/backend/src/TeleWave.Application/Library/Collections/AddCollectionShow/AddCollectionShowCommand.cs b/backend/src/TeleWave.Application/Library/Collections/AddCollectionShow/AddCollectionShowCommand.cs
new file mode 100644
index 0000000..2ad2bac
--- /dev/null
+++ b/backend/src/TeleWave.Application/Library/Collections/AddCollectionShow/AddCollectionShowCommand.cs
@@ -0,0 +1,6 @@
+using LiteCqrs;
+using TeleWave.Application.Common.Models;
+
+namespace TeleWave.Application.Library.Collections.AddCollectionShow;
+
+public sealed record AddCollectionShowCommand(Guid CollectionId, Guid ShowId) : ICommand;
diff --git a/backend/src/TeleWave.Application/Library/Collections/AddCollectionShow/AddCollectionShowCommandHandler.cs b/backend/src/TeleWave.Application/Library/Collections/AddCollectionShow/AddCollectionShowCommandHandler.cs
new file mode 100644
index 0000000..ad721c9
--- /dev/null
+++ b/backend/src/TeleWave.Application/Library/Collections/AddCollectionShow/AddCollectionShowCommandHandler.cs
@@ -0,0 +1,29 @@
+using LiteCqrs;
+using Microsoft.EntityFrameworkCore;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Application.Common.Models;
+
+namespace TeleWave.Application.Library.Collections.AddCollectionShow;
+
+public sealed class AddCollectionShowCommandHandler(IAppDbContext dbContext)
+ : ICommandHandler