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 LiteCqrs;
using TeleWave.Api.Common; using TeleWave.Api.Common;
using TeleWave.Application.Broadcast; using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.CreateChannel; using TeleWave.Application.Broadcast.CreateChannel;
using TeleWave.Application.Broadcast.GetChannel; using TeleWave.Application.Broadcast.GetChannel;
using TeleWave.Application.Broadcast.GetSchedule; using TeleWave.Application.Broadcast.GetSchedule;
using TeleWave.Application.Broadcast.ListChannels; using TeleWave.Application.Broadcast.ListChannels;
using TeleWave.Application.Broadcast.RegenerateSchedule; using TeleWave.Application.Broadcast.UpdateChannelSettings;
using TeleWave.Application.Broadcast.UpdateChannelSettings; using TeleWave.Application.Broadcast.UpdateChannelTime;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints; namespace TeleWave.Api.Endpoints;
/// <summary> /// <summary>
/// Админ-эндпоинты канала. Реализация разнесена по partial-файлам под-ресурсов: /// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки —
/// <c>ChannelEndpoints.Shows.cs</c> (шоу+реклама), <c>ChannelEndpoints.Bumpers.cs</c> /// в <c>ChannelEndpoints.Bumpers.cs</c>. Что и когда идёт в эфире, задаёт шаблон сетки
/// (блоки/подблоки/файлы/preview), <c>ChannelEndpoints.Overrides.cs</c> (override'ы). Здесь — /// (<c>TemplateEndpoints</c>).
/// регистрация всех маршрутов и хендлеры уровня канала (создание/список/настройки/расписание). /// </summary>
/// </summary> public static partial class ChannelEndpoints
public static partial class ChannelEndpoints {
{ public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app) {
{ var admin = app.MapGroup("/api/admin/channels")
var admin = app.MapGroup("/api/admin/channels") .WithTags("Admin.Channels")
.WithTags("Admin.Channels") .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", CreateChannel).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPost("", CreateChannel).Produces<CreatedIdResponse>(StatusCodes.Status201Created); admin.MapGet("", ListChannels).Produces<IReadOnlyList<ChannelSummaryDto>>();
admin.MapGet("", ListChannels).Produces<IReadOnlyList<ChannelSummaryDto>>(); admin.MapGet("/{id:guid}", GetChannel).Produces<ChannelDto>();
admin.MapGet("/{id:guid}", GetChannel).Produces<ChannelDto>(); admin
admin .MapPut("/{id:guid}/settings", UpdateSettings)
.MapPut("/{id:guid}/settings", UpdateSettings) .Produces(StatusCodes.Status204NoContent);
.Produces(StatusCodes.Status204NoContent); admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent);
admin admin
.MapPost("/{id:guid}/shows", AddShow) .MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created); .Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin admin
.MapPut("/{id:guid}/shows/{channelShowId:guid}", UpdateShow) .MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin admin
.MapDelete("/{id:guid}/shows/{channelShowId:guid}", RemoveShow) .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate)
.Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin
admin .MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio)
.MapPost("/{id:guid}/ads", AddAd) .Produces(StatusCodes.Status204NoContent);
.Produces<CreatedIdResponse>(StatusCodes.Status201Created); admin
admin .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio)
.MapDelete("/{id:guid}/ads/{channelAdId:guid}", RemoveAd) .Produces(StatusCodes.Status204NoContent);
.Produces(StatusCodes.Status204NoContent); admin
.MapPut(
admin "/{id:guid}/bumper/templates/{templateId:guid}/background",
.MapPost("/{id:guid}/bumper/templates", AddBumperTemplate) SetTemplateBackground
.Produces<CreatedIdResponse>(StatusCodes.Status201Created); )
admin .Produces(StatusCodes.Status204NoContent);
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate) admin
.Produces(StatusCodes.Status204NoContent); .MapDelete(
admin "/{id:guid}/bumper/templates/{templateId:guid}/background",
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate) ClearTemplateBackground
.Produces(StatusCodes.Status204NoContent); )
admin .Produces(StatusCodes.Status204NoContent);
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio)
.Produces(StatusCodes.Status204NoContent); admin
admin .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview)
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio) .Produces(StatusCodes.Status204NoContent);
.Produces(StatusCodes.Status204NoContent); admin.MapGet(
admin "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8",
.MapPut( PreviewPlaylist
"/{id:guid}/bumper/templates/{templateId:guid}/background", );
SetTemplateBackground admin.MapGet(
) "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}",
.Produces(StatusCodes.Status204NoContent); PreviewSegment
admin );
.MapDelete(
"/{id:guid}/bumper/templates/{templateId:guid}/background", admin
ClearTemplateBackground .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant)
) .Produces<CreatedIdResponse>(StatusCodes.Status201Created);
.Produces(StatusCodes.Status204NoContent); admin
.MapPut(
admin "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview) UpdateBumperVariant
.Produces(StatusCodes.Status204NoContent); )
admin.MapGet( .Produces(StatusCodes.Status204NoContent);
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8", admin
PreviewPlaylist .MapDelete(
); "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
admin.MapGet( RemoveBumperVariant
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}", )
PreviewSegment .Produces(StatusCodes.Status204NoContent);
);
admin
admin .MapGet("/{id:guid}/schedule", GetSchedule)
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant) .Produces<IReadOnlyList<ScheduleEntryDto>>();
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin return app;
.MapPut( }
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
UpdateBumperVariant private static async Task<IResult> CreateChannel(
) CreateChannelCommand command,
.Produces(StatusCodes.Status204NoContent); ISender sender,
admin CancellationToken cancellationToken
.MapDelete( )
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}", {
RemoveBumperVariant var result = await sender.Send(command, cancellationToken);
) return result.IsSuccess
.Produces(StatusCodes.Status204NoContent); ? Results.Created(
$"/api/admin/channels/{result.Value}",
admin new CreatedIdResponse(result.Value)
.MapPost("/{id:guid}/overrides", CreateOverride) )
.Produces<CreatedIdResponse>(StatusCodes.Status201Created); : result.ToHttpResult();
admin }
.MapDelete("/{id:guid}/overrides/{overrideId:guid}", DeleteOverride)
.Produces(StatusCodes.Status204NoContent); private static async Task<IResult> ListChannels(
ISender sender,
admin.MapPost("/{id:guid}/regenerate", Regenerate).Produces(StatusCodes.Status204NoContent); CancellationToken cancellationToken
admin )
.MapGet("/{id:guid}/schedule", GetSchedule) {
.Produces<IReadOnlyList<ScheduleEntryDto>>(); var result = await sender.Send(new ListChannelsQuery(), cancellationToken);
return Results.Ok(result);
return app; }
}
private static async Task<IResult> GetChannel(
private static async Task<IResult> CreateChannel( Guid id,
CreateChannelCommand command, ISender sender,
ISender sender, CancellationToken cancellationToken
CancellationToken cancellationToken )
) {
{ var result = await sender.Send(new GetChannelQuery(id), cancellationToken);
var result = await sender.Send(command, cancellationToken); return result.ToHttpResult();
return result.IsSuccess }
? Results.Created(
$"/api/admin/channels/{result.Value}", private static async Task<IResult> UpdateTime(
new CreatedIdResponse(result.Value) Guid id,
) UpdateChannelTimeBody body,
: result.ToHttpResult(); ISender sender,
} CancellationToken cancellationToken
)
private static async Task<IResult> ListChannels( {
ISender sender, var result = await sender.Send(
CancellationToken cancellationToken new UpdateChannelTimeCommand(
) id,
{ body.Number,
var result = await sender.Send(new ListChannelsQuery(), cancellationToken); body.UtcOffsetMinutes,
return Results.Ok(result); body.DayStartTime
} ),
cancellationToken
private static async Task<IResult> GetChannel( );
Guid id, return result.ToHttpResult();
ISender sender, }
CancellationToken cancellationToken
) private static async Task<IResult> UpdateSettings(
{ Guid id,
var result = await sender.Send(new GetChannelQuery(id), cancellationToken); UpdateChannelSettingsBody body,
return result.ToHttpResult(); ISender sender,
} CancellationToken cancellationToken
)
private static async Task<IResult> UpdateSettings( {
Guid id, var result = await sender.Send(
UpdateChannelSettingsBody body, new UpdateChannelSettingsCommand(
ISender sender, id,
CancellationToken cancellationToken body.Name,
) body.IsEnabled,
{ body.BumpersEnabled,
var result = await sender.Send( body.Bumper,
new UpdateChannelSettingsCommand( body.FillerAssetId
id, ),
body.Name, cancellationToken
body.IsEnabled, );
body.AdInsertion, return result.ToHttpResult();
body.AdsPerBreak, }
body.BumpersEnabled,
body.Bumper,
body.FillerAssetId private static async Task<IResult> GetSchedule(
), Guid id,
cancellationToken DateTimeOffset? from,
); DateTimeOffset? to,
return result.ToHttpResult(); ISender sender,
} CancellationToken cancellationToken
)
private static async Task<IResult> Regenerate( {
Guid id, var fromUtc = from ?? DateTimeOffset.UtcNow;
ISender sender, var toUtc = to ?? fromUtc.AddDays(1);
CancellationToken cancellationToken var result = await sender.Send(
) new GetChannelScheduleQuery(id, fromUtc, toUtc),
{ cancellationToken
var result = await sender.Send(new RegenerateChannelScheduleCommand(id), cancellationToken); );
return result.ToHttpResult(); return result.ToHttpResult();
} }
}
private static async Task<IResult> GetSchedule(
Guid id, /// <summary>Номер канала и его время: смещение от UTC и начало вещательных суток.</summary>
DateTimeOffset? from, public sealed record UpdateChannelTimeBody(
DateTimeOffset? to, int? Number,
ISender sender, int UtcOffsetMinutes,
CancellationToken cancellationToken TimeOnly DayStartTime
) );
{
var fromUtc = from ?? DateTimeOffset.UtcNow; public sealed record UpdateChannelSettingsBody(
var toUtc = to ?? fromUtc.AddDays(1); string Name,
var result = await sender.Send( bool IsEnabled,
new GetChannelScheduleQuery(id, fromUtc, toUtc), bool BumpersEnabled,
cancellationToken BumperSettingsInput Bumper,
); Guid? FillerAssetId
return result.ToHttpResult(); );
}
}
public sealed record UpdateChannelSettingsBody(
string Name,
bool IsEnabled,
AdInsertion AdInsertion,
int AdsPerBreak,
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 LiteCqrs;
using TeleWave.Api.Common; using TeleWave.Api.Common;
using TeleWave.Application.Library; using TeleWave.Application.Library;
using TeleWave.Application.Library.AddEpisode; using TeleWave.Application.Library.AddEpisode;
using TeleWave.Application.Library.CreateShow; using TeleWave.Application.Library.CreateShow;
using TeleWave.Application.Library.DeleteShow; using TeleWave.Application.Library.DeleteShow;
using TeleWave.Application.Library.GetShow; using TeleWave.Application.Library.GetShow;
using TeleWave.Application.Library.ListShows; using TeleWave.Application.Library.ListShows;
using TeleWave.Application.Library.RemoveEpisode; using TeleWave.Application.Library.RemoveEpisode;
using TeleWave.Application.Library.RenameShow; using TeleWave.Application.Library.RenameShow;
using TeleWave.Application.Library.SetShowAudience; using TeleWave.Application.Library.SetShowAudience;
using TeleWave.Application.Library.SetShowOriginalName; using TeleWave.Application.Library.SetShowGenres;
using TeleWave.Domain.Library; using TeleWave.Application.Library.SetShowOriginalName;
using TeleWave.Infrastructure.Identity; using TeleWave.Domain.Library;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
namespace TeleWave.Api.Endpoints;
public static class ShowEndpoints
{ public static class ShowEndpoints
public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app) {
{ public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app)
var admin = app.MapGroup("/api/admin/shows") {
.WithTags("Admin.Shows") var admin = app.MapGroup("/api/admin/shows")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); .WithTags("Admin.Shows")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", CreateShow).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>(); admin.MapPost("", CreateShow).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>(); admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>();
admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent); admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>();
admin admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent);
.MapPut("/{id:guid}/original-name", SetOriginalName) admin
.Produces(StatusCodes.Status204NoContent); .MapPut("/{id:guid}/original-name", SetOriginalName)
admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent); admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
admin admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent);
.MapPost("/{id:guid}/episodes", AddEpisode) admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
.Produces<CreatedIdResponse>(StatusCodes.Status201Created); admin
admin .MapPost("/{id:guid}/episodes", AddEpisode)
.MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode) .Produces<CreatedIdResponse>(StatusCodes.Status201Created);
.Produces(StatusCodes.Status204NoContent); admin
.MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode)
return app; .Produces(StatusCodes.Status204NoContent);
}
return app;
private static async Task<IResult> CreateShow( }
CreateShowCommand command,
ISender sender, private static async Task<IResult> CreateShow(
CancellationToken cancellationToken CreateShowCommand command,
) ISender sender,
{ CancellationToken cancellationToken
var result = await sender.Send(command, cancellationToken); )
return result.IsSuccess {
? Results.Created( var result = await sender.Send(command, cancellationToken);
$"/api/admin/shows/{result.Value}", return result.IsSuccess
new CreatedIdResponse(result.Value) ? Results.Created(
) $"/api/admin/shows/{result.Value}",
: result.ToHttpResult(); new CreatedIdResponse(result.Value)
} )
: result.ToHttpResult();
private static async Task<IResult> ListShows( }
ISender sender,
CancellationToken cancellationToken private static async Task<IResult> ListShows(
) ISender sender,
{ CancellationToken cancellationToken,
var result = await sender.Send(new ListShowsQuery(), cancellationToken); Guid? genreId = null,
return Results.Ok(result); bool interstitials = false
} )
{
private static async Task<IResult> GetShow( var result = await sender.Send(new ListShowsQuery(genreId, interstitials), cancellationToken);
Guid id, return Results.Ok(result);
ISender sender, }
CancellationToken cancellationToken
) private static async Task<IResult> GetShow(
{ Guid id,
var result = await sender.Send(new GetShowQuery(id), cancellationToken); ISender sender,
return result.ToHttpResult(); CancellationToken cancellationToken
} )
{
private static async Task<IResult> Rename( var result = await sender.Send(new GetShowQuery(id), cancellationToken);
Guid id, return result.ToHttpResult();
RenameShowBody body, }
ISender sender,
CancellationToken cancellationToken private static async Task<IResult> Rename(
) Guid id,
{ RenameShowBody body,
var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken); ISender sender,
return result.ToHttpResult(); CancellationToken cancellationToken
} )
{
private static async Task<IResult> SetOriginalName( var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken);
Guid id, return result.ToHttpResult();
SetShowOriginalNameBody body, }
ISender sender,
CancellationToken cancellationToken private static async Task<IResult> SetOriginalName(
) Guid id,
{ SetShowOriginalNameBody body,
var result = await sender.Send( ISender sender,
new SetShowOriginalNameCommand(id, body.OriginalName), CancellationToken cancellationToken
cancellationToken )
); {
return result.ToHttpResult(); var result = await sender.Send(
} new SetShowOriginalNameCommand(id, body.OriginalName),
cancellationToken
private static async Task<IResult> SetAudience( );
Guid id, return result.ToHttpResult();
SetShowAudienceBody body, }
ISender sender,
CancellationToken cancellationToken private static async Task<IResult> SetAudience(
) Guid id,
{ SetShowAudienceBody body,
var result = await sender.Send( ISender sender,
new SetShowAudienceCommand(id, body.Audience), CancellationToken cancellationToken
cancellationToken )
); {
return result.ToHttpResult(); var result = await sender.Send(
} new SetShowAudienceCommand(id, body.Audience),
cancellationToken
private static async Task<IResult> DeleteShow( );
Guid id, return result.ToHttpResult();
ISender sender, }
CancellationToken cancellationToken
) private static async Task<IResult> SetGenres(
{ Guid id,
var result = await sender.Send(new DeleteShowCommand(id), cancellationToken); SetShowGenresBody body,
return result.ToHttpResult(); ISender sender,
} CancellationToken cancellationToken
)
private static async Task<IResult> AddEpisode( {
Guid id, var result = await sender.Send(
AddEpisodeBody body, new SetShowGenresCommand(id, body.GenreIds, body.PrimaryGenreId),
ISender sender, cancellationToken
CancellationToken cancellationToken );
) return result.ToHttpResult();
{ }
var result = await sender.Send(
new AddEpisodeCommand(id, body.MediaAssetId), private static async Task<IResult> DeleteShow(
cancellationToken Guid id,
); ISender sender,
return result.IsSuccess CancellationToken cancellationToken
? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value)) )
: result.ToHttpResult(); {
} var result = await sender.Send(new DeleteShowCommand(id), cancellationToken);
return result.ToHttpResult();
private static async Task<IResult> RemoveEpisode( }
Guid id,
Guid episodeId, private static async Task<IResult> AddEpisode(
ISender sender, Guid id,
CancellationToken cancellationToken AddEpisodeBody body,
) ISender sender,
{ CancellationToken cancellationToken
var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken); )
return result.ToHttpResult(); {
} var result = await sender.Send(
} new AddEpisodeCommand(id, body.MediaAssetId),
cancellationToken
public sealed record AddEpisodeBody(Guid MediaAssetId); );
return result.IsSuccess
public sealed record RenameShowBody(string Name); ? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
public sealed record SetShowOriginalNameBody(string? OriginalName); }
public sealed record SetShowAudienceBody(ShowAudience Audience); 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.Net;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Scalar.AspNetCore; using Scalar.AspNetCore;
using Serilog; using Serilog;
using TeleWave.Api.Common; using TeleWave.Api.Common;
using TeleWave.Api.Endpoints; using TeleWave.Api.Endpoints;
using TeleWave.Application; using TeleWave.Application;
using TeleWave.Infrastructure; using TeleWave.Infrastructure;
using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Persistence; using TeleWave.Infrastructure.Persistence;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Загрузка медиа стримится на диск; поднимаем лимит тела запроса Kestrel до максимума загрузки // Загрузка медиа стримится на диск; поднимаем лимит тела запроса Kestrel до максимума загрузки
// (иначе дефолтные ~30 МБ рубят большие файлы). Собственный контроль размера — в MediaEndpoints. // (иначе дефолтные ~30 МБ рубят большие файлы). Собственный контроль размера — в MediaEndpoints.
builder.WebHost.ConfigureKestrel(options => builder.WebHost.ConfigureKestrel(options =>
options.Limits.MaxRequestBodySize = options.Limits.MaxRequestBodySize =
builder.Configuration.GetValue<long?>("Media:MaxUploadBytes") ?? 20L * 1024 * 1024 * 1024 builder.Configuration.GetValue<long?>("Media:MaxUploadBytes") ?? 20L * 1024 * 1024 * 1024
); );
// Структурное логирование (Serilog), конфигурация из appsettings/env. // Структурное логирование (Serilog), конфигурация из appsettings/env.
builder.Services.AddSerilog( builder.Services.AddSerilog(
(services, configuration) => (services, configuration) =>
configuration configuration
.ReadFrom.Configuration(builder.Configuration) .ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services) .ReadFrom.Services(services)
.Enrich.FromLogContext() .Enrich.FromLogContext()
); );
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно // За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя // перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback; // прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback;
// для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example). // для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
builder.Services.Configure<ForwardedHeadersOptions>(options => builder.Services.Configure<ForwardedHeadersOptions>(options =>
{ {
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
foreach ( foreach (
var proxy in builder var proxy in builder
.Configuration.GetSection("ForwardedHeaders:KnownProxies") .Configuration.GetSection("ForwardedHeaders:KnownProxies")
.Get<string[]>() .Get<string[]>()
?? [] ?? []
) )
options.KnownProxies.Add(IPAddress.Parse(proxy)); options.KnownProxies.Add(IPAddress.Parse(proxy));
foreach ( foreach (
var network in builder var network in builder
.Configuration.GetSection("ForwardedHeaders:KnownNetworks") .Configuration.GetSection("ForwardedHeaders:KnownNetworks")
.Get<string[]>() .Get<string[]>()
?? [] ?? []
) )
{ {
var parts = network.Split('/'); var parts = network.Split('/');
options.KnownIPNetworks.Add( options.KnownIPNetworks.Add(
new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1])) new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
); );
} }
}); });
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddApplication(); builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddInfrastructure(builder.Configuration);
var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20); var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
builder.Services.AddRateLimiter(options => builder.Services.AddRateLimiter(options =>
{ {
// Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый // Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый
// непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех. // непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех.
options.AddPolicy( options.AddPolicy(
RateLimiting.AuthPolicy, RateLimiting.AuthPolicy,
httpContext => httpContext =>
RateLimitPartition.GetFixedWindowLimiter( RateLimitPartition.GetFixedWindowLimiter(
httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions _ => new FixedWindowRateLimiterOptions
{ {
PermitLimit = authPermitLimit, PermitLimit = authPermitLimit,
Window = TimeSpan.FromMinutes(1), Window = TimeSpan.FromMinutes(1),
QueueLimit = 0, QueueLimit = 0,
} }
) )
); );
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
}); });
// Энумы сериализуются строками, не числами — самодокументируемый JSON. // Энумы сериализуются строками, не числами — самодокументируемый JSON.
builder.Services.ConfigureHttpJsonOptions(options => builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()) options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
); );
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>(); builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
var app = builder.Build(); var app = builder.Build();
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте. // Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
await app.Services.ApplyMigrationsAsync(); await app.Services.ApplyMigrationsAsync();
await app.Services.SeedDataAsync(); await app.Services.SeedDataAsync();
app.UseForwardedHeaders(); app.UseForwardedHeaders();
app.UseSerilogRequestLogging(); app.UseSerilogRequestLogging();
app.UseExceptionHandler(); app.UseExceptionHandler();
app.UseRateLimiter(); app.UseRateLimiter();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
// Схему/UI API публикуем не в проде (или явным флагом Api:EnableOpenApi=true) — чтобы в продакшене // Схему/UI API публикуем не в проде (или явным флагом Api:EnableOpenApi=true) — чтобы в продакшене
// не раскрывать полную карту эндпоинтов без необходимости. // не раскрывать полную карту эндпоинтов без необходимости.
if (app.Environment.IsDevelopment() || app.Configuration.GetValue("Api:EnableOpenApi", false)) if (app.Environment.IsDevelopment() || app.Configuration.GetValue("Api:EnableOpenApi", false))
{ {
app.MapOpenApi(); app.MapOpenApi();
app.MapScalarApiReference(); app.MapScalarApiReference();
} }
app.MapHealthChecks("/health"); app.MapHealthChecks("/health");
app.MapAuthEndpoints(); app.MapAuthEndpoints();
app.MapRoleEndpoints(); app.MapRoleEndpoints();
app.MapAdminUserEndpoints(); app.MapAdminUserEndpoints();
app.MapMediaEndpoints(); app.MapMediaEndpoints();
app.MapShowEndpoints(); app.MapShowEndpoints();
app.MapChannelEndpoints(); app.MapGenreEndpoints();
app.MapStreamingEndpoints(); app.MapCollectionEndpoints();
app.MapMaintenanceEndpoints(); app.MapGroupEndpoints();
app.MapSettingsEndpoints(); app.MapTemplateEndpoints();
app.MapMetadataEndpoints(); app.MapJunctionEndpoints();
app.MapImageEndpoints(); app.MapChannelEndpoints();
app.MapStreamingEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов. app.MapMaintenanceEndpoints();
app.UseDefaultFiles(); app.MapSettingsEndpoints();
app.UseStaticFiles(); app.MapMetadataEndpoints();
app.MapFallbackToFile("index.html"); app.MapImageEndpoints();
app.Run(); // Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
/// <summary>Делает неявный класс Program доступным для WebApplicationFactory&lt;Program&gt; в интеграционных тестах.</summary> app.UseStaticFiles();
public partial class Program; 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 "MinFreeSpaceBytes": 10737418240
}, },
"Scheduler": { "Scheduler": {
"HorizonDays": 3, "HorizonDays": 7,
"RetentionHours": 24, "RetentionDays": 90,
"TickMinutes": 30 "TickMinutes": 30
}, },
"Media": { "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 LiteCqrs;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Application.Streaming; using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Broadcast.Bumpers;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class RenderBumperPreviewCommandHandler(
IAppDbContext dbContext, public sealed class RenderBumperPreviewCommandHandler(
IBumperRenderer renderer, IAppDbContext dbContext,
IBumperTemplateStorage storage, IBumperRenderer renderer,
IImageStore imageStore, IBumperTemplateStorage storage,
IOptions<BumperOptions> bumperOptions, IImageStore imageStore,
IOptions<StreamingOptions> streamingOptions IOptions<BumperOptions> bumperOptions,
) : ICommandHandler<RenderBumperPreviewCommand, Result> IOptions<StreamingOptions> streamingOptions
{ ) : ICommandHandler<RenderBumperPreviewCommand, Result>
private readonly BumperOptions _bumper = bumperOptions.Value; {
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds); private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary>
private const int DefaultBumperDurationSeconds = 8; /// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary>
private const int DefaultBumperDurationSeconds = 8;
public async Task<Result> Handle(
RenderBumperPreviewCommand query, public async Task<Result> Handle(
CancellationToken cancellationToken RenderBumperPreviewCommand query,
) CancellationToken cancellationToken
{ )
var channel = await dbContext {
.Channels.AsNoTracking() var channel = await dbContext
.Include(c => c.BumperTemplates) .Channels.AsNoTracking()
.ThenInclude(t => t.Variants) .Include(c => c.BumperTemplates)
.Include(c => c.Shows) .ThenInclude(t => t.Variants)
.AsSplitQuery() .FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken); if (channel is null)
if (channel is null) return Result.Failure(ChannelErrors.NotFound);
return Result.Failure(ChannelErrors.NotFound);
var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId);
var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId); if (template is null)
if (template is null) return Result.Failure(ChannelErrors.BumperTemplateNotFound);
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken); var fontFile =
var fontFile = channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans;
channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans;
// Фон блока — из общего реестра по id (общий для всех подблоков).
// Фон блока — из общего реестра по id (общий для всех подблоков). string? backgroundPath = null;
string? backgroundPath = null; if (template.BackgroundImageId is { } bgId)
if (template.BackgroundImageId is { } bgId) {
{ var bgExt = await dbContext
var bgExt = await dbContext .Images.AsNoTracking()
.Images.AsNoTracking() .Where(i => i.Id == bgId)
.Where(i => i.Id == bgId) .Select(i => i.FileExtension)
.Select(i => i.FileExtension) .FirstOrDefaultAsync(cancellationToken);
.FirstOrDefaultAsync(cancellationToken); if (bgExt is not null)
if (bgExt is not null) backgroundPath = imageStore.ResolvePath(bgId, bgExt);
backgroundPath = imageStore.ResolvePath(bgId, bgExt); }
}
var seconds = template.AudioDurationSeconds is { } d and > 0
var seconds = template.AudioDurationSeconds is { } d and > 0 ? d
? d : DefaultBumperDurationSeconds;
: DefaultBumperDurationSeconds; var aligned = (int)(
var aligned = (int)( Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds
Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds );
); var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
// Рендерим каждый подблок в свой ассет-превью (id по подблоку).
// Рендерим каждый подблок в свой ассет-превью (id по подблоку). foreach (var variant in template.Variants.OrderBy(v => v.Position))
foreach (var variant in template.Variants.OrderBy(v => v.Position)) {
{ var free = variant.Kind == BumperTextKind.Free;
var free = variant.Kind == BumperTextKind.Free; var spec = new BumperRenderSpec(
var spec = new BumperRenderSpec( aligned,
aligned, _bumper.Width,
_bumper.Width, _bumper.Height,
_bumper.Height, template.BackgroundColor,
template.BackgroundColor, template.BackgroundColor2,
template.BackgroundColor2, template.AccentColor,
template.AccentColor, template.TextColor,
template.TextColor, fontFile,
fontFile, free ? "" : variant.NowLabel,
free ? "" : variant.NowLabel, free ? "" : fromName,
free ? "" : fromName, free ? "" : variant.NextLabel,
free ? "" : variant.NextLabel, free ? "" : toName,
free ? "" : toName, backgroundPath,
backgroundPath, audioPath,
audioPath, // Постер зависит от конкретного «следующего» шоу — в превью не подставляем.
// Постер зависит от конкретного «следующего» шоу — в превью не подставляем. null,
null, free,
free, variant.Line1,
variant.Line1, variant.Line2
variant.Line2 );
); await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken); }
}
return Result.Success();
return Result.Success(); }
}
/// <summary>
/// <summary>Примерные названия «из/в» — берём первые два шоу канала, иначе заглушки.</summary> /// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала:
private async Task<(string From, string To)> SampleNamesAsync( /// так превью показывает реальные названия этого канала, а не случайные из библиотеки.
Channel channel, /// </summary>
CancellationToken cancellationToken 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 var names = await (
? [] from slot in dbContext.Slots.AsNoTracking()
: await dbContext join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
.Shows.AsNoTracking() join item in dbContext.GroupItems.AsNoTracking()
.Where(s => showIds.Contains(s.Id)) on slot.GroupId equals item.GroupId
.Select(s => s.Name) join show in dbContext.Shows.AsNoTracking() on item.ElementId equals show.Id
.Take(2) where layer.TemplateId == channel.TemplateId
.ToListAsync(cancellationToken); && item.ElementKind == GroupElementKind.Show
select show.Name
return ( )
names.ElementAtOrDefault(0) ?? "Первое шоу", .Distinct()
names.ElementAtOrDefault(1) ?? "Второе шоу" .Take(2)
); .ToListAsync(cancellationToken);
}
} return (
names.ElementAtOrDefault(0) ?? "Первое шоу",
names.ElementAtOrDefault(1) ?? "Второе шоу"
);
}
}
@@ -1,92 +1,59 @@
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast; namespace TeleWave.Application.Broadcast;
public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled); public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled);
public sealed record ChannelShowDto( /// <summary>Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).</summary>
Guid Id, public sealed record BumperSettingsDto(
Guid ShowId, BumperFont Font,
string ShowName, int MinIntervalMinutes,
int Weight, BumperSelection Selection,
BlockMode BlockMode, double ShowChangeChance,
int BlockValue, double EpisodeChangeChance
bool IsEnabled, );
int NextEpisodeIndex,
int PreferredWeightMultiplier, /// <summary>Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.</summary>
IReadOnlyList<HourWindowDto> PreferredHours public sealed record BumperTextVariantDto(
); Guid Id,
int Position,
/// <summary>Окно предпочтительных часов [StartHour, EndHour) суток (UTC).</summary> string Name,
public sealed record HourWindowDto(int StartHour, int EndHour); BumperTextKind Kind,
string NowLabel,
public sealed record ChannelAdDto(Guid Id, Guid MediaAssetId, string? AssetName, int Position); string NextLabel,
string Line1,
public sealed record OverrideShowDto(Guid ShowId, string ShowName, int Weight); string Line2,
BumperTrigger Trigger,
public sealed record ProgrammingOverrideDto( int Weight
Guid Id, );
OverrideMode Mode,
OverrideRecurrence Recurrence, /// <summary>Блок заставки: своё оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
DateTimeOffset? StartsAtUtc, public sealed record BumperTemplateDto(
DateTimeOffset? EndsAtUtc, Guid Id,
int? DayOfWeek, int Position,
int? StartMinute, bool IsDefault,
int? EndMinute, string Name,
IReadOnlyList<OverrideShowDto> Shows string BackgroundColor,
); string BackgroundColor2,
string AccentColor,
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).</summary> string TextColor,
public sealed record BumperSettingsDto( Guid? BackgroundImageId,
BumperFont Font, bool HasAudio,
int MinIntervalMinutes, double? AudioDurationSeconds,
BumperSelection Selection, IReadOnlyList<BumperTextVariantDto> Variants
double ShowChangeChance, );
double EpisodeChangeChance
); public sealed record ChannelDto(
Guid Id,
/// <summary>Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.</summary> string Name,
public sealed record BumperTextVariantDto( string Slug,
Guid Id, bool IsEnabled,
int Position, int? Number,
string Name, int UtcOffsetMinutes,
BumperTextKind Kind, TimeOnly DayStartTime,
string NowLabel, Guid? TemplateId,
string NextLabel, bool BumpersEnabled,
string Line1, BumperSettingsDto Bumper,
string Line2, IReadOnlyList<BumperTemplateDto> BumperTemplates,
BumperTrigger Trigger, Guid? FillerAssetId
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
);
@@ -1,83 +1,54 @@
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast; namespace TeleWave.Application.Broadcast;
public static class ChannelErrors public static class ChannelErrors
{ {
public static readonly Error NotFound = Error.NotFound("Channels.NotFound", "Канал не найден."); public static readonly Error NotFound = Error.NotFound("Channels.NotFound", "Канал не найден.");
public static readonly Error DuplicateSlug = Error.Conflict( public static readonly Error NumberTaken = Error.Conflict(
"Channels.DuplicateSlug", "Channels.NumberTaken",
"Канал с таким slug уже существует." "Канал с таким номером уже есть."
); );
public static readonly Error ShowAlreadyAdded = Error.Conflict( public static readonly Error TemplateNotFound = Error.NotFound(
"Channels.ShowAlreadyAdded", "Channels.TemplateNotFound",
"Это шоу уже добавлено в канал." "У канала нет шаблона сетки."
); );
public static readonly Error ShowNotFound = Error.NotFound( public static readonly Error DuplicateSlug = Error.Conflict(
"Channels.ShowNotFound", "Channels.DuplicateSlug",
"Шоу не найдено в библиотеке." "Канал с таким slug уже существует."
); );
public static readonly Error ChannelShowNotFound = Error.NotFound( public static readonly Error BumperTemplateNotFound = Error.NotFound(
"Channels.ChannelShowNotFound", "Channels.BumperTemplateNotFound",
"Шоу не найдено в канале." "Блок заставки не найден."
); );
public static readonly Error AdAlreadyAdded = Error.Conflict( public static readonly Error CannotRemoveDefaultBumperTemplate = Error.Validation(
"Channels.AdAlreadyAdded", "Channels.CannotRemoveDefaultBumperTemplate",
"Этот ролик уже в пуле рекламы канала." "Дефолтный блок заставки удалить нельзя."
); );
public static readonly Error AdNotFound = Error.NotFound( public static readonly Error BumperTextVariantNotFound = Error.NotFound(
"Channels.AdNotFound", "Channels.BumperTextVariantNotFound",
"Реклама не найдена в пуле канала." "Подблок заставки не найден."
); );
public static readonly Error BumperTemplateNotFound = Error.NotFound( public static readonly Error CannotRemoveLastBumperTextVariant = Error.Validation(
"Channels.BumperTemplateNotFound", "Channels.CannotRemoveLastBumperTextVariant",
"Блок заставки не найден." "Нельзя удалить последний подблок — нужен хотя бы один."
); );
public static readonly Error CannotRemoveDefaultBumperTemplate = Error.Validation( public static readonly Error AssetNotFound = Error.NotFound(
"Channels.CannotRemoveDefaultBumperTemplate", "Channels.AssetNotFound",
"Дефолтный блок заставки удалить нельзя." "Медиа-ассет не найден."
); );
public static readonly Error BumperTextVariantNotFound = Error.NotFound( public static readonly Error InvalidBumperFile = Error.Validation(
"Channels.BumperTextVariantNotFound", "Channels.InvalidBumperFile",
"Подблок заставки не найден." "Недопустимый файл заставки (формат или размер)."
); );
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 должен ссылаться хотя бы на одно шоу."
);
}
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Broadcast.CreateChannel; namespace TeleWave.Application.Broadcast.CreateChannel;
@@ -22,7 +23,13 @@ public sealed class CreateChannelCommandHandler(IAppDbContext dbContext)
return Result.Failure<Guid>(ChannelErrors.DuplicateSlug); return Result.Failure<Guid>(ChannelErrors.DuplicateSlug);
var channel = Channel.Create(command.Name, command.Slug, DateTimeOffset.UtcNow); 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.Channels.Add(channel);
dbContext.ScheduleTemplates.Add(template);
return Result.Success(channel.Id); 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 CancellationToken cancellationToken
) )
{ {
// Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно
// (GetChannelTemplateQuery) — здесь только собственные свойства канала.
var channel = await dbContext var channel = await dbContext
.Channels.AsNoTracking() .Channels.AsNoTracking()
.Include(c => c.Shows)
.ThenInclude(s => s.PreferredHours)
.Include(c => c.Ads)
.Include(c => c.BumperTemplates) .Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants) .ThenInclude(t => t.Variants)
.Include(c => c.Overrides)
.ThenInclude(o => o.Shows)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken); .FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
if (channel is null) if (channel is null)
return Result.Failure<ChannelDto>(ChannelErrors.NotFound); 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 var bumperTemplates = channel
.BumperTemplates.OrderBy(t => t.Position) .BumperTemplates.OrderBy(t => t.Position)
.Select(t => new BumperTemplateDto( .Select(t => new BumperTemplateDto(
@@ -105,32 +54,16 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
)) ))
.ToList(); .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( return Result.Success(
new ChannelDto( new ChannelDto(
channel.Id, channel.Id,
channel.Name, channel.Name,
channel.Slug, channel.Slug,
channel.IsEnabled, channel.IsEnabled,
channel.AdInsertion, channel.Number,
channel.AdsPerBreak, channel.UtcOffsetMinutes,
channel.DayStartTime,
channel.TemplateId,
channel.BumpersEnabled, channel.BumpersEnabled,
new BumperSettingsDto( new BumperSettingsDto(
channel.BumperFont, channel.BumperFont,
@@ -140,10 +73,7 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
channel.BumperEpisodeChangeChance channel.BumperEpisodeChangeChance
), ),
bumperTemplates, bumperTemplates,
channel.FillerAssetId, channel.FillerAssetId
shows,
ads,
overrides
) )
); );
} }
@@ -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; namespace TeleWave.Application.Broadcast.Scheduling;
public sealed class SchedulerOptions public sealed class SchedulerOptions
{ {
public const string SectionName = "Scheduler"; public const string SectionName = "Scheduler";
/// <summary>На сколько дней вперёд держать материализованное расписание.</summary> /// <summary>
public int HorizonDays { get; init; } = 3; /// На сколько дней вперёд держать материализованное расписание. Неделя — часть замысла:
/// «знать, что мультики будут в субботу в 9:30» работает, только если программа известна заранее.
/// <summary>Сколько часов прошедшего расписания хранить (для EPG «что было»), затем чистить.</summary> /// </summary>
public int RetentionHours { get; init; } = 24; public int HorizonDays { get; init; } = 7;
/// <summary>Период тика фонового планировщика, минуты.</summary> /// <summary>
public int TickMinutes { get; init; } = 30; /// Сколько дней прошедшего расписания хранить. Должно покрывать самое долгое остывание среди правил
} /// канала и самый глубокий повтор: история показов берётся из самой ленты, отдельного журнала нет.
/// </summary>
public int RetentionDays { get; init; } = 90;
/// <summary>Период тика фонового планировщика, минуты.</summary>
public int TickMinutes { get; init; } = 30;
}
@@ -1,25 +1,23 @@
using LiteCqrs; using LiteCqrs;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings; namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed record UpdateChannelSettingsCommand( public sealed record UpdateChannelSettingsCommand(
Guid ChannelId, Guid ChannelId,
string Name, string Name,
bool IsEnabled, bool IsEnabled,
AdInsertion AdInsertion, bool BumpersEnabled,
int AdsPerBreak, BumperSettingsInput Bumper,
bool BumpersEnabled, Guid? FillerAssetId
BumperSettingsInput Bumper, ) : ICommand<Result>;
Guid? FillerAssetId
) : ICommand<Result>; /// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>).</summary>
public sealed record BumperSettingsInput(
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>).</summary> BumperFont Font,
public sealed record BumperSettingsInput( int MinIntervalMinutes,
BumperFont Font, BumperSelection Selection,
int MinIntervalMinutes, double ShowChangeChance,
BumperSelection Selection, double EpisodeChangeChance
double ShowChangeChance, );
double EpisodeChangeChance
);
@@ -1,50 +1,48 @@
using LiteCqrs; using LiteCqrs;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings; namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext) public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateChannelSettingsCommand, Result> : ICommandHandler<UpdateChannelSettingsCommand, Result>
{ {
public async Task<Result> Handle( public async Task<Result> Handle(
UpdateChannelSettingsCommand command, UpdateChannelSettingsCommand command,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var channel = await dbContext.Channels.FirstOrDefaultAsync( var channel = await dbContext.Channels.FirstOrDefaultAsync(
c => c.Id == command.ChannelId, c => c.Id == command.ChannelId,
cancellationToken cancellationToken
); );
if (channel is null) if (channel is null)
return Result.Failure(ChannelErrors.NotFound); return Result.Failure(ChannelErrors.NotFound);
if (command.FillerAssetId is { } fillerId) if (command.FillerAssetId is { } fillerId)
{ {
var exists = await dbContext.MediaAssets.AnyAsync( var exists = await dbContext.MediaAssets.AnyAsync(
a => a.Id == fillerId, a => a.Id == fillerId,
cancellationToken cancellationToken
); );
if (!exists) if (!exists)
return Result.Failure(ChannelErrors.AssetNotFound); return Result.Failure(ChannelErrors.AssetNotFound);
} }
channel.UpdateSettings( channel.UpdateSettings(
command.Name, command.Name,
command.IsEnabled, command.IsEnabled,
command.AdInsertion, command.BumpersEnabled,
command.AdsPerBreak, command.FillerAssetId
command.BumpersEnabled, );
command.FillerAssetId channel.UpdateBumperSettings(
); command.Bumper.Font,
channel.UpdateBumperSettings( command.Bumper.MinIntervalMinutes,
command.Bumper.Font, command.Bumper.Selection,
command.Bumper.MinIntervalMinutes, command.Bumper.ShowChangeChance,
command.Bumper.Selection, command.Bumper.EpisodeChangeChance
command.Bumper.ShowChangeChance, );
command.Bumper.EpisodeChangeChance return Result.Success();
); }
return Result.Success(); }
}
}
@@ -1,17 +1,16 @@
using FluentValidation; using FluentValidation;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings; namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed class UpdateChannelSettingsCommandValidator public sealed class UpdateChannelSettingsCommandValidator
: AbstractValidator<UpdateChannelSettingsCommand> : AbstractValidator<UpdateChannelSettingsCommand>
{ {
public UpdateChannelSettingsCommandValidator() public UpdateChannelSettingsCommandValidator()
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(256); 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.MinIntervalMinutes).InclusiveBetween(0, 1440); RuleFor(x => x.Bumper.ShowChangeChance).InclusiveBetween(0.0, 1.0);
RuleFor(x => x.Bumper.ShowChangeChance).InclusiveBetween(0.0, 1.0); RuleFor(x => x.Bumper.EpisodeChangeChance).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;
using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage;
using TeleWave.Domain.Auth; using TeleWave.Domain.Auth;
using TeleWave.Domain.Broadcast; using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Images; using TeleWave.Domain.Images;
using TeleWave.Domain.Library; using TeleWave.Domain.Library;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
using TeleWave.Domain.Settings; using TeleWave.Domain.Programming;
using TeleWave.Domain.Settings;
namespace TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Common.Interfaces;
public interface IAppDbContext
{ public interface IAppDbContext
DbSet<RefreshToken> RefreshTokens { get; } {
DbSet<MediaAsset> MediaAssets { get; } DbSet<RefreshToken> RefreshTokens { get; }
DbSet<Show> Shows { get; } DbSet<MediaAsset> MediaAssets { get; }
DbSet<Channel> Channels { get; } DbSet<Show> Shows { get; }
DbSet<ScheduleEntry> ScheduleEntries { get; } DbSet<Genre> Genres { get; }
DbSet<BumperTextVariant> BumperTextVariants { get; } DbSet<GenreAlias> GenreAliases { get; }
DbSet<BumperAsset> BumperAssets { get; } DbSet<ShowGenre> ShowGenres { get; }
DbSet<AppSetting> AppSettings { get; } DbSet<Collection> Collections { get; }
DbSet<Image> Images { get; } DbSet<CollectionItem> CollectionItems { get; }
DbSet<Group> Groups { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken); DbSet<GroupItem> GroupItems { get; }
DbSet<ScheduleTemplate> ScheduleTemplates { get; }
/// <summary>Открывает явную транзакцию БД — для команд с несколькими операциями (в т.ч. DbSet<GridLayer> GridLayers { get; }
/// <c>ExecuteDelete</c> в обход change-tracker), которые должны быть атомарны.</summary> DbSet<Slot> Slots { get; }
Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken); DbSet<SlotState> SlotStates { get; }
DbSet<JunctionTemplate> JunctionTemplates { get; }
/// <summary>Берёт транзакционную advisory-блокировку по каналу (снимается при коммите/откате). DbSet<JunctionElement> JunctionElements { get; }
/// Сериализует генерацию расписания одного канала между фоновым тиком и ручной перегенерацией. DbSet<Channel> Channels { get; }
/// Вызывать внутри открытой транзакции.</summary> DbSet<ScheduleEntry> ScheduleEntries { get; }
Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken); 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 Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Groups;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Library.DeleteShow; namespace TeleWave.Application.Library.DeleteShow;
public sealed class DeleteShowCommandHandler(IAppDbContext dbContext) public sealed class DeleteShowCommandHandler(
: ICommandHandler<DeleteShowCommand, Result> IAppDbContext dbContext,
GroupMembershipCleaner groupCleaner
) : ICommandHandler<DeleteShowCommand, Result>
{ {
public async Task<Result> Handle(DeleteShowCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(DeleteShowCommand command, CancellationToken cancellationToken)
{ {
@@ -17,6 +21,13 @@ public sealed class DeleteShowCommandHandler(IAppDbContext dbContext)
if (show is null) if (show is null)
return Result.Failure(ShowErrors.NotFound); return Result.Failure(ShowErrors.NotFound);
// Позиции коллекций уходят каскадом БД, позиции групп — вручную: ссылка группы полиморфна.
await groupCleaner.RemoveElementAsync(
GroupElementKind.Show,
command.ShowId,
cancellationToken
);
// TODO(этап 2+): запретить удаление шоу, пока оно привязано к каналу или будущему расписанию. // TODO(этап 2+): запретить удаление шоу, пока оно привязано к каналу или будущему расписанию.
dbContext.Shows.Remove(show); dbContext.Shows.Remove(show);
return Result.Success(); 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 Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Application.Library.Collections;
namespace TeleWave.Application.Library.GetShow; namespace TeleWave.Application.Library.GetShow;
@@ -16,10 +17,28 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
var show = await dbContext var show = await dbContext
.Shows.AsNoTracking() .Shows.AsNoTracking()
.Include(s => s.Episodes) .Include(s => s.Episodes)
.Include(s => s.Genres)
.FirstOrDefaultAsync(s => s.Id == query.Id, cancellationToken); .FirstOrDefaultAsync(s => s.Id == query.Id, cancellationToken);
if (show is null) if (show is null)
return Result.Failure<ShowDto>(ShowErrors.NotFound); 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 episodes = show.Episodes.OrderBy(e => e.Position).ToList();
var assetIds = episodes.Select(e => e.MediaAssetId).ToList(); var assetIds = episodes.Select(e => e.MediaAssetId).ToList();
var assets = await dbContext var assets = await dbContext
@@ -55,6 +74,19 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
}) })
.ToList(); .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( return Result.Success(
new ShowDto( new ShowDto(
show.Id, show.Id,
@@ -67,7 +99,9 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
show.MetadataExternalId, show.MetadataExternalId,
show.Year, show.Year,
show.PosterImageId, show.PosterImageId,
episodeDtos episodeDtos,
genreDtos,
collections
) )
); );
} }
@@ -2,4 +2,10 @@ using LiteCqrs;
namespace TeleWave.Application.Library.ListShows; 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 LiteCqrs;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.ListShows;
namespace TeleWave.Application.Library.ListShows;
public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListShowsQuery, IReadOnlyList<ShowSummaryDto>> public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
{ : IQueryHandler<ListShowsQuery, IReadOnlyList<ShowSummaryDto>>
public async Task<IReadOnlyList<ShowSummaryDto>> Handle( {
ListShowsQuery query, public async Task<IReadOnlyList<ShowSummaryDto>> Handle(
CancellationToken cancellationToken ListShowsQuery query,
) CancellationToken cancellationToken
{ )
var shows = await dbContext {
.Shows.AsNoTracking() var source = dbContext
.Include(s => s.Episodes) .Shows.AsNoTracking()
.OrderBy(s => s.Name) .Include(s => s.Episodes)
.ToListAsync(cancellationToken); .Include(s => s.Genres)
.Where(s =>
// Имена ассетов нужны, чтобы распознать сезоны (номера в модели не хранятся). query.Interstitials
var assetIds = shows ? s.Kind == ShowKind.Interstitial
.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId)) : s.Kind != ShowKind.Interstitial
.Distinct() );
.ToList();
var names = await dbContext var filtered = query.GenreId is { } genreId
.MediaAssets.AsNoTracking() ? source.Where(s => s.Genres.Any(g => g.GenreId == genreId))
.Where(a => assetIds.Contains(a.Id)) : source;
.Select(a => new { a.Id, a.OriginalFileName })
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken); var shows = await filtered.OrderBy(s => s.Name).ToListAsync(cancellationToken);
return shows // Названия только для основных жанров — в списке показывается один.
.Select(s => var primaryIds = shows
{ .Select(s => s.PrimaryGenreId)
var seasons = s .Where(id => id is not null)
.Episodes.Select(e => .Select(id => id!.Value)
names.TryGetValue(e.MediaAssetId, out var n) .Distinct()
? EpisodeName.ParseSeason(n) .ToList();
: null var genreNames = await dbContext
) .Genres.AsNoTracking()
.Where(season => season is not null) .Where(g => primaryIds.Contains(g.Id))
.Distinct() .ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
.Count();
return new ShowSummaryDto( // Имена ассетов нужны, чтобы распознать сезоны (номера в модели не хранятся).
s.Id, var assetIds = shows
s.Name, .SelectMany(s => s.Episodes.Select(e => e.MediaAssetId))
s.OriginalName, .Distinct()
s.Kind, .ToList();
s.Audience, var names = await dbContext
s.Episodes.Count, .MediaAssets.AsNoTracking()
seasons, .Where(a => assetIds.Contains(a.Id))
s.Year, .Select(a => new { a.Id, a.OriginalFileName })
s.PosterImageId is not null, .ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
s.CreatedAt
); return shows
}) .Select(s =>
.ToList(); {
} 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.Library;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
@@ -13,9 +14,14 @@ public sealed record ShowSummaryDto(
int SeasonCount, int SeasonCount,
int? Year, int? Year,
bool HasPoster, 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( public sealed record EpisodeDto(
Guid Id, Guid Id,
Guid MediaAssetId, Guid MediaAssetId,
@@ -42,5 +48,8 @@ public sealed record ShowDto(
string? MetadataExternalId, string? MetadataExternalId,
int? Year, int? Year,
Guid? PosterImageId, 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.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Application.Library; using TeleWave.Application.Library;
using TeleWave.Application.Library.Genres;
using TeleWave.Domain.Images; using TeleWave.Domain.Images;
namespace TeleWave.Application.Metadata.ApplyShowMetadata; namespace TeleWave.Application.Metadata.ApplyShowMetadata;
@@ -11,7 +12,8 @@ public sealed class ApplyShowMetadataCommandHandler(
IAppDbContext dbContext, IAppDbContext dbContext,
IMetadataProviderResolver resolver, IMetadataProviderResolver resolver,
IImageDownloader downloader, IImageDownloader downloader,
IImageStore imageStore IImageStore imageStore,
GenreMatcher genreMatcher
) : ICommandHandler<ApplyShowMetadataCommand, Result> ) : ICommandHandler<ApplyShowMetadataCommand, Result>
{ {
public async Task<Result> Handle( public async Task<Result> Handle(
@@ -19,10 +21,9 @@ public sealed class ApplyShowMetadataCommandHandler(
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var show = await dbContext.Shows.FirstOrDefaultAsync( var show = await dbContext
s => s.Id == command.ShowId, .Shows.Include(s => s.Genres)
cancellationToken .FirstOrDefaultAsync(s => s.Id == command.ShowId, cancellationToken);
);
if (show is null) if (show is null)
return Result.Failure(ShowErrors.NotFound); return Result.Failure(ShowErrors.NotFound);
@@ -60,6 +61,13 @@ public sealed class ApplyShowMetadataCommandHandler(
meta.Year, meta.Year,
posterImageId posterImageId
); );
// Жанры источника перезаписывают текущие, только если хоть один распознан справочником:
// иначе применение метаданных к шоу с руками проставленными жанрами обнуляло бы их.
var genreIds = await genreMatcher.MatchAsync(meta.Genres, cancellationToken);
if (genreIds.Count > 0)
show.SetGenres(genreIds);
return Result.Success(); return Result.Success();
} }
} }
@@ -9,13 +9,19 @@ public sealed record MetadataCandidate(
string? PosterUrl string? PosterUrl
); );
/// <summary>Метаданные шоу из источника (PosterUrl — полный URL, скачивается локально при применении).</summary> /// <summary>
/// Метаданные шоу из источника (PosterUrl — полный URL, скачивается локально при применении).
/// <paramref name="Genres"/> — сырые обозначения жанров в том порядке, в каком их отдал источник:
/// идентификаторы (<c>tmdb:28</c>) и названия вперемешку. Сопоставление со справочником —
/// на стороне приложения (см. GenreMatcher); первый распознанный становится основным жанром шоу.
/// </summary>
public sealed record ShowMetadata( public sealed record ShowMetadata(
string ExternalId, string ExternalId,
string Title, string Title,
int? Year, int? Year,
string? Overview, string? Overview,
string? PosterUrl string? PosterUrl,
IReadOnlyList<string>? Genres = null
); );
/// <summary>Метаданные серии (для этапа 2).</summary> /// <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