Update scheduling parameters and refactor channel endpoints: extend HorizonDays to 7 and RetentionDays to 90 in appsettings.json. Consolidate channel-related endpoint logic by removing obsolete files and enhancing the ShowEndpoints with audience and genre management capabilities. Improve error handling and streamline command handlers for channel operations.
build / backend (push) Successful in 7m40s
build / frontend (push) Failing after 39s
tests / backend-tests (push) Successful in 6m9s

This commit is contained in:
Leonid Pershin
2026-07-26 13:32:13 +03:00
parent c4ef954dea
commit 66040a8841
272 changed files with 27944 additions and 8699 deletions
@@ -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;
/// <summary>Эндпоинты временных override'ов / марафонов канала (разовые и еженедельные).</summary>
public static partial class ChannelEndpoints
{
private static async Task<IResult> CreateOverride(
Guid id,
CreateOverrideBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateProgrammingOverrideCommand(
id,
body.Mode,
body.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<IResult> 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<OverrideShowInput> Shows
);
@@ -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;
/// <summary>Эндпоинты канала: шоу в ротации и рекламный пул.</summary>
public static partial class ChannelEndpoints
{
private static async Task<IResult> AddShow(
Guid id,
AddChannelShowBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddChannelShowCommand(
id,
body.ShowId,
body.Weight,
body.BlockMode,
body.BlockValue
),
cancellationToken
);
return result.IsSuccess
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> UpdateShow(
Guid id,
Guid channelShowId,
UpdateChannelShowBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateChannelShowCommand(
id,
channelShowId,
body.Weight,
body.BlockMode,
body.BlockValue,
body.IsEnabled,
body.PreferredWeightMultiplier,
body.PreferredHours ?? []
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> RemoveShow(
Guid id,
Guid channelShowId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RemoveChannelShowCommand(id, channelShowId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> AddAd(
Guid id,
AddChannelAdBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddChannelAdCommand(id, body.MediaAssetId),
cancellationToken
);
return result.IsSuccess
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> RemoveAd(
Guid id,
Guid channelAdId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RemoveChannelAdCommand(id, channelAdId),
cancellationToken
);
return result.ToHttpResult();
}
}
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<HourWindowInput> PreferredHours
);
public sealed record AddChannelAdBody(Guid MediaAssetId);
@@ -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;
/// <summary>
/// Админ-эндпоинты канала. Реализация разнесена по partial-файлам под-ресурсов:
/// <c>ChannelEndpoints.Shows.cs</c> (шоу+реклама), <c>ChannelEndpoints.Bumpers.cs</c>
/// (блоки/подблоки/файлы/preview), <c>ChannelEndpoints.Overrides.cs</c> (override'ы). Здесь —
/// регистрация всех маршрутов и хендлеры уровня канала (создание/список/настройки/расписание).
/// </summary>
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<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListChannels).Produces<IReadOnlyList<ChannelSummaryDto>>();
admin.MapGet("/{id:guid}", GetChannel).Produces<ChannelDto>();
admin
.MapPut("/{id:guid}/settings", UpdateSettings)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/shows", AddShow)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/{id:guid}/shows/{channelShowId:guid}", UpdateShow)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/shows/{channelShowId:guid}", RemoveShow)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/ads", AddAd)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapDelete("/{id:guid}/ads/{channelAdId:guid}", RemoveAd)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
.Produces<CreatedIdResponse>(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<CreatedIdResponse>(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<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapDelete("/{id:guid}/overrides/{overrideId:guid}", DeleteOverride)
.Produces(StatusCodes.Status204NoContent);
admin.MapPost("/{id:guid}/regenerate", Regenerate).Produces(StatusCodes.Status204NoContent);
admin
.MapGet("/{id:guid}/schedule", GetSchedule)
.Produces<IReadOnlyList<ScheduleEntryDto>>();
return app;
}
private static async Task<IResult> CreateChannel(
CreateChannelCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/channels/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ListChannels(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListChannelsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetChannel(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetChannelQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateSettings(
Guid id,
UpdateChannelSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateChannelSettingsCommand(
id,
body.Name,
body.IsEnabled,
body.AdInsertion,
body.AdsPerBreak,
body.BumpersEnabled,
body.Bumper,
body.FillerAssetId
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Regenerate(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RegenerateChannelScheduleCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetSchedule(
Guid id,
DateTimeOffset? from,
DateTimeOffset? to,
ISender sender,
CancellationToken cancellationToken
)
{
var fromUtc = from ?? DateTimeOffset.UtcNow;
var toUtc = to ?? fromUtc.AddDays(1);
var result = await sender.Send(
new GetChannelScheduleQuery(id, fromUtc, toUtc),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record UpdateChannelSettingsBody(
string Name,
bool IsEnabled,
AdInsertion AdInsertion,
int AdsPerBreak,
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;
/// <summary>
/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки —
/// в <c>ChannelEndpoints.Bumpers.cs</c>. Что и когда идёт в эфире, задаёт шаблон сетки
/// (<c>TemplateEndpoints</c>).
/// </summary>
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<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListChannels).Produces<IReadOnlyList<ChannelSummaryDto>>();
admin.MapGet("/{id:guid}", GetChannel).Produces<ChannelDto>();
admin
.MapPut("/{id:guid}/settings", UpdateSettings)
.Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
.Produces<CreatedIdResponse>(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<CreatedIdResponse>(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<IReadOnlyList<ScheduleEntryDto>>();
return app;
}
private static async Task<IResult> CreateChannel(
CreateChannelCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/channels/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ListChannels(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListChannelsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetChannel(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetChannelQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> 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<IResult> 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<IResult> GetSchedule(
Guid id,
DateTimeOffset? from,
DateTimeOffset? to,
ISender sender,
CancellationToken cancellationToken
)
{
var fromUtc = from ?? DateTimeOffset.UtcNow;
var toUtc = to ?? fromUtc.AddDays(1);
var result = await sender.Send(
new GetChannelScheduleQuery(id, fromUtc, toUtc),
cancellationToken
);
return result.ToHttpResult();
}
}
/// <summary>Номер канала и его время: смещение от UTC и начало вещательных суток.</summary>
public sealed record UpdateChannelTimeBody(
int? Number,
int UtcOffsetMinutes,
TimeOnly DayStartTime
);
public sealed record UpdateChannelSettingsBody(
string Name,
bool IsEnabled,
bool BumpersEnabled,
BumperSettingsInput Bumper,
Guid? FillerAssetId
);
@@ -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<IReadOnlyList<CollectionSummaryDto>>();
admin.MapGet("/{id:guid}", GetCollection).Produces<CollectionDto>();
admin
.MapPost("", CreateCollection)
.Produces<CreatedIdResponse>(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<IResult> ListCollections(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListCollectionsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetCollection(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetCollectionQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> 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<IResult> 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<IResult> DeleteCollection(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteCollectionCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> 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<IResult> 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<IResult> 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<IResult> 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<Guid> ShowIdsInOrder);
public sealed record CollectionPosterBody(Guid? ImageId);
@@ -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<IReadOnlyList<GenreDto>>();
admin.MapPost("", CreateGenre).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPut("/{id:guid}", UpdateGenre).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteGenre).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListGenres(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListGenresQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> 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<IResult> 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<IResult> 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<string>? Aliases);
@@ -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<IReadOnlyList<GroupSummaryDto>>();
admin.MapGet("/{id:guid}", GetGroup).Produces<GroupDto>();
admin.MapPost("", CreateGroup).Produces<CreatedIdResponse>(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<IReadOnlyList<GroupCandidateDto>>();
admin.MapPost("/{id:guid}/items", AddElements).Produces<AddedCountResponse>();
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<IResult> ListGroups(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListGroupsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetGroup(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetGroupQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> 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<IResult> 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<IResult> DeleteGroup(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteGroupCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<GroupElementRef> Elements);
public sealed record GroupItemWeightBody(int Weight);
public sealed record ReorderGroupBody(IReadOnlyList<Guid> ItemIdsInOrder);
/// <summary>Сколько позиций реально добавлено (уже входящие в группу пропускаются).</summary>
public sealed record AddedCountResponse(int Added);
@@ -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;
/// <summary>
/// Шаблоны стыков канала: что играет между программами. Как и правка сетки, эфира не двигают —
/// помечают шаблон канала изменённым, а хвост пересобирается применением.
/// </summary>
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<IReadOnlyList<JunctionTemplateDto>>();
admin
.MapPost("/channels/{channelId:guid}/junctions", Create)
.Produces<CreatedIdResponse>(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<CreatedIdResponse>(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<IResult> List(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListJunctionsQuery(channelId), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> 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<IResult> 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<IResult> Delete(
Guid junctionId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteJunctionCommand(junctionId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> 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<IResult> 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<IResult> 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<IResult> 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<Guid> ElementIdsInOrder);
@@ -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<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>();
admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>();
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<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> CreateShow(
CreateShowCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/shows/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ListShows(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListShowsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetShow(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetShowQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> 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<IResult> 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<IResult> 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<IResult> DeleteShow(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteShowCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> AddEpisode(
Guid id,
AddEpisodeBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddEpisodeCommand(id, body.MediaAssetId),
cancellationToken
);
return result.IsSuccess
? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> RemoveEpisode(
Guid id,
Guid episodeId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record AddEpisodeBody(Guid MediaAssetId);
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<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>();
admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>();
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<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> CreateShow(
CreateShowCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/shows/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ListShows(
ISender sender,
CancellationToken cancellationToken,
Guid? genreId = null,
bool interstitials = false
)
{
var result = await sender.Send(new ListShowsQuery(genreId, interstitials), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetShow(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetShowQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> DeleteShow(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteShowCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> AddEpisode(
Guid id,
AddEpisodeBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddEpisodeCommand(id, body.MediaAssetId),
cancellationToken
);
return result.IsSuccess
? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> RemoveEpisode(
Guid id,
Guid episodeId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record AddEpisodeBody(Guid MediaAssetId);
public sealed record RenameShowBody(string Name);
public sealed record SetShowOriginalNameBody(string? OriginalName);
public sealed record SetShowAudienceBody(ShowAudience Audience);
public sealed record SetShowGenresBody(IReadOnlyList<Guid> GenreIds, Guid? PrimaryGenreId);
@@ -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;
/// <summary>
/// Сетка канала: шаблон, слои, слоты. Правка ничего не двигает в эфире — она помечает шаблон
/// изменённым, а хвост пересобирается отдельной командой применения.
/// </summary>
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<ScheduleTemplateDto>();
admin
.MapPut("/templates/{templateId:guid}", UpdateTemplate)
.Produces(StatusCodes.Status204NoContent);
// Применение правил к эфиру — отдельным действием: правка слотов эфир не двигает.
admin
.MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate)
.Produces<ApplyResultDto>();
admin
.MapPost("/templates/{templateId:guid}/layers", CreateLayer)
.Produces<CreatedIdResponse>(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<CreatedIdResponse>(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<IResult> GetTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetChannelTemplateQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ApplyTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ApplyChannelTemplateCommand(channelId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> 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<IResult> 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<IResult> 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<IResult> DeleteLayer(
Guid layerId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteLayerCommand(layerId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> 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<IResult> 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<IResult> 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
);
+147 -142
View File
@@ -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<long?>("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<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
foreach (
var proxy in builder
.Configuration.GetSection("ForwardedHeaders:KnownProxies")
.Get<string[]>()
?? []
)
options.KnownProxies.Add(IPAddress.Parse(proxy));
foreach (
var network in builder
.Configuration.GetSection("ForwardedHeaders:KnownNetworks")
.Get<string[]>()
?? []
)
{
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<AppDbContext>();
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();
/// <summary>Делает неявный класс Program доступным для WebApplicationFactory&lt;Program&gt; в интеграционных тестах.</summary>
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<long?>("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<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
foreach (
var proxy in builder
.Configuration.GetSection("ForwardedHeaders:KnownProxies")
.Get<string[]>()
?? []
)
options.KnownProxies.Add(IPAddress.Parse(proxy));
foreach (
var network in builder
.Configuration.GetSection("ForwardedHeaders:KnownNetworks")
.Get<string[]>()
?? []
)
{
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<AppDbContext>();
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();
/// <summary>Делает неявный класс Program доступным для WebApplicationFactory&lt;Program&gt; в интеграционных тестах.</summary>
public partial class Program;
+2 -2
View File
@@ -21,8 +21,8 @@
"MinFreeSpaceBytes": 10737418240
},
"Scheduler": {
"HorizonDays": 3,
"RetentionHours": 24,
"HorizonDays": 7,
"RetentionDays": 90,
"TickMinutes": 30
},
"Media": {
@@ -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<Result<Guid>>;
@@ -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<AddChannelAdCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddChannelAdCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.Ads)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<Guid>(ChannelErrors.NotFound);
var assetExists = await dbContext.MediaAssets.AnyAsync(
a => a.Id == command.MediaAssetId,
cancellationToken
);
if (!assetExists)
return Result.Failure<Guid>(ChannelErrors.AssetNotFound);
if (channel.HasAd(command.MediaAssetId))
return Result.Failure<Guid>(ChannelErrors.AdAlreadyAdded);
var ad = channel.AddAd(command.MediaAssetId);
return Result.Success(ad.Id);
}
}
@@ -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<Result<Guid>>;
@@ -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<AddChannelShowCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddChannelShowCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.Shows)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<Guid>(ChannelErrors.NotFound);
var showExists = await dbContext.Shows.AnyAsync(
s => s.Id == command.ShowId,
cancellationToken
);
if (!showExists)
return Result.Failure<Guid>(ChannelErrors.ShowNotFound);
if (channel.HasShow(command.ShowId))
return Result.Failure<Guid>(ChannelErrors.ShowAlreadyAdded);
var channelShow = channel.AddShow(
command.ShowId,
command.Weight,
command.BlockMode,
command.BlockValue
);
return Result.Success(channelShow.Id);
}
}
@@ -1,12 +0,0 @@
using FluentValidation;
namespace TeleWave.Application.Broadcast.AddChannelShow;
public sealed class AddChannelShowCommandValidator : AbstractValidator<AddChannelShowCommand>
{
public AddChannelShowCommandValidator()
{
RuleFor(x => x.Weight).InclusiveBetween(1, 1000);
RuleFor(x => x.BlockValue).InclusiveBetween(1, 10000);
}
}
@@ -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> bumperOptions,
IOptions<StreamingOptions> streamingOptions
) : ICommandHandler<RenderBumperPreviewCommand, Result>
{
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary>
private const int DefaultBumperDurationSeconds = 8;
public async Task<Result> 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();
}
/// <summary>Примерные названия «из/в» — берём первые два шоу канала, иначе заглушки.</summary>
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> bumperOptions,
IOptions<StreamingOptions> streamingOptions
) : ICommandHandler<RenderBumperPreviewCommand, Result>
{
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary>
private const int DefaultBumperDurationSeconds = 8;
public async Task<Result> 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();
}
/// <summary>
/// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала:
/// так превью показывает реальные названия этого канала, а не случайные из библиотеки.
/// </summary>
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) ?? "Второе шоу"
);
}
}
@@ -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<HourWindowDto> PreferredHours
);
/// <summary>Окно предпочтительных часов [StartHour, EndHour) суток (UTC).</summary>
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<OverrideShowDto> Shows
);
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).</summary>
public sealed record BumperSettingsDto(
BumperFont Font,
int MinIntervalMinutes,
BumperSelection Selection,
double ShowChangeChance,
double EpisodeChangeChance
);
/// <summary>Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.</summary>
public sealed record BumperTextVariantDto(
Guid Id,
int Position,
string Name,
BumperTextKind Kind,
string NowLabel,
string NextLabel,
string Line1,
string Line2,
BumperTrigger Trigger,
int Weight
);
/// <summary>Блок заставки: своё оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
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<BumperTextVariantDto> Variants
);
public sealed record ChannelDto(
Guid Id,
string Name,
string Slug,
bool IsEnabled,
AdInsertion AdInsertion,
int AdsPerBreak,
bool BumpersEnabled,
BumperSettingsDto Bumper,
IReadOnlyList<BumperTemplateDto> BumperTemplates,
Guid? FillerAssetId,
IReadOnlyList<ChannelShowDto> Shows,
IReadOnlyList<ChannelAdDto> Ads,
IReadOnlyList<ProgrammingOverrideDto> Overrides
);
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast;
public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled);
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).</summary>
public sealed record BumperSettingsDto(
BumperFont Font,
int MinIntervalMinutes,
BumperSelection Selection,
double ShowChangeChance,
double EpisodeChangeChance
);
/// <summary>Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.</summary>
public sealed record BumperTextVariantDto(
Guid Id,
int Position,
string Name,
BumperTextKind Kind,
string NowLabel,
string NextLabel,
string Line1,
string Line2,
BumperTrigger Trigger,
int Weight
);
/// <summary>Блок заставки: своё оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
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<BumperTextVariantDto> 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<BumperTemplateDto> BumperTemplates,
Guid? FillerAssetId
);
@@ -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",
"Недопустимый файл заставки (формат или размер)."
);
}
@@ -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<Guid>(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);
}
}
@@ -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<OverrideShowInput> Shows
) : ICommand<Result<Guid>>;
@@ -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<CreateProgrammingOverrideCommand, Result<Guid>>
{
public async Task<Result<Guid>> 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<Guid>(ChannelErrors.InvalidOverrideWindow);
}
else if (
command.StartsAtUtc is not { } start
|| command.EndsAtUtc is not { } end
|| end <= start
)
{
return Result.Failure<Guid>(ChannelErrors.InvalidOverrideWindow);
}
if (command.Shows.Count == 0)
return Result.Failure<Guid>(ChannelErrors.OverrideNeedsShow);
var channel = await dbContext
.Channels.Include(c => c.Overrides)
.ThenInclude(o => o.Shows)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<Guid>(ChannelErrors.NotFound);
var showIds = command.Shows.Select(s => s.ShowId).Distinct().ToList();
var existingCount = await dbContext.Shows.CountAsync(
s => showIds.Contains(s.Id),
cancellationToken
);
if (existingCount != showIds.Count)
return Result.Failure<Guid>(ChannelErrors.ShowNotFound);
var ovr = 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);
}
}
@@ -1,14 +0,0 @@
using FluentValidation;
namespace TeleWave.Application.Broadcast.CreateOverride;
public sealed class CreateProgrammingOverrideCommandValidator
: AbstractValidator<CreateProgrammingOverrideCommand>
{
public CreateProgrammingOverrideCommandValidator()
{
RuleFor(x => x.Shows).NotEmpty();
RuleForEach(x => x.Shows)
.ChildRules(s => s.RuleFor(i => i.Weight).InclusiveBetween(1, 1000));
}
}
@@ -1,3 +0,0 @@
namespace TeleWave.Application.Broadcast.CreateOverride;
public sealed record OverrideShowInput(Guid ShowId, int Weight);
@@ -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<Result>;
@@ -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<DeleteProgrammingOverrideCommand, Result>
{
public async Task<Result> Handle(
DeleteProgrammingOverrideCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.Overrides)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
return channel.RemoveOverride(command.OverrideId)
? Result.Success()
: Result.Failure(ChannelErrors.OverrideNotFound);
}
}
@@ -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<ChannelDto>(ChannelErrors.NotFound);
var showIds = channel
.Shows.Select(s => s.ShowId)
.Concat(channel.Overrides.SelectMany(o => o.Shows.Select(s => s.ShowId)))
.Distinct()
.ToList();
var showNames = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new { s.Id, s.Name })
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
var 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
)
);
}
@@ -1,6 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.RegenerateSchedule;
public sealed record RegenerateChannelScheduleCommand(Guid ChannelId) : ICommand<Result>;
@@ -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<RegenerateChannelScheduleCommand, Result>
{
public async Task<Result> Handle(
RegenerateChannelScheduleCommand command,
CancellationToken cancellationToken
)
{
var exists = await dbContext.Channels.AnyAsync(
c => c.Id == command.ChannelId,
cancellationToken
);
if (!exists)
return Result.Failure(ChannelErrors.NotFound);
// Генератор сам сохраняет изменения (удаление хвоста + новые записи + курсоры).
await generator.GenerateAsync(
command.ChannelId,
DateTimeOffset.UtcNow,
regenerate: true,
cancellationToken
);
return Result.Success();
}
}
@@ -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<Result>;
@@ -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<RemoveChannelAdCommand, Result>
{
public async Task<Result> Handle(
RemoveChannelAdCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.Ads)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
return channel.RemoveAd(command.ChannelAdId)
? Result.Success()
: Result.Failure(ChannelErrors.AdNotFound);
}
}
@@ -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<Result>;
@@ -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<RemoveChannelShowCommand, Result>
{
public async Task<Result> Handle(
RemoveChannelShowCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.Shows)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
return channel.RemoveShow(command.ChannelShowId)
? Result.Success()
: Result.Failure(ChannelErrors.ChannelShowNotFound);
}
}
@@ -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;
/// <summary>
/// Резолвит ассеты ТВ-заставок для запланированных переходов: для каждой уникальной тройки
/// «из→в→подблок» возвращает id ассета-заставки. Сам ffmpeg НЕ запускает — если готового (или уже
/// рендерящегося) ассета в кэше нет, создаёт <see cref="MediaAsset"/> в статусе Pending, кэш-строку
/// <see cref="BumperAsset"/> (с id канала/блока/подблока для восстановления спеки) и ставит в очередь
/// фонового рендерера. Так тик планировщика не блокируется на ffmpeg, а его транзакция не держится во
/// время рендера. До готовности ассета плейлист отдаёт филлер (см. GetLivePlaylistQueryHandler).
/// </summary>
public sealed class ScheduleBumperResolver(
IAppDbContext dbContext,
IBumperRenderQueue renderQueue,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions
)
{
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary>
/// Для каждой уникальной тройки «из→в→подблок» из запланированных заставок возвращает id ассета:
/// готового/рендерящегося из кэша либо только что созданного Pending (поставлен в очередь рендера).
/// </summary>
public async Task<Dictionary<(Guid From, Guid To, Guid Variant), Guid>> ResolveAsync(
Channel channel,
IReadOnlyList<PlannedEntry> entries,
IReadOnlyDictionary<Guid, string> 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;
}
/// <summary>
/// Сигнатура рендера = хэш всех входов заставки: общие настройки канала (шрифт/подписи/версия
/// шаблона), оформление и файлы блока (цвета/фон/звук/ревизия), названия шоу и постер. Меняется —
/// заставка пересобирается. Разделитель — unit separator (U+001F), чтобы поля не слипались.
/// </summary>
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);
}
}
@@ -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;
/// <summary>
/// Оркестратор планирования: загружает конфигурацию канала и готовые ассеты, вызывает чистый
/// <see cref="SchedulePlanner"/>, материализует записи и двигает курсоры. Используется фоновым
/// планировщиком (расширение горизонта) и командой перегенерации (правка конфигурации канала).
/// Ассеты заставок-переходов резолвит/рендерит <see cref="ScheduleBumperResolver"/> (общий контекст).
/// </summary>
public sealed class ScheduleGenerator(
IAppDbContext dbContext,
IRandomSource random,
ScheduleBumperResolver bumperResolver,
IOptions<SchedulerOptions> options,
IOptions<StreamingOptions> streamingOptions
)
{
private readonly SchedulerOptions _options = options.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary>
/// Достраивает (или, при <paramref name="regenerate"/>, перестраивает будущий хвост) расписание
/// канала до горизонта. Возвращает число добавленных записей (-1 — канал не найден/выключен).
/// </summary>
public async Task<int> GenerateAsync(
Guid channelId,
DateTimeOffset now,
bool regenerate,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.Shows)
.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<Dictionary<Guid, string>> LoadShowNamesAsync(
Channel channel,
CancellationToken cancellationToken
)
{
var showIds = channel.Shows.Select(s => s.ShowId).Distinct().ToList();
if (showIds.Count == 0)
return new Dictionary<Guid, string>();
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<PlannerInput> BuildInputAsync(
Channel channel,
DateTimeOffset startTime,
DateTimeOffset horizonEnd,
CancellationToken cancellationToken
)
{
var enabledShows = channel.Shows.Where(s => s.IsEnabled).ToList();
var showIds = enabledShows.Select(s => s.ShowId).Distinct().ToList();
var shows = await dbContext
.Shows.Include(s => s.Episodes)
.Where(s => showIds.Contains(s.Id))
.ToListAsync(cancellationToken);
var episodesByShow = shows.ToDictionary(
s => s.Id,
s => s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).ToList()
);
var candidateAssetIds = episodesByShow
.Values.SelectMany(x => x)
.Concat(channel.Ads.Select(a => a.MediaAssetId))
.Distinct()
.ToList();
var durations = await dbContext
.MediaAssets.Where(a =>
candidateAssetIds.Contains(a.Id)
&& a.Status == MediaAssetStatus.Ready
&& a.Duration != null
)
.Select(a => new { a.Id, a.Duration })
.ToDictionaryAsync(x => x.Id, x => x.Duration!.Value, cancellationToken);
var plannerShows = new List<PlannerShow>();
foreach (var channelShow in enabledShows)
{
if (!episodesByShow.TryGetValue(channelShow.ShowId, out var episodeIds))
continue;
var ready = episodeIds.Where(durations.ContainsKey).ToList();
if (ready.Count == 0)
continue;
plannerShows.Add(
new PlannerShow(
channelShow.Id,
channelShow.ShowId,
channelShow.Weight,
channelShow.BlockMode,
channelShow.BlockValue,
ready,
channelShow.NextEpisodeIndex,
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
);
}
}
@@ -1,15 +1,21 @@
namespace TeleWave.Application.Broadcast.Scheduling;
public sealed class SchedulerOptions
{
public const string SectionName = "Scheduler";
/// <summary>На сколько дней вперёд держать материализованное расписание.</summary>
public int HorizonDays { get; init; } = 3;
/// <summary>Сколько часов прошедшего расписания хранить (для EPG «что было»), затем чистить.</summary>
public int RetentionHours { get; init; } = 24;
/// <summary>Период тика фонового планировщика, минуты.</summary>
public int TickMinutes { get; init; } = 30;
}
namespace TeleWave.Application.Broadcast.Scheduling;
public sealed class SchedulerOptions
{
public const string SectionName = "Scheduler";
/// <summary>
/// На сколько дней вперёд держать материализованное расписание. Неделя — часть замысла:
/// «знать, что мультики будут в субботу в 9:30» работает, только если программа известна заранее.
/// </summary>
public int HorizonDays { get; init; } = 7;
/// <summary>
/// Сколько дней прошедшего расписания хранить. Должно покрывать самое долгое остывание среди правил
/// канала и самый глубокий повтор: история показов берётся из самой ленты, отдельного журнала нет.
/// </summary>
public int RetentionDays { get; init; } = 90;
/// <summary>Период тика фонового планировщика, минуты.</summary>
public int TickMinutes { get; init; } = 30;
}
@@ -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<Result>;
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>).</summary>
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<Result>;
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>).</summary>
public sealed record BumperSettingsInput(
BumperFont Font,
int MinIntervalMinutes,
BumperSelection Selection,
double ShowChangeChance,
double EpisodeChangeChance
);
@@ -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<UpdateChannelSettingsCommand, Result>
{
public async Task<Result> Handle(
UpdateChannelSettingsCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels.FirstOrDefaultAsync(
c => c.Id == command.ChannelId,
cancellationToken
);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
if (command.FillerAssetId is { } fillerId)
{
var exists = await dbContext.MediaAssets.AnyAsync(
a => a.Id == fillerId,
cancellationToken
);
if (!exists)
return Result.Failure(ChannelErrors.AssetNotFound);
}
channel.UpdateSettings(
command.Name,
command.IsEnabled,
command.AdInsertion,
command.AdsPerBreak,
command.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<UpdateChannelSettingsCommand, Result>
{
public async Task<Result> Handle(
UpdateChannelSettingsCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels.FirstOrDefaultAsync(
c => c.Id == command.ChannelId,
cancellationToken
);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
if (command.FillerAssetId is { } fillerId)
{
var exists = await dbContext.MediaAssets.AnyAsync(
a => a.Id == fillerId,
cancellationToken
);
if (!exists)
return Result.Failure(ChannelErrors.AssetNotFound);
}
channel.UpdateSettings(
command.Name,
command.IsEnabled,
command.BumpersEnabled,
command.FillerAssetId
);
channel.UpdateBumperSettings(
command.Bumper.Font,
command.Bumper.MinIntervalMinutes,
command.Bumper.Selection,
command.Bumper.ShowChangeChance,
command.Bumper.EpisodeChangeChance
);
return Result.Success();
}
}
@@ -1,17 +1,16 @@
using FluentValidation;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed class UpdateChannelSettingsCommandValidator
: AbstractValidator<UpdateChannelSettingsCommand>
{
public UpdateChannelSettingsCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.AdsPerBreak).InclusiveBetween(0, 10);
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<UpdateChannelSettingsCommand>
{
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);
}
}
@@ -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<HourWindowInput> PreferredHours
) : ICommand<Result>;
/// <summary>Окно предпочтительных часов [StartHour, EndHour) суток (UTC).</summary>
public sealed record HourWindowInput(int StartHour, int EndHour);
@@ -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<UpdateChannelShowCommand, Result>
{
public async Task<Result> 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();
}
}
@@ -1,22 +0,0 @@
using FluentValidation;
namespace TeleWave.Application.Broadcast.UpdateChannelShow;
public sealed class UpdateChannelShowCommandValidator : AbstractValidator<UpdateChannelShowCommand>
{
public UpdateChannelShowCommandValidator()
{
RuleFor(x => x.Weight).InclusiveBetween(1, 1000);
RuleFor(x => x.BlockValue).InclusiveBetween(1, 10000);
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("Начало окна должно быть раньше конца.");
});
}
}
@@ -0,0 +1,15 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.UpdateChannelTime;
/// <summary>
/// Номер канала и его время: смещение от UTC и начало вещательных суток. Отдельно от общих настроек
/// канала, потому что те завязаны на старую ротацию и уйдут вместе с ней.
/// </summary>
public sealed record UpdateChannelTimeCommand(
Guid ChannelId,
int? Number,
int UtcOffsetMinutes,
TimeOnly DayStartTime
) : ICommand<Result>;
@@ -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<UpdateChannelTimeCommand, Result>
{
public async Task<Result> 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();
}
}
@@ -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<RefreshToken> RefreshTokens { get; }
DbSet<MediaAsset> MediaAssets { get; }
DbSet<Show> Shows { get; }
DbSet<Channel> Channels { get; }
DbSet<ScheduleEntry> ScheduleEntries { get; }
DbSet<BumperTextVariant> BumperTextVariants { get; }
DbSet<BumperAsset> BumperAssets { get; }
DbSet<AppSetting> AppSettings { get; }
DbSet<Image> Images { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
/// <summary>Открывает явную транзакцию БД — для команд с несколькими операциями (в т.ч.
/// <c>ExecuteDelete</c> в обход change-tracker), которые должны быть атомарны.</summary>
Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken);
/// <summary>Берёт транзакционную advisory-блокировку по каналу (снимается при коммите/откате).
/// Сериализует генерацию расписания одного канала между фоновым тиком и ручной перегенерацией.
/// Вызывать внутри открытой транзакции.</summary>
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<RefreshToken> RefreshTokens { get; }
DbSet<MediaAsset> MediaAssets { get; }
DbSet<Show> Shows { get; }
DbSet<Genre> Genres { get; }
DbSet<GenreAlias> GenreAliases { get; }
DbSet<ShowGenre> ShowGenres { get; }
DbSet<Collection> Collections { get; }
DbSet<CollectionItem> CollectionItems { get; }
DbSet<Group> Groups { get; }
DbSet<GroupItem> GroupItems { get; }
DbSet<ScheduleTemplate> ScheduleTemplates { get; }
DbSet<GridLayer> GridLayers { get; }
DbSet<Slot> Slots { get; }
DbSet<SlotState> SlotStates { get; }
DbSet<JunctionTemplate> JunctionTemplates { get; }
DbSet<JunctionElement> JunctionElements { get; }
DbSet<Channel> Channels { get; }
DbSet<ScheduleEntry> ScheduleEntries { get; }
DbSet<BumperTextVariant> BumperTextVariants { get; }
DbSet<BumperAsset> BumperAssets { get; }
DbSet<AppSetting> AppSettings { get; }
DbSet<Image> Images { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
/// <summary>Открывает явную транзакцию БД — для команд с несколькими операциями (в т.ч.
/// <c>ExecuteDelete</c> в обход change-tracker), которые должны быть атомарны.</summary>
Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken);
/// <summary>Берёт транзакционную advisory-блокировку по каналу (снимается при коммите/откате).
/// Сериализует генерацию расписания одного канала между фоновым тиком и ручной перегенерацией.
/// Вызывать внутри открытой транзакции.</summary>
Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken);
}
@@ -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<Result>;
@@ -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<AddCollectionShowCommand, Result>
{
public async Task<Result> Handle(
AddCollectionShowCommand command,
CancellationToken cancellationToken
)
{
var collection = await dbContext
.Collections.Include(c => c.Items)
.FirstOrDefaultAsync(c => c.Id == command.CollectionId, cancellationToken);
if (collection is null)
return Result.Failure(CollectionErrors.NotFound);
if (!await dbContext.Shows.AnyAsync(s => s.Id == command.ShowId, cancellationToken))
return Result.Failure(ShowErrors.NotFound);
return collection.AddShow(command.ShowId) is null
? Result.Failure(CollectionErrors.ShowAlreadyAdded)
: Result.Success();
}
}
@@ -0,0 +1,39 @@
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.Collections;
/// <summary>Коллекция в списке: без состава, но со сводкой — сколько частей и сколько в них единиц
/// воспроизведения (у сериала внутри коллекции их больше одной).</summary>
public sealed record CollectionSummaryDto(
Guid Id,
string Name,
string? Description,
Guid? PosterImageId,
int ItemCount,
int UnitCount,
DateTimeOffset CreatedAt
);
/// <summary>Позиция коллекции с данными шоу — чтобы список правился без второго запроса.</summary>
public sealed record CollectionItemDto(
Guid ShowId,
int Position,
string ShowName,
ShowKind ShowKind,
ShowAudience ShowAudience,
int EpisodeCount,
int? Year,
Guid? PosterImageId
);
public sealed record CollectionDto(
Guid Id,
string Name,
string? Description,
Guid? PosterImageId,
DateTimeOffset CreatedAt,
IReadOnlyList<CollectionItemDto> Items
);
/// <summary>Коллекция, в которую входит шоу — для блока «входит в коллекции» на экране шоу.</summary>
public sealed record ShowCollectionRefDto(Guid Id, string Name, int Position);
@@ -0,0 +1,21 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections;
public static class CollectionErrors
{
public static readonly Error NotFound = Error.NotFound(
"Collections.NotFound",
"Коллекция не найдена."
);
public static readonly Error ShowAlreadyAdded = Error.Conflict(
"Collections.ShowAlreadyAdded",
"Это шоу уже входит в коллекцию."
);
public static readonly Error ShowNotInCollection = Error.NotFound(
"Collections.ShowNotInCollection",
"Шоу не входит в коллекцию."
);
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.CreateCollection;
public sealed record CreateCollectionCommand(string Name, string? Description = null)
: ICommand<Result<Guid>>;
@@ -0,0 +1,20 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.Collections.CreateCollection;
public sealed class CreateCollectionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateCollectionCommand, Result<Guid>>
{
public Task<Result<Guid>> Handle(
CreateCollectionCommand command,
CancellationToken cancellationToken
)
{
var collection = Collection.Create(command.Name, command.Description);
dbContext.Collections.Add(collection);
return Task.FromResult(Result.Success(collection.Id));
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace TeleWave.Application.Library.Collections.CreateCollection;
public sealed class CreateCollectionCommandValidator : AbstractValidator<CreateCollectionCommand>
{
public CreateCollectionCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Description).MaximumLength(2048);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.DeleteCollection;
public sealed record DeleteCollectionCommand(Guid CollectionId) : ICommand<Result>;
@@ -0,0 +1,37 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Groups;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Library.Collections.DeleteCollection;
public sealed class DeleteCollectionCommandHandler(
IAppDbContext dbContext,
GroupMembershipCleaner groupCleaner
) : ICommandHandler<DeleteCollectionCommand, Result>
{
public async Task<Result> Handle(
DeleteCollectionCommand command,
CancellationToken cancellationToken
)
{
var collection = await dbContext.Collections.FirstOrDefaultAsync(
c => c.Id == command.CollectionId,
cancellationToken
);
if (collection is null)
return Result.Failure(CollectionErrors.NotFound);
// Ссылка группы полиморфна — каскад БД её не снимет.
await groupCleaner.RemoveElementAsync(
GroupElementKind.Collection,
command.CollectionId,
cancellationToken
);
dbContext.Collections.Remove(collection);
return Result.Success();
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.GetCollection;
public sealed record GetCollectionQuery(Guid Id) : IQuery<Result<CollectionDto>>;
@@ -0,0 +1,68 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.GetCollection;
public sealed class GetCollectionQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetCollectionQuery, Result<CollectionDto>>
{
public async Task<Result<CollectionDto>> Handle(
GetCollectionQuery query,
CancellationToken cancellationToken
)
{
var collection = await dbContext
.Collections.AsNoTracking()
.Include(c => c.Items)
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
if (collection is null)
return Result.Failure<CollectionDto>(CollectionErrors.NotFound);
var showIds = collection.Items.Select(i => i.ShowId).ToList();
var shows = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
s.Name,
s.Kind,
s.Audience,
s.Year,
s.PosterImageId,
EpisodeCount = s.Episodes.Count,
})
.ToDictionaryAsync(s => s.Id, cancellationToken);
var items = collection
.Items.OrderBy(i => i.Position)
.Select(i =>
{
shows.TryGetValue(i.ShowId, out var show);
return new CollectionItemDto(
i.ShowId,
i.Position,
show?.Name ?? "—",
show?.Kind ?? default,
show?.Audience ?? default,
show?.EpisodeCount ?? 0,
show?.Year,
show?.PosterImageId
);
})
.ToList();
return Result.Success(
new CollectionDto(
collection.Id,
collection.Name,
collection.Description,
collection.PosterImageId,
collection.CreatedAt,
items
)
);
}
}
@@ -0,0 +1,5 @@
using LiteCqrs;
namespace TeleWave.Application.Library.Collections.ListCollections;
public sealed record ListCollectionsQuery : IQuery<IReadOnlyList<CollectionSummaryDto>>;
@@ -0,0 +1,42 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Library.Collections.ListCollections;
public sealed class ListCollectionsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListCollectionsQuery, IReadOnlyList<CollectionSummaryDto>>
{
public async Task<IReadOnlyList<CollectionSummaryDto>> Handle(
ListCollectionsQuery query,
CancellationToken cancellationToken
)
{
var collections = await dbContext
.Collections.AsNoTracking()
.Include(c => c.Items)
.OrderBy(c => c.Name)
.ToListAsync(cancellationToken);
// Единиц воспроизведения может быть больше, чем частей: сериал внутри коллекции
// разворачивается в свои серии.
var showIds = collections.SelectMany(c => c.Items.Select(i => i.ShowId)).Distinct().ToList();
var episodeCounts = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new { s.Id, Count = s.Episodes.Count })
.ToDictionaryAsync(x => x.Id, x => x.Count, cancellationToken);
return collections
.Select(c => new CollectionSummaryDto(
c.Id,
c.Name,
c.Description,
c.PosterImageId,
c.Items.Count,
c.Items.Sum(i => episodeCounts.TryGetValue(i.ShowId, out var n) ? n : 0),
c.CreatedAt
))
.ToList();
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.RemoveCollectionShow;
public sealed record RemoveCollectionShowCommand(Guid CollectionId, Guid ShowId) : ICommand<Result>;
@@ -0,0 +1,26 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.RemoveCollectionShow;
public sealed class RemoveCollectionShowCommandHandler(IAppDbContext dbContext)
: ICommandHandler<RemoveCollectionShowCommand, Result>
{
public async Task<Result> Handle(
RemoveCollectionShowCommand command,
CancellationToken cancellationToken
)
{
var collection = await dbContext
.Collections.Include(c => c.Items)
.FirstOrDefaultAsync(c => c.Id == command.CollectionId, cancellationToken);
if (collection is null)
return Result.Failure(CollectionErrors.NotFound);
return collection.RemoveShow(command.ShowId)
? Result.Success()
: Result.Failure(CollectionErrors.ShowNotInCollection);
}
}
@@ -0,0 +1,9 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.ReorderCollection;
/// <summary>Переставляет части коллекции в порядке <paramref name="ShowIdsInOrder"/>. Не упомянутые
/// остаются после них, сохраняя относительный порядок.</summary>
public sealed record ReorderCollectionCommand(Guid CollectionId, IReadOnlyList<Guid> ShowIdsInOrder)
: ICommand<Result>;
@@ -0,0 +1,25 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.ReorderCollection;
public sealed class ReorderCollectionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<ReorderCollectionCommand, Result>
{
public async Task<Result> Handle(
ReorderCollectionCommand command,
CancellationToken cancellationToken
)
{
var collection = await dbContext
.Collections.Include(c => c.Items)
.FirstOrDefaultAsync(c => c.Id == command.CollectionId, cancellationToken);
if (collection is null)
return Result.Failure(CollectionErrors.NotFound);
collection.Reorder(command.ShowIdsInOrder);
return Result.Success();
}
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.SetCollectionPoster;
/// <summary>Привязать/снять постер коллекции (<paramref name="ImageId"/> = null — отвязать).</summary>
public sealed record SetCollectionPosterCommand(Guid CollectionId, Guid? ImageId) : ICommand<Result>;
@@ -0,0 +1,33 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Images;
namespace TeleWave.Application.Library.Collections.SetCollectionPoster;
public sealed class SetCollectionPosterCommandHandler(IAppDbContext dbContext)
: ICommandHandler<SetCollectionPosterCommand, Result>
{
public async Task<Result> Handle(
SetCollectionPosterCommand command,
CancellationToken cancellationToken
)
{
var collection = await dbContext.Collections.FirstOrDefaultAsync(
c => c.Id == command.CollectionId,
cancellationToken
);
if (collection is null)
return Result.Failure(CollectionErrors.NotFound);
if (
command.ImageId is { } imageId
&& !await dbContext.Images.AnyAsync(i => i.Id == imageId, cancellationToken)
)
return Result.Failure(ImageErrors.NotFound);
collection.SetPosterImage(command.ImageId);
return Result.Success();
}
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.UpdateCollection;
public sealed record UpdateCollectionCommand(Guid CollectionId, string Name, string? Description)
: ICommand<Result>;
@@ -0,0 +1,26 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.UpdateCollection;
public sealed class UpdateCollectionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateCollectionCommand, Result>
{
public async Task<Result> Handle(
UpdateCollectionCommand command,
CancellationToken cancellationToken
)
{
var collection = await dbContext.Collections.FirstOrDefaultAsync(
c => c.Id == command.CollectionId,
cancellationToken
);
if (collection is null)
return Result.Failure(CollectionErrors.NotFound);
collection.Rename(command.Name, command.Description);
return Result.Success();
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace TeleWave.Application.Library.Collections.UpdateCollection;
public sealed class UpdateCollectionCommandValidator : AbstractValidator<UpdateCollectionCommand>
{
public UpdateCollectionCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Description).MaximumLength(2048);
}
}
@@ -2,11 +2,15 @@ using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Groups;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Library.DeleteShow;
public sealed class DeleteShowCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteShowCommand, Result>
public sealed class DeleteShowCommandHandler(
IAppDbContext dbContext,
GroupMembershipCleaner groupCleaner
) : ICommandHandler<DeleteShowCommand, Result>
{
public async Task<Result> Handle(DeleteShowCommand command, CancellationToken cancellationToken)
{
@@ -17,6 +21,13 @@ public sealed class DeleteShowCommandHandler(IAppDbContext dbContext)
if (show is null)
return Result.Failure(ShowErrors.NotFound);
// Позиции коллекций уходят каскадом БД, позиции групп — вручную: ссылка группы полиморфна.
await groupCleaner.RemoveElementAsync(
GroupElementKind.Show,
command.ShowId,
cancellationToken
);
// TODO(этап 2+): запретить удаление шоу, пока оно привязано к каналу или будущему расписанию.
dbContext.Shows.Remove(show);
return Result.Success();
@@ -0,0 +1,12 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Genres.CreateGenre;
/// <summary><paramref name="Aliases"/> — варианты написания для сопоставления с метаданными
/// провайдеров; само название и ключ добавляются автоматически.</summary>
public sealed record CreateGenreCommand(
string Name,
string Slug,
IReadOnlyList<string>? Aliases = null
) : ICommand<Result<Guid>>;
@@ -0,0 +1,36 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.Genres.CreateGenre;
public sealed class CreateGenreCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateGenreCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
CreateGenreCommand command,
CancellationToken cancellationToken
)
{
var slug = GenreAlias.Normalize(command.Slug);
if (await dbContext.Genres.AnyAsync(g => g.Slug == slug, cancellationToken))
return Result.Failure<Guid>(GenreErrors.SlugTaken);
var nextSortOrder = await dbContext.Genres.AnyAsync(cancellationToken)
? await dbContext.Genres.MaxAsync(g => g.SortOrder, cancellationToken) + 1
: 0;
var genre = Genre.Create(command.Name, slug, nextSortOrder);
foreach (var alias in GenreAliasInput.Collect(command.Name, slug, command.Aliases))
genre.AddAlias(alias);
var values = genre.Aliases.Select(a => a.Value).ToList();
if (await dbContext.GenreAliases.AnyAsync(a => values.Contains(a.Value), cancellationToken))
return Result.Failure<Guid>(GenreErrors.AliasTaken);
dbContext.Genres.Add(genre);
return Result.Success(genre.Id);
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace TeleWave.Application.Library.Genres.CreateGenre;
public sealed class CreateGenreCommandValidator : AbstractValidator<CreateGenreCommand>
{
public CreateGenreCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
RuleFor(x => x.Slug).NotEmpty().MaximumLength(64);
RuleForEach(x => x.Aliases).NotEmpty().MaximumLength(128);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Genres.DeleteGenre;
public sealed record DeleteGenreCommand(Guid GenreId) : ICommand<Result>;
@@ -0,0 +1,34 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Genres.DeleteGenre;
public sealed class DeleteGenreCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteGenreCommand, Result>
{
public async Task<Result> Handle(
DeleteGenreCommand command,
CancellationToken cancellationToken
)
{
var genre = await dbContext.Genres.FirstOrDefaultAsync(
g => g.Id == command.GenreId,
cancellationToken
);
if (genre is null)
return Result.Failure(GenreErrors.NotFound);
if (genre.IsSystem)
return Result.Failure(GenreErrors.SystemCannotBeDeleted);
// Связь ShowGenre→Genre настроена как Restrict: без этой проверки удаление упало бы
// исключением БД вместо управляемой ошибки.
if (await dbContext.ShowGenres.AnyAsync(sg => sg.GenreId == genre.Id, cancellationToken))
return Result.Failure(GenreErrors.InUse);
dbContext.Genres.Remove(genre);
return Result.Success();
}
}
@@ -0,0 +1,21 @@
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.Genres;
/// <summary>Сборка набора вариантов написания из пользовательского ввода: к явно заданным всегда
/// добавляются само название и ключ жанра — по ним провайдеры отдают жанр чаще всего.</summary>
public static class GenreAliasInput
{
public static IReadOnlyList<string> Collect(
string name,
string slug,
IReadOnlyList<string>? aliases
) =>
(aliases ?? [])
.Append(name)
.Append(slug)
.Select(GenreAlias.Normalize)
.Where(value => value.Length > 0)
.Distinct(StringComparer.Ordinal)
.ToList();
}
@@ -0,0 +1,13 @@
namespace TeleWave.Application.Library.Genres;
/// <summary>Жанр справочника для админки. <paramref name="ShowCount"/> — сколько шоу его используют
/// (нужен, чтобы админ видел, что удаление заблокировано, ещё до попытки удалить).</summary>
public sealed record GenreDto(
Guid Id,
string Name,
string Slug,
int SortOrder,
bool IsSystem,
IReadOnlyList<string> Aliases,
int ShowCount
);
@@ -0,0 +1,28 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Genres;
public static class GenreErrors
{
public static readonly Error NotFound = Error.NotFound("Genres.NotFound", "Жанр не найден.");
public static readonly Error SlugTaken = Error.Conflict(
"Genres.SlugTaken",
"Жанр с таким ключом уже существует."
);
public static readonly Error AliasTaken = Error.Conflict(
"Genres.AliasTaken",
"Один из вариантов написания уже закреплён за другим жанром."
);
public static readonly Error InUse = Error.Conflict(
"Genres.InUse",
"Жанр проставлен у шоу — сначала снимите его."
);
public static readonly Error SystemCannotBeDeleted = Error.Conflict(
"Genres.SystemCannotBeDeleted",
"Системный жанр нельзя удалить."
);
}
@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.Genres;
/// <summary>
/// Сопоставляет сырые обозначения жанров от внешних источников со справочником. Порядок исходного
/// списка сохраняется: первый распознанный жанр становится основным у шоу, а провайдеры отдают
/// жанры по убыванию значимости.
///
/// Нераспознанные обозначения молча отбрасываются: у шоу останутся те жанры, которые справочник
/// знает, а расширить справочник — задача администратора (см. псевдонимы жанра).
/// </summary>
public sealed class GenreMatcher(IAppDbContext dbContext)
{
public async Task<IReadOnlyList<Guid>> MatchAsync(
IEnumerable<string>? rawGenres,
CancellationToken cancellationToken
)
{
var normalized = (rawGenres ?? [])
.Select(GenreAlias.Normalize)
.Where(value => value.Length > 0)
.Distinct(StringComparer.Ordinal)
.ToList();
if (normalized.Count == 0)
return [];
var byValue = await dbContext
.GenreAliases.AsNoTracking()
.Where(a => normalized.Contains(a.Value))
.ToDictionaryAsync(a => a.Value, a => a.GenreId, cancellationToken);
var result = new List<Guid>();
foreach (var value in normalized)
{
// Один жанр приходит несколькими обозначениями (идентификатор + название) — берём первое
// распознанное, дубликаты пропускаем.
if (byValue.TryGetValue(value, out var genreId) && !result.Contains(genreId))
result.Add(genreId);
}
return result;
}
}
@@ -0,0 +1,5 @@
using LiteCqrs;
namespace TeleWave.Application.Library.Genres.ListGenres;
public sealed record ListGenresQuery : IQuery<IReadOnlyList<GenreDto>>;
@@ -0,0 +1,40 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Library.Genres.ListGenres;
public sealed class ListGenresQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListGenresQuery, IReadOnlyList<GenreDto>>
{
public async Task<IReadOnlyList<GenreDto>> Handle(
ListGenresQuery query,
CancellationToken cancellationToken
)
{
var genres = await dbContext
.Genres.AsNoTracking()
.Include(g => g.Aliases)
.OrderBy(g => g.SortOrder)
.ThenBy(g => g.Name)
.ToListAsync(cancellationToken);
var usage = await dbContext
.ShowGenres.AsNoTracking()
.GroupBy(sg => sg.GenreId)
.Select(g => new { GenreId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.GenreId, x => x.Count, cancellationToken);
return genres
.Select(g => new GenreDto(
g.Id,
g.Name,
g.Slug,
g.SortOrder,
g.IsSystem,
g.Aliases.Select(a => a.Value).OrderBy(v => v, StringComparer.Ordinal).ToList(),
usage.TryGetValue(g.Id, out var count) ? count : 0
))
.ToList();
}
}
@@ -0,0 +1,11 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Genres.UpdateGenre;
public sealed record UpdateGenreCommand(
Guid GenreId,
string Name,
int SortOrder,
IReadOnlyList<string>? Aliases = null
) : ICommand<Result>;
@@ -0,0 +1,36 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Genres.UpdateGenre;
public sealed class UpdateGenreCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateGenreCommand, Result>
{
public async Task<Result> Handle(
UpdateGenreCommand command,
CancellationToken cancellationToken
)
{
var genre = await dbContext
.Genres.Include(g => g.Aliases)
.FirstOrDefaultAsync(g => g.Id == command.GenreId, cancellationToken);
if (genre is null)
return Result.Failure(GenreErrors.NotFound);
var aliases = GenreAliasInput.Collect(command.Name, genre.Slug, command.Aliases);
// Псевдоним уникален по всему справочнику — проверяем, не занят ли он другим жанром.
var taken = await dbContext
.GenreAliases.Where(a => aliases.Contains(a.Value) && a.GenreId != genre.Id)
.AnyAsync(cancellationToken);
if (taken)
return Result.Failure(GenreErrors.AliasTaken);
genre.Rename(command.Name);
genre.SetSortOrder(command.SortOrder);
genre.ReplaceAliases(aliases);
return Result.Success();
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace TeleWave.Application.Library.Genres.UpdateGenre;
public sealed class UpdateGenreCommandValidator : AbstractValidator<UpdateGenreCommand>
{
public UpdateGenreCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
RuleForEach(x => x.Aliases).NotEmpty().MaximumLength(128);
}
}
@@ -2,6 +2,7 @@ using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Library.Collections;
namespace TeleWave.Application.Library.GetShow;
@@ -16,10 +17,28 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
var show = await dbContext
.Shows.AsNoTracking()
.Include(s => s.Episodes)
.Include(s => s.Genres)
.FirstOrDefaultAsync(s => s.Id == query.Id, cancellationToken);
if (show is null)
return Result.Failure<ShowDto>(ShowErrors.NotFound);
var genreIds = show.Genres.Select(g => g.GenreId).ToList();
var genreNames = await dbContext
.Genres.AsNoTracking()
.Where(g => genreIds.Contains(g.Id))
.Select(g => new { g.Id, g.Name, g.SortOrder })
.ToListAsync(cancellationToken);
var genreDtos = genreNames
.OrderByDescending(g => show.Genres.First(sg => sg.GenreId == g.Id).IsPrimary)
.ThenBy(g => g.SortOrder)
.Select(g => new ShowGenreDto(
g.Id,
g.Name,
show.Genres.First(sg => sg.GenreId == g.Id).IsPrimary
))
.ToList();
var episodes = show.Episodes.OrderBy(e => e.Position).ToList();
var assetIds = episodes.Select(e => e.MediaAssetId).ToList();
var assets = await dbContext
@@ -55,6 +74,19 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
})
.ToList();
var collections = await dbContext
.CollectionItems.AsNoTracking()
.Where(i => i.ShowId == show.Id)
.Join(
dbContext.Collections.AsNoTracking(),
item => item.CollectionId,
collection => collection.Id,
(item, collection) =>
new ShowCollectionRefDto(collection.Id, collection.Name, item.Position)
)
.OrderBy(c => c.Name)
.ToListAsync(cancellationToken);
return Result.Success(
new ShowDto(
show.Id,
@@ -67,7 +99,9 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
show.MetadataExternalId,
show.Year,
show.PosterImageId,
episodeDtos
episodeDtos,
genreDtos,
collections
)
);
}
@@ -2,4 +2,10 @@ using LiteCqrs;
namespace TeleWave.Application.Library.ListShows;
public sealed record ListShowsQuery : IQuery<IReadOnlyList<ShowSummaryDto>>;
/// <summary>
/// <paramref name="GenreId"/> — оставить только шоу с этим жанром (основным или нет).
/// <paramref name="Interstitials"/> — вернуть ролики-врезки вместо контента: у них своя страница,
/// и в общей библиотеке они только мешали бы.
/// </summary>
public sealed record ListShowsQuery(Guid? GenreId = null, bool Interstitials = false)
: IQuery<IReadOnlyList<ShowSummaryDto>>;
@@ -1,59 +1,85 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Library.ListShows;
public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListShowsQuery, IReadOnlyList<ShowSummaryDto>>
{
public async Task<IReadOnlyList<ShowSummaryDto>> Handle(
ListShowsQuery query,
CancellationToken cancellationToken
)
{
var shows = await dbContext
.Shows.AsNoTracking()
.Include(s => s.Episodes)
.OrderBy(s => s.Name)
.ToListAsync(cancellationToken);
// Имена ассетов нужны, чтобы распознать сезоны (номера в модели не хранятся).
var assetIds = shows
.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId))
.Distinct()
.ToList();
var names = await dbContext
.MediaAssets.AsNoTracking()
.Where(a => assetIds.Contains(a.Id))
.Select(a => new { a.Id, a.OriginalFileName })
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
return shows
.Select(s =>
{
var seasons = s
.Episodes.Select(e =>
names.TryGetValue(e.MediaAssetId, out var n)
? EpisodeName.ParseSeason(n)
: null
)
.Where(season => season is not null)
.Distinct()
.Count();
return new ShowSummaryDto(
s.Id,
s.Name,
s.OriginalName,
s.Kind,
s.Audience,
s.Episodes.Count,
seasons,
s.Year,
s.PosterImageId is not null,
s.CreatedAt
);
})
.ToList();
}
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.ListShows;
public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListShowsQuery, IReadOnlyList<ShowSummaryDto>>
{
public async Task<IReadOnlyList<ShowSummaryDto>> Handle(
ListShowsQuery query,
CancellationToken cancellationToken
)
{
var source = dbContext
.Shows.AsNoTracking()
.Include(s => s.Episodes)
.Include(s => s.Genres)
.Where(s =>
query.Interstitials
? s.Kind == ShowKind.Interstitial
: s.Kind != ShowKind.Interstitial
);
var filtered = query.GenreId is { } genreId
? source.Where(s => s.Genres.Any(g => g.GenreId == genreId))
: source;
var shows = await filtered.OrderBy(s => s.Name).ToListAsync(cancellationToken);
// Названия только для основных жанров — в списке показывается один.
var primaryIds = shows
.Select(s => s.PrimaryGenreId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var genreNames = await dbContext
.Genres.AsNoTracking()
.Where(g => primaryIds.Contains(g.Id))
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
// Имена ассетов нужны, чтобы распознать сезоны (номера в модели не хранятся).
var assetIds = shows
.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId))
.Distinct()
.ToList();
var names = await dbContext
.MediaAssets.AsNoTracking()
.Where(a => assetIds.Contains(a.Id))
.Select(a => new { a.Id, a.OriginalFileName })
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
return shows
.Select(s =>
{
var seasons = s
.Episodes.Select(e =>
names.TryGetValue(e.MediaAssetId, out var n)
? EpisodeName.ParseSeason(n)
: null
)
.Where(season => season is not null)
.Distinct()
.Count();
return new ShowSummaryDto(
s.Id,
s.Name,
s.OriginalName,
s.Kind,
s.Audience,
s.Episodes.Count,
seasons,
s.Year,
s.PosterImageId is not null,
s.CreatedAt,
s.PrimaryGenreId is { } primaryId && genreNames.TryGetValue(primaryId, out var g)
? g
: null
);
})
.ToList();
}
}
@@ -0,0 +1,12 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.SetShowGenres;
/// <summary>Полностью заменяет набор жанров шоу. <paramref name="PrimaryGenreId"/> — какой считать
/// основным; если он не входит в набор, основным станет первый из списка.</summary>
public sealed record SetShowGenresCommand(
Guid ShowId,
IReadOnlyList<Guid> GenreIds,
Guid? PrimaryGenreId = null
) : ICommand<Result>;
@@ -0,0 +1,37 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Library.Genres;
namespace TeleWave.Application.Library.SetShowGenres;
public sealed class SetShowGenresCommandHandler(IAppDbContext dbContext)
: ICommandHandler<SetShowGenresCommand, Result>
{
public async Task<Result> Handle(
SetShowGenresCommand command,
CancellationToken cancellationToken
)
{
var show = await dbContext
.Shows.Include(s => s.Genres)
.FirstOrDefaultAsync(s => s.Id == command.ShowId, cancellationToken);
if (show is null)
return Result.Failure(ShowErrors.NotFound);
var ids = command.GenreIds.Distinct().ToList();
if (ids.Count > 0)
{
// Ссылка на несуществующий жанр упала бы нарушением внешнего ключа — проверяем заранее.
var known = await dbContext
.Genres.Where(g => ids.Contains(g.Id))
.CountAsync(cancellationToken);
if (known != ids.Count)
return Result.Failure(GenreErrors.NotFound);
}
show.SetGenres(ids, command.PrimaryGenreId);
return Result.Success();
}
}
@@ -1,3 +1,4 @@
using TeleWave.Application.Library.Collections;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
@@ -13,9 +14,14 @@ public sealed record ShowSummaryDto(
int SeasonCount,
int? Year,
bool HasPoster,
DateTimeOffset CreatedAt
DateTimeOffset CreatedAt,
/// <summary>Название основного жанра — в списке показываем только его, остальные видны в карточке шоу.</summary>
string? PrimaryGenre = null
);
/// <summary>Жанр, проставленный шоу.</summary>
public sealed record ShowGenreDto(Guid Id, string Name, bool IsPrimary);
public sealed record EpisodeDto(
Guid Id,
Guid MediaAssetId,
@@ -42,5 +48,8 @@ public sealed record ShowDto(
string? MetadataExternalId,
int? Year,
Guid? PosterImageId,
IReadOnlyList<EpisodeDto> Episodes
IReadOnlyList<EpisodeDto> Episodes,
IReadOnlyList<ShowGenreDto> Genres,
/// <summary>Коллекции (франшизы), в которые входит шоу, с его позицией в каждой.</summary>
IReadOnlyList<ShowCollectionRefDto> Collections
);
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Library;
using TeleWave.Application.Library.Genres;
using TeleWave.Domain.Images;
namespace TeleWave.Application.Metadata.ApplyShowMetadata;
@@ -11,7 +12,8 @@ public sealed class ApplyShowMetadataCommandHandler(
IAppDbContext dbContext,
IMetadataProviderResolver resolver,
IImageDownloader downloader,
IImageStore imageStore
IImageStore imageStore,
GenreMatcher genreMatcher
) : ICommandHandler<ApplyShowMetadataCommand, Result>
{
public async Task<Result> Handle(
@@ -19,10 +21,9 @@ public sealed class ApplyShowMetadataCommandHandler(
CancellationToken cancellationToken
)
{
var show = await dbContext.Shows.FirstOrDefaultAsync(
s => s.Id == command.ShowId,
cancellationToken
);
var show = await dbContext
.Shows.Include(s => s.Genres)
.FirstOrDefaultAsync(s => s.Id == command.ShowId, cancellationToken);
if (show is null)
return Result.Failure(ShowErrors.NotFound);
@@ -60,6 +61,13 @@ public sealed class ApplyShowMetadataCommandHandler(
meta.Year,
posterImageId
);
// Жанры источника перезаписывают текущие, только если хоть один распознан справочником:
// иначе применение метаданных к шоу с руками проставленными жанрами обнуляло бы их.
var genreIds = await genreMatcher.MatchAsync(meta.Genres, cancellationToken);
if (genreIds.Count > 0)
show.SetGenres(genreIds);
return Result.Success();
}
}
@@ -9,13 +9,19 @@ public sealed record MetadataCandidate(
string? PosterUrl
);
/// <summary>Метаданные шоу из источника (PosterUrl — полный URL, скачивается локально при применении).</summary>
/// <summary>
/// Метаданные шоу из источника (PosterUrl — полный URL, скачивается локально при применении).
/// <paramref name="Genres"/> — сырые обозначения жанров в том порядке, в каком их отдал источник:
/// идентификаторы (<c>tmdb:28</c>) и названия вперемешку. Сопоставление со справочником —
/// на стороне приложения (см. GenreMatcher); первый распознанный становится основным жанром шоу.
/// </summary>
public sealed record ShowMetadata(
string ExternalId,
string Title,
int? Year,
string? Overview,
string? PosterUrl
string? PosterUrl,
IReadOnlyList<string>? Genres = null
);
/// <summary>Метаданные серии (для этапа 2).</summary>
@@ -0,0 +1,15 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups.AddGroupElements;
public sealed record GroupElementRef(GroupElementKind ElementKind, Guid ElementId);
/// <summary>
/// Добавляет элементы в конец группы. Массовая — так же добавляется и найденное правилом набора.
/// Уже входящие в группу молча пропускаются: при добавлении полусотни позиций падать из-за одного
/// повтора бессмысленно. Возвращает число реально добавленных.
/// </summary>
public sealed record AddGroupElementsCommand(Guid GroupId, IReadOnlyList<GroupElementRef> Elements)
: ICommand<Result<int>>;
@@ -0,0 +1,57 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups.AddGroupElements;
public sealed class AddGroupElementsCommandHandler(
IAppDbContext dbContext,
GroupStatsService stats
) : ICommandHandler<AddGroupElementsCommand, Result<int>>
{
public async Task<Result<int>> Handle(
AddGroupElementsCommand command,
CancellationToken cancellationToken
)
{
var group = await dbContext
.Groups.Include(g => g.Items)
.FirstOrDefaultAsync(g => g.Id == command.GroupId, cancellationToken);
if (group is null)
return Result.Failure<int>(GroupErrors.NotFound);
var requested = command.Elements.Distinct().ToList();
// Ссылки полиморфные, внешнего ключа нет — проверяем существование сами, иначе в группе
// осядут позиции, которые никогда не развернутся в контент.
var showIds = requested
.Where(e => e.ElementKind == GroupElementKind.Show)
.Select(e => e.ElementId)
.ToList();
var collectionIds = requested
.Where(e => e.ElementKind == GroupElementKind.Collection)
.Select(e => e.ElementId)
.ToList();
var knownShows = await dbContext
.Shows.Where(s => showIds.Contains(s.Id))
.Select(s => s.Id)
.ToListAsync(cancellationToken);
var knownCollections = await dbContext
.Collections.Where(c => collectionIds.Contains(c.Id))
.Select(c => c.Id)
.ToListAsync(cancellationToken);
if (knownShows.Count != showIds.Count || knownCollections.Count != collectionIds.Count)
return Result.Failure<int>(GroupErrors.ElementNotFound);
var added = requested.Count(element =>
group.AddElement(element.ElementKind, element.ElementId) is not null
);
await stats.RecomputeAsync(group, cancellationToken);
return Result.Success(added);
}
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.CreateGroup;
public sealed record CreateGroupCommand(string Name, string? Description = null)
: ICommand<Result<Guid>>;
@@ -0,0 +1,17 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups.CreateGroup;
public sealed class CreateGroupCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateGroupCommand, Result<Guid>>
{
public Task<Result<Guid>> Handle(CreateGroupCommand command, CancellationToken cancellationToken)
{
var group = Group.Create(command.Name, command.Description);
dbContext.Groups.Add(group);
return Task.FromResult(Result.Success(group.Id));
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace TeleWave.Application.Programming.Groups.CreateGroup;
public sealed class CreateGroupCommandValidator : AbstractValidator<CreateGroupCommand>
{
public CreateGroupCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Description).MaximumLength(2048);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.DeleteGroup;
public sealed record DeleteGroupCommand(Guid GroupId) : ICommand<Result>;
@@ -0,0 +1,24 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.DeleteGroup;
public sealed class DeleteGroupCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteGroupCommand, Result>
{
public async Task<Result> Handle(DeleteGroupCommand command, CancellationToken cancellationToken)
{
var group = await dbContext.Groups.FirstOrDefaultAsync(
g => g.Id == command.GroupId,
cancellationToken
);
if (group is null)
return Result.Failure(GroupErrors.NotFound);
// TODO(срез 1C): запретить удаление, пока на группу ссылается слот сетки.
dbContext.Groups.Remove(group);
return Result.Success();
}
}
@@ -0,0 +1,11 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.FindGroupCandidates;
/// <summary>
/// Подбирает позиции по правилу набора. <paramref name="Filter"/> = null — берём правило, сохранённое
/// у группы; передача фильтра явно нужна редактору, где его крутят до сохранения.
/// </summary>
public sealed record FindGroupCandidatesQuery(Guid GroupId, GroupFilter? Filter = null)
: IQuery<Result<IReadOnlyList<GroupCandidateDto>>>;
@@ -0,0 +1,139 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups.FindGroupCandidates;
public sealed class FindGroupCandidatesQueryHandler(
IAppDbContext dbContext,
GroupElementResolver resolver
) : IQueryHandler<FindGroupCandidatesQuery, Result<IReadOnlyList<GroupCandidateDto>>>
{
public async Task<Result<IReadOnlyList<GroupCandidateDto>>> Handle(
FindGroupCandidatesQuery query,
CancellationToken cancellationToken
)
{
var group = await dbContext
.Groups.AsNoTracking()
.Include(g => g.Items)
.FirstOrDefaultAsync(g => g.Id == query.GroupId, cancellationToken);
if (group is null)
return Result.Failure<IReadOnlyList<GroupCandidateDto>>(GroupErrors.NotFound);
var filter = query.Filter ?? GroupFilter.FromJson(group.FilterJson);
if (filter is null)
return Result.Failure<IReadOnlyList<GroupCandidateDto>>(GroupErrors.FilterNotSet);
var wantsShows =
filter.ElementKinds is null or { Count: 0 }
|| filter.ElementKinds.Contains(GroupElementKind.Show);
var wantsCollections =
filter.ElementKinds is null or { Count: 0 }
|| filter.ElementKinds.Contains(GroupElementKind.Collection);
var matchingShowIds = await MatchShowIdsAsync(filter, cancellationToken);
var elements = new List<(GroupElementKind Kind, Guid Id)>();
if (wantsShows)
elements.AddRange(matchingShowIds.Select(id => (GroupElementKind.Show, id)));
if (wantsCollections)
{
// Коллекция подходит, только если подходят все её части: иначе во «взрослую ночь» через
// франшизу просочилось бы детское, а в «боевики» — комедия из той же серии фильмов.
var collections = await dbContext
.Collections.AsNoTracking()
.Select(c => new { c.Id, ShowIds = c.Items.Select(i => i.ShowId).ToList() })
.ToListAsync(cancellationToken);
var matching = matchingShowIds.ToHashSet();
elements.AddRange(
collections
.Where(c => c.ShowIds.Count > 0 && c.ShowIds.All(matching.Contains))
.Select(c => (GroupElementKind.Collection, c.Id))
);
}
var info = await resolver.ResolveAsync(elements, cancellationToken);
var inGroup = group
.Items.Select(i => (i.ElementKind, i.ElementId))
.ToHashSet();
var result = new List<GroupCandidateDto>();
foreach (var (kind, id) in elements)
{
if (!info.TryGetValue((kind, id), out var element))
continue;
if (!MatchesUnitDuration(filter, element))
continue;
result.Add(
new GroupCandidateDto(
kind,
id,
element.Name,
element.UnitCount,
element.ShowKind,
element.Audience,
element.Year,
element.PosterImageId,
inGroup.Contains((kind, id))
)
);
}
return Result.Success<IReadOnlyList<GroupCandidateDto>>(
result.OrderBy(c => c.ElementName, StringComparer.CurrentCultureIgnoreCase).ToList()
);
}
private async Task<List<Guid>> MatchShowIdsAsync(
GroupFilter filter,
CancellationToken cancellationToken
)
{
var shows = dbContext.Shows.AsNoTracking();
if (filter.ShowKinds is { Count: > 0 } kinds)
shows = shows.Where(s => kinds.Contains(s.Kind));
// Категории упорядочены по возрастанию строгости, поэтому «не строже» — обычное сравнение.
if (filter.MaxAudience is { } maxAudience)
shows = shows.Where(s => s.Audience <= maxAudience);
if (filter.YearMin is { } yearMin)
shows = shows.Where(s => s.Year != null && s.Year >= yearMin);
if (filter.YearMax is { } yearMax)
shows = shows.Where(s => s.Year != null && s.Year <= yearMax);
// Жанры — «любой из»: шоу редко подходит под все перечисленные сразу.
if (filter.GenreIds is { Count: > 0 } genreIds)
shows = shows.Where(s => s.Genres.Any(g => genreIds.Contains(g.GenreId)));
return await shows.Select(s => s.Id).ToListAsync(cancellationToken);
}
/// <summary>
/// Длительность проверяется по средней единице, а не по сумме: у сериала в фильтре «серии
/// по 20–25 минут» осмысленна именно длина серии. Элементы без готовых ассетов длительности
/// не имеют — их этот фильтр не отбрасывает, иначе из группы выпадало бы всё, что ещё
/// обрабатывается.
/// </summary>
private static bool MatchesUnitDuration(GroupFilter filter, GroupElementInfo element)
{
if (filter.UnitMinutesMin is null && filter.UnitMinutesMax is null)
return true;
if (element.UnitCount == 0 || element.TotalDuration <= TimeSpan.Zero)
return true;
var averageMinutes = element.TotalDuration.TotalMinutes / element.UnitCount;
if (filter.UnitMinutesMin is { } min && averageMinutes < min)
return false;
if (filter.UnitMinutesMax is { } max && averageMinutes > max)
return false;
return true;
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.GetGroup;
public sealed record GetGroupQuery(Guid Id) : IQuery<Result<GroupDto>>;

Some files were not shown because too many files have changed in this diff Show More