Compare commits

...
2 Commits
73 changed files with 8995 additions and 1807 deletions
@@ -9,6 +9,8 @@ using TeleWave.Application.Broadcast.UpdateChannelSettings;
using TeleWave.Application.Broadcast.UpdateChannelTime;
using TeleWave.Domain.Broadcast;
using TeleWave.Infrastructure.Identity;
using TeleWave.Application.Broadcast.UpdateViewerSettings;
using TeleWave.Application.Programming.Planning.Trace;
namespace TeleWave.Api.Endpoints;
@@ -92,10 +94,47 @@ public static partial class ChannelEndpoints
admin
.MapGet("/{id:guid}/schedule", GetSchedule)
.Produces<IReadOnlyList<ScheduleEntryDto>>();
admin
.MapPut("/{id:guid}/viewer", UpdateViewerSettings)
.Produces(StatusCodes.Status204NoContent);
// «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации.
admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces<EntryTraceDto>();
return app;
}
private static async Task<IResult> UpdateViewerSettings(
Guid id,
UpdateViewerSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateViewerSettingsCommand(
id,
body.LogoImageId,
body.LogoCorner,
body.LogoOpacity,
body.ShowClock,
body.AnalogFilterStrength
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> GetEntryTrace(
Guid entryId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetEntryTraceQuery(entryId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateChannel(
CreateChannelCommand command,
ISender sender,
@@ -203,3 +242,12 @@ public sealed record UpdateChannelSettingsBody(
BumperSettingsInput Bumper,
Guid? FillerAssetId
);
/// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary>
public sealed record UpdateViewerSettingsBody(
Guid? LogoImageId,
LogoCorner LogoCorner,
double LogoOpacity,
bool ShowClock,
double AnalogFilterStrength
);
@@ -1,53 +1,55 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Settings;
using TeleWave.Application.Settings.GetSiteSettings;
using TeleWave.Application.Settings.UpdateSiteSettings;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class SettingsEndpoints
{
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/settings")
.WithTags("Admin.Settings")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", GetSettings).Produces<SiteSettingsDto>();
admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> GetSettings(
ISender sender,
CancellationToken cancellationToken
)
{
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
return Results.Ok(settings);
}
private static async Task<IResult> UpdateSettings(
UpdateSiteSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateSiteSettingsCommand(
body.RegistrationEnabled,
body.PreferredAudioLanguages ?? ""
),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record UpdateSiteSettingsBody(
bool RegistrationEnabled,
string? PreferredAudioLanguages
);
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Settings;
using TeleWave.Application.Settings.GetSiteSettings;
using TeleWave.Application.Settings.UpdateSiteSettings;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class SettingsEndpoints
{
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/settings")
.WithTags("Admin.Settings")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", GetSettings).Produces<SiteSettingsDto>();
admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> GetSettings(
ISender sender,
CancellationToken cancellationToken
)
{
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
return Results.Ok(settings);
}
private static async Task<IResult> UpdateSettings(
UpdateSiteSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateSiteSettingsCommand(
body.RegistrationEnabled,
body.PreferredAudioLanguages ?? "",
body.ChannelNumbersEnabled
),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record UpdateSiteSettingsBody(
bool RegistrationEnabled,
string? PreferredAudioLanguages,
bool ChannelNumbersEnabled
);
@@ -1,184 +1,197 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using LiteCqrs;
using Microsoft.Extensions.Hosting;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Application.Streaming.GetLivePlaylist;
using TeleWave.Application.Streaming.GetPublicEpg;
using TeleWave.Application.Streaming.ListPublicChannels;
using TeleWave.Infrastructure.Media;
using TeleWave.Infrastructure.Streaming;
namespace TeleWave.Api.Endpoints;
public static class StreamingEndpoints
{
private const string StreamCookieName = "tw_stream";
private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled);
public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app)
{
// Публичный API канала (Bearer): список, EPG, выдача stream-cookie.
var channels = app.MapGroup("/api/channels").WithTags("Channels").RequireAuthorization();
channels.MapGet("", ListChannels).Produces<IReadOnlyList<PublicChannelDto>>();
channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent);
channels.MapGet("/{slug}/epg", Epg);
// Раздача эфира (cookie tw_stream): плейлист и сегменты — их грузит <video>/hls.js.
app.MapGet("/api/channels/{slug}/live.m3u8", LivePlaylist).WithTags("Streaming");
app.MapGet("/api/stream/{assetId:guid}/{file}", Segment).WithTags("Streaming");
return app;
}
private static async Task<IResult> ListChannels(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListPublicChannelsQuery(), cancellationToken);
return Results.Ok(result);
}
private static IResult Watch(
string slug,
ICurrentUser currentUser,
StreamTokenService tokens,
HttpRequest request,
HttpResponse response,
IHostEnvironment env
)
{
if (currentUser.UserId is not { } userId)
return Results.Unauthorized();
var (token, expiresAt) = tokens.Issue(userId);
response.Cookies.Append(
StreamCookieName,
token,
new CookieOptions
{
HttpOnly = true,
// Вне Development — всегда Secure (прод за внешним TLS-прокси; request.IsHttps ненадёжен
// при неполной настройке ForwardedHeaders). См. UseSecureCookie в AuthEndpoints.
Secure = !env.IsDevelopment() || request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/api",
Expires = expiresAt,
}
);
return Results.NoContent();
}
private static async Task<IResult> Epg(
string slug,
DateTimeOffset? from,
DateTimeOffset? to,
ISender sender,
CancellationToken cancellationToken
)
{
var fromUtc = from ?? DateTimeOffset.UtcNow;
var toUtc = to ?? fromUtc.AddHours(12);
var result = await sender.Send(
new GetPublicEpgQuery(slug, fromUtc, toUtc),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> LivePlaylist(
string slug,
HttpRequest request,
HttpResponse response,
StreamTokenService tokens,
IIdentityService identity,
ISender sender,
CancellationToken cancellationToken
)
{
// Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить,
// что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу,
// не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто).
if (tokens.Validate(request.Cookies[StreamCookieName]) is not { } userId)
return Results.Unauthorized();
var profile = await identity.GetProfileAsync(userId, cancellationToken);
if (profile is null || profile.IsBlocked)
return Results.Unauthorized();
var result = await sender.Send(
new GetLivePlaylistQuery(slug, DateTimeOffset.UtcNow),
cancellationToken
);
if (!result.IsSuccess)
return result.ToHttpResult();
if (result.Value.Segments.Count == 0)
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
response.Headers.CacheControl = "no-cache";
return Results.Text(Render(result.Value), "application/vnd.apple.mpegurl");
}
private static IResult Segment(
Guid assetId,
string file,
HttpRequest request,
HttpResponse response,
StreamTokenService tokens,
MediaPathResolver paths
)
{
if (tokens.Validate(request.Cookies[StreamCookieName]) is null)
return Results.Unauthorized();
if (!SegmentFileName.IsMatch(file))
return Results.NotFound();
string path;
try
{
path = paths.SegmentPath(assetId, file);
}
catch (UnauthorizedAccessException)
{
return Results.NotFound();
}
if (!File.Exists(path))
return Results.NotFound();
response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
private static string Render(LivePlaylistDto playlist)
{
var extinf = playlist.TargetDuration.ToString("F6", CultureInfo.InvariantCulture);
var sb = new StringBuilder();
sb.Append("#EXTM3U\n");
sb.Append("#EXT-X-VERSION:3\n");
sb.Append(
CultureInfo.InvariantCulture,
$"#EXT-X-TARGETDURATION:{playlist.TargetDuration}\n"
);
sb.Append(
CultureInfo.InvariantCulture,
$"#EXT-X-MEDIA-SEQUENCE:{playlist.MediaSequence}\n"
);
foreach (var segment in playlist.Segments)
{
if (segment.Discontinuity)
sb.Append("#EXT-X-DISCONTINUITY\n");
sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n");
sb.Append(
CultureInfo.InvariantCulture,
$"/api/stream/{segment.AssetId:N}/seg{segment.LocalIndex:D5}.ts\n"
);
}
return sb.ToString();
}
}
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using LiteCqrs;
using Microsoft.Extensions.Hosting;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Application.Streaming.GetLivePlaylist;
using TeleWave.Application.Streaming.GetPublicEpg;
using TeleWave.Application.Streaming.ListPublicChannels;
using TeleWave.Infrastructure.Media;
using TeleWave.Infrastructure.Streaming;
namespace TeleWave.Api.Endpoints;
public static class StreamingEndpoints
{
private const string StreamCookieName = "tw_stream";
private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled);
public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app)
{
// Публичный API канала (Bearer): список, EPG, выдача stream-cookie.
var channels = app.MapGroup("/api/channels").WithTags("Channels").RequireAuthorization();
channels.MapGet("", ListChannels).Produces<IReadOnlyList<PublicChannelDto>>();
// Что включено на стороне зрителя: сейчас только переключение по номерам (см. 6.8).
channels.MapGet("/features", ViewerFeatures).Produces<ViewerFeaturesDto>();
channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent);
channels.MapGet("/{slug}/epg", Epg);
// Раздача эфира (cookie tw_stream): плейлист и сегменты — их грузит <video>/hls.js.
app.MapGet("/api/channels/{slug}/live.m3u8", LivePlaylist).WithTags("Streaming");
app.MapGet("/api/stream/{assetId:guid}/{file}", Segment).WithTags("Streaming");
return app;
}
private static async Task<IResult> ListChannels(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListPublicChannelsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> ViewerFeatures(
ISiteSettings siteSettings,
CancellationToken cancellationToken
) =>
Results.Ok(
new ViewerFeaturesDto(await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken))
);
private static IResult Watch(
string slug,
ICurrentUser currentUser,
StreamTokenService tokens,
HttpRequest request,
HttpResponse response,
IHostEnvironment env
)
{
if (currentUser.UserId is not { } userId)
return Results.Unauthorized();
var (token, expiresAt) = tokens.Issue(userId);
response.Cookies.Append(
StreamCookieName,
token,
new CookieOptions
{
HttpOnly = true,
// Вне Development — всегда Secure (прод за внешним TLS-прокси; request.IsHttps ненадёжен
// при неполной настройке ForwardedHeaders). См. UseSecureCookie в AuthEndpoints.
Secure = !env.IsDevelopment() || request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/api",
Expires = expiresAt,
}
);
return Results.NoContent();
}
private static async Task<IResult> Epg(
string slug,
DateTimeOffset? from,
DateTimeOffset? to,
ISender sender,
CancellationToken cancellationToken
)
{
var fromUtc = from ?? DateTimeOffset.UtcNow;
var toUtc = to ?? fromUtc.AddHours(12);
var result = await sender.Send(
new GetPublicEpgQuery(slug, fromUtc, toUtc),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> LivePlaylist(
string slug,
HttpRequest request,
HttpResponse response,
StreamTokenService tokens,
IIdentityService identity,
ISender sender,
CancellationToken cancellationToken
)
{
// Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить,
// что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу,
// не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто).
if (tokens.Validate(request.Cookies[StreamCookieName]) is not { } userId)
return Results.Unauthorized();
var profile = await identity.GetProfileAsync(userId, cancellationToken);
if (profile is null || profile.IsBlocked)
return Results.Unauthorized();
var result = await sender.Send(
new GetLivePlaylistQuery(slug, DateTimeOffset.UtcNow),
cancellationToken
);
if (!result.IsSuccess)
return result.ToHttpResult();
if (result.Value.Segments.Count == 0)
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
response.Headers.CacheControl = "no-cache";
return Results.Text(Render(result.Value), "application/vnd.apple.mpegurl");
}
private static IResult Segment(
Guid assetId,
string file,
HttpRequest request,
HttpResponse response,
StreamTokenService tokens,
MediaPathResolver paths
)
{
if (tokens.Validate(request.Cookies[StreamCookieName]) is null)
return Results.Unauthorized();
if (!SegmentFileName.IsMatch(file))
return Results.NotFound();
string path;
try
{
path = paths.SegmentPath(assetId, file);
}
catch (UnauthorizedAccessException)
{
return Results.NotFound();
}
if (!File.Exists(path))
return Results.NotFound();
response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
private static string Render(LivePlaylistDto playlist)
{
var extinf = playlist.TargetDuration.ToString("F6", CultureInfo.InvariantCulture);
var sb = new StringBuilder();
sb.Append("#EXTM3U\n");
sb.Append("#EXT-X-VERSION:3\n");
sb.Append(
CultureInfo.InvariantCulture,
$"#EXT-X-TARGETDURATION:{playlist.TargetDuration}\n"
);
sb.Append(
CultureInfo.InvariantCulture,
$"#EXT-X-MEDIA-SEQUENCE:{playlist.MediaSequence}\n"
);
foreach (var segment in playlist.Segments)
{
if (segment.Discontinuity)
sb.Append("#EXT-X-DISCONTINUITY\n");
sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n");
sb.Append(
CultureInfo.InvariantCulture,
$"/api/stream/{segment.AssetId:N}/seg{segment.LocalIndex:D5}.ts\n"
);
}
return sb.ToString();
}
}
/// <summary>Опции зрительской части, включённые глобально.</summary>
public sealed record ViewerFeaturesDto(bool ChannelNumbersEnabled);
@@ -1,13 +1,16 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Programming.Planning.ApplyTemplate;
using TeleWave.Application.Programming.Planning.Diff;
using TeleWave.Application.Programming.Planning.Preview;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.CopyTemplate;
using TeleWave.Application.Programming.Templates.CreateSlot;
using TeleWave.Application.Programming.Templates.DeleteSlot;
using TeleWave.Application.Programming.Templates.GetTemplate;
using TeleWave.Application.Programming.Templates.Layers;
using TeleWave.Application.Programming.Templates.UpdateSlot;
using TeleWave.Application.Programming.Templates.Validate;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
@@ -39,6 +42,17 @@ public static class TemplateEndpoints
admin
.MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
.Produces<SchedulePreviewDto>();
// Проверки по правилам — только по шаблону, без прогона генератора.
admin
.MapGet("/channels/{channelId:guid}/template/issues", ValidateTemplate)
.Produces<IReadOnlyList<TemplateIssueDto>>();
// Что изменится в эфире, если применить прямо сейчас.
admin
.MapGet("/channels/{channelId:guid}/template/diff", DiffTemplate)
.Produces<ScheduleDiffDto>();
admin
.MapPost("/channels/{channelId:guid}/template/copy-to/{targetChannelId:guid}", CopyTemplate)
.Produces<CopyTemplateResultDto>();
admin
.MapPost("/templates/{templateId:guid}/layers", CreateLayer)
@@ -94,6 +108,40 @@ public static class TemplateEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> ValidateTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ValidateTemplateQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DiffTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new PreviewApplyDiffQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CopyTemplate(
Guid channelId,
Guid targetChannelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CopyTemplateCommand(channelId, targetChannelId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateTemplate(
Guid templateId,
UpdateTemplateBody body,
@@ -106,7 +154,8 @@ public static class TemplateEndpoints
templateId,
body.Name,
body.FallbackGroupId,
body.DefaultJunctionId
body.DefaultJunctionId,
body.Rules
),
cancellationToken
);
@@ -197,7 +246,8 @@ public static class TemplateEndpoints
public sealed record UpdateTemplateBody(
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId
Guid? DefaultJunctionId,
PlanningRules? Rules
);
public sealed record CreateLayerBody(string Name, int Priority);
@@ -49,5 +49,16 @@ public sealed record ChannelDto(
bool BumpersEnabled,
BumperSettingsDto Bumper,
IReadOnlyList<BumperTemplateDto> BumperTemplates,
Guid? FillerAssetId
Guid? FillerAssetId,
/// <summary>Оверлеи и фильтр зрительской части — всё опционально (см. 6.8).</summary>
ViewerSettingsDto Viewer
);
/// <summary>Как канал выглядит у зрителя: логотип-оверлей, часы, аналоговый фильтр.</summary>
public sealed record ViewerSettingsDto(
Guid? LogoImageId,
LogoCorner LogoCorner,
double LogoOpacity,
bool ShowClock,
double AnalogFilterStrength
);
@@ -67,7 +67,14 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
channel.BumpersEnabled,
new BumperSettingsDto(channel.BumperFont, channel.BumperSelection),
bumperTemplates,
channel.FillerAssetId
channel.FillerAssetId,
new ViewerSettingsDto(
channel.LogoImageId,
channel.LogoCorner,
channel.LogoOpacity,
channel.ShowClock,
channel.AnalogFilterStrength
)
)
);
}
@@ -0,0 +1,29 @@
using FluentValidation;
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.UpdateViewerSettings;
/// <summary>
/// Оверлеи и аналоговый фильтр канала (см. 6.8). Отдельной командой, а не в общих настройках:
/// это про то, как канал выглядит у зрителя, и правится другими руками и в другой момент.
/// </summary>
public sealed record UpdateViewerSettingsCommand(
Guid ChannelId,
Guid? LogoImageId,
LogoCorner LogoCorner,
double LogoOpacity,
bool ShowClock,
double AnalogFilterStrength
) : ICommand<Result>;
public sealed class UpdateViewerSettingsCommandValidator
: AbstractValidator<UpdateViewerSettingsCommand>
{
public UpdateViewerSettingsCommandValidator()
{
RuleFor(x => x.LogoOpacity).InclusiveBetween(0.0, 1.0);
RuleFor(x => x.AnalogFilterStrength).InclusiveBetween(0.0, 1.0);
}
}
@@ -0,0 +1,38 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.UpdateViewerSettings;
public sealed class UpdateViewerSettingsCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateViewerSettingsCommand, Result>
{
public async Task<Result> Handle(
UpdateViewerSettingsCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels.FirstOrDefaultAsync(
c => c.Id == command.ChannelId,
cancellationToken
);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
if (
command.LogoImageId is { } imageId
&& !await dbContext.Images.AnyAsync(i => i.Id == imageId, cancellationToken)
)
return Result.Failure(ChannelErrors.AssetNotFound);
channel.UpdateViewerSettings(
command.LogoImageId,
command.LogoCorner,
command.LogoOpacity,
command.ShowClock,
command.AnalogFilterStrength
);
return Result.Success();
}
}
@@ -1,16 +1,21 @@
namespace TeleWave.Application.Common.Interfaces;
/// <summary>Доступ к глобальным настройкам сайта (key-value), скрывающий хранилище от хендлеров.</summary>
public interface ISiteSettings
{
Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken);
/// <summary>Пишет значение в контекст (сохранение — за UnitOfWorkBehavior вызывающей команды).</summary>
Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken);
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
/// (напр. «rus,eng»); пусто — без предпочтения.</summary>
Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken);
Task SetPreferredAudioLanguagesAsync(string value, CancellationToken cancellationToken);
}
namespace TeleWave.Application.Common.Interfaces;
/// <summary>Доступ к глобальным настройкам сайта (key-value), скрывающий хранилище от хендлеров.</summary>
public interface ISiteSettings
{
Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken);
/// <summary>Пишет значение в контекст (сохранение — за UnitOfWorkBehavior вызывающей команды).</summary>
Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken);
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
/// (напр. «rus,eng»); пусто — без предпочтения.</summary>
Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken);
Task SetPreferredAudioLanguagesAsync(string value, CancellationToken cancellationToken);
/// <summary>Разрешено ли переключение каналов по номерам (см. 6.8).</summary>
Task<bool> AreChannelNumbersEnabledAsync(CancellationToken cancellationToken);
Task SetChannelNumbersEnabledAsync(bool enabled, CancellationToken cancellationToken);
}
@@ -0,0 +1,28 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Planning.Diff;
/// <summary>
/// Что изменится в эфире, если применить правила сейчас (см. 6.6). Прогон сухой: ни лента,
/// ни курсоры слотов не трогаются.
/// </summary>
public sealed record PreviewApplyDiffQuery(Guid ChannelId) : IQuery<Result<ScheduleDiffDto>>;
/// <summary>Одно расхождение: что стоит в эфире сейчас и что встанет после применения.</summary>
public sealed record ScheduleChangeDto(
DateTimeOffset StartsAtUtc,
string? Before,
string? After,
/// <summary>Попадает ли в ближайшие сутки — самая частая причина случайного ущерба.</summary>
bool Soon
);
public sealed record ScheduleDiffDto(
/// <summary>Сколько будущих записей затрагивает пересборка.</summary>
int Total,
int Changed,
int ChangedSoon,
/// <summary>Первые расхождения; сколько осталось за списком, видно по <c>Changed</c>.</summary>
IReadOnlyList<ScheduleChangeDto> Changes
);
@@ -0,0 +1,143 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Diff;
public sealed class PreviewApplyDiffQueryHandler(
IAppDbContext dbContext,
GridScheduleGenerator generator,
IOptions<SchedulerOptions> options
) : IQueryHandler<PreviewApplyDiffQuery, Result<ScheduleDiffDto>>
{
/// <summary>Сколько расхождений отдаём списком: остальное всё равно не читают, а счётчик честный.</summary>
private const int MaxChanges = 100;
public async Task<Result<ScheduleDiffDto>> Handle(
PreviewApplyDiffQuery query,
CancellationToken cancellationToken
)
{
var now = DateTimeOffset.UtcNow;
var future = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e => e.ChannelId == query.ChannelId && e.StartsAtUtc >= now)
.OrderBy(e => e.StartsAtUtc)
.ToListAsync(cancellationToken);
// Применение не трогает идущую сейчас запись, поэтому пересборка начинается с её конца —
// считать иначе значило бы показать диф, которого не будет.
var currentEnd = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e => e.ChannelId == query.ChannelId && e.StartsAtUtc < now && e.EndsAtUtc > now)
.MaxAsync(e => (DateTimeOffset?)e.EndsAtUtc, cancellationToken);
var result = await generator.PreviewAsync(
query.ChannelId,
currentEnd ?? now,
Math.Max(1, options.Value.HorizonDays),
cancellationToken
);
if (result is null)
return Result.Failure<ScheduleDiffDto>(ChannelErrors.TemplateNotFound);
var showNames = await LoadShowNamesAsync(future, result.Items, cancellationToken);
var soonUntil = now.AddHours(24);
var before = future.Select(e => Describe(e, showNames)).ToList();
var after = result.Items.Select(i => Describe(i, showNames)).ToList();
var changes = new List<ScheduleChangeDto>();
var changed = 0;
var changedSoon = 0;
for (var i = 0; i < Math.Max(before.Count, after.Count); i++)
{
var oldItem = i < before.Count ? before[i] : null;
var newItem = i < after.Count ? after[i] : null;
if (oldItem?.Label == newItem?.Label && oldItem?.StartsAtUtc == newItem?.StartsAtUtc)
continue;
var startsAt = newItem?.StartsAtUtc ?? oldItem!.StartsAtUtc;
var soon = startsAt < soonUntil;
changed++;
if (soon)
changedSoon++;
if (changes.Count < MaxChanges)
changes.Add(new ScheduleChangeDto(startsAt, oldItem?.Label, newItem?.Label, soon));
}
return Result.Success(new ScheduleDiffDto(future.Count, changed, changedSoon, changes));
}
private sealed record Described(DateTimeOffset StartsAtUtc, string Label);
private static Described Describe(ScheduleEntry entry, IReadOnlyDictionary<Guid, string> names) =>
new(
entry.StartsAtUtc,
entry.Kind switch
{
ScheduleEntryKind.Program => Title(entry.ShowId, entry.EpisodeIndex, names),
ScheduleEntryKind.Ad => $"реклама {Seconds(entry.EndsAtUtc - entry.StartsAtUtc)}",
ScheduleEntryKind.Bumper => "заставка",
ScheduleEntryKind.SignOff => "конец вещания",
_ => "фон",
}
);
private static Described Describe(PlannedItem item, IReadOnlyDictionary<Guid, string> names) =>
new(
item.StartsAtUtc,
item.Kind switch
{
PlannedItemKind.Program => Title(item.ShowId, item.UnitIndex, names),
PlannedItemKind.Ad or PlannedItemKind.Promo =>
$"реклама {Seconds(item.EndsAtUtc - item.StartsAtUtc)}",
PlannedItemKind.Bumper => "заставка",
PlannedItemKind.SignOff => "конец вещания",
_ => "фон",
}
);
private static string Title(
Guid? showId,
int? unitIndex,
IReadOnlyDictionary<Guid, string> names
)
{
var name = showId is { } id && names.TryGetValue(id, out var value) ? value : "—";
return unitIndex is { } index ? $"{name} · {index + 1}" : name;
}
private static string Seconds(TimeSpan duration) => $"{(int)duration.TotalSeconds} с";
private async Task<Dictionary<Guid, string>> LoadShowNamesAsync(
IReadOnlyList<ScheduleEntry> future,
IReadOnlyList<PlannedItem> planned,
CancellationToken cancellationToken
)
{
var ids = future
.Select(e => e.ShowId)
.Concat(planned.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);
}
}
@@ -31,6 +31,7 @@ public sealed class GridScheduleGenerator(
IAppDbContext dbContext,
GroupExpander expander,
BumperResolver bumperResolver,
PostCheckRunner postChecks,
IRandomSource random,
IOptions<SchedulerOptions> options,
IOptions<StreamingOptions> streamingOptions
@@ -105,6 +106,16 @@ public sealed class GridScheduleGenerator(
);
var result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random);
// Пост-проверки считаются по готовой ленте и только предупреждают — переигрывать что-либо
// по их итогу мы намеренно не будем (см. 3.8).
var postWarnings = await postChecks.RunAsync(
result.Items,
PlanningRules.FromJson(template.RulesJson),
channel.UtcOffsetMinutes,
channel.DayStartTime,
cancellationToken
);
// Заставки резолвятся после сборки ленты: пара соседей известна только теперь.
var bumperAssets = await bumperResolver.ResolveAsync(
channel,
@@ -143,7 +154,7 @@ public sealed class GridScheduleGenerator(
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return new GenerationReport(added, result.Warnings);
return new GenerationReport(added, [.. result.Warnings, .. postWarnings]);
}
/// <summary>
@@ -176,7 +187,17 @@ public sealed class GridScheduleGenerator(
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);
var result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random);
var postWarnings = await postChecks.RunAsync(
result.Items,
PlanningRules.FromJson(template.RulesJson),
channel.UtcOffsetMinutes,
channel.DayStartTime,
cancellationToken
);
return result with { Warnings = [.. result.Warnings, .. postWarnings] };
}
/// <summary>
@@ -258,7 +279,19 @@ public sealed class GridScheduleGenerator(
.Distinct()
.ToList();
var elementsByGroup = await expander.ExpandAsync(groupIds, channel.Id, cancellationToken);
// Правила канала: детское время резолвится на каждый слот по его времени, а под потолок
// повторов нужна история показов — её глубина и задаётся окном правила.
var rules = PlanningRules.FromJson(template.RulesJson);
var repeatLimit = rules?.MaxRepeatsInWindow is { WindowDays: > 0, Max: > 0 } limit
? new RepeatLimit(limit.WindowDays, limit.Max)
: null;
var elementsByGroup = await expander.ExpandAsync(
groupIds,
channel.Id,
cancellationToken,
repeatLimit is null ? null : startUtc.AddDays(-repeatLimit.WindowDays)
);
var slotIds = scheduled.Select(s => s.Slot.Id).Distinct().ToList();
var states = await dbContext
@@ -308,7 +341,13 @@ public sealed class GridScheduleGenerator(
junctions,
elementsByGroup,
channel
)
),
rules?.AudienceAt(
TimeOnly.FromDateTime(
item.StartUtc.ToOffset(TimeSpan.FromMinutes(channel.UtcOffsetMinutes)).DateTime
)
),
repeatLimit
)
);
}
@@ -1,171 +1,232 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning;
/// <summary>
/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал —
/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается),
/// фильм — в одну единицу.
///
/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё
/// обрабатывается, значит получить дыру в раздаче.
/// </summary>
public sealed class GroupExpander(IAppDbContext dbContext)
{
public async Task<IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>>> ExpandAsync(
IReadOnlyCollection<Guid> groupIds,
Guid channelId,
CancellationToken cancellationToken
)
{
var result = new Dictionary<Guid, IReadOnlyList<PlanningElement>>();
if (groupIds.Count == 0)
return result;
var groups = await dbContext
.Groups.AsNoTracking()
.Include(g => g.Items)
.Where(g => groupIds.Contains(g.Id))
.ToListAsync(cancellationToken);
var collectionIds = groups
.SelectMany(g => g.Items)
.Where(i => i.ElementKind == GroupElementKind.Collection)
.Select(i => i.ElementId)
.Distinct()
.ToList();
var collectionParts = await dbContext
.CollectionItems.AsNoTracking()
.Where(i => collectionIds.Contains(i.CollectionId))
.OrderBy(i => i.Position)
.Select(i => new { i.CollectionId, i.ShowId })
.ToListAsync(cancellationToken);
var showIds = groups
.SelectMany(g => g.Items)
.Where(i => i.ElementKind == GroupElementKind.Show)
.Select(i => i.ElementId)
.Concat(collectionParts.Select(p => p.ShowId))
.Distinct()
.ToList();
var episodes = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
Episodes = s
.Episodes.OrderBy(e => e.Position)
.Select(e => new { e.MediaAssetId, e.Position })
.ToList(),
})
.ToDictionaryAsync(s => s.Id, cancellationToken);
var assetIds = episodes.Values.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId)).Distinct().ToList();
var durations = await dbContext
.MediaAssets.AsNoTracking()
.Where(a =>
assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null
)
.Select(a => new { a.Id, a.Duration })
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken);
List<PlanningUnit> UnitsOfShow(Guid showId)
{
if (!episodes.TryGetValue(showId, out var show))
return [];
return show
.Episodes.Where(e => durations.ContainsKey(e.MediaAssetId))
.Select((e, index) => new PlanningUnit(
e.MediaAssetId,
durations[e.MediaAssetId],
showId,
index
))
.ToList();
}
foreach (var group in groups)
{
var elements = new List<PlanningElement>();
foreach (var item in group.Items.OrderBy(i => i.Position))
{
if (item.ElementKind == GroupElementKind.Show)
{
elements.Add(
new PlanningElement(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
UnitsOfShow(item.ElementId),
lastPlayed.TryGetValue(item.ElementId, out var played) ? played : null
)
);
continue;
}
var partShowIds = collectionParts
.Where(p => p.CollectionId == item.ElementId)
.Select(p => p.ShowId)
.ToList();
var units = partShowIds.SelectMany(UnitsOfShow).ToList();
elements.Add(
new PlanningElement(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
units,
// Остывание коллекции считается по самой свежей из её частей: показ любой
// из них означает, что франшиза недавно была в эфире.
partShowIds
.Select(id => lastPlayed.TryGetValue(id, out var p) ? p : (DateTimeOffset?)null)
.Where(p => p is not null)
.DefaultIfEmpty(null)
.Max()
)
);
}
result[group.Id] = elements;
}
return result;
}
/// <summary>
/// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
/// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
/// остывание среди правил.
/// </summary>
private async Task<Dictionary<Guid, DateTimeOffset>> LoadLastPlayedAsync(
Guid channelId,
IReadOnlyCollection<Guid> showIds,
CancellationToken cancellationToken
) =>
await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channelId
&& e.Kind == ScheduleEntryKind.Program
&& e.ShowId != null
&& showIds.Contains(e.ShowId!.Value)
)
.GroupBy(e => e.ShowId!.Value)
.Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) })
.ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken);
}
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning;
/// <summary>
/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал —
/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается),
/// фильм — в одну единицу.
///
/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё
/// обрабатывается, значит получить дыру в раздаче.
/// </summary>
public sealed class GroupExpander(IAppDbContext dbContext)
{
/// <param name="historyFrom">С какого момента нужна история показов для потолка повторов;
/// null — история не нужна и не загружается.</param>
public async Task<IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>>> ExpandAsync(
IReadOnlyCollection<Guid> groupIds,
Guid channelId,
CancellationToken cancellationToken,
DateTimeOffset? historyFrom = null
)
{
var result = new Dictionary<Guid, IReadOnlyList<PlanningElement>>();
if (groupIds.Count == 0)
return result;
var groups = await dbContext
.Groups.AsNoTracking()
.Include(g => g.Items)
.Where(g => groupIds.Contains(g.Id))
.ToListAsync(cancellationToken);
var collectionIds = groups
.SelectMany(g => g.Items)
.Where(i => i.ElementKind == GroupElementKind.Collection)
.Select(i => i.ElementId)
.Distinct()
.ToList();
var collectionParts = await dbContext
.CollectionItems.AsNoTracking()
.Where(i => collectionIds.Contains(i.CollectionId))
.OrderBy(i => i.Position)
.Select(i => new { i.CollectionId, i.ShowId })
.ToListAsync(cancellationToken);
var showIds = groups
.SelectMany(g => g.Items)
.Where(i => i.ElementKind == GroupElementKind.Show)
.Select(i => i.ElementId)
.Concat(collectionParts.Select(p => p.ShowId))
.Distinct()
.ToList();
var episodes = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
s.Audience,
Episodes = s
.Episodes.OrderBy(e => e.Position)
.Select(e => new { e.MediaAssetId, e.Position })
.ToList(),
})
.ToDictionaryAsync(s => s.Id, cancellationToken);
var assetIds = episodes.Values.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId)).Distinct().ToList();
var durations = await dbContext
.MediaAssets.AsNoTracking()
.Where(a =>
assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null
)
.Select(a => new { a.Id, a.Duration })
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken);
var recentPlays = await LoadRecentPlaysAsync(
channelId,
showIds,
historyFrom,
cancellationToken
);
List<PlanningUnit> UnitsOfShow(Guid showId)
{
if (!episodes.TryGetValue(showId, out var show))
return [];
return show
.Episodes.Where(e => durations.ContainsKey(e.MediaAssetId))
.Select((e, index) => new PlanningUnit(
e.MediaAssetId,
durations[e.MediaAssetId],
showId,
index
))
.ToList();
}
foreach (var group in groups)
{
var elements = new List<PlanningElement>();
foreach (var item in group.Items.OrderBy(i => i.Position))
{
if (item.ElementKind == GroupElementKind.Show)
{
elements.Add(
new PlanningElement(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
UnitsOfShow(item.ElementId),
lastPlayed.TryGetValue(item.ElementId, out var played) ? played : null,
episodes.TryGetValue(item.ElementId, out var show) ? show.Audience : null,
recentPlays.TryGetValue(item.ElementId, out var plays) ? plays : null
)
);
continue;
}
var partShowIds = collectionParts
.Where(p => p.CollectionId == item.ElementId)
.Select(p => p.ShowId)
.ToList();
var units = partShowIds.SelectMany(UnitsOfShow).ToList();
elements.Add(
new PlanningElement(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
units,
// Остывание коллекции считается по самой свежей из её частей: показ любой
// из них означает, что франшиза недавно была в эфире.
partShowIds
.Select(id => lastPlayed.TryGetValue(id, out var p) ? p : (DateTimeOffset?)null)
.Where(p => p is not null)
.DefaultIfEmpty(null)
.Max(),
// Категория коллекции — строжайшая среди частей: франшиза идёт целиком,
// и одна взрослая часть делает взрослой всю.
partShowIds
.Select(id =>
episodes.TryGetValue(id, out var part) ? part.Audience : (ShowAudience?)null
)
.Where(a => a is not null)
.DefaultIfEmpty(null)
.Max(),
partShowIds
.SelectMany(id =>
recentPlays.TryGetValue(id, out var p) ? p : []
)
.ToList()
)
);
}
result[group.Id] = elements;
}
return result;
}
/// <summary>
/// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
/// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
/// остывание среди правил.
/// </summary>
private async Task<Dictionary<Guid, DateTimeOffset>> LoadLastPlayedAsync(
Guid channelId,
IReadOnlyCollection<Guid> showIds,
CancellationToken cancellationToken
) =>
await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channelId
&& e.Kind == ScheduleEntryKind.Program
&& e.ShowId != null
&& showIds.Contains(e.ShowId!.Value)
)
.GroupBy(e => e.ShowId!.Value)
.Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) })
.ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken);
/// <summary>
/// Старты показов за окно потолка повторов. Загружается только когда правило задано: без него
/// это лишние сотни строк на каждый прогон.
/// </summary>
private async Task<Dictionary<Guid, IReadOnlyList<DateTimeOffset>>> LoadRecentPlaysAsync(
Guid channelId,
IReadOnlyCollection<Guid> showIds,
DateTimeOffset? from,
CancellationToken cancellationToken
)
{
if (from is not { } since || showIds.Count == 0)
return [];
var plays = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channelId
&& e.Kind == ScheduleEntryKind.Program
&& e.ShowId != null
&& showIds.Contains(e.ShowId!.Value)
&& e.StartsAtUtc >= since
)
.Select(e => new { ShowId = e.ShowId!.Value, e.StartsAtUtc })
.ToListAsync(cancellationToken);
return plays
.GroupBy(p => p.ShowId)
.ToDictionary(
g => g.Key,
g => (IReadOnlyList<DateTimeOffset>)g.Select(p => p.StartsAtUtc).ToList()
);
}
}
@@ -0,0 +1,177 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Templates;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning;
/// <summary>
/// Пост-проверки по готовой ленте (см. 3.8, 5.2). Только предупреждения: пересборки слота при
/// нарушении нет намеренно — она сделала бы результат зависимым от порядка проверок и обесценила
/// бы трейс «почему это здесь».
/// </summary>
public sealed class PostCheckRunner(IAppDbContext dbContext)
{
public async Task<IReadOnlyList<PlanningWarning>> RunAsync(
IReadOnlyList<PlannedItem> items,
PlanningRules? rules,
int utcOffsetMinutes,
TimeOnly dayStartTime,
CancellationToken cancellationToken
)
{
if (rules is null || items.Count == 0)
return [];
var warnings = new List<PlanningWarning>();
var offset = TimeSpan.FromMinutes(utcOffsetMinutes);
if (rules.MaxBreakMinutesPerHour is { } breakCap and > 0)
warnings.AddRange(CheckBreaks(items, offset, breakCap));
if (rules.MaxFallbackSharePercent is { } fallbackCap and > 0)
warnings.AddRange(CheckFallbackShare(items, fallbackCap));
if (rules.MaxGenreSharePercent is { } genreCap and > 0)
warnings.AddRange(
await CheckGenreShareAsync(items, offset, dayStartTime, genreCap, cancellationToken)
);
return warnings;
}
private static IEnumerable<PlanningWarning> CheckBreaks(
IReadOnlyList<PlannedItem> items,
TimeSpan offset,
int capMinutes
)
{
var byHour = items
.Where(IsBreak)
.GroupBy(i => Truncate(i.StartsAtUtc + offset, TimeSpan.FromHours(1)))
.Select(g => new
{
Hour = g.Key,
Minutes = g.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes),
})
.Where(x => x.Minutes > capMinutes)
.OrderBy(x => x.Hour);
foreach (var hour in byHour)
yield return new PlanningWarning(
PlanningWarningKind.BreakLimitExceeded,
null,
$"{hour.Hour:dd.MM HH:mm} — врезок {hour.Minutes:0} мин при потолке {capMinutes}."
);
}
private static IEnumerable<PlanningWarning> CheckFallbackShare(
IReadOnlyList<PlannedItem> items,
int capPercent
)
{
var total = items.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes);
if (total <= 0)
yield break;
// Конец вещания в долю фона не входит: это осознанная настройка, а не нехватка контента.
var fallback = items
.Where(i => i.Kind == PlannedItemKind.Fallback)
.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes);
var share = fallback / total * 100;
if (share > capPercent)
yield return new PlanningWarning(
PlanningWarningKind.FallbackShareExceeded,
null,
$"Фон занял {share:0}% эфира при норме {capPercent}% — контента не хватает."
);
}
/// <summary>
/// Доля жанра за вещательные сутки. Считается по основному жанру шоу: у контента их бывает
/// несколько, но «доля боевиков за день» осмысленна только по одному, иначе сумма долей
/// перевалит за сто процентов и порог потеряет смысл.
/// </summary>
private async Task<IReadOnlyList<PlanningWarning>> CheckGenreShareAsync(
IReadOnlyList<PlannedItem> items,
TimeSpan offset,
TimeOnly dayStartTime,
int capPercent,
CancellationToken cancellationToken
)
{
var showIds = items
.Where(i => i.Kind == PlannedItemKind.Program && i.ShowId is not null)
.Select(i => i.ShowId!.Value)
.Distinct()
.ToList();
if (showIds.Count == 0)
return [];
var primaryGenre = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
GenreId = s.Genres.Where(g => g.IsPrimary).Select(g => (Guid?)g.GenreId).FirstOrDefault(),
})
.Where(s => s.GenreId != null)
.ToDictionaryAsync(s => s.Id, s => s.GenreId!.Value, cancellationToken);
if (primaryGenre.Count == 0)
return [];
var genreNames = await dbContext
.Genres.AsNoTracking()
.Where(g => primaryGenre.Values.Contains(g.Id))
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
var warnings = new List<PlanningWarning>();
var days = items
.Where(i => i.Kind == PlannedItemKind.Program && i.ShowId is not null)
.GroupBy(i => BroadcastDate(i.StartsAtUtc + offset, dayStartTime));
foreach (var day in days)
{
var total = day.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes);
if (total <= 0)
continue;
var byGenre = day.Where(i => primaryGenre.ContainsKey(i.ShowId!.Value))
.GroupBy(i => primaryGenre[i.ShowId!.Value])
.Select(g => new
{
GenreId = g.Key,
Minutes = g.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes),
})
.Where(g => g.Minutes / total * 100 > capPercent);
foreach (var genre in byGenre)
warnings.Add(
new PlanningWarning(
PlanningWarningKind.GenreShareExceeded,
null,
$"{day.Key:dd.MM} — «{(genreNames.TryGetValue(genre.GenreId, out var name) ? name : genre.GenreId)}» "
+ $"занял {genre.Minutes / total * 100:0}% суток при норме {capPercent}%."
)
);
}
return warnings;
}
private static bool IsBreak(PlannedItem item) =>
item.Kind is PlannedItemKind.Ad or PlannedItemKind.Promo or PlannedItemKind.Bumper;
private static DateTimeOffset Truncate(DateTimeOffset value, TimeSpan step) =>
value.AddTicks(-(value.Ticks % step.Ticks));
/// <summary>Вещательные сутки момента: ночь до <paramref name="dayStartTime"/> — вчерашний день.</summary>
private static DateOnly BroadcastDate(DateTimeOffset channelTime, TimeOnly dayStartTime) =>
DateOnly.FromDateTime(
TimeOnly.FromDateTime(channelTime.DateTime) >= dayStartTime
? channelTime.Date
: channelTime.Date.AddDays(-1)
);
}
@@ -0,0 +1,37 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
/// <summary>
/// Цепочка происхождения записи — «почему это здесь» (см. 6.5). Трейс пишется в момент генерации
/// и хранится в самой записи: восстановить его потом невозможно, состав групп и правила меняются.
/// </summary>
public sealed record GetEntryTraceQuery(Guid EntryId) : IQuery<Result<EntryTraceDto>>;
public sealed record EntryTraceDto(
Guid EntryId,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
string? ShowName,
int? EpisodeIndex,
/// <summary>Слой и слот, из которых выросла запись; null — трейс не писался (старая запись).</summary>
string? LayerName,
int? LayerPriority,
string? SlotTitle,
SlotKind? SlotKind,
int? SlotWeekday,
TimeOnly? SlotTargetStart,
int? SlotDurationMinutes,
string? GroupName,
int? GroupItemCount,
SlotStrategyKind? Strategy,
int? CooldownDays,
/// <summary>Сколько кандидатов осталось после остывания (null — выбор шёл без него).</summary>
int? CandidatesAfterCooldown,
int DriftMinutes,
bool Snapped,
string? JunctionName
);
@@ -0,0 +1,141 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Templates;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetEntryTraceQuery, Result<EntryTraceDto>>
{
/// <summary>Те же настройки, что при записи трейса генератором.</summary>
private static readonly JsonSerializerOptions TraceJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
};
public async Task<Result<EntryTraceDto>> Handle(
GetEntryTraceQuery query,
CancellationToken cancellationToken
)
{
var entry = await dbContext
.ScheduleEntries.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == query.EntryId, cancellationToken);
if (entry is null)
return Result.Failure<EntryTraceDto>(ChannelErrors.NotFound);
var showName =
entry.ShowId is { } showId
? await dbContext
.Shows.AsNoTracking()
.Where(s => s.Id == showId)
.Select(s => s.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var trace = Parse(entry.TraceJson);
if (trace is null)
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
0,
false,
null
)
);
// Слот мог быть удалён или изменён после генерации — трейс от этого не портится, просто
// часть подписей окажется пустой.
var slot = trace.SlotId is { } slotId
? await dbContext.Slots.AsNoTracking().FirstOrDefaultAsync(s => s.Id == slotId, cancellationToken)
: null;
var layer = slot is null
? null
: await dbContext
.GridLayers.AsNoTracking()
.FirstOrDefaultAsync(l => l.Id == slot.LayerId, cancellationToken);
var group = slot?.GroupId is { } groupId
? await dbContext
.Groups.AsNoTracking()
.Where(g => g.Id == groupId)
.Select(g => new { g.Name, g.ItemCount })
.FirstOrDefaultAsync(cancellationToken)
: null;
var strategy = SlotStrategy.FromJson(slot?.StrategyJson);
var junctionId = slot?.JunctionAfterId ?? slot?.JunctionBetweenId;
var junctionName = junctionId is { } id
? await dbContext
.JunctionTemplates.AsNoTracking()
.Where(j => j.Id == id)
.Select(j => j.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
layer?.Name,
layer?.Priority,
slot?.Title,
trace.SlotKind,
slot?.Weekday,
slot?.TargetStart,
slot?.TargetDurationMinutes,
group?.Name,
group?.ItemCount,
trace.Strategy,
strategy?.CooldownDays,
trace.CandidatesAfterCooldown,
trace.DriftMinutes,
trace.Snapped,
junctionName
)
);
}
private static PlanTrace? Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<PlanTrace>(json, TraceJsonOptions);
}
catch (JsonException)
{
return null;
}
}
}
@@ -0,0 +1,31 @@
using FluentValidation;
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.CopyTemplate;
/// <summary>
/// Копирует сетку канала на другой канал: слои, слоты, стыки и правила. Группы не копируются —
/// они общие для всех каналов. Прежний шаблон канала-приёмника заменяется целиком.
/// </summary>
public sealed record CopyTemplateCommand(Guid SourceChannelId, Guid TargetChannelId)
: ICommand<Result<CopyTemplateResultDto>>;
/// <summary>
/// Что скопировалось. <paramref name="DroppedBumperRefs"/> — врезки-заставки, для которых на канале
/// -приёмнике не нашлось блока с таким же именем: ссылка снята, врезку надо донастроить руками.
/// </summary>
public sealed record CopyTemplateResultDto(
int Layers,
int Slots,
int Junctions,
int DroppedBumperRefs
);
public sealed class CopyTemplateCommandValidator : AbstractValidator<CopyTemplateCommand>
{
public CopyTemplateCommandValidator() =>
RuleFor(x => x.TargetChannelId)
.NotEqual(x => x.SourceChannelId)
.WithMessage("Копировать сетку саму на себя бессмысленно.");
}
@@ -0,0 +1,165 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.CopyTemplate;
public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CopyTemplateCommand, Result<CopyTemplateResultDto>>
{
public async Task<Result<CopyTemplateResultDto>> Handle(
CopyTemplateCommand command,
CancellationToken cancellationToken
)
{
var target = await dbContext
.Channels.Include(c => c.BumperTemplates)
.FirstOrDefaultAsync(c => c.Id == command.TargetChannelId, cancellationToken);
if (target is null)
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.NotFound);
var source = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.FirstOrDefaultAsync(t => t.ChannelId == command.SourceChannelId, cancellationToken);
if (source is null)
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.TemplateNotFound);
var sourceJunctions = await dbContext
.JunctionTemplates.AsNoTracking()
.Include(j => j.Elements)
.Where(j => j.ChannelId == command.SourceChannelId)
.ToListAsync(cancellationToken);
// Заставки живут на канале и на диске, поэтому не копируются: врезка ищет блок с таким же
// именем у приёмника, а не найдя — остаётся без ссылки, и это возвращается в отчёте.
var bumperByName = target
.BumperTemplates.GroupBy(t => t.Name)
.ToDictionary(g => g.Key, g => g.First().Id);
var sourceBumperNames = await dbContext
.Channels.AsNoTracking()
.Where(c => c.Id == command.SourceChannelId)
.SelectMany(c => c.BumperTemplates)
.Select(t => new { t.Id, t.Name })
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
var junctionMap = new Dictionary<Guid, Guid>();
var droppedBumperRefs = 0;
foreach (var junction in sourceJunctions)
{
var copy = JunctionTemplate.Create(target.Id, junction.Name);
junctionMap[junction.Id] = copy.Id;
foreach (var element in junction.Elements.OrderBy(e => e.Position))
{
var created = copy.AddElement(element.Kind);
Guid? bumperTemplateId = null;
if (element.Kind == JunctionElementKind.Bumper)
{
if (
element.BumperTemplateId is { } sourceId
&& sourceBumperNames.TryGetValue(sourceId, out var name)
&& bumperByName.TryGetValue(name, out var mapped)
)
bumperTemplateId = mapped;
else
droppedBumperRefs++;
}
created.Update(
element.Kind,
element.GroupId,
bumperTemplateId,
element.AmountMode,
element.AmountValue,
element.IsRequired,
element.ConditionsJson
);
}
dbContext.JunctionTemplates.Add(copy);
}
// Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя,
// а «добавить поверх» дало бы кашу из пересекающихся слотов.
var existing = await dbContext
.ScheduleTemplates.Where(t => t.ChannelId == target.Id)
.ToListAsync(cancellationToken);
dbContext.ScheduleTemplates.RemoveRange(existing);
await dbContext
.JunctionTemplates.Where(j =>
j.ChannelId == target.Id && !junctionMap.Values.Contains(j.Id)
)
.ExecuteDeleteAsync(cancellationToken);
var copyTemplate = ScheduleTemplate.Create(target.Id, source.Name);
copyTemplate.SetFallbackGroup(source.FallbackGroupId);
copyTemplate.SetRules(source.RulesJson);
if (source.DefaultJunctionId is { } defaultJunction && junctionMap.TryGetValue(defaultJunction, out var mappedDefault))
copyTemplate.SetDefaultJunction(mappedDefault);
var layers = 0;
var slots = 0;
foreach (var layer in source.Layers.OrderByDescending(l => l.Priority))
{
// Фоновый слой у нового шаблона уже есть — в него переносим слоты, а не заводим второй.
var copyLayer = layer.IsBackground
? copyTemplate.Background!
: copyTemplate.AddLayer(layer.Name, layer.Priority);
copyLayer.Update(layer.Name, layer.Priority, layer.ApplicabilityJson, layer.IsEnabled);
if (!layer.IsBackground)
layers++;
foreach (var slot in layer.Slots)
{
var copySlot = copyLayer.AddSlot(
slot.Title,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Daypart,
slot.SlotKind,
slot.Weekday
);
copySlot.UpdateTiming(
slot.Weekday,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Daypart,
slot.IsAnchor,
slot.MaxDriftMinutes,
slot.SnapToMinutes
);
copySlot.UpdateContent(
slot.Title,
slot.SlotKind,
slot.GroupId,
slot.StrategyJson,
slot.RepeatSourceJson,
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
Map(slot.JunctionBetweenId, junctionMap),
Map(slot.JunctionAfterId, junctionMap)
);
slots++;
}
}
dbContext.ScheduleTemplates.Add(copyTemplate);
target.SetTemplate(copyTemplate.Id);
return Result.Success(
new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs)
);
}
private static Guid? Map(Guid? id, IReadOnlyDictionary<Guid, Guid> map) =>
id is { } value && map.TryGetValue(value, out var mapped) ? mapped : null;
}
@@ -88,6 +88,7 @@ public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext)
template.Name,
template.FallbackGroupId,
template.DefaultJunctionId,
PlanningRules.FromJson(template.RulesJson),
template.Revision,
template.AppliedRevision,
template.HasPendingChanges,
@@ -92,6 +92,7 @@ public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext)
template.Rename(command.Name);
template.SetFallbackGroup(command.FallbackGroupId);
template.SetDefaultJunction(command.DefaultJunctionId);
template.SetRules(command.Rules?.ToJson());
template.MarkChanged();
return Result.Success();
}
@@ -17,12 +17,13 @@ public sealed record UpdateLayerCommand(
public sealed record DeleteLayerCommand(Guid LayerId) : ICommand<Result>;
/// <summary>Имя шаблона и аварийная группа, играющая, когда пуст даже фоновый слой.</summary>
/// <summary>Имя шаблона, аварийная группа, стык по умолчанию и правила отбора кандидатов.</summary>
public sealed record UpdateTemplateCommand(
Guid TemplateId,
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId
Guid? DefaultJunctionId,
PlanningRules? Rules
) : ICommand<Result>;
public sealed class CreateLayerCommandValidator : AbstractValidator<CreateLayerCommand>
@@ -45,5 +46,22 @@ public sealed class UpdateLayerCommandValidator : AbstractValidator<UpdateLayerC
public sealed class UpdateTemplateCommandValidator : AbstractValidator<UpdateTemplateCommand>
{
public UpdateTemplateCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
public UpdateTemplateCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
// Год — верхняя граница окна повторов: за ним правило перестаёт что-либо значить, а история
// всё равно ограничена глубиной хранения ленты.
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.WindowDays)
.InclusiveBetween(1, 365)
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.Max)
.InclusiveBetween(1, 1000)
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
RuleForEach(x => x.Rules!.MaxAudienceByTime)
.Must(window => window.From != window.To)
.WithMessage("Окно нулевой длины ничего не ограничивает.")
.When(x => x.Rules?.MaxAudienceByTime is not null);
}
}
@@ -0,0 +1,79 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Programming.Templates;
/// <summary>
/// Окно детского времени: с <paramref name="From"/> до <paramref name="To"/> во времени канала
/// в эфир идёт только контент не строже <paramref name="MaxAudience"/>. Окно может переходить
/// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону.
/// </summary>
public sealed record AudienceWindow(TimeOnly From, TimeOnly To, ShowAudience MaxAudience)
{
public bool Contains(TimeOnly moment) =>
From <= To ? moment >= From && moment < To : moment >= From || moment < To;
}
/// <summary>
/// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают
/// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость.
///
/// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени
/// и так задаются временем, а потолок повторов осмыслен только целиком по каналу.
/// </summary>
public sealed record PlanningRules(
IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null,
/// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary>
RepeatLimitRule? MaxRepeatsInWindow = null,
// ── Пост-проверки: считаются по готовой ленте, дают предупреждения и ничего не переигрывают. ──
/// <summary>Потолок врезок в часе, минуты (null — не проверять).</summary>
int? MaxBreakMinutesPerHour = null,
/// <summary>Потолок доли одного жанра за вещательные сутки, проценты (null — не проверять).</summary>
int? MaxGenreSharePercent = null,
/// <summary>Потолок доли эфира, отданной фону, проценты (null — не проверять).</summary>
int? MaxFallbackSharePercent = null
)
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
/// <summary>Возрастной потолок в указанный момент времени канала (null — без ограничения).</summary>
public ShowAudience? AudienceAt(TimeOnly moment)
{
if (MaxAudienceByTime is not { Count: > 0 } windows)
return null;
// Пересекающиеся окна разрешаются в пользу строгого: детское время не должно
// отменяться более широким окном, случайно наложенным сверху.
ShowAudience? strictest = null;
foreach (var window in windows.Where(w => w.Contains(moment)))
strictest = strictest is { } current && current <= window.MaxAudience
? current
: window.MaxAudience;
return strictest;
}
public string ToJson() => JsonSerializer.Serialize(this, Options);
public static PlanningRules? FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<PlanningRules>(json, Options);
}
catch (JsonException)
{
return null;
}
}
}
public sealed record RepeatLimitRule(int WindowDays, int Max);
@@ -41,6 +41,8 @@ public sealed record ScheduleTemplateDto(
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
/// <summary>Правила отбора кандидатов канала: детское время и потолок повторов.</summary>
PlanningRules? Rules,
int Revision,
int AppliedRevision,
/// <summary>Есть ли правки правил, ещё не применённые к эфиру.</summary>
@@ -0,0 +1,50 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Validate;
/// <summary>
/// Проверки сетки по правилам, до генерации (см. 5.1). Ничего не считает по ленте — только по
/// шаблону и статистике групп, поэтому дёшево и вызывается на каждом открытии редактора.
/// </summary>
public sealed record ValidateTemplateQuery(Guid ChannelId)
: IQuery<Result<IReadOnlyList<TemplateIssueDto>>>;
public enum TemplateIssueKind
{
/// <summary>Слот ссылается на пустую группу — место закроет фон.</summary>
GroupEmpty = 0,
/// <summary>Позиций в группе меньше, чем выходов слота в неделю.</summary>
GroupTooSmall = 1,
/// <summary>Интервал суток не покрыт ни одним слотом, даже фоновым.</summary>
GridGap = 2,
/// <summary>Два слота одного слоя перекрываются — сыграет только первый.</summary>
SlotOverlap = 3,
/// <summary>Остывание длиннее, чем группа успевает прокрутиться.</summary>
CooldownUnreachable = 4,
/// <summary>В группе есть контент строже, чем разрешает окно детского времени.</summary>
AudienceConflict = 5,
/// <summary>Слот без группы: контент брать неоткуда.</summary>
GroupMissing = 6,
}
/// <summary>Насколько это больно: <c>Error</c> — в эфире будет фон вместо контента.</summary>
public enum TemplateIssueSeverity
{
Warning = 0,
Error = 1,
}
public sealed record TemplateIssueDto(
TemplateIssueKind Kind,
TemplateIssueSeverity Severity,
Guid? LayerId,
Guid? SlotId,
string Details
);
@@ -0,0 +1,306 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Validate;
public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
{
private const int MinutesInDay = 24 * 60;
public async Task<Result<IReadOnlyList<TemplateIssueDto>>> Handle(
ValidateTemplateQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.NotFound);
var template = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
if (template is null)
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.TemplateNotFound);
var layers = template.Layers.Where(l => l.IsEnabled).ToList();
var groupIds = layers
.SelectMany(l => l.Slots)
.Select(s => s.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var groups = await dbContext
.Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id))
.Select(g => new GroupStats(g.Id, g.Name, g.ItemCount))
.ToDictionaryAsync(g => g.Id, cancellationToken);
var rules = PlanningRules.FromJson(template.RulesJson);
var strictest = await LoadStrictestAudienceAsync(groupIds, cancellationToken);
var dayStart = channel.DayStartTime;
var issues = new List<TemplateIssueDto>();
foreach (var layer in layers)
{
issues.AddRange(FindOverlaps(layer, dayStart));
foreach (var slot in layer.Slots.Where(s => s.SlotKind == SlotKind.Content))
issues.AddRange(CheckSlot(layer, slot, groups, strictest, rules));
}
issues.AddRange(FindGaps(layers, dayStart));
return Result.Success<IReadOnlyList<TemplateIssueDto>>(issues);
}
private sealed record GroupStats(Guid Id, string Name, int ItemCount);
private static IEnumerable<TemplateIssueDto> CheckSlot(
GridLayer layer,
Slot slot,
IReadOnlyDictionary<Guid, GroupStats> groups,
IReadOnlyDictionary<Guid, ShowAudience> strictest,
PlanningRules? rules
)
{
if (slot.GroupId is not { } groupId || !groups.TryGetValue(groupId, out var group))
{
yield return new TemplateIssueDto(
TemplateIssueKind.GroupMissing,
TemplateIssueSeverity.Error,
layer.Id,
slot.Id,
$"У слота «{slot.Title}» не выбрана группа — место закроет фон."
);
yield break;
}
if (group.ItemCount == 0)
{
yield return new TemplateIssueDto(
TemplateIssueKind.GroupEmpty,
TemplateIssueSeverity.Error,
layer.Id,
slot.Id,
$"Группа «{group.Name}» пуста — слот «{slot.Title}» заполнит фон."
);
yield break;
}
var perWeek = OccurrencesPerWeek(slot);
if (group.ItemCount < perWeek)
yield return new TemplateIssueDto(
TemplateIssueKind.GroupTooSmall,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"В группе «{group.Name}» {group.ItemCount} позиций при {perWeek} выходах в неделю — "
+ "повторы пойдут чаще, чем раз в неделю."
);
// Остывание считается по числу выходов: за N дней слот выйдет N × (выходов в день) раз,
// и если это больше состава группы, отсекать будет некого.
var strategy = SlotStrategy.FromJson(slot.StrategyJson);
if (
strategy is { Type: SlotStrategyType.RandomWithCooldown, CooldownDays: > 0 }
&& strategy.CooldownDays * perWeek / 7.0 > group.ItemCount
)
yield return new TemplateIssueDto(
TemplateIssueKind.CooldownUnreachable,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"Остывание {strategy.CooldownDays} дней невыполнимо при {group.ItemCount} позициях "
+ $"в группе «{group.Name}»."
);
if (
rules?.AudienceAt(slot.TargetStart) is { } maxAudience
&& strictest.TryGetValue(groupId, out var groupAudience)
&& groupAudience > maxAudience
)
yield return new TemplateIssueDto(
TemplateIssueKind.AudienceConflict,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"В группе «{group.Name}» есть контент категории «{groupAudience}», а слот "
+ $"«{slot.Title}» стоит во времени не строже «{maxAudience}»."
);
}
/// <summary>Сколько раз слот выходит в неделю: без дня недели — каждый день.</summary>
private static int OccurrencesPerWeek(Slot slot) => slot.Weekday is null ? 7 : 1;
/// <summary>
/// Пересечения слотов внутри одного слоя. Внутри слоя приоритетов нет, поэтому сыграет первый
/// по времени, а второй молча пропадёт — это стоит показать до генерации.
/// </summary>
private static IEnumerable<TemplateIssueDto> FindOverlaps(GridLayer layer, TimeOnly dayStart)
{
var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList();
for (var i = 0; i < slots.Count; i++)
for (var j = i + 1; j < slots.Count; j++)
{
var a = slots[i];
var b = slots[j];
if (!SameDays(a, b))
continue;
var aFrom = OffsetInDay(a.TargetStart, dayStart);
var bFrom = OffsetInDay(b.TargetStart, dayStart);
if (aFrom < bFrom + b.TargetDurationMinutes && bFrom < aFrom + a.TargetDurationMinutes)
yield return new TemplateIssueDto(
TemplateIssueKind.SlotOverlap,
TemplateIssueSeverity.Warning,
layer.Id,
b.Id,
$"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»."
);
}
}
/// <summary>Слот без дня недели идёт каждый день, поэтому пересекается с любым.</summary>
private static bool SameDays(Slot a, Slot b) =>
a.Weekday is null || b.Weekday is null || a.Weekday == b.Weekday;
/// <summary>
/// Интервалы вещательных суток, не покрытые ни одним слотом. Проверяется по каждому дню недели:
/// дыра во вторник ночью не видна, если смотреть на неделю целиком.
/// </summary>
private static IEnumerable<TemplateIssueDto> FindGaps(
IReadOnlyList<GridLayer> layers,
TimeOnly dayStart
)
{
foreach (var weekday in new[] { 1, 2, 3, 4, 5, 6, 0 })
{
var intervals = layers
.SelectMany(l => l.Slots)
.Where(s => s.Weekday is null || s.Weekday == weekday)
.Select(s =>
{
var from = OffsetInDay(s.TargetStart, dayStart);
return (From: from, To: from + s.TargetDurationMinutes);
})
.OrderBy(i => i.From)
.ToList();
var cursor = 0;
foreach (var interval in intervals)
{
if (interval.From > cursor)
yield return Gap(weekday, cursor, interval.From, dayStart);
cursor = Math.Max(cursor, Math.Min(interval.To, MinutesInDay));
}
if (cursor < MinutesInDay)
yield return Gap(weekday, cursor, MinutesInDay, dayStart);
}
}
private static TemplateIssueDto Gap(int weekday, int from, int to, TimeOnly dayStart)
{
var day = new[] { "вс", "пн", "вт", "ср", "чт", "пт", "сб" }[weekday];
return new TemplateIssueDto(
TemplateIssueKind.GridGap,
TemplateIssueSeverity.Error,
null,
null,
$"Не покрыто: {day} {Clock(from, dayStart)}{Clock(to, dayStart)}."
);
}
private static string Clock(int offsetMinutes, TimeOnly dayStart)
{
var minutes = ((int)dayStart.ToTimeSpan().TotalMinutes + offsetMinutes) % MinutesInDay;
return $"{minutes / 60:00}:{minutes % 60:00}";
}
private static int OffsetInDay(TimeOnly time, TimeOnly dayStart)
{
var diff = (int)(time.ToTimeSpan() - dayStart.ToTimeSpan()).TotalMinutes;
return diff >= 0 ? diff : diff + MinutesInDay;
}
/// <summary>
/// Строжайшая категория среди позиций каждой группы: именно она конфликтует с детским временем.
/// Коллекция берётся по строжайшей части — франшиза идёт целиком.
/// </summary>
private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
IReadOnlyCollection<Guid> groupIds,
CancellationToken cancellationToken
)
{
if (groupIds.Count == 0)
return [];
var items = await dbContext
.GroupItems.AsNoTracking()
.Where(i => groupIds.Contains(i.GroupId))
.Select(i => new
{
i.GroupId,
i.ElementKind,
i.ElementId,
})
.ToListAsync(cancellationToken);
var showIds = items
.Where(i => i.ElementKind == GroupElementKind.Show)
.Select(i => i.ElementId)
.ToList();
var collectionIds = items
.Where(i => i.ElementKind == GroupElementKind.Collection)
.Select(i => i.ElementId)
.ToList();
var partsByCollection = await dbContext
.CollectionItems.AsNoTracking()
.Where(i => collectionIds.Contains(i.CollectionId))
.Select(i => new { i.CollectionId, i.ShowId })
.ToListAsync(cancellationToken);
var audiences = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id) || partsByCollection.Select(p => p.ShowId).Contains(s.Id))
.Select(s => new { s.Id, s.Audience })
.ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken);
var result = new Dictionary<Guid, ShowAudience>();
foreach (var item in items)
{
var candidates =
item.ElementKind == GroupElementKind.Show
? [item.ElementId]
: partsByCollection
.Where(p => p.CollectionId == item.ElementId)
.Select(p => p.ShowId)
.ToList();
foreach (var showId in candidates)
{
if (!audiences.TryGetValue(showId, out var audience))
continue;
if (!result.TryGetValue(item.GroupId, out var current) || audience > current)
result[item.GroupId] = audience;
}
}
return result;
}
}
@@ -1,18 +1,19 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Settings.GetSiteSettings;
public sealed class GetSiteSettingsQueryHandler(ISiteSettings siteSettings)
: IQueryHandler<GetSiteSettingsQuery, SiteSettingsDto>
{
public async Task<SiteSettingsDto> Handle(
GetSiteSettingsQuery query,
CancellationToken cancellationToken
)
{
var registrationEnabled = await siteSettings.IsRegistrationEnabledAsync(cancellationToken);
var preferredAudio = await siteSettings.GetPreferredAudioLanguagesAsync(cancellationToken);
return new SiteSettingsDto(registrationEnabled, preferredAudio);
}
}
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Settings.GetSiteSettings;
public sealed class GetSiteSettingsQueryHandler(ISiteSettings siteSettings)
: IQueryHandler<GetSiteSettingsQuery, SiteSettingsDto>
{
public async Task<SiteSettingsDto> Handle(
GetSiteSettingsQuery query,
CancellationToken cancellationToken
)
{
var registrationEnabled = await siteSettings.IsRegistrationEnabledAsync(cancellationToken);
var preferredAudio = await siteSettings.GetPreferredAudioLanguagesAsync(cancellationToken);
var channelNumbers = await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken);
return new SiteSettingsDto(registrationEnabled, preferredAudio, channelNumbers);
}
}
@@ -1,12 +1,16 @@
namespace TeleWave.Application.Settings;
/// <summary>Стабильные ключи глобальных настроек сайта в key-value хранилище (AppSetting).</summary>
public static class SettingKeys
{
/// <summary>Разрешена ли открытая регистрация пользователей (по умолчанию — нет).</summary>
public const string RegistrationEnabled = "registration.enabled";
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
/// (напр. «rus,eng»). При наличии дорожки с таким языком она выбирается первой; иначе — дефолт ffmpeg.</summary>
public const string PreferredAudioLanguages = "media.preferredAudioLanguages";
}
namespace TeleWave.Application.Settings;
/// <summary>Стабильные ключи глобальных настроек сайта в key-value хранилище (AppSetting).</summary>
public static class SettingKeys
{
/// <summary>Разрешена ли открытая регистрация пользователей (по умолчанию — нет).</summary>
public const string RegistrationEnabled = "registration.enabled";
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
/// (напр. «rus,eng»). При наличии дорожки с таким языком она выбирается первой; иначе — дефолт ffmpeg.</summary>
public const string PreferredAudioLanguages = "media.preferredAudioLanguages";
/// <summary>Разрешено ли зрителю переключать каналы по номерам, как на телевизоре (по умолчанию — нет).
/// Сетка каналов остаётся вторым способом навигации всегда.</summary>
public const string ChannelNumbersEnabled = "viewer.channelNumbersEnabled";
}
@@ -1,4 +1,9 @@
namespace TeleWave.Application.Settings;
/// <summary>Глобальные настройки сайта, управляемые администратором.</summary>
public sealed record SiteSettingsDto(bool RegistrationEnabled, string PreferredAudioLanguages);
namespace TeleWave.Application.Settings;
/// <summary>Глобальные настройки сайта, управляемые администратором.</summary>
public sealed record SiteSettingsDto(
bool RegistrationEnabled,
string PreferredAudioLanguages,
/// <summary>Переключение каналов по номерам у зрителя — сетка каналов остаётся всегда.</summary>
bool ChannelNumbersEnabled
);
@@ -1,9 +1,10 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Settings.UpdateSiteSettings;
public sealed record UpdateSiteSettingsCommand(
bool RegistrationEnabled,
string PreferredAudioLanguages
) : ICommand<Result>;
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Settings.UpdateSiteSettings;
public sealed record UpdateSiteSettingsCommand(
bool RegistrationEnabled,
string PreferredAudioLanguages,
bool ChannelNumbersEnabled
) : ICommand<Result>;
@@ -1,30 +1,34 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Settings.UpdateSiteSettings;
public sealed class UpdateSiteSettingsCommandHandler(ISiteSettings siteSettings)
: ICommandHandler<UpdateSiteSettingsCommand, Result>
{
public async Task<Result> Handle(
UpdateSiteSettingsCommand command,
CancellationToken cancellationToken
)
{
// Сохранение выполняет UnitOfWorkBehavior команды.
await siteSettings.SetRegistrationEnabledAsync(
command.RegistrationEnabled,
cancellationToken
);
// Нормализуем список языков: без пробелов, в нижнем регистре, пустые отбрасываем.
var languages = string.Join(
',',
(command.PreferredAudioLanguages ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(x => x.ToLowerInvariant())
);
await siteSettings.SetPreferredAudioLanguagesAsync(languages, cancellationToken);
return Result.Success();
}
}
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Settings.UpdateSiteSettings;
public sealed class UpdateSiteSettingsCommandHandler(ISiteSettings siteSettings)
: ICommandHandler<UpdateSiteSettingsCommand, Result>
{
public async Task<Result> Handle(
UpdateSiteSettingsCommand command,
CancellationToken cancellationToken
)
{
// Сохранение выполняет UnitOfWorkBehavior команды.
await siteSettings.SetRegistrationEnabledAsync(
command.RegistrationEnabled,
cancellationToken
);
// Нормализуем список языков: без пробелов, в нижнем регистре, пустые отбрасываем.
var languages = string.Join(
',',
(command.PreferredAudioLanguages ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(x => x.ToLowerInvariant())
);
await siteSettings.SetPreferredAudioLanguagesAsync(languages, cancellationToken);
await siteSettings.SetChannelNumbersEnabledAsync(
command.ChannelNumbersEnabled,
cancellationToken
);
return Result.Success();
}
}
@@ -1,77 +1,93 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Streaming.ListPublicChannels;
public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListPublicChannelsQuery, IReadOnlyList<PublicChannelDto>>
{
public async Task<IReadOnlyList<PublicChannelDto>> Handle(
ListPublicChannelsQuery query,
CancellationToken cancellationToken
)
{
var channels = await dbContext
.Channels.AsNoTracking()
.Where(c => c.IsEnabled)
.OrderBy(c => c.Name)
.Select(c => new
{
c.Id,
c.Slug,
c.Name,
})
.ToListAsync(cancellationToken);
if (channels.Count == 0)
return [];
var now = DateTimeOffset.UtcNow;
var channelIds = channels.Select(c => c.Id).ToList();
// Что идёт прямо сейчас на каждом канале (программа) — для постера-обложки плитки.
var currentByChannel = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
channelIds.Contains(e.ChannelId)
&& e.Kind == ScheduleEntryKind.Program
&& e.StartsAtUtc <= now
&& e.EndsAtUtc > now
&& e.ShowId != null
)
.Select(e => new { e.ChannelId, ShowId = e.ShowId!.Value })
.ToListAsync(cancellationToken);
var currentShowByChannel = currentByChannel
.GroupBy(x => x.ChannelId)
.ToDictionary(g => g.Key, g => g.First().ShowId);
var showIds = currentShowByChannel.Values.Distinct().ToList();
var shows = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
s.Name,
s.PosterImageId,
})
.ToDictionaryAsync(s => s.Id, cancellationToken);
return channels
.Select(c =>
{
var showId = currentShowByChannel.GetValueOrDefault(c.Id);
var show = showId != Guid.Empty ? shows.GetValueOrDefault(showId) : null;
return new PublicChannelDto(
c.Id,
c.Slug,
c.Name,
show is null ? null : showId,
show?.Name,
show?.PosterImageId
);
})
.ToList();
}
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Streaming.ListPublicChannels;
public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListPublicChannelsQuery, IReadOnlyList<PublicChannelDto>>
{
public async Task<IReadOnlyList<PublicChannelDto>> Handle(
ListPublicChannelsQuery query,
CancellationToken cancellationToken
)
{
var channels = await dbContext
.Channels.AsNoTracking()
.Where(c => c.IsEnabled)
// Каналы без номера — в конец: на телевизоре порядок задаёт номер, а имя лишь
// разрешает ничью между ненумерованными.
.OrderBy(c => c.Number == null)
.ThenBy(c => c.Number)
.ThenBy(c => c.Name)
.Select(c => new
{
c.Id,
c.Slug,
c.Name,
c.Number,
c.LogoImageId,
c.LogoCorner,
c.LogoOpacity,
c.ShowClock,
c.AnalogFilterStrength,
})
.ToListAsync(cancellationToken);
if (channels.Count == 0)
return [];
var now = DateTimeOffset.UtcNow;
var channelIds = channels.Select(c => c.Id).ToList();
// Что идёт прямо сейчас на каждом канале (программа) — для постера-обложки плитки.
var currentByChannel = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
channelIds.Contains(e.ChannelId)
&& e.Kind == ScheduleEntryKind.Program
&& e.StartsAtUtc <= now
&& e.EndsAtUtc > now
&& e.ShowId != null
)
.Select(e => new { e.ChannelId, ShowId = e.ShowId!.Value })
.ToListAsync(cancellationToken);
var currentShowByChannel = currentByChannel
.GroupBy(x => x.ChannelId)
.ToDictionary(g => g.Key, g => g.First().ShowId);
var showIds = currentShowByChannel.Values.Distinct().ToList();
var shows = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
s.Name,
s.PosterImageId,
})
.ToDictionaryAsync(s => s.Id, cancellationToken);
return channels
.Select(c =>
{
var showId = currentShowByChannel.GetValueOrDefault(c.Id);
var show = showId != Guid.Empty ? shows.GetValueOrDefault(showId) : null;
return new PublicChannelDto(
c.Id,
c.Slug,
c.Name,
c.Number,
show is null ? null : showId,
show?.Name,
show?.PosterImageId,
c.LogoImageId,
c.LogoCorner,
c.LogoOpacity,
c.ShowClock,
c.AnalogFilterStrength
);
})
.ToList();
}
}
@@ -1,34 +1,42 @@
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Streaming;
public sealed record PublicChannelDto(
Guid Id,
string Slug,
string Name,
Guid? CurrentShowId,
string? CurrentShowName,
Guid? CurrentShowPosterImageId
);
/// <summary>Запись публичного телегида с метаданными (без имён файлов и номеров серий).</summary>
public sealed record PublicEpgEntryDto(
ScheduleEntryKind Kind,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
Guid? ShowId,
string? ShowName,
Guid? ShowPosterImageId,
Guid? EpisodeId,
string? EpisodeTitle,
string? EpisodeOverview,
Guid? EpisodeStillImageId
);
public sealed record LiveSegmentDto(Guid AssetId, int LocalIndex, bool Discontinuity);
public sealed record LivePlaylistDto(
long MediaSequence,
int TargetDuration,
IReadOnlyList<LiveSegmentDto> Segments
);
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Streaming;
public sealed record PublicChannelDto(
Guid Id,
string Slug,
string Name,
/// <summary>Номер канала для переключения по номерам или null, если не задан.</summary>
int? Number,
Guid? CurrentShowId,
string? CurrentShowName,
Guid? CurrentShowPosterImageId,
/// <summary>Что рисовать поверх картинки — оверлеи считает клиент, ffmpeg их не касается.</summary>
Guid? LogoImageId,
LogoCorner LogoCorner,
double LogoOpacity,
bool ShowClock,
double AnalogFilterStrength
);
/// <summary>Запись публичного телегида с метаданными (без имён файлов и номеров серий).</summary>
public sealed record PublicEpgEntryDto(
ScheduleEntryKind Kind,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
Guid? ShowId,
string? ShowName,
Guid? ShowPosterImageId,
Guid? EpisodeId,
string? EpisodeTitle,
string? EpisodeOverview,
Guid? EpisodeStillImageId
);
public sealed record LiveSegmentDto(Guid AssetId, int LocalIndex, bool Discontinuity);
public sealed record LivePlaylistDto(
long MediaSequence,
int TargetDuration,
IReadOnlyList<LiveSegmentDto> Segments
);
@@ -58,6 +58,23 @@ public class Channel
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
public Guid? FillerAssetId { get; private set; }
// ── Зрительская часть (см. 6.8). Всё рисуется на клиенте поверх <video>, ffmpeg не трогает,
// и всё по умолчанию выключено: канал без логотипа и без шума — законная конфигурация. ──
/// <summary>Логотип-оверлей: ссылка на реестр изображений или null (логотипа нет).</summary>
public Guid? LogoImageId { get; private set; }
public LogoCorner LogoCorner { get; private set; }
/// <summary>Прозрачность логотипа, 0..1.</summary>
public double LogoOpacity { get; private set; } = 0.8;
/// <summary>Показывать ли часы поверх картинки.</summary>
public bool ShowClock { get; private set; }
/// <summary>Сила аналогового фильтра, 0..1 (0 — выключен).</summary>
public double AnalogFilterStrength { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
/// <summary>Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.</summary>
@@ -77,6 +94,7 @@ public class Channel
BumpersEnabled = false,
BumperSelection = BumperSelection.WeightedRandom,
BumperFont = BumperFont.Sans,
LogoOpacity = 0.8,
UtcOffsetMinutes = DefaultUtcOffsetMinutes,
DayStartTime = DefaultDayStartTime,
CreatedAt = DateTimeOffset.UtcNow,
@@ -106,6 +124,22 @@ public class Channel
BumperSelection = selection;
}
/// <summary>Оверлеи и фильтр зрительской части. Всё опционально; силы зажимаются в 0..1.</summary>
public void UpdateViewerSettings(
Guid? logoImageId,
LogoCorner logoCorner,
double logoOpacity,
bool showClock,
double analogFilterStrength
)
{
LogoImageId = logoImageId;
LogoCorner = logoCorner;
LogoOpacity = Math.Clamp(logoOpacity, 0.0, 1.0);
ShowClock = showClock;
AnalogFilterStrength = Math.Clamp(analogFilterStrength, 0.0, 1.0);
}
/// <summary>Добавить блок заставки в конец списка. Возвращает созданный блок.</summary>
public BumperTemplate AddBumperTemplate(string name)
{
@@ -0,0 +1,14 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Угол экрана для логотипа канала. Настоящий вещательный логотип вжигается в картинку при
/// кодировании; для нас это означало бы перекодирование всей библиотеки при смене логотипа,
/// поэтому логотип — оверлей на клиенте, и угол ему нужен.
/// </summary>
public enum LogoCorner
{
TopLeft = 0,
TopRight = 1,
BottomLeft = 2,
BottomRight = 3,
}
@@ -22,7 +22,11 @@ public static class ElementSelector
IRandomSource random
)
{
var playable = slot.Elements.Where(e => e.Units.Count > 0).ToList();
// Возрастной потолок — жёсткое «нельзя», поэтому отсекает до любой стратегии и до курсора:
// начатый в общее время сериал не должен доигрываться в детское.
var playable = slot
.Elements.Where(e => e.Units.Count > 0 && IsAllowedByAudience(slot, e))
.ToList();
if (playable.Count == 0)
return null;
@@ -95,10 +99,14 @@ public static class ElementSelector
if (current is not null && HasUnitsLeft(slot, current))
return Continue(slot, current);
// Потолок повторов — тоже жёсткий фильтр, но применяется только здесь: в последовательной
// стратегии он выбрасывал бы очередную серию сериала и рвал порядок показа.
var withinLimit = ApplyRepeatLimit(slot, playable, moment);
var cooldown = TimeSpan.FromDays(Math.Max(0, slot.Strategy.CooldownDays));
var eligible = cooldown <= TimeSpan.Zero
? playable
: playable
? withinLimit
: withinLimit
.Where(e => e.LastPlayedUtc is not { } last || moment - last >= cooldown)
.ToList();
@@ -106,16 +114,47 @@ public static class ElementSelector
if (exhausted)
{
// Остывание отсекло всех. Либо игнорируем его на этот выход, либо берём самый давний —
// пустой эфир хуже раннего повтора в обоих случаях.
// пустой эфир хуже раннего повтора в обоих случаях. Откат идёт по уже суженному потолком
// повторов пулу: иначе более слабое правило воскрешало бы отсечённое более строгим.
eligible = slot.Strategy.IgnoreCooldownWhenExhausted
? playable
: [playable.OrderBy(e => e.LastPlayedUtc ?? DateTimeOffset.MinValue).First()];
? withinLimit
: [withinLimit.OrderBy(e => e.LastPlayedUtc ?? DateTimeOffset.MinValue).First()];
}
var picked = WeightedPick(eligible, random);
return new ElementPick(picked, 0, eligible.Count, exhausted);
}
/// <summary>
/// Проходит ли элемент по возрастному потолку слота. Категории упорядочены по строгости, поэтому
/// сравнение — обычное «не строже». Элемент без категории не отсекается: неизвестное не значит
/// «взрослое», а молчаливое выбрасывание такого контента искало бы потом весь вечер.
/// </summary>
public static bool IsAllowedByAudience(PlanningSlot slot, PlanningElement element) =>
slot.MaxAudience is not { } max || element.Audience is not { } audience || audience <= max;
/// <summary>
/// Отсекает элементы, выбранные за период чаще потолка. История — старты уже записанных показов;
/// без неё (правило не задано или лента не загружена) фильтр не работает и никого не выбрасывает.
/// </summary>
private static List<PlanningElement> ApplyRepeatLimit(
PlanningSlot slot,
List<PlanningElement> playable,
DateTimeOffset moment
)
{
if (slot.RepeatLimit is not { } limit || limit.WindowDays <= 0 || limit.Max <= 0)
return playable;
var from = moment.AddDays(-limit.WindowDays);
var filtered = playable
.Where(e => (e.RecentPlaysUtc?.Count(p => p >= from) ?? 0) < limit.Max)
.ToList();
// Все упёрлись в потолок — правило не должно оставлять слот пустым: пусть решает остывание.
return filtered.Count == 0 ? playable : filtered;
}
/// <summary>Продолжение текущего элемента с позиции курсора.</summary>
private static ElementPick Continue(PlanningSlot slot, PlanningElement element)
{
@@ -1,3 +1,5 @@
using TeleWave.Domain.Library;
namespace TeleWave.Domain.Programming.Planning;
/// <summary>
@@ -16,9 +18,16 @@ public sealed record PlanningElement(
int Weight,
int Position,
IReadOnlyList<PlanningUnit> Units,
DateTimeOffset? LastPlayedUtc = null
DateTimeOffset? LastPlayedUtc = null,
/// <summary>Категория аудитории (у коллекции — строжайшая из частей); по ней работает детское время.</summary>
ShowAudience? Audience = null,
/// <summary>Старты недавних показов в этом канале — по ним считается потолок повторов за период.</summary>
IReadOnlyList<DateTimeOffset>? RecentPlaysUtc = null
);
/// <summary>Потолок повторов: не чаще <paramref name="Max"/> раз за <paramref name="WindowDays"/> суток.</summary>
public sealed record RepeatLimit(int WindowDays, int Max);
/// <summary>Стратегия выбора элемента, приведённая к виду, понятному чистому планировщику.</summary>
public sealed record PlanningStrategy(
SlotStrategyKind Kind,
@@ -66,7 +75,11 @@ public sealed record PlanningSlot(
/// <summary>Врезки между единицами внутри блока.</summary>
PlanningJunction? JunctionBetween = null,
/// <summary>Врезки в конце блока.</summary>
PlanningJunction? JunctionAfter = null
PlanningJunction? JunctionAfter = null,
/// <summary>Возрастной потолок в это время суток (null — без ограничения). Жёсткий фильтр.</summary>
ShowAudience? MaxAudience = null,
/// <summary>Потолок повторов за период (null — без ограничения). Жёсткий фильтр.</summary>
RepeatLimit? RepeatLimit = null
)
{
public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes);
@@ -189,4 +202,18 @@ public enum PlanningWarningKind
/// <summary>Пусто даже в фоне — в ленте образуется дыра.</summary>
FallbackEmpty = 4,
/// <summary>Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата.</summary>
CandidatesFiltered = 5,
// ── Пост-проверки: считаются по готовой ленте и ничего не переигрывают (см. 3.8). ──
/// <summary>Врезок в часе больше заданного потолка.</summary>
BreakLimitExceeded = 6,
/// <summary>Доля одного жанра за сутки выше заданной.</summary>
GenreShareExceeded = 7,
/// <summary>Фон занял больше эфира, чем считается нормой.</summary>
FallbackShareExceeded = 8,
}
@@ -244,12 +244,26 @@ public static class SchedulePlanner
var pick = ElementSelector.Select(slot, cursor, random);
if (pick is null)
{
// Пустая группа и отсечённая фильтром — разные беды: во втором случае контент есть,
// но не подходит по правилам, и админу надо чинить правило, а не состав группы.
var filteredOut =
slot.Elements.Any(e => e.Units.Count > 0)
&& !slot.Elements.Any(e =>
e.Units.Count > 0 && ElementSelector.IsAllowedByAudience(slot, e)
);
warnings.Add(
new PlanningWarning(
PlanningWarningKind.SlotEmpty,
slot.SlotId,
"Слот не дал контента — место закрыл фон."
)
filteredOut
? new PlanningWarning(
PlanningWarningKind.CandidatesFiltered,
slot.SlotId,
"Возрастной потолок отсёк всех кандидатов — место закрыл фон."
)
: new PlanningWarning(
PlanningWarningKind.SlotEmpty,
slot.SlotId,
"Слот не дал контента — место закрыл фон."
)
);
return FillWithFallback(
cursor,
@@ -22,6 +22,12 @@ public class ScheduleTemplate
/// <summary>Стык по умолчанию — для слотов, у которых свой не задан.</summary>
public Guid? DefaultJunctionId { get; private set; }
/// <summary>
/// Правила отбора кандидатов (детское время, потолок повторов) в JSON. Домен их не разбирает —
/// схема живёт в Application, как и у стратегий слотов и условий стыка.
/// </summary>
public string? RulesJson { get; private set; }
/// <summary>Номер правки правил; входит в кэш-ключи и историю.</summary>
public int Revision { get; private set; }
@@ -63,6 +69,9 @@ public class ScheduleTemplate
public void SetDefaultJunction(Guid? junctionId) => DefaultJunctionId = junctionId;
public void SetRules(string? rulesJson) =>
RulesJson = string.IsNullOrWhiteSpace(rulesJson) ? null : rulesJson;
/// <summary>Отметить, что правила изменились — эфир пойдёт по старым до применения.</summary>
public void MarkChanged() => Revision++;
@@ -120,6 +120,7 @@ public static class DependencyInjection
services.AddScoped<SlotWriter>();
services.AddScoped<GroupExpander>();
services.AddScoped<BumperResolver>();
services.AddScoped<PostCheckRunner>();
services.AddScoped<GridScheduleGenerator>();
AddMedia(services, configuration);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class TemplatePlanningRules : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "RulesJson",
table: "ScheduleTemplates",
type: "jsonb",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "RulesJson",
table: "ScheduleTemplates");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,73 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ChannelViewerSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "AnalogFilterStrength",
table: "Channels",
type: "double precision",
nullable: false,
defaultValue: 0.0);
migrationBuilder.AddColumn<int>(
name: "LogoCorner",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<Guid>(
name: "LogoImageId",
table: "Channels",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "LogoOpacity",
table: "Channels",
type: "double precision",
nullable: false,
defaultValue: 0.0);
migrationBuilder.AddColumn<bool>(
name: "ShowClock",
table: "Channels",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AnalogFilterStrength",
table: "Channels");
migrationBuilder.DropColumn(
name: "LogoCorner",
table: "Channels");
migrationBuilder.DropColumn(
name: "LogoImageId",
table: "Channels");
migrationBuilder.DropColumn(
name: "LogoOpacity",
table: "Channels");
migrationBuilder.DropColumn(
name: "ShowClock",
table: "Channels");
}
}
}
@@ -318,6 +318,9 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<double>("AnalogFilterStrength")
.HasColumnType("double precision");
b.Property<int>("BumperFont")
.HasColumnType("integer");
@@ -342,6 +345,15 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<int>("LogoCorner")
.HasColumnType("integer");
b.Property<Guid?>("LogoImageId")
.HasColumnType("uuid");
b.Property<double>("LogoOpacity")
.HasColumnType("double precision");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
@@ -350,6 +362,9 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<int?>("Number")
.HasColumnType("integer");
b.Property<bool>("ShowClock")
.HasColumnType("boolean");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
@@ -928,6 +943,9 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<int>("Revision")
.HasColumnType("integer");
b.Property<string>("RulesJson")
.HasColumnType("jsonb");
b.HasKey("Id");
b.HasIndex("ChannelId");
@@ -10,6 +10,7 @@ public class ScheduleTemplateConfiguration : IEntityTypeConfiguration<ScheduleTe
public void Configure(EntityTypeBuilder<ScheduleTemplate> builder)
{
builder.Property(x => x.Name).IsRequired().HasMaxLength(256);
builder.Property(x => x.RulesJson).HasColumnType("jsonb");
builder.HasIndex(x => x.ChannelId);
builder
@@ -1,43 +1,49 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Settings;
using TeleWave.Domain.Settings;
namespace TeleWave.Infrastructure.Settings;
/// <summary>Настройки сайта поверх key-value таблицы AppSetting. Запись не сохраняет сама —
/// сохранение выполняет UnitOfWorkBehavior команды (используется общий scoped-контекст).</summary>
public sealed class SiteSettings(IAppDbContext dbContext) : ISiteSettings
{
public Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken) =>
dbContext.GetBoolSettingAsync(SettingKeys.RegistrationEnabled, false, cancellationToken);
public async Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken)
{
await UpsertAsync(
SettingKeys.RegistrationEnabled,
enabled ? "true" : "false",
cancellationToken
);
}
public Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken) =>
dbContext.GetStringSettingAsync(SettingKeys.PreferredAudioLanguages, "", cancellationToken);
public Task SetPreferredAudioLanguagesAsync(
string value,
CancellationToken cancellationToken
) => UpsertAsync(SettingKeys.PreferredAudioLanguages, value, cancellationToken);
private async Task UpsertAsync(string key, string value, CancellationToken cancellationToken)
{
var existing = await dbContext.AppSettings.FirstOrDefaultAsync(
s => s.Key == key,
cancellationToken
);
if (existing is null)
dbContext.AppSettings.Add(AppSetting.Create(key, value));
else
existing.SetValue(value);
}
}
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Settings;
using TeleWave.Domain.Settings;
namespace TeleWave.Infrastructure.Settings;
/// <summary>Настройки сайта поверх key-value таблицы AppSetting. Запись не сохраняет сама —
/// сохранение выполняет UnitOfWorkBehavior команды (используется общий scoped-контекст).</summary>
public sealed class SiteSettings(IAppDbContext dbContext) : ISiteSettings
{
public Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken) =>
dbContext.GetBoolSettingAsync(SettingKeys.RegistrationEnabled, false, cancellationToken);
public async Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken)
{
await UpsertAsync(
SettingKeys.RegistrationEnabled,
enabled ? "true" : "false",
cancellationToken
);
}
public Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken) =>
dbContext.GetStringSettingAsync(SettingKeys.PreferredAudioLanguages, "", cancellationToken);
public Task SetPreferredAudioLanguagesAsync(
string value,
CancellationToken cancellationToken
) => UpsertAsync(SettingKeys.PreferredAudioLanguages, value, cancellationToken);
public Task<bool> AreChannelNumbersEnabledAsync(CancellationToken cancellationToken) =>
dbContext.GetBoolSettingAsync(SettingKeys.ChannelNumbersEnabled, false, cancellationToken);
public Task SetChannelNumbersEnabledAsync(bool enabled, CancellationToken cancellationToken) =>
UpsertAsync(SettingKeys.ChannelNumbersEnabled, enabled ? "true" : "false", cancellationToken);
private async Task UpsertAsync(string key, string value, CancellationToken cancellationToken)
{
var existing = await dbContext.AppSettings.FirstOrDefaultAsync(
s => s.Key == key,
cancellationToken
);
if (existing is null)
dbContext.AppSettings.Add(AppSetting.Create(key, value));
else
existing.SetValue(value);
}
}
@@ -0,0 +1,148 @@
using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Programming.Templates;
using TeleWave.Domain.Programming;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
/// <summary>
/// Применимость слоёв (см. 3.4) и разрешение перекрытий: какие слоты реально действуют в сутки.
/// </summary>
public class LayerApplicabilityTests
{
private static readonly TimeOnly DayStart = new(6, 0);
[Fact]
public void Empty_CoversEveryDate()
{
var applicability = new LayerApplicability();
Assert.True(applicability.IsEmpty);
Assert.True(applicability.Covers(new DateOnly(2026, 3, 17)));
}
[Theory]
[InlineData(2026, 12, 25, true)] // внутри, до Нового года
[InlineData(2026, 1, 3, true)] // внутри, после Нового года
[InlineData(2026, 12, 20, true)] // ровно начало
[InlineData(2026, 1, 8, true)] // ровно конец
[InlineData(2026, 12, 19, false)] // за день до начала
[InlineData(2026, 1, 9, false)] // на следующий день после конца
[InlineData(2026, 6, 15, false)] // середина года
public void AnnualRange_CrossingNewYear_IsInclusiveOnBothEnds(
int year,
int month,
int day,
bool expected
)
{
// «20 декабря — 8 января» задаётся один раз и работает в любом году, поэтому сравнение идёт
// по паре (месяц, день), а не по датам.
var applicability = new LayerApplicability(
AnnualRanges: [new AnnualRange(12, 20, 1, 8)]
);
Assert.Equal(expected, applicability.Covers(new DateOnly(year, month, day)));
}
[Fact]
public void AnnualRange_WithinOneYear_DoesNotWrap()
{
var applicability = new LayerApplicability(AnnualRanges: [new AnnualRange(6, 1, 8, 31)]);
Assert.True(applicability.Covers(new DateOnly(2026, 7, 4)));
Assert.False(applicability.Covers(new DateOnly(2026, 1, 4)));
}
[Fact]
public void Sections_AreCombinedWithOr()
{
// Понедельник ИЛИ конкретная дата: суббота из списка дат проходит, обычная суббота — нет.
var applicability = new LayerApplicability(
Weekdays: [1],
SpecificDates: [new DateOnly(2026, 3, 21)]
);
Assert.True(applicability.Covers(new DateOnly(2026, 3, 16))); // понедельник
Assert.True(applicability.Covers(new DateOnly(2026, 3, 21))); // суббота из списка
Assert.False(applicability.Covers(new DateOnly(2026, 3, 14))); // другая суббота
}
[Fact]
public void Build_SkipsSlotsCoveredByHigherPriorityLayer()
{
// Слот младшего слоя пропускается целиком, а не обрезается: половина слота означала бы
// половину настройки — своей группы и стратегии у половинки нет.
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
var top = template.AddLayer("Прайм", 100);
var bottom = template.AddLayer("Обычный", 50);
AddSlot(top, "Кино", new TimeOnly(20, 0), 120);
AddSlot(bottom, "Сериал", new TimeOnly(20, 30), 60);
AddSlot(bottom, "Ночь", new TimeOnly(23, 0), 60);
var titles = Build(template).Select(s => s.Slot.Title).Distinct().ToList();
Assert.Contains("Кино", titles);
Assert.Contains("Ночь", titles);
Assert.DoesNotContain("Сериал", titles);
}
[Fact]
public void Build_DropsLayersThatDoNotApplyOnTheDate()
{
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
var newYear = template.AddLayer("Новогодний", 100);
newYear.Update(
newYear.Name,
newYear.Priority,
new LayerApplicability(AnnualRanges: [new AnnualRange(12, 20, 1, 8)]).ToJson(),
isEnabled: true
);
AddSlot(newYear, "Ирония судьбы", new TimeOnly(20, 0), 180);
var usual = template.AddLayer("Обычный", 50);
AddSlot(usual, "Вечерний сериал", new TimeOnly(20, 0), 60);
var march = Build(template, new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero))
.Select(s => s.Slot.Title)
.ToList();
var december = Build(template, new DateTimeOffset(2026, 12, 25, 12, 0, 0, TimeSpan.Zero))
.Select(s => s.Slot.Title)
.ToList();
Assert.Contains("Вечерний сериал", march);
Assert.DoesNotContain("Ирония судьбы", march);
Assert.Contains("Ирония судьбы", december);
Assert.DoesNotContain("Вечерний сериал", december);
}
[Fact]
public void Build_DisabledLayerIsIgnored()
{
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
var layer = template.AddLayer("Выключённый", 100);
AddSlot(layer, "Ничего", new TimeOnly(20, 0), 60);
layer.Update(layer.Name, layer.Priority, null, isEnabled: false);
Assert.Empty(Build(template));
}
private static void AddSlot(GridLayer layer, string title, TimeOnly start, int durationMinutes) =>
layer.AddSlot(title, start, durationMinutes);
private static IReadOnlyList<ScheduledSlot> Build(
ScheduleTemplate template,
DateTimeOffset? from = null
)
{
var start = from ?? new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
return EffectiveGridBuilder.Build(
template,
utcOffsetMinutes: 180,
DayStart,
start,
start.AddHours(18)
);
}
}
@@ -0,0 +1,94 @@
using TeleWave.Application.Programming.Templates;
using TeleWave.Domain.Library;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
/// <summary>Окна детского времени: разрешение по времени суток, включая переход через полночь.</summary>
public class PlanningRulesTests
{
[Theory]
[InlineData(7, 0, true)]
[InlineData(22, 59, true)]
[InlineData(6, 0, true)] // ровно начало
[InlineData(23, 0, false)] // ровно конец — уже вне окна
[InlineData(2, 0, false)]
public void AudienceAt_DayWindow(int hour, int minute, bool inside)
{
var rules = new PlanningRules(
[new AudienceWindow(new TimeOnly(6, 0), new TimeOnly(23, 0), ShowAudience.Teen)]
);
var result = rules.AudienceAt(new TimeOnly(hour, minute));
Assert.Equal(inside ? ShowAudience.Teen : null, result);
}
[Theory]
[InlineData(23, 30, true)]
[InlineData(3, 0, true)]
[InlineData(12, 0, false)]
public void AudienceAt_WindowCrossingMidnight(int hour, int minute, bool inside)
{
// «С 23:00 до 06:00» — ночное окно, границы сравниваются в обратную сторону.
var rules = new PlanningRules(
[new AudienceWindow(new TimeOnly(23, 0), new TimeOnly(6, 0), ShowAudience.Adult)]
);
var result = rules.AudienceAt(new TimeOnly(hour, minute));
Assert.Equal(inside ? ShowAudience.Adult : null, result);
}
[Fact]
public void AudienceAt_OverlappingWindows_TakesTheStrictest()
{
// Широкое окно, случайно наложенное поверх детского, не должно его отменять.
var rules = new PlanningRules(
[
new AudienceWindow(new TimeOnly(6, 0), new TimeOnly(23, 0), ShowAudience.General),
new AudienceWindow(new TimeOnly(7, 0), new TimeOnly(10, 0), ShowAudience.Kids),
]
);
Assert.Equal(ShowAudience.Kids, rules.AudienceAt(new TimeOnly(8, 0)));
Assert.Equal(ShowAudience.General, rules.AudienceAt(new TimeOnly(12, 0)));
}
[Fact]
public void AudienceAt_WithoutWindows_IsUnlimited()
{
Assert.Null(new PlanningRules().AudienceAt(new TimeOnly(3, 0)));
}
[Fact]
public void Json_RoundTrips()
{
var rules = new PlanningRules(
[new AudienceWindow(new TimeOnly(6, 0), new TimeOnly(23, 0), ShowAudience.Teen)],
new RepeatLimitRule(7, 2),
MaxBreakMinutesPerHour: 12,
MaxGenreSharePercent: 40,
MaxFallbackSharePercent: 15
);
var restored = PlanningRules.FromJson(rules.ToJson());
// Сравниваем по полям: у record со списком внутри равенство ссылочное, и `Assert.Equal`
// на самих правилах проверял бы не то.
Assert.NotNull(restored);
Assert.Equal(rules.MaxAudienceByTime!, restored.MaxAudienceByTime!);
Assert.Equal(rules.MaxRepeatsInWindow, restored.MaxRepeatsInWindow);
Assert.Equal(rules.MaxBreakMinutesPerHour, restored.MaxBreakMinutesPerHour);
Assert.Equal(rules.MaxGenreSharePercent, restored.MaxGenreSharePercent);
Assert.Equal(rules.MaxFallbackSharePercent, restored.MaxFallbackSharePercent);
}
[Fact]
public void FromJson_Garbage_IsNull()
{
// Битый JSON не должен ронять генерацию: правило просто считается незаданным.
Assert.Null(PlanningRules.FromJson("{"));
Assert.Null(PlanningRules.FromJson(null));
}
}
@@ -55,6 +55,39 @@ public class ChannelTests
Assert.Equal(BumperSelection.AlwaysFirst, channel.BumperSelection);
}
[Theory]
[InlineData(-1.0, 2.0, 0.0, 1.0)]
[InlineData(0.4, 0.25, 0.4, 0.25)]
public void UpdateViewerSettings_ClampsStrengths(
double opacity,
double filter,
double expectedOpacity,
double expectedFilter
)
{
var channel = NewChannel();
var logo = Guid.NewGuid();
channel.UpdateViewerSettings(logo, LogoCorner.BottomRight, opacity, true, filter);
Assert.Equal(logo, channel.LogoImageId);
Assert.Equal(LogoCorner.BottomRight, channel.LogoCorner);
Assert.Equal(expectedOpacity, channel.LogoOpacity);
Assert.True(channel.ShowClock);
Assert.Equal(expectedFilter, channel.AnalogFilterStrength);
}
[Fact]
public void NewChannel_HasViewerOverlaysOff()
{
// Канал без логотипа, часов и шума — законная конфигурация, а не недонастроенная.
var channel = NewChannel();
Assert.Null(channel.LogoImageId);
Assert.False(channel.ShowClock);
Assert.Equal(0.0, channel.AnalogFilterStrength);
}
[Fact]
public void AddBumperTemplate_AppendsWithNextPosition()
{
@@ -0,0 +1,208 @@
using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
using Xunit;
namespace TeleWave.Domain.Tests.Programming;
/// <summary>
/// Жёсткие фильтры кандидатов (см. 3.8): детское время и потолок повторов. Оба отсекают до жребия,
/// поэтому проверяются на выборе элемента, а не на готовой ленте.
/// </summary>
public class CandidateFilterTests
{
private static readonly DateTimeOffset T0 = new(2026, 1, 5, 12, 0, 0, TimeSpan.Zero);
private sealed class FirstAlways : IRandomSource
{
public int Next(int maxExclusive) => 0;
}
private static PlanningElement Element(
ShowAudience? audience = null,
int position = 0,
DateTimeOffset? lastPlayed = null,
IReadOnlyList<DateTimeOffset>? recentPlays = null
)
{
var showId = Guid.NewGuid();
return new PlanningElement(
GroupElementKind.Show,
Guid.NewGuid(),
Weight: 1,
position,
[new PlanningUnit(Guid.NewGuid(), TimeSpan.FromMinutes(30), showId, 0)],
lastPlayed,
audience,
recentPlays
);
}
private static PlanningSlot Slot(
IReadOnlyList<PlanningElement> elements,
ShowAudience? maxAudience = null,
RepeatLimit? repeatLimit = null,
SlotStrategyKind strategy = SlotStrategyKind.RandomWithCooldown
) =>
new(
Guid.NewGuid(),
T0,
TargetDurationMinutes: 60,
SlotKind.Content,
IsAnchor: false,
MaxDriftMinutes: 30,
SnapToMinutes: null,
SlotBlockMode.FillSlot,
BlockValue: 1,
OverflowPolicy.ContinueNext,
new PlanningStrategy(strategy),
elements,
Cursor: null,
MaxAudience: maxAudience,
RepeatLimit: repeatLimit
);
[Fact]
public void Audience_FiltersOutStricterContent()
{
var kids = Element(ShowAudience.Kids);
var adult = Element(ShowAudience.Adult, position: 1);
var pick = ElementSelector.Select(
Slot([adult, kids], maxAudience: ShowAudience.Family),
T0,
new FirstAlways()
);
Assert.NotNull(pick);
Assert.Equal(kids.ElementId, pick.Element.ElementId);
}
[Fact]
public void Audience_WithoutLimit_KeepsEverything()
{
var adult = Element(ShowAudience.Adult);
var pick = ElementSelector.Select(Slot([adult]), T0, new FirstAlways());
Assert.NotNull(pick);
Assert.Equal(adult.ElementId, pick.Element.ElementId);
}
[Fact]
public void Audience_UnknownCategory_IsNotDropped()
{
// Неизвестная категория — не повод выбрасывать: иначе контент исчезал бы из эфира молча.
var unknown = Element(audience: null);
var pick = ElementSelector.Select(
Slot([unknown], maxAudience: ShowAudience.Kids),
T0,
new FirstAlways()
);
Assert.NotNull(pick);
Assert.Equal(unknown.ElementId, pick.Element.ElementId);
}
[Fact]
public void Audience_AllFilteredOut_ReturnsNothing()
{
var pick = ElementSelector.Select(
Slot([Element(ShowAudience.Adult)], maxAudience: ShowAudience.Kids),
T0,
new FirstAlways()
);
Assert.Null(pick);
}
[Fact]
public void Audience_AppliesToSequentialStrategyToo()
{
var adult = Element(ShowAudience.Adult, position: 0);
var teen = Element(ShowAudience.Teen, position: 1);
var pick = ElementSelector.Select(
Slot([adult, teen], maxAudience: ShowAudience.Teen, strategy: SlotStrategyKind.Sequential),
T0,
new FirstAlways()
);
Assert.NotNull(pick);
Assert.Equal(teen.ElementId, pick.Element.ElementId);
}
[Fact]
public void RepeatLimit_DropsElementsOverTheCap()
{
var overCap = Element(recentPlays: [T0.AddDays(-1), T0.AddDays(-2)]);
var fresh = Element(position: 1, recentPlays: [T0.AddDays(-1)]);
var pick = ElementSelector.Select(
Slot([overCap, fresh], repeatLimit: new RepeatLimit(WindowDays: 7, Max: 2)),
T0,
new FirstAlways()
);
Assert.NotNull(pick);
Assert.Equal(fresh.ElementId, pick.Element.ElementId);
}
[Fact]
public void RepeatLimit_IgnoresPlaysOutsideTheWindow()
{
// Показы старше окна не считаются — иначе правило «не чаще двух раз в неделю» запирало бы
// элемент навсегда.
var old = Element(recentPlays: [T0.AddDays(-30), T0.AddDays(-31)]);
var pick = ElementSelector.Select(
Slot([old], repeatLimit: new RepeatLimit(WindowDays: 7, Max: 2)),
T0,
new FirstAlways()
);
Assert.NotNull(pick);
Assert.Equal(old.ElementId, pick.Element.ElementId);
}
[Fact]
public void RepeatLimit_WhenEveryoneIsOverTheCap_StillPicksSomething()
{
// Пустой эфир хуже раннего повтора — как и при исчерпанном остывании.
var a = Element(recentPlays: [T0.AddDays(-1), T0.AddDays(-2)]);
var b = Element(position: 1, recentPlays: [T0.AddDays(-1), T0.AddDays(-2)]);
var pick = ElementSelector.Select(
Slot([a, b], repeatLimit: new RepeatLimit(WindowDays: 7, Max: 1)),
T0,
new FirstAlways()
);
Assert.NotNull(pick);
}
[Fact]
public void RepeatLimit_AppliesBeforeCooldown()
{
// Порядок проверяется через исчерпание: потолок повторов выбрасывает «давний» элемент ещё
// до остывания, поэтому в вырожденном случае остаётся свежий. В обратном порядке остывание
// оставило бы давний, и выбор был бы другим.
var overCap = Element(
lastPlayed: T0.AddDays(-10),
recentPlays: [T0.AddDays(-10), T0.AddDays(-9), T0.AddDays(-8)]
);
var recent = Element(position: 1, lastPlayed: T0.AddHours(-1), recentPlays: [T0.AddHours(-1)]);
var slot = Slot([overCap, recent], repeatLimit: new RepeatLimit(WindowDays: 30, Max: 2)) with
{
Strategy = new PlanningStrategy(SlotStrategyKind.RandomWithCooldown, CooldownDays: 2),
};
var pick = ElementSelector.Select(slot, T0, new FirstAlways());
Assert.NotNull(pick);
Assert.Equal(recent.ElementId, pick.Element.ElementId);
}
}
+28 -21
View File
@@ -18,8 +18,19 @@
(`BumperMinIntervalMinutes`, обе `*Chance`, `NextBumperIndex`) удалены — условия показа живут
в элементе стыка; из `BumperSelection` убрана «ротация», у которой не было реализации.
**Срезы 3 и 4** не начинались, кроме того, что уже понадобилось раньше: слоты повтора и конца
вещания сделаны вместе с планировщиком, применимость слоёв и панель слоёв — частично.
**Срез 3 закрыт целиком.** Применимость слоёв правится из UI, слои переупорядочиваются
перетаскиванием и выключаются, сетку можно посмотреть на конкретную дату, слоты двигаются
и растягиваются мышью, день копируется на другие дни. Добавлены жёсткие фильтры кандидатов —
детское время и потолок повторов.
**Срез 4 закрыт целиком.** Проверки по правилам показываются прямо в редакторе, пост-проверки
дают предупреждения по готовой ленте, у предпросмотра появилась вкладка «Проблемы» с тепловой
картой повторов, у каждой записи — трейс «почему это здесь», применение идёт через диф
с подсветкой ближайших суток, сетка копируется на другой канал.
**Смежная зрительская часть закрыта.** Номера каналов с сортировкой публичного списка,
переключение по номерам стрелками (глобальный флаг рядом с флагом регистрации), логотип-оверлей,
часы, плашка «Далее» и аналоговый фильтр. Всё опционально и по умолчанию выключено.
Ничего из этого не проверялось на живой базе: только сборка, юнит-тесты и typecheck.
@@ -307,19 +318,17 @@ N новых позиций по фильтру».
## Срез 3 — гибкость и календарь
#### [~] 3.1. Применимость слоёв
#### [x] 3.1. Применимость слоёв
`Domain` `Infrastructure` `Application`
> Частично: модель применимости и разрешение слоёв на дату готовы; нет UI правки применимости.
`applicability`: дни недели, разовые диапазоны дат, ежегодные диапазоны, конкретные даты (см. 3.4).
Приоритеты, разрешение конфликтов. Юнит-тесты на пересекающиеся слои и на границу года в ежегодном
диапазоне.
#### [~] 3.2. Фильтры кандидатов
#### [x] 3.2. Фильтры кандидатов
`Domain`
> Частично: остывание работает; возрастной фильтр по времени и потолок повторов не сделаны.
`maxAudienceByTime`, `cooldown`, `maxRepeatsInWindow` (см. 3.8) — применяются **до** взвешенного
выбора. История берётся из материализованной ленты. `fallback` стратегии, когда остывание отсекло
@@ -337,15 +346,14 @@ N новых позиций по фильтру».
Конец вещания: заполнение зацикленным ассетом, пометка в EPG «эфир не ведётся», отображение
в программе.
#### [~] 3.5. Панель слоёв
#### [x] 3.5. Панель слоёв
`Frontend`
> Частично: список слоёв с приоритетом и выбором готов; нет drag-переупорядочивания и переключателя даты.
Список слоёв с видимостью и приоритетом, drag для переупорядочивания, выбор редактируемого слоя,
штриховка перекрытых слотов, переключатель даты («показать сетку на 25 декабря»).
#### [ ] 3.6. Drag & drop в календаре
#### [x] 3.6. Drag & drop в календаре
`Frontend`
Перетаскивание слотов и изменение длительности за края, копирование дня на другие дни, копирование
@@ -355,36 +363,36 @@ N новых позиций по фильтру».
## Срез 4 — эксплуатация
#### [ ] 4.1. Валидация до генерации
#### [x] 4.1. Валидация до генерации
`Application` `Api` `Frontend`
Проверки из 5.1: нехватка контента, пустая группа, дыра в сетке, пересечение слотов, недостижимый
кулдаун, возрастной конфликт. Отдаются вместе с шаблоном, показываются в редакторе.
#### [ ] 4.2. Пост-проверки
#### [x] 4.2. Пост-проверки
`Application`
Потолок врезок в час, доля жанра за сутки, превышение дрейфа, доля эфира у фона (см. 3.8, 5.2).
Предупреждения, не ошибки — ничего не переигрывается.
#### [ ] 4.3. Вкладка «Проблемы» и тепловая карта
#### [x] 4.3. Вкладка «Проблемы» и тепловая карта
`Frontend`
Сгруппированные предупреждения с переходом к источнику; матрица «элемент × день» с яркостью
по числу показов.
#### [ ] 4.4. «Почему это здесь»
#### [x] 4.4. «Почему это здесь»
`Api` `Frontend`
Отдача `trace` записи и экран цепочки происхождения (см. 6.5).
#### [ ] 4.5. Диф перед применением
#### [x] 4.5. Диф перед применением
`Application` `Api` `Frontend`
Сравнение текущего хвоста с пересчитанным, список изменений, отдельная подсветка ближайших суток
(см. 6.6).
#### [ ] 4.6. Копирование шаблона
#### [x] 4.6. Копирование шаблона
`Application` `Api` `Frontend`
Глубокая копия шаблона, слоёв и слотов на другой канал; группы не копируются, они общие.
@@ -396,31 +404,30 @@ N новых позиций по фильтру».
От планировщика не зависит, делается параллельно в любой момент. Всё опционально и по умолчанию
выключено (см. 6.8).
#### [~] V.1. Номер канала
#### [x] V.1. Номер канала
`Domain` `Application` `Api` `Frontend`
> Частично: поле, уникальность и правка в настройках канала готовы; сортировки публичного списка нет.
`Channel.number`, уникальность, сортировка публичного списка по номеру.
#### [ ] V.2. Переключение по номерам
#### [x] V.2. Переключение по номерам
`Frontend`
Вверх-вниз по номерам, короткий чёрный кадр, номер в углу на секунду. Включается глобальным флагом
в `AppSetting` рядом с флагом регистрации. Сетка каналов остаётся вторым способом навигации.
#### [ ] V.3. Логотип канала
#### [x] V.3. Логотип канала
`Domain` `Api` `Frontend`
`logoImageId` (реестр изображений), угол и прозрачность. Оверлей поверх `<video>`, без касания
ffmpeg.
#### [ ] V.4. Часы и плашка «Далее»
#### [x] V.4. Часы и плашка «Далее»
`Frontend`
Часы — опция канала. Плашка в конце программы по данным EPG.
#### [ ] V.5. Аналоговый фильтр
#### [x] V.5. Аналоговый фильтр
`Domain` `Frontend`
`analogFilterStrength` на канале, CSS-фильтр или шейдер, по умолчанию выключен.
@@ -8,29 +8,51 @@ import { HttpError } from '@/shared/api/client'
import type { GridLayerDto, SlotDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store'
import {
applyChannelTemplate,
copyTemplateTo,
createLayer,
createSlot,
deleteLayer,
getChannel,
getChannelTemplate,
getSchedule,
listChannels,
toSlotBody,
updateLayer,
updateSlot,
} from './api'
import { ApplyDialog } from './components/ApplyDialog'
import { BumperCard } from './components/BumperCard'
import { CollapsibleCard } from './components/CollapsibleCard'
import { EntryTraceDialog } from './components/EntryTraceDialog'
import { JunctionsCard } from './components/JunctionsCard'
import { LayerApplicabilityDialog } from './components/LayerApplicabilityDialog'
import { LayerList, ScheduleGrid } from './components/ScheduleGrid'
import { SchedulePreview } from './components/SchedulePreview'
import { RulesCard } from './components/RulesCard'
import { SettingsCard } from './components/SettingsCard'
import { TemplateIssues } from './components/TemplateIssues'
import { TemplatePreview } from './components/TemplatePreview'
import { ViewerCard } from './components/ViewerCard'
import { SlotInspector, type SlotDraft } from './components/SlotInspector'
import { toTime } from './lib/format'
export function ChannelDetail({ channelId }: { channelId: string }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [draft, setDraft] = useState<SlotDraft | null>(null)
const [activeLayerId, setActiveLayerId] = useState<string | null>(null)
const [viewDate, setViewDate] = useState<string>('')
const [applicabilityLayer, setApplicabilityLayer] = useState<GridLayerDto | null>(null)
// День, который копируем, и отмеченные дни-приёмники.
const [copySource, setCopySource] = useState<number | null>(null)
const [copyTargets, setCopyTargets] = useState<number[]>([])
const [applyOpen, setApplyOpen] = useState(false)
const [traceEntryId, setTraceEntryId] = useState<string | null>(null)
const [copyToChannel, setCopyToChannel] = useState('')
const { data: channel, isLoading } = useQuery({
queryKey: ['admin', 'channels', channelId],
@@ -55,9 +77,12 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const { data: channels } = useQuery({ queryKey: ['admin', 'channels'], queryFn: listChannels })
const applyMutation = useMutation({
mutationFn: () => applyChannelTemplate(channelId),
onSuccess: (result) => {
setApplyOpen(false)
toast.success(t('admin.channels.applied', { count: result.added }))
// Предупреждения показываем по одному: каждое указывает на конкретный слот.
for (const warning of result.warnings)
@@ -85,6 +110,97 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
onError,
})
const toggleLayerMutation = useMutation({
mutationFn: (layer: GridLayerDto) =>
updateLayer(layer.id, {
name: layer.name,
priority: layer.priority,
applicability: layer.applicability,
isEnabled: !layer.isEnabled,
}),
onSuccess: invalidate,
onError,
})
/**
* Порядок слоёв задаётся перетаскиванием, а хранится приоритетом. Раздаём приоритеты с шагом 10
* снизу вверх: шаг оставляет место, чтобы следующая вставка не переписывала весь список.
*/
const reorderLayersMutation = useMutation({
mutationFn: async (layerIdsTopFirst: string[]) => {
const byId = new Map(template!.layers.map((l) => [l.id, l]))
const total = layerIdsTopFirst.length
await Promise.all(
layerIdsTopFirst.map((id, index) => {
const layer = byId.get(id)
const priority = (total - index) * 10
if (!layer || layer.priority === priority) return Promise.resolve()
return updateLayer(id, {
name: layer.name,
priority,
applicability: layer.applicability,
isEnabled: layer.isEnabled,
})
}),
)
},
onSuccess: invalidate,
onError,
})
const copyTemplateMutation = useMutation({
mutationFn: (targetChannelId: string) => copyTemplateTo(channelId, targetChannelId),
onSuccess: (result) => {
setCopyToChannel('')
toast.success(
t('admin.channels.templateCopied', { layers: result.layers, slots: result.slots }),
)
if (result.droppedBumperRefs > 0)
toast.error(
t('admin.channels.copyDroppedBumpers', { count: result.droppedBumperRefs }),
)
},
onError,
})
const moveSlotMutation = useMutation({
mutationFn: ({
slot,
weekday,
startMinutes,
}: {
slot: SlotDto
weekday: number
startMinutes: number
}) => updateSlot(slot.id, { ...toSlotBody(slot), weekday, targetStart: toTime(startMinutes) }),
onSuccess: invalidate,
onError,
})
const resizeSlotMutation = useMutation({
mutationFn: ({ slot, minutes }: { slot: SlotDto; minutes: number }) =>
updateSlot(slot.id, { ...toSlotBody(slot), targetDurationMinutes: minutes }),
onSuccess: invalidate,
onError,
})
/** Копирование дня: слоты «каждый день» не копируются — они и так есть во всех колонках. */
const copyDayMutation = useMutation({
mutationFn: async ({ from, to }: { from: number; to: number[] }) => {
const sources = (template?.layers ?? []).flatMap((layer) =>
layer.slots.filter((slot) => slot.weekday === from).map((slot) => ({ layer, slot })),
)
for (const weekday of to)
for (const { layer, slot } of sources)
await createSlot(layer.id, { ...toSlotBody(slot), weekday })
},
onSuccess: () => {
setCopySource(null)
invalidate()
},
onError,
})
if (isLoading || !channel) return <p className="text-muted-foreground">{t('common.loading')}</p>
const layerForNewSlot =
@@ -126,7 +242,7 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
{template?.hasPendingChanges && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-amber-500/50 bg-amber-500/10 px-4 py-2 text-sm">
<span>{t('admin.channels.pendingChanges')}</span>
<Button size="sm" disabled={applyMutation.isPending} onClick={() => applyMutation.mutate()}>
<Button size="sm" disabled={applyMutation.isPending} onClick={() => setApplyOpen(true)}>
<Send className="h-4 w-4" /> {t('admin.channels.apply')}
</Button>
</div>
@@ -159,19 +275,129 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
<LayerList
template={template}
activeLayerId={layerForNewSlot ?? null}
viewDate={viewDate || null}
onSelect={(layer) => setActiveLayerId(layer.id)}
onDelete={(layer) => deleteLayerMutation.mutate(layer)}
onToggle={(layer) => toggleLayerMutation.mutate(layer)}
onReorder={(order) => reorderLayersMutation.mutate(order)}
onEditApplicability={setApplicabilityLayer}
/>
<p className="text-xs text-muted-foreground">{t('admin.channels.layersHint')}</p>
{/* Копия сетки на другой канал: группы общие, поэтому переносятся только правила. */}
<div className="flex flex-col gap-1.5 border-t border-border pt-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.copyTemplate')}
</span>
<select
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
value={copyToChannel}
onChange={(e) => setCopyToChannel(e.target.value)}
>
<option value="">{t('admin.channels.pickTargetChannel')}</option>
{(channels ?? [])
.filter((c) => c.id !== channelId)
.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<Button
size="sm"
variant="outline"
disabled={!copyToChannel || copyTemplateMutation.isPending}
onClick={() => copyTemplateMutation.mutate(copyToChannel)}
>
{t('admin.channels.copy')}
</Button>
<p className="text-xs text-muted-foreground">
{t('admin.channels.copyTemplateHint')}
</p>
</div>
</div>
<div className="flex flex-col gap-3">
<TemplateIssues
channelId={channelId}
slotsById={
new Map(template.layers.flatMap((l) => l.slots).map((slot) => [slot.id, slot]))
}
onGoToSlot={openSlot}
/>
<TemplatePreview channelId={channelId} />
{/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */}
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">{t('admin.channels.showForDate')}</span>
<Input
type="date"
className="h-8 w-40"
value={viewDate}
onChange={(e) => setViewDate(e.target.value)}
/>
{viewDate && (
<Button size="sm" variant="ghost" onClick={() => setViewDate('')}>
{t('admin.channels.allDates')}
</Button>
)}
</div>
{/* Копирование дня: сначала выбирается источник, потом дни-приёмники. */}
{copySource !== null && (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
<span>
{t('admin.channels.copyDayFrom', {
day: t(`admin.channels.weekdays.${copySource}`),
})}
</span>
{[1, 2, 3, 4, 5, 6, 0]
.filter((day) => day !== copySource)
.map((day) => (
<label key={day} className="flex items-center gap-1">
<input
type="checkbox"
checked={copyTargets.includes(day)}
onChange={(e) =>
setCopyTargets((current) =>
e.target.checked
? [...current, day]
: current.filter((d) => d !== day),
)
}
/>
{t(`admin.channels.weekdays.${day}`)}
</label>
))}
<Button
size="sm"
disabled={copyTargets.length === 0 || copyDayMutation.isPending}
onClick={() =>
copyDayMutation.mutate({ from: copySource, to: copyTargets })
}
>
{t('admin.channels.copy')}
</Button>
<Button size="sm" variant="ghost" onClick={() => setCopySource(null)}>
{t('common.cancel')}
</Button>
</div>
)}
<ScheduleGrid
template={template}
selectedSlotId={draft?.slot?.id ?? null}
viewDate={viewDate || null}
onSelectSlot={openSlot}
onAddSlot={openNewSlot}
onMoveSlot={(slot, weekday, startMinutes) =>
moveSlotMutation.mutate({ slot, weekday, startMinutes })
}
onResizeSlot={(slot, minutes) => resizeSlotMutation.mutate({ slot, minutes })}
onCopyDay={(weekday) => {
setCopySource(weekday)
setCopyTargets([])
}}
/>
{draft && (
<SlotInspector
@@ -186,6 +412,10 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
</CollapsibleCard>
)}
{template && (
<RulesCard template={template} onChanged={invalidate} onError={onError} />
)}
<JunctionsCard
channel={channel}
template={template}
@@ -195,7 +425,36 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
<SchedulePreview entries={schedule ?? []} />
<ViewerCard channel={channel} onSaved={invalidate} onError={onError} />
{applicabilityLayer && (
<LayerApplicabilityDialog
layer={applicabilityLayer}
onClose={() => setApplicabilityLayer(null)}
onChanged={invalidate}
onError={onError}
/>
)}
<SchedulePreview entries={schedule ?? []} onShowTrace={setTraceEntryId} />
{applyOpen && (
<ApplyDialog
channelId={channelId}
utcOffsetMinutes={channel.utcOffsetMinutes}
pending={applyMutation.isPending}
onApply={() => applyMutation.mutate()}
onClose={() => setApplyOpen(false)}
/>
)}
{traceEntryId && (
<EntryTraceDialog
entryId={traceEntryId}
utcOffsetMinutes={channel.utcOffsetMinutes}
onClose={() => setTraceEntryId(null)}
/>
)}
</div>
)
}
+46 -1
View File
@@ -6,16 +6,22 @@ import type {
BumperTrigger,
ChannelDto,
ChannelSummaryDto,
CopyTemplateResultDto,
CreatedIdResponse,
EntryTraceDto,
JunctionAmountMode,
JunctionConditions,
JunctionElementKind,
JunctionTemplateDto,
LayerApplicability,
PlanningRules,
ScheduleDiffDto,
ScheduleEntryDto,
SchedulePreviewDto,
ScheduleTemplateDto,
SlotDto,
TemplateIssueDto,
ViewerSettings,
} from '@/shared/api/types'
export function listChannels() {
@@ -42,6 +48,11 @@ export function updateChannelSettings(id: string, body: ChannelSettingsBody) {
return apiRequest<void>(`/admin/channels/${id}/settings`, { method: 'PUT', body })
}
/** Оверлеи и аналоговый фильтр канала — как он выглядит у зрителя. */
export function updateViewerSettings(id: string, body: ViewerSettings) {
return apiRequest<void>(`/admin/channels/${id}/viewer`, { method: 'PUT', body })
}
/** Номер канала и его время: смещение от UTC и начало вещательных суток. */
export function updateChannelTime(
id: string,
@@ -63,6 +74,29 @@ export function applyChannelTemplate(channelId: string) {
})
}
/** Проверки сетки по правилам — считаются по шаблону, без прогона генератора. */
export function getTemplateIssues(channelId: string) {
return apiRequest<TemplateIssueDto[]>(`/admin/channels/${channelId}/template/issues`)
}
/** Что изменится в эфире, если применить сейчас. Прогон сухой — лента не трогается. */
export function getApplyDiff(channelId: string) {
return apiRequest<ScheduleDiffDto>(`/admin/channels/${channelId}/template/diff`)
}
/** Копия сетки на другой канал: слои, слоты, стыки и правила. Группы общие и не копируются. */
export function copyTemplateTo(channelId: string, targetChannelId: string) {
return apiRequest<CopyTemplateResultDto>(
`/admin/channels/${channelId}/template/copy-to/${targetChannelId}`,
{ method: 'POST' },
)
}
/** Цепочка происхождения записи, записанная в момент генерации. */
export function getEntryTrace(entryId: string) {
return apiRequest<EntryTraceDto>(`/admin/channels/entries/${entryId}/trace`)
}
/** Сухой прогон по текущим правилам: ничего не пишет и не двигает курсоры слотов. */
export function previewTemplate(channelId: string, days: number) {
const query = new URLSearchParams({ days: String(days) })
@@ -73,7 +107,12 @@ export function previewTemplate(channelId: string, days: number) {
export function updateTemplate(
templateId: string,
body: { name: string; fallbackGroupId: string | null; defaultJunctionId: string | null },
body: {
name: string
fallbackGroupId: string | null
defaultJunctionId: string | null
rules: PlanningRules | null
},
) {
return apiRequest<void>(`/admin/templates/${templateId}`, { method: 'PUT', body })
}
@@ -104,6 +143,12 @@ export function deleteLayer(layerId: string) {
/** Тело слота: то же для создания и правки (см. SlotInput на сервере). */
export type SlotBody = Omit<SlotDto, 'id' | 'layerId' | 'groupName'>
/** Слот из ответа сервера → тело запроса: отбрасываем то, что сервер проставляет сам. */
export function toSlotBody(slot: SlotDto): SlotBody {
const { id: _id, layerId: _layerId, groupName: _groupName, ...body } = slot
return body
}
export function createSlot(layerId: string, body: SlotBody) {
return apiRequest<CreatedIdResponse>(`/admin/layers/${layerId}/slots`, { method: 'POST', body })
}
@@ -0,0 +1,102 @@
import { useQuery } from '@tanstack/react-query'
import { AlertTriangle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { cn } from '@/shared/lib/cn'
import { getApplyDiff } from '../api'
import { formatChannelTime } from '../lib/format'
/**
* Диф перед применением (см. 6.6). Изменения в ближайшие сутки подсвечены отдельно — это самая
* частая причина случайного ущерба: у зрителя из-под носа уезжает то, что он уже видит в программе.
*/
export function ApplyDialog({
channelId,
utcOffsetMinutes,
pending,
onApply,
onClose,
}: {
channelId: string
utcOffsetMinutes: number
pending: boolean
onApply: () => void
onClose: () => void
}) {
const { t } = useTranslation()
const { data, isFetching } = useQuery({
queryKey: ['admin', 'channels', channelId, 'diff'],
queryFn: () => getApplyDiff(channelId),
staleTime: 0,
gcTime: 0,
})
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('admin.channels.apply')}</DialogTitle>
<DialogDescription>
{isFetching || !data
? t('common.loading')
: t('admin.channels.diffSummary', { total: data.total, changed: data.changed })}
</DialogDescription>
</DialogHeader>
{data && (
<div className="flex flex-col gap-2 text-sm">
{data.changedSoon > 0 && (
<p className="flex items-center gap-1.5 text-amber-500">
<AlertTriangle className="h-4 w-4 shrink-0" />
{t('admin.channels.diffSoon', { count: data.changedSoon })}
</p>
)}
{data.changes.length === 0 ? (
<p className="text-muted-foreground">{t('admin.channels.diffNoChanges')}</p>
) : (
<ul className="max-h-80 divide-y divide-border overflow-y-auto text-xs">
{data.changes.map((change, index) => (
<li
key={`${change.startsAtUtc}-${index}`}
className={cn('flex items-center gap-2 py-1', change.soon && 'text-amber-500')}
>
<span className="w-24 shrink-0 tabular-nums text-muted-foreground">
{formatChannelTime(change.startsAtUtc, utcOffsetMinutes)}
</span>
<span className="min-w-0 flex-1 truncate">{change.before ?? '—'}</span>
<span className="shrink-0 text-muted-foreground"></span>
<span className="min-w-0 flex-1 truncate">{change.after ?? '—'}</span>
</li>
))}
</ul>
)}
{data.changed > data.changes.length && (
<p className="text-xs text-muted-foreground">
{t('admin.channels.andMore', { count: data.changed - data.changes.length })}
</p>
)}
</div>
)}
<DialogFooter>
<Button size="sm" variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button size="sm" disabled={pending} onClick={onApply}>
{t('admin.channels.apply')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,104 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { getEntryTrace } from '../api'
import { formatChannelTime } from '../lib/format'
/**
* «Почему это здесь» (см. 6.5): цепочка происхождения записи. Трейс пишется в момент генерации —
* восстановить его потом нельзя, поэтому у старых записей часть строк будет пустой.
*/
export function EntryTraceDialog({
entryId,
utcOffsetMinutes,
onClose,
}: {
entryId: string
utcOffsetMinutes: number
onClose: () => void
}) {
const { t } = useTranslation()
const { data } = useQuery({
queryKey: ['admin', 'entries', entryId, 'trace'],
queryFn: () => getEntryTrace(entryId),
})
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{data
? `${data.showName ?? '—'} · ${formatChannelTime(data.startsAtUtc, utcOffsetMinutes)}`
: t('common.loading')}
</DialogTitle>
</DialogHeader>
{data && (
<dl className="grid grid-cols-[110px_1fr] gap-x-3 gap-y-1.5 text-sm">
<Row label={t('admin.channels.traceLayer')}>
{data.layerName
? `${data.layerName}${data.layerPriority !== null ? ` (${t('admin.channels.priority')} ${data.layerPriority})` : ''}`
: null}
</Row>
<Row label={t('admin.channels.traceSlot')}>
{data.slotTitle
? [
data.slotTitle,
data.slotWeekday === null
? t('admin.channels.everyDay')
: t(`admin.channels.weekdays.${data.slotWeekday}`),
data.slotTargetStart?.slice(0, 5),
data.slotDurationMinutes ? `${data.slotDurationMinutes} мин` : null,
data.driftMinutes !== 0
? t('admin.channels.traceDrift', { minutes: data.driftMinutes })
: null,
data.snapped ? t('admin.channels.traceSnapped') : null,
]
.filter(Boolean)
.join(' · ')
: null}
</Row>
<Row label={t('admin.channels.traceGroup')}>
{data.groupName
? `${data.groupName}${data.groupItemCount !== null ? ` (${data.groupItemCount})` : ''}`
: null}
</Row>
<Row label={t('admin.channels.traceStrategy')}>
{data.strategy
? [
t(`admin.channels.strategies.${data.strategy}`),
data.cooldownDays
? t('admin.channels.traceCooldown', { days: data.cooldownDays })
: null,
data.candidatesAfterCooldown !== null
? t('admin.channels.traceCandidates', {
count: data.candidatesAfterCooldown,
})
: null,
]
.filter(Boolean)
.join(' · ')
: null}
</Row>
<Row label={t('admin.channels.traceJunction')}>{data.junctionName}</Row>
</dl>
)}
</DialogContent>
</Dialog>
)
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<>
<dt className="text-muted-foreground">{label}</dt>
<dd>{children || '—'}</dd>
</>
)
}
@@ -97,6 +97,7 @@ export function JunctionsCard({
name: template!.name,
fallbackGroupId: template!.fallbackGroupId,
defaultJunctionId: junctionId,
rules: template!.rules,
}),
onSuccess: onChanged,
onError,
@@ -0,0 +1,300 @@
import { useMutation } from '@tanstack/react-query'
import { Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { AnnualRange, DateRange, GridLayerDto, LayerApplicability } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { updateLayer } from '../api'
import { isEmpty, toIsoDate } from '../lib/applicability'
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0]
/**
* Когда действует слой (см. 3.4). Разделы объединяются по ИЛИ: слой применим, если дата подходит
* хотя бы под одно условие. Пустая применимость — слой действует всегда.
*/
export function LayerApplicabilityDialog({
layer,
onClose,
onChanged,
onError,
}: {
layer: GridLayerDto
onClose: () => void
onChanged: () => void
onError: (error: unknown) => void
}) {
const { t } = useTranslation()
const [name, setName] = useState(layer.name)
const [weekdays, setWeekdays] = useState<number[]>(layer.applicability?.weekdays ?? [])
const [dateRanges, setDateRanges] = useState<DateRange[]>(layer.applicability?.dateRanges ?? [])
const [annualRanges, setAnnualRanges] = useState<AnnualRange[]>(
layer.applicability?.annualRanges ?? [],
)
const [specificDates, setSpecificDates] = useState<string[]>(
layer.applicability?.specificDates ?? [],
)
const save = useMutation({
mutationFn: () => {
const applicability: LayerApplicability = {
weekdays: weekdays.length > 0 ? [...weekdays].sort((a, b) => a - b) : null,
dateRanges: dateRanges.length > 0 ? dateRanges : null,
annualRanges: annualRanges.length > 0 ? annualRanges : null,
specificDates: specificDates.length > 0 ? specificDates : null,
}
return updateLayer(layer.id, {
name: name.trim() || layer.name,
priority: layer.priority,
// Пустую применимость отправляем как null — «действует всегда» и «пустые списки» это одно
// и то же, но null читается однозначно.
applicability: isEmpty(applicability) ? null : applicability,
isEnabled: layer.isEnabled,
})
},
onSuccess: () => {
onChanged()
onClose()
},
onError,
})
const toggleWeekday = (day: number) =>
setWeekdays((current) =>
current.includes(day) ? current.filter((d) => d !== day) : [...current, day],
)
const today = toIsoDate(new Date())
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{t('admin.channels.layerApplicability')}</DialogTitle>
<DialogDescription>{t('admin.channels.applicabilityHint')}</DialogDescription>
</DialogHeader>
<div className="flex max-h-[60vh] flex-col gap-4 overflow-y-auto text-sm">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.layerName')}</Label>
<Input value={name} maxLength={128} onChange={(e) => setName(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.applicabilityWeekdays')}</Label>
<div className="flex flex-wrap gap-1">
{WEEKDAYS.map((day) => (
<button
key={day}
type="button"
onClick={() => toggleWeekday(day)}
className={
weekdays.includes(day)
? 'rounded border border-primary bg-primary/15 px-2 py-1 text-xs text-primary'
: 'rounded border border-border px-2 py-1 text-xs text-muted-foreground'
}
>
{t(`admin.channels.weekdays.${day}`)}
</button>
))}
</div>
</div>
<Section
title={t('admin.channels.applicabilityDateRanges')}
onAdd={() => setDateRanges((c) => [...c, { from: today, to: today }])}
empty={dateRanges.length === 0}
>
{dateRanges.map((range, index) => (
<li key={index} className="flex flex-wrap items-end gap-2">
<Input
type="date"
className="w-40"
value={range.from}
onChange={(e) =>
setDateRanges((c) =>
c.map((r, i) => (i === index ? { ...r, from: e.target.value } : r)),
)
}
/>
<Input
type="date"
className="w-40"
value={range.to}
onChange={(e) =>
setDateRanges((c) =>
c.map((r, i) => (i === index ? { ...r, to: e.target.value } : r)),
)
}
/>
<Button
size="sm"
variant="ghost"
onClick={() => setDateRanges((c) => c.filter((_, i) => i !== index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</Section>
<Section
title={t('admin.channels.applicabilityAnnual')}
onAdd={() =>
setAnnualRanges((c) => [...c, { fromMonth: 12, fromDay: 20, toMonth: 1, toDay: 8 }])
}
empty={annualRanges.length === 0}
>
{annualRanges.map((range, index) => (
<li key={index} className="flex flex-wrap items-end gap-2">
<MonthDay
value={range}
prefix="from"
onChange={(part) =>
setAnnualRanges((c) => c.map((r, i) => (i === index ? { ...r, ...part } : r)))
}
/>
<span className="pb-2 text-muted-foreground"></span>
<MonthDay
value={range}
prefix="to"
onChange={(part) =>
setAnnualRanges((c) => c.map((r, i) => (i === index ? { ...r, ...part } : r)))
}
/>
<Button
size="sm"
variant="ghost"
onClick={() => setAnnualRanges((c) => c.filter((_, i) => i !== index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</Section>
<Section
title={t('admin.channels.applicabilityDates')}
onAdd={() => setSpecificDates((c) => [...c, today])}
empty={specificDates.length === 0}
>
{specificDates.map((date, index) => (
<li key={index} className="flex items-end gap-2">
<Input
type="date"
className="w-40"
value={date}
onChange={(e) =>
setSpecificDates((c) => c.map((d, i) => (i === index ? e.target.value : d)))
}
/>
<Button
size="sm"
variant="ghost"
onClick={() => setSpecificDates((c) => c.filter((_, i) => i !== index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</Section>
</div>
<DialogFooter>
<Button size="sm" variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function Section({
title,
onAdd,
empty,
children,
}: {
title: string
onAdd: () => void
empty: boolean
children: React.ReactNode
}) {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<Label>{title}</Label>
<Button size="sm" variant="ghost" onClick={onAdd}>
<Plus className="h-4 w-4" />
</Button>
</div>
{empty ? (
<p className="text-xs text-muted-foreground">{t('admin.channels.applicabilityNone')}</p>
) : (
<ul className="flex flex-col gap-2">{children}</ul>
)}
</div>
)
}
/** Пара «месяц / день» ежегодного периода — год у него намеренно отсутствует. */
function MonthDay({
value,
prefix,
onChange,
}: {
value: AnnualRange
prefix: 'from' | 'to'
onChange: (part: Partial<AnnualRange>) => void
}) {
const month = prefix === 'from' ? value.fromMonth : value.toMonth
const day = prefix === 'from' ? value.fromDay : value.toDay
return (
<span className="flex items-end gap-1">
<Input
type="number"
min={1}
max={12}
className="w-16"
value={month}
onChange={(e) =>
onChange(
prefix === 'from'
? { fromMonth: Number(e.target.value) }
: { toMonth: Number(e.target.value) },
)
}
/>
<Input
type="number"
min={1}
max={31}
className="w-16"
value={day}
onChange={(e) =>
onChange(
prefix === 'from'
? { fromDay: Number(e.target.value) }
: { toDay: Number(e.target.value) },
)
}
/>
</span>
)
}
@@ -0,0 +1,240 @@
import { useMutation } from '@tanstack/react-query'
import { Plus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
SHOW_AUDIENCES,
type AudienceWindow,
type PlanningRules,
type ScheduleTemplateDto,
type ShowAudience,
} from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { updateTemplate } from '../api'
import { CollapsibleCard } from './CollapsibleCard'
const EMPTY_WINDOW: AudienceWindow = { from: '06:00:00', to: '23:00:00', maxAudience: 'Teen' }
/**
* Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие
* фильтры — они отсекают недопустимое до жребия, поэтому не требуют пересборки и не ломают
* воспроизводимость. Как и правка сетки, эфира сами по себе не двигают.
*/
export function RulesCard({
template,
onChanged,
onError,
}: {
template: ScheduleTemplateDto
onChanged: () => void
onError: (error: unknown) => void
}) {
const { t } = useTranslation()
const [windows, setWindows] = useState<AudienceWindow[]>(
() => template.rules?.maxAudienceByTime ?? [],
)
const [limitOn, setLimitOn] = useState(() => template.rules?.maxRepeatsInWindow != null)
const [windowDays, setWindowDays] = useState(
() => template.rules?.maxRepeatsInWindow?.windowDays ?? 7,
)
const [max, setMax] = useState(() => template.rules?.maxRepeatsInWindow?.max ?? 2)
const [breakCap, setBreakCap] = useState(() => template.rules?.maxBreakMinutesPerHour ?? 0)
const [genreCap, setGenreCap] = useState(() => template.rules?.maxGenreSharePercent ?? 0)
const [fallbackCap, setFallbackCap] = useState(() => template.rules?.maxFallbackSharePercent ?? 0)
useEffect(() => {
setWindows(template.rules?.maxAudienceByTime ?? [])
setLimitOn(template.rules?.maxRepeatsInWindow != null)
setWindowDays(template.rules?.maxRepeatsInWindow?.windowDays ?? 7)
setMax(template.rules?.maxRepeatsInWindow?.max ?? 2)
setBreakCap(template.rules?.maxBreakMinutesPerHour ?? 0)
setGenreCap(template.rules?.maxGenreSharePercent ?? 0)
setFallbackCap(template.rules?.maxFallbackSharePercent ?? 0)
}, [template])
const save = useMutation({
mutationFn: () => {
const rules: PlanningRules = {
maxAudienceByTime: windows.length > 0 ? windows : null,
maxRepeatsInWindow: limitOn ? { windowDays, max } : null,
// Ноль означает «не проверять»: отдельного выключателя на каждый порог не нужно.
maxBreakMinutesPerHour: breakCap > 0 ? breakCap : null,
maxGenreSharePercent: genreCap > 0 ? genreCap : null,
maxFallbackSharePercent: fallbackCap > 0 ? fallbackCap : null,
}
return updateTemplate(template.id, {
name: template.name,
fallbackGroupId: template.fallbackGroupId,
defaultJunctionId: template.defaultJunctionId,
rules,
})
},
onSuccess: onChanged,
onError,
})
const patchWindow = (index: number, part: Partial<AudienceWindow>) =>
setWindows((current) => current.map((w, i) => (i === index ? { ...w, ...part } : w)))
return (
<CollapsibleCard title={t('admin.channels.rules')}>
<div className="flex flex-col gap-4 text-sm">
<p className="text-xs text-muted-foreground">{t('admin.channels.rulesHint')}</p>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.audienceWindows')}
</h3>
<Button
size="sm"
variant="ghost"
onClick={() => setWindows((c) => [...c, EMPTY_WINDOW])}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{windows.length === 0 ? (
<p className="text-xs text-muted-foreground">{t('admin.channels.noAudienceWindows')}</p>
) : (
<ul className="flex flex-col gap-2">
{windows.map((window, index) => (
<li key={index} className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.from')}</Label>
<Input
type="time"
className="w-28"
value={window.from.slice(0, 5)}
onChange={(e) => patchWindow(index, { from: `${e.target.value}:00` })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.to')}</Label>
<Input
type="time"
className="w-28"
value={window.to.slice(0, 5)}
onChange={(e) => patchWindow(index, { to: `${e.target.value}:00` })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.maxAudience')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={window.maxAudience}
onChange={(e) =>
patchWindow(index, { maxAudience: e.target.value as ShowAudience })
}
>
{SHOW_AUDIENCES.map((value) => (
<option key={value} value={value}>
{t(`admin.shows.audiences.${value}`)}
</option>
))}
</select>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => setWindows((c) => c.filter((_, i) => i !== index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
)}
<p className="text-xs text-muted-foreground">{t('admin.channels.audienceWindowsHint')}</p>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={limitOn}
onChange={(e) => setLimitOn(e.target.checked)}
/>
{t('admin.channels.repeatLimit')}
</label>
{limitOn && (
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.repeatWindowDays')}</Label>
<Input
type="number"
min={1}
max={365}
className="w-28"
value={windowDays}
onChange={(e) => setWindowDays(Number(e.target.value))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.repeatMax')}</Label>
<Input
type="number"
min={1}
className="w-28"
value={max}
onChange={(e) => setMax(Number(e.target.value))}
/>
</div>
</div>
)}
<p className="text-xs text-muted-foreground">{t('admin.channels.repeatLimitHint')}</p>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-4">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.postChecks')}
</h3>
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.breakLimit')}</Label>
<Input
type="number"
min={0}
className="w-28"
value={breakCap}
onChange={(e) => setBreakCap(Number(e.target.value))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.genreShare')}</Label>
<Input
type="number"
min={0}
max={100}
className="w-28"
value={genreCap}
onChange={(e) => setGenreCap(Number(e.target.value))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.fallbackShare')}</Label>
<Input
type="number"
min={0}
max={100}
className="w-28"
value={fallbackCap}
onChange={(e) => setFallbackCap(Number(e.target.value))}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">{t('admin.channels.postChecksHint')}</p>
</div>
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</div>
</CollapsibleCard>
)
}
@@ -1,203 +1,372 @@
import { Anchor, Plus } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { GridLayerDto, ScheduleTemplateDto, SlotDto } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { cn } from '@/shared/lib/cn'
const HOUR_HEIGHT = 44
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0]
/** Цвет блока — по дейпарту: сетка должна читаться одним взглядом, без легенды. */
const DAYPART_CLASS: Record<string, string> = {
Morning: 'bg-amber-500/20 border-amber-500/40',
Day: 'bg-sky-500/20 border-sky-500/40',
Prime: 'bg-violet-500/25 border-violet-500/50',
Night: 'bg-slate-500/20 border-slate-500/40',
}
function minutesOf(time: string): number {
const [h, m] = time.split(':')
return Number(h) * 60 + Number(m)
}
/**
* Смещение слота от начала вещательных суток. Ночной блок (00:00–06:00 при старте суток в 06:00)
* принадлежит предыдущему дню, поэтому его смещение больше суточного, а не отрицательное.
*/
function offsetInDay(slotStart: string, dayStart: string): number {
const diff = minutesOf(slotStart) - minutesOf(dayStart)
return diff >= 0 ? diff : diff + 24 * 60
}
/** Слоты, попадающие в колонку дня: слот без дня недели идёт каждый день. */
function slotsOfDay(layers: GridLayerDto[], weekday: number) {
return layers
.filter((layer) => layer.isEnabled)
.flatMap((layer) =>
layer.slots
.filter((slot) => slot.weekday === null || slot.weekday === weekday)
.map((slot) => ({ slot, layer })),
)
}
export function ScheduleGrid({
template,
selectedSlotId,
onSelectSlot,
onAddSlot,
}: {
template: ScheduleTemplateDto
selectedSlotId: string | null
onSelectSlot: (slot: SlotDto) => void
onAddSlot: (weekday: number, startMinutes: number) => void
}) {
const { t } = useTranslation()
const dayStart = template.dayStartTime.slice(0, 5)
const dayStartMinutes = minutesOf(dayStart)
// Подписи часов идут от начала вещательных суток, а не от полуночи.
const hours = Array.from({ length: 24 }, (_, i) => (dayStartMinutes / 60 + i) % 24)
// Слои отсортированы по убыванию приоритета: слот, лежащий под более приоритетным, штрихуем.
const ordered = [...template.layers].sort((a, b) => b.priority - a.priority)
const isCovered = (slot: SlotDto, layer: GridLayerDto, weekday: number) => {
const from = offsetInDay(slot.targetStart, dayStart)
const to = from + slot.targetDurationMinutes
return ordered
.filter((other) => other.isEnabled && other.priority > layer.priority)
.some((other) =>
other.slots
.filter((s) => s.weekday === null || s.weekday === weekday)
.some((s) => {
const otherFrom = offsetInDay(s.targetStart, dayStart)
return from < otherFrom + s.targetDurationMinutes && otherFrom < to
}),
)
}
return (
<div className="crt-panel overflow-x-auto rounded-md">
<div className="min-w-[720px]">
<div className="grid grid-cols-[56px_repeat(7,1fr)] border-b border-border text-xs text-muted-foreground">
<div className="px-2 py-1">{dayStart}</div>
{WEEKDAYS.map((weekday) => (
<div key={weekday} className="px-2 py-1 text-center font-medium">
{t(`admin.channels.weekdays.${weekday}`)}
</div>
))}
</div>
<div className="grid grid-cols-[56px_repeat(7,1fr)]">
<div>
{hours.map((hour, index) => (
<div
key={index}
className="border-b border-border/40 px-2 text-[11px] text-muted-foreground"
style={{ height: HOUR_HEIGHT }}
>
{hour.toString().padStart(2, '0')}:00
</div>
))}
</div>
{WEEKDAYS.map((weekday) => (
<div
key={weekday}
className="relative border-l border-border"
style={{ height: HOUR_HEIGHT * 24 }}
>
{hours.map((_, index) => (
<button
key={index}
type="button"
title={t('admin.channels.addSlotHere')}
className="group absolute inset-x-0 border-b border-border/40 hover:bg-muted/30"
style={{ top: HOUR_HEIGHT * index, height: HOUR_HEIGHT }}
onClick={() => onAddSlot(weekday, (dayStartMinutes + index * 60) % (24 * 60))}
>
<Plus className="mx-auto h-3 w-3 opacity-0 group-hover:opacity-40" />
</button>
))}
{slotsOfDay(ordered, weekday).map(({ slot, layer }) => {
const from = offsetInDay(slot.targetStart, dayStart)
const covered = isCovered(slot, layer, weekday)
return (
<button
key={`${slot.id}-${weekday}`}
type="button"
onClick={() => onSelectSlot(slot)}
className={cn(
'absolute inset-x-1 overflow-hidden rounded border px-1.5 py-0.5 text-left text-[11px] leading-tight',
DAYPART_CLASS[slot.daypart] ?? DAYPART_CLASS.Day,
selectedSlotId === slot.id && 'ring-2 ring-primary',
// Перекрытый слот виден, но приглушён: он не сыграет, пока лежит под старшим слоем.
covered && 'opacity-40 [background-image:repeating-linear-gradient(45deg,transparent,transparent_4px,rgba(0,0,0,.15)_4px,rgba(0,0,0,.15)_8px)]',
)}
style={{
top: (from / 60) * HOUR_HEIGHT,
height: Math.max(16, (slot.targetDurationMinutes / 60) * HOUR_HEIGHT - 2),
}}
>
<span className="flex items-center gap-1 font-medium">
{slot.isAnchor && <Anchor className="h-3 w-3 shrink-0" />}
{slot.targetStart.slice(0, 5)}
</span>
<span className="block truncate">{slot.title}</span>
</button>
)
})}
</div>
))}
</div>
</div>
</div>
)
}
/** Панель слоёв: видимость, приоритет и выбор редактируемого. */
export function LayerList({
template,
activeLayerId,
onSelect,
onDelete,
}: {
template: ScheduleTemplateDto
activeLayerId: string | null
onSelect: (layer: GridLayerDto) => void
onDelete: (layer: GridLayerDto) => void
}) {
const { t } = useTranslation()
const ordered = [...template.layers].sort((a, b) => b.priority - a.priority)
return (
<ul className="divide-y divide-border text-sm">
{ordered.map((layer) => (
<li key={layer.id} className="flex items-center gap-2 py-1.5">
<button
type="button"
className={cn(
'min-w-0 flex-1 truncate text-left',
activeLayerId === layer.id && 'text-primary',
)}
onClick={() => onSelect(layer)}
>
{layer.name}
</button>
<span className="shrink-0 text-xs text-muted-foreground">
{layer.isBackground ? t('admin.channels.background') : layer.priority}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{layer.slots.length}
</span>
{!layer.isBackground && (
<Button size="sm" variant="ghost" onClick={() => onDelete(layer)}>
×
</Button>
)}
</li>
))}
</ul>
)
}
import { Anchor, CalendarRange, Copy, GripVertical, Plus, Repeat } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { GridLayerDto, ScheduleTemplateDto, SlotDto } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { cn } from '@/shared/lib/cn'
import { coversDate, isEmpty } from '../lib/applicability'
const HOUR_HEIGHT = 44
/** Шаг сетки при перетаскивании и растягивании — минуты. */
const SNAP_MINUTES = 15
const snap = (minutes: number) => Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0]
/** Цвет блока — по дейпарту: сетка должна читаться одним взглядом, без легенды. */
const DAYPART_CLASS: Record<string, string> = {
Morning: 'bg-amber-500/20 border-amber-500/40',
Day: 'bg-sky-500/20 border-sky-500/40',
Prime: 'bg-violet-500/25 border-violet-500/50',
Night: 'bg-slate-500/20 border-slate-500/40',
}
function minutesOf(time: string): number {
const [h, m] = time.split(':')
return Number(h) * 60 + Number(m)
}
/**
* Смещение слота от начала вещательных суток. Ночной блок (00:00–06:00 при старте суток в 06:00)
* принадлежит предыдущему дню, поэтому его смещение больше суточного, а не отрицательное.
*/
function offsetInDay(slotStart: string, dayStart: string): number {
const diff = minutesOf(slotStart) - minutesOf(dayStart)
return diff >= 0 ? diff : diff + 24 * 60
}
/** Слоты, попадающие в колонку дня: слот без дня недели идёт каждый день. */
function slotsOfDay(layers: GridLayerDto[], weekday: number) {
return layers
.filter((layer) => layer.isEnabled)
.flatMap((layer) =>
layer.slots
.filter((slot) => slot.weekday === null || slot.weekday === weekday)
.map((slot) => ({ slot, layer })),
)
}
export function ScheduleGrid({
template,
selectedSlotId,
viewDate,
onSelectSlot,
onAddSlot,
onMoveSlot,
onResizeSlot,
onCopyDay,
}: {
template: ScheduleTemplateDto
selectedSlotId: string | null
/** Дата, на которую смотрим сетку («показать 25 декабря»); null — все слои разом. */
viewDate: string | null
onSelectSlot: (slot: SlotDto) => void
onAddSlot: (weekday: number, startMinutes: number) => void
/** Перенос слота: новое время старта и (для слота с днём недели) новый день. */
onMoveSlot: (slot: SlotDto, weekday: number, startMinutes: number) => void
onResizeSlot: (slot: SlotDto, durationMinutes: number) => void
onCopyDay: (fromWeekday: number) => void
}) {
const { t } = useTranslation()
const dayStart = template.dayStartTime.slice(0, 5)
const dayStartMinutes = minutesOf(dayStart)
// Подписи часов идут от начала вещательных суток, а не от полуночи.
const hours = Array.from({ length: 24 }, (_, i) => (dayStartMinutes / 60 + i) % 24)
// На выбранную дату показываем только те слои, которые в этот день действуют, — иначе сетка
// «на 25 декабря» показывала бы и обычный день, и новогодний одновременно.
const day = viewDate ? parseIsoDate(viewDate) : null
const applicable = day
? template.layers.filter((layer) => coversDate(layer.applicability, day))
: template.layers
// Слои отсортированы по убыванию приоритета: слот, лежащий под более приоритетным, штрихуем.
const ordered = [...applicable].sort((a, b) => b.priority - a.priority)
const highlightWeekday = day?.getDay() ?? null
const [dragged, setDragged] = useState<SlotDto | null>(null)
const [resizing, setResizing] = useState<{ slot: SlotDto; minutes: number } | null>(null)
/** Позиция курсора в колонке дня — минуты суток, округлённые до шага сетки. */
const minutesAt = (clientY: number, column: HTMLElement) => {
const rect = column.getBoundingClientRect()
const offset = Math.max(0, Math.min(rect.height, clientY - rect.top))
const fromDayStart = snap((offset / HOUR_HEIGHT) * 60)
return (dayStartMinutes + fromDayStart) % (24 * 60)
}
const drop = (event: React.DragEvent<HTMLDivElement>, weekday: number) => {
event.preventDefault()
if (!dragged) return
// Слот «каждый день» при переносе таким и остаётся: молча превратить его в слот одного дня
// значило бы убрать его сразу из шести колонок.
onMoveSlot(dragged, dragged.weekday ?? weekday, minutesAt(event.clientY, event.currentTarget))
setDragged(null)
}
/** Растягивание за нижний край: пока тянем — видно новую высоту, отпустили — сохраняем. */
const startResize = (event: React.MouseEvent, slot: SlotDto, column: HTMLElement) => {
event.preventDefault()
event.stopPropagation()
const from = offsetInDay(slot.targetStart, dayStart)
const move = (moveEvent: MouseEvent) => {
const rect = column.getBoundingClientRect()
const offset = Math.max(0, Math.min(rect.height, moveEvent.clientY - rect.top))
const end = snap((offset / HOUR_HEIGHT) * 60)
setResizing({ slot, minutes: Math.max(SNAP_MINUTES, end - from) })
}
const up = () => {
window.removeEventListener('mousemove', move)
window.removeEventListener('mouseup', up)
setResizing((current) => {
if (current && current.minutes !== slot.targetDurationMinutes)
onResizeSlot(slot, current.minutes)
return null
})
}
window.addEventListener('mousemove', move)
window.addEventListener('mouseup', up)
}
const isCovered = (slot: SlotDto, layer: GridLayerDto, weekday: number) => {
const from = offsetInDay(slot.targetStart, dayStart)
const to = from + slot.targetDurationMinutes
return ordered
.filter((other) => other.isEnabled && other.priority > layer.priority)
.some((other) =>
other.slots
.filter((s) => s.weekday === null || s.weekday === weekday)
.some((s) => {
const otherFrom = offsetInDay(s.targetStart, dayStart)
return from < otherFrom + s.targetDurationMinutes && otherFrom < to
}),
)
}
return (
<div className="crt-panel overflow-x-auto rounded-md">
<div className="min-w-[720px]">
<div className="grid grid-cols-[56px_repeat(7,1fr)] border-b border-border text-xs text-muted-foreground">
<div className="px-2 py-1">{dayStart}</div>
{WEEKDAYS.map((weekday) => (
<div
key={weekday}
className={cn(
'flex items-center justify-center gap-1 px-2 py-1 font-medium',
highlightWeekday === weekday && 'text-primary',
)}
>
{t(`admin.channels.weekdays.${weekday}`)}
<button
type="button"
title={t('admin.channels.copyDay')}
className="opacity-40 hover:opacity-100"
onClick={() => onCopyDay(weekday)}
>
<Copy className="h-3 w-3" />
</button>
</div>
))}
</div>
<div className="grid grid-cols-[56px_repeat(7,1fr)]">
<div>
{hours.map((hour, index) => (
<div
key={index}
className="border-b border-border/40 px-2 text-[11px] text-muted-foreground"
style={{ height: HOUR_HEIGHT }}
>
{hour.toString().padStart(2, '0')}:00
</div>
))}
</div>
{WEEKDAYS.map((weekday) => (
<div
key={weekday}
className={cn(
'relative border-l border-border',
highlightWeekday === weekday && 'bg-primary/5',
dragged && 'bg-primary/10',
)}
style={{ height: HOUR_HEIGHT * 24 }}
onDragOver={(e) => dragged && e.preventDefault()}
onDrop={(e) => drop(e, weekday)}
>
{hours.map((_, index) => (
<button
key={index}
type="button"
title={t('admin.channels.addSlotHere')}
className="group absolute inset-x-0 border-b border-border/40 hover:bg-muted/30"
style={{ top: HOUR_HEIGHT * index, height: HOUR_HEIGHT }}
onClick={() => onAddSlot(weekday, (dayStartMinutes + index * 60) % (24 * 60))}
>
<Plus className="mx-auto h-3 w-3 opacity-0 group-hover:opacity-40" />
</button>
))}
{slotsOfDay(ordered, weekday).map(({ slot, layer }) => {
const from = offsetInDay(slot.targetStart, dayStart)
const covered = isCovered(slot, layer, weekday)
const minutes =
resizing?.slot.id === slot.id ? resizing.minutes : slot.targetDurationMinutes
return (
<div
key={`${slot.id}-${weekday}`}
draggable
onDragStart={() => setDragged(slot)}
onDragEnd={() => setDragged(null)}
onClick={() => onSelectSlot(slot)}
className={cn(
'absolute inset-x-1 cursor-grab overflow-hidden rounded border px-1.5 py-0.5 text-left text-[11px] leading-tight',
DAYPART_CLASS[slot.daypart] ?? DAYPART_CLASS.Day,
selectedSlotId === slot.id && 'ring-2 ring-primary',
dragged?.id === slot.id && 'opacity-50',
// Перекрытый слот виден, но приглушён: он не сыграет, пока лежит под старшим слоем.
covered && 'opacity-40 [background-image:repeating-linear-gradient(45deg,transparent,transparent_4px,rgba(0,0,0,.15)_4px,rgba(0,0,0,.15)_8px)]',
)}
style={{
top: (from / 60) * HOUR_HEIGHT,
height: Math.max(16, (minutes / 60) * HOUR_HEIGHT - 2),
}}
>
<span className="flex items-center gap-1 font-medium">
{slot.isAnchor && <Anchor className="h-3 w-3 shrink-0" />}
{slot.targetStart.slice(0, 5)}
{slot.weekday === null && <Repeat className="h-3 w-3 shrink-0 opacity-60" />}
</span>
<span className="block truncate">{slot.title}</span>
<span
role="presentation"
title={t('admin.channels.resizeSlot')}
className="absolute inset-x-0 bottom-0 h-1.5 cursor-ns-resize hover:bg-primary/40"
onMouseDown={(e) =>
startResize(e, slot, e.currentTarget.parentElement!.parentElement!)
}
/>
</div>
)
})}
</div>
))}
</div>
</div>
</div>
)
}
/**
* Панель слоёв: видимость, приоритет (перетаскиванием), применимость и выбор редактируемого.
* Выше в списке — приоритетнее; фоновый слой всегда внизу и не двигается.
*/
export function LayerList({
template,
activeLayerId,
viewDate,
onSelect,
onDelete,
onToggle,
onReorder,
onEditApplicability,
}: {
template: ScheduleTemplateDto
activeLayerId: string | null
viewDate: string | null
onSelect: (layer: GridLayerDto) => void
onDelete: (layer: GridLayerDto) => void
onToggle: (layer: GridLayerDto) => void
onReorder: (layerIdsTopFirst: string[]) => void
onEditApplicability: (layer: GridLayerDto) => void
}) {
const { t } = useTranslation()
const [dragged, setDragged] = useState<string | null>(null)
const ordered = [...template.layers].sort((a, b) => b.priority - a.priority)
const day = viewDate ? parseIsoDate(viewDate) : null
const dropOn = (targetId: string) => {
if (!dragged || dragged === targetId) return
const movable = ordered.filter((l) => !l.isBackground).map((l) => l.id)
const order = movable.filter((id) => id !== dragged)
const at = order.indexOf(targetId)
// Бросок на фоновый слой означает «в самый низ»: он в порядке не участвует.
order.splice(at === -1 ? order.length : at, 0, dragged)
setDragged(null)
onReorder(order)
}
return (
<ul className="divide-y divide-border text-sm">
{ordered.map((layer) => {
const inactiveToday = day !== null && !coversDate(layer.applicability, day)
return (
<li
key={layer.id}
draggable={!layer.isBackground}
onDragStart={() => setDragged(layer.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => dropOn(layer.id)}
className={cn('flex items-center gap-1.5 py-1.5', inactiveToday && 'opacity-40')}
>
{layer.isBackground ? (
<span className="w-4 shrink-0" />
) : (
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
)}
<input
type="checkbox"
className="shrink-0"
title={t('admin.channels.layerVisible')}
checked={layer.isEnabled}
onChange={() => onToggle(layer)}
/>
<button
type="button"
className={cn(
'min-w-0 flex-1 truncate text-left',
activeLayerId === layer.id && 'text-primary',
)}
onClick={() => onSelect(layer)}
>
{layer.name}
</button>
<span className="shrink-0 text-xs text-muted-foreground">
{layer.isBackground ? t('admin.channels.background') : layer.slots.length}
</span>
{!layer.isBackground && (
<>
<Button
size="sm"
variant="ghost"
title={t('admin.channels.layerApplicability')}
onClick={() => onEditApplicability(layer)}
>
<CalendarRange
className={cn(
'h-4 w-4',
isEmpty(layer.applicability) ? 'text-muted-foreground' : 'text-primary',
)}
/>
</Button>
<Button size="sm" variant="ghost" onClick={() => onDelete(layer)}>
×
</Button>
</>
)}
</li>
)
})}
</ul>
)
}
/** «2026-12-25» → локальная дата. `new Date(iso)` разобрал бы её как UTC и сместил день. */
function parseIsoDate(iso: string): Date {
const [year, month, day] = iso.split('-').map(Number)
return new Date(year, month - 1, day)
}
@@ -1,52 +1,67 @@
import { useTranslation } from 'react-i18next'
import type { ScheduleEntryDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { formatTime } from '../lib/format'
export function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) {
const { t } = useTranslation()
if (entries.length === 0)
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
return (
<ul className="flex flex-col divide-y divide-border text-sm">
{entries.slice(0, 40).map((e) => (
<li key={e.id} className="flex items-center gap-3 py-1.5">
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
{formatTime(e.startsAtUtc)}
</span>
{e.kind === 'Ad' ? (
<Badge variant="muted">{t('air.ad')}</Badge>
) : e.kind === 'Bumper' ? (
<span className="flex min-w-0 items-center gap-2">
<Badge variant="muted" className="shrink-0">
{t('air.bumper')}
</Badge>
{(e.bumperName || e.bumperText) && (
<span className="min-w-0 truncate text-muted-foreground">
{e.bumperName}
{e.bumperName && e.bumperText ? ' · ' : ''}
{e.bumperText}
</span>
)}
</span>
) : (
<span>
{e.showName ?? ''}
{e.seasonEpisode ? (
<span className="text-muted-foreground"> · {e.seasonEpisode}</span>
) : (
e.episodeIndex != null && (
<span className="text-muted-foreground">
{' '}
· {t('air.episode')} {e.episodeIndex + 1}
</span>
)
)}
</span>
)}
</li>
))}
</ul>
)
}
import { HelpCircle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { ScheduleEntryDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { formatTime } from '../lib/format'
export function SchedulePreview({
entries,
onShowTrace,
}: {
entries: ScheduleEntryDto[]
onShowTrace: (entryId: string) => void
}) {
const { t } = useTranslation()
if (entries.length === 0)
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
return (
<ul className="flex flex-col divide-y divide-border text-sm">
{entries.slice(0, 40).map((e) => (
<li key={e.id} className="group flex items-center gap-3 py-1.5">
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
{formatTime(e.startsAtUtc)}
</span>
{e.kind === 'Ad' ? (
<Badge variant="muted">{t('air.ad')}</Badge>
) : e.kind === 'Bumper' ? (
<span className="flex min-w-0 items-center gap-2">
<Badge variant="muted" className="shrink-0">
{t('air.bumper')}
</Badge>
{(e.bumperName || e.bumperText) && (
<span className="min-w-0 truncate text-muted-foreground">
{e.bumperName}
{e.bumperName && e.bumperText ? ' · ' : ''}
{e.bumperText}
</span>
)}
</span>
) : (
<span>
{e.showName ?? '—'}
{e.seasonEpisode ? (
<span className="text-muted-foreground"> · {e.seasonEpisode}</span>
) : (
e.episodeIndex != null && (
<span className="text-muted-foreground">
{' '}
· {t('air.episode')} {e.episodeIndex + 1}
</span>
)
)}
</span>
)}
<button
type="button"
title={t('admin.channels.whyHere')}
className="ml-auto shrink-0 text-muted-foreground opacity-0 hover:text-foreground group-hover:opacity-100"
onClick={() => onShowTrace(e.id)}
>
<HelpCircle className="h-4 w-4" />
</button>
</li>
))}
</ul>
)
}
@@ -16,7 +16,14 @@ import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { toast } from '@/shared/ui/toast-store'
import { createSlot, deleteSlot, listJunctions, updateSlot, type SlotBody } from '../api'
import {
createSlot,
deleteSlot,
listJunctions,
toSlotBody,
updateSlot,
type SlotBody,
} from '../api'
const DAYPARTS: Daypart[] = ['Morning', 'Day', 'Prime', 'Night']
const SLOT_KINDS: SlotKind[] = ['Content', 'Repeat', 'SignOff']
@@ -28,11 +35,6 @@ const SNAP_OPTIONS = [0, 5, 10, 15, 30]
/** Черновик слота: новый (layerId + предзаполненные время/день) либо существующий. */
export type SlotDraft = { layerId: string; slot: SlotDto | null; defaults?: Partial<SlotBody> }
function toBody(slot: SlotDto): SlotBody {
const { id: _id, layerId: _layerId, groupName: _groupName, ...body } = slot
return body
}
function emptyBody(defaults?: Partial<SlotBody>): SlotBody {
return {
title: '',
@@ -74,11 +76,11 @@ export function SlotInspector({
}) {
const { t } = useTranslation()
const [body, setBody] = useState<SlotBody>(() =>
draft.slot ? toBody(draft.slot) : emptyBody(draft.defaults),
draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults),
)
useEffect(() => {
setBody(draft.slot ? toBody(draft.slot) : emptyBody(draft.defaults))
setBody(draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults))
}, [draft])
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
@@ -0,0 +1,88 @@
import { useQuery } from '@tanstack/react-query'
import { AlertTriangle, CircleAlert } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { SlotDto, TemplateIssueDto } from '@/shared/api/types'
import { cn } from '@/shared/lib/cn'
import { getTemplateIssues } from '../api'
/**
* Проверки по правилам (см. 5.1). Считаются на сервере по шаблону, без прогона генератора, поэтому
* показываются прямо в редакторе и обновляются вместе с сеткой.
*/
export function TemplateIssues({
channelId,
slotsById,
onGoToSlot,
}: {
channelId: string
slotsById: Map<string, SlotDto>
onGoToSlot: (slot: SlotDto) => void
}) {
const { t } = useTranslation()
const { data: issues } = useQuery({
queryKey: ['admin', 'channels', channelId, 'issues'],
queryFn: () => getTemplateIssues(channelId),
})
if (!issues || issues.length === 0) return null
const errors = issues.filter((i) => i.severity === 'Error')
const warnings = issues.filter((i) => i.severity === 'Warning')
return (
<div className="flex flex-col gap-1 rounded-md border border-border px-3 py-2 text-xs">
<span className="font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.issues', { errors: errors.length, warnings: warnings.length })}
</span>
<ul className="flex flex-col gap-0.5">
{[...errors, ...warnings].map((issue, index) => (
<IssueRow
key={`${issue.kind}-${index}`}
issue={issue}
slot={issue.slotId ? slotsById.get(issue.slotId) : undefined}
onGoToSlot={onGoToSlot}
/>
))}
</ul>
</div>
)
}
function IssueRow({
issue,
slot,
onGoToSlot,
}: {
issue: TemplateIssueDto
slot: SlotDto | undefined
onGoToSlot: (slot: SlotDto) => void
}) {
const { t } = useTranslation()
const Icon = issue.severity === 'Error' ? CircleAlert : AlertTriangle
return (
<li className="flex items-start gap-1.5">
<Icon
className={cn(
'mt-0.5 h-3 w-3 shrink-0',
issue.severity === 'Error' ? 'text-red-500' : 'text-amber-500',
)}
/>
<span className="min-w-0 flex-1">
<span className="text-muted-foreground">
{t(`admin.channels.issueKinds.${issue.kind}`)}:{' '}
</span>
{issue.details}
</span>
{slot && (
<button
type="button"
className="shrink-0 text-primary hover:underline"
onClick={() => onGoToSlot(slot)}
>
{t('admin.channels.goToSlot')}
</button>
)}
</li>
)
}
@@ -1,205 +1,327 @@
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>
)
}
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'
import { toIsoDate } from '../lib/applicability'
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' | 'problems'>('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', 'problems'] 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}`)}
{value === 'problems' && data.warnings.length > 0 && ` · ${data.warnings.length}`}
</button>
))}
</div>
{tab === 'programme' && <Programme preview={data} />}
{tab === 'tape' && <Tape preview={data} />}
{tab === 'problems' && <Problems preview={data} />}
</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>
)
}
/** Предупреждения, сгруппированные по виду: десять однотипных строк читаются как одна проблема. */
function Problems({ preview }: { preview: SchedulePreviewDto }) {
const { t } = useTranslation()
const grouped = useMemo(() => {
const map = new Map<string, string[]>()
for (const warning of preview.warnings) {
const list = map.get(warning.kind) ?? []
list.push(warning.details)
map.set(warning.kind, list)
}
return [...map.entries()]
}, [preview])
return (
<div className="flex flex-col gap-4">
{grouped.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('admin.channels.noProblems')}</p>
) : (
<ul className="flex flex-col gap-2 text-xs">
{grouped.map(([kind, details]) => (
<li key={kind} className="flex flex-col gap-0.5">
<span className="font-medium text-amber-500">
{t(`admin.channels.warnings.${kind}`)} · {details.length}
</span>
{details.slice(0, 20).map((detail, index) => (
<span key={index} className="text-muted-foreground">
{detail}
</span>
))}
{details.length > 20 && (
<span className="text-muted-foreground">
{t('admin.channels.andMore', { count: details.length - 20 })}
</span>
)}
</li>
))}
</ul>
)}
<RepeatHeatmap preview={preview} />
</div>
)
}
/**
* Тепловая карта повторов: матрица «шоу × вещательные сутки», яркость — число показов. Сразу видно,
* что один фильм крутится четыре раза за неделю.
*/
function RepeatHeatmap({ preview }: { preview: SchedulePreviewDto }) {
const { t } = useTranslation()
const { days, rows } = useMemo(() => {
const counts = new Map<string, Map<string, number>>()
const dayKeys = new Set<string>()
for (const item of preview.items) {
if (item.kind !== 'Program' || !item.title) continue
const day = toIsoDate(channelTime(item.startsAtUtc, preview.utcOffsetMinutes))
dayKeys.add(day)
const row = counts.get(item.title) ?? new Map<string, number>()
row.set(day, (row.get(day) ?? 0) + 1)
counts.set(item.title, row)
}
const sortedDays = [...dayKeys].sort()
const sortedRows = [...counts.entries()]
.map(([title, byDay]) => ({
title,
byDay,
total: [...byDay.values()].reduce((sum, n) => sum + n, 0),
}))
.sort((a, b) => b.total - a.total)
.slice(0, 25)
return { days: sortedDays, rows: sortedRows }
}, [preview])
if (rows.length === 0) return null
const peak = Math.max(1, ...rows.flatMap((row) => [...row.byDay.values()]))
return (
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.heatmap')}
</span>
<div className="overflow-x-auto">
<table className="text-[11px]">
<thead className="text-muted-foreground">
<tr>
<th className="px-1 text-left font-medium" />
{days.map((day) => (
<th key={day} className="px-1 font-medium">
{day.slice(8)}.{day.slice(5, 7)}
</th>
))}
<th className="px-1 font-medium">{t('admin.channels.heatmapTotal')}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.title}>
<td className="max-w-56 truncate px-1" title={row.title}>
{row.title}
</td>
{days.map((day) => {
const count = row.byDay.get(day) ?? 0
return (
<td key={day} className="px-0.5 py-0.5">
<span
className="block h-4 w-6 rounded-sm bg-primary text-center text-[10px] leading-4"
style={{ opacity: count === 0 ? 0.06 : 0.25 + (count / peak) * 0.75 }}
>
{count > 0 ? count : ''}
</span>
</td>
)
})}
<td className="px-1 tabular-nums text-muted-foreground">{row.total}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,146 @@
import { useMutation } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { imageUrl } from '@/features/admin/images/api'
import { ImageGallery } from '@/features/admin/images/ImageGallery'
import type { ChannelDto, LogoCorner, ViewerSettings } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { updateViewerSettings } from '../api'
import { CollapsibleCard } from './CollapsibleCard'
const CORNERS: LogoCorner[] = ['TopLeft', 'TopRight', 'BottomLeft', 'BottomRight']
/**
* Как канал выглядит у зрителя (см. 6.8): логотип, часы, аналоговый фильтр. Всё рисуется на клиенте
* поверх видео и по умолчанию выключено — канал без логотипа и без шума остаётся нормальным каналом.
*/
export function ViewerCard({
channel,
onSaved,
onError,
}: {
channel: ChannelDto
onSaved: () => void
onError: (error: unknown) => void
}) {
const { t } = useTranslation()
const [viewer, setViewer] = useState<ViewerSettings>(channel.viewer)
const [galleryOpen, setGalleryOpen] = useState(false)
useEffect(() => setViewer(channel.viewer), [channel])
const patch = (part: Partial<ViewerSettings>) => setViewer((prev) => ({ ...prev, ...part }))
const save = useMutation({
mutationFn: () => updateViewerSettings(channel.id, viewer),
onSuccess: onSaved,
onError,
})
return (
<CollapsibleCard title={t('admin.channels.viewer')}>
<div className="flex flex-col gap-4 text-sm">
<p className="text-xs text-muted-foreground">{t('admin.channels.viewerHint')}</p>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.logo')}</Label>
<div className="flex h-16 w-24 items-center justify-center rounded-md border border-border bg-muted/30">
{viewer.logoImageId ? (
<img
src={imageUrl(viewer.logoImageId)}
alt=""
className="h-full w-full object-contain"
/>
) : (
<span className="text-xs text-muted-foreground">{t('admin.channels.noLogo')}</span>
)}
</div>
</div>
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
{t('admin.channels.pickLogo')}
</Button>
{viewer.logoImageId && (
<Button size="sm" variant="ghost" onClick={() => patch({ logoImageId: null })}>
{t('common.delete')}
</Button>
)}
<ImageGallery
open={galleryOpen}
onOpenChange={setGalleryOpen}
category="Library"
onSelect={(image) => patch({ logoImageId: image.id })}
/>
</div>
{viewer.logoImageId && (
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.logoCorner')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={viewer.logoCorner}
onChange={(e) => patch({ logoCorner: e.target.value as LogoCorner })}
>
{CORNERS.map((corner) => (
<option key={corner} value={corner}>
{t(`admin.channels.corners.${corner}`)}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.logoOpacity')}</Label>
<Input
type="number"
min={0}
max={1}
step={0.05}
className="w-28"
value={viewer.logoOpacity}
onChange={(e) => patch({ logoOpacity: clamp01(e.target.value) })}
/>
</div>
</div>
)}
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={viewer.showClock}
onChange={(e) => patch({ showClock: e.target.checked })}
/>
{t('admin.channels.showClock')}
</label>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.analogFilter')}</Label>
<Input
type="number"
min={0}
max={1}
step={0.1}
className="w-28"
value={viewer.analogFilterStrength}
onChange={(e) => patch({ analogFilterStrength: clamp01(e.target.value) })}
/>
<p className="text-xs text-muted-foreground">{t('admin.channels.analogFilterHint')}</p>
</div>
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</div>
</CollapsibleCard>
)
}
/** Сила и прозрачность живут в 0..1: пустой ввод трактуем как ноль, а не как NaN. */
function clamp01(value: string): number {
const n = Number(value)
return Number.isNaN(n) ? 0 : Math.min(1, Math.max(0, n))
}
@@ -0,0 +1,45 @@
import type { LayerApplicability } from '@/shared/api/types'
/**
* Действует ли слой в эту дату вещательных суток. Зеркало серверного `LayerApplicability.Covers`
* (`TeleWave.Application/Programming/Templates/LayerApplicability.cs`) — источник правды там,
* здесь только подсветка сетки. Меняя одно, правьте второе.
*/
export function coversDate(applicability: LayerApplicability | null, date: Date): boolean {
if (!applicability || isEmpty(applicability)) return true
const weekday = date.getDay()
if (applicability.weekdays?.includes(weekday)) return true
const iso = toIsoDate(date)
if (applicability.specificDates?.includes(iso)) return true
if (applicability.dateRanges?.some((r) => iso >= r.from && iso <= r.to)) return true
// Ежегодный период может пересекать Новый год — сравниваем по паре (месяц, день).
const value = (date.getMonth() + 1) * 100 + date.getDate()
return (
applicability.annualRanges?.some((r) => {
const from = r.fromMonth * 100 + r.fromDay
const to = r.toMonth * 100 + r.toDay
return from <= to ? value >= from && value <= to : value >= from || value <= to
}) ?? false
)
}
export function isEmpty(applicability: LayerApplicability | null): boolean {
if (!applicability) return true
return (
!applicability.weekdays?.length &&
!applicability.dateRanges?.length &&
!applicability.annualRanges?.length &&
!applicability.specificDates?.length
)
}
/** «2026-12-25» из локальной даты — без ухода в UTC, иначе вечер уезжает на день назад. */
export function toIsoDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(
date.getDate(),
).padStart(2, '0')}`
}
@@ -10,6 +10,12 @@ export function formatTime(iso: string | null) {
})
}
/** Минуты суток → «HH:MM:00» — формат `TimeOnly` на сервере. */
export function toTime(minutes: number): string {
const m = ((minutes % (24 * 60)) + 24 * 60) % (24 * 60)
return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}:00`
}
/** Минуты суток → «HH:MM». */
export function formatMinute(minute: number | null) {
if (minute == null) return '—'
@@ -1,85 +1,108 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store'
import { getSiteSettings, updateSiteSettings } from './api'
export function SettingsPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [registrationEnabled, setRegistrationEnabled] = useState(false)
const [preferredAudioLanguages, setPreferredAudioLanguages] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['admin', 'settings'],
queryFn: getSiteSettings,
})
useEffect(() => {
if (data) {
setRegistrationEnabled(data.registrationEnabled)
setPreferredAudioLanguages(data.preferredAudioLanguages)
}
}, [data])
const save = useMutation({
mutationFn: () => updateSiteSettings({ registrationEnabled, preferredAudioLanguages }),
onSuccess: () => {
toast.success(t('settings.saved'))
void queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
},
onError: (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
})
return (
<div className="flex flex-col gap-4">
<h2 className="crt-glow text-xl font-semibold">{t('admin.settings.title')}</h2>
<Card>
<CardHeader>
<CardTitle>{t('admin.settings.registration')}</CardTitle>
<CardDescription>{t('admin.settings.registrationHint')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={registrationEnabled}
disabled={isLoading}
onChange={(e) => setRegistrationEnabled(e.target.checked)}
/>
{t('admin.settings.registrationLabel')}
</label>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium" htmlFor="preferred-audio">
{t('admin.settings.preferredAudio')}
</label>
<Input
id="preferred-audio"
className="max-w-xs"
placeholder="rus, eng"
value={preferredAudioLanguages}
disabled={isLoading}
onChange={(e) => setPreferredAudioLanguages(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{t('admin.settings.preferredAudioHint')}
</p>
</div>
<div>
<Button size="sm" disabled={isLoading || save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store'
import { getSiteSettings, updateSiteSettings } from './api'
export function SettingsPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [registrationEnabled, setRegistrationEnabled] = useState(false)
const [preferredAudioLanguages, setPreferredAudioLanguages] = useState('')
const [channelNumbersEnabled, setChannelNumbersEnabled] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['admin', 'settings'],
queryFn: getSiteSettings,
})
useEffect(() => {
if (data) {
setRegistrationEnabled(data.registrationEnabled)
setPreferredAudioLanguages(data.preferredAudioLanguages)
setChannelNumbersEnabled(data.channelNumbersEnabled)
}
}, [data])
const save = useMutation({
mutationFn: () =>
updateSiteSettings({
registrationEnabled,
preferredAudioLanguages,
channelNumbersEnabled,
}),
onSuccess: () => {
toast.success(t('settings.saved'))
void queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
},
onError: (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
})
return (
<div className="flex flex-col gap-4">
<h2 className="crt-glow text-xl font-semibold">{t('admin.settings.title')}</h2>
<Card>
<CardHeader>
<CardTitle>{t('admin.settings.registration')}</CardTitle>
<CardDescription>{t('admin.settings.registrationHint')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={registrationEnabled}
disabled={isLoading}
onChange={(e) => setRegistrationEnabled(e.target.checked)}
/>
{t('admin.settings.registrationLabel')}
</label>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium" htmlFor="preferred-audio">
{t('admin.settings.preferredAudio')}
</label>
<Input
id="preferred-audio"
className="max-w-xs"
placeholder="rus, eng"
value={preferredAudioLanguages}
disabled={isLoading}
onChange={(e) => setPreferredAudioLanguages(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{t('admin.settings.preferredAudioHint')}
</p>
</div>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
className="mt-1"
checked={channelNumbersEnabled}
disabled={isLoading}
onChange={(e) => setChannelNumbersEnabled(e.target.checked)}
/>
<span>
{t('admin.settings.channelNumbers')}
<span className="block text-xs text-muted-foreground">
{t('admin.settings.channelNumbersHint')}
</span>
</span>
</label>
<div>
<Button size="sm" disabled={isLoading || save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
+325 -261
View File
@@ -1,261 +1,325 @@
import { useQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Radio, RotateCw } from 'lucide-react'
import type { PublicEpgEntryDto } from '@/shared/api/types'
import { cn } from '@/shared/lib/cn'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { ChannelPlayer } from './ChannelPlayer'
import { getEpg, imageUrl, listChannels, watchChannel } from './api'
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
export function AirPage() {
const { t } = useTranslation()
const [selected, setSelected] = useState<string | null>(null)
const [watchReady, setWatchReady] = useState(false)
const [playerError, setPlayerError] = useState(false)
const [attempt, setAttempt] = useState(0)
const handleUnavailable = useCallback(() => setPlayerError(true), [])
const retry = () => {
setPlayerError(false)
setAttempt((a) => a + 1)
}
const { data: channels, isLoading } = useQuery({
queryKey: ['air', 'channels'],
queryFn: listChannels,
})
useEffect(() => {
if (!selected && channels && channels.length > 0) setSelected(channels[0].slug)
}, [channels, selected])
useEffect(() => {
if (!selected) return
setWatchReady(false)
setPlayerError(false)
let cancelled = false
void watchChannel(selected)
.then(() => {
if (!cancelled) setWatchReady(true)
})
.catch(() => {
// Выдача stream-cookie не удалась (403/500/сеть) — показываем offline-панель с кнопкой ретрая,
// а не бесконечный скелетон. Ретрай (attempt) заново дёрнет watchChannel.
if (!cancelled) {
setWatchReady(true)
setPlayerError(true)
}
})
return () => {
cancelled = true
}
}, [selected, attempt])
// Stream-cookie короткоживущий (TTL на сервере ~30 мин) — периодически перевыпускаем, пока смотрим,
// иначе плейлист/сегменты начнут отдавать 401 посреди эфира. Тихо: ошибку словит перезагрузка плейлиста.
useEffect(() => {
if (!selected || playerError) return
const id = window.setInterval(
() => {
void watchChannel(selected).catch(() => undefined)
},
20 * 60_000,
)
return () => window.clearInterval(id)
}, [selected, playerError])
const { data: epg } = useQuery({
queryKey: ['air', 'epg', selected],
queryFn: () =>
getEpg(
selected!,
new Date(Date.now() - 30 * 60_000),
new Date(Date.now() + 3 * 60 * 60_000),
),
enabled: !!selected,
refetchInterval: 60_000,
})
const { current, upcoming, currentEntry } = useMemo(() => buildGuide(epg ?? []), [epg])
if (isLoading) return <p className="text-muted-foreground">{t('common.loading')}</p>
if (!channels || channels.length === 0)
return (
<div className="flex flex-col gap-2">
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
<p className="text-muted-foreground">{t('air.noChannels')}</p>
</div>
)
return (
<div className="flex flex-col gap-4">
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
<aside className="flex gap-2 overflow-x-auto md:flex-col md:overflow-visible">
{channels.map((channel) => (
<button
key={channel.id}
onClick={() => setSelected(channel.slug)}
className={cn(
'flex shrink-0 items-center gap-2 rounded-sm border border-border px-3 py-2 text-left text-sm hover:bg-muted md:shrink',
selected === channel.slug && 'border-primary text-primary',
)}
>
{channel.currentShowPosterImageId ? (
<img
src={imageUrl(channel.currentShowPosterImageId)}
alt=""
className="h-10 w-7 shrink-0 rounded object-cover"
/>
) : (
<Radio className="h-4 w-4 shrink-0" />
)}
<span className="flex min-w-0 flex-col">
<span className="truncate">{channel.name}</span>
{channel.currentShowName && (
<span className="truncate text-xs text-muted-foreground">
{channel.currentShowName}
</span>
)}
</span>
</button>
))}
</aside>
<div className="flex flex-col gap-4">
{selected && watchReady ? (
playerError ? (
<div className="crt-panel flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-md text-center">
<Radio className="h-10 w-10 text-muted-foreground" strokeWidth={1} />
<div className="flex flex-col gap-1">
<p className="font-medium">{t('air.offline')}</p>
<p className="text-sm text-muted-foreground">{t('air.offlineHint')}</p>
</div>
<Button size="sm" variant="outline" onClick={retry}>
<RotateCw className="h-4 w-4" />
{t('air.retry')}
</Button>
</div>
) : (
<ChannelPlayer
key={`${selected}-${attempt}`}
slug={selected}
onUnavailable={handleUnavailable}
/>
)
) : (
<div className="aspect-video w-full animate-pulse rounded-md border border-border bg-black" />
)}
<div className="flex flex-col gap-3">
{current && (
<div className="crt-panel flex gap-3 rounded-md p-3">
{currentEntry?.episodeStillImageId ? (
<img
src={imageUrl(currentEntry.episodeStillImageId)}
alt=""
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
/>
) : currentEntry?.showPosterImageId ? (
<img
src={imageUrl(currentEntry.showPosterImageId)}
alt=""
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
/>
) : null}
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2">
<Badge>{t('air.now')}</Badge>
<span className="font-medium">{current.showName}</span>
</div>
{currentEntry?.episodeTitle && (
<span className="text-sm">{currentEntry.episodeTitle}</span>
)}
<span className="text-xs text-muted-foreground">
{formatTime(current.startsAtUtc)} {formatTime(current.endsAtUtc)}
</span>
{currentEntry?.episodeOverview && (
<p className="line-clamp-3 text-xs text-muted-foreground">
{currentEntry.episodeOverview}
</p>
)}
</div>
</div>
)}
{upcoming.length > 0 && (
<div className="crt-panel rounded-md">
<div className="border-b border-border px-4 py-2 text-xs uppercase tracking-wide text-muted-foreground">
{t('air.next')}
</div>
<ul className="divide-y divide-border">
{upcoming.slice(0, 6).map((block) => (
<li key={block.key} className="flex items-center gap-3 px-4 py-2 text-sm">
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
{formatTime(block.startsAtUtc)} {formatTime(block.endsAtUtc)}
</span>
<span>{block.showName}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
</div>
)
}
type GuideBlock = {
key: string
showId: string | null
showName: string
startsAtUtc: string
endsAtUtc: string
}
/**
* Строит телегид: рекламу/заставки не показываем, а подряд идущие серии одного шоу склеиваем в один
* блок с диапазоном «с – по». Отдельно возвращаем текущую серию (для метаданных карточки «сейчас»).
*/
function buildGuide(entries: PublicEpgEntryDto[]): {
current?: GuideBlock
upcoming: GuideBlock[]
currentEntry?: PublicEpgEntryDto
} {
const blocks: GuideBlock[] = []
for (const entry of entries) {
if (entry.kind !== 'Program') continue
const last = blocks[blocks.length - 1]
if (last && last.showId === entry.showId) {
last.endsAtUtc = entry.endsAtUtc
} else {
blocks.push({
key: entry.startsAtUtc,
showId: entry.showId,
showName: entry.showName ?? '—',
startsAtUtc: entry.startsAtUtc,
endsAtUtc: entry.endsAtUtc,
})
}
}
const now = Date.now()
const active = (start: string, end: string) =>
new Date(start).getTime() <= now && new Date(end).getTime() > now
const current = blocks.find((b) => active(b.startsAtUtc, b.endsAtUtc))
const upcoming = blocks.filter((b) => new Date(b.startsAtUtc).getTime() > now)
const currentEntry = entries.find(
(e) => e.kind === 'Program' && active(e.startsAtUtc, e.endsAtUtc),
)
return { current, upcoming, currentEntry }
}
import { useQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Radio, RotateCw } from 'lucide-react'
import type { PublicEpgEntryDto } from '@/shared/api/types'
import { cn } from '@/shared/lib/cn'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { ChannelPlayer } from './ChannelPlayer'
import { getEpg, getViewerFeatures, imageUrl, listChannels, watchChannel } from './api'
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
export function AirPage() {
const { t } = useTranslation()
const [selected, setSelected] = useState<string | null>(null)
const [watchReady, setWatchReady] = useState(false)
const [playerError, setPlayerError] = useState(false)
const [attempt, setAttempt] = useState(0)
const [flash, setFlash] = useState(false)
const handleUnavailable = useCallback(() => setPlayerError(true), [])
const retry = () => {
setPlayerError(false)
setAttempt((a) => a + 1)
}
const { data: channels, isLoading } = useQuery({
queryKey: ['air', 'channels'],
queryFn: listChannels,
})
const { data: features } = useQuery({ queryKey: ['air', 'features'], queryFn: getViewerFeatures })
const numbersEnabled = features?.channelNumbersEnabled ?? false
const currentChannel = channels?.find((c) => c.slug === selected)
/**
* Переключение по номерам: список уже отсортирован сервером, поэтому «вверх-вниз» — это шаг
* по нему. Короткий чёрный кадр с номером ставится сразу, до готовности потока.
*/
const step = useCallback(
(delta: number) => {
if (!channels || channels.length === 0) return
const index = channels.findIndex((c) => c.slug === selected)
const next = channels[(index + delta + channels.length) % channels.length]
if (!next || next.slug === selected) return
setFlash(true)
setSelected(next.slug)
},
[channels, selected],
)
useEffect(() => {
if (!flash) return
const id = window.setTimeout(() => setFlash(false), 900)
return () => window.clearTimeout(id)
}, [flash, selected])
useEffect(() => {
if (!numbersEnabled) return
const onKey = (event: KeyboardEvent) => {
// Не перехватываем стрелки, пока фокус в поле ввода: там они двигают каретку.
const target = event.target as HTMLElement | null
if (target && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return
if (event.key === 'ArrowUp' || event.key === 'PageUp') {
event.preventDefault()
step(-1)
} else if (event.key === 'ArrowDown' || event.key === 'PageDown') {
event.preventDefault()
step(1)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [numbersEnabled, step])
useEffect(() => {
if (!selected && channels && channels.length > 0) setSelected(channels[0].slug)
}, [channels, selected])
useEffect(() => {
if (!selected) return
setWatchReady(false)
setPlayerError(false)
let cancelled = false
void watchChannel(selected)
.then(() => {
if (!cancelled) setWatchReady(true)
})
.catch(() => {
// Выдача stream-cookie не удалась (403/500/сеть) — показываем offline-панель с кнопкой ретрая,
// а не бесконечный скелетон. Ретрай (attempt) заново дёрнет watchChannel.
if (!cancelled) {
setWatchReady(true)
setPlayerError(true)
}
})
return () => {
cancelled = true
}
}, [selected, attempt])
// Stream-cookie короткоживущий (TTL на сервере ~30 мин) — периодически перевыпускаем, пока смотрим,
// иначе плейлист/сегменты начнут отдавать 401 посреди эфира. Тихо: ошибку словит перезагрузка плейлиста.
useEffect(() => {
if (!selected || playerError) return
const id = window.setInterval(
() => {
void watchChannel(selected).catch(() => undefined)
},
20 * 60_000,
)
return () => window.clearInterval(id)
}, [selected, playerError])
const { data: epg } = useQuery({
queryKey: ['air', 'epg', selected],
queryFn: () =>
getEpg(
selected!,
new Date(Date.now() - 30 * 60_000),
new Date(Date.now() + 3 * 60 * 60_000),
),
enabled: !!selected,
refetchInterval: 60_000,
})
const { current, upcoming, currentEntry } = useMemo(() => buildGuide(epg ?? []), [epg])
// Плашка «Далее» — только на исходе программы: висеть весь эфир ей незачем.
const nextUp =
current && upcoming.length > 0 && new Date(current.endsAtUtc).getTime() - Date.now() < 60_000
? upcoming[0].showName
: null
if (isLoading) return <p className="text-muted-foreground">{t('common.loading')}</p>
if (!channels || channels.length === 0)
return (
<div className="flex flex-col gap-2">
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
<p className="text-muted-foreground">{t('air.noChannels')}</p>
</div>
)
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
{numbersEnabled && (
<span className="text-xs text-muted-foreground">{t('air.numbersHint')}</span>
)}
</div>
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
<aside className="flex gap-2 overflow-x-auto md:flex-col md:overflow-visible">
{channels.map((channel) => (
<button
key={channel.id}
onClick={() => setSelected(channel.slug)}
className={cn(
'flex shrink-0 items-center gap-2 rounded-sm border border-border px-3 py-2 text-left text-sm hover:bg-muted md:shrink',
selected === channel.slug && 'border-primary text-primary',
)}
>
{numbersEnabled && channel.number !== null && (
<span className="w-6 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
{channel.number}
</span>
)}
{channel.currentShowPosterImageId ? (
<img
src={imageUrl(channel.currentShowPosterImageId)}
alt=""
className="h-10 w-7 shrink-0 rounded object-cover"
/>
) : (
<Radio className="h-4 w-4 shrink-0" />
)}
<span className="flex min-w-0 flex-col">
<span className="truncate">{channel.name}</span>
{channel.currentShowName && (
<span className="truncate text-xs text-muted-foreground">
{channel.currentShowName}
</span>
)}
</span>
</button>
))}
</aside>
<div className="flex flex-col gap-4">
{selected && watchReady ? (
playerError ? (
<div className="crt-panel flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-md text-center">
<Radio className="h-10 w-10 text-muted-foreground" strokeWidth={1} />
<div className="flex flex-col gap-1">
<p className="font-medium">{t('air.offline')}</p>
<p className="text-sm text-muted-foreground">{t('air.offlineHint')}</p>
</div>
<Button size="sm" variant="outline" onClick={retry}>
<RotateCw className="h-4 w-4" />
{t('air.retry')}
</Button>
</div>
) : (
<ChannelPlayer
key={`${selected}-${attempt}`}
slug={selected}
channel={currentChannel}
nextUp={nextUp}
flash={flash}
onUnavailable={handleUnavailable}
/>
)
) : (
<div className="aspect-video w-full animate-pulse rounded-md border border-border bg-black" />
)}
<div className="flex flex-col gap-3">
{current && (
<div className="crt-panel flex gap-3 rounded-md p-3">
{currentEntry?.episodeStillImageId ? (
<img
src={imageUrl(currentEntry.episodeStillImageId)}
alt=""
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
/>
) : currentEntry?.showPosterImageId ? (
<img
src={imageUrl(currentEntry.showPosterImageId)}
alt=""
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
/>
) : null}
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2">
<Badge>{t('air.now')}</Badge>
<span className="font-medium">{current.showName}</span>
</div>
{currentEntry?.episodeTitle && (
<span className="text-sm">{currentEntry.episodeTitle}</span>
)}
<span className="text-xs text-muted-foreground">
{formatTime(current.startsAtUtc)} {formatTime(current.endsAtUtc)}
</span>
{currentEntry?.episodeOverview && (
<p className="line-clamp-3 text-xs text-muted-foreground">
{currentEntry.episodeOverview}
</p>
)}
</div>
</div>
)}
{upcoming.length > 0 && (
<div className="crt-panel rounded-md">
<div className="border-b border-border px-4 py-2 text-xs uppercase tracking-wide text-muted-foreground">
{t('air.next')}
</div>
<ul className="divide-y divide-border">
{upcoming.slice(0, 6).map((block) => (
<li key={block.key} className="flex items-center gap-3 px-4 py-2 text-sm">
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
{formatTime(block.startsAtUtc)} {formatTime(block.endsAtUtc)}
</span>
<span>{block.showName}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
</div>
)
}
type GuideBlock = {
key: string
showId: string | null
showName: string
startsAtUtc: string
endsAtUtc: string
}
/**
* Строит телегид: рекламу/заставки не показываем, а подряд идущие серии одного шоу склеиваем в один
* блок с диапазоном «с – по». Отдельно возвращаем текущую серию (для метаданных карточки «сейчас»).
*/
function buildGuide(entries: PublicEpgEntryDto[]): {
current?: GuideBlock
upcoming: GuideBlock[]
currentEntry?: PublicEpgEntryDto
} {
const blocks: GuideBlock[] = []
for (const entry of entries) {
if (entry.kind !== 'Program') continue
const last = blocks[blocks.length - 1]
if (last && last.showId === entry.showId) {
last.endsAtUtc = entry.endsAtUtc
} else {
blocks.push({
key: entry.startsAtUtc,
showId: entry.showId,
showName: entry.showName ?? '—',
startsAtUtc: entry.startsAtUtc,
endsAtUtc: entry.endsAtUtc,
})
}
}
const now = Date.now()
const active = (start: string, end: string) =>
new Date(start).getTime() <= now && new Date(end).getTime() > now
const current = blocks.find((b) => active(b.startsAtUtc, b.endsAtUtc))
const upcoming = blocks.filter((b) => new Date(b.startsAtUtc).getTime() > now)
const currentEntry = entries.find(
(e) => e.kind === 'Program' && active(e.startsAtUtc, e.endsAtUtc),
)
return { current, upcoming, currentEntry }
}
+279 -241
View File
@@ -1,241 +1,279 @@
import Hls from 'hls.js'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Maximize, Volume2, VolumeX } from 'lucide-react'
const STORAGE_KEY = 'tw:player'
/** Читает сохранённые громкость/mute из localStorage (с валидацией и дефолтами). */
function readStoredAudio(): { volume: number; muted: boolean } {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw) as { volume?: unknown; muted?: unknown }
const volume =
typeof parsed.volume === 'number' ? Math.min(1, Math.max(0, parsed.volume)) : 1
const muted = typeof parsed.muted === 'boolean' ? parsed.muted : true
return { volume, muted }
}
} catch {
/* недоступен/битый localStorage — дефолты */
}
return { volume: 1, muted: true }
}
/**
* HLS-плеер линейного канала. Это живой эфир: ни перемотки, ни паузы — только звук, громкость и
* полноэкранный режим. Cookie tw_stream уже выдана к монтированию.
*/
export function ChannelPlayer({
slug,
onUnavailable,
}: {
slug: string
onUnavailable?: () => void
}) {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const hlsRef = useRef<Hls | null>(null)
const [muted, setMuted] = useState(() => readStoredAudio().muted)
const [volume, setVolume] = useState(() => readStoredAudio().volume)
// Последние настройки звука без пересоздания HLS-эффекта + сохранение в localStorage.
const audioRef = useRef({ volume, muted })
useEffect(() => {
audioRef.current = { volume, muted }
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ volume, muted }))
} catch {
/* localStorage недоступен — не критично */
}
}, [volume, muted])
const [controlsVisible, setControlsVisible] = useState(true)
const hideTimerRef = useRef<number | null>(null)
const overControlsRef = useRef(false)
const clearHideTimer = useCallback(() => {
if (hideTimerRef.current !== null) {
window.clearTimeout(hideTimerRef.current)
hideTimerRef.current = null
}
}, [])
// Прячем панель и курсор после бездействия — но не когда курсор на панели или видео на паузе.
const scheduleHide = useCallback(() => {
clearHideTimer()
hideTimerRef.current = window.setTimeout(() => {
if (!overControlsRef.current && videoRef.current && !videoRef.current.paused) {
setControlsVisible(false)
}
}, 2500)
}, [clearHideTimer])
const revealControls = useCallback(() => {
setControlsVisible(true)
scheduleHide()
}, [scheduleHide])
useEffect(() => clearHideTimer, [clearHideTimer])
useEffect(() => {
const video = videoRef.current
if (!video) return
const src = `/api/channels/${slug}/live.m3u8`
let hls: Hls | null = null
// Восстанавливаем сохранённую громкость/mute и запускаем; если браузер блокирует автоплей со
// звуком — откатываемся на воспроизведение без звука.
const startPlayback = () => {
const audio = audioRef.current
video.volume = audio.volume
video.muted = audio.muted
video.play().catch(() => {
video.muted = true
setMuted(true)
void video.play().catch(() => undefined)
})
}
// Слушатели нативной ветки — держим ссылки, чтобы снять их в cleanup (симметрично hls.destroy()).
const onNativeError = () => onUnavailable?.()
if (Hls.isSupported()) {
hls = new Hls({ liveSyncDurationCount: 3, enableWorker: true, lowLatencyMode: false })
hlsRef.current = hls
hls.loadSource(src)
hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, startPlayback)
// Живой эфир: краткий сетевой сбой сегмента или media-ошибку сперва пробуем восстановить
// (hls.js рекомендует startLoad / recoverMediaError), и только исчерпав попытки — уходим в offline.
let recoverAttempts = 0
hls.on(Hls.Events.ERROR, (_event, data) => {
if (!data.fatal) return
if (data.type === Hls.ErrorTypes.NETWORK_ERROR && recoverAttempts < 3) {
recoverAttempts += 1
hls?.startLoad()
return
}
if (data.type === Hls.ErrorTypes.MEDIA_ERROR && recoverAttempts < 3) {
recoverAttempts += 1
hls?.recoverMediaError()
return
}
hls?.destroy()
hls = null
hlsRef.current = null
onUnavailable?.()
})
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = src
video.addEventListener('loadedmetadata', startPlayback)
video.addEventListener('error', onNativeError)
} else {
onUnavailable?.()
}
return () => {
hls?.destroy()
hlsRef.current = null
video.removeEventListener('loadedmetadata', startPlayback)
video.removeEventListener('error', onNativeError)
}
}, [slug, onUnavailable])
// Установить громкость (0..1); 0 = mute, >0 запоминаем как последний уровень для «размьютить».
const applyVolume = (value: number) => {
const video = videoRef.current
if (!video) return
const clamped = Math.min(1, Math.max(0, value))
video.volume = clamped
video.muted = clamped === 0
setMuted(clamped === 0)
if (clamped > 0) setVolume(clamped)
}
const toggleMute = () => {
const video = videoRef.current
if (!video) return
if (video.muted || video.volume === 0) {
applyVolume(volume > 0 ? volume : 0.5)
} else {
video.muted = true
setMuted(true)
}
}
const toggleFullscreen = () => {
const container = containerRef.current
if (!container) return
if (document.fullscreenElement) void document.exitFullscreen()
else void container.requestFullscreen().catch(() => undefined)
}
return (
<div
ref={containerRef}
onMouseMove={revealControls}
onMouseLeave={() => {
clearHideTimer()
if (videoRef.current && !videoRef.current.paused) setControlsVisible(false)
}}
className={`relative aspect-video w-full overflow-hidden rounded-md border border-border bg-black ${
controlsVisible ? '' : 'cursor-none'
}`}
>
<video
ref={videoRef}
playsInline
muted
className="h-full w-full"
onDoubleClick={toggleFullscreen}
onPlay={scheduleHide}
onPause={() => {
clearHideTimer()
setControlsVisible(true)
}}
/>
<div
onMouseEnter={() => {
overControlsRef.current = true
clearHideTimer()
setControlsVisible(true)
}}
onMouseLeave={() => {
overControlsRef.current = false
scheduleHide()
}}
className={`absolute inset-x-0 bottom-0 flex items-center gap-3 bg-gradient-to-t from-black/70 to-transparent px-3 py-2 text-white transition-opacity ${
controlsVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
}`}
>
<div className="flex items-center gap-2">
<button type="button" onClick={toggleMute} aria-label="mute">
{muted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
</button>
<input
type="range"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
onChange={(e) => applyVolume(Number(e.target.value))}
aria-label={t('air.volume')}
className="h-1 w-20 cursor-pointer accent-emerald-400"
/>
</div>
<span className="ml-auto flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-red-500">
<span className="h-2 w-2 animate-pulse rounded-full bg-red-500" />
{t('air.live')}
</span>
<button type="button" onClick={toggleFullscreen} aria-label="fullscreen">
<Maximize className="h-5 w-5" />
</button>
</div>
</div>
)
}
import Hls from 'hls.js'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Maximize, Volume2, VolumeX } from 'lucide-react'
import type { PublicChannelDto } from '@/shared/api/types'
import {
AnalogFilter,
ChannelFlash,
ChannelLogo,
NextUpBanner,
ScreenClock,
} from './PlayerOverlays'
const STORAGE_KEY = 'tw:player'
/** Читает сохранённые громкость/mute из localStorage (с валидацией и дефолтами). */
function readStoredAudio(): { volume: number; muted: boolean } {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw) as { volume?: unknown; muted?: unknown }
const volume =
typeof parsed.volume === 'number' ? Math.min(1, Math.max(0, parsed.volume)) : 1
const muted = typeof parsed.muted === 'boolean' ? parsed.muted : true
return { volume, muted }
}
} catch {
/* недоступен/битый localStorage — дефолты */
}
return { volume: 1, muted: true }
}
/**
* HLS-плеер линейного канала. Это живой эфир: ни перемотки, ни паузы — только звук, громкость и
* полноэкранный режим. Cookie tw_stream уже выдана к монтированию.
*/
export function ChannelPlayer({
slug,
channel,
nextUp,
flash,
onUnavailable,
}: {
slug: string
/** Канал, чьи оверлеи рисуем. Всё опционально: канал без логотипа и без шума — норма. */
channel?: PublicChannelDto
/** Название следующей программы, когда до неё осталось меньше минуты. */
nextUp?: string | null
/** Показать чёрный кадр с номером — переключение по номерам, как на телевизоре. */
flash?: boolean
onUnavailable?: () => void
}) {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const hlsRef = useRef<Hls | null>(null)
const [muted, setMuted] = useState(() => readStoredAudio().muted)
const [volume, setVolume] = useState(() => readStoredAudio().volume)
// Последние настройки звука без пересоздания HLS-эффекта + сохранение в localStorage.
const audioRef = useRef({ volume, muted })
useEffect(() => {
audioRef.current = { volume, muted }
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ volume, muted }))
} catch {
/* localStorage недоступен — не критично */
}
}, [volume, muted])
const [controlsVisible, setControlsVisible] = useState(true)
const hideTimerRef = useRef<number | null>(null)
const overControlsRef = useRef(false)
const clearHideTimer = useCallback(() => {
if (hideTimerRef.current !== null) {
window.clearTimeout(hideTimerRef.current)
hideTimerRef.current = null
}
}, [])
// Прячем панель и курсор после бездействия — но не когда курсор на панели или видео на паузе.
const scheduleHide = useCallback(() => {
clearHideTimer()
hideTimerRef.current = window.setTimeout(() => {
if (!overControlsRef.current && videoRef.current && !videoRef.current.paused) {
setControlsVisible(false)
}
}, 2500)
}, [clearHideTimer])
const revealControls = useCallback(() => {
setControlsVisible(true)
scheduleHide()
}, [scheduleHide])
useEffect(() => clearHideTimer, [clearHideTimer])
useEffect(() => {
const video = videoRef.current
if (!video) return
const src = `/api/channels/${slug}/live.m3u8`
let hls: Hls | null = null
// Восстанавливаем сохранённую громкость/mute и запускаем; если браузер блокирует автоплей со
// звуком — откатываемся на воспроизведение без звука.
const startPlayback = () => {
const audio = audioRef.current
video.volume = audio.volume
video.muted = audio.muted
video.play().catch(() => {
video.muted = true
setMuted(true)
void video.play().catch(() => undefined)
})
}
// Слушатели нативной ветки — держим ссылки, чтобы снять их в cleanup (симметрично hls.destroy()).
const onNativeError = () => onUnavailable?.()
if (Hls.isSupported()) {
hls = new Hls({ liveSyncDurationCount: 3, enableWorker: true, lowLatencyMode: false })
hlsRef.current = hls
hls.loadSource(src)
hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, startPlayback)
// Живой эфир: краткий сетевой сбой сегмента или media-ошибку сперва пробуем восстановить
// (hls.js рекомендует startLoad / recoverMediaError), и только исчерпав попытки — уходим в offline.
let recoverAttempts = 0
hls.on(Hls.Events.ERROR, (_event, data) => {
if (!data.fatal) return
if (data.type === Hls.ErrorTypes.NETWORK_ERROR && recoverAttempts < 3) {
recoverAttempts += 1
hls?.startLoad()
return
}
if (data.type === Hls.ErrorTypes.MEDIA_ERROR && recoverAttempts < 3) {
recoverAttempts += 1
hls?.recoverMediaError()
return
}
hls?.destroy()
hls = null
hlsRef.current = null
onUnavailable?.()
})
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = src
video.addEventListener('loadedmetadata', startPlayback)
video.addEventListener('error', onNativeError)
} else {
onUnavailable?.()
}
return () => {
hls?.destroy()
hlsRef.current = null
video.removeEventListener('loadedmetadata', startPlayback)
video.removeEventListener('error', onNativeError)
}
}, [slug, onUnavailable])
// Установить громкость (0..1); 0 = mute, >0 запоминаем как последний уровень для «размьютить».
const applyVolume = (value: number) => {
const video = videoRef.current
if (!video) return
const clamped = Math.min(1, Math.max(0, value))
video.volume = clamped
video.muted = clamped === 0
setMuted(clamped === 0)
if (clamped > 0) setVolume(clamped)
}
const toggleMute = () => {
const video = videoRef.current
if (!video) return
if (video.muted || video.volume === 0) {
applyVolume(volume > 0 ? volume : 0.5)
} else {
video.muted = true
setMuted(true)
}
}
const toggleFullscreen = () => {
const container = containerRef.current
if (!container) return
if (document.fullscreenElement) void document.exitFullscreen()
else void container.requestFullscreen().catch(() => undefined)
}
return (
<div
ref={containerRef}
onMouseMove={revealControls}
onMouseLeave={() => {
clearHideTimer()
if (videoRef.current && !videoRef.current.paused) setControlsVisible(false)
}}
className={`relative aspect-video w-full overflow-hidden rounded-md border border-border bg-black ${
controlsVisible ? '' : 'cursor-none'
}`}
>
<video
ref={videoRef}
playsInline
muted
className="h-full w-full"
style={
channel && channel.analogFilterStrength > 0
? {
filter: `saturate(${1 + channel.analogFilterStrength * 0.4}) contrast(${1 + channel.analogFilterStrength * 0.15}) blur(${channel.analogFilterStrength * 0.6}px)`,
}
: undefined
}
onDoubleClick={toggleFullscreen}
onPlay={scheduleHide}
onPause={() => {
clearHideTimer()
setControlsVisible(true)
}}
/>
{channel && channel.analogFilterStrength > 0 && (
<AnalogFilter strength={channel.analogFilterStrength} />
)}
{channel?.logoImageId && (
<ChannelLogo
imageId={channel.logoImageId}
corner={channel.logoCorner}
opacity={channel.logoOpacity}
/>
)}
{channel?.showClock && <ScreenClock />}
{nextUp && <NextUpBanner title={nextUp} />}
{flash && channel && <ChannelFlash number={channel.number} name={channel.name} />}
<div
onMouseEnter={() => {
overControlsRef.current = true
clearHideTimer()
setControlsVisible(true)
}}
onMouseLeave={() => {
overControlsRef.current = false
scheduleHide()
}}
className={`absolute inset-x-0 bottom-0 flex items-center gap-3 bg-gradient-to-t from-black/70 to-transparent px-3 py-2 text-white transition-opacity ${
controlsVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
}`}
>
<div className="flex items-center gap-2">
<button type="button" onClick={toggleMute} aria-label="mute">
{muted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
</button>
<input
type="range"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
onChange={(e) => applyVolume(Number(e.target.value))}
aria-label={t('air.volume')}
className="h-1 w-20 cursor-pointer accent-emerald-400"
/>
</div>
<span className="ml-auto flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-red-500">
<span className="h-2 w-2 animate-pulse rounded-full bg-red-500" />
{t('air.live')}
</span>
<button type="button" onClick={toggleFullscreen} aria-label="fullscreen">
<Maximize className="h-5 w-5" />
</button>
</div>
</div>
)
}
@@ -0,0 +1,103 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { LogoCorner } from '@/shared/api/types'
import { cn } from '@/shared/lib/cn'
import { imageUrl } from './api'
const CORNER_CLASS: Record<LogoCorner, string> = {
TopLeft: 'left-3 top-3',
TopRight: 'right-3 top-3',
BottomLeft: 'left-3 bottom-14',
BottomRight: 'right-3 bottom-14',
}
/**
* Логотип канала поверх картинки. Настоящий вещательный логотип вжигается при кодировании; для нас
* это означало бы перекодирование всей библиотеки при смене логотипа, поэтому только оверлей.
*/
export function ChannelLogo({
imageId,
corner,
opacity,
}: {
imageId: string
corner: LogoCorner
opacity: number
}) {
return (
<img
src={imageUrl(imageId)}
alt=""
style={{ opacity }}
className={cn('pointer-events-none absolute h-10 w-auto max-w-24 object-contain', CORNER_CLASS[corner])}
/>
)
}
/** Часы поверх картинки — опция канала, а не общее украшение плеера. */
export function ScreenClock() {
const [now, setNow] = useState(() => new Date())
useEffect(() => {
// Тик раз в 10 секунд: минуты меняются реже, а секунды на часах в углу никому не нужны.
const id = window.setInterval(() => setNow(new Date()), 10_000)
return () => window.clearInterval(id)
}, [])
return (
<span className="pointer-events-none absolute right-3 top-3 rounded bg-black/40 px-2 py-0.5 text-sm tabular-nums text-white/90">
{now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
)
}
/** Плашка «Далее: …» — данные уже есть в EPG, отдельного запроса не нужно. */
export function NextUpBanner({ title }: { title: string }) {
const { t } = useTranslation()
return (
<span className="pointer-events-none absolute bottom-14 left-3 rounded bg-black/60 px-2 py-1 text-sm text-white">
{t('air.next')}: {title}
</span>
)
}
/**
* Аналоговый фильтр: лёгкий VHS-шум, дрожание и размытие краёв. Переборщить очень легко, поэтому
* сила регулируется, а вклад каждого слоя от неё убывает нелинейно.
*/
export function AnalogFilter({ strength }: { strength: number }) {
const s = Math.min(1, Math.max(0, strength))
return (
<>
<span
aria-hidden
className="pointer-events-none absolute inset-0 mix-blend-overlay"
style={{
opacity: s * 0.35,
backgroundImage:
'repeating-linear-gradient(0deg, rgba(255,255,255,.12) 0 1px, transparent 1px 3px)',
}}
/>
<span
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
opacity: s * 0.5,
boxShadow: `inset 0 0 ${40 + s * 60}px rgba(0,0,0,.75)`,
}}
/>
</>
)
}
/** Короткий чёрный кадр с номером канала — как при переключении на телевизоре. */
export function ChannelFlash({ number, name }: { number: number | null; name: string }) {
return (
<div className="pointer-events-none absolute inset-0 flex items-start justify-end bg-black">
<span className="m-6 flex items-baseline gap-2 text-white">
{number !== null && <span className="text-5xl font-bold tabular-nums">{number}</span>}
<span className="text-lg">{name}</span>
</span>
</div>
)
}
+29 -24
View File
@@ -1,24 +1,29 @@
import { apiRequest } from '@/shared/api/client'
import type { PublicChannelDto, PublicEpgEntryDto } from '@/shared/api/types'
/** Ссылка на изображение общего реестра (постеры/кадры) — по id из публичных DTO. */
export function imageUrl(imageId: string) {
return `/api/images/${imageId}`
}
export function listChannels() {
return apiRequest<PublicChannelDto[]>('/channels')
}
/** Выдаёт httpOnly-cookie tw_stream — после этого <video> сможет грузить плейлист и сегменты. */
export function watchChannel(slug: string) {
return apiRequest<void>(`/channels/${slug}/watch`, { method: 'POST' })
}
export function getEpg(slug: string, from?: Date, to?: Date) {
const query = new URLSearchParams()
if (from) query.set('from', from.toISOString())
if (to) query.set('to', to.toISOString())
const qs = query.toString()
return apiRequest<PublicEpgEntryDto[]>(`/channels/${slug}/epg${qs ? `?${qs}` : ''}`)
}
import { apiRequest } from '@/shared/api/client'
import type { PublicChannelDto, PublicEpgEntryDto } from '@/shared/api/types'
/** Ссылка на изображение общего реестра (постеры/кадры) — по id из публичных DTO. */
export function imageUrl(imageId: string) {
return `/api/images/${imageId}`
}
export function listChannels() {
return apiRequest<PublicChannelDto[]>('/channels')
}
/** Что включено глобально на стороне зрителя (сейчас — переключение по номерам). */
export function getViewerFeatures() {
return apiRequest<{ channelNumbersEnabled: boolean }>('/channels/features')
}
/** Выдаёт httpOnly-cookie tw_stream — после этого <video> сможет грузить плейлист и сегменты. */
export function watchChannel(slug: string) {
return apiRequest<void>(`/channels/${slug}/watch`, { method: 'POST' })
}
export function getEpg(slug: string, from?: Date, to?: Date) {
const query = new URLSearchParams()
if (from) query.set('from', from.toISOString())
if (to) query.set('to', to.toISOString())
const qs = query.toString()
return apiRequest<PublicEpgEntryDto[]>(`/channels/${slug}/epg${qs ? `?${qs}` : ''}`)
}
+111 -1
View File
@@ -18,7 +18,24 @@ export type AuthResponse = {
export type RegistrationStatus = { enabled: boolean }
export type SiteSettings = { registrationEnabled: boolean; preferredAudioLanguages: string }
export type SiteSettings = {
registrationEnabled: boolean
preferredAudioLanguages: string
/** Переключение каналов по номерам у зрителя; сетка каналов остаётся всегда. */
channelNumbersEnabled: boolean
}
/** Угол экрана для логотипа-оверлея. */
export type LogoCorner = 'TopLeft' | 'TopRight' | 'BottomLeft' | 'BottomRight'
/** Как канал выглядит у зрителя — всё опционально и по умолчанию выключено (см. 6.8). */
export type ViewerSettings = {
logoImageId: string | null
logoCorner: LogoCorner
logoOpacity: number
showClock: boolean
analogFilterStrength: number
}
export type RoleDto = {
id: string
@@ -360,6 +377,7 @@ export type ChannelDto = {
bumper: BumperSettings
bumperTemplates: BumperTemplateDto[]
fillerAssetId: string | null
viewer: ViewerSettings
}
// ── Сетка канала (шаблон → слои → слоты) ──────────────────────────────────
@@ -459,6 +477,19 @@ export type GridLayerDto = {
slots: SlotDto[]
}
/** Окно детского времени: до `to` в эфир идёт контент не строже `maxAudience`. */
export type AudienceWindow = { from: string; to: string; maxAudience: ShowAudience }
/** Правила отбора кандидатов канала — жёсткие фильтры, применяются до жребия. */
export type PlanningRules = {
maxAudienceByTime?: AudienceWindow[] | null
maxRepeatsInWindow?: { windowDays: number; max: number } | null
/** Пост-проверки: считаются по готовой ленте и только предупреждают. */
maxBreakMinutesPerHour?: number | null
maxGenreSharePercent?: number | null
maxFallbackSharePercent?: number | null
}
export type ScheduleTemplateDto = {
id: string
channelId: string
@@ -466,6 +497,8 @@ export type ScheduleTemplateDto = {
fallbackGroupId: string | null
/** Стык, который берётся, когда слот своего не задал. */
defaultJunctionId: string | null
/** Детское время и потолок повторов. */
rules: PlanningRules | null
revision: number
appliedRevision: number
/** Есть ли правки правил, не применённые к эфиру. */
@@ -481,6 +514,10 @@ export type PlanningWarningKind =
| 'CooldownExhausted'
| 'RepeatSourceEmpty'
| 'FallbackEmpty'
| 'CandidatesFiltered'
| 'BreakLimitExceeded'
| 'GenreShareExceeded'
| 'FallbackShareExceeded'
export type PlanningWarningDto = {
kind: PlanningWarningKind
@@ -490,6 +527,72 @@ export type PlanningWarningDto = {
export type ApplyResultDto = { added: number; warnings: PlanningWarningDto[] }
/** Что изменится в эфире, если применить правила сейчас (см. 6.6). */
export type ScheduleChangeDto = {
startsAtUtc: string
before: string | null
after: string | null
/** Попадает в ближайшие сутки — подсвечивается отдельно. */
soon: boolean
}
export type ScheduleDiffDto = {
total: number
changed: number
changedSoon: number
changes: ScheduleChangeDto[]
}
/** Цепочка происхождения записи — «почему это здесь» (см. 6.5). */
export type EntryTraceDto = {
entryId: string
startsAtUtc: string
endsAtUtc: string
showName: string | null
episodeIndex: number | null
layerName: string | null
layerPriority: number | null
slotTitle: string | null
slotKind: SlotKind | null
slotWeekday: number | null
slotTargetStart: string | null
slotDurationMinutes: number | null
groupName: string | null
groupItemCount: number | null
strategy: SlotStrategyType | null
cooldownDays: number | null
candidatesAfterCooldown: number | null
driftMinutes: number
snapped: boolean
junctionName: string | null
}
export type CopyTemplateResultDto = {
layers: number
slots: number
junctions: number
/** Врезки-заставки, для которых на канале-приёмнике не нашлось блока с таким же именем. */
droppedBumperRefs: number
}
/** Проверки сетки по правилам, до генерации (см. 5.1). */
export type TemplateIssueKind =
| 'GroupEmpty'
| 'GroupTooSmall'
| 'GridGap'
| 'SlotOverlap'
| 'CooldownUnreachable'
| 'AudienceConflict'
| 'GroupMissing'
export type TemplateIssueDto = {
kind: TemplateIssueKind
severity: 'Warning' | 'Error'
layerId: string | null
slotId: string | null
details: string
}
/** Что попало в ленту предпросмотра. `Bumper` приходит без ассета — он рендерится при применении. */
export type PlannedItemKind = 'Program' | 'Fallback' | 'SignOff' | 'Ad' | 'Promo' | 'Bumper'
@@ -531,9 +634,16 @@ export type PublicChannelDto = {
id: string
slug: string
name: string
/** Номер канала для переключения по номерам или null. */
number: number | null
currentShowId: string | null
currentShowName: string | null
currentShowPosterImageId: string | null
logoImageId: string | null
logoCorner: LogoCorner
logoOpacity: number
showClock: boolean
analogFilterStrength: number
}
export type PublicEpgEntryDto = {
+197 -28
View File
@@ -66,6 +66,7 @@ const resources = {
noChannels: 'Пока нет доступных каналов. Загляните позже.',
offline: 'Канал сейчас не в эфире',
offlineHint: 'Нет расписания или контента. Загляните позже.',
numbersHint: '↑ / ↓ — переключение каналов по номерам',
retry: 'Повторить',
},
settings: {
@@ -341,13 +342,102 @@ const resources = {
snapOff: 'нет',
bumperConditionsHint:
'Как часто ставить заставку и на каких переходах — условия элемента стыка, а не настройка канала.',
resizeSlot: 'Потянуть за край — длительность',
copyDay: 'Копировать день',
copyDayFrom: 'Копировать {{day}} в:',
copy: 'Копировать',
layerVisible: 'Показывать слой',
layerName: 'Название слоя',
layerApplicability: 'Когда действует',
applicabilityHint:
'Разделы объединяются по ИЛИ: слой действует, если дата подходит хотя бы под одно условие. Ничего не заполнено — действует всегда.',
applicabilityWeekdays: 'Дни недели',
applicabilityDateRanges: 'Диапазоны дат',
applicabilityAnnual: 'Ежегодно (месяц / день)',
applicabilityDates: 'Конкретные даты',
applicabilityNone: 'не задано',
showForDate: 'Сетка на дату',
allDates: 'Все слои',
rules: 'Правила отбора',
rulesHint:
'Жёсткие фильтры: отсекают неподходящее до жребия. Как и правка сетки, эфир не двигают — нужно применить.',
audienceWindows: 'Детское время',
noAudienceWindows: 'Окон нет — возраст ничем не ограничен.',
audienceWindowsHint:
'В окне в эфир идёт контент не строже выбранной категории. Окно может переходить через полночь. Контент без категории не отсекается.',
from: 'С',
to: 'До',
maxAudience: 'Не строже',
repeatLimit: 'Потолок повторов',
repeatWindowDays: 'Окно, суток',
repeatMax: 'Не чаще, раз',
repeatLimitHint:
'Считается по уже записанной ленте. Если потолка достигли все кандидаты, слот всё равно заполняется: пустой эфир хуже раннего повтора.',
preview: 'Предпросмотр',
previewHide: 'Свернуть предпросмотр',
previewHint: 'Прогон по текущим правилам: ничего не пишется, курсоры слотов не двигаются.',
previewDays_one: '{{count}} сутки',
previewDays_few: '{{count}} суток',
previewDays_many: '{{count}} суток',
previewTabs: { programme: 'Программа', tape: 'Лента' },
previewTabs: { programme: 'Программа', tape: 'Лента', problems: 'Проблемы' },
noProblems: 'Проблем нет',
andMore: 'и ещё {{count}}',
heatmap: 'Повторы: шоу × сутки',
heatmapTotal: 'всего',
issues: 'Проверки: ошибок {{errors}}, предупреждений {{warnings}}',
goToSlot: 'к слоту',
issueKinds: {
GroupEmpty: 'Пустая группа',
GroupTooSmall: 'Мало контента',
GridGap: 'Дыра в сетке',
SlotOverlap: 'Слоты пересекаются',
CooldownUnreachable: 'Недостижимое остывание',
AudienceConflict: 'Возрастной конфликт',
GroupMissing: 'Группа не выбрана',
},
viewer: 'Как выглядит у зрителя',
viewerHint:
'Оверлеи рисуются поверх картинки на клиенте — видео не перекодируется. Всё по умолчанию выключено.',
logo: 'Логотип',
noLogo: 'нет',
pickLogo: 'Выбрать логотип',
logoCorner: 'Угол',
logoOpacity: 'Прозрачность',
corners: {
TopLeft: 'Слева вверху',
TopRight: 'Справа вверху',
BottomLeft: 'Слева внизу',
BottomRight: 'Справа внизу',
},
showClock: 'Показывать часы',
analogFilter: 'Аналоговый фильтр',
analogFilterHint: 'Сила 0..1. Ноль — выключен; переборщить очень легко.',
whyHere: 'Почему это здесь',
priority: 'приоритет',
traceLayer: 'Слой',
traceSlot: 'Слот',
traceGroup: 'Группа',
traceStrategy: 'Стратегия',
traceJunction: 'Врезки',
traceDrift: 'дрейф {{minutes}} мин',
traceSnapped: 'старт округлён',
traceCooldown: 'остывание {{days}} дн.',
traceCandidates: 'кандидатов после остывания: {{count}}',
diffSummary: 'Затронет {{total}} записей, изменятся {{changed}}',
diffSoon: 'В ближайшие сутки изменится записей: {{count}}',
diffNoChanges: 'Эфир не изменится',
copyTemplate: 'Копировать сетку',
pickTargetChannel: 'Выберите канал',
copyTemplateHint:
'Слои, слоты, стыки и правила уедут на выбранный канал, его прежняя сетка заменится. Группы общие и не копируются.',
templateCopied: 'Скопировано: слоёв {{layers}}, слотов {{slots}}',
copyDroppedBumpers: 'Врезок без блока заставки: {{count}} — донастройте руками',
postChecks: 'Пост-проверки',
breakLimit: 'Потолок врезок в час, мин',
genreShare: 'Потолок доли жанра за сутки, %',
fallbackShare: 'Потолок доли фона, %',
postChecksHint:
'Пост-проверки считаются по готовой ленте и только предупреждают — ничего не переигрывается.',
previewKinds: {
Program: 'Программа',
Fallback: 'Фон',
@@ -422,6 +512,10 @@ const resources = {
CooldownExhausted: 'Остывание отсекло всех',
RepeatSourceEmpty: 'Нечего повторять',
FallbackEmpty: 'Нечем закрыть паузы',
CandidatesFiltered: 'Возрастной потолок отсёк всех',
BreakLimitExceeded: 'Врезок в часе больше потолка',
GenreShareExceeded: 'Доля жанра выше нормы',
FallbackShareExceeded: 'Фона в эфире больше нормы',
},
title: 'Каналы',
@@ -509,19 +603,7 @@ const resources = {
preferredNone: 'Окна не заданы — предпочтений по времени нет.',
preferredAddWindow: 'Добавить окно',
preferredBadRange: 'начало ≥ конца',
ads: 'Реклама',
pickAd: 'Выберите ролик',
noAds: 'Пул рекламы пуст',
overrides: 'Марафоны / override',
modes: { Exclusive: 'Эксклюзив', Boost: 'Буст' },
overrideRecurrence: 'Повтор',
recurrenceOneTime: 'Разово',
recurrenceWeekly: 'Еженедельно',
from: 'С',
to: 'По',
noOverrides: 'Override не заданы',
schedule: 'Расписание (12 ч)',
noSchedule: 'Расписание ещё не построено — нажмите «Пересобрать»',
noSchedule: 'Расписание ещё не построено',
},
maintenance: {
title: 'Обслуживание',
@@ -545,6 +627,9 @@ const resources = {
'Когда выключено — новые пользователи не могут регистрироваться сами, учётки заводит только администратор.',
registrationLabel: 'Разрешить регистрацию на сайте',
preferredAudio: 'Предпочитаемые озвучки',
channelNumbers: 'Переключение каналов по номерам',
channelNumbersHint:
'Зритель переключает каналы стрелками, как на телевизоре. Сетка каналов остаётся всегда.',
preferredAudioHint:
'Коды языков через запятую в порядке приоритета (напр. «rus, eng»). Если в файле есть дорожка с таким языком — при обработке выбирается она (по порядку); иначе — выбор ffmpeg по умолчанию. Применяется к новым обработкам.',
},
@@ -918,12 +1003,101 @@ const resources = {
snapOff: 'off',
bumperConditionsHint:
'How often a bumper is inserted and on which transitions is a junction-element condition, not a channel setting.',
resizeSlot: 'Drag the edge to change duration',
copyDay: 'Copy day',
copyDayFrom: 'Copy {{day}} to:',
copy: 'Copy',
layerVisible: 'Show layer',
layerName: 'Layer name',
layerApplicability: 'When it applies',
applicabilityHint:
'Sections are OR-ed: the layer applies when the date matches at least one condition. Nothing filled in — it always applies.',
applicabilityWeekdays: 'Weekdays',
applicabilityDateRanges: 'Date ranges',
applicabilityAnnual: 'Yearly (month / day)',
applicabilityDates: 'Specific dates',
applicabilityNone: 'not set',
showForDate: 'Grid for date',
allDates: 'All layers',
rules: 'Candidate rules',
rulesHint:
'Hard filters: they cut out what is not allowed before the draw. Like grid edits, they do not move the air — apply to take effect.',
audienceWindows: 'Family hours',
noAudienceWindows: 'No windows — the age is not limited.',
audienceWindowsHint:
'Inside a window only content no stricter than the chosen category airs. A window may cross midnight. Content with no category is never dropped.',
from: 'From',
to: 'To',
maxAudience: 'No stricter than',
repeatLimit: 'Repeat cap',
repeatWindowDays: 'Window, days',
repeatMax: 'At most, times',
repeatLimitHint:
'Counted against the already recorded tape. If every candidate hits the cap the slot is still filled: empty air is worse than an early repeat.',
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' },
previewTabs: { programme: 'Programme', tape: 'Tape', problems: 'Problems' },
noProblems: 'No problems',
andMore: 'and {{count}} more',
heatmap: 'Repeats: show × day',
heatmapTotal: 'total',
issues: 'Checks: {{errors}} errors, {{warnings}} warnings',
goToSlot: 'to slot',
issueKinds: {
GroupEmpty: 'Empty group',
GroupTooSmall: 'Too little content',
GridGap: 'Gap in the grid',
SlotOverlap: 'Slots overlap',
CooldownUnreachable: 'Unreachable cooldown',
AudienceConflict: 'Age conflict',
GroupMissing: 'No group selected',
},
viewer: 'How viewers see it',
viewerHint:
'Overlays are drawn on the client on top of the picture — the video is not re-encoded. Everything is off by default.',
logo: 'Logo',
noLogo: 'none',
pickLogo: 'Pick a logo',
logoCorner: 'Corner',
logoOpacity: 'Opacity',
corners: {
TopLeft: 'Top left',
TopRight: 'Top right',
BottomLeft: 'Bottom left',
BottomRight: 'Bottom right',
},
showClock: 'Show a clock',
analogFilter: 'Analog filter',
analogFilterHint: 'Strength 0..1. Zero is off; it is very easy to overdo.',
whyHere: 'Why is this here',
priority: 'priority',
traceLayer: 'Layer',
traceSlot: 'Slot',
traceGroup: 'Group',
traceStrategy: 'Strategy',
traceJunction: 'Breaks',
traceDrift: 'drift {{minutes}} min',
traceSnapped: 'start snapped',
traceCooldown: 'cooldown {{days}} d.',
traceCandidates: 'candidates after cooldown: {{count}}',
diffSummary: 'Affects {{total}} entries, {{changed}} will change',
diffSoon: 'Entries changing within 24 hours: {{count}}',
diffNoChanges: 'The air will not change',
copyTemplate: 'Copy grid',
pickTargetChannel: 'Pick a channel',
copyTemplateHint:
'Layers, slots, junctions and rules move to the chosen channel, replacing its grid. Groups are shared and not copied.',
templateCopied: 'Copied: {{layers}} layers, {{slots}} slots',
copyDroppedBumpers: 'Breaks left without a bumper block: {{count}} — set them up by hand',
postChecks: 'Post-checks',
breakLimit: 'Breaks per hour cap, min',
genreShare: 'Genre share per day cap, %',
fallbackShare: 'Background share cap, %',
postChecksHint:
'Post-checks run against the finished tape and only warn — nothing is replanned.',
previewKinds: {
Program: 'Programme',
Fallback: 'Background',
@@ -993,6 +1167,10 @@ const resources = {
CooldownExhausted: 'Cooldown ruled out every candidate',
RepeatSourceEmpty: 'Nothing to repeat',
FallbackEmpty: 'Nothing to fill pauses with',
CandidatesFiltered: 'The age cap ruled out every candidate',
BreakLimitExceeded: 'Breaks in an hour exceed the cap',
GenreShareExceeded: 'Genre share above the norm',
FallbackShareExceeded: 'Background share above the norm',
},
title: 'Channels',
@@ -1080,19 +1258,7 @@ const resources = {
preferredNone: 'No windows set — no time preference.',
preferredAddWindow: 'Add window',
preferredBadRange: 'start ≥ end',
ads: 'Ads',
pickAd: 'Pick an ad',
noAds: 'Ad pool is empty',
overrides: 'Marathons / overrides',
modes: { Exclusive: 'Exclusive', Boost: 'Boost' },
overrideRecurrence: 'Repeat',
recurrenceOneTime: 'One-time',
recurrenceWeekly: 'Weekly',
from: 'From',
to: 'To',
noOverrides: 'No overrides set',
schedule: 'Schedule (12h)',
noSchedule: 'Schedule not built yet — click “Rebuild”',
noSchedule: 'Schedule not built yet',
},
maintenance: {
title: 'Maintenance',
@@ -1115,6 +1281,9 @@ const resources = {
registrationHint:
'When off, new users cannot sign up themselves — only an administrator can create accounts.',
registrationLabel: 'Allow public registration',
channelNumbers: 'Switch channels by number',
channelNumbersHint:
'Viewers switch channels with the arrow keys, like on a TV set. The channel grid stays available regardless.',
preferredAudio: 'Preferred audio tracks',
preferredAudioHint:
'Comma-separated language codes in priority order (e.g. "rus, eng"). If a file has a track in one of these languages, it is picked during processing (by order); otherwise ffmpeg default. Applies to new processing.',