Refactor ChannelEndpoints and ScheduleGenerator: consolidate endpoint logic into partial files, remove unused methods, and enhance dependency injection for scheduling. Update ChannelDetail component to streamline imports and improve UI structure.
This commit is contained in:
@@ -0,0 +1,343 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Api.Common;
|
||||||
|
using TeleWave.Application.Broadcast;
|
||||||
|
using TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
|
using TeleWave.Infrastructure.Media;
|
||||||
|
|
||||||
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>Эндпоинты ТВ-заставок канала: блоки (стиль/аудио/фон), подблоки и рендер превью.</summary>
|
||||||
|
public static partial class ChannelEndpoints
|
||||||
|
{
|
||||||
|
private static readonly Regex BumperSegmentFileName = new(
|
||||||
|
@"^seg\d{1,6}\.ts$",
|
||||||
|
RegexOptions.Compiled
|
||||||
|
);
|
||||||
|
|
||||||
|
private static async Task<IResult> AddBumperTemplate(
|
||||||
|
Guid id,
|
||||||
|
AddBumperTemplateBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new AddBumperTemplateCommand(id, body.Name),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
|
||||||
|
: result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateBumperTemplate(
|
||||||
|
Guid id,
|
||||||
|
Guid templateId,
|
||||||
|
UpdateBumperTemplateBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new UpdateBumperTemplateCommand(
|
||||||
|
id,
|
||||||
|
templateId,
|
||||||
|
body.Name,
|
||||||
|
body.BackgroundColor,
|
||||||
|
body.BackgroundColor2,
|
||||||
|
body.AccentColor,
|
||||||
|
body.TextColor
|
||||||
|
),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> RemoveBumperTemplate(
|
||||||
|
Guid id,
|
||||||
|
Guid templateId,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new RemoveBumperTemplateCommand(id, templateId),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UploadTemplateAudio(
|
||||||
|
Guid id,
|
||||||
|
Guid templateId,
|
||||||
|
string fileName,
|
||||||
|
HttpRequest request,
|
||||||
|
IBumperTemplateStorage storage,
|
||||||
|
IAudioProbe probe,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (ResolveBumperExtension(fileName, request, BumperFiles.AudioExtensions) is not { } ext)
|
||||||
|
return ChannelErrors.InvalidBumperFile.ToProblem();
|
||||||
|
|
||||||
|
await storage.SaveAudioAsync(templateId, ext, request.Body, cancellationToken);
|
||||||
|
|
||||||
|
// Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина).
|
||||||
|
var path = storage.AudioPath(templateId, ext);
|
||||||
|
var duration = path is null
|
||||||
|
? null
|
||||||
|
: await probe.TryGetDurationAsync(path, cancellationToken);
|
||||||
|
|
||||||
|
var result = await sender.Send(
|
||||||
|
new SetBumperTemplateAudioCommand(id, templateId, ext, duration?.TotalSeconds ?? 0),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
storage.DeleteAudio(templateId);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ClearTemplateAudio(
|
||||||
|
Guid id,
|
||||||
|
Guid templateId,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new ClearBumperTemplateAudioCommand(id, templateId),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> SetTemplateBackground(
|
||||||
|
Guid id,
|
||||||
|
Guid templateId,
|
||||||
|
SetBumperTemplateBackgroundBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new SetBumperTemplateBackgroundCommand(id, templateId, body.ImageId),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ClearTemplateBackground(
|
||||||
|
Guid id,
|
||||||
|
Guid templateId,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new ClearBumperTemplateBackgroundCommand(id, templateId),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> AddBumperVariant(
|
||||||
|
Guid id,
|
||||||
|
Guid templateId,
|
||||||
|
AddBumperVariantBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new AddBumperTextVariantCommand(id, templateId, body.Name),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
|
||||||
|
: result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateBumperVariant(
|
||||||
|
Guid id,
|
||||||
|
Guid templateId,
|
||||||
|
Guid variantId,
|
||||||
|
UpdateBumperVariantBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new UpdateBumperTextVariantCommand(
|
||||||
|
id,
|
||||||
|
templateId,
|
||||||
|
variantId,
|
||||||
|
body.Name,
|
||||||
|
body.Kind,
|
||||||
|
body.NowLabel,
|
||||||
|
body.NextLabel,
|
||||||
|
body.Line1,
|
||||||
|
body.Line2,
|
||||||
|
body.Trigger,
|
||||||
|
body.Weight
|
||||||
|
),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> RemoveBumperVariant(
|
||||||
|
Guid id,
|
||||||
|
Guid templateId,
|
||||||
|
Guid variantId,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new RemoveBumperTextVariantCommand(id, templateId, variantId),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Синхронно рендерит пример заставки блока (несколько секунд ffmpeg).</summary>
|
||||||
|
private static async Task<IResult> RenderPreview(
|
||||||
|
Guid id,
|
||||||
|
Guid templateId,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new RenderBumperPreviewQuery(id, templateId),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.IsSuccess ? Results.NoContent() : result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Плейлист превью подблока: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
||||||
|
private static IResult PreviewPlaylist(
|
||||||
|
Guid id,
|
||||||
|
Guid templateId,
|
||||||
|
Guid variantId,
|
||||||
|
MediaPathResolver paths
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var previewId = BumperPreview.AssetId(variantId);
|
||||||
|
string indexPath;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
indexPath = paths.SegmentPath(previewId, "index.m3u8");
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
if (!File.Exists(indexPath))
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
|
var baseUrl =
|
||||||
|
$"/api/admin/channels/{id}/bumper/templates/{templateId}/preview/{variantId}/";
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var line in File.ReadLines(indexPath))
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
if (trimmed.Length == 0)
|
||||||
|
continue;
|
||||||
|
// Комментарии/директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
|
||||||
|
sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
|
||||||
|
.Append('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult PreviewSegment(
|
||||||
|
Guid id,
|
||||||
|
Guid templateId,
|
||||||
|
Guid variantId,
|
||||||
|
string file,
|
||||||
|
MediaPathResolver paths
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (!BumperSegmentFileName.IsMatch(file))
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
|
var previewId = BumperPreview.AssetId(variantId);
|
||||||
|
string path;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
path = paths.SegmentPath(previewId, file);
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
if (!File.Exists(path))
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
|
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AddBumperTemplateBody(string Name);
|
||||||
|
|
||||||
|
public sealed record UpdateBumperTemplateBody(
|
||||||
|
string Name,
|
||||||
|
string BackgroundColor,
|
||||||
|
string BackgroundColor2,
|
||||||
|
string AccentColor,
|
||||||
|
string TextColor
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record SetBumperTemplateBackgroundBody(Guid ImageId);
|
||||||
|
|
||||||
|
public sealed record AddBumperVariantBody(string Name);
|
||||||
|
|
||||||
|
public sealed record UpdateBumperVariantBody(
|
||||||
|
string Name,
|
||||||
|
BumperTextKind Kind,
|
||||||
|
string NowLabel,
|
||||||
|
string NextLabel,
|
||||||
|
string Line1,
|
||||||
|
string Line2,
|
||||||
|
BumperTrigger Trigger,
|
||||||
|
int Weight
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр).</summary>
|
||||||
|
internal static class BumperFiles
|
||||||
|
{
|
||||||
|
public const long MaxBytes = 200L * 1024 * 1024; // 200 МБ
|
||||||
|
|
||||||
|
public static readonly IReadOnlySet<string> AudioExtensions = new HashSet<string>(
|
||||||
|
StringComparer.OrdinalIgnoreCase
|
||||||
|
)
|
||||||
|
{
|
||||||
|
".mp3",
|
||||||
|
".m4a",
|
||||||
|
".aac",
|
||||||
|
".ogg",
|
||||||
|
".opus",
|
||||||
|
".wav",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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,36 +1,25 @@
|
|||||||
using System.Text;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Application.Broadcast;
|
using TeleWave.Application.Broadcast;
|
||||||
using TeleWave.Application.Broadcast.AddChannelAd;
|
|
||||||
using TeleWave.Application.Broadcast.AddChannelShow;
|
|
||||||
using TeleWave.Application.Broadcast.Bumpers;
|
|
||||||
using TeleWave.Application.Broadcast.CreateChannel;
|
using TeleWave.Application.Broadcast.CreateChannel;
|
||||||
using TeleWave.Application.Broadcast.CreateOverride;
|
|
||||||
using TeleWave.Application.Broadcast.DeleteOverride;
|
|
||||||
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.RegenerateSchedule;
|
||||||
using TeleWave.Application.Broadcast.RemoveChannelAd;
|
|
||||||
using TeleWave.Application.Broadcast.RemoveChannelShow;
|
|
||||||
using TeleWave.Application.Broadcast.UpdateChannelSettings;
|
using TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||||
using TeleWave.Application.Broadcast.UpdateChannelShow;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Infrastructure.Identity;
|
||||||
using TeleWave.Infrastructure.Media;
|
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
public static class ChannelEndpoints
|
/// <summary>
|
||||||
|
/// Админ-эндпоинты канала. Реализация разнесена по partial-файлам под-ресурсов:
|
||||||
|
/// <c>ChannelEndpoints.Shows.cs</c> (шоу+реклама), <c>ChannelEndpoints.Bumpers.cs</c>
|
||||||
|
/// (блоки/подблоки/файлы/preview), <c>ChannelEndpoints.Overrides.cs</c> (override'ы). Здесь —
|
||||||
|
/// регистрация всех маршрутов и хендлеры уровня канала (создание/список/настройки/расписание).
|
||||||
|
/// </summary>
|
||||||
|
public static partial class ChannelEndpoints
|
||||||
{
|
{
|
||||||
private static readonly Regex BumperSegmentFileName = new(
|
|
||||||
@"^seg\d{1,6}\.ts$",
|
|
||||||
RegexOptions.Compiled
|
|
||||||
);
|
|
||||||
|
|
||||||
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")
|
||||||
@@ -189,416 +178,6 @@ public static class ChannelEndpoints
|
|||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> AddBumperTemplate(
|
|
||||||
Guid id,
|
|
||||||
AddBumperTemplateBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new AddBumperTemplateCommand(id, body.Name),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.IsSuccess
|
|
||||||
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
|
|
||||||
: result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> UpdateBumperTemplate(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
UpdateBumperTemplateBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new UpdateBumperTemplateCommand(
|
|
||||||
id,
|
|
||||||
templateId,
|
|
||||||
body.Name,
|
|
||||||
body.BackgroundColor,
|
|
||||||
body.BackgroundColor2,
|
|
||||||
body.AccentColor,
|
|
||||||
body.TextColor
|
|
||||||
),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> RemoveBumperTemplate(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new RemoveBumperTemplateCommand(id, templateId),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> UploadTemplateAudio(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
string fileName,
|
|
||||||
HttpRequest request,
|
|
||||||
IBumperTemplateStorage storage,
|
|
||||||
IAudioProbe probe,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (ResolveBumperExtension(fileName, request, BumperFiles.AudioExtensions) is not { } ext)
|
|
||||||
return ChannelErrors.InvalidBumperFile.ToProblem();
|
|
||||||
|
|
||||||
await storage.SaveAudioAsync(templateId, ext, request.Body, cancellationToken);
|
|
||||||
|
|
||||||
// Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина).
|
|
||||||
var path = storage.AudioPath(templateId, ext);
|
|
||||||
var duration = path is null
|
|
||||||
? null
|
|
||||||
: await probe.TryGetDurationAsync(path, cancellationToken);
|
|
||||||
|
|
||||||
var result = await sender.Send(
|
|
||||||
new SetBumperTemplateAudioCommand(id, templateId, ext, duration?.TotalSeconds ?? 0),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
if (!result.IsSuccess)
|
|
||||||
storage.DeleteAudio(templateId);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> ClearTemplateAudio(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new ClearBumperTemplateAudioCommand(id, templateId),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> SetTemplateBackground(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
SetBumperTemplateBackgroundBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new SetBumperTemplateBackgroundCommand(id, templateId, body.ImageId),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> AddBumperVariant(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
AddBumperVariantBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new AddBumperTextVariantCommand(id, templateId, body.Name),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.IsSuccess
|
|
||||||
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
|
|
||||||
: result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> UpdateBumperVariant(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
Guid variantId,
|
|
||||||
UpdateBumperVariantBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new UpdateBumperTextVariantCommand(
|
|
||||||
id,
|
|
||||||
templateId,
|
|
||||||
variantId,
|
|
||||||
body.Name,
|
|
||||||
body.Kind,
|
|
||||||
body.NowLabel,
|
|
||||||
body.NextLabel,
|
|
||||||
body.Line1,
|
|
||||||
body.Line2,
|
|
||||||
body.Trigger,
|
|
||||||
body.Weight
|
|
||||||
),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> RemoveBumperVariant(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
Guid variantId,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new RemoveBumperTextVariantCommand(id, templateId, variantId),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> ClearTemplateBackground(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new ClearBumperTemplateBackgroundCommand(id, templateId),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Синхронно рендерит пример заставки блока (несколько секунд ffmpeg).</summary>
|
|
||||||
private static async Task<IResult> RenderPreview(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new RenderBumperPreviewQuery(id, templateId),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.IsSuccess ? Results.NoContent() : result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Плейлист превью подблока: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
|
||||||
private static IResult PreviewPlaylist(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
Guid variantId,
|
|
||||||
MediaPathResolver paths
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var previewId = BumperPreview.AssetId(variantId);
|
|
||||||
string indexPath;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
indexPath = paths.SegmentPath(previewId, "index.m3u8");
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException)
|
|
||||||
{
|
|
||||||
return Results.NotFound();
|
|
||||||
}
|
|
||||||
if (!File.Exists(indexPath))
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
var baseUrl =
|
|
||||||
$"/api/admin/channels/{id}/bumper/templates/{templateId}/preview/{variantId}/";
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
foreach (var line in File.ReadLines(indexPath))
|
|
||||||
{
|
|
||||||
var trimmed = line.Trim();
|
|
||||||
if (trimmed.Length == 0)
|
|
||||||
continue;
|
|
||||||
// Комментарии/директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
|
|
||||||
sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
|
|
||||||
.Append('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IResult PreviewSegment(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
Guid variantId,
|
|
||||||
string file,
|
|
||||||
MediaPathResolver paths
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (!BumperSegmentFileName.IsMatch(file))
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
var previewId = BumperPreview.AssetId(variantId);
|
|
||||||
string path;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
path = paths.SegmentPath(previewId, file);
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException)
|
|
||||||
{
|
|
||||||
return Results.NotFound();
|
|
||||||
}
|
|
||||||
if (!File.Exists(path))
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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,
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> Regenerate(
|
private static async Task<IResult> Regenerate(
|
||||||
Guid id,
|
Guid id,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
@@ -636,75 +215,3 @@ public sealed record UpdateChannelSettingsBody(
|
|||||||
BumperSettingsInput Bumper,
|
BumperSettingsInput Bumper,
|
||||||
Guid? FillerAssetId
|
Guid? FillerAssetId
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record AddChannelShowBody(
|
|
||||||
Guid ShowId,
|
|
||||||
int Weight,
|
|
||||||
BlockMode BlockMode,
|
|
||||||
int BlockValue
|
|
||||||
);
|
|
||||||
|
|
||||||
public sealed record UpdateChannelShowBody(
|
|
||||||
int Weight,
|
|
||||||
BlockMode BlockMode,
|
|
||||||
int BlockValue,
|
|
||||||
bool IsEnabled,
|
|
||||||
int PreferredWeightMultiplier,
|
|
||||||
IReadOnlyList<HourWindowInput> PreferredHours
|
|
||||||
);
|
|
||||||
|
|
||||||
public sealed record AddChannelAdBody(Guid MediaAssetId);
|
|
||||||
|
|
||||||
public sealed record AddBumperTemplateBody(string Name);
|
|
||||||
|
|
||||||
public sealed record SetBumperTemplateBackgroundBody(Guid ImageId);
|
|
||||||
|
|
||||||
public sealed record AddBumperVariantBody(string Name);
|
|
||||||
|
|
||||||
public sealed record UpdateBumperVariantBody(
|
|
||||||
string Name,
|
|
||||||
BumperTextKind Kind,
|
|
||||||
string NowLabel,
|
|
||||||
string NextLabel,
|
|
||||||
string Line1,
|
|
||||||
string Line2,
|
|
||||||
BumperTrigger Trigger,
|
|
||||||
int Weight
|
|
||||||
);
|
|
||||||
|
|
||||||
public sealed record UpdateBumperTemplateBody(
|
|
||||||
string Name,
|
|
||||||
string BackgroundColor,
|
|
||||||
string BackgroundColor2,
|
|
||||||
string AccentColor,
|
|
||||||
string TextColor
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр).</summary>
|
|
||||||
internal static class BumperFiles
|
|
||||||
{
|
|
||||||
public const long MaxBytes = 200L * 1024 * 1024; // 200 МБ
|
|
||||||
|
|
||||||
public static readonly IReadOnlySet<string> AudioExtensions = new HashSet<string>(
|
|
||||||
StringComparer.OrdinalIgnoreCase
|
|
||||||
)
|
|
||||||
{
|
|
||||||
".mp3",
|
|
||||||
".m4a",
|
|
||||||
".aac",
|
|
||||||
".ogg",
|
|
||||||
".opus",
|
|
||||||
".wav",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record CreateOverrideBody(
|
|
||||||
OverrideMode Mode,
|
|
||||||
OverrideRecurrence Recurrence,
|
|
||||||
DateTimeOffset? StartsAtUtc,
|
|
||||||
DateTimeOffset? EndsAtUtc,
|
|
||||||
int? DayOfWeek,
|
|
||||||
int? StartMinute,
|
|
||||||
int? EndMinute,
|
|
||||||
IReadOnlyList<OverrideShowInput> Shows
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Broadcast.Scheduling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Длительность заставки блока: по длине загруженного звука (иначе дефолт для синтеза), выровненная
|
||||||
|
/// вверх до кратности длине сегмента — ключевой инвариант эфирной математики. Общая для генератора
|
||||||
|
/// (слоты в плане) и резолвера заставок (рендер).
|
||||||
|
/// </summary>
|
||||||
|
internal static class BumperDuration
|
||||||
|
{
|
||||||
|
/// <summary>Длительность заставки без загруженного звука (сек) — синтезированный джингл.</summary>
|
||||||
|
private const int DefaultSeconds = 8;
|
||||||
|
|
||||||
|
/// <summary>Длина заставки блока (сек): по загруженному звуку либо дефолт для синтеза.</summary>
|
||||||
|
public static double TemplateSeconds(BumperTemplate template) =>
|
||||||
|
template.AudioDurationSeconds is { } d and > 0 ? d : DefaultSeconds;
|
||||||
|
|
||||||
|
/// <summary>Секунды, выровненные вверх до кратности длине сегмента.</summary>
|
||||||
|
public static int Aligned(double seconds, int segmentSeconds)
|
||||||
|
{
|
||||||
|
var requested = Math.Max(segmentSeconds, seconds);
|
||||||
|
return (int)(Math.Ceiling(requested / segmentSeconds) * segmentSeconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
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 готового ассета — из кэша (<see cref="BumperAsset"/>) либо
|
||||||
|
/// свежесгенерированного ffmpeg-рендером. Работает через тот же scoped <see cref="IAppDbContext"/>,
|
||||||
|
/// что и <see cref="ScheduleGenerator"/>: добавленные ассеты сохраняются его общим SaveChanges.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ScheduleBumperResolver(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
IBumperRenderer bumperRenderer,
|
||||||
|
IBumperTemplateStorage bumperStorage,
|
||||||
|
IImageStore imageStore,
|
||||||
|
IOptions<BumperOptions> bumperOptions,
|
||||||
|
IOptions<StreamingOptions> streamingOptions,
|
||||||
|
ILogger<ScheduleBumperResolver> logger
|
||||||
|
)
|
||||||
|
{
|
||||||
|
private readonly BumperOptions _bumper = bumperOptions.Value;
|
||||||
|
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Для каждой уникальной тройки «из→в→подблок» из запланированных заставок возвращает id готового
|
||||||
|
/// ассета-заставки: из кэша либо свежесгенерированного.
|
||||||
|
/// </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 постера → расширение → абсолютный путь.
|
||||||
|
var showIds = fromIds.Concat(toIds).Distinct().ToList();
|
||||||
|
var posterShows = await dbContext
|
||||||
|
.Shows.AsNoTracking()
|
||||||
|
.Where(s => showIds.Contains(s.Id) && s.PosterImageId != null)
|
||||||
|
.Select(s => new { s.Id, ImageId = s.PosterImageId!.Value })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var posterImageIds = posterShows.Select(p => p.ImageId).Distinct().ToList();
|
||||||
|
var posterExtById = await dbContext
|
||||||
|
.Images.AsNoTracking()
|
||||||
|
.Where(i => posterImageIds.Contains(i.Id))
|
||||||
|
.Select(i => new { i.Id, i.FileExtension })
|
||||||
|
.ToDictionaryAsync(i => i.Id, i => i.FileExtension, cancellationToken);
|
||||||
|
var posterByShow = new Dictionary<Guid, (Guid ImageId, string AbsPath)>();
|
||||||
|
foreach (var p in posterShows)
|
||||||
|
if (
|
||||||
|
posterExtById.TryGetValue(p.ImageId, out var ext)
|
||||||
|
&& imageStore.ResolvePath(p.ImageId, ext) is { } abs
|
||||||
|
)
|
||||||
|
posterByShow[p.Id] = (p.ImageId, abs);
|
||||||
|
|
||||||
|
// Фон-картинки блоков (из реестра) — абсолютные пути по id.
|
||||||
|
var bgImageIds = channel
|
||||||
|
.BumperTemplates.Where(t => t.BackgroundImageId != null)
|
||||||
|
.Select(t => t.BackgroundImageId!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
var bgExtById = await dbContext
|
||||||
|
.Images.AsNoTracking()
|
||||||
|
.Where(i => bgImageIds.Contains(i.Id))
|
||||||
|
.Select(i => new { i.Id, i.FileExtension })
|
||||||
|
.ToDictionaryAsync(i => i.Id, i => i.FileExtension, cancellationToken);
|
||||||
|
var bgByTemplate = new Dictionary<Guid, string>();
|
||||||
|
foreach (var t in channel.BumperTemplates)
|
||||||
|
if (
|
||||||
|
t.BackgroundImageId is { } bgId
|
||||||
|
&& bgExtById.TryGetValue(bgId, out var bgExt)
|
||||||
|
&& imageStore.ResolvePath(bgId, bgExt) is { } bgAbs
|
||||||
|
)
|
||||||
|
bgByTemplate[t.Id] = bgAbs;
|
||||||
|
|
||||||
|
// Кандидаты из кэша + статусы их ассетов (годятся только Ready — файлы могли удалить).
|
||||||
|
var cached = await dbContext
|
||||||
|
.BumperAssets.AsNoTracking()
|
||||||
|
.Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId))
|
||||||
|
.Select(b => new
|
||||||
|
{
|
||||||
|
b.FromShowId,
|
||||||
|
b.ToShowId,
|
||||||
|
b.Signature,
|
||||||
|
b.MediaAssetId,
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var cachedAssetIds = cached.Select(c => c.MediaAssetId).Distinct().ToList();
|
||||||
|
var readyAssetIds = await dbContext
|
||||||
|
.MediaAssets.AsNoTracking()
|
||||||
|
.Where(a => cachedAssetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready)
|
||||||
|
.Select(a => a.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var readySet = readyAssetIds.ToHashSet();
|
||||||
|
|
||||||
|
foreach (var combo in combos)
|
||||||
|
{
|
||||||
|
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 poster = usePoster && posterByShow.TryGetValue(combo.To, out var pr) ? pr : default;
|
||||||
|
var posterToken = poster.ImageId == Guid.Empty ? "-" : poster.ImageId.ToString();
|
||||||
|
var posterAbs = poster.ImageId == Guid.Empty ? null : poster.AbsPath;
|
||||||
|
var bgAbs = bgByTemplate.GetValueOrDefault(template.Id);
|
||||||
|
var aligned = BumperDuration.Aligned(
|
||||||
|
BumperDuration.TemplateSeconds(template),
|
||||||
|
_segmentSeconds
|
||||||
|
);
|
||||||
|
var signature = ComputeSignature(
|
||||||
|
channel,
|
||||||
|
template,
|
||||||
|
variant,
|
||||||
|
fromName,
|
||||||
|
toName,
|
||||||
|
aligned,
|
||||||
|
posterToken
|
||||||
|
);
|
||||||
|
|
||||||
|
var hit = cached.FirstOrDefault(c =>
|
||||||
|
c.FromShowId == combo.From
|
||||||
|
&& c.ToShowId == combo.To
|
||||||
|
&& c.Signature == signature
|
||||||
|
&& readySet.Contains(c.MediaAssetId)
|
||||||
|
);
|
||||||
|
if (hit is not null)
|
||||||
|
{
|
||||||
|
result[combo] = hit.MediaAssetId;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var assetId = await RenderAsync(
|
||||||
|
channel,
|
||||||
|
template,
|
||||||
|
variant,
|
||||||
|
combo.From,
|
||||||
|
combo.To,
|
||||||
|
fromName,
|
||||||
|
toName,
|
||||||
|
aligned,
|
||||||
|
signature,
|
||||||
|
posterAbs,
|
||||||
|
bgAbs,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
result[combo] = assetId;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
ex,
|
||||||
|
"Не удалось отрендерить заставку {From} → {To}",
|
||||||
|
fromName,
|
||||||
|
toName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Guid> RenderAsync(
|
||||||
|
Channel channel,
|
||||||
|
BumperTemplate template,
|
||||||
|
BumperTextVariant variant,
|
||||||
|
Guid fromShowId,
|
||||||
|
Guid toShowId,
|
||||||
|
string fromName,
|
||||||
|
string toName,
|
||||||
|
int alignedDurationSeconds,
|
||||||
|
string signature,
|
||||||
|
string? posterAbsolutePath,
|
||||||
|
string? backgroundAbsolutePath,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}");
|
||||||
|
var render = await bumperRenderer.RenderAsync(
|
||||||
|
asset.Id,
|
||||||
|
BuildSpec(
|
||||||
|
channel,
|
||||||
|
template,
|
||||||
|
variant,
|
||||||
|
alignedDurationSeconds,
|
||||||
|
fromName,
|
||||||
|
toName,
|
||||||
|
posterAbsolutePath,
|
||||||
|
backgroundAbsolutePath
|
||||||
|
),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
asset.MarkReady(
|
||||||
|
render.Duration,
|
||||||
|
render.SegmentSeconds,
|
||||||
|
render.SegmentCount,
|
||||||
|
render.Width,
|
||||||
|
render.Height,
|
||||||
|
"h264",
|
||||||
|
"aac",
|
||||||
|
render.RelativePath
|
||||||
|
);
|
||||||
|
|
||||||
|
dbContext.MediaAssets.Add(asset);
|
||||||
|
dbContext.BumperAssets.Add(BumperAsset.Create(fromShowId, toShowId, signature, asset.Id));
|
||||||
|
return asset.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BumperRenderSpec BuildSpec(
|
||||||
|
Channel channel,
|
||||||
|
BumperTemplate template,
|
||||||
|
BumperTextVariant variant,
|
||||||
|
int alignedDurationSeconds,
|
||||||
|
string fromName,
|
||||||
|
string toName,
|
||||||
|
string? posterAbsolutePath,
|
||||||
|
string? backgroundAbsolutePath
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var free = variant.Kind == BumperTextKind.Free;
|
||||||
|
return new BumperRenderSpec(
|
||||||
|
alignedDurationSeconds,
|
||||||
|
_bumper.Width,
|
||||||
|
_bumper.Height,
|
||||||
|
template.BackgroundColor,
|
||||||
|
template.BackgroundColor2,
|
||||||
|
template.AccentColor,
|
||||||
|
template.TextColor,
|
||||||
|
FontPath(channel.BumperFont),
|
||||||
|
free ? "" : variant.NowLabel,
|
||||||
|
free ? "" : fromName,
|
||||||
|
free ? "" : variant.NextLabel,
|
||||||
|
free ? "" : toName,
|
||||||
|
backgroundAbsolutePath,
|
||||||
|
bumperStorage.AudioPath(template.Id, template.AudioExtension),
|
||||||
|
posterAbsolutePath,
|
||||||
|
free,
|
||||||
|
variant.Line1,
|
||||||
|
variant.Line2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FontPath(BumperFont font) =>
|
||||||
|
font == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans;
|
||||||
|
|
||||||
|
/// <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,9 +1,5 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using TeleWave.Application.Broadcast.Bumpers;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Streaming;
|
using TeleWave.Application.Streaming;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
@@ -16,28 +12,19 @@ namespace TeleWave.Application.Broadcast.Scheduling;
|
|||||||
/// Оркестратор планирования: загружает конфигурацию канала и готовые ассеты, вызывает чистый
|
/// Оркестратор планирования: загружает конфигурацию канала и готовые ассеты, вызывает чистый
|
||||||
/// <see cref="SchedulePlanner"/>, материализует записи и двигает курсоры. Используется фоновым
|
/// <see cref="SchedulePlanner"/>, материализует записи и двигает курсоры. Используется фоновым
|
||||||
/// планировщиком (расширение горизонта) и командой перегенерации (правка конфигурации канала).
|
/// планировщиком (расширение горизонта) и командой перегенерации (правка конфигурации канала).
|
||||||
/// Заставки-переходы, отмеченные планировщиком, здесь рендерятся (или берутся из кэша) по выбранному
|
/// Ассеты заставок-переходов резолвит/рендерит <see cref="ScheduleBumperResolver"/> (общий контекст).
|
||||||
/// блоку и подставляются как обычные ассеты.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ScheduleGenerator(
|
public sealed class ScheduleGenerator(
|
||||||
IAppDbContext dbContext,
|
IAppDbContext dbContext,
|
||||||
IRandomSource random,
|
IRandomSource random,
|
||||||
IBumperRenderer bumperRenderer,
|
ScheduleBumperResolver bumperResolver,
|
||||||
IBumperTemplateStorage bumperStorage,
|
|
||||||
IImageStore imageStore,
|
|
||||||
IOptions<SchedulerOptions> options,
|
IOptions<SchedulerOptions> options,
|
||||||
IOptions<BumperOptions> bumperOptions,
|
IOptions<StreamingOptions> streamingOptions
|
||||||
IOptions<StreamingOptions> streamingOptions,
|
|
||||||
ILogger<ScheduleGenerator> logger
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
private readonly SchedulerOptions _options = options.Value;
|
private readonly SchedulerOptions _options = options.Value;
|
||||||
private readonly BumperOptions _bumper = bumperOptions.Value;
|
|
||||||
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
||||||
|
|
||||||
/// <summary>Длительность заставки без загруженного звука (сек) — синтезированный джингл.</summary>
|
|
||||||
private const int DefaultBumperDurationSeconds = 8;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Достраивает (или, при <paramref name="regenerate"/>, перестраивает будущий хвост) расписание
|
/// Достраивает (или, при <paramref name="regenerate"/>, перестраивает будущий хвост) расписание
|
||||||
/// канала до горизонта. Возвращает число добавленных записей (-1 — канал не найден/выключен).
|
/// канала до горизонта. Возвращает число добавленных записей (-1 — канал не найден/выключен).
|
||||||
@@ -97,8 +84,8 @@ public sealed class ScheduleGenerator(
|
|||||||
var input = await BuildInputAsync(channel, startTime, horizonEnd, cancellationToken);
|
var input = await BuildInputAsync(channel, startTime, horizonEnd, cancellationToken);
|
||||||
var result = SchedulePlanner.Plan(input, random);
|
var result = SchedulePlanner.Plan(input, random);
|
||||||
|
|
||||||
// Рендерим/достаём из кэша ассеты заставок для всех переходов плана (по паре шоу + блоку).
|
// Рендерим/достаём из кэша ассеты заставок для всех переходов плана (по паре шоу + подблоку).
|
||||||
var bumperAssets = await ResolveBumperAssetsAsync(
|
var bumperAssets = await bumperResolver.ResolveAsync(
|
||||||
channel,
|
channel,
|
||||||
result.Entries,
|
result.Entries,
|
||||||
showNames,
|
showNames,
|
||||||
@@ -172,315 +159,6 @@ public sealed class ScheduleGenerator(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Для каждой уникальной тройки «из→в→подблок» из запланированных заставок возвращает id готового
|
|
||||||
/// ассета-заставки: из кэша (<see cref="BumperAsset"/>) либо свежесгенерированного.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<
|
|
||||||
Dictionary<(Guid From, Guid To, Guid Variant), Guid>
|
|
||||||
> ResolveBumperAssetsAsync(
|
|
||||||
Channel channel,
|
|
||||||
IReadOnlyList<PlannedEntry> entries,
|
|
||||||
IReadOnlyDictionary<Guid, string> showNames,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = new Dictionary<(Guid, Guid, Guid), Guid>();
|
|
||||||
var templatesById = channel.BumperTemplates.ToDictionary(t => t.Id);
|
|
||||||
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 постера → расширение → абсолютный путь.
|
|
||||||
var showIds = fromIds.Concat(toIds).Distinct().ToList();
|
|
||||||
var posterShows = await dbContext
|
|
||||||
.Shows.AsNoTracking()
|
|
||||||
.Where(s => showIds.Contains(s.Id) && s.PosterImageId != null)
|
|
||||||
.Select(s => new { s.Id, ImageId = s.PosterImageId!.Value })
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
var posterImageIds = posterShows.Select(p => p.ImageId).Distinct().ToList();
|
|
||||||
var posterExtById = await dbContext
|
|
||||||
.Images.AsNoTracking()
|
|
||||||
.Where(i => posterImageIds.Contains(i.Id))
|
|
||||||
.Select(i => new { i.Id, i.FileExtension })
|
|
||||||
.ToDictionaryAsync(i => i.Id, i => i.FileExtension, cancellationToken);
|
|
||||||
var posterByShow = new Dictionary<Guid, (Guid ImageId, string AbsPath)>();
|
|
||||||
foreach (var p in posterShows)
|
|
||||||
if (
|
|
||||||
posterExtById.TryGetValue(p.ImageId, out var ext)
|
|
||||||
&& imageStore.ResolvePath(p.ImageId, ext) is { } abs
|
|
||||||
)
|
|
||||||
posterByShow[p.Id] = (p.ImageId, abs);
|
|
||||||
|
|
||||||
// Фон-картинки блоков (из реестра) — абсолютные пути по id.
|
|
||||||
var bgImageIds = channel
|
|
||||||
.BumperTemplates.Where(t => t.BackgroundImageId != null)
|
|
||||||
.Select(t => t.BackgroundImageId!.Value)
|
|
||||||
.Distinct()
|
|
||||||
.ToList();
|
|
||||||
var bgExtById = await dbContext
|
|
||||||
.Images.AsNoTracking()
|
|
||||||
.Where(i => bgImageIds.Contains(i.Id))
|
|
||||||
.Select(i => new { i.Id, i.FileExtension })
|
|
||||||
.ToDictionaryAsync(i => i.Id, i => i.FileExtension, cancellationToken);
|
|
||||||
var bgByTemplate = new Dictionary<Guid, string>();
|
|
||||||
foreach (var t in channel.BumperTemplates)
|
|
||||||
if (
|
|
||||||
t.BackgroundImageId is { } bgId
|
|
||||||
&& bgExtById.TryGetValue(bgId, out var bgExt)
|
|
||||||
&& imageStore.ResolvePath(bgId, bgExt) is { } bgAbs
|
|
||||||
)
|
|
||||||
bgByTemplate[t.Id] = bgAbs;
|
|
||||||
|
|
||||||
// Кандидаты из кэша + статусы их ассетов (годятся только Ready — файлы могли удалить).
|
|
||||||
var cached = await dbContext
|
|
||||||
.BumperAssets.AsNoTracking()
|
|
||||||
.Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId))
|
|
||||||
.Select(b => new
|
|
||||||
{
|
|
||||||
b.FromShowId,
|
|
||||||
b.ToShowId,
|
|
||||||
b.Signature,
|
|
||||||
b.MediaAssetId,
|
|
||||||
})
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var cachedAssetIds = cached.Select(c => c.MediaAssetId).Distinct().ToList();
|
|
||||||
var readyAssetIds = await dbContext
|
|
||||||
.MediaAssets.AsNoTracking()
|
|
||||||
.Where(a => cachedAssetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready)
|
|
||||||
.Select(a => a.Id)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
var readySet = readyAssetIds.ToHashSet();
|
|
||||||
|
|
||||||
foreach (var combo in combos)
|
|
||||||
{
|
|
||||||
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 poster = usePoster && posterByShow.TryGetValue(combo.To, out var pr) ? pr : default;
|
|
||||||
var posterToken = poster.ImageId == Guid.Empty ? "-" : poster.ImageId.ToString();
|
|
||||||
var posterAbs = poster.ImageId == Guid.Empty ? null : poster.AbsPath;
|
|
||||||
var bgAbs = bgByTemplate.GetValueOrDefault(template.Id);
|
|
||||||
var aligned = AlignedDurationSeconds(TemplateDurationSeconds(template));
|
|
||||||
var signature = ComputeSignature(
|
|
||||||
channel,
|
|
||||||
template,
|
|
||||||
variant,
|
|
||||||
fromName,
|
|
||||||
toName,
|
|
||||||
aligned,
|
|
||||||
posterToken
|
|
||||||
);
|
|
||||||
|
|
||||||
var hit = cached.FirstOrDefault(c =>
|
|
||||||
c.FromShowId == combo.From
|
|
||||||
&& c.ToShowId == combo.To
|
|
||||||
&& c.Signature == signature
|
|
||||||
&& readySet.Contains(c.MediaAssetId)
|
|
||||||
);
|
|
||||||
if (hit is not null)
|
|
||||||
{
|
|
||||||
result[combo] = hit.MediaAssetId;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var assetId = await RenderBumperAsync(
|
|
||||||
channel,
|
|
||||||
template,
|
|
||||||
variant,
|
|
||||||
combo.From,
|
|
||||||
combo.To,
|
|
||||||
fromName,
|
|
||||||
toName,
|
|
||||||
aligned,
|
|
||||||
signature,
|
|
||||||
posterAbs,
|
|
||||||
bgAbs,
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
result[combo] = assetId;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogError(
|
|
||||||
ex,
|
|
||||||
"Не удалось отрендерить заставку {From} → {To}",
|
|
||||||
fromName,
|
|
||||||
toName
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Guid> RenderBumperAsync(
|
|
||||||
Channel channel,
|
|
||||||
BumperTemplate template,
|
|
||||||
BumperTextVariant variant,
|
|
||||||
Guid fromShowId,
|
|
||||||
Guid toShowId,
|
|
||||||
string fromName,
|
|
||||||
string toName,
|
|
||||||
int alignedDurationSeconds,
|
|
||||||
string signature,
|
|
||||||
string? posterAbsolutePath,
|
|
||||||
string? backgroundAbsolutePath,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}");
|
|
||||||
var render = await bumperRenderer.RenderAsync(
|
|
||||||
asset.Id,
|
|
||||||
BuildSpec(
|
|
||||||
channel,
|
|
||||||
template,
|
|
||||||
variant,
|
|
||||||
alignedDurationSeconds,
|
|
||||||
fromName,
|
|
||||||
toName,
|
|
||||||
posterAbsolutePath,
|
|
||||||
backgroundAbsolutePath
|
|
||||||
),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
|
|
||||||
asset.MarkReady(
|
|
||||||
render.Duration,
|
|
||||||
render.SegmentSeconds,
|
|
||||||
render.SegmentCount,
|
|
||||||
render.Width,
|
|
||||||
render.Height,
|
|
||||||
"h264",
|
|
||||||
"aac",
|
|
||||||
render.RelativePath
|
|
||||||
);
|
|
||||||
|
|
||||||
dbContext.MediaAssets.Add(asset);
|
|
||||||
dbContext.BumperAssets.Add(BumperAsset.Create(fromShowId, toShowId, signature, asset.Id));
|
|
||||||
return asset.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private BumperRenderSpec BuildSpec(
|
|
||||||
Channel channel,
|
|
||||||
BumperTemplate template,
|
|
||||||
BumperTextVariant variant,
|
|
||||||
int alignedDurationSeconds,
|
|
||||||
string fromName,
|
|
||||||
string toName,
|
|
||||||
string? posterAbsolutePath,
|
|
||||||
string? backgroundAbsolutePath
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var free = variant.Kind == BumperTextKind.Free;
|
|
||||||
return new BumperRenderSpec(
|
|
||||||
alignedDurationSeconds,
|
|
||||||
_bumper.Width,
|
|
||||||
_bumper.Height,
|
|
||||||
template.BackgroundColor,
|
|
||||||
template.BackgroundColor2,
|
|
||||||
template.AccentColor,
|
|
||||||
template.TextColor,
|
|
||||||
FontPath(channel.BumperFont),
|
|
||||||
free ? "" : variant.NowLabel,
|
|
||||||
free ? "" : fromName,
|
|
||||||
free ? "" : variant.NextLabel,
|
|
||||||
free ? "" : toName,
|
|
||||||
backgroundAbsolutePath,
|
|
||||||
bumperStorage.AudioPath(template.Id, template.AudioExtension),
|
|
||||||
posterAbsolutePath,
|
|
||||||
free,
|
|
||||||
variant.Line1,
|
|
||||||
variant.Line2
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FontPath(BumperFont font) =>
|
|
||||||
font == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans;
|
|
||||||
|
|
||||||
/// <summary>Длина заставки блока (сек): по загруженному звуку либо дефолт для синтеза.</summary>
|
|
||||||
private static double TemplateDurationSeconds(BumperTemplate template) =>
|
|
||||||
template.AudioDurationSeconds is { } d and > 0 ? d : DefaultBumperDurationSeconds;
|
|
||||||
|
|
||||||
/// <summary>Длительность, выровненная вверх до кратности длине сегмента (инвариант раздачи).</summary>
|
|
||||||
private int AlignedDurationSeconds(double seconds)
|
|
||||||
{
|
|
||||||
var requested = Math.Max(_segmentSeconds, seconds);
|
|
||||||
return (int)(Math.Ceiling(requested / _segmentSeconds) * _segmentSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Сигнатура рендера = хэш всех входов заставки: общие настройки канала (шрифт/подписи/версия
|
|
||||||
/// шаблона), оформление и файлы блока (цвета/фон/звук/ревизия), названия шоу и постер. Меняется —
|
|
||||||
/// заставка пересобирается.
|
|
||||||
/// </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);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Dictionary<Guid, string>> LoadShowNamesAsync(
|
private async Task<Dictionary<Guid, string>> LoadShowNamesAsync(
|
||||||
Channel channel,
|
Channel channel,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
@@ -569,7 +247,9 @@ public sealed class ScheduleGenerator(
|
|||||||
.BumperTemplates.OrderBy(t => t.Position)
|
.BumperTemplates.OrderBy(t => t.Position)
|
||||||
.SelectMany(t =>
|
.SelectMany(t =>
|
||||||
{
|
{
|
||||||
var dur = TimeSpan.FromSeconds(AlignedDurationSeconds(TemplateDurationSeconds(t)));
|
var dur = TimeSpan.FromSeconds(
|
||||||
|
BumperDuration.Aligned(BumperDuration.TemplateSeconds(t), _segmentSeconds)
|
||||||
|
);
|
||||||
return t
|
return t
|
||||||
.Variants.OrderBy(v => v.Position)
|
.Variants.OrderBy(v => v.Position)
|
||||||
.Select(v => new PlannerBumperVariant(v.Id, t.Id, dur, v.Trigger, v.Weight));
|
.Select(v => new PlannerBumperVariant(v.Id, t.Id, dur, v.Trigger, v.Weight));
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ public static class DependencyInjection
|
|||||||
services.AddSingleton<IRandomSource, SystemRandomSource>();
|
services.AddSingleton<IRandomSource, SystemRandomSource>();
|
||||||
services.AddSingleton<StreamTokenService>();
|
services.AddSingleton<StreamTokenService>();
|
||||||
services.AddSingleton<IBumperRenderer, FfmpegBumperRenderer>();
|
services.AddSingleton<IBumperRenderer, FfmpegBumperRenderer>();
|
||||||
|
services.AddScoped<ScheduleBumperResolver>();
|
||||||
services.AddScoped<ScheduleGenerator>();
|
services.AddScoped<ScheduleGenerator>();
|
||||||
services.AddHostedService<SchedulingBackgroundService>();
|
services.AddHostedService<SchedulingBackgroundService>();
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
|
import { addChannelAd } from '../api'
|
||||||
|
|
||||||
|
export function AddAdForm({
|
||||||
|
channelId,
|
||||||
|
options,
|
||||||
|
onAdded,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
channelId: string
|
||||||
|
options: { id: string; name: string }[]
|
||||||
|
onAdded: () => void
|
||||||
|
onError: (e: unknown) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [assetId, setAssetId] = useState('')
|
||||||
|
|
||||||
|
const add = useMutation({
|
||||||
|
mutationFn: () => addChannelAd(channelId, assetId),
|
||||||
|
onSuccess: () => {
|
||||||
|
setAssetId('')
|
||||||
|
onAdded()
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<Select value={assetId} onValueChange={setAssetId}>
|
||||||
|
<SelectTrigger className="max-w-md">
|
||||||
|
<SelectValue placeholder={t('admin.channels.pickAd')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((o) => (
|
||||||
|
<SelectItem key={o.id} value={o.id}>
|
||||||
|
{o.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="sm" disabled={!assetId || add.isPending} onClick={() => add.mutate()}>
|
||||||
|
{t('common.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { BlockMode } from '@/shared/api/types'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
|
import { addChannelShow } from '../api'
|
||||||
|
import { NumberField } from './fields'
|
||||||
|
|
||||||
|
export function AddShowForm({
|
||||||
|
channelId,
|
||||||
|
options,
|
||||||
|
onAdded,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
channelId: string
|
||||||
|
options: { id: string; name: string }[]
|
||||||
|
onAdded: () => void
|
||||||
|
onError: (e: unknown) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [showId, setShowId] = useState('')
|
||||||
|
const [weight, setWeight] = useState(1)
|
||||||
|
const [blockMode, setBlockMode] = useState<BlockMode>('Count')
|
||||||
|
const [blockValue, setBlockValue] = useState(1)
|
||||||
|
|
||||||
|
const add = useMutation({
|
||||||
|
mutationFn: () => addChannelShow(channelId, { showId, weight, blockMode, blockValue }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowId('')
|
||||||
|
onAdded()
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<Select value={showId} onValueChange={setShowId}>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<SelectValue placeholder={t('admin.channels.pickShow')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((o) => (
|
||||||
|
<SelectItem key={o.id} value={o.id}>
|
||||||
|
{o.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<NumberField label={t('admin.channels.weight')} value={weight} onChange={setWeight} min={1} />
|
||||||
|
<Select value={blockMode} onValueChange={(v) => setBlockMode(v as BlockMode)}>
|
||||||
|
<SelectTrigger className="w-36">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Count">{t('admin.channels.blockCount')}</SelectItem>
|
||||||
|
<SelectItem value="Duration">{t('admin.channels.blockDuration')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<NumberField
|
||||||
|
label={blockMode === 'Count' ? t('admin.channels.episodes') : t('admin.channels.minutes')}
|
||||||
|
value={blockValue}
|
||||||
|
onChange={setBlockValue}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
<Button size="sm" disabled={!showId || add.isPending} onClick={() => add.mutate()}>
|
||||||
|
{t('common.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { imageUrl } from '@/features/admin/images/api'
|
||||||
|
import { ImageGallery } from '@/features/admin/images/ImageGallery'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { clearBumperTemplateBackground, setBumperTemplateBackground } from '../api'
|
||||||
|
|
||||||
|
export function BumperBackgroundField({
|
||||||
|
channelId,
|
||||||
|
templateId,
|
||||||
|
backgroundImageId,
|
||||||
|
onChanged,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
channelId: string
|
||||||
|
templateId: string
|
||||||
|
backgroundImageId: string | null
|
||||||
|
onChanged: () => void
|
||||||
|
onError: (e: unknown) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [galleryOpen, setGalleryOpen] = useState(false)
|
||||||
|
|
||||||
|
const setBg = useMutation({
|
||||||
|
mutationFn: (imageId: string) => setBumperTemplateBackground(channelId, templateId, imageId),
|
||||||
|
onSuccess: onChanged,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
const clearBg = useMutation({
|
||||||
|
mutationFn: () => clearBumperTemplateBackground(channelId, templateId),
|
||||||
|
onSuccess: onChanged,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>
|
||||||
|
{t('admin.channels.bumperBackground')}{' '}
|
||||||
|
<span className={backgroundImageId ? 'text-emerald-500' : 'text-muted-foreground'}>
|
||||||
|
{backgroundImageId
|
||||||
|
? `· ${t('admin.channels.bumperFileLoaded')}`
|
||||||
|
: `· ${t('admin.channels.bumperFileDefault')}`}
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">{t('admin.channels.bumperBackgroundHint')}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{backgroundImageId && (
|
||||||
|
<img
|
||||||
|
src={imageUrl(backgroundImageId)}
|
||||||
|
alt=""
|
||||||
|
className="h-10 w-16 shrink-0 rounded border border-border object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
|
||||||
|
{t('admin.channels.bumperBackgroundPick')}
|
||||||
|
</Button>
|
||||||
|
{backgroundImageId && (
|
||||||
|
<Button size="sm" variant="ghost" disabled={clearBg.isPending} onClick={() => clearBg.mutate()}>
|
||||||
|
{t('admin.channels.bumperReset')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ImageGallery
|
||||||
|
open={galleryOpen}
|
||||||
|
onOpenChange={setGalleryOpen}
|
||||||
|
category="BumperBackground"
|
||||||
|
onSelect={(img) => setBg.mutate(img.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { BumperFont, BumperSelection, BumperSettings, ChannelDto } from '@/shared/api/types'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
|
import { addBumperTemplate, updateChannelSettings } from '../api'
|
||||||
|
import { clampChance } from '../lib/format'
|
||||||
|
import { BumperTemplateEditor } from './BumperTemplateEditor'
|
||||||
|
import { CollapsibleCard } from './CollapsibleCard'
|
||||||
|
|
||||||
|
export function BumperCard({
|
||||||
|
channel,
|
||||||
|
onSaved,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
channel: ChannelDto
|
||||||
|
onSaved: () => void
|
||||||
|
onError: (e: unknown) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled)
|
||||||
|
const [bumper, setBumper] = useState<BumperSettings>(channel.bumper)
|
||||||
|
|
||||||
|
const setField = <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) =>
|
||||||
|
setBumper((prev) => ({ ...prev, [key]: value }))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBumpersEnabled(channel.bumpersEnabled)
|
||||||
|
setBumper(channel.bumper)
|
||||||
|
}, [channel])
|
||||||
|
|
||||||
|
// Общие настройки заставок сохраняются тем же эндпоинтом, что и настройки канала — остальные
|
||||||
|
// поля берём из канала без изменений (они правятся в своей карточке).
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
updateChannelSettings(channel.id, {
|
||||||
|
name: channel.name,
|
||||||
|
isEnabled: channel.isEnabled,
|
||||||
|
adInsertion: channel.adInsertion,
|
||||||
|
adsPerBreak: channel.adsPerBreak,
|
||||||
|
bumpersEnabled,
|
||||||
|
bumper,
|
||||||
|
fillerAssetId: channel.fillerAssetId,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t('settings.saved'))
|
||||||
|
onSaved()
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const addTemplate = useMutation({
|
||||||
|
mutationFn: () => addBumperTemplate(channel.id, ''),
|
||||||
|
onSuccess: onSaved,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleCard title={t('admin.channels.bumpers')} contentClassName="flex flex-col gap-4">
|
||||||
|
<label className="flex items-start gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1"
|
||||||
|
checked={bumpersEnabled}
|
||||||
|
onChange={(e) => setBumpersEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{t('admin.channels.bumpersLabel')}
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
{t('admin.channels.bumpersHint')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Общие настройки */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.bumperSelection')}</Label>
|
||||||
|
<Select value={bumper.selection} onValueChange={(v) => setField('selection', v as BumperSelection)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Rotation">{t('admin.channels.bumperSelectionRotation')}</SelectItem>
|
||||||
|
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
|
||||||
|
<SelectItem value="WeightedRandom">
|
||||||
|
{t('admin.channels.bumperSelectionWeighted')}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="AlwaysFirst">
|
||||||
|
{t('admin.channels.bumperSelectionAlwaysFirst')}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.bumperFont')}</Label>
|
||||||
|
<Select value={bumper.font} onValueChange={(v) => setField('font', v as BumperFont)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Sans">{t('admin.channels.bumperFontSans')}</SelectItem>
|
||||||
|
<SelectItem value="Serif">{t('admin.channels.bumperFontSerif')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.bumperMinInterval')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1440}
|
||||||
|
value={bumper.minIntervalMinutes}
|
||||||
|
onChange={(e) => setField('minIntervalMinutes', Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.bumperShowChangeChance')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
value={bumper.showChangeChance}
|
||||||
|
onChange={(e) => setField('showChangeChance', clampChance(e.target.value))}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.channels.bumperShowChangeChanceHint')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.bumperEpisodeChangeChance')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
value={bumper.episodeChangeChance}
|
||||||
|
onChange={(e) => setField('episodeChangeChance', clampChance(e.target.value))}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.channels.bumperEpisodeChangeChanceHint')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Блоки заставок */}
|
||||||
|
<div className="border-t border-border pt-4">
|
||||||
|
<p className="text-sm font-medium">{t('admin.channels.bumperTemplates')}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{templates.map((template) => (
|
||||||
|
<BumperTemplateEditor
|
||||||
|
key={template.id}
|
||||||
|
channelId={channel.id}
|
||||||
|
template={template}
|
||||||
|
onChanged={onSaved}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button size="sm" variant="outline" disabled={addTemplate.isPending} onClick={() => addTemplate.mutate()}>
|
||||||
|
{t('admin.channels.bumperAddTemplate')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CollapsibleCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
|
||||||
|
export function BumperFileUpload({
|
||||||
|
channelId,
|
||||||
|
templateId,
|
||||||
|
kind,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
has,
|
||||||
|
accept,
|
||||||
|
upload,
|
||||||
|
clear,
|
||||||
|
onSaved,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
channelId: string
|
||||||
|
templateId: string
|
||||||
|
kind: string
|
||||||
|
label: string
|
||||||
|
hint: string
|
||||||
|
has: boolean
|
||||||
|
accept: string
|
||||||
|
upload: (id: string, templateId: string, file: File) => Promise<void>
|
||||||
|
clear: (id: string, templateId: string) => Promise<void>
|
||||||
|
onSaved: () => void
|
||||||
|
onError: (e: unknown) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const inputId = `bumper-${kind}-${templateId}`
|
||||||
|
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
mutationFn: (file: File) => upload(channelId, templateId, file),
|
||||||
|
onSuccess: onSaved,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
const clearMutation = useMutation({
|
||||||
|
mutationFn: () => clear(channelId, templateId),
|
||||||
|
onSuccess: onSaved,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
const busy = uploadMutation.isPending || clearMutation.isPending
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>
|
||||||
|
{label}{' '}
|
||||||
|
<span className={has ? 'text-emerald-500' : 'text-muted-foreground'}>
|
||||||
|
{has
|
||||||
|
? `· ${t('admin.channels.bumperFileLoaded')}`
|
||||||
|
: `· ${t('admin.channels.bumperFileDefault')}`}
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">{hint}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) uploadMutation.mutate(file)
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => document.getElementById(inputId)?.click()}
|
||||||
|
>
|
||||||
|
{t('admin.channels.bumperUpload')}
|
||||||
|
</Button>
|
||||||
|
{has && (
|
||||||
|
<Button size="sm" variant="ghost" disabled={busy} onClick={() => clearMutation.mutate()}>
|
||||||
|
{t('admin.channels.bumperReset')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import Hls from 'hls.js'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { getAccessToken } from '@/shared/api/client'
|
||||||
|
import type { BumperTextVariantDto } from '@/shared/api/types'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { bumperPreviewPlaylistUrl, renderBumperPreviews } from '../api'
|
||||||
|
|
||||||
|
export function BumperPreviewPlayer({
|
||||||
|
channelId,
|
||||||
|
templateId,
|
||||||
|
variants,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
channelId: string
|
||||||
|
templateId: string
|
||||||
|
variants: BumperTextVariantDto[]
|
||||||
|
onError: (e: unknown) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
const [bust, setBust] = useState(0)
|
||||||
|
|
||||||
|
const render = useMutation({
|
||||||
|
mutationFn: () => renderBumperPreviews(channelId, templateId),
|
||||||
|
onSuccess: () => {
|
||||||
|
setBust(Date.now())
|
||||||
|
setReady(true)
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button size="sm" variant="outline" disabled={render.isPending} onClick={() => render.mutate()}>
|
||||||
|
{render.isPending
|
||||||
|
? t('admin.channels.bumperPreviewRendering')
|
||||||
|
: t('admin.channels.bumperPreview')}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">{t('admin.channels.bumperPreviewHint')}</span>
|
||||||
|
</div>
|
||||||
|
{ready && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{[...variants]
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
.map((v) => (
|
||||||
|
<div key={v.id} className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground">{v.name}</span>
|
||||||
|
<PreviewVideo src={`${bumperPreviewPlaylistUrl(channelId, templateId, v.id)}?t=${bust}`} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Мини-плеер одного превью: грузит HLS через hls.js с Bearer-токеном (admin-роут под JWT). */
|
||||||
|
function PreviewVideo({ src }: { src: string }) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current
|
||||||
|
if (!video) return
|
||||||
|
let hls: Hls | null = null
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
hls = new Hls({
|
||||||
|
xhrSetup: (xhr) => {
|
||||||
|
const token = getAccessToken()
|
||||||
|
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
hls.loadSource(src)
|
||||||
|
hls.attachMedia(video)
|
||||||
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
video.src = src
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
hls?.destroy()
|
||||||
|
}
|
||||||
|
}, [src])
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
className="aspect-video w-full rounded-md border border-border bg-black"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ChevronDown } from 'lucide-react'
|
||||||
|
import type { BumperTemplateDto } from '@/shared/api/types'
|
||||||
|
import { Badge } from '@/shared/ui/badge'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
|
import {
|
||||||
|
addBumperVariant,
|
||||||
|
clearBumperTemplateAudio,
|
||||||
|
removeBumperTemplate,
|
||||||
|
updateBumperTemplate,
|
||||||
|
uploadBumperTemplateAudio,
|
||||||
|
} from '../api'
|
||||||
|
import { cssColor } from '../lib/format'
|
||||||
|
import { BumperBackgroundField } from './BumperBackgroundField'
|
||||||
|
import { BumperFileUpload } from './BumperFileUpload'
|
||||||
|
import { BumperPreviewPlayer } from './BumperPreviewPlayer'
|
||||||
|
import { BumperVariantEditor } from './BumperVariantEditor'
|
||||||
|
|
||||||
|
export function BumperTemplateEditor({
|
||||||
|
channelId,
|
||||||
|
template,
|
||||||
|
onChanged,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
channelId: string
|
||||||
|
template: BumperTemplateDto
|
||||||
|
onChanged: () => void
|
||||||
|
onError: (e: unknown) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [name, setName] = useState(template.name)
|
||||||
|
const [colors, setColors] = useState({
|
||||||
|
backgroundColor: template.backgroundColor,
|
||||||
|
backgroundColor2: template.backgroundColor2,
|
||||||
|
accentColor: template.accentColor,
|
||||||
|
textColor: template.textColor,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setName(template.name)
|
||||||
|
setColors({
|
||||||
|
backgroundColor: template.backgroundColor,
|
||||||
|
backgroundColor2: template.backgroundColor2,
|
||||||
|
accentColor: template.accentColor,
|
||||||
|
textColor: template.textColor,
|
||||||
|
})
|
||||||
|
}, [template])
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => updateBumperTemplate(channelId, template.id, { name: name.trim(), ...colors }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t('settings.saved'))
|
||||||
|
onChanged()
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: () => removeBumperTemplate(channelId, template.id),
|
||||||
|
onSuccess: onChanged,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
const addVariant = useMutation({
|
||||||
|
mutationFn: () => addBumperVariant(channelId, template.id, ''),
|
||||||
|
onSuccess: onChanged,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const colorFields: { key: keyof typeof colors; label: string }[] = [
|
||||||
|
{ key: 'backgroundColor', label: t('admin.channels.bumperBg') },
|
||||||
|
{ key: 'backgroundColor2', label: t('admin.channels.bumperBg2') },
|
||||||
|
{ key: 'accentColor', label: t('admin.channels.bumperAccent') },
|
||||||
|
{ key: 'textColor', label: t('admin.channels.bumperText') },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-md border border-border bg-muted/30 p-4">
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer items-center justify-between gap-2"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">{template.name}</span>
|
||||||
|
{template.isDefault && <Badge variant="muted">{t('admin.channels.bumperDefault')}</Badge>}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{template.hasAudio && template.audioDurationSeconds != null
|
||||||
|
? `≈${Math.round(template.audioDurationSeconds)} ${t('admin.channels.bumperSeconds')}`
|
||||||
|
: t('admin.channels.bumperDefaultDuration')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!template.isDefault && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={remove.isPending}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
remove.mutate()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.bumperTemplateName')}</Label>
|
||||||
|
<Input value={name} maxLength={64} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
{colorFields.map(({ key, label }) => (
|
||||||
|
<div key={key} className="flex flex-col gap-1.5">
|
||||||
|
<Label>{label}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="h-8 w-8 shrink-0 rounded border border-border"
|
||||||
|
style={{ backgroundColor: cssColor(colors[key]) }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={colors[key]}
|
||||||
|
onChange={(e) => setColors((c) => ({ ...c, [key]: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 border-t border-border pt-3 sm:grid-cols-2">
|
||||||
|
<BumperFileUpload
|
||||||
|
channelId={channelId}
|
||||||
|
templateId={template.id}
|
||||||
|
kind="audio"
|
||||||
|
label={t('admin.channels.bumperAudio')}
|
||||||
|
hint={t('admin.channels.bumperAudioHint')}
|
||||||
|
has={template.hasAudio}
|
||||||
|
accept="audio/*"
|
||||||
|
upload={uploadBumperTemplateAudio}
|
||||||
|
clear={clearBumperTemplateAudio}
|
||||||
|
onSaved={onChanged}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
<BumperBackgroundField
|
||||||
|
channelId={channelId}
|
||||||
|
templateId={template.id}
|
||||||
|
backgroundImageId={template.backgroundImageId}
|
||||||
|
onChanged={onChanged}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Подблоки (текст-варианты) */}
|
||||||
|
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||||
|
<p className="text-sm font-medium">{t('admin.channels.bumperVariants')}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperVariantsHint')}</p>
|
||||||
|
{[...template.variants]
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
.map((variant) => (
|
||||||
|
<BumperVariantEditor
|
||||||
|
key={variant.id}
|
||||||
|
channelId={channelId}
|
||||||
|
templateId={template.id}
|
||||||
|
variant={variant}
|
||||||
|
canRemove={template.variants.length > 1}
|
||||||
|
onChanged={onChanged}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={addVariant.isPending}
|
||||||
|
onClick={() => addVariant.mutate()}
|
||||||
|
>
|
||||||
|
{t('admin.channels.bumperAddVariant')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||||
|
<BumperPreviewPlayer
|
||||||
|
channelId={channelId}
|
||||||
|
templateId={template.id}
|
||||||
|
variants={template.variants}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { BumperTextKind, BumperTextVariantDto, BumperTrigger } from '@/shared/api/types'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
|
import { removeBumperVariant, updateBumperVariant } from '../api'
|
||||||
|
|
||||||
|
export function BumperVariantEditor({
|
||||||
|
channelId,
|
||||||
|
templateId,
|
||||||
|
variant,
|
||||||
|
canRemove,
|
||||||
|
onChanged,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
channelId: string
|
||||||
|
templateId: string
|
||||||
|
variant: BumperTextVariantDto
|
||||||
|
canRemove: boolean
|
||||||
|
onChanged: () => void
|
||||||
|
onError: (e: unknown) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: variant.name,
|
||||||
|
kind: variant.kind,
|
||||||
|
nowLabel: variant.nowLabel,
|
||||||
|
nextLabel: variant.nextLabel,
|
||||||
|
line1: variant.line1,
|
||||||
|
line2: variant.line2,
|
||||||
|
trigger: variant.trigger,
|
||||||
|
weight: variant.weight,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setForm({
|
||||||
|
name: variant.name,
|
||||||
|
kind: variant.kind,
|
||||||
|
nowLabel: variant.nowLabel,
|
||||||
|
nextLabel: variant.nextLabel,
|
||||||
|
line1: variant.line1,
|
||||||
|
line2: variant.line2,
|
||||||
|
trigger: variant.trigger,
|
||||||
|
weight: variant.weight,
|
||||||
|
})
|
||||||
|
}, [variant])
|
||||||
|
|
||||||
|
const set = <K extends keyof typeof form>(key: K, value: (typeof form)[K]) =>
|
||||||
|
setForm((f) => ({ ...f, [key]: value }))
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
updateBumperVariant(channelId, templateId, variant.id, { ...form, name: form.name.trim() }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t('settings.saved'))
|
||||||
|
onChanged()
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: () => removeBumperVariant(channelId, templateId, variant.id),
|
||||||
|
onSuccess: onChanged,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 rounded-md border border-border bg-background/40 p-3">
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.bumperVariantName')}</Label>
|
||||||
|
<Input value={form.name} maxLength={64} onChange={(e) => set('name', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.bumperTextKind')}</Label>
|
||||||
|
<Select value={form.kind} onValueChange={(v) => set('kind', v as BumperTextKind)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="NowNext">{t('admin.channels.bumperKindNowNext')}</SelectItem>
|
||||||
|
<SelectItem value="Free">{t('admin.channels.bumperKindFree')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.bumperTrigger')}</Label>
|
||||||
|
<Select value={form.trigger} onValueChange={(v) => set('trigger', v as BumperTrigger)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="OnShowChange">
|
||||||
|
{t('admin.channels.bumperTriggerOnShowChange')}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="BetweenEpisodes">
|
||||||
|
{t('admin.channels.bumperTriggerBetweenEpisodes')}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="Both">{t('admin.channels.bumperTriggerBoth')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.bumperVariantWeight')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
value={form.weight}
|
||||||
|
onChange={(e) => set('weight', Math.max(0, Math.round(Number(e.target.value)) || 0))}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.channels.bumperVariantWeightHint')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{form.kind === 'NowNext' ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.bumperNowLabel')}</Label>
|
||||||
|
<Input
|
||||||
|
value={form.nowLabel}
|
||||||
|
maxLength={64}
|
||||||
|
onChange={(e) => set('nowLabel', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.bumperNextLabel')}</Label>
|
||||||
|
<Input
|
||||||
|
value={form.nextLabel}
|
||||||
|
maxLength={64}
|
||||||
|
onChange={(e) => set('nextLabel', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.bumperLine1')}</Label>
|
||||||
|
<Input value={form.line1} maxLength={120} onChange={(e) => set('line1', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.bumperLine2')}</Label>
|
||||||
|
<Input value={form.line2} maxLength={120} onChange={(e) => set('line2', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{canRemove && (
|
||||||
|
<Button size="sm" variant="ghost" disabled={remove.isPending} onClick={() => remove.mutate()}>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={save.isPending || !form.name.trim()}
|
||||||
|
onClick={() => save.mutate()}
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { BlockMode, ChannelShowDto, HourWindow } from '@/shared/api/types'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
|
import { removeChannelShow, updateChannelShow } from '../api'
|
||||||
|
import { RemoveButton } from './fields'
|
||||||
|
|
||||||
|
export function ChannelShowRow({
|
||||||
|
channelId,
|
||||||
|
row,
|
||||||
|
onChanged,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
channelId: string
|
||||||
|
row: ChannelShowDto
|
||||||
|
onChanged: () => void
|
||||||
|
onError: (e: unknown) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [weight, setWeight] = useState(row.weight)
|
||||||
|
const [blockMode, setBlockMode] = useState<BlockMode>(row.blockMode)
|
||||||
|
const [blockValue, setBlockValue] = useState(row.blockValue)
|
||||||
|
const [isEnabled, setIsEnabled] = useState(row.isEnabled)
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const [multiplier, setMultiplier] = useState(row.preferredWeightMultiplier)
|
||||||
|
const [hours, setHours] = useState<HourWindow[]>(row.preferredHours)
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
updateChannelShow(channelId, row.id, {
|
||||||
|
weight,
|
||||||
|
blockMode,
|
||||||
|
blockValue,
|
||||||
|
isEnabled,
|
||||||
|
preferredWeightMultiplier: multiplier,
|
||||||
|
preferredHours: hours.filter((h) => h.startHour < h.endHour),
|
||||||
|
}),
|
||||||
|
onSuccess: onChanged,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const addHour = () => setHours((h) => [...h, { startHour: 18, endHour: 23 }])
|
||||||
|
const setHour = (i: number, patch: Partial<HourWindow>) =>
|
||||||
|
setHours((h) => h.map((w, idx) => (idx === i ? { ...w, ...patch } : w)))
|
||||||
|
const removeHour = (i: number) => setHours((h) => h.filter((_, idx) => idx !== i))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr className="border-b border-border last:border-0">
|
||||||
|
<td className="py-2">{row.showName}</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={weight}
|
||||||
|
onChange={(e) => setWeight(Number(e.target.value))}
|
||||||
|
className="h-8 w-16"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Select value={blockMode} onValueChange={(v) => setBlockMode(v as BlockMode)}>
|
||||||
|
<SelectTrigger className="h-8 w-36 whitespace-nowrap">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Count">{t('admin.channels.blockCount')}</SelectItem>
|
||||||
|
<SelectItem value="Duration">{t('admin.channels.blockDuration')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={blockValue}
|
||||||
|
onChange={(e) => setBlockValue(Number(e.target.value))}
|
||||||
|
className="h-8 w-16"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
|
||||||
|
</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
{t('admin.channels.preferredHours')}
|
||||||
|
{hours.length > 0 ? ` (${hours.length})` : ''}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
<RemoveButton
|
||||||
|
onClick={() => removeChannelShow(channelId, row.id).then(onChanged).catch(onError)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{expanded && (
|
||||||
|
<tr className="border-b border-border last:border-0">
|
||||||
|
<td colSpan={5} className="bg-muted/30 py-3">
|
||||||
|
<div className="flex flex-col gap-3 pl-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="whitespace-nowrap">{t('admin.channels.preferredMultiplier')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={multiplier}
|
||||||
|
onChange={(e) => setMultiplier(Math.max(1, Math.round(Number(e.target.value)) || 1))}
|
||||||
|
className="h-8 w-20"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.channels.preferredHoursHint')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{hours.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.channels.preferredNone')}</p>
|
||||||
|
)}
|
||||||
|
{hours.map((w, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<HourSelect value={w.startHour} from={0} to={23} onChange={(v) => setHour(i, { startHour: v })} />
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
<HourSelect value={w.endHour} from={1} to={24} onChange={(v) => setHour(i, { endHour: v })} />
|
||||||
|
{w.startHour >= w.endHour && (
|
||||||
|
<span className="text-xs text-destructive">
|
||||||
|
{t('admin.channels.preferredBadRange')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => removeHour(i)}>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div>
|
||||||
|
<Button size="sm" variant="outline" onClick={addHour}>
|
||||||
|
{t('admin.channels.preferredAddWindow')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выпадающий выбор часа суток (значения from..to включительно), формат «HH:00». */
|
||||||
|
function HourSelect({
|
||||||
|
value,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: number
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
onChange: (v: number) => void
|
||||||
|
}) {
|
||||||
|
const options = Array.from({ length: to - from + 1 }, (_, i) => from + i)
|
||||||
|
return (
|
||||||
|
<Select value={String(value)} onValueChange={(v) => onChange(Number(v))}>
|
||||||
|
<SelectTrigger className="h-8 w-24">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((h) => (
|
||||||
|
<SelectItem key={h} value={String(h)}>
|
||||||
|
{String(h).padStart(2, '0')}:00
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { type ReactNode, useState } from 'react'
|
||||||
|
import { ChevronDown } from 'lucide-react'
|
||||||
|
import { Card, CardContent, CardTitle } from '@/shared/ui/card'
|
||||||
|
|
||||||
|
/** Карточка со сворачиваемым содержимым: клик по заголовку скрывает/раскрывает блок. */
|
||||||
|
export function CollapsibleCard({
|
||||||
|
title,
|
||||||
|
defaultOpen = false,
|
||||||
|
contentClassName,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
defaultOpen?: boolean
|
||||||
|
contentClassName?: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen)
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex w-full items-center justify-between gap-2 p-6 text-left"
|
||||||
|
>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-5 w-5 shrink-0 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{open && <CardContent className={contentClassName}>{children}</CardContent>}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { OverrideMode, OverrideRecurrence } from '@/shared/api/types'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
|
import { createOverride } from '../api'
|
||||||
|
import { NumberField } from './fields'
|
||||||
|
|
||||||
|
export function OverrideForm({
|
||||||
|
channelId,
|
||||||
|
options,
|
||||||
|
onCreated,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
channelId: string
|
||||||
|
options: { id: string; name: string }[]
|
||||||
|
onCreated: () => void
|
||||||
|
onError: (e: unknown) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [mode, setMode] = useState<OverrideMode>('Exclusive')
|
||||||
|
const [recurrence, setRecurrence] = useState<OverrideRecurrence>('OneTime')
|
||||||
|
const [showId, setShowId] = useState('')
|
||||||
|
const [weight, setWeight] = useState(1)
|
||||||
|
const [start, setStart] = useState('')
|
||||||
|
const [end, setEnd] = useState('')
|
||||||
|
// Weekly: день недели (0=Вс..6=Сб) + окна времени суток «HH:MM».
|
||||||
|
const [dayOfWeek, setDayOfWeek] = useState(6)
|
||||||
|
const [startTime, setStartTime] = useState('')
|
||||||
|
const [endTime, setEndTime] = useState('')
|
||||||
|
|
||||||
|
const toMinutes = (hhmm: string) => {
|
||||||
|
const [h, m] = hhmm.split(':').map(Number)
|
||||||
|
return h * 60 + m
|
||||||
|
}
|
||||||
|
const weekly = recurrence === 'Weekly'
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
createOverride(
|
||||||
|
channelId,
|
||||||
|
weekly
|
||||||
|
? {
|
||||||
|
mode,
|
||||||
|
recurrence,
|
||||||
|
dayOfWeek,
|
||||||
|
startMinute: toMinutes(startTime),
|
||||||
|
endMinute: toMinutes(endTime),
|
||||||
|
shows: [{ showId, weight }],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
mode,
|
||||||
|
recurrence,
|
||||||
|
startsAtUtc: new Date(start).toISOString(),
|
||||||
|
endsAtUtc: new Date(end).toISOString(),
|
||||||
|
shows: [{ showId, weight }],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowId('')
|
||||||
|
setStart('')
|
||||||
|
setEnd('')
|
||||||
|
setStartTime('')
|
||||||
|
setEndTime('')
|
||||||
|
onCreated()
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const valid = weekly
|
||||||
|
? showId && startTime && endTime && toMinutes(endTime) > toMinutes(startTime)
|
||||||
|
: showId && start && end && new Date(end) > new Date(start)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.overrideRecurrence')}</Label>
|
||||||
|
<Select value={recurrence} onValueChange={(v) => setRecurrence(v as OverrideRecurrence)}>
|
||||||
|
<SelectTrigger className="w-36">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="OneTime">{t('admin.channels.recurrenceOneTime')}</SelectItem>
|
||||||
|
<SelectItem value="Weekly">{t('admin.channels.recurrenceWeekly')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Select value={mode} onValueChange={(v) => setMode(v as OverrideMode)}>
|
||||||
|
<SelectTrigger className="w-36">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Exclusive">{t('admin.channels.modes.Exclusive')}</SelectItem>
|
||||||
|
<SelectItem value="Boost">{t('admin.channels.modes.Boost')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={showId} onValueChange={setShowId}>
|
||||||
|
<SelectTrigger className="w-44">
|
||||||
|
<SelectValue placeholder={t('admin.channels.pickShow')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((o) => (
|
||||||
|
<SelectItem key={o.id} value={o.id}>
|
||||||
|
{o.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{mode === 'Boost' && (
|
||||||
|
<NumberField label={t('admin.channels.weight')} value={weight} onChange={setWeight} min={1} />
|
||||||
|
)}
|
||||||
|
{weekly ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.weekday')}</Label>
|
||||||
|
<Select value={String(dayOfWeek)} onValueChange={(v) => setDayOfWeek(Number(v))}>
|
||||||
|
<SelectTrigger className="w-36">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
|
||||||
|
<SelectItem key={d} value={String(d)}>
|
||||||
|
{t(`admin.channels.weekdays.${d}`)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.from')}</Label>
|
||||||
|
<Input type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} className="w-32" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.to')}</Label>
|
||||||
|
<Input type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} className="w-32" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.from')}</Label>
|
||||||
|
<Input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} className="w-60" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.to')}</Label>
|
||||||
|
<Input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} className="w-60" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button size="sm" disabled={!valid || create.isPending} onClick={() => create.mutate()}>
|
||||||
|
{t('common.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { ScheduleEntryDto } from '@/shared/api/types'
|
||||||
|
import { Badge } from '@/shared/ui/badge'
|
||||||
|
import { formatTime } from '../lib/format'
|
||||||
|
|
||||||
|
export function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
if (entries.length === 0)
|
||||||
|
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-col divide-y divide-border text-sm">
|
||||||
|
{entries.slice(0, 40).map((e) => (
|
||||||
|
<li key={e.id} className="flex items-center gap-3 py-1.5">
|
||||||
|
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
||||||
|
{formatTime(e.startsAtUtc)}
|
||||||
|
</span>
|
||||||
|
{e.kind === 'Ad' ? (
|
||||||
|
<Badge variant="muted">{t('air.ad')}</Badge>
|
||||||
|
) : e.kind === 'Bumper' ? (
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<Badge variant="muted" className="shrink-0">
|
||||||
|
{t('air.bumper')}
|
||||||
|
</Badge>
|
||||||
|
{(e.bumperName || e.bumperText) && (
|
||||||
|
<span className="min-w-0 truncate text-muted-foreground">
|
||||||
|
{e.bumperName}
|
||||||
|
{e.bumperName && e.bumperText ? ' · ' : ''}
|
||||||
|
{e.bumperText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
{e.showName ?? '—'}
|
||||||
|
{e.seasonEpisode ? (
|
||||||
|
<span className="text-muted-foreground"> · {e.seasonEpisode}</span>
|
||||||
|
) : (
|
||||||
|
e.episodeIndex != null && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{' '}
|
||||||
|
· {t('air.episode')} {e.episodeIndex + 1}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { AdInsertion, ChannelDto } from '@/shared/api/types'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
|
import { updateChannelSettings } from '../api'
|
||||||
|
import { CollapsibleCard } from './CollapsibleCard'
|
||||||
|
|
||||||
|
export function SettingsCard({
|
||||||
|
channel,
|
||||||
|
readyAssets,
|
||||||
|
onSaved,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
channel: ChannelDto
|
||||||
|
readyAssets: { id: string; originalFileName: string }[]
|
||||||
|
onSaved: () => void
|
||||||
|
onError: (e: unknown) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [name, setName] = useState(channel.name)
|
||||||
|
const [isEnabled, setIsEnabled] = useState(channel.isEnabled)
|
||||||
|
const [adInsertion, setAdInsertion] = useState<AdInsertion>(channel.adInsertion)
|
||||||
|
const [adsPerBreak, setAdsPerBreak] = useState(channel.adsPerBreak)
|
||||||
|
const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setName(channel.name)
|
||||||
|
setIsEnabled(channel.isEnabled)
|
||||||
|
setAdInsertion(channel.adInsertion)
|
||||||
|
setAdsPerBreak(channel.adsPerBreak)
|
||||||
|
setFillerAssetId(channel.fillerAssetId ?? '')
|
||||||
|
}, [channel])
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
updateChannelSettings(channel.id, {
|
||||||
|
name: name.trim(),
|
||||||
|
isEnabled,
|
||||||
|
adInsertion,
|
||||||
|
adsPerBreak,
|
||||||
|
// Заставки правятся в отдельной карточке — здесь передаём сохранённые значения без изменений.
|
||||||
|
bumpersEnabled: channel.bumpersEnabled,
|
||||||
|
bumper: channel.bumper,
|
||||||
|
fillerAssetId: fillerAssetId || null,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t('settings.saved'))
|
||||||
|
onSaved()
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleCard
|
||||||
|
title={t('admin.channels.settings')}
|
||||||
|
defaultOpen
|
||||||
|
contentClassName="grid gap-4 sm:grid-cols-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.name')}</Label>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.adPolicy')}</Label>
|
||||||
|
<Select value={adInsertion} onValueChange={(v) => setAdInsertion(v as AdInsertion)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="BetweenBlocks">{t('admin.channels.betweenBlocks')}</SelectItem>
|
||||||
|
<SelectItem value="BetweenEpisodes">{t('admin.channels.betweenEpisodes')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.adsPerBreak')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={10}
|
||||||
|
value={adsPerBreak}
|
||||||
|
onChange={(e) => setAdsPerBreak(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.filler')}</Label>
|
||||||
|
<Select
|
||||||
|
value={fillerAssetId || 'none'}
|
||||||
|
onValueChange={(v) => setFillerAssetId(v === 'none' ? '' : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">{t('admin.channels.noFiller')}</SelectItem>
|
||||||
|
{readyAssets.map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{a.originalFileName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
|
||||||
|
{t('admin.channels.enabledLabel')}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-end justify-end sm:col-span-2">
|
||||||
|
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CollapsibleCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
|
||||||
|
export function NumberField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
min,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
onChange: (v: number) => void
|
||||||
|
min?: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{label}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={min}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RemoveButton({ onClick }: { onClick: () => void }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<Button size="sm" variant="destructive" onClick={onClick}>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/** Утилиты форматирования для карточек канала (без React). */
|
||||||
|
|
||||||
|
export function formatTime(iso: string | null) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleString([], {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Минуты суток → «HH:MM». */
|
||||||
|
export function formatMinute(minute: number | null) {
|
||||||
|
if (minute == null) return '—'
|
||||||
|
const h = Math.floor(minute / 60)
|
||||||
|
const m = minute % 60
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ffmpeg-цвет (0xRRGGBB / имя) → CSS для превью-плашки. */
|
||||||
|
export function cssColor(value: string): string {
|
||||||
|
const v = value.trim()
|
||||||
|
if (v.startsWith('0x')) return `#${v.slice(2)}`
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ограничивает вероятность появления заставки диапазоном 0..1 (пустой ввод → 0). */
|
||||||
|
export function clampChance(value: string): number {
|
||||||
|
const n = Number(value)
|
||||||
|
if (Number.isNaN(n)) return 0
|
||||||
|
return Math.min(1, Math.max(0, n))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user