Add TV bumpers functionality: introduce configuration options for bumpers in .env.example and appsettings.json, enhance ChannelEndpoints to manage jingles and bumper assets, and update Channel and ScheduleEntry models to support bumper logic. Implement validation for bumper settings and integrate bumper handling in scheduling logic.

This commit is contained in:
Leonid Pershin
2026-07-25 00:45:22 +03:00
parent 622bf1e440
commit 0cfc72166a
60 changed files with 5073 additions and 33 deletions
@@ -10,7 +10,7 @@ public static class ResultExtensions
public static IResult ToHttpResult<T>(this Result<T> result) =>
result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error);
private static IResult ToProblem(Error error)
public static IResult ToProblem(this Error error)
{
var statusCode = error.Type switch
{
@@ -2,7 +2,10 @@ using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.AddChannelAd;
using TeleWave.Application.Broadcast.AddChannelJingle;
using TeleWave.Application.Broadcast.AddChannelShow;
using TeleWave.Application.Broadcast.BumperBackground;
using TeleWave.Application.Broadcast.BumperMusic;
using TeleWave.Application.Broadcast.CreateChannel;
using TeleWave.Application.Broadcast.CreateOverride;
using TeleWave.Application.Broadcast.DeleteOverride;
@@ -11,9 +14,11 @@ using TeleWave.Application.Broadcast.GetSchedule;
using TeleWave.Application.Broadcast.ListChannels;
using TeleWave.Application.Broadcast.RegenerateSchedule;
using TeleWave.Application.Broadcast.RemoveChannelAd;
using TeleWave.Application.Broadcast.RemoveChannelJingle;
using TeleWave.Application.Broadcast.RemoveChannelShow;
using TeleWave.Application.Broadcast.UpdateChannelSettings;
using TeleWave.Application.Broadcast.UpdateChannelShow;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Infrastructure.Identity;
@@ -49,6 +54,26 @@ public static class ChannelEndpoints
.MapDelete("/{id:guid}/ads/{channelAdId:guid}", RemoveAd)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/jingles", AddJingle)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapDelete("/{id:guid}/jingles/{channelJingleId:guid}", RemoveJingle)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/bumper/background", UploadBackground)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/background", ClearBackground)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/bumper/music", UploadMusic)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/music", ClearMusic)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/overrides", CreateOverride)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
@@ -109,6 +134,8 @@ public static class ChannelEndpoints
body.IsEnabled,
body.AdInsertion,
body.AdsPerBreak,
body.BumpersEnabled,
body.Bumper,
body.FillerAssetId
),
cancellationToken
@@ -189,6 +216,110 @@ public static class ChannelEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> AddJingle(
Guid id,
AddChannelJingleBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddChannelJingleCommand(id, body.MediaAssetId),
cancellationToken
);
return result.IsSuccess
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> RemoveJingle(
Guid id,
Guid channelJingleId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RemoveChannelJingleCommand(id, channelJingleId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UploadBackground(
Guid id,
string fileName,
HttpRequest request,
IBumperTemplateStorage storage,
ISender sender,
CancellationToken cancellationToken
)
{
if (ResolveBumperExtension(fileName, request, BumperFiles.BackgroundExtensions) is not { } ext)
return ChannelErrors.InvalidBumperFile.ToProblem();
await storage.SaveBackgroundAsync(id, ext, request.Body, cancellationToken);
var result = await sender.Send(new SetBumperBackgroundCommand(id, ext), cancellationToken);
if (!result.IsSuccess)
storage.DeleteBackground(id);
return result.ToHttpResult();
}
private static async Task<IResult> ClearBackground(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ClearBumperBackgroundCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UploadMusic(
Guid id,
string fileName,
HttpRequest request,
IBumperTemplateStorage storage,
ISender sender,
CancellationToken cancellationToken
)
{
if (ResolveBumperExtension(fileName, request, BumperFiles.MusicExtensions) is not { } ext)
return ChannelErrors.InvalidBumperFile.ToProblem();
await storage.SaveMusicAsync(id, ext, request.Body, cancellationToken);
var result = await sender.Send(new SetBumperMusicCommand(id, ext), cancellationToken);
if (!result.IsSuccess)
storage.DeleteMusic(id);
return result.ToHttpResult();
}
private static async Task<IResult> ClearMusic(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ClearBumperMusicCommand(id), cancellationToken);
return result.ToHttpResult();
}
/// <summary>Проверяет расширение файла (по allowlist) и размер (Content-Length). Возвращает
/// нормализованное расширение (с точкой, нижний регистр) или null при отказе.</summary>
private static string? ResolveBumperExtension(
string fileName,
HttpRequest request,
IReadOnlySet<string> allowedExtensions
)
{
if (request.ContentLength is > BumperFiles.MaxBytes or 0 or null)
return null;
var ext = Path.GetExtension(fileName).ToLowerInvariant();
return allowedExtensions.Contains(ext) ? ext : null;
}
private static async Task<IResult> CreateOverride(
Guid id,
CreateOverrideBody body,
@@ -258,6 +389,8 @@ public sealed record UpdateChannelSettingsBody(
bool IsEnabled,
AdInsertion AdInsertion,
int AdsPerBreak,
bool BumpersEnabled,
BumperSettingsInput Bumper,
Guid? FillerAssetId
);
@@ -272,6 +405,42 @@ public sealed record UpdateChannelShowBody(
public sealed record AddChannelAdBody(Guid MediaAssetId);
public sealed record AddChannelJingleBody(Guid MediaAssetId);
/// <summary>Ограничения на загружаемые файлы заставки (фон/музыка).</summary>
internal static class BumperFiles
{
public const long MaxBytes = 200L * 1024 * 1024; // 200 МБ
public static readonly IReadOnlySet<string> BackgroundExtensions = new HashSet<string>(
StringComparer.OrdinalIgnoreCase
)
{
".jpg",
".jpeg",
".png",
".webp",
".bmp",
".gif",
".mp4",
".mov",
".mkv",
".webm",
};
public static readonly IReadOnlySet<string> MusicExtensions = new HashSet<string>(
StringComparer.OrdinalIgnoreCase
)
{
".mp3",
".m4a",
".aac",
".ogg",
".opus",
".wav",
};
}
public sealed record CreateOverrideBody(
OverrideMode Mode,
DateTimeOffset StartsAtUtc,
@@ -34,6 +34,13 @@
"NormalizeLoudness": true,
"LoudnessTargetLufs": -16
},
"Bumpers": {
"Width": 1280,
"Height": 720,
"FontFileSans": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"FontFileSerif": "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf",
"TemplateVersion": 1
},
"Serilog": {
"Using": [ "Serilog.Sinks.Console" ],
"MinimumLevel": {