Compare commits
2
Commits
7b12a06d1b
...
f699576582
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f699576582 | ||
|
|
8494eb5e6a |
@@ -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
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
+29
@@ -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);
|
||||
}
|
||||
}
|
||||
+38
@@ -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
|
||||
);
|
||||
+143
@@ -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
|
||||
);
|
||||
+141
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -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("Копировать сетку саму на себя бессмысленно.");
|
||||
}
|
||||
+165
@@ -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;
|
||||
}
|
||||
+1
@@ -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>
|
||||
|
||||
+50
@@ -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
|
||||
);
|
||||
+306
@@ -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;
|
||||
}
|
||||
}
|
||||
+19
-18
@@ -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
|
||||
);
|
||||
|
||||
+10
-9
@@ -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>;
|
||||
|
||||
+34
-30
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+93
-77
@@ -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);
|
||||
|
||||
Generated
+1371
File diff suppressed because it is too large
Load Diff
+28
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1386
File diff suppressed because it is too large
Load Diff
+73
@@ -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");
|
||||
|
||||
+1
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}` : ''}`)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user