Add interstitial endpoints and enhance media and template functionalities: implement MapInterstitialEndpoints in Program.cs, add preview functionality for media assets in MediaEndpoints, and introduce template preview capabilities in TemplateEndpoints. Remove obsolete bumper settings from Channel and related classes to streamline configuration.
This commit is contained in:
@@ -0,0 +1,64 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Api.Common;
|
||||||
|
using TeleWave.Application.Library.Interstitials;
|
||||||
|
using TeleWave.Application.Library.Interstitials.ImportInterstitials;
|
||||||
|
using TeleWave.Application.Library.Interstitials.ListInterstitialBlocks;
|
||||||
|
using TeleWave.Application.Library.Interstitials.ListInterstitials;
|
||||||
|
using TeleWave.Infrastructure.Identity;
|
||||||
|
|
||||||
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ролики-врезки: тот же <c>Show(Kind = Interstitial)</c>, но со своим экраном (см. 6.7). Правка
|
||||||
|
/// названия и удаление идут через обычные эндпоинты шоу — здесь только то, чего у библиотеки нет:
|
||||||
|
/// список с длительностями, блоки и импорт загруженных файлов.
|
||||||
|
/// </summary>
|
||||||
|
public static class InterstitialEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapInterstitialEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var admin = app.MapGroup("/api/admin/interstitials")
|
||||||
|
.WithTags("Admin.Interstitials")
|
||||||
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
|
admin.MapGet("", List).Produces<IReadOnlyList<InterstitialDto>>();
|
||||||
|
admin.MapGet("/blocks", ListBlocks).Produces<IReadOnlyList<InterstitialBlockDto>>();
|
||||||
|
admin.MapPost("/import", Import).Produces<ImportInterstitialsResponse>();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> List(ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new ListInterstitialsQuery(), cancellationToken);
|
||||||
|
return Results.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListBlocks(
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new ListInterstitialBlocksQuery(), cancellationToken);
|
||||||
|
return Results.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Import(
|
||||||
|
ImportInterstitialsBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new ImportInterstitialsCommand(body.MediaAssetIds),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.Ok(new ImportInterstitialsResponse(result.Value))
|
||||||
|
: result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ImportInterstitialsBody(IReadOnlyList<Guid> MediaAssetIds);
|
||||||
|
|
||||||
|
public sealed record ImportInterstitialsResponse(int Imported);
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
@@ -16,6 +18,8 @@ namespace TeleWave.Api.Endpoints;
|
|||||||
|
|
||||||
public static class MediaEndpoints
|
public static class MediaEndpoints
|
||||||
{
|
{
|
||||||
|
private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled);
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var admin = app.MapGroup("/api/admin/media")
|
var admin = app.MapGroup("/api/admin/media")
|
||||||
@@ -27,6 +31,11 @@ public static class MediaEndpoints
|
|||||||
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
|
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
|
||||||
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
// Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
|
||||||
|
// по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
|
||||||
|
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
|
||||||
|
admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +144,56 @@ public static class MediaEndpoints
|
|||||||
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
|
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
||||||
|
private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
|
||||||
|
{
|
||||||
|
string indexPath;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
indexPath = paths.SegmentPath(id, "index.m3u8");
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
if (!File.Exists(indexPath))
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
|
var baseUrl = $"/api/admin/media/{id}/preview/";
|
||||||
|
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, string file, MediaPathResolver paths)
|
||||||
|
{
|
||||||
|
if (!SegmentFileName.IsMatch(file))
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
|
string path;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
path = paths.SegmentPath(id, file);
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
if (!File.Exists(path))
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
|
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record UploadMediaResponse(Guid Id);
|
public sealed record UploadMediaResponse(Guid Id);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Application.Programming.Planning.ApplyTemplate;
|
using TeleWave.Application.Programming.Planning.ApplyTemplate;
|
||||||
|
using TeleWave.Application.Programming.Planning.Preview;
|
||||||
using TeleWave.Application.Programming.Templates;
|
using TeleWave.Application.Programming.Templates;
|
||||||
using TeleWave.Application.Programming.Templates.CreateSlot;
|
using TeleWave.Application.Programming.Templates.CreateSlot;
|
||||||
using TeleWave.Application.Programming.Templates.DeleteSlot;
|
using TeleWave.Application.Programming.Templates.DeleteSlot;
|
||||||
@@ -34,6 +35,10 @@ public static class TemplateEndpoints
|
|||||||
admin
|
admin
|
||||||
.MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate)
|
.MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate)
|
||||||
.Produces<ApplyResultDto>();
|
.Produces<ApplyResultDto>();
|
||||||
|
// Предпросмотр — тот же генератор, но без записи и без продвижения курсоров.
|
||||||
|
admin
|
||||||
|
.MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
|
||||||
|
.Produces<SchedulePreviewDto>();
|
||||||
|
|
||||||
admin
|
admin
|
||||||
.MapPost("/templates/{templateId:guid}/layers", CreateLayer)
|
.MapPost("/templates/{templateId:guid}/layers", CreateLayer)
|
||||||
@@ -75,6 +80,20 @@ public static class TemplateEndpoints
|
|||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> PreviewTemplate(
|
||||||
|
Guid channelId,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
int days = 1
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new PreviewScheduleQuery(channelId, days),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UpdateTemplate(
|
private static async Task<IResult> UpdateTemplate(
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
UpdateTemplateBody body,
|
UpdateTemplateBody body,
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ app.MapAdminUserEndpoints();
|
|||||||
app.MapMediaEndpoints();
|
app.MapMediaEndpoints();
|
||||||
app.MapShowEndpoints();
|
app.MapShowEndpoints();
|
||||||
app.MapGenreEndpoints();
|
app.MapGenreEndpoints();
|
||||||
|
app.MapInterstitialEndpoints();
|
||||||
app.MapCollectionEndpoints();
|
app.MapCollectionEndpoints();
|
||||||
app.MapGroupEndpoints();
|
app.MapGroupEndpoints();
|
||||||
app.MapTemplateEndpoints();
|
app.MapTemplateEndpoints();
|
||||||
|
|||||||
@@ -5,13 +5,7 @@ namespace TeleWave.Application.Broadcast;
|
|||||||
public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled);
|
public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled);
|
||||||
|
|
||||||
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).</summary>
|
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).</summary>
|
||||||
public sealed record BumperSettingsDto(
|
public sealed record BumperSettingsDto(BumperFont Font, BumperSelection Selection);
|
||||||
BumperFont Font,
|
|
||||||
int MinIntervalMinutes,
|
|
||||||
BumperSelection Selection,
|
|
||||||
double ShowChangeChance,
|
|
||||||
double EpisodeChangeChance
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.</summary>
|
/// <summary>Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.</summary>
|
||||||
public sealed record BumperTextVariantDto(
|
public sealed record BumperTextVariantDto(
|
||||||
|
|||||||
@@ -1,80 +1,74 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.GetChannel;
|
namespace TeleWave.Application.Broadcast.GetChannel;
|
||||||
|
|
||||||
public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
||||||
: IQueryHandler<GetChannelQuery, Result<ChannelDto>>
|
: IQueryHandler<GetChannelQuery, Result<ChannelDto>>
|
||||||
{
|
{
|
||||||
public async Task<Result<ChannelDto>> Handle(
|
public async Task<Result<ChannelDto>> Handle(
|
||||||
GetChannelQuery query,
|
GetChannelQuery query,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно
|
// Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно
|
||||||
// (GetChannelTemplateQuery) — здесь только собственные свойства канала.
|
// (GetChannelTemplateQuery) — здесь только собственные свойства канала.
|
||||||
var channel = await dbContext
|
var channel = await dbContext
|
||||||
.Channels.AsNoTracking()
|
.Channels.AsNoTracking()
|
||||||
.Include(c => c.BumperTemplates)
|
.Include(c => c.BumperTemplates)
|
||||||
.ThenInclude(t => t.Variants)
|
.ThenInclude(t => t.Variants)
|
||||||
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
|
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
|
||||||
if (channel is null)
|
if (channel is null)
|
||||||
return Result.Failure<ChannelDto>(ChannelErrors.NotFound);
|
return Result.Failure<ChannelDto>(ChannelErrors.NotFound);
|
||||||
|
|
||||||
var bumperTemplates = channel
|
var bumperTemplates = channel
|
||||||
.BumperTemplates.OrderBy(t => t.Position)
|
.BumperTemplates.OrderBy(t => t.Position)
|
||||||
.Select(t => new BumperTemplateDto(
|
.Select(t => new BumperTemplateDto(
|
||||||
t.Id,
|
t.Id,
|
||||||
t.Position,
|
t.Position,
|
||||||
t.IsDefault,
|
t.IsDefault,
|
||||||
t.Name,
|
t.Name,
|
||||||
t.BackgroundColor,
|
t.BackgroundColor,
|
||||||
t.BackgroundColor2,
|
t.BackgroundColor2,
|
||||||
t.AccentColor,
|
t.AccentColor,
|
||||||
t.TextColor,
|
t.TextColor,
|
||||||
t.BackgroundImageId,
|
t.BackgroundImageId,
|
||||||
t.AudioExtension is not null,
|
t.AudioExtension is not null,
|
||||||
t.AudioDurationSeconds,
|
t.AudioDurationSeconds,
|
||||||
t.Variants.OrderBy(v => v.Position)
|
t.Variants.OrderBy(v => v.Position)
|
||||||
.Select(v => new BumperTextVariantDto(
|
.Select(v => new BumperTextVariantDto(
|
||||||
v.Id,
|
v.Id,
|
||||||
v.Position,
|
v.Position,
|
||||||
v.Name,
|
v.Name,
|
||||||
v.Kind,
|
v.Kind,
|
||||||
v.NowLabel,
|
v.NowLabel,
|
||||||
v.NextLabel,
|
v.NextLabel,
|
||||||
v.Line1,
|
v.Line1,
|
||||||
v.Line2,
|
v.Line2,
|
||||||
v.Trigger,
|
v.Trigger,
|
||||||
v.Weight
|
v.Weight
|
||||||
))
|
))
|
||||||
.ToList()
|
.ToList()
|
||||||
))
|
))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return Result.Success(
|
return Result.Success(
|
||||||
new ChannelDto(
|
new ChannelDto(
|
||||||
channel.Id,
|
channel.Id,
|
||||||
channel.Name,
|
channel.Name,
|
||||||
channel.Slug,
|
channel.Slug,
|
||||||
channel.IsEnabled,
|
channel.IsEnabled,
|
||||||
channel.Number,
|
channel.Number,
|
||||||
channel.UtcOffsetMinutes,
|
channel.UtcOffsetMinutes,
|
||||||
channel.DayStartTime,
|
channel.DayStartTime,
|
||||||
channel.TemplateId,
|
channel.TemplateId,
|
||||||
channel.BumpersEnabled,
|
channel.BumpersEnabled,
|
||||||
new BumperSettingsDto(
|
new BumperSettingsDto(channel.BumperFont, channel.BumperSelection),
|
||||||
channel.BumperFont,
|
bumperTemplates,
|
||||||
channel.BumperMinIntervalMinutes,
|
channel.FillerAssetId
|
||||||
channel.BumperSelection,
|
)
|
||||||
channel.BumperShowChangeChance,
|
);
|
||||||
channel.BumperEpisodeChangeChance
|
}
|
||||||
),
|
}
|
||||||
bumperTemplates,
|
|
||||||
channel.FillerAssetId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-8
@@ -13,11 +13,6 @@ public sealed record UpdateChannelSettingsCommand(
|
|||||||
Guid? FillerAssetId
|
Guid? FillerAssetId
|
||||||
) : ICommand<Result>;
|
) : ICommand<Result>;
|
||||||
|
|
||||||
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>).</summary>
|
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>). Условия
|
||||||
public sealed record BumperSettingsInput(
|
/// показа сюда не входят — они задаются на элементе стыка.</summary>
|
||||||
BumperFont Font,
|
public sealed record BumperSettingsInput(BumperFont Font, BumperSelection Selection);
|
||||||
int MinIntervalMinutes,
|
|
||||||
BumperSelection Selection,
|
|
||||||
double ShowChangeChance,
|
|
||||||
double EpisodeChangeChance
|
|
||||||
);
|
|
||||||
|
|||||||
+1
-7
@@ -36,13 +36,7 @@ public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
|
|||||||
command.BumpersEnabled,
|
command.BumpersEnabled,
|
||||||
command.FillerAssetId
|
command.FillerAssetId
|
||||||
);
|
);
|
||||||
channel.UpdateBumperSettings(
|
channel.UpdateBumperSettings(command.Bumper.Font, command.Bumper.Selection);
|
||||||
command.Bumper.Font,
|
|
||||||
command.Bumper.MinIntervalMinutes,
|
|
||||||
command.Bumper.Selection,
|
|
||||||
command.Bumper.ShowChangeChance,
|
|
||||||
command.Bumper.EpisodeChangeChance
|
|
||||||
);
|
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-4
@@ -8,9 +8,5 @@ public sealed class UpdateChannelSettingsCommandValidator
|
|||||||
public UpdateChannelSettingsCommandValidator()
|
public UpdateChannelSettingsCommandValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||||
|
|
||||||
RuleFor(x => x.Bumper.MinIntervalMinutes).InclusiveBetween(0, 1440);
|
|
||||||
RuleFor(x => x.Bumper.ShowChangeChance).InclusiveBetween(0.0, 1.0);
|
|
||||||
RuleFor(x => x.Bumper.EpisodeChangeChance).InclusiveBetween(0.0, 1.0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Library.Interstitials.ImportInterstitials;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Превращает готовые медиа-ассеты в ролики: по одному <c>Show(Kind = Interstitial)</c> на файл,
|
||||||
|
/// имя — имя файла без расширения. Ассеты, уже привязанные к какому-нибудь шоу, пропускаются —
|
||||||
|
/// так повторный импорт того же выделения не плодит дубли.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ImportInterstitialsCommand(IReadOnlyList<Guid> MediaAssetIds)
|
||||||
|
: ICommand<Result<int>>;
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Library.Interstitials.ImportInterstitials;
|
||||||
|
|
||||||
|
public sealed class ImportInterstitialsCommandHandler(IAppDbContext dbContext)
|
||||||
|
: ICommandHandler<ImportInterstitialsCommand, Result<int>>
|
||||||
|
{
|
||||||
|
public async Task<Result<int>> Handle(
|
||||||
|
ImportInterstitialsCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var ids = command.MediaAssetIds.Distinct().ToList();
|
||||||
|
|
||||||
|
var assets = await dbContext
|
||||||
|
.MediaAssets.AsNoTracking()
|
||||||
|
.Where(a => ids.Contains(a.Id))
|
||||||
|
.Select(a => new { a.Id, a.OriginalFileName })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (assets.Count == 0)
|
||||||
|
return Result.Failure<int>(ShowErrors.AssetNotFound);
|
||||||
|
|
||||||
|
var alreadyUsed = await dbContext
|
||||||
|
.Shows.SelectMany(s => s.Episodes)
|
||||||
|
.Where(e => ids.Contains(e.MediaAssetId))
|
||||||
|
.Select(e => e.MediaAssetId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var used = alreadyUsed.ToHashSet();
|
||||||
|
|
||||||
|
var created = 0;
|
||||||
|
foreach (var asset in assets.Where(a => !used.Contains(a.Id)))
|
||||||
|
{
|
||||||
|
var show = Show.Create(ClipName(asset.OriginalFileName), ShowKind.Interstitial);
|
||||||
|
show.AddEpisode(asset.Id);
|
||||||
|
dbContext.Shows.Add(show);
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Success(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Имя ролика — имя файла без расширения; пустое (файл вида «.mp4») заменяем самим файлом.</summary>
|
||||||
|
private static string ClipName(string fileName)
|
||||||
|
{
|
||||||
|
var name = Path.GetFileNameWithoutExtension(fileName);
|
||||||
|
return string.IsNullOrWhiteSpace(name) ? fileName : name;
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Library.Interstitials.ImportInterstitials;
|
||||||
|
|
||||||
|
public sealed class ImportInterstitialsCommandValidator
|
||||||
|
: AbstractValidator<ImportInterstitialsCommand>
|
||||||
|
{
|
||||||
|
public ImportInterstitialsCommandValidator() =>
|
||||||
|
RuleFor(x => x.MediaAssetIds).NotEmpty().Must(ids => ids.Count <= 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using TeleWave.Domain.Media;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Library.Interstitials;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ролик на экране «Ролики». Это то же <c>Show(Kind = Interstitial)</c>, но показывается по-другому:
|
||||||
|
/// у ролика нет ни года, ни постера, ни серий — важна длительность, поэтому она приходит сразу,
|
||||||
|
/// а не вторым запросом за медиа-ассетом.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record InterstitialDto(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
Guid? MediaAssetId,
|
||||||
|
MediaAssetStatus? AssetStatus,
|
||||||
|
double? DurationSeconds,
|
||||||
|
DateTimeOffset CreatedAt
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Рекламный блок — коллекция, целиком собранная из роликов. Отдельного типа под блок нет
|
||||||
|
/// (см. 3.7): блок и есть коллекция, здесь она показывается со своей суммарной длительностью.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record InterstitialBlockDto(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
int ItemCount,
|
||||||
|
double DurationSeconds
|
||||||
|
);
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Library.Interstitials.ListInterstitialBlocks;
|
||||||
|
|
||||||
|
/// <summary>Коллекции, целиком собранные из роликов, — рекламные блоки экрана «Ролики».</summary>
|
||||||
|
public sealed record ListInterstitialBlocksQuery : IQuery<IReadOnlyList<InterstitialBlockDto>>;
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Library.Interstitials.ListInterstitialBlocks;
|
||||||
|
|
||||||
|
public sealed class ListInterstitialBlocksQueryHandler(IAppDbContext dbContext)
|
||||||
|
: IQueryHandler<ListInterstitialBlocksQuery, IReadOnlyList<InterstitialBlockDto>>
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<InterstitialBlockDto>> Handle(
|
||||||
|
ListInterstitialBlocksQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var collections = await dbContext
|
||||||
|
.Collections.AsNoTracking()
|
||||||
|
.Include(c => c.Items)
|
||||||
|
.OrderBy(c => c.Name)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
// «Блок» — не отдельная сущность, а признак состава: коллекция целиком из роликов. Смешанные
|
||||||
|
// коллекции (франшизы) остаются на своём экране и сюда не попадают.
|
||||||
|
var showIds = collections.SelectMany(c => c.Items.Select(i => i.ShowId)).Distinct().ToList();
|
||||||
|
var clips = await dbContext
|
||||||
|
.Shows.AsNoTracking()
|
||||||
|
.Where(s => showIds.Contains(s.Id) && s.Kind == ShowKind.Interstitial)
|
||||||
|
.Select(s => new
|
||||||
|
{
|
||||||
|
s.Id,
|
||||||
|
AssetIds = s.Episodes.Select(e => e.MediaAssetId).ToList(),
|
||||||
|
})
|
||||||
|
.ToDictionaryAsync(s => s.Id, s => s.AssetIds, cancellationToken);
|
||||||
|
|
||||||
|
var assetIds = clips.Values.SelectMany(ids => ids).Distinct().ToList();
|
||||||
|
var durations = await dbContext
|
||||||
|
.MediaAssets.AsNoTracking()
|
||||||
|
.Where(a => assetIds.Contains(a.Id) && a.Duration != null)
|
||||||
|
.Select(a => new { a.Id, a.Duration })
|
||||||
|
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value.TotalSeconds, cancellationToken);
|
||||||
|
|
||||||
|
return collections
|
||||||
|
.Where(c => c.Items.Count > 0 && c.Items.All(i => clips.ContainsKey(i.ShowId)))
|
||||||
|
.Select(c => new InterstitialBlockDto(
|
||||||
|
c.Id,
|
||||||
|
c.Name,
|
||||||
|
c.Items.Count,
|
||||||
|
c.Items.Sum(i =>
|
||||||
|
clips[i.ShowId]
|
||||||
|
.Sum(assetId => durations.TryGetValue(assetId, out var d) ? d : 0)
|
||||||
|
)
|
||||||
|
))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Library.Interstitials.ListInterstitials;
|
||||||
|
|
||||||
|
public sealed record ListInterstitialsQuery : IQuery<IReadOnlyList<InterstitialDto>>;
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Library.Interstitials.ListInterstitials;
|
||||||
|
|
||||||
|
public sealed class ListInterstitialsQueryHandler(IAppDbContext dbContext)
|
||||||
|
: IQueryHandler<ListInterstitialsQuery, IReadOnlyList<InterstitialDto>>
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<InterstitialDto>> Handle(
|
||||||
|
ListInterstitialsQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var shows = await dbContext
|
||||||
|
.Shows.AsNoTracking()
|
||||||
|
.Include(s => s.Episodes)
|
||||||
|
.Where(s => s.Kind == ShowKind.Interstitial)
|
||||||
|
.OrderBy(s => s.Name)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
// У ролика ровно одна «серия» — берём её ассет, чтобы показать длительность и статус обработки.
|
||||||
|
var assetIds = shows
|
||||||
|
.Select(s => s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).FirstOrDefault())
|
||||||
|
.Where(id => id != Guid.Empty)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
var assets = await dbContext
|
||||||
|
.MediaAssets.AsNoTracking()
|
||||||
|
.Where(a => assetIds.Contains(a.Id))
|
||||||
|
.Select(a => new
|
||||||
|
{
|
||||||
|
a.Id,
|
||||||
|
a.Status,
|
||||||
|
a.Duration,
|
||||||
|
})
|
||||||
|
.ToDictionaryAsync(a => a.Id, cancellationToken);
|
||||||
|
|
||||||
|
return shows
|
||||||
|
.Select(s =>
|
||||||
|
{
|
||||||
|
var assetId = s
|
||||||
|
.Episodes.OrderBy(e => e.Position)
|
||||||
|
.Select(e => (Guid?)e.MediaAssetId)
|
||||||
|
.FirstOrDefault();
|
||||||
|
var asset =
|
||||||
|
assetId is { } id && assets.TryGetValue(id, out var a) ? a : null;
|
||||||
|
return new InterstitialDto(
|
||||||
|
s.Id,
|
||||||
|
s.Name,
|
||||||
|
assetId,
|
||||||
|
asset?.Status,
|
||||||
|
asset?.Duration?.TotalSeconds,
|
||||||
|
s.CreatedAt
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ public static class ShowErrors
|
|||||||
|
|
||||||
public static readonly Error SingleAlreadyHasEpisode = Error.Conflict(
|
public static readonly Error SingleAlreadyHasEpisode = Error.Conflict(
|
||||||
"Shows.SingleAlreadyHasEpisode",
|
"Shows.SingleAlreadyHasEpisode",
|
||||||
"Полнометражка/разовый выпуск может содержать только одну серию."
|
"Больше одной серии бывает только у сериала."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error AssetNotFound = Error.NotFound(
|
public static readonly Error AssetNotFound = Error.NotFound(
|
||||||
|
|||||||
@@ -146,6 +146,39 @@ public sealed class GridScheduleGenerator(
|
|||||||
return new GenerationReport(added, result.Warnings);
|
return new GenerationReport(added, result.Warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Сухой прогон: считает, каким получился бы эфир по текущим правилам, но ничего не пишет —
|
||||||
|
/// ни ленты, ни курсоров слотов, ни отметки о применении. Заставки остаются резервом известной
|
||||||
|
/// длины: рендер долгий и может упасть, поэтому он делается только при реальном применении.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<PlanningResult?> PreviewAsync(
|
||||||
|
Guid channelId,
|
||||||
|
DateTimeOffset from,
|
||||||
|
int days,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var channel = await dbContext
|
||||||
|
.Channels.AsNoTracking()
|
||||||
|
.Include(c => c.BumperTemplates)
|
||||||
|
.ThenInclude(t => t.Variants)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
|
||||||
|
if (channel is null || channel.TemplateId is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var template = await dbContext
|
||||||
|
.ScheduleTemplates.AsNoTracking()
|
||||||
|
.Include(t => t.Layers)
|
||||||
|
.ThenInclude(l => l.Slots)
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == channel.TemplateId, cancellationToken);
|
||||||
|
if (template is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var horizonEnd = from.AddDays(Math.Clamp(days, 1, Math.Max(1, _options.HorizonDays)));
|
||||||
|
var input = await BuildInputAsync(channel, template, from, horizonEnd, cancellationToken);
|
||||||
|
return Domain.Programming.Planning.SchedulePlanner.Plan(input, random);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Чистит прошлое сверх окна хранения. Окно должно покрывать самое долгое остывание среди правил —
|
/// Чистит прошлое сверх окна хранения. Окно должно покрывать самое долгое остывание среди правил —
|
||||||
/// история показов берётся из самой ленты, отдельного журнала нет.
|
/// история показов берётся из самой ленты, отдельного журнала нет.
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Application.Programming.Planning.ApplyTemplate;
|
||||||
|
using TeleWave.Domain.Programming.Planning;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Programming.Planning.Preview;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Прогон генератора по текущим правилам без записи: что было бы в эфире, если применить сейчас.
|
||||||
|
/// Курсоры слотов не двигаются, поэтому предпросмотр можно жать сколько угодно раз.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PreviewScheduleQuery(Guid ChannelId, int Days = 1)
|
||||||
|
: IQuery<Result<SchedulePreviewDto>>;
|
||||||
|
|
||||||
|
/// <summary>Запись предполагаемой ленты. Заставка приходит без ассета — он рендерится при применении.</summary>
|
||||||
|
public sealed record PreviewItemDto(
|
||||||
|
PlannedItemKind Kind,
|
||||||
|
DateTimeOffset StartsAtUtc,
|
||||||
|
DateTimeOffset EndsAtUtc,
|
||||||
|
Guid? ShowId,
|
||||||
|
string? Title,
|
||||||
|
Guid? SlotId,
|
||||||
|
string? SlotTitle
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record SchedulePreviewDto(
|
||||||
|
DateTimeOffset FromUtc,
|
||||||
|
DateTimeOffset ToUtc,
|
||||||
|
/// <summary>Время канала: лента отдаётся в UTC, а показывается в нём.</summary>
|
||||||
|
int UtcOffsetMinutes,
|
||||||
|
IReadOnlyList<PreviewItemDto> Items,
|
||||||
|
IReadOnlyList<PlanningWarningDto> Warnings
|
||||||
|
);
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Broadcast;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Application.Programming.Planning.ApplyTemplate;
|
||||||
|
using TeleWave.Domain.Programming.Planning;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Programming.Planning.Preview;
|
||||||
|
|
||||||
|
public sealed class PreviewScheduleQueryHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
GridScheduleGenerator generator
|
||||||
|
) : IQueryHandler<PreviewScheduleQuery, Result<SchedulePreviewDto>>
|
||||||
|
{
|
||||||
|
public async Task<Result<SchedulePreviewDto>> Handle(
|
||||||
|
PreviewScheduleQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var channel = await dbContext
|
||||||
|
.Channels.AsNoTracking()
|
||||||
|
.Where(c => c.Id == query.ChannelId)
|
||||||
|
.Select(c => new { c.UtcOffsetMinutes })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (channel is null)
|
||||||
|
return Result.Failure<SchedulePreviewDto>(ChannelErrors.NotFound);
|
||||||
|
|
||||||
|
var from = DateTimeOffset.UtcNow;
|
||||||
|
var result = await generator.PreviewAsync(
|
||||||
|
query.ChannelId,
|
||||||
|
from,
|
||||||
|
query.Days,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (result is null)
|
||||||
|
return Result.Failure<SchedulePreviewDto>(ChannelErrors.TemplateNotFound);
|
||||||
|
|
||||||
|
var showNames = await LoadShowNamesAsync(result.Items, cancellationToken);
|
||||||
|
var slotTitles = await LoadSlotTitlesAsync(result.Items, cancellationToken);
|
||||||
|
|
||||||
|
var items = result
|
||||||
|
.Items.Select(item => new PreviewItemDto(
|
||||||
|
item.Kind,
|
||||||
|
item.StartsAtUtc,
|
||||||
|
item.EndsAtUtc,
|
||||||
|
item.ShowId,
|
||||||
|
item.ShowId is { } showId && showNames.TryGetValue(showId, out var name)
|
||||||
|
? name
|
||||||
|
: null,
|
||||||
|
item.SlotId,
|
||||||
|
item.SlotId is { } slotId && slotTitles.TryGetValue(slotId, out var title)
|
||||||
|
? title
|
||||||
|
: null
|
||||||
|
))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var to = items.Count == 0 ? from : items[^1].EndsAtUtc;
|
||||||
|
|
||||||
|
return Result.Success(
|
||||||
|
new SchedulePreviewDto(
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
channel.UtcOffsetMinutes,
|
||||||
|
items,
|
||||||
|
result
|
||||||
|
.Warnings.Select(w => new PlanningWarningDto(w.Kind, w.SlotId, w.Details))
|
||||||
|
.ToList()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Dictionary<Guid, string>> LoadShowNamesAsync(
|
||||||
|
IReadOnlyList<PlannedItem> items,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var ids = items
|
||||||
|
.Select(i => i.ShowId)
|
||||||
|
.Where(id => id is not null && id != Guid.Empty)
|
||||||
|
.Select(id => id!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
if (ids.Count == 0)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return await dbContext
|
||||||
|
.Shows.AsNoTracking()
|
||||||
|
.Where(s => ids.Contains(s.Id))
|
||||||
|
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Dictionary<Guid, string>> LoadSlotTitlesAsync(
|
||||||
|
IReadOnlyList<PlannedItem> items,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var ids = items
|
||||||
|
.Select(i => i.SlotId)
|
||||||
|
.Where(id => id is not null)
|
||||||
|
.Select(id => id!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
if (ids.Count == 0)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return await dbContext
|
||||||
|
.Slots.AsNoTracking()
|
||||||
|
.Where(s => ids.Contains(s.Id))
|
||||||
|
.ToDictionaryAsync(s => s.Id, s => s.Title, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
namespace TeleWave.Domain.Broadcast;
|
namespace TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
/// <summary>Как выбирать блок заставки на каждом переходе между шоу.</summary>
|
/// <summary>
|
||||||
|
/// Как выбирать подблок заставки на переходе. Значения заданы явно: прежний вариант «по кругу»
|
||||||
|
/// (0) убран — курсора ротации в новом пайплайне нет, и он молча вырождался в случайный выбор.
|
||||||
|
/// </summary>
|
||||||
public enum BumperSelection
|
public enum BumperSelection
|
||||||
{
|
{
|
||||||
/// <summary>По кругу в порядке блоков (курсор <see cref="Channel.NextBumperIndex"/>).</summary>
|
/// <summary>Случайный подблок на каждом переходе (равновероятно).</summary>
|
||||||
Rotation,
|
Random = 1,
|
||||||
|
|
||||||
/// <summary>Случайный блок на каждом переходе (равновероятно).</summary>
|
/// <summary>Всегда первый (дефолтный) подблок.</summary>
|
||||||
Random,
|
AlwaysFirst = 2,
|
||||||
|
|
||||||
/// <summary>Всегда первый (дефолтный) блок.</summary>
|
/// <summary>Случайный подблок с учётом веса (<see cref="BumperTextVariant.Weight"/>).</summary>
|
||||||
AlwaysFirst,
|
WeightedRandom = 3,
|
||||||
|
|
||||||
/// <summary>Случайный блок с учётом веса подблока (<see cref="BumperTextVariant.Weight"/>).</summary>
|
|
||||||
WeightedRandom,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,176 +1,145 @@
|
|||||||
namespace TeleWave.Domain.Broadcast;
|
namespace TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Канал линейного эфира. Что и когда идёт в эфире, определяет шаблон сетки (<see cref="TemplateId"/>,
|
/// Канал линейного эфира. Что и когда идёт в эфире, определяет шаблон сетки (<see cref="TemplateId"/>,
|
||||||
/// см. <c>Domain/Programming</c>); канал хранит только собственные свойства: время, номер, аварийный
|
/// см. <c>Domain/Programming</c>); канал хранит только собственные свойства: время, номер, аварийный
|
||||||
/// филлер и общие настройки заставок.
|
/// филлер и общие настройки заставок.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Channel
|
public class Channel
|
||||||
{
|
{
|
||||||
private readonly List<BumperTemplate> _bumperTemplates = new();
|
private readonly List<BumperTemplate> _bumperTemplates = new();
|
||||||
|
|
||||||
public Guid Id { get; private set; }
|
public Guid Id { get; private set; }
|
||||||
public string Name { get; private set; } = string.Empty;
|
public string Name { get; private set; } = string.Empty;
|
||||||
public string Slug { get; private set; } = string.Empty;
|
public string Slug { get; private set; } = string.Empty;
|
||||||
public bool IsEnabled { get; private set; }
|
public bool IsEnabled { get; private set; }
|
||||||
|
|
||||||
/// <summary>Точка отсчёта эфирной ленты (UTC) — база для MEDIA-SEQUENCE на этапе раздачи.</summary>
|
/// <summary>Точка отсчёта эфирной ленты (UTC) — база для MEDIA-SEQUENCE на этапе раздачи.</summary>
|
||||||
public DateTimeOffset EpochUtc { get; private set; }
|
public DateTimeOffset EpochUtc { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Номер канала — на телевизоре канал это номер, а не карточка в сетке. Переключение по номерам
|
/// Номер канала — на телевизоре канал это номер, а не карточка в сетке. Переключение по номерам
|
||||||
/// включается глобальным флагом настроек сайта; null — номер не задан.
|
/// включается глобальным флагом настроек сайта; null — номер не задан.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? Number { get; private set; }
|
public int? Number { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Смещение времени канала от UTC в минутах (по умолчанию 180 — московское). Фиксированный
|
/// Смещение времени канала от UTC в минутах (по умолчанию 180 — московское). Фиксированный
|
||||||
/// оффсет, а не IANA-зона: с переводом часов сутки становятся 23- или 25-часовыми, и сетке
|
/// оффсет, а не IANA-зона: с переводом часов сутки становятся 23- или 25-часовыми, и сетке
|
||||||
/// понадобилось бы отдельное правило подрезки. Появятся каналы в зонах с DST — перейдём на IANA.
|
/// понадобилось бы отдельное правило подрезки. Появятся каналы в зонах с DST — перейдём на IANA.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int UtcOffsetMinutes { get; private set; }
|
public int UtcOffsetMinutes { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Начало вещательных суток в времени канала (по умолчанию 06:00). Ночной блок с 00:00 до 06:00
|
/// Начало вещательных суток в времени канала (по умолчанию 06:00). Ночной блок с 00:00 до 06:00
|
||||||
/// относится к предыдущему дню: «ночь с пятницы на субботу» — это пятница.
|
/// относится к предыдущему дню: «ночь с пятницы на субботу» — это пятница.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public TimeOnly DayStartTime { get; private set; }
|
public TimeOnly DayStartTime { get; private set; }
|
||||||
|
|
||||||
/// <summary>Активный шаблон сетки канала (один на канал).</summary>
|
/// <summary>Активный шаблон сетки канала (один на канал).</summary>
|
||||||
public Guid? TemplateId { get; private set; }
|
public Guid? TemplateId { get; private set; }
|
||||||
|
|
||||||
public const int DefaultUtcOffsetMinutes = 180;
|
public const int DefaultUtcOffsetMinutes = 180;
|
||||||
public static readonly TimeOnly DefaultDayStartTime = new(6, 0);
|
public static readonly TimeOnly DefaultDayStartTime = new(6, 0);
|
||||||
|
|
||||||
// ── Настройки ТВ-заставок. В срезе 2 условия показа переезжают в элементы стыка,
|
// ── Настройки ТВ-заставок. Условия показа (как часто, на смене шоу или между сериями)
|
||||||
// здесь останется только общий для канала шрифт. ──
|
// живут в элементах стыка; на канале осталось только общее для всех заставок. ──
|
||||||
|
|
||||||
/// <summary>Вставлять ли ТВ-заставки на переходах между разными шоу.</summary>
|
/// <summary>Вставлять ли ТВ-заставки вообще: общий выключатель канала.</summary>
|
||||||
public bool BumpersEnabled { get; private set; }
|
public bool BumpersEnabled { get; private set; }
|
||||||
|
|
||||||
/// <summary>Как выбирать блок заставки на каждом переходе (по кругу/случайно/всегда первый).</summary>
|
/// <summary>Как выбирать подблок заставки на переходе (случайно/по весам/всегда первый).</summary>
|
||||||
public BumperSelection BumperSelection { get; private set; }
|
public BumperSelection BumperSelection { get; private set; }
|
||||||
|
|
||||||
/// <summary>Курсор ротации блоков заставок.</summary>
|
public BumperFont BumperFont { get; private set; }
|
||||||
public int NextBumperIndex { get; private set; }
|
|
||||||
|
private const string DefaultTemplateName = "Заставка 1";
|
||||||
public BumperFont BumperFont { get; private set; }
|
|
||||||
|
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
|
||||||
/// <summary>Не вставлять заставку чаще, чем раз в N минут (0 — на каждом подходящем переходе).</summary>
|
public Guid? FillerAssetId { get; private set; }
|
||||||
public int BumperMinIntervalMinutes { get; private set; }
|
|
||||||
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
/// <summary>Вероятность заставки на смене шоу (0..1; 1 — на каждой смене, 0 — никогда).</summary>
|
|
||||||
public double BumperShowChangeChance { get; private set; } = 1.0;
|
/// <summary>Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.</summary>
|
||||||
|
public IReadOnlyList<BumperTemplate> BumperTemplates => _bumperTemplates;
|
||||||
/// <summary>Вероятность заставки между блоками одного шоу (0..1; напр. 0.3 — примерно в 30% случаев).</summary>
|
|
||||||
public double BumperEpisodeChangeChance { get; private set; } = 1.0;
|
private Channel() { }
|
||||||
|
|
||||||
private const string DefaultTemplateName = "Заставка 1";
|
public static Channel Create(string name, string slug, DateTimeOffset epochUtc)
|
||||||
|
{
|
||||||
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
|
var channel = new Channel
|
||||||
public Guid? FillerAssetId { get; private set; }
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
public DateTimeOffset CreatedAt { get; private set; }
|
Name = name,
|
||||||
|
Slug = slug,
|
||||||
/// <summary>Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.</summary>
|
IsEnabled = true,
|
||||||
public IReadOnlyList<BumperTemplate> BumperTemplates => _bumperTemplates;
|
EpochUtc = epochUtc,
|
||||||
|
BumpersEnabled = false,
|
||||||
private Channel() { }
|
BumperSelection = BumperSelection.WeightedRandom,
|
||||||
|
BumperFont = BumperFont.Sans,
|
||||||
public static Channel Create(string name, string slug, DateTimeOffset epochUtc)
|
UtcOffsetMinutes = DefaultUtcOffsetMinutes,
|
||||||
{
|
DayStartTime = DefaultDayStartTime,
|
||||||
var channel = new Channel
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
{
|
};
|
||||||
Id = Guid.NewGuid(),
|
// На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл).
|
||||||
Name = name,
|
channel._bumperTemplates.Add(BumperTemplate.Create(channel.Id, 0, DefaultTemplateName));
|
||||||
Slug = slug,
|
return channel;
|
||||||
IsEnabled = true,
|
}
|
||||||
EpochUtc = epochUtc,
|
|
||||||
BumpersEnabled = false,
|
public void UpdateSettings(
|
||||||
BumperSelection = BumperSelection.Rotation,
|
string name,
|
||||||
NextBumperIndex = 0,
|
bool isEnabled,
|
||||||
BumperFont = BumperFont.Sans,
|
bool bumpersEnabled,
|
||||||
BumperMinIntervalMinutes = 0,
|
Guid? fillerAssetId
|
||||||
BumperShowChangeChance = 1.0,
|
)
|
||||||
BumperEpisodeChangeChance = 1.0,
|
{
|
||||||
UtcOffsetMinutes = DefaultUtcOffsetMinutes,
|
Name = name;
|
||||||
DayStartTime = DefaultDayStartTime,
|
IsEnabled = isEnabled;
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
BumpersEnabled = bumpersEnabled;
|
||||||
};
|
FillerAssetId = fillerAssetId;
|
||||||
// На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл).
|
}
|
||||||
channel._bumperTemplates.Add(BumperTemplate.Create(channel.Id, 0, DefaultTemplateName));
|
|
||||||
return channel;
|
/// <summary>Общие настройки ТВ-заставок канала: шрифт и стратегия выбора подблока.</summary>
|
||||||
}
|
public void UpdateBumperSettings(BumperFont font, BumperSelection selection)
|
||||||
|
{
|
||||||
public void UpdateSettings(
|
BumperFont = font;
|
||||||
string name,
|
BumperSelection = selection;
|
||||||
bool isEnabled,
|
}
|
||||||
bool bumpersEnabled,
|
|
||||||
Guid? fillerAssetId
|
/// <summary>Добавить блок заставки в конец списка. Возвращает созданный блок.</summary>
|
||||||
)
|
public BumperTemplate AddBumperTemplate(string name)
|
||||||
{
|
{
|
||||||
Name = name;
|
var nextPosition =
|
||||||
IsEnabled = isEnabled;
|
_bumperTemplates.Count == 0 ? 0 : _bumperTemplates.Max(t => t.Position) + 1;
|
||||||
BumpersEnabled = bumpersEnabled;
|
var template = BumperTemplate.Create(Id, nextPosition, name);
|
||||||
FillerAssetId = fillerAssetId;
|
_bumperTemplates.Add(template);
|
||||||
}
|
return template;
|
||||||
|
}
|
||||||
/// <summary>
|
|
||||||
/// Общие настройки ТВ-заставок канала: шрифт, мин. интервал, стратегия выбора подблока и
|
public BumperTemplate? FindBumperTemplate(Guid templateId) =>
|
||||||
/// вероятности появления на смене шоу / между блоками одного шоу (0..1).
|
_bumperTemplates.FirstOrDefault(t => t.Id == templateId);
|
||||||
/// </summary>
|
|
||||||
public void UpdateBumperSettings(
|
/// <summary>Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false.</summary>
|
||||||
BumperFont font,
|
public bool RemoveBumperTemplate(Guid templateId)
|
||||||
int minIntervalMinutes,
|
{
|
||||||
BumperSelection selection,
|
var template = _bumperTemplates.FirstOrDefault(t => t.Id == templateId);
|
||||||
double showChangeChance,
|
if (template is null || template.IsDefault)
|
||||||
double episodeChangeChance
|
return false;
|
||||||
)
|
_bumperTemplates.Remove(template);
|
||||||
{
|
return true;
|
||||||
BumperFont = font;
|
}
|
||||||
BumperMinIntervalMinutes = Math.Max(0, minIntervalMinutes);
|
|
||||||
BumperSelection = selection;
|
/// <summary>Привязать активный шаблон сетки.</summary>
|
||||||
BumperShowChangeChance = Math.Clamp(showChangeChance, 0.0, 1.0);
|
public void SetTemplate(Guid? templateId) => TemplateId = templateId;
|
||||||
BumperEpisodeChangeChance = Math.Clamp(episodeChangeChance, 0.0, 1.0);
|
|
||||||
}
|
/// <summary>
|
||||||
|
/// Настройки времени канала: номер, смещение от UTC и начало вещательных суток. Смещение
|
||||||
/// <summary>Добавить блок заставки в конец списка. Возвращает созданный блок.</summary>
|
/// ограничено сутками — за пределами этого диапазона сетка потеряла бы связь с календарём.
|
||||||
public BumperTemplate AddBumperTemplate(string name)
|
/// </summary>
|
||||||
{
|
public void UpdateTimeSettings(int? number, int utcOffsetMinutes, TimeOnly dayStartTime)
|
||||||
var nextPosition =
|
{
|
||||||
_bumperTemplates.Count == 0 ? 0 : _bumperTemplates.Max(t => t.Position) + 1;
|
Number = number is > 0 ? number : null;
|
||||||
var template = BumperTemplate.Create(Id, nextPosition, name);
|
UtcOffsetMinutes = Math.Clamp(utcOffsetMinutes, -12 * 60, 14 * 60);
|
||||||
_bumperTemplates.Add(template);
|
DayStartTime = dayStartTime;
|
||||||
return template;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public BumperTemplate? FindBumperTemplate(Guid templateId) =>
|
|
||||||
_bumperTemplates.FirstOrDefault(t => t.Id == templateId);
|
|
||||||
|
|
||||||
/// <summary>Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false.</summary>
|
|
||||||
public bool RemoveBumperTemplate(Guid templateId)
|
|
||||||
{
|
|
||||||
var template = _bumperTemplates.FirstOrDefault(t => t.Id == templateId);
|
|
||||||
if (template is null || template.IsDefault)
|
|
||||||
return false;
|
|
||||||
_bumperTemplates.Remove(template);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Планировщик двигает курсор ротации блоков заставок по мере вставки.</summary>
|
|
||||||
public void SetNextBumperIndex(int index) => NextBumperIndex = index;
|
|
||||||
|
|
||||||
/// <summary>Привязать активный шаблон сетки.</summary>
|
|
||||||
public void SetTemplate(Guid? templateId) => TemplateId = templateId;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Настройки времени канала: номер, смещение от UTC и начало вещательных суток. Смещение
|
|
||||||
/// ограничено сутками — за пределами этого диапазона сетка потеряла бы связь с календарём.
|
|
||||||
/// </summary>
|
|
||||||
public void UpdateTimeSettings(int? number, int utcOffsetMinutes, TimeOnly dayStartTime)
|
|
||||||
{
|
|
||||||
Number = number is > 0 ? number : null;
|
|
||||||
UtcOffsetMinutes = Math.Clamp(utcOffsetMinutes, -12 * 60, 14 * 60);
|
|
||||||
DayStartTime = dayStartTime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ public class Show
|
|||||||
{
|
{
|
||||||
if (!CanAddEpisode)
|
if (!CanAddEpisode)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"Полнометражка (ShowKind.Single) может содержать только одну серию."
|
"Только сериал может содержать больше одной серии."
|
||||||
);
|
);
|
||||||
|
|
||||||
var nextPosition = _episodes.Count == 0 ? 0 : _episodes.Max(e => e.Position) + 1;
|
var nextPosition = _episodes.Count == 0 ? 0 : _episodes.Max(e => e.Position) + 1;
|
||||||
@@ -127,7 +127,9 @@ public class Show
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool CanAddEpisode => Kind != ShowKind.Single || _episodes.Count == 0;
|
/// <summary>Несколько серий бывает только у сериала. У полнометражки и у ролика-врезки серия ровно
|
||||||
|
/// одна: ролик с тремя сериями вёл бы себя в планировщике как мини-сериал, а задуман как единица.</summary>
|
||||||
|
public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0;
|
||||||
|
|
||||||
/// <summary>Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null.</summary>
|
/// <summary>Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null.</summary>
|
||||||
public void ApplyMetadata(
|
public void ApplyMetadata(
|
||||||
|
|||||||
Generated
+1368
File diff suppressed because it is too large
Load Diff
+68
@@ -0,0 +1,68 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class DropDeadBumperSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Значение 0 (прежняя «ротация») из перечисления убрано: курсора ротации больше нет,
|
||||||
|
// и выбор молча вырождался в случайный. Переводим такие каналы на выбор по весам.
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""UPDATE "Channels" SET "BumperSelection" = 3 WHERE "BumperSelection" = 0;"""
|
||||||
|
);
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "BumperEpisodeChangeChance",
|
||||||
|
table: "Channels");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "BumperMinIntervalMinutes",
|
||||||
|
table: "Channels");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "BumperShowChangeChance",
|
||||||
|
table: "Channels");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "NextBumperIndex",
|
||||||
|
table: "Channels");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<double>(
|
||||||
|
name: "BumperEpisodeChangeChance",
|
||||||
|
table: "Channels",
|
||||||
|
type: "double precision",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0.0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "BumperMinIntervalMinutes",
|
||||||
|
table: "Channels",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<double>(
|
||||||
|
name: "BumperShowChangeChance",
|
||||||
|
table: "Channels",
|
||||||
|
type: "double precision",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0.0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "NextBumperIndex",
|
||||||
|
table: "Channels",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -318,21 +318,12 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<double>("BumperEpisodeChangeChance")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<int>("BumperFont")
|
b.Property<int>("BumperFont")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int>("BumperMinIntervalMinutes")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int>("BumperSelection")
|
b.Property<int>("BumperSelection")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<double>("BumperShowChangeChance")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<bool>("BumpersEnabled")
|
b.Property<bool>("BumpersEnabled")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
@@ -356,9 +347,6 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("character varying(256)");
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
b.Property<int>("NextBumperIndex")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("Number")
|
b.Property<int?>("Number")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
|||||||
@@ -56,13 +56,7 @@ public class ChannelHandlersTests
|
|||||||
"c",
|
"c",
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
new BumperSettingsInput(
|
new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom),
|
||||||
BumperFont.Sans,
|
|
||||||
5,
|
|
||||||
BumperSelection.WeightedRandom,
|
|
||||||
0.5,
|
|
||||||
0.2
|
|
||||||
),
|
|
||||||
null
|
null
|
||||||
),
|
),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
@@ -72,8 +66,7 @@ public class ChannelHandlersTests
|
|||||||
|
|
||||||
await using var verify = fixture.New();
|
await using var verify = fixture.New();
|
||||||
var stored = await verify.Channels.FindAsync(channel.Id);
|
var stored = await verify.Channels.FindAsync(channel.Id);
|
||||||
Assert.Equal(0.5, stored!.BumperShowChangeChance);
|
Assert.Equal(BumperFont.Sans, stored!.BumperFont);
|
||||||
Assert.Equal(0.2, stored.BumperEpisodeChangeChance);
|
|
||||||
Assert.Equal(BumperSelection.WeightedRandom, stored.BumperSelection);
|
Assert.Equal(BumperSelection.WeightedRandom, stored.BumperSelection);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,13 +40,7 @@ public class ValidatorTests
|
|||||||
public void UpdateChannelSettings_ChecksRanges()
|
public void UpdateChannelSettings_ChecksRanges()
|
||||||
{
|
{
|
||||||
var v = new UpdateChannelSettingsCommandValidator();
|
var v = new UpdateChannelSettingsCommandValidator();
|
||||||
var bumper = new BumperSettingsInput(
|
var bumper = new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom);
|
||||||
BumperFont.Sans,
|
|
||||||
10,
|
|
||||||
BumperSelection.Rotation,
|
|
||||||
0.5,
|
|
||||||
0.5
|
|
||||||
);
|
|
||||||
|
|
||||||
var good = new UpdateChannelSettingsCommand(
|
var good = new UpdateChannelSettingsCommand(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
@@ -59,9 +53,6 @@ public class ValidatorTests
|
|||||||
Assert.True(v.Validate(good).IsValid);
|
Assert.True(v.Validate(good).IsValid);
|
||||||
|
|
||||||
Assert.False(v.Validate(good with { Name = "" }).IsValid);
|
Assert.False(v.Validate(good with { Name = "" }).IsValid);
|
||||||
Assert.False(
|
|
||||||
v.Validate(good with { Bumper = bumper with { ShowChangeChance = 2 } }).IsValid
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ public class ChannelTests
|
|||||||
Assert.True(channel.IsEnabled);
|
Assert.True(channel.IsEnabled);
|
||||||
Assert.Equal(Epoch, channel.EpochUtc);
|
Assert.Equal(Epoch, channel.EpochUtc);
|
||||||
Assert.False(channel.BumpersEnabled);
|
Assert.False(channel.BumpersEnabled);
|
||||||
Assert.Equal(BumperSelection.Rotation, channel.BumperSelection);
|
Assert.Equal(BumperSelection.WeightedRandom, channel.BumperSelection);
|
||||||
Assert.Equal(1.0, channel.BumperShowChangeChance);
|
|
||||||
Assert.Equal(1.0, channel.BumperEpisodeChangeChance);
|
|
||||||
Assert.Equal(Channel.DefaultUtcOffsetMinutes, channel.UtcOffsetMinutes);
|
Assert.Equal(Channel.DefaultUtcOffsetMinutes, channel.UtcOffsetMinutes);
|
||||||
Assert.Equal(Channel.DefaultDayStartTime, channel.DayStartTime);
|
Assert.Equal(Channel.DefaultDayStartTime, channel.DayStartTime);
|
||||||
|
|
||||||
@@ -46,32 +44,15 @@ public class ChannelTests
|
|||||||
Assert.Equal(filler, channel.FillerAssetId);
|
Assert.Equal(filler, channel.FillerAssetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Fact]
|
||||||
[InlineData(-1, 2.0, -5.0, 1.0, 0.0)]
|
public void UpdateBumperSettings_ChangesFontAndSelection()
|
||||||
[InlineData(30, 0.3, 0.9, 0.3, 0.9)]
|
|
||||||
public void UpdateBumperSettings_ClampsChancesAndInterval(
|
|
||||||
int interval,
|
|
||||||
double show,
|
|
||||||
double episode,
|
|
||||||
double expectedShow,
|
|
||||||
double expectedEpisode
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
var channel = NewChannel();
|
var channel = NewChannel();
|
||||||
|
|
||||||
channel.UpdateBumperSettings(
|
channel.UpdateBumperSettings(BumperFont.Serif, BumperSelection.AlwaysFirst);
|
||||||
BumperFont.Serif,
|
|
||||||
interval,
|
|
||||||
BumperSelection.WeightedRandom,
|
|
||||||
show,
|
|
||||||
episode
|
|
||||||
);
|
|
||||||
|
|
||||||
Assert.Equal(BumperFont.Serif, channel.BumperFont);
|
Assert.Equal(BumperFont.Serif, channel.BumperFont);
|
||||||
Assert.Equal(Math.Max(0, interval), channel.BumperMinIntervalMinutes);
|
Assert.Equal(BumperSelection.AlwaysFirst, channel.BumperSelection);
|
||||||
Assert.Equal(BumperSelection.WeightedRandom, channel.BumperSelection);
|
|
||||||
Assert.Equal(expectedShow, channel.BumperShowChangeChance);
|
|
||||||
Assert.Equal(expectedEpisode, channel.BumperEpisodeChangeChance);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -13,9 +13,10 @@
|
|||||||
**Срез 1 закрыт целиком.** Канал вещает по сетке: библиотека с жанрами и коллекциями, группы,
|
**Срез 1 закрыт целиком.** Канал вещает по сетке: библиотека с жанрами и коллекциями, группы,
|
||||||
шаблон со слоями и слотами, генератор с трейсом, применение по кнопке, UI сетки. Старая ротация снесена.
|
шаблон со слоями и слотами, генератор с трейсом, применение по кнопке, UI сетки. Старая ротация снесена.
|
||||||
|
|
||||||
**Срез 2 — бэкенд закрыт, UI нет.** Работают ролики, шаблоны стыков, раскладка врезок, рендер
|
**Срез 2 закрыт целиком.** Ролики со своим экраном и сборкой блоков, шаблоны стыков с редактором
|
||||||
заставок и API управления стыками; собрать канал с рекламой и заставками можно через HTTP, но не
|
цепочки, привязка стыков к слотам, предпросмотр без записи. Мёртвые настройки заставок
|
||||||
из интерфейса.
|
(`BumperMinIntervalMinutes`, обе `*Chance`, `NextBumperIndex`) удалены — условия показа живут
|
||||||
|
в элементе стыка; из `BumperSelection` убрана «ротация», у которой не было реализации.
|
||||||
|
|
||||||
**Срезы 3 и 4** не начинались, кроме того, что уже понадобилось раньше: слоты повтора и конца
|
**Срезы 3 и 4** не начинались, кроме того, что уже понадобилось раньше: слоты повтора и конца
|
||||||
вещания сделаны вместе с планировщиком, применимость слоёв и панель слоёв — частично.
|
вещания сделаны вместе с планировщиком, применимость слоёв и панель слоёв — частично.
|
||||||
@@ -257,7 +258,7 @@ N новых позиций по фильтру».
|
|||||||
Новое значение `ShowKind`. Исключение таких шоу из обычного списка библиотеки и из запросов
|
Новое значение `ShowKind`. Исключение таких шоу из обычного списка библиотеки и из запросов
|
||||||
метаданных.
|
метаданных.
|
||||||
|
|
||||||
#### [ ] 2.2. Раздел «Ролики»
|
#### [x] 2.2. Раздел «Ролики»
|
||||||
`Api` `Frontend`
|
`Api` `Frontend`
|
||||||
|
|
||||||
Отдельный экран (см. 6.7): плоский список с длительностями и превью, массовая загрузка, сборка
|
Отдельный экран (см. 6.7): плоский список с длительностями и превью, массовая загрузка, сборка
|
||||||
@@ -289,13 +290,13 @@ N новых позиций по фильтру».
|
|||||||
`snapToMinutes` с отказом при превышении `maxDriftMinutes` (см. 4.3). Юнит-тесты на оба механизма
|
`snapToMinutes` с отказом при превышении `maxDriftMinutes` (см. 4.3). Юнит-тесты на оба механизма
|
||||||
и на их сочетание.
|
и на их сочетание.
|
||||||
|
|
||||||
#### [ ] 2.7. Редактор стыка
|
#### [x] 2.7. Редактор стыка
|
||||||
`Frontend`
|
`Frontend`
|
||||||
|
|
||||||
Горизонтальная цепочка с перетаскиванием элементов, линейка суммарной длительности, попап параметров
|
Горизонтальная цепочка с перетаскиванием элементов, линейка суммарной длительности, попап параметров
|
||||||
(см. 6.3).
|
(см. 6.3).
|
||||||
|
|
||||||
#### [ ] 2.8. Предпросмотр
|
#### [x] 2.8. Предпросмотр
|
||||||
`Application` `Api` `Frontend`
|
`Application` `Api` `Frontend`
|
||||||
|
|
||||||
Прогон генератора на черновике правил **без записи и без продвижения курсоров**. Заставки —
|
Прогон генератора на черновике правил **без записи и без продвижения курсоров**. Заставки —
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ import {
|
|||||||
} from './api'
|
} from './api'
|
||||||
import { BumperCard } from './components/BumperCard'
|
import { BumperCard } from './components/BumperCard'
|
||||||
import { CollapsibleCard } from './components/CollapsibleCard'
|
import { CollapsibleCard } from './components/CollapsibleCard'
|
||||||
|
import { JunctionsCard } from './components/JunctionsCard'
|
||||||
import { LayerList, ScheduleGrid } from './components/ScheduleGrid'
|
import { LayerList, ScheduleGrid } from './components/ScheduleGrid'
|
||||||
import { SchedulePreview } from './components/SchedulePreview'
|
import { SchedulePreview } from './components/SchedulePreview'
|
||||||
import { SettingsCard } from './components/SettingsCard'
|
import { SettingsCard } from './components/SettingsCard'
|
||||||
|
import { TemplatePreview } from './components/TemplatePreview'
|
||||||
import { SlotInspector, type SlotDraft } from './components/SlotInspector'
|
import { SlotInspector, type SlotDraft } from './components/SlotInspector'
|
||||||
|
|
||||||
export function ChannelDetail({ channelId }: { channelId: string }) {
|
export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||||
@@ -164,6 +166,7 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
<TemplatePreview channelId={channelId} />
|
||||||
<ScheduleGrid
|
<ScheduleGrid
|
||||||
template={template}
|
template={template}
|
||||||
selectedSlotId={draft?.slot?.id ?? null}
|
selectedSlotId={draft?.slot?.id ?? null}
|
||||||
@@ -171,13 +174,25 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
|||||||
onAddSlot={openNewSlot}
|
onAddSlot={openNewSlot}
|
||||||
/>
|
/>
|
||||||
{draft && (
|
{draft && (
|
||||||
<SlotInspector draft={draft} onClose={() => setDraft(null)} onChanged={invalidate} />
|
<SlotInspector
|
||||||
|
channelId={channelId}
|
||||||
|
draft={draft}
|
||||||
|
onClose={() => setDraft(null)}
|
||||||
|
onChanged={invalidate}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleCard>
|
</CollapsibleCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<JunctionsCard
|
||||||
|
channel={channel}
|
||||||
|
template={template}
|
||||||
|
onChanged={invalidate}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
|
||||||
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
|
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
|
||||||
|
|
||||||
<SchedulePreview entries={schedule ?? []} />
|
<SchedulePreview entries={schedule ?? []} />
|
||||||
|
|||||||
@@ -7,8 +7,13 @@ import type {
|
|||||||
ChannelDto,
|
ChannelDto,
|
||||||
ChannelSummaryDto,
|
ChannelSummaryDto,
|
||||||
CreatedIdResponse,
|
CreatedIdResponse,
|
||||||
|
JunctionAmountMode,
|
||||||
|
JunctionConditions,
|
||||||
|
JunctionElementKind,
|
||||||
|
JunctionTemplateDto,
|
||||||
LayerApplicability,
|
LayerApplicability,
|
||||||
ScheduleEntryDto,
|
ScheduleEntryDto,
|
||||||
|
SchedulePreviewDto,
|
||||||
ScheduleTemplateDto,
|
ScheduleTemplateDto,
|
||||||
SlotDto,
|
SlotDto,
|
||||||
} from '@/shared/api/types'
|
} from '@/shared/api/types'
|
||||||
@@ -58,9 +63,17 @@ export function applyChannelTemplate(channelId: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Сухой прогон по текущим правилам: ничего не пишет и не двигает курсоры слотов. */
|
||||||
|
export function previewTemplate(channelId: string, days: number) {
|
||||||
|
const query = new URLSearchParams({ days: String(days) })
|
||||||
|
return apiRequest<SchedulePreviewDto>(
|
||||||
|
`/admin/channels/${channelId}/template/preview?${query.toString()}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function updateTemplate(
|
export function updateTemplate(
|
||||||
templateId: string,
|
templateId: string,
|
||||||
body: { name: string; fallbackGroupId: string | null },
|
body: { name: string; fallbackGroupId: string | null; defaultJunctionId: string | null },
|
||||||
) {
|
) {
|
||||||
return apiRequest<void>(`/admin/templates/${templateId}`, { method: 'PUT', body })
|
return apiRequest<void>(`/admin/templates/${templateId}`, { method: 'PUT', body })
|
||||||
}
|
}
|
||||||
@@ -103,6 +116,70 @@ export function deleteSlot(slotId: string) {
|
|||||||
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'DELETE' })
|
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Стыки канала ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function listJunctions(channelId: string) {
|
||||||
|
return apiRequest<JunctionTemplateDto[]>(`/admin/channels/${channelId}/junctions`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createJunction(channelId: string, name: string) {
|
||||||
|
return apiRequest<CreatedIdResponse>(`/admin/channels/${channelId}/junctions`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameJunction(junctionId: string, name: string) {
|
||||||
|
return apiRequest<void>(`/admin/junctions/${junctionId}`, { method: 'PUT', body: { name } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteJunction(junctionId: string) {
|
||||||
|
return apiRequest<void>(`/admin/junctions/${junctionId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addJunctionElement(junctionId: string, kind: JunctionElementKind) {
|
||||||
|
return apiRequest<CreatedIdResponse>(`/admin/junctions/${junctionId}/elements`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { kind },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Тело врезки: то же для любого типа — лишние поля сервер обнуляет сам (см. JunctionElement.Update). */
|
||||||
|
export type JunctionElementBody = {
|
||||||
|
kind: JunctionElementKind
|
||||||
|
groupId: string | null
|
||||||
|
bumperTemplateId: string | null
|
||||||
|
amountMode: JunctionAmountMode
|
||||||
|
amountValue: number
|
||||||
|
isRequired: boolean
|
||||||
|
conditions: JunctionConditions | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateJunctionElement(
|
||||||
|
junctionId: string,
|
||||||
|
elementId: string,
|
||||||
|
body: JunctionElementBody,
|
||||||
|
) {
|
||||||
|
return apiRequest<void>(`/admin/junctions/${junctionId}/elements/${elementId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeJunctionElement(junctionId: string, elementId: string) {
|
||||||
|
return apiRequest<void>(`/admin/junctions/${junctionId}/elements/${elementId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Порядок врезок: не упомянутые остаются после перечисленных. */
|
||||||
|
export function reorderJunction(junctionId: string, elementIdsInOrder: string[]) {
|
||||||
|
return apiRequest<void>(`/admin/junctions/${junctionId}/order`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { elementIdsInOrder },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export type BumperTemplateStyleBody = {
|
export type BumperTemplateStyleBody = {
|
||||||
name: string
|
name: string
|
||||||
backgroundColor: string
|
backgroundColor: string
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { BumperFont, BumperSelection, BumperSettings, ChannelDto } from '@/shared/api/types'
|
import type { BumperFont, BumperSelection, BumperSettings, ChannelDto } from '@/shared/api/types'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Input } from '@/shared/ui/input'
|
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { addBumperTemplate, updateChannelSettings } from '../api'
|
import { addBumperTemplate, updateChannelSettings } from '../api'
|
||||||
import { clampChance } from '../lib/format'
|
|
||||||
import { BumperTemplateEditor } from './BumperTemplateEditor'
|
import { BumperTemplateEditor } from './BumperTemplateEditor'
|
||||||
import { CollapsibleCard } from './CollapsibleCard'
|
import { CollapsibleCard } from './CollapsibleCard'
|
||||||
|
|
||||||
@@ -76,8 +74,8 @@ export function BumperCard({
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* Общие настройки */}
|
{/* Общие настройки. Как часто ставить заставку — не здесь: это условие элемента стыка. */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t('admin.channels.bumperSelection')}</Label>
|
<Label>{t('admin.channels.bumperSelection')}</Label>
|
||||||
<Select value={bumper.selection} onValueChange={(v) => setField('selection', v as BumperSelection)}>
|
<Select value={bumper.selection} onValueChange={(v) => setField('selection', v as BumperSelection)}>
|
||||||
@@ -85,7 +83,6 @@ export function BumperCard({
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="Rotation">{t('admin.channels.bumperSelectionRotation')}</SelectItem>
|
|
||||||
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
|
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
|
||||||
<SelectItem value="WeightedRandom">
|
<SelectItem value="WeightedRandom">
|
||||||
{t('admin.channels.bumperSelectionWeighted')}
|
{t('admin.channels.bumperSelectionWeighted')}
|
||||||
@@ -108,45 +105,8 @@ export function BumperCard({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperConditionsHint')}</p>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import Hls from 'hls.js'
|
import { useState } from 'react'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getAccessToken } from '@/shared/api/client'
|
|
||||||
import type { BumperTextVariantDto } from '@/shared/api/types'
|
import type { BumperTextVariantDto } from '@/shared/api/types'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { HlsVideo } from '@/shared/ui/hls-video'
|
||||||
import { bumperPreviewPlaylistUrl, renderBumperPreviews } from '../api'
|
import { bumperPreviewPlaylistUrl, renderBumperPreviews } from '../api'
|
||||||
|
|
||||||
export function BumperPreviewPlayer({
|
export function BumperPreviewPlayer({
|
||||||
@@ -48,7 +47,7 @@ export function BumperPreviewPlayer({
|
|||||||
.map((v) => (
|
.map((v) => (
|
||||||
<div key={v.id} className="flex flex-col gap-1">
|
<div key={v.id} className="flex flex-col gap-1">
|
||||||
<span className="text-xs text-muted-foreground">{v.name}</span>
|
<span className="text-xs text-muted-foreground">{v.name}</span>
|
||||||
<PreviewVideo src={`${bumperPreviewPlaylistUrl(channelId, templateId, v.id)}?t=${bust}`} />
|
<HlsVideo src={`${bumperPreviewPlaylistUrl(channelId, templateId, v.id)}?t=${bust}`} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -56,36 +55,3 @@ export function BumperPreviewPlayer({
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Мини-плеер одного превью: грузит 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,222 @@
|
|||||||
|
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { listGroups } from '@/features/admin/groups/api'
|
||||||
|
import type {
|
||||||
|
BumperTemplateDto,
|
||||||
|
JunctionAmountMode,
|
||||||
|
JunctionElementDto,
|
||||||
|
JunctionElementKind,
|
||||||
|
} from '@/shared/api/types'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/shared/ui/dialog'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { removeJunctionElement, updateJunctionElement, type JunctionElementBody } from '../api'
|
||||||
|
|
||||||
|
const KINDS: JunctionElementKind[] = ['Ad', 'Promo', 'Bumper', 'Filler']
|
||||||
|
const AMOUNT_MODES: JunctionAmountMode[] = ['Count', 'Duration']
|
||||||
|
|
||||||
|
function toBody(element: JunctionElementDto): JunctionElementBody {
|
||||||
|
return {
|
||||||
|
kind: element.kind,
|
||||||
|
groupId: element.groupId,
|
||||||
|
bumperTemplateId: element.bumperTemplateId,
|
||||||
|
amountMode: element.amountMode,
|
||||||
|
amountValue: element.amountValue,
|
||||||
|
isRequired: element.isRequired,
|
||||||
|
conditions: element.conditions ?? { onlyOnElementChange: false, minMinutesBetween: 0 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Параметры одной врезки: тип, источник, сколько её и при каких условиях ставить. */
|
||||||
|
export function JunctionElementDialog({
|
||||||
|
junctionId,
|
||||||
|
element,
|
||||||
|
bumperTemplates,
|
||||||
|
onClose,
|
||||||
|
onChanged,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
junctionId: string
|
||||||
|
element: JunctionElementDto
|
||||||
|
bumperTemplates: BumperTemplateDto[]
|
||||||
|
onClose: () => void
|
||||||
|
onChanged: () => void
|
||||||
|
onError: (error: unknown) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [body, setBody] = useState<JunctionElementBody>(() => toBody(element))
|
||||||
|
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
|
||||||
|
|
||||||
|
const patch = (part: Partial<JunctionElementBody>) => setBody((prev) => ({ ...prev, ...part }))
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => updateJunctionElement(junctionId, element.id, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
onChanged()
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: () => removeJunctionElement(junctionId, element.id),
|
||||||
|
onSuccess: () => {
|
||||||
|
onChanged()
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isBumper = body.kind === 'Bumper'
|
||||||
|
const conditions = body.conditions ?? { onlyOnElementChange: false, minMinutesBetween: 0 }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('admin.channels.junctionElement')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 text-sm">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.junctionKind')}</Label>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||||
|
value={body.kind}
|
||||||
|
onChange={(e) => patch({ kind: e.target.value as JunctionElementKind })}
|
||||||
|
>
|
||||||
|
{KINDS.map((kind) => (
|
||||||
|
<option key={kind} value={kind}>
|
||||||
|
{t(`admin.channels.junctionKinds.${kind}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isBumper ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.bumperTemplate')}</Label>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||||
|
value={body.bumperTemplateId ?? ''}
|
||||||
|
onChange={(e) => patch({ bumperTemplateId: e.target.value || null })}
|
||||||
|
>
|
||||||
|
<option value="">{t('admin.channels.pickBumperTemplate')}</option>
|
||||||
|
{bumperTemplates.map((template) => (
|
||||||
|
<option key={template.id} value={template.id}>
|
||||||
|
{template.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.group')}</Label>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||||
|
value={body.groupId ?? ''}
|
||||||
|
onChange={(e) => patch({ groupId: e.target.value || null })}
|
||||||
|
>
|
||||||
|
<option value="">{t('admin.channels.pickGroup')}</option>
|
||||||
|
{(groups ?? []).map((group) => (
|
||||||
|
<option key={group.id} value={group.id}>
|
||||||
|
{group.name} · {group.itemCount}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isBumper && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.junctionAmountMode')}</Label>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||||
|
value={body.amountMode}
|
||||||
|
onChange={(e) => patch({ amountMode: e.target.value as JunctionAmountMode })}
|
||||||
|
>
|
||||||
|
{AMOUNT_MODES.map((mode) => (
|
||||||
|
<option key={mode} value={mode}>
|
||||||
|
{t(`admin.channels.junctionAmountModes.${mode}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>
|
||||||
|
{body.amountMode === 'Count'
|
||||||
|
? t('admin.channels.junctionCount')
|
||||||
|
: t('admin.channels.junctionMinutes')}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={body.amountValue}
|
||||||
|
onChange={(e) => patch({ amountValue: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.channels.junctionAmountHint')}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={body.isRequired}
|
||||||
|
onChange={(e) => patch({ isRequired: e.target.checked })}
|
||||||
|
/>
|
||||||
|
{t('admin.channels.junctionRequired')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={conditions.onlyOnElementChange}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ conditions: { ...conditions, onlyOnElementChange: e.target.checked } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{t('admin.channels.junctionOnlyOnChange')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.junctionMinInterval')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="w-32"
|
||||||
|
value={conditions.minMinutesBetween}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ conditions: { ...conditions, minMinutesBetween: Number(e.target.value) } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.channels.junctionMinIntervalHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button size="sm" variant="destructive" onClick={() => remove.mutate()}>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||||
|
import { ChevronRight, Plus, Trash2 } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { listGroups } from '@/features/admin/groups/api'
|
||||||
|
import { formatClock } from '@/features/admin/interstitials/format'
|
||||||
|
import type {
|
||||||
|
ChannelDto,
|
||||||
|
GroupSummaryDto,
|
||||||
|
JunctionElementDto,
|
||||||
|
JunctionElementKind,
|
||||||
|
JunctionTemplateDto,
|
||||||
|
ScheduleTemplateDto,
|
||||||
|
} from '@/shared/api/types'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
import {
|
||||||
|
addJunctionElement,
|
||||||
|
createJunction,
|
||||||
|
deleteJunction,
|
||||||
|
listJunctions,
|
||||||
|
renameJunction,
|
||||||
|
reorderJunction,
|
||||||
|
updateTemplate,
|
||||||
|
} from '../api'
|
||||||
|
import { CollapsibleCard } from './CollapsibleCard'
|
||||||
|
import { JunctionElementDialog } from './JunctionElementDialog'
|
||||||
|
|
||||||
|
/** Заставка без загруженного звука синтезируется фиксированной длины — та же цифра, что на сервере. */
|
||||||
|
const DEFAULT_BUMPER_SECONDS = 8
|
||||||
|
|
||||||
|
const KIND_COLORS: Record<JunctionElementKind, string> = {
|
||||||
|
Ad: 'bg-amber-500/70',
|
||||||
|
Promo: 'bg-sky-500/70',
|
||||||
|
Bumper: 'bg-violet-500/70',
|
||||||
|
Filler: 'bg-muted-foreground/40',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прикидка длины врезки для линейки. По длительности — точная, по количеству — среднее единицы
|
||||||
|
* группы: в смешанной группе «одна единица» это то ли ролик, то ли блок, поэтому число заведомо
|
||||||
|
* приблизительное и помечается как оценка.
|
||||||
|
*/
|
||||||
|
function estimateSeconds(
|
||||||
|
element: JunctionElementDto,
|
||||||
|
groups: GroupSummaryDto[] | undefined,
|
||||||
|
channel: ChannelDto,
|
||||||
|
): { seconds: number; exact: boolean } {
|
||||||
|
if (element.kind === 'Bumper') {
|
||||||
|
const template = channel.bumperTemplates.find((b) => b.id === element.bumperTemplateId)
|
||||||
|
return { seconds: template?.audioDurationSeconds ?? DEFAULT_BUMPER_SECONDS, exact: true }
|
||||||
|
}
|
||||||
|
if (element.amountMode === 'Duration') return { seconds: element.amountValue * 60, exact: true }
|
||||||
|
|
||||||
|
const group = groups?.find((g) => g.id === element.groupId)
|
||||||
|
if (!group || group.unitCount === 0) return { seconds: 0, exact: false }
|
||||||
|
return {
|
||||||
|
seconds: (element.amountValue * group.totalDurationSeconds) / group.unitCount,
|
||||||
|
exact: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JunctionsCard({
|
||||||
|
channel,
|
||||||
|
template,
|
||||||
|
onChanged,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
channel: ChannelDto
|
||||||
|
template: ScheduleTemplateDto | undefined
|
||||||
|
onChanged: () => void
|
||||||
|
onError: (error: unknown) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
|
||||||
|
const { data: junctions } = useQuery({
|
||||||
|
queryKey: ['admin', 'channels', channel.id, 'junctions'],
|
||||||
|
queryFn: () => listJunctions(channel.id),
|
||||||
|
})
|
||||||
|
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => createJunction(channel.id, newName.trim()),
|
||||||
|
onSuccess: () => {
|
||||||
|
setNewName('')
|
||||||
|
onChanged()
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const defaultMutation = useMutation({
|
||||||
|
mutationFn: (junctionId: string | null) =>
|
||||||
|
updateTemplate(template!.id, {
|
||||||
|
name: template!.name,
|
||||||
|
fallbackGroupId: template!.fallbackGroupId,
|
||||||
|
defaultJunctionId: junctionId,
|
||||||
|
}),
|
||||||
|
onSuccess: onChanged,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleCard title={t('admin.channels.junctions')}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.channels.junctionsHint')}</p>
|
||||||
|
|
||||||
|
{template && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.defaultJunction')}</Label>
|
||||||
|
<select
|
||||||
|
className="h-9 max-w-xs rounded-md border border-border bg-transparent px-2 text-sm"
|
||||||
|
value={template.defaultJunctionId ?? ''}
|
||||||
|
onChange={(e) => defaultMutation.mutate(e.target.value || null)}
|
||||||
|
>
|
||||||
|
<option value="">{t('admin.channels.noJunction')}</option>
|
||||||
|
{(junctions ?? []).map((junction) => (
|
||||||
|
<option key={junction.id} value={junction.id}>
|
||||||
|
{junction.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(junctions ?? []).map((junction) => (
|
||||||
|
<JunctionChain
|
||||||
|
key={junction.id}
|
||||||
|
junction={junction}
|
||||||
|
channel={channel}
|
||||||
|
groups={groups}
|
||||||
|
onChanged={onChanged}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex max-w-md gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder={t('admin.channels.newJunctionName')}
|
||||||
|
value={newName}
|
||||||
|
maxLength={128}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!newName.trim() || createMutation.isPending}
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" /> {t('common.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADDABLE: JunctionElementKind[] = ['Ad', 'Promo', 'Bumper', 'Filler']
|
||||||
|
|
||||||
|
function JunctionChain({
|
||||||
|
junction,
|
||||||
|
channel,
|
||||||
|
groups,
|
||||||
|
onChanged,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
junction: JunctionTemplateDto
|
||||||
|
channel: ChannelDto
|
||||||
|
groups: GroupSummaryDto[] | undefined
|
||||||
|
onChanged: () => void
|
||||||
|
onError: (error: unknown) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [name, setName] = useState<string | null>(null)
|
||||||
|
const [dragged, setDragged] = useState<string | null>(null)
|
||||||
|
const [editing, setEditing] = useState<JunctionElementDto | null>(null)
|
||||||
|
|
||||||
|
const renameMutation = useMutation({
|
||||||
|
mutationFn: (value: string) => renameJunction(junction.id, value),
|
||||||
|
onSuccess: () => {
|
||||||
|
setName(null)
|
||||||
|
onChanged()
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => deleteJunction(junction.id),
|
||||||
|
onSuccess: onChanged,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
const addMutation = useMutation({
|
||||||
|
mutationFn: (kind: JunctionElementKind) => addJunctionElement(junction.id, kind),
|
||||||
|
onSuccess: onChanged,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
const reorderMutation = useMutation({
|
||||||
|
mutationFn: (order: string[]) => reorderJunction(junction.id, order),
|
||||||
|
onSuccess: onChanged,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const elements = [...junction.elements].sort((a, b) => a.position - b.position)
|
||||||
|
const estimates = elements.map((element) => estimateSeconds(element, groups, channel))
|
||||||
|
const total = estimates.reduce((sum, e) => sum + e.seconds, 0)
|
||||||
|
const exact = estimates.every((e) => e.exact)
|
||||||
|
|
||||||
|
const dropOn = (targetId: string) => {
|
||||||
|
if (!dragged || dragged === targetId) return
|
||||||
|
const order = elements.map((e) => e.id).filter((id) => id !== dragged)
|
||||||
|
order.splice(order.indexOf(targetId), 0, dragged)
|
||||||
|
setDragged(null)
|
||||||
|
reorderMutation.mutate(order)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="crt-panel flex flex-col gap-2 rounded-md p-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Input
|
||||||
|
className="h-8 max-w-56"
|
||||||
|
value={name ?? junction.name}
|
||||||
|
maxLength={128}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onBlur={() =>
|
||||||
|
name !== null && name.trim() && name !== junction.name
|
||||||
|
? renameMutation.mutate(name.trim())
|
||||||
|
: setName(null)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
|
||||||
|
value=""
|
||||||
|
onChange={(e) => e.target.value && addMutation.mutate(e.target.value as JunctionElementKind)}
|
||||||
|
>
|
||||||
|
<option value="">{t('admin.channels.addJunctionElement')}</option>
|
||||||
|
{ADDABLE.map((kind) => (
|
||||||
|
<option key={kind} value={kind}>
|
||||||
|
{t(`admin.channels.junctionKinds.${kind}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{exact ? '' : '≈ '}
|
||||||
|
{formatClock(total)}
|
||||||
|
</span>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => deleteMutation.mutate()}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Цепочка: что играет между концом одной программы и началом следующей. */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1 text-xs">
|
||||||
|
<span className="rounded border border-border px-2 py-1 uppercase text-muted-foreground">
|
||||||
|
{t('admin.channels.junctionFrom')}
|
||||||
|
</span>
|
||||||
|
{elements.length === 0 && (
|
||||||
|
<>
|
||||||
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">{t('admin.channels.junctionEmpty')}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{elements.map((element) => (
|
||||||
|
<span key={element.id} className="flex items-center gap-1">
|
||||||
|
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setDragged(element.id)}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={() => dropOn(element.id)}
|
||||||
|
onClick={() => setEditing(element)}
|
||||||
|
className={cn(
|
||||||
|
'cursor-grab rounded border border-border px-2 py-1 hover:border-primary',
|
||||||
|
element.isRequired && 'border-primary/70',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t(`admin.channels.junctionKinds.${element.kind}`)}
|
||||||
|
{element.kind === 'Bumper'
|
||||||
|
? element.bumperTemplateName
|
||||||
|
? ` · ${element.bumperTemplateName}`
|
||||||
|
: ''
|
||||||
|
: ` ×${element.amountValue}${element.amountMode === 'Duration' ? t('admin.channels.minutesShort') : ''}`}
|
||||||
|
{element.isRequired && ' *'}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="rounded border border-border px-2 py-1 uppercase text-muted-foreground">
|
||||||
|
{t('admin.channels.junctionTo')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Линейка: доля каждой врезки в стыке. Пустые (без группы) в неё не попадают. */}
|
||||||
|
{total > 0 && (
|
||||||
|
<div className="flex h-2 overflow-hidden rounded-full bg-muted/40">
|
||||||
|
{elements.map((element, index) => (
|
||||||
|
<div
|
||||||
|
key={element.id}
|
||||||
|
className={KIND_COLORS[element.kind]}
|
||||||
|
style={{ width: `${(estimates[index].seconds / total) * 100}%` }}
|
||||||
|
title={`${t(`admin.channels.junctionKinds.${element.kind}`)} · ${formatClock(estimates[index].seconds)}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<JunctionElementDialog
|
||||||
|
junctionId={junction.id}
|
||||||
|
element={editing}
|
||||||
|
bumperTemplates={channel.bumperTemplates}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
onChanged={onChanged}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import { Button } from '@/shared/ui/button'
|
|||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { createSlot, deleteSlot, updateSlot, type SlotBody } from '../api'
|
import { createSlot, deleteSlot, listJunctions, updateSlot, type SlotBody } from '../api'
|
||||||
|
|
||||||
const DAYPARTS: Daypart[] = ['Morning', 'Day', 'Prime', 'Night']
|
const DAYPARTS: Daypart[] = ['Morning', 'Day', 'Prime', 'Night']
|
||||||
const SLOT_KINDS: SlotKind[] = ['Content', 'Repeat', 'SignOff']
|
const SLOT_KINDS: SlotKind[] = ['Content', 'Repeat', 'SignOff']
|
||||||
@@ -55,15 +55,19 @@ function emptyBody(defaults?: Partial<SlotBody>): SlotBody {
|
|||||||
isAnchor: false,
|
isAnchor: false,
|
||||||
maxDriftMinutes: 5,
|
maxDriftMinutes: 5,
|
||||||
snapToMinutes: null,
|
snapToMinutes: null,
|
||||||
|
junctionBetweenId: null,
|
||||||
|
junctionAfterId: null,
|
||||||
...defaults,
|
...defaults,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SlotInspector({
|
export function SlotInspector({
|
||||||
|
channelId,
|
||||||
draft,
|
draft,
|
||||||
onClose,
|
onClose,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
|
channelId: string
|
||||||
draft: SlotDraft
|
draft: SlotDraft
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onChanged: () => void
|
onChanged: () => void
|
||||||
@@ -78,6 +82,10 @@ export function SlotInspector({
|
|||||||
}, [draft])
|
}, [draft])
|
||||||
|
|
||||||
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
|
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
|
||||||
|
const { data: junctions } = useQuery({
|
||||||
|
queryKey: ['admin', 'channels', channelId, 'junctions'],
|
||||||
|
queryFn: () => listJunctions(channelId),
|
||||||
|
})
|
||||||
|
|
||||||
const onError = (error: unknown) =>
|
const onError = (error: unknown) =>
|
||||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||||
@@ -391,6 +399,40 @@ export function SlotInspector({
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.channels.anchorHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('admin.channels.anchorHint')}</p>
|
||||||
|
|
||||||
|
{/* Стыки: внутри слота — между единицами, после слота — на переходе к следующему. */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.junctionBetween')}</Label>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||||
|
value={body.junctionBetweenId ?? ''}
|
||||||
|
onChange={(e) => patch({ junctionBetweenId: e.target.value || null })}
|
||||||
|
>
|
||||||
|
<option value="">{t('admin.channels.junctionDefault')}</option>
|
||||||
|
{(junctions ?? []).map((junction) => (
|
||||||
|
<option key={junction.id} value={junction.id}>
|
||||||
|
{junction.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.junctionAfter')}</Label>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||||
|
value={body.junctionAfterId ?? ''}
|
||||||
|
onChange={(e) => patch({ junctionAfterId: e.target.value || null })}
|
||||||
|
>
|
||||||
|
<option value="">{t('admin.channels.junctionDefault')}</option>
|
||||||
|
{(junctions ?? []).map((junction) => (
|
||||||
|
<option key={junction.id} value={junction.id}>
|
||||||
|
{junction.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
{draft.slot ? (
|
{draft.slot ? (
|
||||||
<Button size="sm" variant="destructive" onClick={() => remove.mutate()}>
|
<Button size="sm" variant="destructive" onClick={() => remove.mutate()}>
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Eye } from 'lucide-react'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { PlannedItemKind, PreviewItemDto, SchedulePreviewDto } from '@/shared/api/types'
|
||||||
|
import { Badge } from '@/shared/ui/badge'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
import { previewTemplate } from '../api'
|
||||||
|
import { channelTime, formatChannelTime } from '../lib/format'
|
||||||
|
|
||||||
|
const KIND_COLORS: Record<PlannedItemKind, string> = {
|
||||||
|
Program: 'bg-primary/70',
|
||||||
|
Fallback: 'bg-muted-foreground/40',
|
||||||
|
SignOff: 'bg-slate-500/60',
|
||||||
|
Ad: 'bg-amber-500/70',
|
||||||
|
Promo: 'bg-sky-500/70',
|
||||||
|
Bumper: 'bg-violet-500/70',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Что видит зритель как программу — врезки в программу передач не попадают. */
|
||||||
|
const PROGRAMME_KINDS: PlannedItemKind[] = ['Program', 'Fallback', 'SignOff']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Предпросмотр (см. 6.4): прогон генератора по текущим правилам без записи и без продвижения
|
||||||
|
* курсоров. Заставки приходят резервом известной длины — реальный рендер только при применении.
|
||||||
|
*/
|
||||||
|
export function TemplatePreview({ channelId }: { channelId: string }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [days, setDays] = useState(1)
|
||||||
|
const [tab, setTab] = useState<'programme' | 'tape'>('programme')
|
||||||
|
|
||||||
|
const { data, isFetching } = useQuery({
|
||||||
|
queryKey: ['admin', 'channels', channelId, 'preview', days],
|
||||||
|
queryFn: () => previewTemplate(channelId, days),
|
||||||
|
enabled: open,
|
||||||
|
// Черновик правил может меняться между открытиями — кэшировать прогон смысла нет.
|
||||||
|
staleTime: 0,
|
||||||
|
gcTime: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setOpen((v) => !v)}>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
{open ? t('admin.channels.previewHide') : t('admin.channels.preview')}
|
||||||
|
</Button>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
|
||||||
|
value={days}
|
||||||
|
onChange={(e) => setDays(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{[1, 3, 7].map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{t('admin.channels.previewDays', { count: value })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{isFetching ? t('common.loading') : t('admin.channels.previewHint')}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && data && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex gap-2 border-b border-border text-xs uppercase tracking-wide">
|
||||||
|
{(['programme', 'tape'] as const).map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(value)}
|
||||||
|
className={cn(
|
||||||
|
'pb-1 text-muted-foreground hover:text-foreground',
|
||||||
|
tab === value && 'border-b-2 border-primary text-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t(`admin.channels.previewTabs.${value}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'programme' ? <Programme preview={data} /> : <Tape preview={data} />}
|
||||||
|
|
||||||
|
{data.warnings.length > 0 && (
|
||||||
|
<ul className="flex flex-col gap-1 text-xs text-amber-500">
|
||||||
|
{data.warnings.map((warning, index) => (
|
||||||
|
<li key={`${warning.kind}-${index}`}>
|
||||||
|
{t(`admin.channels.warnings.${warning.kind}`)}: {warning.details}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Programme({ preview }: { preview: SchedulePreviewDto }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const items = preview.items.filter((i) => PROGRAMME_KINDS.includes(i.kind))
|
||||||
|
|
||||||
|
if (items.length === 0)
|
||||||
|
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-col divide-y divide-border text-sm">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<li key={`${item.startsAtUtc}-${index}`} className="flex items-center gap-3 py-1.5">
|
||||||
|
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||||
|
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
|
{item.title ?? t(`admin.channels.previewKinds.${item.kind}`)}
|
||||||
|
</span>
|
||||||
|
{item.slotTitle && (
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">{item.slotTitle}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Час вещания → сколько в нём минут врезок. По ним же строится гистограмма нагрузки. */
|
||||||
|
function loadByHour(preview: SchedulePreviewDto): { hour: Date; minutes: number }[] {
|
||||||
|
const buckets = new Map<number, number>()
|
||||||
|
for (const item of preview.items) {
|
||||||
|
if (item.kind !== 'Ad' && item.kind !== 'Promo' && item.kind !== 'Bumper') continue
|
||||||
|
const start = channelTime(item.startsAtUtc, preview.utcOffsetMinutes)
|
||||||
|
const hour = Date.UTC(
|
||||||
|
start.getUTCFullYear(),
|
||||||
|
start.getUTCMonth(),
|
||||||
|
start.getUTCDate(),
|
||||||
|
start.getUTCHours(),
|
||||||
|
)
|
||||||
|
const minutes = (new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
|
||||||
|
buckets.set(hour, (buckets.get(hour) ?? 0) + minutes)
|
||||||
|
}
|
||||||
|
return [...buckets.entries()]
|
||||||
|
.sort((a, b) => a[0] - b[0])
|
||||||
|
.map(([hour, minutes]) => ({ hour: new Date(hour), minutes }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tape({ preview }: { preview: SchedulePreviewDto }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const load = useMemo(() => loadByHour(preview), [preview])
|
||||||
|
const peak = Math.max(1, ...load.map((l) => l.minutes))
|
||||||
|
|
||||||
|
if (preview.items.length === 0)
|
||||||
|
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{load.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.channels.previewLoad', { peak: Math.round(peak) })}
|
||||||
|
</span>
|
||||||
|
<div className="flex h-16 items-end gap-px">
|
||||||
|
{load.map((bucket) => (
|
||||||
|
<div
|
||||||
|
key={bucket.hour.toISOString()}
|
||||||
|
className="flex-1 bg-amber-500/70"
|
||||||
|
style={{ height: `${(bucket.minutes / peak) * 100}%` }}
|
||||||
|
title={`${String(bucket.hour.getUTCHours()).padStart(2, '0')}:00 · ${Math.round(bucket.minutes)} ${t('admin.groups.minutesShort')}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="flex flex-col gap-0.5 text-xs">
|
||||||
|
{preview.items.map((item, index) => (
|
||||||
|
<TapeRow key={`${item.startsAtUtc}-${index}`} item={item} preview={preview} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TapeRow({ item, preview }: { item: PreviewItemDto; preview: SchedulePreviewDto }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const minutes =
|
||||||
|
(new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="w-10 shrink-0 tabular-nums text-muted-foreground">
|
||||||
|
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
|
||||||
|
</span>
|
||||||
|
<span className={cn('h-2 shrink-0 rounded-sm', KIND_COLORS[item.kind])} style={{ width: `${Math.max(4, minutes * 2)}px` }} />
|
||||||
|
<Badge variant="muted" className="shrink-0">
|
||||||
|
{t(`admin.channels.previewKinds.${item.kind}`)}
|
||||||
|
</Badge>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-muted-foreground">{item.title ?? ''}</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,16 +18,23 @@ export function formatMinute(minute: number | null) {
|
|||||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Момент UTC во времени канала. Сетка задаётся в нём, поэтому предпросмотр показывается так же:
|
||||||
|
* локальное время админа тут только запутало бы.
|
||||||
|
*/
|
||||||
|
export function channelTime(iso: string, utcOffsetMinutes: number): Date {
|
||||||
|
return new Date(new Date(iso).getTime() + utcOffsetMinutes * 60_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «HH:MM» во времени канала. */
|
||||||
|
export function formatChannelTime(iso: string, utcOffsetMinutes: number): string {
|
||||||
|
const d = channelTime(iso, utcOffsetMinutes)
|
||||||
|
return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
/** ffmpeg-цвет (0xRRGGBB / имя) → CSS для превью-плашки. */
|
/** ffmpeg-цвет (0xRRGGBB / имя) → CSS для превью-плашки. */
|
||||||
export function cssColor(value: string): string {
|
export function cssColor(value: string): string {
|
||||||
const v = value.trim()
|
const v = value.trim()
|
||||||
if (v.startsWith('0x')) return `#${v.slice(2)}`
|
if (v.startsWith('0x')) return `#${v.slice(2)}`
|
||||||
return v
|
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))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { GripVertical, Trash2 } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { addCollectionShow, createCollection } from '@/features/admin/collections/api'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
import { type ClipDragItem, readDragItem } from './dnd'
|
||||||
|
import { formatClock } from './format'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сборка рекламного блока: ролики перетаскиваются в упорядоченный список, под ним — суммарная
|
||||||
|
* длительность. Сохраняется обычной коллекцией — отдельной сущности «блок» в модели нет (см. 3.7).
|
||||||
|
*/
|
||||||
|
export function BlockBuilder({
|
||||||
|
onSaved,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
onSaved: () => void
|
||||||
|
onError: (error: unknown) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [items, setItems] = useState<ClipDragItem[]>([])
|
||||||
|
const [over, setOver] = useState(false)
|
||||||
|
const [dragged, setDragged] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const total = items.reduce((sum, item) => sum + item.seconds, 0)
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { id } = await createCollection({ name: name.trim() })
|
||||||
|
// Порядок задаётся порядком добавления: коллекция ставит позицию в конец.
|
||||||
|
for (const item of items) await addCollectionShow(id, item.id)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setName('')
|
||||||
|
setItems([])
|
||||||
|
onSaved()
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const drop = (event: React.DragEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setOver(false)
|
||||||
|
const item = readDragItem(event)
|
||||||
|
// Блок из блоков собрать нельзя: коллекция хранит шоу, а не вложенные коллекции.
|
||||||
|
if (!item || item.kind !== 'clip') return
|
||||||
|
setItems((current) => [...current, item])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Перестановка внутри черновика — до сохранения всё живёт в состоянии, запросов нет. */
|
||||||
|
const reorder = (target: number) => {
|
||||||
|
if (dragged === null || dragged === target) return
|
||||||
|
setItems((current) => {
|
||||||
|
const next = [...current]
|
||||||
|
const [moved] = next.splice(dragged, 1)
|
||||||
|
next.splice(target, 0, moved)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setDragged(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t('admin.interstitials.blockBuilder')}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setOver(true)
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setOver(false)}
|
||||||
|
onDrop={drop}
|
||||||
|
className={cn(
|
||||||
|
'crt-panel flex min-h-32 flex-col rounded-md border border-dashed border-border',
|
||||||
|
over && 'border-primary bg-primary/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||||
|
{t('admin.interstitials.dropHint')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border text-sm">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<li
|
||||||
|
key={`${item.id}-${index}`}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setDragged(index)}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
reorder(index)
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
|
||||||
|
<span className="w-5 shrink-0 text-muted-foreground">{index + 1}</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate" title={item.name}>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||||
|
{formatClock(item.seconds)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setItems((c) => c.filter((_, i) => i !== index))}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>{t('admin.interstitials.blockTotal')}</span>
|
||||||
|
<span className="tabular-nums">{formatClock(total)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder={t('admin.interstitials.blockName')}
|
||||||
|
value={name}
|
||||||
|
maxLength={256}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!name.trim() || items.length === 0 || saveMutation.isPending}
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { addGroupElements, createGroup, listGroups } from '@/features/admin/groups/api'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
import { readDragItem } from './dnd'
|
||||||
|
import { formatClock } from './format'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Группы роликов собираются здесь же, а не в общем редакторе групп (см. 6.7): в выбранную группу
|
||||||
|
* перетаскиваются и отдельные ролики, и готовые блоки — стык умеет и то и другое.
|
||||||
|
*/
|
||||||
|
export function ClipGroupPanel({ onError }: { onError: (error: unknown) => void }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [selected, setSelected] = useState('')
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
const [over, setOver] = useState(false)
|
||||||
|
|
||||||
|
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
|
||||||
|
const invalidate = () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['admin', 'groups'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => createGroup({ name: newName.trim() }),
|
||||||
|
onSuccess: ({ id }) => {
|
||||||
|
setNewName('')
|
||||||
|
setSelected(id)
|
||||||
|
invalidate()
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const addMutation = useMutation({
|
||||||
|
mutationFn: (element: { elementKind: 'Show' | 'Collection'; elementId: string }) =>
|
||||||
|
addGroupElements(selected, [element]),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const group = groups?.find((g) => g.id === selected)
|
||||||
|
|
||||||
|
const drop = (event: React.DragEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setOver(false)
|
||||||
|
const item = readDragItem(event)
|
||||||
|
if (!item || !selected) return
|
||||||
|
addMutation.mutate({
|
||||||
|
elementKind: item.kind === 'block' ? 'Collection' : 'Show',
|
||||||
|
elementId: item.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t('admin.interstitials.groups')}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<Select value={selected} onValueChange={setSelected}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t('admin.interstitials.pickGroup')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(groups ?? []).map((g) => (
|
||||||
|
<SelectItem key={g.id} value={g.id}>
|
||||||
|
{g.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setOver(true)
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setOver(false)}
|
||||||
|
onDrop={drop}
|
||||||
|
className={cn(
|
||||||
|
'crt-panel flex min-h-20 flex-col items-center justify-center gap-1 rounded-md border border-dashed border-border px-3 py-4 text-center text-xs',
|
||||||
|
over && selected && 'border-primary bg-primary/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selected ? (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground">{t('admin.interstitials.dropToGroup')}</span>
|
||||||
|
{group && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t('admin.groups.items')}: {group.itemCount} ·{' '}
|
||||||
|
{formatClock(group.totalDurationSeconds)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{group && (
|
||||||
|
<Link
|
||||||
|
to="/admin/groups/$groupId"
|
||||||
|
params={{ groupId: group.id }}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{t('admin.interstitials.openGroup')}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">{t('admin.interstitials.pickGroupFirst')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder={t('admin.interstitials.newGroupName')}
|
||||||
|
value={newName}
|
||||||
|
maxLength={256}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!newName.trim() || createMutation.isPending}
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
>
|
||||||
|
{t('common.create')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { GripVertical, Play, Trash2, Upload } from 'lucide-react'
|
||||||
|
import { useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { deleteCollection } from '@/features/admin/collections/api'
|
||||||
|
import { useUploadStore } from '@/features/admin/media/upload-store'
|
||||||
|
import { deleteShow, renameShow } from '@/features/admin/shows/api'
|
||||||
|
import { HttpError } from '@/shared/api/client'
|
||||||
|
import type { InterstitialDto } from '@/shared/api/types'
|
||||||
|
import { Badge } from '@/shared/ui/badge'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||||
|
import { HlsVideo } from '@/shared/ui/hls-video'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
|
import { BlockBuilder } from './BlockBuilder'
|
||||||
|
import { ClipGroupPanel } from './ClipGroupPanel'
|
||||||
|
import { listInterstitialBlocks, listInterstitials, mediaPreviewUrl } from './api'
|
||||||
|
import { setDragItem } from './dnd'
|
||||||
|
import { formatClock } from './format'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экран роликов (см. 6.7). Под капотом это `Show(Kind = Interstitial)` и коллекции, но сценарий
|
||||||
|
* другой: массовая загрузка, длительности вместо метаданных и сборка блока перетаскиванием.
|
||||||
|
*/
|
||||||
|
export function InterstitialsPanel() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const enqueue = useUploadStore((s) => s.enqueue)
|
||||||
|
const fileInput = useRef<HTMLInputElement>(null)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [preview, setPreview] = useState<InterstitialDto | null>(null)
|
||||||
|
const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null)
|
||||||
|
|
||||||
|
const { data: clips, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'interstitials'],
|
||||||
|
queryFn: listInterstitials,
|
||||||
|
})
|
||||||
|
const { data: blocks } = useQuery({
|
||||||
|
queryKey: ['admin', 'interstitials', 'blocks'],
|
||||||
|
queryFn: listInterstitialBlocks,
|
||||||
|
})
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['admin', 'interstitials'] })
|
||||||
|
}
|
||||||
|
const onError = (error: unknown) =>
|
||||||
|
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||||
|
|
||||||
|
const renameMutation = useMutation({
|
||||||
|
mutationFn: ({ id, name }: { id: string; name: string }) => renameShow(id, name),
|
||||||
|
onSuccess: () => {
|
||||||
|
setRenaming(null)
|
||||||
|
invalidate()
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
const deleteClipMutation = useMutation({ mutationFn: deleteShow, onSuccess: invalidate, onError })
|
||||||
|
const deleteBlockMutation = useMutation({
|
||||||
|
mutationFn: deleteCollection,
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
const all = clips ?? []
|
||||||
|
return q ? all.filter((c) => c.name.toLowerCase().includes(q)) : all
|
||||||
|
}, [clips, query])
|
||||||
|
|
||||||
|
const pickFiles = (files: FileList | null) => {
|
||||||
|
if (!files || files.length === 0) return
|
||||||
|
void enqueue(Array.from(files), { interstitial: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h2 className="crt-glow text-xl font-semibold">{t('admin.interstitials.title')}</h2>
|
||||||
|
<Button size="sm" onClick={() => fileInput.current?.click()}>
|
||||||
|
<Upload className="h-4 w-4" /> {t('admin.interstitials.upload')}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInput}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
pickFiles(e.target.files)
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.interstitials.hint')}</p>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[1fr_340px]">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Input
|
||||||
|
className="max-w-xs"
|
||||||
|
placeholder={t('common.search')}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="crt-panel overflow-x-auto rounded-md">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b border-border text-left text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 font-medium">{t('admin.interstitials.name')}</th>
|
||||||
|
<th className="px-4 py-2 font-medium">{t('admin.interstitials.duration')}</th>
|
||||||
|
<th className="px-4 py-2 font-medium">{t('admin.media.status')}</th>
|
||||||
|
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{isLoading && (
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground" colSpan={4}>
|
||||||
|
{t('common.loading')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{!isLoading && filtered.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground" colSpan={4}>
|
||||||
|
{t('admin.interstitials.empty')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{filtered.map((clip) => (
|
||||||
|
<tr
|
||||||
|
key={clip.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) =>
|
||||||
|
setDragItem(e, {
|
||||||
|
kind: 'clip',
|
||||||
|
id: clip.id,
|
||||||
|
name: clip.name,
|
||||||
|
seconds: clip.durationSeconds ?? 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="border-b border-border last:border-0"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
|
||||||
|
{renaming?.id === clip.id ? (
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
className="h-8"
|
||||||
|
value={renaming.name}
|
||||||
|
maxLength={256}
|
||||||
|
onChange={(e) => setRenaming({ id: clip.id, name: e.target.value })}
|
||||||
|
onBlur={() =>
|
||||||
|
renaming.name.trim() && renaming.name !== clip.name
|
||||||
|
? renameMutation.mutate({ id: clip.id, name: renaming.name.trim() })
|
||||||
|
: setRenaming(null)
|
||||||
|
}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') e.currentTarget.blur()
|
||||||
|
if (e.key === 'Escape') setRenaming(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-w-0 truncate text-left hover:underline"
|
||||||
|
onClick={() => setRenaming({ id: clip.id, name: clip.name })}
|
||||||
|
>
|
||||||
|
{clip.name}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 tabular-nums text-muted-foreground">
|
||||||
|
{formatClock(clip.durationSeconds)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<Badge variant="muted">
|
||||||
|
{clip.assetStatus
|
||||||
|
? t(`admin.media.statuses.${clip.assetStatus}`)
|
||||||
|
: t('admin.interstitials.noAsset')}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={clip.assetStatus !== 'Ready'}
|
||||||
|
onClick={() => setPreview(clip)}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => deleteClipMutation.mutate(clip.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t('admin.interstitials.blocks')}
|
||||||
|
</h3>
|
||||||
|
<div className="crt-panel rounded-md">
|
||||||
|
{(blocks ?? []).length === 0 ? (
|
||||||
|
<p className="px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
{t('admin.interstitials.noBlocks')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border text-sm">
|
||||||
|
{(blocks ?? []).map((block) => (
|
||||||
|
<li
|
||||||
|
key={block.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) =>
|
||||||
|
setDragItem(e, {
|
||||||
|
kind: 'block',
|
||||||
|
id: block.id,
|
||||||
|
name: block.name,
|
||||||
|
seconds: block.durationSeconds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="flex items-center gap-2 px-4 py-2"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
|
||||||
|
<Link
|
||||||
|
to="/admin/collections/$collectionId"
|
||||||
|
params={{ collectionId: block.id }}
|
||||||
|
className="min-w-0 flex-1 truncate text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{block.name}
|
||||||
|
</Link>
|
||||||
|
<span className="shrink-0 text-muted-foreground">
|
||||||
|
{t('admin.interstitials.clipsCount', { count: block.itemCount })}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||||
|
{formatClock(block.durationSeconds)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => deleteBlockMutation.mutate(block.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<BlockBuilder onSaved={invalidate} onError={onError} />
|
||||||
|
<ClipGroupPanel onError={onError} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={preview !== null} onOpenChange={(open) => !open && setPreview(null)}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{preview?.name}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{preview?.mediaAssetId && <HlsVideo src={mediaPreviewUrl(preview.mediaAssetId)} />}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { apiRequest } from '@/shared/api/client'
|
||||||
|
import type { InterstitialBlockDto, InterstitialDto } from '@/shared/api/types'
|
||||||
|
|
||||||
|
export function listInterstitials() {
|
||||||
|
return apiRequest<InterstitialDto[]>('/admin/interstitials')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listInterstitialBlocks() {
|
||||||
|
return apiRequest<InterstitialBlockDto[]>('/admin/interstitials/blocks')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Превращает загруженные файлы в ролики. Уже привязанные к шоу ассеты сервер пропускает. */
|
||||||
|
export function importInterstitials(mediaAssetIds: string[]) {
|
||||||
|
return apiRequest<{ imported: number }>('/admin/interstitials/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { mediaAssetIds },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Плейлист обработанного ассета под admin-роутом (JWT) — грузится через hls.js. */
|
||||||
|
export function mediaPreviewUrl(assetId: string) {
|
||||||
|
return `/api/admin/media/${assetId}/preview/index.m3u8`
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Перетаскивание на экране роликов. В сборку блока едут только ролики, в группу — и ролики,
|
||||||
|
* и готовые блоки, поэтому в переносимых данных лежит вид элемента, а не только идентификатор.
|
||||||
|
*/
|
||||||
|
export type ClipDragItem = {
|
||||||
|
kind: 'clip' | 'block'
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
seconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME = 'application/x-telewave-clip'
|
||||||
|
|
||||||
|
export function setDragItem(event: React.DragEvent, item: ClipDragItem) {
|
||||||
|
event.dataTransfer.setData(MIME, JSON.stringify(item))
|
||||||
|
event.dataTransfer.effectAllowed = 'copy'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readDragItem(event: React.DragEvent): ClipDragItem | null {
|
||||||
|
const raw = event.dataTransfer.getData(MIME)
|
||||||
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as ClipDragItem
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Длительность ролика — часами тут мерить нечего: «0:20», «2:40», «1:02:03». Прочерк вместо нуля,
|
||||||
|
* потому что «0:00» читается как ролик нулевой длины, а не как «ещё не обработан».
|
||||||
|
*/
|
||||||
|
export function formatClock(seconds: number | null | undefined): string {
|
||||||
|
if (seconds === null || seconds === undefined || seconds <= 0) return '—'
|
||||||
|
const total = Math.round(seconds)
|
||||||
|
const s = total % 60
|
||||||
|
const m = Math.floor(total / 60) % 60
|
||||||
|
const h = Math.floor(total / 3600)
|
||||||
|
const mm = h > 0 ? String(m).padStart(2, '0') : String(m)
|
||||||
|
return `${h > 0 ? `${h}:` : ''}${mm}:${String(s).padStart(2, '0')}`
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { HttpError, refreshAccessToken } from '@/shared/api/client'
|
import { HttpError, refreshAccessToken } from '@/shared/api/client'
|
||||||
import { queryClient } from '@/shared/api/query-client'
|
import { queryClient } from '@/shared/api/query-client'
|
||||||
|
import { importInterstitials } from '@/features/admin/interstitials/api'
|
||||||
import { addEpisode } from '@/features/admin/shows/api'
|
import { addEpisode } from '@/features/admin/shows/api'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { listMedia, uploadMedia } from './api'
|
import { listMedia, uploadMedia } from './api'
|
||||||
@@ -33,11 +34,13 @@ type UploadStore = {
|
|||||||
export type EnqueueOptions = {
|
export type EnqueueOptions = {
|
||||||
showId?: string
|
showId?: string
|
||||||
resolveShowId?: (file: File) => string | undefined
|
resolveShowId?: (file: File) => string | undefined
|
||||||
|
/** Загрузка с экрана «Ролики»: каждый файл после аплоада становится роликом (Show + серия). */
|
||||||
|
interstitial?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Очередь и флаг живут вне React — загрузка продолжается при любой навигации.
|
// Очередь и флаг живут вне React — загрузка продолжается при любой навигации.
|
||||||
let counter = 0
|
let counter = 0
|
||||||
type Job = { id: string; file: File; showId?: string }
|
type Job = { id: string; file: File; showId?: string; interstitial?: boolean }
|
||||||
const queue: Job[] = []
|
const queue: Job[] = []
|
||||||
const controllers = new Map<string, AbortController>()
|
const controllers = new Map<string, AbortController>()
|
||||||
const failed = new Map<string, Job>() // упавшие — для ручного повтора
|
const failed = new Map<string, Job>() // упавшие — для ручного повтора
|
||||||
@@ -112,6 +115,14 @@ async function pump() {
|
|||||||
} catch {
|
} catch {
|
||||||
toast.error(`${job.file.name}: не удалось добавить в шоу`)
|
toast.error(`${job.file.name}: не удалось добавить в шоу`)
|
||||||
}
|
}
|
||||||
|
} else if (job.interstitial) {
|
||||||
|
// Ролик заводится сразу после аплоада: длительность подтянется, когда ассет обработается.
|
||||||
|
try {
|
||||||
|
await importInterstitials([created.id])
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['admin', 'interstitials'] })
|
||||||
|
} catch {
|
||||||
|
toast.error(`${job.file.name}: не удалось завести ролик`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (isAbort(lastError) || controller.signal.aborted) {
|
} else if (isAbort(lastError) || controller.signal.aborted) {
|
||||||
// Отмена — тихо: элемент уже убран из списка.
|
// Отмена — тихо: элемент уже убран из списка.
|
||||||
@@ -157,7 +168,7 @@ export const useUploadStore = create<UploadStore>((set) => ({
|
|||||||
const newItems: UploadItem[] = toAdd.map((file) => {
|
const newItems: UploadItem[] = toAdd.map((file) => {
|
||||||
const id = `u${++counter}`
|
const id = `u${++counter}`
|
||||||
const showId = options?.resolveShowId?.(file) ?? options?.showId
|
const showId = options?.resolveShowId?.(file) ?? options?.showId
|
||||||
queue.push({ id, file, showId })
|
queue.push({ id, file, showId, interstitial: options?.interstitial })
|
||||||
return { id, name: file.name, percent: 0, status: 'queued' }
|
return { id, name: file.name, percent: 0, status: 'queued' }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { Route as AdminCollectionsRouteImport } from './routes/admin/collections
|
|||||||
import { Route as AdminGalleryRouteImport } from './routes/admin/gallery'
|
import { Route as AdminGalleryRouteImport } from './routes/admin/gallery'
|
||||||
import { Route as AdminGenresRouteImport } from './routes/admin/genres'
|
import { Route as AdminGenresRouteImport } from './routes/admin/genres'
|
||||||
import { Route as AdminGroupsRouteImport } from './routes/admin/groups'
|
import { Route as AdminGroupsRouteImport } from './routes/admin/groups'
|
||||||
|
import { Route as AdminInterstitialsRouteImport } from './routes/admin/interstitials'
|
||||||
import { Route as AdminMaintenanceRouteImport } from './routes/admin/maintenance'
|
import { Route as AdminMaintenanceRouteImport } from './routes/admin/maintenance'
|
||||||
import { Route as AdminMediaRouteImport } from './routes/admin/media'
|
import { Route as AdminMediaRouteImport } from './routes/admin/media'
|
||||||
import { Route as AdminRolesRouteImport } from './routes/admin/roles'
|
import { Route as AdminRolesRouteImport } from './routes/admin/roles'
|
||||||
@@ -96,6 +97,11 @@ const AdminGroupsRoute = AdminGroupsRouteImport.update({
|
|||||||
path: '/groups',
|
path: '/groups',
|
||||||
getParentRoute: () => AdminRoute,
|
getParentRoute: () => AdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AdminInterstitialsRoute = AdminInterstitialsRouteImport.update({
|
||||||
|
id: '/interstitials',
|
||||||
|
path: '/interstitials',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
const AdminMaintenanceRoute = AdminMaintenanceRouteImport.update({
|
const AdminMaintenanceRoute = AdminMaintenanceRouteImport.update({
|
||||||
id: '/maintenance',
|
id: '/maintenance',
|
||||||
path: '/maintenance',
|
path: '/maintenance',
|
||||||
@@ -180,6 +186,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/admin/gallery': typeof AdminGalleryRoute
|
'/admin/gallery': typeof AdminGalleryRoute
|
||||||
'/admin/genres': typeof AdminGenresRoute
|
'/admin/genres': typeof AdminGenresRoute
|
||||||
'/admin/groups': typeof AdminGroupsRouteWithChildren
|
'/admin/groups': typeof AdminGroupsRouteWithChildren
|
||||||
|
'/admin/interstitials': typeof AdminInterstitialsRoute
|
||||||
'/admin/maintenance': typeof AdminMaintenanceRoute
|
'/admin/maintenance': typeof AdminMaintenanceRoute
|
||||||
'/admin/media': typeof AdminMediaRoute
|
'/admin/media': typeof AdminMediaRoute
|
||||||
'/admin/roles': typeof AdminRolesRoute
|
'/admin/roles': typeof AdminRolesRoute
|
||||||
@@ -204,6 +211,7 @@ export interface FileRoutesByTo {
|
|||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
'/admin/gallery': typeof AdminGalleryRoute
|
'/admin/gallery': typeof AdminGalleryRoute
|
||||||
'/admin/genres': typeof AdminGenresRoute
|
'/admin/genres': typeof AdminGenresRoute
|
||||||
|
'/admin/interstitials': typeof AdminInterstitialsRoute
|
||||||
'/admin/maintenance': typeof AdminMaintenanceRoute
|
'/admin/maintenance': typeof AdminMaintenanceRoute
|
||||||
'/admin/media': typeof AdminMediaRoute
|
'/admin/media': typeof AdminMediaRoute
|
||||||
'/admin/roles': typeof AdminRolesRoute
|
'/admin/roles': typeof AdminRolesRoute
|
||||||
@@ -232,6 +240,7 @@ export interface FileRoutesById {
|
|||||||
'/admin/gallery': typeof AdminGalleryRoute
|
'/admin/gallery': typeof AdminGalleryRoute
|
||||||
'/admin/genres': typeof AdminGenresRoute
|
'/admin/genres': typeof AdminGenresRoute
|
||||||
'/admin/groups': typeof AdminGroupsRouteWithChildren
|
'/admin/groups': typeof AdminGroupsRouteWithChildren
|
||||||
|
'/admin/interstitials': typeof AdminInterstitialsRoute
|
||||||
'/admin/maintenance': typeof AdminMaintenanceRoute
|
'/admin/maintenance': typeof AdminMaintenanceRoute
|
||||||
'/admin/media': typeof AdminMediaRoute
|
'/admin/media': typeof AdminMediaRoute
|
||||||
'/admin/roles': typeof AdminRolesRoute
|
'/admin/roles': typeof AdminRolesRoute
|
||||||
@@ -262,6 +271,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin/gallery'
|
| '/admin/gallery'
|
||||||
| '/admin/genres'
|
| '/admin/genres'
|
||||||
| '/admin/groups'
|
| '/admin/groups'
|
||||||
|
| '/admin/interstitials'
|
||||||
| '/admin/maintenance'
|
| '/admin/maintenance'
|
||||||
| '/admin/media'
|
| '/admin/media'
|
||||||
| '/admin/roles'
|
| '/admin/roles'
|
||||||
@@ -286,6 +296,7 @@ export interface FileRouteTypes {
|
|||||||
| '/settings'
|
| '/settings'
|
||||||
| '/admin/gallery'
|
| '/admin/gallery'
|
||||||
| '/admin/genres'
|
| '/admin/genres'
|
||||||
|
| '/admin/interstitials'
|
||||||
| '/admin/maintenance'
|
| '/admin/maintenance'
|
||||||
| '/admin/media'
|
| '/admin/media'
|
||||||
| '/admin/roles'
|
| '/admin/roles'
|
||||||
@@ -313,6 +324,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin/gallery'
|
| '/admin/gallery'
|
||||||
| '/admin/genres'
|
| '/admin/genres'
|
||||||
| '/admin/groups'
|
| '/admin/groups'
|
||||||
|
| '/admin/interstitials'
|
||||||
| '/admin/maintenance'
|
| '/admin/maintenance'
|
||||||
| '/admin/media'
|
| '/admin/media'
|
||||||
| '/admin/roles'
|
| '/admin/roles'
|
||||||
@@ -425,6 +437,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AdminGroupsRouteImport
|
preLoaderRoute: typeof AdminGroupsRouteImport
|
||||||
parentRoute: typeof AdminRoute
|
parentRoute: typeof AdminRoute
|
||||||
}
|
}
|
||||||
|
'/admin/interstitials': {
|
||||||
|
id: '/admin/interstitials'
|
||||||
|
path: '/interstitials'
|
||||||
|
fullPath: '/admin/interstitials'
|
||||||
|
preLoaderRoute: typeof AdminInterstitialsRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
'/admin/maintenance': {
|
'/admin/maintenance': {
|
||||||
id: '/admin/maintenance'
|
id: '/admin/maintenance'
|
||||||
path: '/maintenance'
|
path: '/maintenance'
|
||||||
@@ -587,6 +606,7 @@ interface AdminRouteChildren {
|
|||||||
AdminGalleryRoute: typeof AdminGalleryRoute
|
AdminGalleryRoute: typeof AdminGalleryRoute
|
||||||
AdminGenresRoute: typeof AdminGenresRoute
|
AdminGenresRoute: typeof AdminGenresRoute
|
||||||
AdminGroupsRoute: typeof AdminGroupsRouteWithChildren
|
AdminGroupsRoute: typeof AdminGroupsRouteWithChildren
|
||||||
|
AdminInterstitialsRoute: typeof AdminInterstitialsRoute
|
||||||
AdminMaintenanceRoute: typeof AdminMaintenanceRoute
|
AdminMaintenanceRoute: typeof AdminMaintenanceRoute
|
||||||
AdminMediaRoute: typeof AdminMediaRoute
|
AdminMediaRoute: typeof AdminMediaRoute
|
||||||
AdminRolesRoute: typeof AdminRolesRoute
|
AdminRolesRoute: typeof AdminRolesRoute
|
||||||
@@ -602,6 +622,7 @@ const AdminRouteChildren: AdminRouteChildren = {
|
|||||||
AdminGalleryRoute: AdminGalleryRoute,
|
AdminGalleryRoute: AdminGalleryRoute,
|
||||||
AdminGenresRoute: AdminGenresRoute,
|
AdminGenresRoute: AdminGenresRoute,
|
||||||
AdminGroupsRoute: AdminGroupsRouteWithChildren,
|
AdminGroupsRoute: AdminGroupsRouteWithChildren,
|
||||||
|
AdminInterstitialsRoute: AdminInterstitialsRoute,
|
||||||
AdminMaintenanceRoute: AdminMaintenanceRoute,
|
AdminMaintenanceRoute: AdminMaintenanceRoute,
|
||||||
AdminMediaRoute: AdminMediaRoute,
|
AdminMediaRoute: AdminMediaRoute,
|
||||||
AdminRolesRoute: AdminRolesRoute,
|
AdminRolesRoute: AdminRolesRoute,
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ function AdminLayout() {
|
|||||||
>
|
>
|
||||||
{t('admin.shows.title')}
|
{t('admin.shows.title')}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/admin/interstitials"
|
||||||
|
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
||||||
|
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
|
||||||
|
>
|
||||||
|
{t('admin.interstitials.title')}
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/admin/groups"
|
to="/admin/groups"
|
||||||
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { InterstitialsPanel } from '@/features/admin/interstitials/InterstitialsPanel'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/admin/interstitials')({ component: InterstitialsPanel })
|
||||||
@@ -80,7 +80,8 @@ export type GenreDto = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Библиотека (шоу) ───────────────────────────────────────────────────────
|
// ── Библиотека (шоу) ───────────────────────────────────────────────────────
|
||||||
export type ShowKind = 'Series' | 'Single'
|
/** `Interstitial` — ролик-врезка: то же шоу, но со своим экраном и вне общей библиотеки. */
|
||||||
|
export type ShowKind = 'Series' | 'Single' | 'Interstitial'
|
||||||
|
|
||||||
/** Возрастная категория, по возрастанию строгости — порядок значим для правил планировщика. */
|
/** Возрастная категория, по возрастанию строгости — порядок значим для правил планировщика. */
|
||||||
export type ShowAudience = 'Kids' | 'Family' | 'Teen' | 'General' | 'Adult'
|
export type ShowAudience = 'Kids' | 'Family' | 'Teen' | 'General' | 'Adult'
|
||||||
@@ -109,6 +110,25 @@ export type ShowGenreDto = {
|
|||||||
isPrimary: boolean
|
isPrimary: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Ролики-врезки ──────────────────────────────────────────────────────────
|
||||||
|
/** Ролик: важна длительность, поэтому она приходит сразу, а не вторым запросом за ассетом. */
|
||||||
|
export type InterstitialDto = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
mediaAssetId: string | null
|
||||||
|
assetStatus: MediaAssetStatus | null
|
||||||
|
durationSeconds: number | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Рекламный блок — коллекция, целиком собранная из роликов. */
|
||||||
|
export type InterstitialBlockDto = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
itemCount: number
|
||||||
|
durationSeconds: number
|
||||||
|
}
|
||||||
|
|
||||||
// ── Библиотека (коллекции) ─────────────────────────────────────────────────
|
// ── Библиотека (коллекции) ─────────────────────────────────────────────────
|
||||||
export type CollectionSummaryDto = {
|
export type CollectionSummaryDto = {
|
||||||
id: string
|
id: string
|
||||||
@@ -276,19 +296,15 @@ export type ShowDto = {
|
|||||||
// ── Каналы ────────────────────────────────────────────────────────────────
|
// ── Каналы ────────────────────────────────────────────────────────────────
|
||||||
export type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper' | 'Fallback' | 'SignOff'
|
export type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper' | 'Fallback' | 'SignOff'
|
||||||
export type BumperFont = 'Sans' | 'Serif'
|
export type BumperFont = 'Sans' | 'Serif'
|
||||||
export type BumperSelection = 'Rotation' | 'Random' | 'AlwaysFirst' | 'WeightedRandom'
|
export type BumperSelection = 'Random' | 'AlwaysFirst' | 'WeightedRandom'
|
||||||
export type BumperTextKind = 'NowNext' | 'Free'
|
export type BumperTextKind = 'NowNext' | 'Free'
|
||||||
export type BumperTrigger = 'OnShowChange' | 'BetweenEpisodes' | 'Both'
|
export type BumperTrigger = 'OnShowChange' | 'BetweenEpisodes' | 'Both'
|
||||||
|
|
||||||
/** Общие для канала настройки заставок (стиль/звук/текст — на блоках и подблоках). */
|
/** Общие для канала настройки заставок (стиль/звук/текст — на блоках и подблоках). */
|
||||||
|
/** Условия показа (как часто, на смене шоу или между сериями) живут на элементе стыка, не здесь. */
|
||||||
export type BumperSettings = {
|
export type BumperSettings = {
|
||||||
font: BumperFont
|
font: BumperFont
|
||||||
minIntervalMinutes: number
|
|
||||||
selection: BumperSelection
|
selection: BumperSelection
|
||||||
/** Вероятность заставки на смене шоу (0..1). */
|
|
||||||
showChangeChance: number
|
|
||||||
/** Вероятность заставки между блоками одного шоу (0..1). */
|
|
||||||
episodeChangeChance: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Подблок (текст-вариант): свой текст + правило показа поверх стиля/звука блока. */
|
/** Подблок (текст-вариант): свой текст + правило показа поверх стиля/звука блока. */
|
||||||
@@ -386,6 +402,40 @@ export type SlotDto = {
|
|||||||
maxDriftMinutes: number
|
maxDriftMinutes: number
|
||||||
/** Округление старта до кратного N минут (5/10/15/30) или null. */
|
/** Округление старта до кратного N минут (5/10/15/30) или null. */
|
||||||
snapToMinutes: number | null
|
snapToMinutes: number | null
|
||||||
|
/** Стык между единицами внутри слота (null — шаблон канала по умолчанию). */
|
||||||
|
junctionBetweenId: string | null
|
||||||
|
/** Стык после слота (null — шаблон канала по умолчанию). */
|
||||||
|
junctionAfterId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Стыки (что играет между программами) ──────────────────────────────────
|
||||||
|
export type JunctionElementKind = 'Ad' | 'Promo' | 'Bumper' | 'Filler'
|
||||||
|
export type JunctionAmountMode = 'Count' | 'Duration'
|
||||||
|
|
||||||
|
/** Условия показа врезки — структурные поля, а не выражение-строка. */
|
||||||
|
export type JunctionConditions = {
|
||||||
|
onlyOnElementChange: boolean
|
||||||
|
minMinutesBetween: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JunctionElementDto = {
|
||||||
|
id: string
|
||||||
|
position: number
|
||||||
|
kind: JunctionElementKind
|
||||||
|
groupId: string | null
|
||||||
|
groupName: string | null
|
||||||
|
bumperTemplateId: string | null
|
||||||
|
bumperTemplateName: string | null
|
||||||
|
amountMode: JunctionAmountMode
|
||||||
|
amountValue: number
|
||||||
|
isRequired: boolean
|
||||||
|
conditions: JunctionConditions | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JunctionTemplateDto = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
elements: JunctionElementDto[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DateRange = { from: string; to: string }
|
export type DateRange = { from: string; to: string }
|
||||||
@@ -414,6 +464,8 @@ export type ScheduleTemplateDto = {
|
|||||||
channelId: string
|
channelId: string
|
||||||
name: string
|
name: string
|
||||||
fallbackGroupId: string | null
|
fallbackGroupId: string | null
|
||||||
|
/** Стык, который берётся, когда слот своего не задал. */
|
||||||
|
defaultJunctionId: string | null
|
||||||
revision: number
|
revision: number
|
||||||
appliedRevision: number
|
appliedRevision: number
|
||||||
/** Есть ли правки правил, не применённые к эфиру. */
|
/** Есть ли правки правил, не применённые к эфиру. */
|
||||||
@@ -438,6 +490,27 @@ export type PlanningWarningDto = {
|
|||||||
|
|
||||||
export type ApplyResultDto = { added: number; warnings: PlanningWarningDto[] }
|
export type ApplyResultDto = { added: number; warnings: PlanningWarningDto[] }
|
||||||
|
|
||||||
|
/** Что попало в ленту предпросмотра. `Bumper` приходит без ассета — он рендерится при применении. */
|
||||||
|
export type PlannedItemKind = 'Program' | 'Fallback' | 'SignOff' | 'Ad' | 'Promo' | 'Bumper'
|
||||||
|
|
||||||
|
export type PreviewItemDto = {
|
||||||
|
kind: PlannedItemKind
|
||||||
|
startsAtUtc: string
|
||||||
|
endsAtUtc: string
|
||||||
|
showId: string | null
|
||||||
|
title: string | null
|
||||||
|
slotId: string | null
|
||||||
|
slotTitle: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SchedulePreviewDto = {
|
||||||
|
fromUtc: string
|
||||||
|
toUtc: string
|
||||||
|
utcOffsetMinutes: number
|
||||||
|
items: PreviewItemDto[]
|
||||||
|
warnings: PlanningWarningDto[]
|
||||||
|
}
|
||||||
|
|
||||||
export type ScheduleEntryDto = {
|
export type ScheduleEntryDto = {
|
||||||
id: string
|
id: string
|
||||||
kind: ScheduleEntryKind
|
kind: ScheduleEntryKind
|
||||||
|
|||||||
+144
-14
@@ -132,6 +132,28 @@ const resources = {
|
|||||||
empty: 'Коллекция пуста',
|
empty: 'Коллекция пуста',
|
||||||
orderHint: 'Порядок частей задаётся перетаскиванием — в нём они и пойдут в эфир.',
|
orderHint: 'Порядок частей задаётся перетаскиванием — в нём они и пойдут в эфир.',
|
||||||
},
|
},
|
||||||
|
interstitials: {
|
||||||
|
title: 'Ролики',
|
||||||
|
hint: 'Реклама, промо и джинглы. Перетащите ролики в сборку блока или в группу справа — блок сохранится коллекцией и пойдёт в эфир целиком.',
|
||||||
|
upload: 'Загрузить ролики',
|
||||||
|
name: 'Название',
|
||||||
|
duration: 'Длительность',
|
||||||
|
empty: 'Роликов пока нет',
|
||||||
|
noAsset: 'Без файла',
|
||||||
|
blocks: 'Блоки',
|
||||||
|
noBlocks: 'Блоков пока нет',
|
||||||
|
clipsCount: '{{count}} рол.',
|
||||||
|
blockBuilder: 'Сборка блока',
|
||||||
|
blockName: 'Название блока',
|
||||||
|
blockTotal: 'Длительность блока',
|
||||||
|
dropHint: 'Перетащите сюда ролики',
|
||||||
|
groups: 'Группы роликов',
|
||||||
|
pickGroup: 'Выберите группу',
|
||||||
|
pickGroupFirst: 'Сначала выберите группу',
|
||||||
|
dropToGroup: 'Перетащите сюда ролик или блок',
|
||||||
|
openGroup: 'Открыть группу',
|
||||||
|
newGroupName: 'Новая группа',
|
||||||
|
},
|
||||||
genres: {
|
genres: {
|
||||||
title: 'Жанры',
|
title: 'Жанры',
|
||||||
hint: 'Справочник жанров: по нему собираются группы контента, в него сводятся жанры из метаданных.',
|
hint: 'Справочник жанров: по нему собираются группы контента, в него сводятся жанры из метаданных.',
|
||||||
@@ -245,7 +267,7 @@ const resources = {
|
|||||||
name: 'Название',
|
name: 'Название',
|
||||||
originalName: 'Оригинальное название (eng)',
|
originalName: 'Оригинальное название (eng)',
|
||||||
kind: 'Тип',
|
kind: 'Тип',
|
||||||
kinds: { Series: 'Сериал', Single: 'Полнометражка' },
|
kinds: { Series: 'Сериал', Single: 'Полнометражка', Interstitial: 'Ролик' },
|
||||||
audience: 'Категория',
|
audience: 'Категория',
|
||||||
audiences: {
|
audiences: {
|
||||||
Kids: 'Детское',
|
Kids: 'Детское',
|
||||||
@@ -317,6 +339,58 @@ const resources = {
|
|||||||
maxDrift: 'Допуск, мин',
|
maxDrift: 'Допуск, мин',
|
||||||
snap: 'Округление',
|
snap: 'Округление',
|
||||||
snapOff: 'нет',
|
snapOff: 'нет',
|
||||||
|
bumperConditionsHint:
|
||||||
|
'Как часто ставить заставку и на каких переходах — условия элемента стыка, а не настройка канала.',
|
||||||
|
preview: 'Предпросмотр',
|
||||||
|
previewHide: 'Свернуть предпросмотр',
|
||||||
|
previewHint: 'Прогон по текущим правилам: ничего не пишется, курсоры слотов не двигаются.',
|
||||||
|
previewDays_one: '{{count}} сутки',
|
||||||
|
previewDays_few: '{{count}} суток',
|
||||||
|
previewDays_many: '{{count}} суток',
|
||||||
|
previewTabs: { programme: 'Программа', tape: 'Лента' },
|
||||||
|
previewKinds: {
|
||||||
|
Program: 'Программа',
|
||||||
|
Fallback: 'Фон',
|
||||||
|
SignOff: 'Конец вещания',
|
||||||
|
Ad: 'Реклама',
|
||||||
|
Promo: 'Анонс',
|
||||||
|
Bumper: 'Заставка',
|
||||||
|
},
|
||||||
|
previewLoad: 'Врезки по часам, пик — {{peak}} мин',
|
||||||
|
junctions: 'Стыки',
|
||||||
|
junctionsHint:
|
||||||
|
'Что играет между программами: реклама, анонсы, заставки. Слот может взять свой стык, иначе берётся стык по умолчанию.',
|
||||||
|
defaultJunction: 'Стык по умолчанию',
|
||||||
|
noJunction: 'без стыка',
|
||||||
|
newJunctionName: 'Новый стык',
|
||||||
|
addJunctionElement: '+ врезка',
|
||||||
|
junctionEmpty: 'пусто',
|
||||||
|
junctionFrom: 'конец',
|
||||||
|
junctionTo: 'начало',
|
||||||
|
junctionElement: 'Врезка',
|
||||||
|
junctionKind: 'Тип',
|
||||||
|
junctionKinds: {
|
||||||
|
Ad: 'Реклама',
|
||||||
|
Promo: 'Анонс',
|
||||||
|
Bumper: 'Заставка',
|
||||||
|
Filler: 'Заполнитель',
|
||||||
|
},
|
||||||
|
junctionAmountMode: 'Чем меряется',
|
||||||
|
junctionAmountModes: { Count: 'Единиц', Duration: 'Минут' },
|
||||||
|
junctionCount: 'Сколько единиц',
|
||||||
|
junctionMinutes: 'Сколько минут',
|
||||||
|
junctionAmountHint:
|
||||||
|
'В смешанной группе (ролики и готовые блоки) считайте минутами: одна «единица» там — то ли ролик, то ли блок.',
|
||||||
|
junctionRequired: 'Обязательная — не выбрасывать при нехватке времени',
|
||||||
|
junctionOnlyOnChange: 'Только при смене шоу',
|
||||||
|
junctionMinInterval: 'Не чаще, чем раз в, мин',
|
||||||
|
junctionMinIntervalHint: '0 — без ограничения.',
|
||||||
|
junctionBetween: 'Стык внутри слота',
|
||||||
|
junctionAfter: 'Стык после слота',
|
||||||
|
junctionDefault: 'по умолчанию',
|
||||||
|
bumperTemplate: 'Блок заставки',
|
||||||
|
pickBumperTemplate: 'выберите блок',
|
||||||
|
minutesShort: ' мин',
|
||||||
pendingChanges: 'Правила изменены — эфир идёт по старым.',
|
pendingChanges: 'Правила изменены — эфир идёт по старым.',
|
||||||
apply: 'Применить',
|
apply: 'Применить',
|
||||||
applied: 'Эфир пересобран, записей: {{count}}',
|
applied: 'Эфир пересобран, записей: {{count}}',
|
||||||
@@ -367,15 +441,9 @@ const resources = {
|
|||||||
bumpersLabel: 'Заставки на переходах',
|
bumpersLabel: 'Заставки на переходах',
|
||||||
bumpersHint: 'Короткая заставка «Сейчас / Далее» между разными шоу',
|
bumpersHint: 'Короткая заставка «Сейчас / Далее» между разными шоу',
|
||||||
bumperSelection: 'Выбор блока',
|
bumperSelection: 'Выбор блока',
|
||||||
bumperSelectionRotation: 'По кругу',
|
|
||||||
bumperSelectionRandom: 'Случайно',
|
bumperSelectionRandom: 'Случайно',
|
||||||
bumperSelectionWeighted: 'Случайно взвешенный',
|
bumperSelectionWeighted: 'Случайно взвешенный',
|
||||||
bumperSelectionAlwaysFirst: 'Всегда первый',
|
bumperSelectionAlwaysFirst: 'Всегда первый',
|
||||||
bumperMinInterval: 'Мин. интервал, мин',
|
|
||||||
bumperShowChangeChance: 'Вероятность на смене шоу',
|
|
||||||
bumperShowChangeChanceHint: '0..1: 1 — на каждой смене, 0 — никогда',
|
|
||||||
bumperEpisodeChangeChance: 'Вероятность между сериями',
|
|
||||||
bumperEpisodeChangeChanceHint: '0..1: напр. 0.3 — примерно в 30% переходов между сериями',
|
|
||||||
bumperFont: 'Шрифт',
|
bumperFont: 'Шрифт',
|
||||||
bumperFontSans: 'Гротеск',
|
bumperFontSans: 'Гротеск',
|
||||||
bumperFontSerif: 'Антиква',
|
bumperFontSerif: 'Антиква',
|
||||||
@@ -641,6 +709,28 @@ const resources = {
|
|||||||
empty: 'Collection is empty',
|
empty: 'Collection is empty',
|
||||||
orderHint: 'Drag to set the order of the parts — that is the order they air in.',
|
orderHint: 'Drag to set the order of the parts — that is the order they air in.',
|
||||||
},
|
},
|
||||||
|
interstitials: {
|
||||||
|
title: 'Clips',
|
||||||
|
hint: 'Ads, promos and jingles. Drag clips into the block builder or a group on the right — a block is saved as a collection and airs as a whole.',
|
||||||
|
upload: 'Upload clips',
|
||||||
|
name: 'Name',
|
||||||
|
duration: 'Duration',
|
||||||
|
empty: 'No clips yet',
|
||||||
|
noAsset: 'No file',
|
||||||
|
blocks: 'Blocks',
|
||||||
|
noBlocks: 'No blocks yet',
|
||||||
|
clipsCount: '{{count}} clips',
|
||||||
|
blockBuilder: 'Block builder',
|
||||||
|
blockName: 'Block name',
|
||||||
|
blockTotal: 'Block duration',
|
||||||
|
dropHint: 'Drop clips here',
|
||||||
|
groups: 'Clip groups',
|
||||||
|
pickGroup: 'Pick a group',
|
||||||
|
pickGroupFirst: 'Pick a group first',
|
||||||
|
dropToGroup: 'Drop a clip or a block here',
|
||||||
|
openGroup: 'Open group',
|
||||||
|
newGroupName: 'New group',
|
||||||
|
},
|
||||||
genres: {
|
genres: {
|
||||||
title: 'Genres',
|
title: 'Genres',
|
||||||
hint: 'Genre reference: content groups are built from it, provider metadata is mapped into it.',
|
hint: 'Genre reference: content groups are built from it, provider metadata is mapped into it.',
|
||||||
@@ -754,7 +844,7 @@ const resources = {
|
|||||||
name: 'Name',
|
name: 'Name',
|
||||||
originalName: 'Original name (eng)',
|
originalName: 'Original name (eng)',
|
||||||
kind: 'Kind',
|
kind: 'Kind',
|
||||||
kinds: { Series: 'Series', Single: 'Movie' },
|
kinds: { Series: 'Series', Single: 'Movie', Interstitial: 'Clip' },
|
||||||
audience: 'Category',
|
audience: 'Category',
|
||||||
audiences: {
|
audiences: {
|
||||||
Kids: 'Kids',
|
Kids: 'Kids',
|
||||||
@@ -826,6 +916,52 @@ const resources = {
|
|||||||
maxDrift: 'Allowance, min',
|
maxDrift: 'Allowance, min',
|
||||||
snap: 'Snap',
|
snap: 'Snap',
|
||||||
snapOff: 'off',
|
snapOff: 'off',
|
||||||
|
bumperConditionsHint:
|
||||||
|
'How often a bumper is inserted and on which transitions is a junction-element condition, not a channel setting.',
|
||||||
|
preview: 'Preview',
|
||||||
|
previewHide: 'Hide preview',
|
||||||
|
previewHint: 'A run against the current rules: nothing is written, slot cursors do not move.',
|
||||||
|
previewDays_one: '{{count}} day',
|
||||||
|
previewDays_other: '{{count}} days',
|
||||||
|
previewTabs: { programme: 'Programme', tape: 'Tape' },
|
||||||
|
previewKinds: {
|
||||||
|
Program: 'Programme',
|
||||||
|
Fallback: 'Background',
|
||||||
|
SignOff: 'Sign-off',
|
||||||
|
Ad: 'Ad',
|
||||||
|
Promo: 'Promo',
|
||||||
|
Bumper: 'Bumper',
|
||||||
|
},
|
||||||
|
previewLoad: 'Breaks per hour, peak — {{peak}} min',
|
||||||
|
junctions: 'Junctions',
|
||||||
|
junctionsHint:
|
||||||
|
'What plays between programmes: ads, promos, bumpers. A slot may pick its own junction, otherwise the default one is used.',
|
||||||
|
defaultJunction: 'Default junction',
|
||||||
|
noJunction: 'no junction',
|
||||||
|
newJunctionName: 'New junction',
|
||||||
|
addJunctionElement: '+ break',
|
||||||
|
junctionEmpty: 'empty',
|
||||||
|
junctionFrom: 'end',
|
||||||
|
junctionTo: 'start',
|
||||||
|
junctionElement: 'Break',
|
||||||
|
junctionKind: 'Kind',
|
||||||
|
junctionKinds: { Ad: 'Ad', Promo: 'Promo', Bumper: 'Bumper', Filler: 'Filler' },
|
||||||
|
junctionAmountMode: 'Measured in',
|
||||||
|
junctionAmountModes: { Count: 'Units', Duration: 'Minutes' },
|
||||||
|
junctionCount: 'How many units',
|
||||||
|
junctionMinutes: 'How many minutes',
|
||||||
|
junctionAmountHint:
|
||||||
|
'For a mixed group (clips and ready-made blocks) count in minutes: one "unit" there is either a clip or a whole block.',
|
||||||
|
junctionRequired: 'Required — never dropped when time runs short',
|
||||||
|
junctionOnlyOnChange: 'Only when the show changes',
|
||||||
|
junctionMinInterval: 'No more often than once per, min',
|
||||||
|
junctionMinIntervalHint: '0 — no limit.',
|
||||||
|
junctionBetween: 'Junction inside the slot',
|
||||||
|
junctionAfter: 'Junction after the slot',
|
||||||
|
junctionDefault: 'default',
|
||||||
|
bumperTemplate: 'Bumper block',
|
||||||
|
pickBumperTemplate: 'pick a block',
|
||||||
|
minutesShort: ' min',
|
||||||
pendingChanges: 'Rules changed — the air still follows the old ones.',
|
pendingChanges: 'Rules changed — the air still follows the old ones.',
|
||||||
apply: 'Apply',
|
apply: 'Apply',
|
||||||
applied: 'Air rebuilt, entries: {{count}}',
|
applied: 'Air rebuilt, entries: {{count}}',
|
||||||
@@ -876,15 +1012,9 @@ const resources = {
|
|||||||
bumpersLabel: 'Transition bumpers',
|
bumpersLabel: 'Transition bumpers',
|
||||||
bumpersHint: 'Short “Now / Next” bumper between different shows',
|
bumpersHint: 'Short “Now / Next” bumper between different shows',
|
||||||
bumperSelection: 'Block selection',
|
bumperSelection: 'Block selection',
|
||||||
bumperSelectionRotation: 'Rotation',
|
|
||||||
bumperSelectionRandom: 'Random',
|
bumperSelectionRandom: 'Random',
|
||||||
bumperSelectionWeighted: 'Weighted random',
|
bumperSelectionWeighted: 'Weighted random',
|
||||||
bumperSelectionAlwaysFirst: 'Always first',
|
bumperSelectionAlwaysFirst: 'Always first',
|
||||||
bumperMinInterval: 'Min interval, min',
|
|
||||||
bumperShowChangeChance: 'Chance on show change',
|
|
||||||
bumperShowChangeChanceHint: '0..1: 1 — every change, 0 — never',
|
|
||||||
bumperEpisodeChangeChance: 'Chance between episodes',
|
|
||||||
bumperEpisodeChangeChanceHint: '0..1: e.g. 0.3 — about 30% of same-show transitions',
|
|
||||||
bumperFont: 'Font',
|
bumperFont: 'Font',
|
||||||
bumperFontSans: 'Sans',
|
bumperFontSans: 'Sans',
|
||||||
bumperFontSerif: 'Serif',
|
bumperFontSerif: 'Serif',
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import Hls from 'hls.js'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { getAccessToken } from '@/shared/api/client'
|
||||||
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мини-плеер HLS для админки: плейлист и сегменты лежат под admin-роутами (JWT), поэтому запросы
|
||||||
|
* идут через hls.js с Bearer-заголовком. Нативный путь (Safari) — только там, где hls.js не нужен.
|
||||||
|
*/
|
||||||
|
export function HlsVideo({ src, className }: { src: string; className?: 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={cn('aspect-video w-full rounded-md border border-border bg-black', className)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user