Enhance channel and settings functionalities: introduce viewer settings in ChannelEndpoints, update SiteSettings to include channel number toggling, and refactor related data structures. Implement new endpoints for validating templates and diffing scheduling changes, improving overall user experience and configuration management.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,72 +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
|
||||
)
|
||||
{
|
||||
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);
|
||||
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);
|
||||
|
||||
+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,
|
||||
}
|
||||
@@ -205,4 +205,15 @@ public enum PlanningWarningKind
|
||||
|
||||
/// <summary>Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата.</summary>
|
||||
CandidatesFiltered = 5,
|
||||
|
||||
// ── Пост-проверки: считаются по готовой ленте и ничего не переигрывают (см. 3.8). ──
|
||||
|
||||
/// <summary>Врезок в часе больше заданного потолка.</summary>
|
||||
BreakLimitExceeded = 6,
|
||||
|
||||
/// <summary>Доля одного жанра за сутки выше заданной.</summary>
|
||||
GenreShareExceeded = 7,
|
||||
|
||||
/// <summary>Фон занял больше эфира, чем считается нормой.</summary>
|
||||
FallbackShareExceeded = 8,
|
||||
}
|
||||
|
||||
@@ -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
+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)
|
||||
|
||||
@@ -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,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()
|
||||
{
|
||||
|
||||
+19
-13
@@ -23,7 +23,14 @@
|
||||
и растягиваются мышью, день копируется на другие дни. Добавлены жёсткие фильтры кандидатов —
|
||||
детское время и потолок повторов.
|
||||
|
||||
**Срез 4** не начинался.
|
||||
**Срез 4 закрыт целиком.** Проверки по правилам показываются прямо в редакторе, пост-проверки
|
||||
дают предупреждения по готовой ленте, у предпросмотра появилась вкладка «Проблемы» с тепловой
|
||||
картой повторов, у каждой записи — трейс «почему это здесь», применение идёт через диф
|
||||
с подсветкой ближайших суток, сетка копируется на другой канал.
|
||||
|
||||
**Смежная зрительская часть закрыта.** Номера каналов с сортировкой публичного списка,
|
||||
переключение по номерам стрелками (глобальный флаг рядом с флагом регистрации), логотип-оверлей,
|
||||
часы, плашка «Далее» и аналоговый фильтр. Всё опционально и по умолчанию выключено.
|
||||
|
||||
Ничего из этого не проверялось на живой базе: только сборка, юнит-тесты и typecheck.
|
||||
|
||||
@@ -356,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`
|
||||
|
||||
Глубокая копия шаблона, слоёв и слотов на другой канал; группы не копируются, они общие.
|
||||
@@ -397,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-фильтр или шейдер, по умолчанию выключен.
|
||||
|
||||
@@ -12,25 +12,31 @@ 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'
|
||||
|
||||
@@ -44,6 +50,9 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
// День, который копируем, и отмеченные дни-приёмники.
|
||||
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],
|
||||
@@ -68,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)
|
||||
@@ -136,6 +148,21 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
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,
|
||||
@@ -215,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>
|
||||
@@ -256,9 +283,48 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
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} />
|
||||
|
||||
{/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */}
|
||||
@@ -359,6 +425,8 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
|
||||
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
|
||||
|
||||
<ViewerCard channel={channel} onSaved={invalidate} onError={onError} />
|
||||
|
||||
{applicabilityLayer && (
|
||||
<LayerApplicabilityDialog
|
||||
layer={applicabilityLayer}
|
||||
@@ -368,7 +436,25 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<SchedulePreview entries={schedule ?? []} />
|
||||
<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,17 +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() {
|
||||
@@ -43,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,
|
||||
@@ -64,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) })
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,189 +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)
|
||||
|
||||
useEffect(() => {
|
||||
setWindows(template.rules?.maxAudienceByTime ?? [])
|
||||
setLimitOn(template.rules?.maxRepeatsInWindow != null)
|
||||
setWindowDays(template.rules?.maxRepeatsInWindow?.windowDays ?? 7)
|
||||
setMax(template.rules?.maxRepeatsInWindow?.max ?? 2)
|
||||
}, [template])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const rules: PlanningRules = {
|
||||
maxAudienceByTime: windows.length > 0 ? windows : null,
|
||||
maxRepeatsInWindow: limitOn ? { windowDays, max } : 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 justify-end">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
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,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// ── Сетка канала (шаблон → слои → слоты) ──────────────────────────────────
|
||||
@@ -466,6 +484,10 @@ export type AudienceWindow = { from: string; to: string; maxAudience: ShowAudien
|
||||
export type PlanningRules = {
|
||||
maxAudienceByTime?: AudienceWindow[] | null
|
||||
maxRepeatsInWindow?: { windowDays: number; max: number } | null
|
||||
/** Пост-проверки: считаются по готовой ленте и только предупреждают. */
|
||||
maxBreakMinutesPerHour?: number | null
|
||||
maxGenreSharePercent?: number | null
|
||||
maxFallbackSharePercent?: number | null
|
||||
}
|
||||
|
||||
export type ScheduleTemplateDto = {
|
||||
@@ -493,6 +515,9 @@ export type PlanningWarningKind =
|
||||
| 'RepeatSourceEmpty'
|
||||
| 'FallbackEmpty'
|
||||
| 'CandidatesFiltered'
|
||||
| 'BreakLimitExceeded'
|
||||
| 'GenreShareExceeded'
|
||||
| 'FallbackShareExceeded'
|
||||
|
||||
export type PlanningWarningDto = {
|
||||
kind: PlanningWarningKind
|
||||
@@ -502,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'
|
||||
|
||||
@@ -543,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 = {
|
||||
|
||||
@@ -66,6 +66,7 @@ const resources = {
|
||||
noChannels: 'Пока нет доступных каналов. Загляните позже.',
|
||||
offline: 'Канал сейчас не в эфире',
|
||||
offlineHint: 'Нет расписания или контента. Загляните позже.',
|
||||
numbersHint: '↑ / ↓ — переключение каналов по номерам',
|
||||
retry: 'Повторить',
|
||||
},
|
||||
settings: {
|
||||
@@ -378,7 +379,65 @@ const resources = {
|
||||
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: 'Фон',
|
||||
@@ -454,6 +513,9 @@ const resources = {
|
||||
RepeatSourceEmpty: 'Нечего повторять',
|
||||
FallbackEmpty: 'Нечем закрыть паузы',
|
||||
CandidatesFiltered: 'Возрастной потолок отсёк всех',
|
||||
BreakLimitExceeded: 'Врезок в часе больше потолка',
|
||||
GenreShareExceeded: 'Доля жанра выше нормы',
|
||||
FallbackShareExceeded: 'Фона в эфире больше нормы',
|
||||
},
|
||||
|
||||
title: 'Каналы',
|
||||
@@ -565,6 +627,9 @@ const resources = {
|
||||
'Когда выключено — новые пользователи не могут регистрироваться сами, учётки заводит только администратор.',
|
||||
registrationLabel: 'Разрешить регистрацию на сайте',
|
||||
preferredAudio: 'Предпочитаемые озвучки',
|
||||
channelNumbers: 'Переключение каналов по номерам',
|
||||
channelNumbersHint:
|
||||
'Зритель переключает каналы стрелками, как на телевизоре. Сетка каналов остаётся всегда.',
|
||||
preferredAudioHint:
|
||||
'Коды языков через запятую в порядке приоритета (напр. «rus, eng»). Если в файле есть дорожка с таким языком — при обработке выбирается она (по порядку); иначе — выбор ffmpeg по умолчанию. Применяется к новым обработкам.',
|
||||
},
|
||||
@@ -974,7 +1039,65 @@ const resources = {
|
||||
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',
|
||||
@@ -1045,6 +1168,9 @@ const resources = {
|
||||
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',
|
||||
@@ -1155,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