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.Application.Broadcast.UpdateChannelTime;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Infrastructure.Identity;
|
||||||
|
using TeleWave.Application.Broadcast.UpdateViewerSettings;
|
||||||
|
using TeleWave.Application.Programming.Planning.Trace;
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
@@ -92,10 +94,47 @@ public static partial class ChannelEndpoints
|
|||||||
admin
|
admin
|
||||||
.MapGet("/{id:guid}/schedule", GetSchedule)
|
.MapGet("/{id:guid}/schedule", GetSchedule)
|
||||||
.Produces<IReadOnlyList<ScheduleEntryDto>>();
|
.Produces<IReadOnlyList<ScheduleEntryDto>>();
|
||||||
|
admin
|
||||||
|
.MapPut("/{id:guid}/viewer", UpdateViewerSettings)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
// «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации.
|
||||||
|
admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces<EntryTraceDto>();
|
||||||
|
|
||||||
return app;
|
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(
|
private static async Task<IResult> CreateChannel(
|
||||||
CreateChannelCommand command,
|
CreateChannelCommand command,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
@@ -203,3 +242,12 @@ public sealed record UpdateChannelSettingsBody(
|
|||||||
BumperSettingsInput Bumper,
|
BumperSettingsInput Bumper,
|
||||||
Guid? FillerAssetId
|
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 LiteCqrs;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Application.Settings;
|
using TeleWave.Application.Settings;
|
||||||
using TeleWave.Application.Settings.GetSiteSettings;
|
using TeleWave.Application.Settings.GetSiteSettings;
|
||||||
using TeleWave.Application.Settings.UpdateSiteSettings;
|
using TeleWave.Application.Settings.UpdateSiteSettings;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Infrastructure.Identity;
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
public static class SettingsEndpoints
|
public static class SettingsEndpoints
|
||||||
{
|
{
|
||||||
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var admin = app.MapGroup("/api/admin/settings")
|
var admin = app.MapGroup("/api/admin/settings")
|
||||||
.WithTags("Admin.Settings")
|
.WithTags("Admin.Settings")
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
admin.MapGet("", GetSettings).Produces<SiteSettingsDto>();
|
admin.MapGet("", GetSettings).Produces<SiteSettingsDto>();
|
||||||
admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent);
|
admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetSettings(
|
private static async Task<IResult> GetSettings(
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
|
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
|
||||||
return Results.Ok(settings);
|
return Results.Ok(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UpdateSettings(
|
private static async Task<IResult> UpdateSettings(
|
||||||
UpdateSiteSettingsBody body,
|
UpdateSiteSettingsBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new UpdateSiteSettingsCommand(
|
new UpdateSiteSettingsCommand(
|
||||||
body.RegistrationEnabled,
|
body.RegistrationEnabled,
|
||||||
body.PreferredAudioLanguages ?? ""
|
body.PreferredAudioLanguages ?? "",
|
||||||
),
|
body.ChannelNumbersEnabled
|
||||||
cancellationToken
|
),
|
||||||
);
|
cancellationToken
|
||||||
return result.ToHttpResult();
|
);
|
||||||
}
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
public sealed record UpdateSiteSettingsBody(
|
|
||||||
bool RegistrationEnabled,
|
public sealed record UpdateSiteSettingsBody(
|
||||||
string? PreferredAudioLanguages
|
bool RegistrationEnabled,
|
||||||
);
|
string? PreferredAudioLanguages,
|
||||||
|
bool ChannelNumbersEnabled
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,184 +1,197 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Streaming;
|
using TeleWave.Application.Streaming;
|
||||||
using TeleWave.Application.Streaming.GetLivePlaylist;
|
using TeleWave.Application.Streaming.GetLivePlaylist;
|
||||||
using TeleWave.Application.Streaming.GetPublicEpg;
|
using TeleWave.Application.Streaming.GetPublicEpg;
|
||||||
using TeleWave.Application.Streaming.ListPublicChannels;
|
using TeleWave.Application.Streaming.ListPublicChannels;
|
||||||
using TeleWave.Infrastructure.Media;
|
using TeleWave.Infrastructure.Media;
|
||||||
using TeleWave.Infrastructure.Streaming;
|
using TeleWave.Infrastructure.Streaming;
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
public static class StreamingEndpoints
|
public static class StreamingEndpoints
|
||||||
{
|
{
|
||||||
private const string StreamCookieName = "tw_stream";
|
private const string StreamCookieName = "tw_stream";
|
||||||
private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled);
|
private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled);
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
// Публичный API канала (Bearer): список, EPG, выдача stream-cookie.
|
// Публичный API канала (Bearer): список, EPG, выдача stream-cookie.
|
||||||
var channels = app.MapGroup("/api/channels").WithTags("Channels").RequireAuthorization();
|
var channels = app.MapGroup("/api/channels").WithTags("Channels").RequireAuthorization();
|
||||||
channels.MapGet("", ListChannels).Produces<IReadOnlyList<PublicChannelDto>>();
|
channels.MapGet("", ListChannels).Produces<IReadOnlyList<PublicChannelDto>>();
|
||||||
channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent);
|
// Что включено на стороне зрителя: сейчас только переключение по номерам (см. 6.8).
|
||||||
channels.MapGet("/{slug}/epg", Epg);
|
channels.MapGet("/features", ViewerFeatures).Produces<ViewerFeaturesDto>();
|
||||||
|
channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent);
|
||||||
// Раздача эфира (cookie tw_stream): плейлист и сегменты — их грузит <video>/hls.js.
|
channels.MapGet("/{slug}/epg", Epg);
|
||||||
app.MapGet("/api/channels/{slug}/live.m3u8", LivePlaylist).WithTags("Streaming");
|
|
||||||
app.MapGet("/api/stream/{assetId:guid}/{file}", Segment).WithTags("Streaming");
|
// Раздача эфира (cookie tw_stream): плейлист и сегменты — их грузит <video>/hls.js.
|
||||||
|
app.MapGet("/api/channels/{slug}/live.m3u8", LivePlaylist).WithTags("Streaming");
|
||||||
return app;
|
app.MapGet("/api/stream/{assetId:guid}/{file}", Segment).WithTags("Streaming");
|
||||||
}
|
|
||||||
|
return app;
|
||||||
private static async Task<IResult> ListChannels(
|
}
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
private static async Task<IResult> ListChannels(
|
||||||
)
|
ISender sender,
|
||||||
{
|
CancellationToken cancellationToken
|
||||||
var result = await sender.Send(new ListPublicChannelsQuery(), cancellationToken);
|
)
|
||||||
return Results.Ok(result);
|
{
|
||||||
}
|
var result = await sender.Send(new ListPublicChannelsQuery(), cancellationToken);
|
||||||
|
return Results.Ok(result);
|
||||||
private static IResult Watch(
|
}
|
||||||
string slug,
|
|
||||||
ICurrentUser currentUser,
|
private static async Task<IResult> ViewerFeatures(
|
||||||
StreamTokenService tokens,
|
ISiteSettings siteSettings,
|
||||||
HttpRequest request,
|
CancellationToken cancellationToken
|
||||||
HttpResponse response,
|
) =>
|
||||||
IHostEnvironment env
|
Results.Ok(
|
||||||
)
|
new ViewerFeaturesDto(await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken))
|
||||||
{
|
);
|
||||||
if (currentUser.UserId is not { } userId)
|
|
||||||
return Results.Unauthorized();
|
private static IResult Watch(
|
||||||
|
string slug,
|
||||||
var (token, expiresAt) = tokens.Issue(userId);
|
ICurrentUser currentUser,
|
||||||
response.Cookies.Append(
|
StreamTokenService tokens,
|
||||||
StreamCookieName,
|
HttpRequest request,
|
||||||
token,
|
HttpResponse response,
|
||||||
new CookieOptions
|
IHostEnvironment env
|
||||||
{
|
)
|
||||||
HttpOnly = true,
|
{
|
||||||
// Вне Development — всегда Secure (прод за внешним TLS-прокси; request.IsHttps ненадёжен
|
if (currentUser.UserId is not { } userId)
|
||||||
// при неполной настройке ForwardedHeaders). См. UseSecureCookie в AuthEndpoints.
|
return Results.Unauthorized();
|
||||||
Secure = !env.IsDevelopment() || request.IsHttps,
|
|
||||||
SameSite = SameSiteMode.Strict,
|
var (token, expiresAt) = tokens.Issue(userId);
|
||||||
Path = "/api",
|
response.Cookies.Append(
|
||||||
Expires = expiresAt,
|
StreamCookieName,
|
||||||
}
|
token,
|
||||||
);
|
new CookieOptions
|
||||||
return Results.NoContent();
|
{
|
||||||
}
|
HttpOnly = true,
|
||||||
|
// Вне Development — всегда Secure (прод за внешним TLS-прокси; request.IsHttps ненадёжен
|
||||||
private static async Task<IResult> Epg(
|
// при неполной настройке ForwardedHeaders). См. UseSecureCookie в AuthEndpoints.
|
||||||
string slug,
|
Secure = !env.IsDevelopment() || request.IsHttps,
|
||||||
DateTimeOffset? from,
|
SameSite = SameSiteMode.Strict,
|
||||||
DateTimeOffset? to,
|
Path = "/api",
|
||||||
ISender sender,
|
Expires = expiresAt,
|
||||||
CancellationToken cancellationToken
|
}
|
||||||
)
|
);
|
||||||
{
|
return Results.NoContent();
|
||||||
var fromUtc = from ?? DateTimeOffset.UtcNow;
|
}
|
||||||
var toUtc = to ?? fromUtc.AddHours(12);
|
|
||||||
var result = await sender.Send(
|
private static async Task<IResult> Epg(
|
||||||
new GetPublicEpgQuery(slug, fromUtc, toUtc),
|
string slug,
|
||||||
cancellationToken
|
DateTimeOffset? from,
|
||||||
);
|
DateTimeOffset? to,
|
||||||
return result.ToHttpResult();
|
ISender sender,
|
||||||
}
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
private static async Task<IResult> LivePlaylist(
|
{
|
||||||
string slug,
|
var fromUtc = from ?? DateTimeOffset.UtcNow;
|
||||||
HttpRequest request,
|
var toUtc = to ?? fromUtc.AddHours(12);
|
||||||
HttpResponse response,
|
var result = await sender.Send(
|
||||||
StreamTokenService tokens,
|
new GetPublicEpgQuery(slug, fromUtc, toUtc),
|
||||||
IIdentityService identity,
|
cancellationToken
|
||||||
ISender sender,
|
);
|
||||||
CancellationToken cancellationToken
|
return result.ToHttpResult();
|
||||||
)
|
}
|
||||||
{
|
|
||||||
// Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить,
|
private static async Task<IResult> LivePlaylist(
|
||||||
// что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу,
|
string slug,
|
||||||
// не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто).
|
HttpRequest request,
|
||||||
if (tokens.Validate(request.Cookies[StreamCookieName]) is not { } userId)
|
HttpResponse response,
|
||||||
return Results.Unauthorized();
|
StreamTokenService tokens,
|
||||||
var profile = await identity.GetProfileAsync(userId, cancellationToken);
|
IIdentityService identity,
|
||||||
if (profile is null || profile.IsBlocked)
|
ISender sender,
|
||||||
return Results.Unauthorized();
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
var result = await sender.Send(
|
{
|
||||||
new GetLivePlaylistQuery(slug, DateTimeOffset.UtcNow),
|
// Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить,
|
||||||
cancellationToken
|
// что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу,
|
||||||
);
|
// не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто).
|
||||||
if (!result.IsSuccess)
|
if (tokens.Validate(request.Cookies[StreamCookieName]) is not { } userId)
|
||||||
return result.ToHttpResult();
|
return Results.Unauthorized();
|
||||||
if (result.Value.Segments.Count == 0)
|
var profile = await identity.GetProfileAsync(userId, cancellationToken);
|
||||||
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
if (profile is null || profile.IsBlocked)
|
||||||
|
return Results.Unauthorized();
|
||||||
response.Headers.CacheControl = "no-cache";
|
|
||||||
return Results.Text(Render(result.Value), "application/vnd.apple.mpegurl");
|
var result = await sender.Send(
|
||||||
}
|
new GetLivePlaylistQuery(slug, DateTimeOffset.UtcNow),
|
||||||
|
cancellationToken
|
||||||
private static IResult Segment(
|
);
|
||||||
Guid assetId,
|
if (!result.IsSuccess)
|
||||||
string file,
|
return result.ToHttpResult();
|
||||||
HttpRequest request,
|
if (result.Value.Segments.Count == 0)
|
||||||
HttpResponse response,
|
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||||
StreamTokenService tokens,
|
|
||||||
MediaPathResolver paths
|
response.Headers.CacheControl = "no-cache";
|
||||||
)
|
return Results.Text(Render(result.Value), "application/vnd.apple.mpegurl");
|
||||||
{
|
}
|
||||||
if (tokens.Validate(request.Cookies[StreamCookieName]) is null)
|
|
||||||
return Results.Unauthorized();
|
private static IResult Segment(
|
||||||
if (!SegmentFileName.IsMatch(file))
|
Guid assetId,
|
||||||
return Results.NotFound();
|
string file,
|
||||||
|
HttpRequest request,
|
||||||
string path;
|
HttpResponse response,
|
||||||
try
|
StreamTokenService tokens,
|
||||||
{
|
MediaPathResolver paths
|
||||||
path = paths.SegmentPath(assetId, file);
|
)
|
||||||
}
|
{
|
||||||
catch (UnauthorizedAccessException)
|
if (tokens.Validate(request.Cookies[StreamCookieName]) is null)
|
||||||
{
|
return Results.Unauthorized();
|
||||||
return Results.NotFound();
|
if (!SegmentFileName.IsMatch(file))
|
||||||
}
|
return Results.NotFound();
|
||||||
|
|
||||||
if (!File.Exists(path))
|
string path;
|
||||||
return Results.NotFound();
|
try
|
||||||
|
{
|
||||||
response.Headers.CacheControl = "public, max-age=31536000, immutable";
|
path = paths.SegmentPath(assetId, file);
|
||||||
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
}
|
||||||
}
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
private static string Render(LivePlaylistDto playlist)
|
return Results.NotFound();
|
||||||
{
|
}
|
||||||
var extinf = playlist.TargetDuration.ToString("F6", CultureInfo.InvariantCulture);
|
|
||||||
var sb = new StringBuilder();
|
if (!File.Exists(path))
|
||||||
sb.Append("#EXTM3U\n");
|
return Results.NotFound();
|
||||||
sb.Append("#EXT-X-VERSION:3\n");
|
|
||||||
sb.Append(
|
response.Headers.CacheControl = "public, max-age=31536000, immutable";
|
||||||
CultureInfo.InvariantCulture,
|
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
||||||
$"#EXT-X-TARGETDURATION:{playlist.TargetDuration}\n"
|
}
|
||||||
);
|
|
||||||
sb.Append(
|
private static string Render(LivePlaylistDto playlist)
|
||||||
CultureInfo.InvariantCulture,
|
{
|
||||||
$"#EXT-X-MEDIA-SEQUENCE:{playlist.MediaSequence}\n"
|
var extinf = playlist.TargetDuration.ToString("F6", CultureInfo.InvariantCulture);
|
||||||
);
|
var sb = new StringBuilder();
|
||||||
|
sb.Append("#EXTM3U\n");
|
||||||
foreach (var segment in playlist.Segments)
|
sb.Append("#EXT-X-VERSION:3\n");
|
||||||
{
|
sb.Append(
|
||||||
if (segment.Discontinuity)
|
CultureInfo.InvariantCulture,
|
||||||
sb.Append("#EXT-X-DISCONTINUITY\n");
|
$"#EXT-X-TARGETDURATION:{playlist.TargetDuration}\n"
|
||||||
sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n");
|
);
|
||||||
sb.Append(
|
sb.Append(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
$"/api/stream/{segment.AssetId:N}/seg{segment.LocalIndex:D5}.ts\n"
|
$"#EXT-X-MEDIA-SEQUENCE:{playlist.MediaSequence}\n"
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
foreach (var segment in playlist.Segments)
|
||||||
return sb.ToString();
|
{
|
||||||
}
|
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 LiteCqrs;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Application.Programming.Planning.ApplyTemplate;
|
using TeleWave.Application.Programming.Planning.ApplyTemplate;
|
||||||
|
using TeleWave.Application.Programming.Planning.Diff;
|
||||||
using TeleWave.Application.Programming.Planning.Preview;
|
using TeleWave.Application.Programming.Planning.Preview;
|
||||||
using TeleWave.Application.Programming.Templates;
|
using TeleWave.Application.Programming.Templates;
|
||||||
|
using TeleWave.Application.Programming.Templates.CopyTemplate;
|
||||||
using TeleWave.Application.Programming.Templates.CreateSlot;
|
using TeleWave.Application.Programming.Templates.CreateSlot;
|
||||||
using TeleWave.Application.Programming.Templates.DeleteSlot;
|
using TeleWave.Application.Programming.Templates.DeleteSlot;
|
||||||
using TeleWave.Application.Programming.Templates.GetTemplate;
|
using TeleWave.Application.Programming.Templates.GetTemplate;
|
||||||
using TeleWave.Application.Programming.Templates.Layers;
|
using TeleWave.Application.Programming.Templates.Layers;
|
||||||
using TeleWave.Application.Programming.Templates.UpdateSlot;
|
using TeleWave.Application.Programming.Templates.UpdateSlot;
|
||||||
|
using TeleWave.Application.Programming.Templates.Validate;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Infrastructure.Identity;
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
namespace TeleWave.Api.Endpoints;
|
||||||
@@ -39,6 +42,17 @@ public static class TemplateEndpoints
|
|||||||
admin
|
admin
|
||||||
.MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
|
.MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
|
||||||
.Produces<SchedulePreviewDto>();
|
.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
|
admin
|
||||||
.MapPost("/templates/{templateId:guid}/layers", CreateLayer)
|
.MapPost("/templates/{templateId:guid}/layers", CreateLayer)
|
||||||
@@ -94,6 +108,40 @@ public static class TemplateEndpoints
|
|||||||
return result.ToHttpResult();
|
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(
|
private static async Task<IResult> UpdateTemplate(
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
UpdateTemplateBody body,
|
UpdateTemplateBody body,
|
||||||
|
|||||||
@@ -49,5 +49,16 @@ public sealed record ChannelDto(
|
|||||||
bool BumpersEnabled,
|
bool BumpersEnabled,
|
||||||
BumperSettingsDto Bumper,
|
BumperSettingsDto Bumper,
|
||||||
IReadOnlyList<BumperTemplateDto> BumperTemplates,
|
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,
|
channel.BumpersEnabled,
|
||||||
new BumperSettingsDto(channel.BumperFont, channel.BumperSelection),
|
new BumperSettingsDto(channel.BumperFont, channel.BumperSelection),
|
||||||
bumperTemplates,
|
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;
|
namespace TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
/// <summary>Доступ к глобальным настройкам сайта (key-value), скрывающий хранилище от хендлеров.</summary>
|
/// <summary>Доступ к глобальным настройкам сайта (key-value), скрывающий хранилище от хендлеров.</summary>
|
||||||
public interface ISiteSettings
|
public interface ISiteSettings
|
||||||
{
|
{
|
||||||
Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken);
|
Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Пишет значение в контекст (сохранение — за UnitOfWorkBehavior вызывающей команды).</summary>
|
/// <summary>Пишет значение в контекст (сохранение — за UnitOfWorkBehavior вызывающей команды).</summary>
|
||||||
Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken);
|
Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
|
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
|
||||||
/// (напр. «rus,eng»); пусто — без предпочтения.</summary>
|
/// (напр. «rus,eng»); пусто — без предпочтения.</summary>
|
||||||
Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken);
|
Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task SetPreferredAudioLanguagesAsync(string value, 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,
|
IAppDbContext dbContext,
|
||||||
GroupExpander expander,
|
GroupExpander expander,
|
||||||
BumperResolver bumperResolver,
|
BumperResolver bumperResolver,
|
||||||
|
PostCheckRunner postChecks,
|
||||||
IRandomSource random,
|
IRandomSource random,
|
||||||
IOptions<SchedulerOptions> options,
|
IOptions<SchedulerOptions> options,
|
||||||
IOptions<StreamingOptions> streamingOptions
|
IOptions<StreamingOptions> streamingOptions
|
||||||
@@ -105,6 +106,16 @@ public sealed class GridScheduleGenerator(
|
|||||||
);
|
);
|
||||||
var result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random);
|
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(
|
var bumperAssets = await bumperResolver.ResolveAsync(
|
||||||
channel,
|
channel,
|
||||||
@@ -143,7 +154,7 @@ public sealed class GridScheduleGenerator(
|
|||||||
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
await transaction.CommitAsync(cancellationToken);
|
await transaction.CommitAsync(cancellationToken);
|
||||||
return new GenerationReport(added, result.Warnings);
|
return new GenerationReport(added, [.. result.Warnings, .. postWarnings]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -176,7 +187,17 @@ public sealed class GridScheduleGenerator(
|
|||||||
|
|
||||||
var horizonEnd = from.AddDays(Math.Clamp(days, 1, Math.Max(1, _options.HorizonDays)));
|
var horizonEnd = from.AddDays(Math.Clamp(days, 1, Math.Max(1, _options.HorizonDays)));
|
||||||
var input = await BuildInputAsync(channel, template, from, horizonEnd, cancellationToken);
|
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>
|
/// <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;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using TeleWave.Domain.Library;
|
using TeleWave.Domain.Library;
|
||||||
|
|
||||||
namespace TeleWave.Application.Programming.Templates;
|
namespace TeleWave.Application.Programming.Templates;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Окно детского времени: с <paramref name="From"/> до <paramref name="To"/> во времени канала
|
/// Окно детского времени: с <paramref name="From"/> до <paramref name="To"/> во времени канала
|
||||||
/// в эфир идёт только контент не строже <paramref name="MaxAudience"/>. Окно может переходить
|
/// в эфир идёт только контент не строже <paramref name="MaxAudience"/>. Окно может переходить
|
||||||
/// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону.
|
/// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record AudienceWindow(TimeOnly From, TimeOnly To, ShowAudience MaxAudience)
|
public sealed record AudienceWindow(TimeOnly From, TimeOnly To, ShowAudience MaxAudience)
|
||||||
{
|
{
|
||||||
public bool Contains(TimeOnly moment) =>
|
public bool Contains(TimeOnly moment) =>
|
||||||
From <= To ? moment >= From && moment < To : moment >= From || moment < To;
|
From <= To ? moment >= From && moment < To : moment >= From || moment < To;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают
|
/// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают
|
||||||
/// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость.
|
/// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость.
|
||||||
///
|
///
|
||||||
/// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени
|
/// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени
|
||||||
/// и так задаются временем, а потолок повторов осмыслен только целиком по каналу.
|
/// и так задаются временем, а потолок повторов осмыслен только целиком по каналу.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record PlanningRules(
|
public sealed record PlanningRules(
|
||||||
IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null,
|
IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null,
|
||||||
/// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary>
|
/// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary>
|
||||||
RepeatLimitRule? MaxRepeatsInWindow = null
|
RepeatLimitRule? MaxRepeatsInWindow = null,
|
||||||
)
|
// ── Пост-проверки: считаются по готовой ленте, дают предупреждения и ничего не переигрывают. ──
|
||||||
{
|
/// <summary>Потолок врезок в часе, минуты (null — не проверять).</summary>
|
||||||
private static readonly JsonSerializerOptions Options = new()
|
int? MaxBreakMinutesPerHour = null,
|
||||||
{
|
/// <summary>Потолок доли одного жанра за вещательные сутки, проценты (null — не проверять).</summary>
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
int? MaxGenreSharePercent = null,
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
/// <summary>Потолок доли эфира, отданной фону, проценты (null — не проверять).</summary>
|
||||||
Converters = { new JsonStringEnumConverter() },
|
int? MaxFallbackSharePercent = null
|
||||||
};
|
)
|
||||||
|
{
|
||||||
/// <summary>Возрастной потолок в указанный момент времени канала (null — без ограничения).</summary>
|
private static readonly JsonSerializerOptions Options = new()
|
||||||
public ShowAudience? AudienceAt(TimeOnly moment)
|
{
|
||||||
{
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
if (MaxAudienceByTime is not { Count: > 0 } windows)
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
return null;
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
// Пересекающиеся окна разрешаются в пользу строгого: детское время не должно
|
|
||||||
// отменяться более широким окном, случайно наложенным сверху.
|
/// <summary>Возрастной потолок в указанный момент времени канала (null — без ограничения).</summary>
|
||||||
ShowAudience? strictest = null;
|
public ShowAudience? AudienceAt(TimeOnly moment)
|
||||||
foreach (var window in windows.Where(w => w.Contains(moment)))
|
{
|
||||||
strictest = strictest is { } current && current <= window.MaxAudience
|
if (MaxAudienceByTime is not { Count: > 0 } windows)
|
||||||
? current
|
return null;
|
||||||
: window.MaxAudience;
|
|
||||||
return strictest;
|
// Пересекающиеся окна разрешаются в пользу строгого: детское время не должно
|
||||||
}
|
// отменяться более широким окном, случайно наложенным сверху.
|
||||||
|
ShowAudience? strictest = null;
|
||||||
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
foreach (var window in windows.Where(w => w.Contains(moment)))
|
||||||
|
strictest = strictest is { } current && current <= window.MaxAudience
|
||||||
public static PlanningRules? FromJson(string? json)
|
? current
|
||||||
{
|
: window.MaxAudience;
|
||||||
if (string.IsNullOrWhiteSpace(json))
|
return strictest;
|
||||||
return null;
|
}
|
||||||
|
|
||||||
try
|
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||||
{
|
|
||||||
return JsonSerializer.Deserialize<PlanningRules>(json, Options);
|
public static PlanningRules? FromJson(string? json)
|
||||||
}
|
{
|
||||||
catch (JsonException)
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
{
|
return null;
|
||||||
return null;
|
|
||||||
}
|
try
|
||||||
}
|
{
|
||||||
}
|
return JsonSerializer.Deserialize<PlanningRules>(json, Options);
|
||||||
|
}
|
||||||
public sealed record RepeatLimitRule(int WindowDays, int Max);
|
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 LiteCqrs;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
namespace TeleWave.Application.Settings.GetSiteSettings;
|
namespace TeleWave.Application.Settings.GetSiteSettings;
|
||||||
|
|
||||||
public sealed class GetSiteSettingsQueryHandler(ISiteSettings siteSettings)
|
public sealed class GetSiteSettingsQueryHandler(ISiteSettings siteSettings)
|
||||||
: IQueryHandler<GetSiteSettingsQuery, SiteSettingsDto>
|
: IQueryHandler<GetSiteSettingsQuery, SiteSettingsDto>
|
||||||
{
|
{
|
||||||
public async Task<SiteSettingsDto> Handle(
|
public async Task<SiteSettingsDto> Handle(
|
||||||
GetSiteSettingsQuery query,
|
GetSiteSettingsQuery query,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var registrationEnabled = await siteSettings.IsRegistrationEnabledAsync(cancellationToken);
|
var registrationEnabled = await siteSettings.IsRegistrationEnabledAsync(cancellationToken);
|
||||||
var preferredAudio = await siteSettings.GetPreferredAudioLanguagesAsync(cancellationToken);
|
var preferredAudio = await siteSettings.GetPreferredAudioLanguagesAsync(cancellationToken);
|
||||||
return new SiteSettingsDto(registrationEnabled, preferredAudio);
|
var channelNumbers = await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken);
|
||||||
}
|
return new SiteSettingsDto(registrationEnabled, preferredAudio, channelNumbers);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
namespace TeleWave.Application.Settings;
|
namespace TeleWave.Application.Settings;
|
||||||
|
|
||||||
/// <summary>Стабильные ключи глобальных настроек сайта в key-value хранилище (AppSetting).</summary>
|
/// <summary>Стабильные ключи глобальных настроек сайта в key-value хранилище (AppSetting).</summary>
|
||||||
public static class SettingKeys
|
public static class SettingKeys
|
||||||
{
|
{
|
||||||
/// <summary>Разрешена ли открытая регистрация пользователей (по умолчанию — нет).</summary>
|
/// <summary>Разрешена ли открытая регистрация пользователей (по умолчанию — нет).</summary>
|
||||||
public const string RegistrationEnabled = "registration.enabled";
|
public const string RegistrationEnabled = "registration.enabled";
|
||||||
|
|
||||||
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
|
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
|
||||||
/// (напр. «rus,eng»). При наличии дорожки с таким языком она выбирается первой; иначе — дефолт ffmpeg.</summary>
|
/// (напр. «rus,eng»). При наличии дорожки с таким языком она выбирается первой; иначе — дефолт ffmpeg.</summary>
|
||||||
public const string PreferredAudioLanguages = "media.preferredAudioLanguages";
|
public const string PreferredAudioLanguages = "media.preferredAudioLanguages";
|
||||||
}
|
|
||||||
|
/// <summary>Разрешено ли зрителю переключать каналы по номерам, как на телевизоре (по умолчанию — нет).
|
||||||
|
/// Сетка каналов остаётся вторым способом навигации всегда.</summary>
|
||||||
|
public const string ChannelNumbersEnabled = "viewer.channelNumbersEnabled";
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
namespace TeleWave.Application.Settings;
|
namespace TeleWave.Application.Settings;
|
||||||
|
|
||||||
/// <summary>Глобальные настройки сайта, управляемые администратором.</summary>
|
/// <summary>Глобальные настройки сайта, управляемые администратором.</summary>
|
||||||
public sealed record SiteSettingsDto(bool RegistrationEnabled, string PreferredAudioLanguages);
|
public sealed record SiteSettingsDto(
|
||||||
|
bool RegistrationEnabled,
|
||||||
|
string PreferredAudioLanguages,
|
||||||
|
/// <summary>Переключение каналов по номерам у зрителя — сетка каналов остаётся всегда.</summary>
|
||||||
|
bool ChannelNumbersEnabled
|
||||||
|
);
|
||||||
|
|||||||
+10
-9
@@ -1,9 +1,10 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
namespace TeleWave.Application.Settings.UpdateSiteSettings;
|
namespace TeleWave.Application.Settings.UpdateSiteSettings;
|
||||||
|
|
||||||
public sealed record UpdateSiteSettingsCommand(
|
public sealed record UpdateSiteSettingsCommand(
|
||||||
bool RegistrationEnabled,
|
bool RegistrationEnabled,
|
||||||
string PreferredAudioLanguages
|
string PreferredAudioLanguages,
|
||||||
) : ICommand<Result>;
|
bool ChannelNumbersEnabled
|
||||||
|
) : ICommand<Result>;
|
||||||
|
|||||||
+34
-30
@@ -1,30 +1,34 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
namespace TeleWave.Application.Settings.UpdateSiteSettings;
|
namespace TeleWave.Application.Settings.UpdateSiteSettings;
|
||||||
|
|
||||||
public sealed class UpdateSiteSettingsCommandHandler(ISiteSettings siteSettings)
|
public sealed class UpdateSiteSettingsCommandHandler(ISiteSettings siteSettings)
|
||||||
: ICommandHandler<UpdateSiteSettingsCommand, Result>
|
: ICommandHandler<UpdateSiteSettingsCommand, Result>
|
||||||
{
|
{
|
||||||
public async Task<Result> Handle(
|
public async Task<Result> Handle(
|
||||||
UpdateSiteSettingsCommand command,
|
UpdateSiteSettingsCommand command,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// Сохранение выполняет UnitOfWorkBehavior команды.
|
// Сохранение выполняет UnitOfWorkBehavior команды.
|
||||||
await siteSettings.SetRegistrationEnabledAsync(
|
await siteSettings.SetRegistrationEnabledAsync(
|
||||||
command.RegistrationEnabled,
|
command.RegistrationEnabled,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
// Нормализуем список языков: без пробелов, в нижнем регистре, пустые отбрасываем.
|
// Нормализуем список языков: без пробелов, в нижнем регистре, пустые отбрасываем.
|
||||||
var languages = string.Join(
|
var languages = string.Join(
|
||||||
',',
|
',',
|
||||||
(command.PreferredAudioLanguages ?? string.Empty)
|
(command.PreferredAudioLanguages ?? string.Empty)
|
||||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
.Select(x => x.ToLowerInvariant())
|
.Select(x => x.ToLowerInvariant())
|
||||||
);
|
);
|
||||||
await siteSettings.SetPreferredAudioLanguagesAsync(languages, cancellationToken);
|
await siteSettings.SetPreferredAudioLanguagesAsync(languages, cancellationToken);
|
||||||
return Result.Success();
|
await siteSettings.SetChannelNumbersEnabledAsync(
|
||||||
}
|
command.ChannelNumbersEnabled,
|
||||||
}
|
cancellationToken
|
||||||
|
);
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+93
-77
@@ -1,77 +1,93 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
namespace TeleWave.Application.Streaming.ListPublicChannels;
|
namespace TeleWave.Application.Streaming.ListPublicChannels;
|
||||||
|
|
||||||
public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
|
public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
|
||||||
: IQueryHandler<ListPublicChannelsQuery, IReadOnlyList<PublicChannelDto>>
|
: IQueryHandler<ListPublicChannelsQuery, IReadOnlyList<PublicChannelDto>>
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<PublicChannelDto>> Handle(
|
public async Task<IReadOnlyList<PublicChannelDto>> Handle(
|
||||||
ListPublicChannelsQuery query,
|
ListPublicChannelsQuery query,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var channels = await dbContext
|
var channels = await dbContext
|
||||||
.Channels.AsNoTracking()
|
.Channels.AsNoTracking()
|
||||||
.Where(c => c.IsEnabled)
|
.Where(c => c.IsEnabled)
|
||||||
.OrderBy(c => c.Name)
|
// Каналы без номера — в конец: на телевизоре порядок задаёт номер, а имя лишь
|
||||||
.Select(c => new
|
// разрешает ничью между ненумерованными.
|
||||||
{
|
.OrderBy(c => c.Number == null)
|
||||||
c.Id,
|
.ThenBy(c => c.Number)
|
||||||
c.Slug,
|
.ThenBy(c => c.Name)
|
||||||
c.Name,
|
.Select(c => new
|
||||||
})
|
{
|
||||||
.ToListAsync(cancellationToken);
|
c.Id,
|
||||||
if (channels.Count == 0)
|
c.Slug,
|
||||||
return [];
|
c.Name,
|
||||||
|
c.Number,
|
||||||
var now = DateTimeOffset.UtcNow;
|
c.LogoImageId,
|
||||||
var channelIds = channels.Select(c => c.Id).ToList();
|
c.LogoCorner,
|
||||||
|
c.LogoOpacity,
|
||||||
// Что идёт прямо сейчас на каждом канале (программа) — для постера-обложки плитки.
|
c.ShowClock,
|
||||||
var currentByChannel = await dbContext
|
c.AnalogFilterStrength,
|
||||||
.ScheduleEntries.AsNoTracking()
|
})
|
||||||
.Where(e =>
|
.ToListAsync(cancellationToken);
|
||||||
channelIds.Contains(e.ChannelId)
|
if (channels.Count == 0)
|
||||||
&& e.Kind == ScheduleEntryKind.Program
|
return [];
|
||||||
&& e.StartsAtUtc <= now
|
|
||||||
&& e.EndsAtUtc > now
|
var now = DateTimeOffset.UtcNow;
|
||||||
&& e.ShowId != null
|
var channelIds = channels.Select(c => c.Id).ToList();
|
||||||
)
|
|
||||||
.Select(e => new { e.ChannelId, ShowId = e.ShowId!.Value })
|
// Что идёт прямо сейчас на каждом канале (программа) — для постера-обложки плитки.
|
||||||
.ToListAsync(cancellationToken);
|
var currentByChannel = await dbContext
|
||||||
var currentShowByChannel = currentByChannel
|
.ScheduleEntries.AsNoTracking()
|
||||||
.GroupBy(x => x.ChannelId)
|
.Where(e =>
|
||||||
.ToDictionary(g => g.Key, g => g.First().ShowId);
|
channelIds.Contains(e.ChannelId)
|
||||||
|
&& e.Kind == ScheduleEntryKind.Program
|
||||||
var showIds = currentShowByChannel.Values.Distinct().ToList();
|
&& e.StartsAtUtc <= now
|
||||||
var shows = await dbContext
|
&& e.EndsAtUtc > now
|
||||||
.Shows.AsNoTracking()
|
&& e.ShowId != null
|
||||||
.Where(s => showIds.Contains(s.Id))
|
)
|
||||||
.Select(s => new
|
.Select(e => new { e.ChannelId, ShowId = e.ShowId!.Value })
|
||||||
{
|
.ToListAsync(cancellationToken);
|
||||||
s.Id,
|
var currentShowByChannel = currentByChannel
|
||||||
s.Name,
|
.GroupBy(x => x.ChannelId)
|
||||||
s.PosterImageId,
|
.ToDictionary(g => g.Key, g => g.First().ShowId);
|
||||||
})
|
|
||||||
.ToDictionaryAsync(s => s.Id, cancellationToken);
|
var showIds = currentShowByChannel.Values.Distinct().ToList();
|
||||||
|
var shows = await dbContext
|
||||||
return channels
|
.Shows.AsNoTracking()
|
||||||
.Select(c =>
|
.Where(s => showIds.Contains(s.Id))
|
||||||
{
|
.Select(s => new
|
||||||
var showId = currentShowByChannel.GetValueOrDefault(c.Id);
|
{
|
||||||
var show = showId != Guid.Empty ? shows.GetValueOrDefault(showId) : null;
|
s.Id,
|
||||||
return new PublicChannelDto(
|
s.Name,
|
||||||
c.Id,
|
s.PosterImageId,
|
||||||
c.Slug,
|
})
|
||||||
c.Name,
|
.ToDictionaryAsync(s => s.Id, cancellationToken);
|
||||||
show is null ? null : showId,
|
|
||||||
show?.Name,
|
return channels
|
||||||
show?.PosterImageId
|
.Select(c =>
|
||||||
);
|
{
|
||||||
})
|
var showId = currentShowByChannel.GetValueOrDefault(c.Id);
|
||||||
.ToList();
|
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;
|
using TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
namespace TeleWave.Application.Streaming;
|
namespace TeleWave.Application.Streaming;
|
||||||
|
|
||||||
public sealed record PublicChannelDto(
|
public sealed record PublicChannelDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Slug,
|
string Slug,
|
||||||
string Name,
|
string Name,
|
||||||
Guid? CurrentShowId,
|
/// <summary>Номер канала для переключения по номерам или null, если не задан.</summary>
|
||||||
string? CurrentShowName,
|
int? Number,
|
||||||
Guid? CurrentShowPosterImageId
|
Guid? CurrentShowId,
|
||||||
);
|
string? CurrentShowName,
|
||||||
|
Guid? CurrentShowPosterImageId,
|
||||||
/// <summary>Запись публичного телегида с метаданными (без имён файлов и номеров серий).</summary>
|
/// <summary>Что рисовать поверх картинки — оверлеи считает клиент, ffmpeg их не касается.</summary>
|
||||||
public sealed record PublicEpgEntryDto(
|
Guid? LogoImageId,
|
||||||
ScheduleEntryKind Kind,
|
LogoCorner LogoCorner,
|
||||||
DateTimeOffset StartsAtUtc,
|
double LogoOpacity,
|
||||||
DateTimeOffset EndsAtUtc,
|
bool ShowClock,
|
||||||
Guid? ShowId,
|
double AnalogFilterStrength
|
||||||
string? ShowName,
|
);
|
||||||
Guid? ShowPosterImageId,
|
|
||||||
Guid? EpisodeId,
|
/// <summary>Запись публичного телегида с метаданными (без имён файлов и номеров серий).</summary>
|
||||||
string? EpisodeTitle,
|
public sealed record PublicEpgEntryDto(
|
||||||
string? EpisodeOverview,
|
ScheduleEntryKind Kind,
|
||||||
Guid? EpisodeStillImageId
|
DateTimeOffset StartsAtUtc,
|
||||||
);
|
DateTimeOffset EndsAtUtc,
|
||||||
|
Guid? ShowId,
|
||||||
public sealed record LiveSegmentDto(Guid AssetId, int LocalIndex, bool Discontinuity);
|
string? ShowName,
|
||||||
|
Guid? ShowPosterImageId,
|
||||||
public sealed record LivePlaylistDto(
|
Guid? EpisodeId,
|
||||||
long MediaSequence,
|
string? EpisodeTitle,
|
||||||
int TargetDuration,
|
string? EpisodeOverview,
|
||||||
IReadOnlyList<LiveSegmentDto> Segments
|
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>
|
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
|
||||||
public Guid? FillerAssetId { get; private set; }
|
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; }
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
|
|
||||||
/// <summary>Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.</summary>
|
/// <summary>Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.</summary>
|
||||||
@@ -77,6 +94,7 @@ public class Channel
|
|||||||
BumpersEnabled = false,
|
BumpersEnabled = false,
|
||||||
BumperSelection = BumperSelection.WeightedRandom,
|
BumperSelection = BumperSelection.WeightedRandom,
|
||||||
BumperFont = BumperFont.Sans,
|
BumperFont = BumperFont.Sans,
|
||||||
|
LogoOpacity = 0.8,
|
||||||
UtcOffsetMinutes = DefaultUtcOffsetMinutes,
|
UtcOffsetMinutes = DefaultUtcOffsetMinutes,
|
||||||
DayStartTime = DefaultDayStartTime,
|
DayStartTime = DefaultDayStartTime,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
@@ -106,6 +124,22 @@ public class Channel
|
|||||||
BumperSelection = selection;
|
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>
|
/// <summary>Добавить блок заставки в конец списка. Возвращает созданный блок.</summary>
|
||||||
public BumperTemplate AddBumperTemplate(string name)
|
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>
|
/// <summary>Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата.</summary>
|
||||||
CandidatesFiltered = 5,
|
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<SlotWriter>();
|
||||||
services.AddScoped<GroupExpander>();
|
services.AddScoped<GroupExpander>();
|
||||||
services.AddScoped<BumperResolver>();
|
services.AddScoped<BumperResolver>();
|
||||||
|
services.AddScoped<PostCheckRunner>();
|
||||||
services.AddScoped<GridScheduleGenerator>();
|
services.AddScoped<GridScheduleGenerator>();
|
||||||
|
|
||||||
AddMedia(services, configuration);
|
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")
|
b.Property<Guid>("Id")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<double>("AnalogFilterStrength")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
b.Property<int>("BumperFont")
|
b.Property<int>("BumperFont")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -342,6 +345,15 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
b.Property<bool>("IsEnabled")
|
b.Property<bool>("IsEnabled")
|
||||||
.HasColumnType("boolean");
|
.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")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
@@ -350,6 +362,9 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
b.Property<int?>("Number")
|
b.Property<int?>("Number")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("ShowClock")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<string>("Slug")
|
b.Property<string>("Slug")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(128)
|
.HasMaxLength(128)
|
||||||
|
|||||||
@@ -1,43 +1,49 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Settings;
|
using TeleWave.Application.Settings;
|
||||||
using TeleWave.Domain.Settings;
|
using TeleWave.Domain.Settings;
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Settings;
|
namespace TeleWave.Infrastructure.Settings;
|
||||||
|
|
||||||
/// <summary>Настройки сайта поверх key-value таблицы AppSetting. Запись не сохраняет сама —
|
/// <summary>Настройки сайта поверх key-value таблицы AppSetting. Запись не сохраняет сама —
|
||||||
/// сохранение выполняет UnitOfWorkBehavior команды (используется общий scoped-контекст).</summary>
|
/// сохранение выполняет UnitOfWorkBehavior команды (используется общий scoped-контекст).</summary>
|
||||||
public sealed class SiteSettings(IAppDbContext dbContext) : ISiteSettings
|
public sealed class SiteSettings(IAppDbContext dbContext) : ISiteSettings
|
||||||
{
|
{
|
||||||
public Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken) =>
|
public Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken) =>
|
||||||
dbContext.GetBoolSettingAsync(SettingKeys.RegistrationEnabled, false, cancellationToken);
|
dbContext.GetBoolSettingAsync(SettingKeys.RegistrationEnabled, false, cancellationToken);
|
||||||
|
|
||||||
public async Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken)
|
public async Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await UpsertAsync(
|
await UpsertAsync(
|
||||||
SettingKeys.RegistrationEnabled,
|
SettingKeys.RegistrationEnabled,
|
||||||
enabled ? "true" : "false",
|
enabled ? "true" : "false",
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken) =>
|
public Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken) =>
|
||||||
dbContext.GetStringSettingAsync(SettingKeys.PreferredAudioLanguages, "", cancellationToken);
|
dbContext.GetStringSettingAsync(SettingKeys.PreferredAudioLanguages, "", cancellationToken);
|
||||||
|
|
||||||
public Task SetPreferredAudioLanguagesAsync(
|
public Task SetPreferredAudioLanguagesAsync(
|
||||||
string value,
|
string value,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
) => UpsertAsync(SettingKeys.PreferredAudioLanguages, value, cancellationToken);
|
) => UpsertAsync(SettingKeys.PreferredAudioLanguages, value, cancellationToken);
|
||||||
|
|
||||||
private async Task UpsertAsync(string key, string value, CancellationToken cancellationToken)
|
public Task<bool> AreChannelNumbersEnabledAsync(CancellationToken cancellationToken) =>
|
||||||
{
|
dbContext.GetBoolSettingAsync(SettingKeys.ChannelNumbersEnabled, false, cancellationToken);
|
||||||
var existing = await dbContext.AppSettings.FirstOrDefaultAsync(
|
|
||||||
s => s.Key == key,
|
public Task SetChannelNumbersEnabledAsync(bool enabled, CancellationToken cancellationToken) =>
|
||||||
cancellationToken
|
UpsertAsync(SettingKeys.ChannelNumbersEnabled, enabled ? "true" : "false", cancellationToken);
|
||||||
);
|
|
||||||
if (existing is null)
|
private async Task UpsertAsync(string key, string value, CancellationToken cancellationToken)
|
||||||
dbContext.AppSettings.Add(AppSetting.Create(key, value));
|
{
|
||||||
else
|
var existing = await dbContext.AppSettings.FirstOrDefaultAsync(
|
||||||
existing.SetValue(value);
|
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);
|
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]
|
[Fact]
|
||||||
public void AddBumperTemplate_AppendsWithNextPosition()
|
public void AddBumperTemplate_AppendsWithNextPosition()
|
||||||
{
|
{
|
||||||
|
|||||||
+19
-13
@@ -23,7 +23,14 @@
|
|||||||
и растягиваются мышью, день копируется на другие дни. Добавлены жёсткие фильтры кандидатов —
|
и растягиваются мышью, день копируется на другие дни. Добавлены жёсткие фильтры кандидатов —
|
||||||
детское время и потолок повторов.
|
детское время и потолок повторов.
|
||||||
|
|
||||||
**Срез 4** не начинался.
|
**Срез 4 закрыт целиком.** Проверки по правилам показываются прямо в редакторе, пост-проверки
|
||||||
|
дают предупреждения по готовой ленте, у предпросмотра появилась вкладка «Проблемы» с тепловой
|
||||||
|
картой повторов, у каждой записи — трейс «почему это здесь», применение идёт через диф
|
||||||
|
с подсветкой ближайших суток, сетка копируется на другой канал.
|
||||||
|
|
||||||
|
**Смежная зрительская часть закрыта.** Номера каналов с сортировкой публичного списка,
|
||||||
|
переключение по номерам стрелками (глобальный флаг рядом с флагом регистрации), логотип-оверлей,
|
||||||
|
часы, плашка «Далее» и аналоговый фильтр. Всё опционально и по умолчанию выключено.
|
||||||
|
|
||||||
Ничего из этого не проверялось на живой базе: только сборка, юнит-тесты и typecheck.
|
Ничего из этого не проверялось на живой базе: только сборка, юнит-тесты и typecheck.
|
||||||
|
|
||||||
@@ -356,36 +363,36 @@ N новых позиций по фильтру».
|
|||||||
|
|
||||||
## Срез 4 — эксплуатация
|
## Срез 4 — эксплуатация
|
||||||
|
|
||||||
#### [ ] 4.1. Валидация до генерации
|
#### [x] 4.1. Валидация до генерации
|
||||||
`Application` `Api` `Frontend`
|
`Application` `Api` `Frontend`
|
||||||
|
|
||||||
Проверки из 5.1: нехватка контента, пустая группа, дыра в сетке, пересечение слотов, недостижимый
|
Проверки из 5.1: нехватка контента, пустая группа, дыра в сетке, пересечение слотов, недостижимый
|
||||||
кулдаун, возрастной конфликт. Отдаются вместе с шаблоном, показываются в редакторе.
|
кулдаун, возрастной конфликт. Отдаются вместе с шаблоном, показываются в редакторе.
|
||||||
|
|
||||||
#### [ ] 4.2. Пост-проверки
|
#### [x] 4.2. Пост-проверки
|
||||||
`Application`
|
`Application`
|
||||||
|
|
||||||
Потолок врезок в час, доля жанра за сутки, превышение дрейфа, доля эфира у фона (см. 3.8, 5.2).
|
Потолок врезок в час, доля жанра за сутки, превышение дрейфа, доля эфира у фона (см. 3.8, 5.2).
|
||||||
Предупреждения, не ошибки — ничего не переигрывается.
|
Предупреждения, не ошибки — ничего не переигрывается.
|
||||||
|
|
||||||
#### [ ] 4.3. Вкладка «Проблемы» и тепловая карта
|
#### [x] 4.3. Вкладка «Проблемы» и тепловая карта
|
||||||
`Frontend`
|
`Frontend`
|
||||||
|
|
||||||
Сгруппированные предупреждения с переходом к источнику; матрица «элемент × день» с яркостью
|
Сгруппированные предупреждения с переходом к источнику; матрица «элемент × день» с яркостью
|
||||||
по числу показов.
|
по числу показов.
|
||||||
|
|
||||||
#### [ ] 4.4. «Почему это здесь»
|
#### [x] 4.4. «Почему это здесь»
|
||||||
`Api` `Frontend`
|
`Api` `Frontend`
|
||||||
|
|
||||||
Отдача `trace` записи и экран цепочки происхождения (см. 6.5).
|
Отдача `trace` записи и экран цепочки происхождения (см. 6.5).
|
||||||
|
|
||||||
#### [ ] 4.5. Диф перед применением
|
#### [x] 4.5. Диф перед применением
|
||||||
`Application` `Api` `Frontend`
|
`Application` `Api` `Frontend`
|
||||||
|
|
||||||
Сравнение текущего хвоста с пересчитанным, список изменений, отдельная подсветка ближайших суток
|
Сравнение текущего хвоста с пересчитанным, список изменений, отдельная подсветка ближайших суток
|
||||||
(см. 6.6).
|
(см. 6.6).
|
||||||
|
|
||||||
#### [ ] 4.6. Копирование шаблона
|
#### [x] 4.6. Копирование шаблона
|
||||||
`Application` `Api` `Frontend`
|
`Application` `Api` `Frontend`
|
||||||
|
|
||||||
Глубокая копия шаблона, слоёв и слотов на другой канал; группы не копируются, они общие.
|
Глубокая копия шаблона, слоёв и слотов на другой канал; группы не копируются, они общие.
|
||||||
@@ -397,31 +404,30 @@ N новых позиций по фильтру».
|
|||||||
От планировщика не зависит, делается параллельно в любой момент. Всё опционально и по умолчанию
|
От планировщика не зависит, делается параллельно в любой момент. Всё опционально и по умолчанию
|
||||||
выключено (см. 6.8).
|
выключено (см. 6.8).
|
||||||
|
|
||||||
#### [~] V.1. Номер канала
|
#### [x] V.1. Номер канала
|
||||||
`Domain` `Application` `Api` `Frontend`
|
`Domain` `Application` `Api` `Frontend`
|
||||||
|
|
||||||
> Частично: поле, уникальность и правка в настройках канала готовы; сортировки публичного списка нет.
|
|
||||||
|
|
||||||
`Channel.number`, уникальность, сортировка публичного списка по номеру.
|
`Channel.number`, уникальность, сортировка публичного списка по номеру.
|
||||||
|
|
||||||
#### [ ] V.2. Переключение по номерам
|
#### [x] V.2. Переключение по номерам
|
||||||
`Frontend`
|
`Frontend`
|
||||||
|
|
||||||
Вверх-вниз по номерам, короткий чёрный кадр, номер в углу на секунду. Включается глобальным флагом
|
Вверх-вниз по номерам, короткий чёрный кадр, номер в углу на секунду. Включается глобальным флагом
|
||||||
в `AppSetting` рядом с флагом регистрации. Сетка каналов остаётся вторым способом навигации.
|
в `AppSetting` рядом с флагом регистрации. Сетка каналов остаётся вторым способом навигации.
|
||||||
|
|
||||||
#### [ ] V.3. Логотип канала
|
#### [x] V.3. Логотип канала
|
||||||
`Domain` `Api` `Frontend`
|
`Domain` `Api` `Frontend`
|
||||||
|
|
||||||
`logoImageId` (реестр изображений), угол и прозрачность. Оверлей поверх `<video>`, без касания
|
`logoImageId` (реестр изображений), угол и прозрачность. Оверлей поверх `<video>`, без касания
|
||||||
ffmpeg.
|
ffmpeg.
|
||||||
|
|
||||||
#### [ ] V.4. Часы и плашка «Далее»
|
#### [x] V.4. Часы и плашка «Далее»
|
||||||
`Frontend`
|
`Frontend`
|
||||||
|
|
||||||
Часы — опция канала. Плашка в конце программы по данным EPG.
|
Часы — опция канала. Плашка в конце программы по данным EPG.
|
||||||
|
|
||||||
#### [ ] V.5. Аналоговый фильтр
|
#### [x] V.5. Аналоговый фильтр
|
||||||
`Domain` `Frontend`
|
`Domain` `Frontend`
|
||||||
|
|
||||||
`analogFilterStrength` на канале, CSS-фильтр или шейдер, по умолчанию выключен.
|
`analogFilterStrength` на канале, CSS-фильтр или шейдер, по умолчанию выключен.
|
||||||
|
|||||||
@@ -12,25 +12,31 @@ import { Input } from '@/shared/ui/input'
|
|||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import {
|
import {
|
||||||
applyChannelTemplate,
|
applyChannelTemplate,
|
||||||
|
copyTemplateTo,
|
||||||
createLayer,
|
createLayer,
|
||||||
createSlot,
|
createSlot,
|
||||||
deleteLayer,
|
deleteLayer,
|
||||||
getChannel,
|
getChannel,
|
||||||
getChannelTemplate,
|
getChannelTemplate,
|
||||||
getSchedule,
|
getSchedule,
|
||||||
|
listChannels,
|
||||||
toSlotBody,
|
toSlotBody,
|
||||||
updateLayer,
|
updateLayer,
|
||||||
updateSlot,
|
updateSlot,
|
||||||
} from './api'
|
} from './api'
|
||||||
|
import { ApplyDialog } from './components/ApplyDialog'
|
||||||
import { BumperCard } from './components/BumperCard'
|
import { BumperCard } from './components/BumperCard'
|
||||||
import { CollapsibleCard } from './components/CollapsibleCard'
|
import { CollapsibleCard } from './components/CollapsibleCard'
|
||||||
|
import { EntryTraceDialog } from './components/EntryTraceDialog'
|
||||||
import { JunctionsCard } from './components/JunctionsCard'
|
import { JunctionsCard } from './components/JunctionsCard'
|
||||||
import { LayerApplicabilityDialog } from './components/LayerApplicabilityDialog'
|
import { LayerApplicabilityDialog } from './components/LayerApplicabilityDialog'
|
||||||
import { LayerList, ScheduleGrid } from './components/ScheduleGrid'
|
import { LayerList, ScheduleGrid } from './components/ScheduleGrid'
|
||||||
import { SchedulePreview } from './components/SchedulePreview'
|
import { SchedulePreview } from './components/SchedulePreview'
|
||||||
import { RulesCard } from './components/RulesCard'
|
import { RulesCard } from './components/RulesCard'
|
||||||
import { SettingsCard } from './components/SettingsCard'
|
import { SettingsCard } from './components/SettingsCard'
|
||||||
|
import { TemplateIssues } from './components/TemplateIssues'
|
||||||
import { TemplatePreview } from './components/TemplatePreview'
|
import { TemplatePreview } from './components/TemplatePreview'
|
||||||
|
import { ViewerCard } from './components/ViewerCard'
|
||||||
import { SlotInspector, type SlotDraft } from './components/SlotInspector'
|
import { SlotInspector, type SlotDraft } from './components/SlotInspector'
|
||||||
import { toTime } from './lib/format'
|
import { toTime } from './lib/format'
|
||||||
|
|
||||||
@@ -44,6 +50,9 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
|||||||
// День, который копируем, и отмеченные дни-приёмники.
|
// День, который копируем, и отмеченные дни-приёмники.
|
||||||
const [copySource, setCopySource] = useState<number | null>(null)
|
const [copySource, setCopySource] = useState<number | null>(null)
|
||||||
const [copyTargets, setCopyTargets] = useState<number[]>([])
|
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({
|
const { data: channel, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'channels', channelId],
|
queryKey: ['admin', 'channels', channelId],
|
||||||
@@ -68,9 +77,12 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
|||||||
const onError = (error: unknown) =>
|
const onError = (error: unknown) =>
|
||||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||||
|
|
||||||
|
const { data: channels } = useQuery({ queryKey: ['admin', 'channels'], queryFn: listChannels })
|
||||||
|
|
||||||
const applyMutation = useMutation({
|
const applyMutation = useMutation({
|
||||||
mutationFn: () => applyChannelTemplate(channelId),
|
mutationFn: () => applyChannelTemplate(channelId),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
|
setApplyOpen(false)
|
||||||
toast.success(t('admin.channels.applied', { count: result.added }))
|
toast.success(t('admin.channels.applied', { count: result.added }))
|
||||||
// Предупреждения показываем по одному: каждое указывает на конкретный слот.
|
// Предупреждения показываем по одному: каждое указывает на конкретный слот.
|
||||||
for (const warning of result.warnings)
|
for (const warning of result.warnings)
|
||||||
@@ -136,6 +148,21 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
|||||||
onError,
|
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({
|
const moveSlotMutation = useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
slot,
|
slot,
|
||||||
@@ -215,7 +242,7 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
|||||||
{template?.hasPendingChanges && (
|
{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">
|
<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>
|
<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')}
|
<Send className="h-4 w-4" /> {t('admin.channels.apply')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,9 +283,48 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
|||||||
onEditApplicability={setApplicabilityLayer}
|
onEditApplicability={setApplicabilityLayer}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.channels.layersHint')}</p>
|
<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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<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} />
|
<TemplatePreview channelId={channelId} />
|
||||||
|
|
||||||
{/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */}
|
{/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */}
|
||||||
@@ -359,6 +425,8 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
|||||||
|
|
||||||
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
|
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
|
||||||
|
|
||||||
|
<ViewerCard channel={channel} onSaved={invalidate} onError={onError} />
|
||||||
|
|
||||||
{applicabilityLayer && (
|
{applicabilityLayer && (
|
||||||
<LayerApplicabilityDialog
|
<LayerApplicabilityDialog
|
||||||
layer={applicabilityLayer}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,22 @@ import type {
|
|||||||
BumperTrigger,
|
BumperTrigger,
|
||||||
ChannelDto,
|
ChannelDto,
|
||||||
ChannelSummaryDto,
|
ChannelSummaryDto,
|
||||||
|
CopyTemplateResultDto,
|
||||||
CreatedIdResponse,
|
CreatedIdResponse,
|
||||||
|
EntryTraceDto,
|
||||||
JunctionAmountMode,
|
JunctionAmountMode,
|
||||||
JunctionConditions,
|
JunctionConditions,
|
||||||
JunctionElementKind,
|
JunctionElementKind,
|
||||||
JunctionTemplateDto,
|
JunctionTemplateDto,
|
||||||
LayerApplicability,
|
LayerApplicability,
|
||||||
PlanningRules,
|
PlanningRules,
|
||||||
|
ScheduleDiffDto,
|
||||||
ScheduleEntryDto,
|
ScheduleEntryDto,
|
||||||
SchedulePreviewDto,
|
SchedulePreviewDto,
|
||||||
ScheduleTemplateDto,
|
ScheduleTemplateDto,
|
||||||
SlotDto,
|
SlotDto,
|
||||||
|
TemplateIssueDto,
|
||||||
|
ViewerSettings,
|
||||||
} from '@/shared/api/types'
|
} from '@/shared/api/types'
|
||||||
|
|
||||||
export function listChannels() {
|
export function listChannels() {
|
||||||
@@ -43,6 +48,11 @@ export function updateChannelSettings(id: string, body: ChannelSettingsBody) {
|
|||||||
return apiRequest<void>(`/admin/channels/${id}/settings`, { method: 'PUT', body })
|
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 и начало вещательных суток. */
|
/** Номер канала и его время: смещение от UTC и начало вещательных суток. */
|
||||||
export function updateChannelTime(
|
export function updateChannelTime(
|
||||||
id: string,
|
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) {
|
export function previewTemplate(channelId: string, days: number) {
|
||||||
const query = new URLSearchParams({ days: String(days) })
|
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 { useMutation } from '@tanstack/react-query'
|
||||||
import { Plus, Trash2 } from 'lucide-react'
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
SHOW_AUDIENCES,
|
SHOW_AUDIENCES,
|
||||||
type AudienceWindow,
|
type AudienceWindow,
|
||||||
type PlanningRules,
|
type PlanningRules,
|
||||||
type ScheduleTemplateDto,
|
type ScheduleTemplateDto,
|
||||||
type ShowAudience,
|
type ShowAudience,
|
||||||
} from '@/shared/api/types'
|
} from '@/shared/api/types'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
import { updateTemplate } from '../api'
|
import { updateTemplate } from '../api'
|
||||||
import { CollapsibleCard } from './CollapsibleCard'
|
import { CollapsibleCard } from './CollapsibleCard'
|
||||||
|
|
||||||
const EMPTY_WINDOW: AudienceWindow = { from: '06:00:00', to: '23:00:00', maxAudience: 'Teen' }
|
const EMPTY_WINDOW: AudienceWindow = { from: '06:00:00', to: '23:00:00', maxAudience: 'Teen' }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие
|
* Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие
|
||||||
* фильтры — они отсекают недопустимое до жребия, поэтому не требуют пересборки и не ломают
|
* фильтры — они отсекают недопустимое до жребия, поэтому не требуют пересборки и не ломают
|
||||||
* воспроизводимость. Как и правка сетки, эфира сами по себе не двигают.
|
* воспроизводимость. Как и правка сетки, эфира сами по себе не двигают.
|
||||||
*/
|
*/
|
||||||
export function RulesCard({
|
export function RulesCard({
|
||||||
template,
|
template,
|
||||||
onChanged,
|
onChanged,
|
||||||
onError,
|
onError,
|
||||||
}: {
|
}: {
|
||||||
template: ScheduleTemplateDto
|
template: ScheduleTemplateDto
|
||||||
onChanged: () => void
|
onChanged: () => void
|
||||||
onError: (error: unknown) => void
|
onError: (error: unknown) => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [windows, setWindows] = useState<AudienceWindow[]>(
|
const [windows, setWindows] = useState<AudienceWindow[]>(
|
||||||
() => template.rules?.maxAudienceByTime ?? [],
|
() => template.rules?.maxAudienceByTime ?? [],
|
||||||
)
|
)
|
||||||
const [limitOn, setLimitOn] = useState(() => template.rules?.maxRepeatsInWindow != null)
|
const [limitOn, setLimitOn] = useState(() => template.rules?.maxRepeatsInWindow != null)
|
||||||
const [windowDays, setWindowDays] = useState(
|
const [windowDays, setWindowDays] = useState(
|
||||||
() => template.rules?.maxRepeatsInWindow?.windowDays ?? 7,
|
() => template.rules?.maxRepeatsInWindow?.windowDays ?? 7,
|
||||||
)
|
)
|
||||||
const [max, setMax] = useState(() => template.rules?.maxRepeatsInWindow?.max ?? 2)
|
const [max, setMax] = useState(() => template.rules?.maxRepeatsInWindow?.max ?? 2)
|
||||||
|
const [breakCap, setBreakCap] = useState(() => template.rules?.maxBreakMinutesPerHour ?? 0)
|
||||||
useEffect(() => {
|
const [genreCap, setGenreCap] = useState(() => template.rules?.maxGenreSharePercent ?? 0)
|
||||||
setWindows(template.rules?.maxAudienceByTime ?? [])
|
const [fallbackCap, setFallbackCap] = useState(() => template.rules?.maxFallbackSharePercent ?? 0)
|
||||||
setLimitOn(template.rules?.maxRepeatsInWindow != null)
|
|
||||||
setWindowDays(template.rules?.maxRepeatsInWindow?.windowDays ?? 7)
|
useEffect(() => {
|
||||||
setMax(template.rules?.maxRepeatsInWindow?.max ?? 2)
|
setWindows(template.rules?.maxAudienceByTime ?? [])
|
||||||
}, [template])
|
setLimitOn(template.rules?.maxRepeatsInWindow != null)
|
||||||
|
setWindowDays(template.rules?.maxRepeatsInWindow?.windowDays ?? 7)
|
||||||
const save = useMutation({
|
setMax(template.rules?.maxRepeatsInWindow?.max ?? 2)
|
||||||
mutationFn: () => {
|
setBreakCap(template.rules?.maxBreakMinutesPerHour ?? 0)
|
||||||
const rules: PlanningRules = {
|
setGenreCap(template.rules?.maxGenreSharePercent ?? 0)
|
||||||
maxAudienceByTime: windows.length > 0 ? windows : null,
|
setFallbackCap(template.rules?.maxFallbackSharePercent ?? 0)
|
||||||
maxRepeatsInWindow: limitOn ? { windowDays, max } : null,
|
}, [template])
|
||||||
}
|
|
||||||
return updateTemplate(template.id, {
|
const save = useMutation({
|
||||||
name: template.name,
|
mutationFn: () => {
|
||||||
fallbackGroupId: template.fallbackGroupId,
|
const rules: PlanningRules = {
|
||||||
defaultJunctionId: template.defaultJunctionId,
|
maxAudienceByTime: windows.length > 0 ? windows : null,
|
||||||
rules,
|
maxRepeatsInWindow: limitOn ? { windowDays, max } : null,
|
||||||
})
|
// Ноль означает «не проверять»: отдельного выключателя на каждый порог не нужно.
|
||||||
},
|
maxBreakMinutesPerHour: breakCap > 0 ? breakCap : null,
|
||||||
onSuccess: onChanged,
|
maxGenreSharePercent: genreCap > 0 ? genreCap : null,
|
||||||
onError,
|
maxFallbackSharePercent: fallbackCap > 0 ? fallbackCap : null,
|
||||||
})
|
}
|
||||||
|
return updateTemplate(template.id, {
|
||||||
const patchWindow = (index: number, part: Partial<AudienceWindow>) =>
|
name: template.name,
|
||||||
setWindows((current) => current.map((w, i) => (i === index ? { ...w, ...part } : w)))
|
fallbackGroupId: template.fallbackGroupId,
|
||||||
|
defaultJunctionId: template.defaultJunctionId,
|
||||||
return (
|
rules,
|
||||||
<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>
|
onSuccess: onChanged,
|
||||||
|
onError,
|
||||||
<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">
|
const patchWindow = (index: number, part: Partial<AudienceWindow>) =>
|
||||||
{t('admin.channels.audienceWindows')}
|
setWindows((current) => current.map((w, i) => (i === index ? { ...w, ...part } : w)))
|
||||||
</h3>
|
|
||||||
<Button
|
return (
|
||||||
size="sm"
|
<CollapsibleCard title={t('admin.channels.rules')}>
|
||||||
variant="ghost"
|
<div className="flex flex-col gap-4 text-sm">
|
||||||
onClick={() => setWindows((c) => [...c, EMPTY_WINDOW])}
|
<p className="text-xs text-muted-foreground">{t('admin.channels.rulesHint')}</p>
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
<div className="flex flex-col gap-2">
|
||||||
</Button>
|
<div className="flex items-center justify-between gap-2">
|
||||||
</div>
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t('admin.channels.audienceWindows')}
|
||||||
{windows.length === 0 ? (
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.channels.noAudienceWindows')}</p>
|
<Button
|
||||||
) : (
|
size="sm"
|
||||||
<ul className="flex flex-col gap-2">
|
variant="ghost"
|
||||||
{windows.map((window, index) => (
|
onClick={() => setWindows((c) => [...c, EMPTY_WINDOW])}
|
||||||
<li key={index} className="flex flex-wrap items-end gap-2">
|
>
|
||||||
<div className="flex flex-col gap-1.5">
|
<Plus className="h-4 w-4" />
|
||||||
<Label>{t('admin.channels.from')}</Label>
|
</Button>
|
||||||
<Input
|
</div>
|
||||||
type="time"
|
|
||||||
className="w-28"
|
{windows.length === 0 ? (
|
||||||
value={window.from.slice(0, 5)}
|
<p className="text-xs text-muted-foreground">{t('admin.channels.noAudienceWindows')}</p>
|
||||||
onChange={(e) => patchWindow(index, { from: `${e.target.value}:00` })}
|
) : (
|
||||||
/>
|
<ul className="flex flex-col gap-2">
|
||||||
</div>
|
{windows.map((window, index) => (
|
||||||
<div className="flex flex-col gap-1.5">
|
<li key={index} className="flex flex-wrap items-end gap-2">
|
||||||
<Label>{t('admin.channels.to')}</Label>
|
<div className="flex flex-col gap-1.5">
|
||||||
<Input
|
<Label>{t('admin.channels.from')}</Label>
|
||||||
type="time"
|
<Input
|
||||||
className="w-28"
|
type="time"
|
||||||
value={window.to.slice(0, 5)}
|
className="w-28"
|
||||||
onChange={(e) => patchWindow(index, { to: `${e.target.value}:00` })}
|
value={window.from.slice(0, 5)}
|
||||||
/>
|
onChange={(e) => patchWindow(index, { from: `${e.target.value}:00` })}
|
||||||
</div>
|
/>
|
||||||
<div className="flex flex-col gap-1.5">
|
</div>
|
||||||
<Label>{t('admin.channels.maxAudience')}</Label>
|
<div className="flex flex-col gap-1.5">
|
||||||
<select
|
<Label>{t('admin.channels.to')}</Label>
|
||||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
<Input
|
||||||
value={window.maxAudience}
|
type="time"
|
||||||
onChange={(e) =>
|
className="w-28"
|
||||||
patchWindow(index, { maxAudience: e.target.value as ShowAudience })
|
value={window.to.slice(0, 5)}
|
||||||
}
|
onChange={(e) => patchWindow(index, { to: `${e.target.value}:00` })}
|
||||||
>
|
/>
|
||||||
{SHOW_AUDIENCES.map((value) => (
|
</div>
|
||||||
<option key={value} value={value}>
|
<div className="flex flex-col gap-1.5">
|
||||||
{t(`admin.shows.audiences.${value}`)}
|
<Label>{t('admin.channels.maxAudience')}</Label>
|
||||||
</option>
|
<select
|
||||||
))}
|
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||||
</select>
|
value={window.maxAudience}
|
||||||
</div>
|
onChange={(e) =>
|
||||||
<Button
|
patchWindow(index, { maxAudience: e.target.value as ShowAudience })
|
||||||
size="sm"
|
}
|
||||||
variant="ghost"
|
>
|
||||||
onClick={() => setWindows((c) => c.filter((_, i) => i !== index))}
|
{SHOW_AUDIENCES.map((value) => (
|
||||||
>
|
<option key={value} value={value}>
|
||||||
<Trash2 className="h-4 w-4" />
|
{t(`admin.shows.audiences.${value}`)}
|
||||||
</Button>
|
</option>
|
||||||
</li>
|
))}
|
||||||
))}
|
</select>
|
||||||
</ul>
|
</div>
|
||||||
)}
|
<Button
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.channels.audienceWindowsHint')}</p>
|
size="sm"
|
||||||
</div>
|
variant="ghost"
|
||||||
|
onClick={() => setWindows((c) => c.filter((_, i) => i !== index))}
|
||||||
<div className="flex flex-col gap-2 border-t border-border pt-4">
|
>
|
||||||
<label className="flex items-center gap-2">
|
<Trash2 className="h-4 w-4" />
|
||||||
<input
|
</Button>
|
||||||
type="checkbox"
|
</li>
|
||||||
checked={limitOn}
|
))}
|
||||||
onChange={(e) => setLimitOn(e.target.checked)}
|
</ul>
|
||||||
/>
|
)}
|
||||||
{t('admin.channels.repeatLimit')}
|
<p className="text-xs text-muted-foreground">{t('admin.channels.audienceWindowsHint')}</p>
|
||||||
</label>
|
</div>
|
||||||
{limitOn && (
|
|
||||||
<div className="flex flex-wrap items-end gap-2">
|
<div className="flex flex-col gap-2 border-t border-border pt-4">
|
||||||
<div className="flex flex-col gap-1.5">
|
<label className="flex items-center gap-2">
|
||||||
<Label>{t('admin.channels.repeatWindowDays')}</Label>
|
<input
|
||||||
<Input
|
type="checkbox"
|
||||||
type="number"
|
checked={limitOn}
|
||||||
min={1}
|
onChange={(e) => setLimitOn(e.target.checked)}
|
||||||
max={365}
|
/>
|
||||||
className="w-28"
|
{t('admin.channels.repeatLimit')}
|
||||||
value={windowDays}
|
</label>
|
||||||
onChange={(e) => setWindowDays(Number(e.target.value))}
|
{limitOn && (
|
||||||
/>
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
</div>
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex flex-col gap-1.5">
|
<Label>{t('admin.channels.repeatWindowDays')}</Label>
|
||||||
<Label>{t('admin.channels.repeatMax')}</Label>
|
<Input
|
||||||
<Input
|
type="number"
|
||||||
type="number"
|
min={1}
|
||||||
min={1}
|
max={365}
|
||||||
className="w-28"
|
className="w-28"
|
||||||
value={max}
|
value={windowDays}
|
||||||
onChange={(e) => setMax(Number(e.target.value))}
|
onChange={(e) => setWindowDays(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex flex-col gap-1.5">
|
||||||
)}
|
<Label>{t('admin.channels.repeatMax')}</Label>
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.channels.repeatLimitHint')}</p>
|
<Input
|
||||||
</div>
|
type="number"
|
||||||
|
min={1}
|
||||||
<div className="flex justify-end">
|
className="w-28"
|
||||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
value={max}
|
||||||
{t('common.save')}
|
onChange={(e) => setMax(Number(e.target.value))}
|
||||||
</Button>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleCard>
|
)}
|
||||||
)
|
<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 { HelpCircle } from 'lucide-react'
|
||||||
import type { ScheduleEntryDto } from '@/shared/api/types'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Badge } from '@/shared/ui/badge'
|
import type { ScheduleEntryDto } from '@/shared/api/types'
|
||||||
import { formatTime } from '../lib/format'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
|
import { formatTime } from '../lib/format'
|
||||||
export function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) {
|
|
||||||
const { t } = useTranslation()
|
export function SchedulePreview({
|
||||||
if (entries.length === 0)
|
entries,
|
||||||
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
onShowTrace,
|
||||||
|
}: {
|
||||||
return (
|
entries: ScheduleEntryDto[]
|
||||||
<ul className="flex flex-col divide-y divide-border text-sm">
|
onShowTrace: (entryId: string) => void
|
||||||
{entries.slice(0, 40).map((e) => (
|
}) {
|
||||||
<li key={e.id} className="flex items-center gap-3 py-1.5">
|
const { t } = useTranslation()
|
||||||
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
if (entries.length === 0)
|
||||||
{formatTime(e.startsAtUtc)}
|
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||||
</span>
|
|
||||||
{e.kind === 'Ad' ? (
|
return (
|
||||||
<Badge variant="muted">{t('air.ad')}</Badge>
|
<ul className="flex flex-col divide-y divide-border text-sm">
|
||||||
) : e.kind === 'Bumper' ? (
|
{entries.slice(0, 40).map((e) => (
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<li key={e.id} className="group flex items-center gap-3 py-1.5">
|
||||||
<Badge variant="muted" className="shrink-0">
|
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
||||||
{t('air.bumper')}
|
{formatTime(e.startsAtUtc)}
|
||||||
</Badge>
|
</span>
|
||||||
{(e.bumperName || e.bumperText) && (
|
{e.kind === 'Ad' ? (
|
||||||
<span className="min-w-0 truncate text-muted-foreground">
|
<Badge variant="muted">{t('air.ad')}</Badge>
|
||||||
{e.bumperName}
|
) : e.kind === 'Bumper' ? (
|
||||||
{e.bumperName && e.bumperText ? ' · ' : ''}
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
{e.bumperText}
|
<Badge variant="muted" className="shrink-0">
|
||||||
</span>
|
{t('air.bumper')}
|
||||||
)}
|
</Badge>
|
||||||
</span>
|
{(e.bumperName || e.bumperText) && (
|
||||||
) : (
|
<span className="min-w-0 truncate text-muted-foreground">
|
||||||
<span>
|
{e.bumperName}
|
||||||
{e.showName ?? '—'}
|
{e.bumperName && e.bumperText ? ' · ' : ''}
|
||||||
{e.seasonEpisode ? (
|
{e.bumperText}
|
||||||
<span className="text-muted-foreground"> · {e.seasonEpisode}</span>
|
</span>
|
||||||
) : (
|
)}
|
||||||
e.episodeIndex != null && (
|
</span>
|
||||||
<span className="text-muted-foreground">
|
) : (
|
||||||
{' '}
|
<span>
|
||||||
· {t('air.episode')} {e.episodeIndex + 1}
|
{e.showName ?? '—'}
|
||||||
</span>
|
{e.seasonEpisode ? (
|
||||||
)
|
<span className="text-muted-foreground"> · {e.seasonEpisode}</span>
|
||||||
)}
|
) : (
|
||||||
</span>
|
e.episodeIndex != null && (
|
||||||
)}
|
<span className="text-muted-foreground">
|
||||||
</li>
|
{' '}
|
||||||
))}
|
· {t('air.episode')} {e.episodeIndex + 1}
|
||||||
</ul>
|
</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 { useQuery } from '@tanstack/react-query'
|
||||||
import { Eye } from 'lucide-react'
|
import { Eye } from 'lucide-react'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { PlannedItemKind, PreviewItemDto, SchedulePreviewDto } from '@/shared/api/types'
|
import type { PlannedItemKind, PreviewItemDto, SchedulePreviewDto } from '@/shared/api/types'
|
||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { cn } from '@/shared/lib/cn'
|
import { cn } from '@/shared/lib/cn'
|
||||||
import { previewTemplate } from '../api'
|
import { previewTemplate } from '../api'
|
||||||
import { channelTime, formatChannelTime } from '../lib/format'
|
import { channelTime, formatChannelTime } from '../lib/format'
|
||||||
|
import { toIsoDate } from '../lib/applicability'
|
||||||
const KIND_COLORS: Record<PlannedItemKind, string> = {
|
|
||||||
Program: 'bg-primary/70',
|
const KIND_COLORS: Record<PlannedItemKind, string> = {
|
||||||
Fallback: 'bg-muted-foreground/40',
|
Program: 'bg-primary/70',
|
||||||
SignOff: 'bg-slate-500/60',
|
Fallback: 'bg-muted-foreground/40',
|
||||||
Ad: 'bg-amber-500/70',
|
SignOff: 'bg-slate-500/60',
|
||||||
Promo: 'bg-sky-500/70',
|
Ad: 'bg-amber-500/70',
|
||||||
Bumper: 'bg-violet-500/70',
|
Promo: 'bg-sky-500/70',
|
||||||
}
|
Bumper: 'bg-violet-500/70',
|
||||||
|
}
|
||||||
/** Что видит зритель как программу — врезки в программу передач не попадают. */
|
|
||||||
const PROGRAMME_KINDS: PlannedItemKind[] = ['Program', 'Fallback', 'SignOff']
|
/** Что видит зритель как программу — врезки в программу передач не попадают. */
|
||||||
|
const PROGRAMME_KINDS: PlannedItemKind[] = ['Program', 'Fallback', 'SignOff']
|
||||||
/**
|
|
||||||
* Предпросмотр (см. 6.4): прогон генератора по текущим правилам без записи и без продвижения
|
/**
|
||||||
* курсоров. Заставки приходят резервом известной длины — реальный рендер только при применении.
|
* Предпросмотр (см. 6.4): прогон генератора по текущим правилам без записи и без продвижения
|
||||||
*/
|
* курсоров. Заставки приходят резервом известной длины — реальный рендер только при применении.
|
||||||
export function TemplatePreview({ channelId }: { channelId: string }) {
|
*/
|
||||||
const { t } = useTranslation()
|
export function TemplatePreview({ channelId }: { channelId: string }) {
|
||||||
const [open, setOpen] = useState(false)
|
const { t } = useTranslation()
|
||||||
const [days, setDays] = useState(1)
|
const [open, setOpen] = useState(false)
|
||||||
const [tab, setTab] = useState<'programme' | 'tape'>('programme')
|
const [days, setDays] = useState(1)
|
||||||
|
const [tab, setTab] = useState<'programme' | 'tape' | 'problems'>('programme')
|
||||||
const { data, isFetching } = useQuery({
|
|
||||||
queryKey: ['admin', 'channels', channelId, 'preview', days],
|
const { data, isFetching } = useQuery({
|
||||||
queryFn: () => previewTemplate(channelId, days),
|
queryKey: ['admin', 'channels', channelId, 'preview', days],
|
||||||
enabled: open,
|
queryFn: () => previewTemplate(channelId, days),
|
||||||
// Черновик правил может меняться между открытиями — кэшировать прогон смысла нет.
|
enabled: open,
|
||||||
staleTime: 0,
|
// Черновик правил может меняться между открытиями — кэшировать прогон смысла нет.
|
||||||
gcTime: 0,
|
staleTime: 0,
|
||||||
})
|
gcTime: 0,
|
||||||
|
})
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3">
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-col gap-3">
|
||||||
<Button size="sm" variant="outline" onClick={() => setOpen((v) => !v)}>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Eye className="h-4 w-4" />
|
<Button size="sm" variant="outline" onClick={() => setOpen((v) => !v)}>
|
||||||
{open ? t('admin.channels.previewHide') : t('admin.channels.preview')}
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
{open ? t('admin.channels.previewHide') : t('admin.channels.preview')}
|
||||||
{open && (
|
</Button>
|
||||||
<>
|
{open && (
|
||||||
<select
|
<>
|
||||||
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
|
<select
|
||||||
value={days}
|
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
|
||||||
onChange={(e) => setDays(Number(e.target.value))}
|
value={days}
|
||||||
>
|
onChange={(e) => setDays(Number(e.target.value))}
|
||||||
{[1, 3, 7].map((value) => (
|
>
|
||||||
<option key={value} value={value}>
|
{[1, 3, 7].map((value) => (
|
||||||
{t('admin.channels.previewDays', { count: value })}
|
<option key={value} value={value}>
|
||||||
</option>
|
{t('admin.channels.previewDays', { count: value })}
|
||||||
))}
|
</option>
|
||||||
</select>
|
))}
|
||||||
<span className="text-xs text-muted-foreground">
|
</select>
|
||||||
{isFetching ? t('common.loading') : t('admin.channels.previewHint')}
|
<span className="text-xs text-muted-foreground">
|
||||||
</span>
|
{isFetching ? t('common.loading') : t('admin.channels.previewHint')}
|
||||||
</>
|
</span>
|
||||||
)}
|
</>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
{open && data && (
|
|
||||||
<div className="flex flex-col gap-3">
|
{open && data && (
|
||||||
<div className="flex gap-2 border-b border-border text-xs uppercase tracking-wide">
|
<div className="flex flex-col gap-3">
|
||||||
{(['programme', 'tape'] as const).map((value) => (
|
<div className="flex gap-2 border-b border-border text-xs uppercase tracking-wide">
|
||||||
<button
|
{(['programme', 'tape', 'problems'] as const).map((value) => (
|
||||||
key={value}
|
<button
|
||||||
type="button"
|
key={value}
|
||||||
onClick={() => setTab(value)}
|
type="button"
|
||||||
className={cn(
|
onClick={() => setTab(value)}
|
||||||
'pb-1 text-muted-foreground hover:text-foreground',
|
className={cn(
|
||||||
tab === value && 'border-b-2 border-primary text-primary',
|
'pb-1 text-muted-foreground hover:text-foreground',
|
||||||
)}
|
tab === value && 'border-b-2 border-primary text-primary',
|
||||||
>
|
)}
|
||||||
{t(`admin.channels.previewTabs.${value}`)}
|
>
|
||||||
</button>
|
{t(`admin.channels.previewTabs.${value}`)}
|
||||||
))}
|
{value === 'problems' && data.warnings.length > 0 && ` · ${data.warnings.length}`}
|
||||||
</div>
|
</button>
|
||||||
|
))}
|
||||||
{tab === 'programme' ? <Programme preview={data} /> : <Tape preview={data} />}
|
</div>
|
||||||
|
|
||||||
{data.warnings.length > 0 && (
|
{tab === 'programme' && <Programme preview={data} />}
|
||||||
<ul className="flex flex-col gap-1 text-xs text-amber-500">
|
{tab === 'tape' && <Tape preview={data} />}
|
||||||
{data.warnings.map((warning, index) => (
|
{tab === 'problems' && <Problems preview={data} />}
|
||||||
<li key={`${warning.kind}-${index}`}>
|
</div>
|
||||||
{t(`admin.channels.warnings.${warning.kind}`)}: {warning.details}
|
)}
|
||||||
</li>
|
</div>
|
||||||
))}
|
)
|
||||||
</ul>
|
}
|
||||||
)}
|
|
||||||
</div>
|
function Programme({ preview }: { preview: SchedulePreviewDto }) {
|
||||||
)}
|
const { t } = useTranslation()
|
||||||
</div>
|
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>
|
||||||
function Programme({ preview }: { preview: SchedulePreviewDto }) {
|
|
||||||
const { t } = useTranslation()
|
return (
|
||||||
const items = preview.items.filter((i) => PROGRAMME_KINDS.includes(i.kind))
|
<ul className="flex flex-col divide-y divide-border text-sm">
|
||||||
|
{items.map((item, index) => (
|
||||||
if (items.length === 0)
|
<li key={`${item.startsAtUtc}-${index}`} className="flex items-center gap-3 py-1.5">
|
||||||
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||||
|
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
|
||||||
return (
|
</span>
|
||||||
<ul className="flex flex-col divide-y divide-border text-sm">
|
<span className="min-w-0 flex-1 truncate">
|
||||||
{items.map((item, index) => (
|
{item.title ?? t(`admin.channels.previewKinds.${item.kind}`)}
|
||||||
<li key={`${item.startsAtUtc}-${index}`} className="flex items-center gap-3 py-1.5">
|
</span>
|
||||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
{item.slotTitle && (
|
||||||
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
|
<span className="shrink-0 text-xs text-muted-foreground">{item.slotTitle}</span>
|
||||||
</span>
|
)}
|
||||||
<span className="min-w-0 flex-1 truncate">
|
</li>
|
||||||
{item.title ?? t(`admin.channels.previewKinds.${item.kind}`)}
|
))}
|
||||||
</span>
|
</ul>
|
||||||
{item.slotTitle && (
|
)
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">{item.slotTitle}</span>
|
}
|
||||||
)}
|
|
||||||
</li>
|
/** Час вещания → сколько в нём минут врезок. По ним же строится гистограмма нагрузки. */
|
||||||
))}
|
function loadByHour(preview: SchedulePreviewDto): { hour: Date; minutes: number }[] {
|
||||||
</ul>
|
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(
|
||||||
function loadByHour(preview: SchedulePreviewDto): { hour: Date; minutes: number }[] {
|
start.getUTCFullYear(),
|
||||||
const buckets = new Map<number, number>()
|
start.getUTCMonth(),
|
||||||
for (const item of preview.items) {
|
start.getUTCDate(),
|
||||||
if (item.kind !== 'Ad' && item.kind !== 'Promo' && item.kind !== 'Bumper') continue
|
start.getUTCHours(),
|
||||||
const start = channelTime(item.startsAtUtc, preview.utcOffsetMinutes)
|
)
|
||||||
const hour = Date.UTC(
|
const minutes = (new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
|
||||||
start.getUTCFullYear(),
|
buckets.set(hour, (buckets.get(hour) ?? 0) + minutes)
|
||||||
start.getUTCMonth(),
|
}
|
||||||
start.getUTCDate(),
|
return [...buckets.entries()]
|
||||||
start.getUTCHours(),
|
.sort((a, b) => a[0] - b[0])
|
||||||
)
|
.map(([hour, minutes]) => ({ hour: new Date(hour), minutes }))
|
||||||
const minutes = (new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
|
}
|
||||||
buckets.set(hour, (buckets.get(hour) ?? 0) + minutes)
|
|
||||||
}
|
function Tape({ preview }: { preview: SchedulePreviewDto }) {
|
||||||
return [...buckets.entries()]
|
const { t } = useTranslation()
|
||||||
.sort((a, b) => a[0] - b[0])
|
const load = useMemo(() => loadByHour(preview), [preview])
|
||||||
.map(([hour, minutes]) => ({ hour: new Date(hour), minutes }))
|
const peak = Math.max(1, ...load.map((l) => l.minutes))
|
||||||
}
|
|
||||||
|
if (preview.items.length === 0)
|
||||||
function Tape({ preview }: { preview: SchedulePreviewDto }) {
|
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||||
const { t } = useTranslation()
|
|
||||||
const load = useMemo(() => loadByHour(preview), [preview])
|
return (
|
||||||
const peak = Math.max(1, ...load.map((l) => l.minutes))
|
<div className="flex flex-col gap-4">
|
||||||
|
{load.length > 0 && (
|
||||||
if (preview.items.length === 0)
|
<div className="flex flex-col gap-1">
|
||||||
return <p className="text-sm text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.channels.previewLoad', { peak: Math.round(peak) })}
|
||||||
return (
|
</span>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex h-16 items-end gap-px">
|
||||||
{load.length > 0 && (
|
{load.map((bucket) => (
|
||||||
<div className="flex flex-col gap-1">
|
<div
|
||||||
<span className="text-xs text-muted-foreground">
|
key={bucket.hour.toISOString()}
|
||||||
{t('admin.channels.previewLoad', { peak: Math.round(peak) })}
|
className="flex-1 bg-amber-500/70"
|
||||||
</span>
|
style={{ height: `${(bucket.minutes / peak) * 100}%` }}
|
||||||
<div className="flex h-16 items-end gap-px">
|
title={`${String(bucket.hour.getUTCHours()).padStart(2, '0')}:00 · ${Math.round(bucket.minutes)} ${t('admin.groups.minutesShort')}`}
|
||||||
{load.map((bucket) => (
|
/>
|
||||||
<div
|
))}
|
||||||
key={bucket.hour.toISOString()}
|
</div>
|
||||||
className="flex-1 bg-amber-500/70"
|
</div>
|
||||||
style={{ height: `${(bucket.minutes / peak) * 100}%` }}
|
)}
|
||||||
title={`${String(bucket.hour.getUTCHours()).padStart(2, '0')}:00 · ${Math.round(bucket.minutes)} ${t('admin.groups.minutesShort')}`}
|
|
||||||
/>
|
<ul className="flex flex-col gap-0.5 text-xs">
|
||||||
))}
|
{preview.items.map((item, index) => (
|
||||||
</div>
|
<TapeRow key={`${item.startsAtUtc}-${index}`} item={item} preview={preview} />
|
||||||
</div>
|
))}
|
||||||
)}
|
</ul>
|
||||||
|
</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} />
|
|
||||||
))}
|
function TapeRow({ item, preview }: { item: PreviewItemDto; preview: SchedulePreviewDto }) {
|
||||||
</ul>
|
const { t } = useTranslation()
|
||||||
</div>
|
const minutes =
|
||||||
)
|
(new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
|
||||||
}
|
|
||||||
|
return (
|
||||||
function TapeRow({ item, preview }: { item: PreviewItemDto; preview: SchedulePreviewDto }) {
|
<li className="flex items-center gap-2">
|
||||||
const { t } = useTranslation()
|
<span className="w-10 shrink-0 tabular-nums text-muted-foreground">
|
||||||
const minutes =
|
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
|
||||||
(new Date(item.endsAtUtc).getTime() - new Date(item.startsAtUtc).getTime()) / 60_000
|
</span>
|
||||||
|
<span className={cn('h-2 shrink-0 rounded-sm', KIND_COLORS[item.kind])} style={{ width: `${Math.max(4, minutes * 2)}px` }} />
|
||||||
return (
|
<Badge variant="muted" className="shrink-0">
|
||||||
<li className="flex items-center gap-2">
|
{t(`admin.channels.previewKinds.${item.kind}`)}
|
||||||
<span className="w-10 shrink-0 tabular-nums text-muted-foreground">
|
</Badge>
|
||||||
{formatChannelTime(item.startsAtUtc, preview.utcOffsetMinutes)}
|
<span className="min-w-0 flex-1 truncate text-muted-foreground">{item.title ?? ''}</span>
|
||||||
</span>
|
</li>
|
||||||
<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>
|
function Problems({ preview }: { preview: SchedulePreviewDto }) {
|
||||||
</li>
|
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { HttpError } from '@/shared/api/client'
|
import { HttpError } from '@/shared/api/client'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { getSiteSettings, updateSiteSettings } from './api'
|
import { getSiteSettings, updateSiteSettings } from './api'
|
||||||
|
|
||||||
export function SettingsPanel() {
|
export function SettingsPanel() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [registrationEnabled, setRegistrationEnabled] = useState(false)
|
const [registrationEnabled, setRegistrationEnabled] = useState(false)
|
||||||
const [preferredAudioLanguages, setPreferredAudioLanguages] = useState('')
|
const [preferredAudioLanguages, setPreferredAudioLanguages] = useState('')
|
||||||
|
const [channelNumbersEnabled, setChannelNumbersEnabled] = useState(false)
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'settings'],
|
const { data, isLoading } = useQuery({
|
||||||
queryFn: getSiteSettings,
|
queryKey: ['admin', 'settings'],
|
||||||
})
|
queryFn: getSiteSettings,
|
||||||
|
})
|
||||||
useEffect(() => {
|
|
||||||
if (data) {
|
useEffect(() => {
|
||||||
setRegistrationEnabled(data.registrationEnabled)
|
if (data) {
|
||||||
setPreferredAudioLanguages(data.preferredAudioLanguages)
|
setRegistrationEnabled(data.registrationEnabled)
|
||||||
}
|
setPreferredAudioLanguages(data.preferredAudioLanguages)
|
||||||
}, [data])
|
setChannelNumbersEnabled(data.channelNumbersEnabled)
|
||||||
|
}
|
||||||
const save = useMutation({
|
}, [data])
|
||||||
mutationFn: () => updateSiteSettings({ registrationEnabled, preferredAudioLanguages }),
|
|
||||||
onSuccess: () => {
|
const save = useMutation({
|
||||||
toast.success(t('settings.saved'))
|
mutationFn: () =>
|
||||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
|
updateSiteSettings({
|
||||||
},
|
registrationEnabled,
|
||||||
onError: (error: unknown) =>
|
preferredAudioLanguages,
|
||||||
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
|
channelNumbersEnabled,
|
||||||
})
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
return (
|
toast.success(t('settings.saved'))
|
||||||
<div className="flex flex-col gap-4">
|
void queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
|
||||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.settings.title')}</h2>
|
},
|
||||||
|
onError: (error: unknown) =>
|
||||||
<Card>
|
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
|
||||||
<CardHeader>
|
})
|
||||||
<CardTitle>{t('admin.settings.registration')}</CardTitle>
|
|
||||||
<CardDescription>{t('admin.settings.registrationHint')}</CardDescription>
|
return (
|
||||||
</CardHeader>
|
<div className="flex flex-col gap-4">
|
||||||
<CardContent className="flex flex-col gap-4">
|
<h2 className="crt-glow text-xl font-semibold">{t('admin.settings.title')}</h2>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
|
||||||
<input
|
<Card>
|
||||||
type="checkbox"
|
<CardHeader>
|
||||||
checked={registrationEnabled}
|
<CardTitle>{t('admin.settings.registration')}</CardTitle>
|
||||||
disabled={isLoading}
|
<CardDescription>{t('admin.settings.registrationHint')}</CardDescription>
|
||||||
onChange={(e) => setRegistrationEnabled(e.target.checked)}
|
</CardHeader>
|
||||||
/>
|
<CardContent className="flex flex-col gap-4">
|
||||||
{t('admin.settings.registrationLabel')}
|
<label className="flex items-center gap-2 text-sm">
|
||||||
</label>
|
<input
|
||||||
|
type="checkbox"
|
||||||
<div className="flex flex-col gap-1.5">
|
checked={registrationEnabled}
|
||||||
<label className="text-sm font-medium" htmlFor="preferred-audio">
|
disabled={isLoading}
|
||||||
{t('admin.settings.preferredAudio')}
|
onChange={(e) => setRegistrationEnabled(e.target.checked)}
|
||||||
</label>
|
/>
|
||||||
<Input
|
{t('admin.settings.registrationLabel')}
|
||||||
id="preferred-audio"
|
</label>
|
||||||
className="max-w-xs"
|
|
||||||
placeholder="rus, eng"
|
<div className="flex flex-col gap-1.5">
|
||||||
value={preferredAudioLanguages}
|
<label className="text-sm font-medium" htmlFor="preferred-audio">
|
||||||
disabled={isLoading}
|
{t('admin.settings.preferredAudio')}
|
||||||
onChange={(e) => setPreferredAudioLanguages(e.target.value)}
|
</label>
|
||||||
/>
|
<Input
|
||||||
<p className="text-xs text-muted-foreground">
|
id="preferred-audio"
|
||||||
{t('admin.settings.preferredAudioHint')}
|
className="max-w-xs"
|
||||||
</p>
|
placeholder="rus, eng"
|
||||||
</div>
|
value={preferredAudioLanguages}
|
||||||
|
disabled={isLoading}
|
||||||
<div>
|
onChange={(e) => setPreferredAudioLanguages(e.target.value)}
|
||||||
<Button size="sm" disabled={isLoading || save.isPending} onClick={() => save.mutate()}>
|
/>
|
||||||
{t('common.save')}
|
<p className="text-xs text-muted-foreground">
|
||||||
</Button>
|
{t('admin.settings.preferredAudioHint')}
|
||||||
</div>
|
</p>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
</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 { useQuery } from '@tanstack/react-query'
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Radio, RotateCw } from 'lucide-react'
|
import { Radio, RotateCw } from 'lucide-react'
|
||||||
import type { PublicEpgEntryDto } from '@/shared/api/types'
|
import type { PublicEpgEntryDto } from '@/shared/api/types'
|
||||||
import { cn } from '@/shared/lib/cn'
|
import { cn } from '@/shared/lib/cn'
|
||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { ChannelPlayer } from './ChannelPlayer'
|
import { ChannelPlayer } from './ChannelPlayer'
|
||||||
import { getEpg, imageUrl, listChannels, watchChannel } from './api'
|
import { getEpg, getViewerFeatures, imageUrl, listChannels, watchChannel } from './api'
|
||||||
|
|
||||||
function formatTime(iso: string) {
|
function formatTime(iso: string) {
|
||||||
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AirPage() {
|
export function AirPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [selected, setSelected] = useState<string | null>(null)
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
const [watchReady, setWatchReady] = useState(false)
|
const [watchReady, setWatchReady] = useState(false)
|
||||||
const [playerError, setPlayerError] = useState(false)
|
const [playerError, setPlayerError] = useState(false)
|
||||||
const [attempt, setAttempt] = useState(0)
|
const [attempt, setAttempt] = useState(0)
|
||||||
|
const [flash, setFlash] = useState(false)
|
||||||
const handleUnavailable = useCallback(() => setPlayerError(true), [])
|
|
||||||
const retry = () => {
|
const handleUnavailable = useCallback(() => setPlayerError(true), [])
|
||||||
setPlayerError(false)
|
const retry = () => {
|
||||||
setAttempt((a) => a + 1)
|
setPlayerError(false)
|
||||||
}
|
setAttempt((a) => a + 1)
|
||||||
|
}
|
||||||
const { data: channels, isLoading } = useQuery({
|
|
||||||
queryKey: ['air', 'channels'],
|
const { data: channels, isLoading } = useQuery({
|
||||||
queryFn: listChannels,
|
queryKey: ['air', 'channels'],
|
||||||
})
|
queryFn: listChannels,
|
||||||
|
})
|
||||||
useEffect(() => {
|
const { data: features } = useQuery({ queryKey: ['air', 'features'], queryFn: getViewerFeatures })
|
||||||
if (!selected && channels && channels.length > 0) setSelected(channels[0].slug)
|
|
||||||
}, [channels, selected])
|
const numbersEnabled = features?.channelNumbersEnabled ?? false
|
||||||
|
const currentChannel = channels?.find((c) => c.slug === selected)
|
||||||
useEffect(() => {
|
|
||||||
if (!selected) return
|
/**
|
||||||
setWatchReady(false)
|
* Переключение по номерам: список уже отсортирован сервером, поэтому «вверх-вниз» — это шаг
|
||||||
setPlayerError(false)
|
* по нему. Короткий чёрный кадр с номером ставится сразу, до готовности потока.
|
||||||
let cancelled = false
|
*/
|
||||||
void watchChannel(selected)
|
const step = useCallback(
|
||||||
.then(() => {
|
(delta: number) => {
|
||||||
if (!cancelled) setWatchReady(true)
|
if (!channels || channels.length === 0) return
|
||||||
})
|
const index = channels.findIndex((c) => c.slug === selected)
|
||||||
.catch(() => {
|
const next = channels[(index + delta + channels.length) % channels.length]
|
||||||
// Выдача stream-cookie не удалась (403/500/сеть) — показываем offline-панель с кнопкой ретрая,
|
if (!next || next.slug === selected) return
|
||||||
// а не бесконечный скелетон. Ретрай (attempt) заново дёрнет watchChannel.
|
setFlash(true)
|
||||||
if (!cancelled) {
|
setSelected(next.slug)
|
||||||
setWatchReady(true)
|
},
|
||||||
setPlayerError(true)
|
[channels, selected],
|
||||||
}
|
)
|
||||||
})
|
|
||||||
return () => {
|
useEffect(() => {
|
||||||
cancelled = true
|
if (!flash) return
|
||||||
}
|
const id = window.setTimeout(() => setFlash(false), 900)
|
||||||
}, [selected, attempt])
|
return () => window.clearTimeout(id)
|
||||||
|
}, [flash, selected])
|
||||||
// Stream-cookie короткоживущий (TTL на сервере ~30 мин) — периодически перевыпускаем, пока смотрим,
|
|
||||||
// иначе плейлист/сегменты начнут отдавать 401 посреди эфира. Тихо: ошибку словит перезагрузка плейлиста.
|
useEffect(() => {
|
||||||
useEffect(() => {
|
if (!numbersEnabled) return
|
||||||
if (!selected || playerError) return
|
const onKey = (event: KeyboardEvent) => {
|
||||||
const id = window.setInterval(
|
// Не перехватываем стрелки, пока фокус в поле ввода: там они двигают каретку.
|
||||||
() => {
|
const target = event.target as HTMLElement | null
|
||||||
void watchChannel(selected).catch(() => undefined)
|
if (target && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return
|
||||||
},
|
if (event.key === 'ArrowUp' || event.key === 'PageUp') {
|
||||||
20 * 60_000,
|
event.preventDefault()
|
||||||
)
|
step(-1)
|
||||||
return () => window.clearInterval(id)
|
} else if (event.key === 'ArrowDown' || event.key === 'PageDown') {
|
||||||
}, [selected, playerError])
|
event.preventDefault()
|
||||||
|
step(1)
|
||||||
const { data: epg } = useQuery({
|
}
|
||||||
queryKey: ['air', 'epg', selected],
|
}
|
||||||
queryFn: () =>
|
window.addEventListener('keydown', onKey)
|
||||||
getEpg(
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
selected!,
|
}, [numbersEnabled, step])
|
||||||
new Date(Date.now() - 30 * 60_000),
|
|
||||||
new Date(Date.now() + 3 * 60 * 60_000),
|
useEffect(() => {
|
||||||
),
|
if (!selected && channels && channels.length > 0) setSelected(channels[0].slug)
|
||||||
enabled: !!selected,
|
}, [channels, selected])
|
||||||
refetchInterval: 60_000,
|
|
||||||
})
|
useEffect(() => {
|
||||||
|
if (!selected) return
|
||||||
const { current, upcoming, currentEntry } = useMemo(() => buildGuide(epg ?? []), [epg])
|
setWatchReady(false)
|
||||||
|
setPlayerError(false)
|
||||||
if (isLoading) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
let cancelled = false
|
||||||
|
void watchChannel(selected)
|
||||||
if (!channels || channels.length === 0)
|
.then(() => {
|
||||||
return (
|
if (!cancelled) setWatchReady(true)
|
||||||
<div className="flex flex-col gap-2">
|
})
|
||||||
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
|
.catch(() => {
|
||||||
<p className="text-muted-foreground">{t('air.noChannels')}</p>
|
// Выдача stream-cookie не удалась (403/500/сеть) — показываем offline-панель с кнопкой ретрая,
|
||||||
</div>
|
// а не бесконечный скелетон. Ретрай (attempt) заново дёрнет watchChannel.
|
||||||
)
|
if (!cancelled) {
|
||||||
|
setWatchReady(true)
|
||||||
return (
|
setPlayerError(true)
|
||||||
<div className="flex flex-col gap-4">
|
}
|
||||||
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
|
})
|
||||||
|
return () => {
|
||||||
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
|
cancelled = true
|
||||||
<aside className="flex gap-2 overflow-x-auto md:flex-col md:overflow-visible">
|
}
|
||||||
{channels.map((channel) => (
|
}, [selected, attempt])
|
||||||
<button
|
|
||||||
key={channel.id}
|
// Stream-cookie короткоживущий (TTL на сервере ~30 мин) — периодически перевыпускаем, пока смотрим,
|
||||||
onClick={() => setSelected(channel.slug)}
|
// иначе плейлист/сегменты начнут отдавать 401 посреди эфира. Тихо: ошибку словит перезагрузка плейлиста.
|
||||||
className={cn(
|
useEffect(() => {
|
||||||
'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',
|
if (!selected || playerError) return
|
||||||
selected === channel.slug && 'border-primary text-primary',
|
const id = window.setInterval(
|
||||||
)}
|
() => {
|
||||||
>
|
void watchChannel(selected).catch(() => undefined)
|
||||||
{channel.currentShowPosterImageId ? (
|
},
|
||||||
<img
|
20 * 60_000,
|
||||||
src={imageUrl(channel.currentShowPosterImageId)}
|
)
|
||||||
alt=""
|
return () => window.clearInterval(id)
|
||||||
className="h-10 w-7 shrink-0 rounded object-cover"
|
}, [selected, playerError])
|
||||||
/>
|
|
||||||
) : (
|
const { data: epg } = useQuery({
|
||||||
<Radio className="h-4 w-4 shrink-0" />
|
queryKey: ['air', 'epg', selected],
|
||||||
)}
|
queryFn: () =>
|
||||||
<span className="flex min-w-0 flex-col">
|
getEpg(
|
||||||
<span className="truncate">{channel.name}</span>
|
selected!,
|
||||||
{channel.currentShowName && (
|
new Date(Date.now() - 30 * 60_000),
|
||||||
<span className="truncate text-xs text-muted-foreground">
|
new Date(Date.now() + 3 * 60 * 60_000),
|
||||||
{channel.currentShowName}
|
),
|
||||||
</span>
|
enabled: !!selected,
|
||||||
)}
|
refetchInterval: 60_000,
|
||||||
</span>
|
})
|
||||||
</button>
|
|
||||||
))}
|
const { current, upcoming, currentEntry } = useMemo(() => buildGuide(epg ?? []), [epg])
|
||||||
</aside>
|
|
||||||
|
// Плашка «Далее» — только на исходе программы: висеть весь эфир ей незачем.
|
||||||
<div className="flex flex-col gap-4">
|
const nextUp =
|
||||||
{selected && watchReady ? (
|
current && upcoming.length > 0 && new Date(current.endsAtUtc).getTime() - Date.now() < 60_000
|
||||||
playerError ? (
|
? upcoming[0].showName
|
||||||
<div className="crt-panel flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-md text-center">
|
: null
|
||||||
<Radio className="h-10 w-10 text-muted-foreground" strokeWidth={1} />
|
|
||||||
<div className="flex flex-col gap-1">
|
if (isLoading) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
||||||
<p className="font-medium">{t('air.offline')}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{t('air.offlineHint')}</p>
|
if (!channels || channels.length === 0)
|
||||||
</div>
|
return (
|
||||||
<Button size="sm" variant="outline" onClick={retry}>
|
<div className="flex flex-col gap-2">
|
||||||
<RotateCw className="h-4 w-4" />
|
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
|
||||||
{t('air.retry')}
|
<p className="text-muted-foreground">{t('air.noChannels')}</p>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
)
|
||||||
) : (
|
|
||||||
<ChannelPlayer
|
return (
|
||||||
key={`${selected}-${attempt}`}
|
<div className="flex flex-col gap-4">
|
||||||
slug={selected}
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
onUnavailable={handleUnavailable}
|
<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 className="aspect-video w-full animate-pulse rounded-md border border-border bg-black" />
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
|
||||||
<div className="flex flex-col gap-3">
|
<aside className="flex gap-2 overflow-x-auto md:flex-col md:overflow-visible">
|
||||||
{current && (
|
{channels.map((channel) => (
|
||||||
<div className="crt-panel flex gap-3 rounded-md p-3">
|
<button
|
||||||
{currentEntry?.episodeStillImageId ? (
|
key={channel.id}
|
||||||
<img
|
onClick={() => setSelected(channel.slug)}
|
||||||
src={imageUrl(currentEntry.episodeStillImageId)}
|
className={cn(
|
||||||
alt=""
|
'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',
|
||||||
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
|
selected === channel.slug && 'border-primary text-primary',
|
||||||
/>
|
)}
|
||||||
) : currentEntry?.showPosterImageId ? (
|
>
|
||||||
<img
|
{numbersEnabled && channel.number !== null && (
|
||||||
src={imageUrl(currentEntry.showPosterImageId)}
|
<span className="w-6 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
|
||||||
alt=""
|
{channel.number}
|
||||||
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
|
</span>
|
||||||
/>
|
)}
|
||||||
) : null}
|
{channel.currentShowPosterImageId ? (
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
<img
|
||||||
<div className="flex items-center gap-2">
|
src={imageUrl(channel.currentShowPosterImageId)}
|
||||||
<Badge>{t('air.now')}</Badge>
|
alt=""
|
||||||
<span className="font-medium">{current.showName}</span>
|
className="h-10 w-7 shrink-0 rounded object-cover"
|
||||||
</div>
|
/>
|
||||||
{currentEntry?.episodeTitle && (
|
) : (
|
||||||
<span className="text-sm">{currentEntry.episodeTitle}</span>
|
<Radio className="h-4 w-4 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="flex min-w-0 flex-col">
|
||||||
{formatTime(current.startsAtUtc)} – {formatTime(current.endsAtUtc)}
|
<span className="truncate">{channel.name}</span>
|
||||||
</span>
|
{channel.currentShowName && (
|
||||||
{currentEntry?.episodeOverview && (
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
<p className="line-clamp-3 text-xs text-muted-foreground">
|
{channel.currentShowName}
|
||||||
{currentEntry.episodeOverview}
|
</span>
|
||||||
</p>
|
)}
|
||||||
)}
|
</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
))}
|
||||||
)}
|
</aside>
|
||||||
|
|
||||||
{upcoming.length > 0 && (
|
<div className="flex flex-col gap-4">
|
||||||
<div className="crt-panel rounded-md">
|
{selected && watchReady ? (
|
||||||
<div className="border-b border-border px-4 py-2 text-xs uppercase tracking-wide text-muted-foreground">
|
playerError ? (
|
||||||
{t('air.next')}
|
<div className="crt-panel flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-md text-center">
|
||||||
</div>
|
<Radio className="h-10 w-10 text-muted-foreground" strokeWidth={1} />
|
||||||
<ul className="divide-y divide-border">
|
<div className="flex flex-col gap-1">
|
||||||
{upcoming.slice(0, 6).map((block) => (
|
<p className="font-medium">{t('air.offline')}</p>
|
||||||
<li key={block.key} className="flex items-center gap-3 px-4 py-2 text-sm">
|
<p className="text-sm text-muted-foreground">{t('air.offlineHint')}</p>
|
||||||
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
</div>
|
||||||
{formatTime(block.startsAtUtc)} – {formatTime(block.endsAtUtc)}
|
<Button size="sm" variant="outline" onClick={retry}>
|
||||||
</span>
|
<RotateCw className="h-4 w-4" />
|
||||||
<span>{block.showName}</span>
|
{t('air.retry')}
|
||||||
</li>
|
</Button>
|
||||||
))}
|
</div>
|
||||||
</ul>
|
) : (
|
||||||
</div>
|
<ChannelPlayer
|
||||||
)}
|
key={`${selected}-${attempt}`}
|
||||||
</div>
|
slug={selected}
|
||||||
</div>
|
channel={currentChannel}
|
||||||
</div>
|
nextUp={nextUp}
|
||||||
</div>
|
flash={flash}
|
||||||
)
|
onUnavailable={handleUnavailable}
|
||||||
}
|
/>
|
||||||
|
)
|
||||||
type GuideBlock = {
|
) : (
|
||||||
key: string
|
<div className="aspect-video w-full animate-pulse rounded-md border border-border bg-black" />
|
||||||
showId: string | null
|
)}
|
||||||
showName: string
|
|
||||||
startsAtUtc: string
|
<div className="flex flex-col gap-3">
|
||||||
endsAtUtc: string
|
{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"
|
||||||
function buildGuide(entries: PublicEpgEntryDto[]): {
|
/>
|
||||||
current?: GuideBlock
|
) : currentEntry?.showPosterImageId ? (
|
||||||
upcoming: GuideBlock[]
|
<img
|
||||||
currentEntry?: PublicEpgEntryDto
|
src={imageUrl(currentEntry.showPosterImageId)}
|
||||||
} {
|
alt=""
|
||||||
const blocks: GuideBlock[] = []
|
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
|
||||||
for (const entry of entries) {
|
/>
|
||||||
if (entry.kind !== 'Program') continue
|
) : null}
|
||||||
const last = blocks[blocks.length - 1]
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
if (last && last.showId === entry.showId) {
|
<div className="flex items-center gap-2">
|
||||||
last.endsAtUtc = entry.endsAtUtc
|
<Badge>{t('air.now')}</Badge>
|
||||||
} else {
|
<span className="font-medium">{current.showName}</span>
|
||||||
blocks.push({
|
</div>
|
||||||
key: entry.startsAtUtc,
|
{currentEntry?.episodeTitle && (
|
||||||
showId: entry.showId,
|
<span className="text-sm">{currentEntry.episodeTitle}</span>
|
||||||
showName: entry.showName ?? '—',
|
)}
|
||||||
startsAtUtc: entry.startsAtUtc,
|
<span className="text-xs text-muted-foreground">
|
||||||
endsAtUtc: entry.endsAtUtc,
|
{formatTime(current.startsAtUtc)} – {formatTime(current.endsAtUtc)}
|
||||||
})
|
</span>
|
||||||
}
|
{currentEntry?.episodeOverview && (
|
||||||
}
|
<p className="line-clamp-3 text-xs text-muted-foreground">
|
||||||
|
{currentEntry.episodeOverview}
|
||||||
const now = Date.now()
|
</p>
|
||||||
const active = (start: string, end: string) =>
|
)}
|
||||||
new Date(start).getTime() <= now && new Date(end).getTime() > now
|
</div>
|
||||||
const current = blocks.find((b) => active(b.startsAtUtc, b.endsAtUtc))
|
</div>
|
||||||
const upcoming = blocks.filter((b) => new Date(b.startsAtUtc).getTime() > now)
|
)}
|
||||||
const currentEntry = entries.find(
|
|
||||||
(e) => e.kind === 'Program' && active(e.startsAtUtc, e.endsAtUtc),
|
{upcoming.length > 0 && (
|
||||||
)
|
<div className="crt-panel rounded-md">
|
||||||
return { current, upcoming, currentEntry }
|
<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 Hls from 'hls.js'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Maximize, Volume2, VolumeX } from 'lucide-react'
|
import { Maximize, Volume2, VolumeX } from 'lucide-react'
|
||||||
|
import type { PublicChannelDto } from '@/shared/api/types'
|
||||||
const STORAGE_KEY = 'tw:player'
|
import {
|
||||||
|
AnalogFilter,
|
||||||
/** Читает сохранённые громкость/mute из localStorage (с валидацией и дефолтами). */
|
ChannelFlash,
|
||||||
function readStoredAudio(): { volume: number; muted: boolean } {
|
ChannelLogo,
|
||||||
try {
|
NextUpBanner,
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
ScreenClock,
|
||||||
if (raw) {
|
} from './PlayerOverlays'
|
||||||
const parsed = JSON.parse(raw) as { volume?: unknown; muted?: unknown }
|
|
||||||
const volume =
|
const STORAGE_KEY = 'tw:player'
|
||||||
typeof parsed.volume === 'number' ? Math.min(1, Math.max(0, parsed.volume)) : 1
|
|
||||||
const muted = typeof parsed.muted === 'boolean' ? parsed.muted : true
|
/** Читает сохранённые громкость/mute из localStorage (с валидацией и дефолтами). */
|
||||||
return { volume, muted }
|
function readStoredAudio(): { volume: number; muted: boolean } {
|
||||||
}
|
try {
|
||||||
} catch {
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
/* недоступен/битый localStorage — дефолты */
|
if (raw) {
|
||||||
}
|
const parsed = JSON.parse(raw) as { volume?: unknown; muted?: unknown }
|
||||||
return { volume: 1, muted: true }
|
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 }
|
||||||
* HLS-плеер линейного канала. Это живой эфир: ни перемотки, ни паузы — только звук, громкость и
|
}
|
||||||
* полноэкранный режим. Cookie tw_stream уже выдана к монтированию.
|
} catch {
|
||||||
*/
|
/* недоступен/битый localStorage — дефолты */
|
||||||
export function ChannelPlayer({
|
}
|
||||||
slug,
|
return { volume: 1, muted: true }
|
||||||
onUnavailable,
|
}
|
||||||
}: {
|
|
||||||
slug: string
|
/**
|
||||||
onUnavailable?: () => void
|
* HLS-плеер линейного канала. Это живой эфир: ни перемотки, ни паузы — только звук, громкость и
|
||||||
}) {
|
* полноэкранный режим. Cookie tw_stream уже выдана к монтированию.
|
||||||
const { t } = useTranslation()
|
*/
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
export function ChannelPlayer({
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
slug,
|
||||||
const hlsRef = useRef<Hls | null>(null)
|
channel,
|
||||||
const [muted, setMuted] = useState(() => readStoredAudio().muted)
|
nextUp,
|
||||||
const [volume, setVolume] = useState(() => readStoredAudio().volume)
|
flash,
|
||||||
|
onUnavailable,
|
||||||
// Последние настройки звука без пересоздания HLS-эффекта + сохранение в localStorage.
|
}: {
|
||||||
const audioRef = useRef({ volume, muted })
|
slug: string
|
||||||
useEffect(() => {
|
/** Канал, чьи оверлеи рисуем. Всё опционально: канал без логотипа и без шума — норма. */
|
||||||
audioRef.current = { volume, muted }
|
channel?: PublicChannelDto
|
||||||
try {
|
/** Название следующей программы, когда до неё осталось меньше минуты. */
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ volume, muted }))
|
nextUp?: string | null
|
||||||
} catch {
|
/** Показать чёрный кадр с номером — переключение по номерам, как на телевизоре. */
|
||||||
/* localStorage недоступен — не критично */
|
flash?: boolean
|
||||||
}
|
onUnavailable?: () => void
|
||||||
}, [volume, muted])
|
}) {
|
||||||
const [controlsVisible, setControlsVisible] = useState(true)
|
const { t } = useTranslation()
|
||||||
const hideTimerRef = useRef<number | null>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const overControlsRef = useRef(false)
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
const hlsRef = useRef<Hls | null>(null)
|
||||||
const clearHideTimer = useCallback(() => {
|
const [muted, setMuted] = useState(() => readStoredAudio().muted)
|
||||||
if (hideTimerRef.current !== null) {
|
const [volume, setVolume] = useState(() => readStoredAudio().volume)
|
||||||
window.clearTimeout(hideTimerRef.current)
|
|
||||||
hideTimerRef.current = null
|
// Последние настройки звука без пересоздания HLS-эффекта + сохранение в localStorage.
|
||||||
}
|
const audioRef = useRef({ volume, muted })
|
||||||
}, [])
|
useEffect(() => {
|
||||||
|
audioRef.current = { volume, muted }
|
||||||
// Прячем панель и курсор после бездействия — но не когда курсор на панели или видео на паузе.
|
try {
|
||||||
const scheduleHide = useCallback(() => {
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({ volume, muted }))
|
||||||
clearHideTimer()
|
} catch {
|
||||||
hideTimerRef.current = window.setTimeout(() => {
|
/* localStorage недоступен — не критично */
|
||||||
if (!overControlsRef.current && videoRef.current && !videoRef.current.paused) {
|
}
|
||||||
setControlsVisible(false)
|
}, [volume, muted])
|
||||||
}
|
const [controlsVisible, setControlsVisible] = useState(true)
|
||||||
}, 2500)
|
const hideTimerRef = useRef<number | null>(null)
|
||||||
}, [clearHideTimer])
|
const overControlsRef = useRef(false)
|
||||||
|
|
||||||
const revealControls = useCallback(() => {
|
const clearHideTimer = useCallback(() => {
|
||||||
setControlsVisible(true)
|
if (hideTimerRef.current !== null) {
|
||||||
scheduleHide()
|
window.clearTimeout(hideTimerRef.current)
|
||||||
}, [scheduleHide])
|
hideTimerRef.current = null
|
||||||
|
}
|
||||||
useEffect(() => clearHideTimer, [clearHideTimer])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
// Прячем панель и курсор после бездействия — но не когда курсор на панели или видео на паузе.
|
||||||
const video = videoRef.current
|
const scheduleHide = useCallback(() => {
|
||||||
if (!video) return
|
clearHideTimer()
|
||||||
const src = `/api/channels/${slug}/live.m3u8`
|
hideTimerRef.current = window.setTimeout(() => {
|
||||||
let hls: Hls | null = null
|
if (!overControlsRef.current && videoRef.current && !videoRef.current.paused) {
|
||||||
|
setControlsVisible(false)
|
||||||
// Восстанавливаем сохранённую громкость/mute и запускаем; если браузер блокирует автоплей со
|
}
|
||||||
// звуком — откатываемся на воспроизведение без звука.
|
}, 2500)
|
||||||
const startPlayback = () => {
|
}, [clearHideTimer])
|
||||||
const audio = audioRef.current
|
|
||||||
video.volume = audio.volume
|
const revealControls = useCallback(() => {
|
||||||
video.muted = audio.muted
|
setControlsVisible(true)
|
||||||
video.play().catch(() => {
|
scheduleHide()
|
||||||
video.muted = true
|
}, [scheduleHide])
|
||||||
setMuted(true)
|
|
||||||
void video.play().catch(() => undefined)
|
useEffect(() => clearHideTimer, [clearHideTimer])
|
||||||
})
|
|
||||||
}
|
useEffect(() => {
|
||||||
|
const video = videoRef.current
|
||||||
// Слушатели нативной ветки — держим ссылки, чтобы снять их в cleanup (симметрично hls.destroy()).
|
if (!video) return
|
||||||
const onNativeError = () => onUnavailable?.()
|
const src = `/api/channels/${slug}/live.m3u8`
|
||||||
|
let hls: Hls | null = null
|
||||||
if (Hls.isSupported()) {
|
|
||||||
hls = new Hls({ liveSyncDurationCount: 3, enableWorker: true, lowLatencyMode: false })
|
// Восстанавливаем сохранённую громкость/mute и запускаем; если браузер блокирует автоплей со
|
||||||
hlsRef.current = hls
|
// звуком — откатываемся на воспроизведение без звука.
|
||||||
hls.loadSource(src)
|
const startPlayback = () => {
|
||||||
hls.attachMedia(video)
|
const audio = audioRef.current
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, startPlayback)
|
video.volume = audio.volume
|
||||||
|
video.muted = audio.muted
|
||||||
// Живой эфир: краткий сетевой сбой сегмента или media-ошибку сперва пробуем восстановить
|
video.play().catch(() => {
|
||||||
// (hls.js рекомендует startLoad / recoverMediaError), и только исчерпав попытки — уходим в offline.
|
video.muted = true
|
||||||
let recoverAttempts = 0
|
setMuted(true)
|
||||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
void video.play().catch(() => undefined)
|
||||||
if (!data.fatal) return
|
})
|
||||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR && recoverAttempts < 3) {
|
}
|
||||||
recoverAttempts += 1
|
|
||||||
hls?.startLoad()
|
// Слушатели нативной ветки — держим ссылки, чтобы снять их в cleanup (симметрично hls.destroy()).
|
||||||
return
|
const onNativeError = () => onUnavailable?.()
|
||||||
}
|
|
||||||
if (data.type === Hls.ErrorTypes.MEDIA_ERROR && recoverAttempts < 3) {
|
if (Hls.isSupported()) {
|
||||||
recoverAttempts += 1
|
hls = new Hls({ liveSyncDurationCount: 3, enableWorker: true, lowLatencyMode: false })
|
||||||
hls?.recoverMediaError()
|
hlsRef.current = hls
|
||||||
return
|
hls.loadSource(src)
|
||||||
}
|
hls.attachMedia(video)
|
||||||
hls?.destroy()
|
hls.on(Hls.Events.MANIFEST_PARSED, startPlayback)
|
||||||
hls = null
|
|
||||||
hlsRef.current = null
|
// Живой эфир: краткий сетевой сбой сегмента или media-ошибку сперва пробуем восстановить
|
||||||
onUnavailable?.()
|
// (hls.js рекомендует startLoad / recoverMediaError), и только исчерпав попытки — уходим в offline.
|
||||||
})
|
let recoverAttempts = 0
|
||||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||||
video.src = src
|
if (!data.fatal) return
|
||||||
video.addEventListener('loadedmetadata', startPlayback)
|
if (data.type === Hls.ErrorTypes.NETWORK_ERROR && recoverAttempts < 3) {
|
||||||
video.addEventListener('error', onNativeError)
|
recoverAttempts += 1
|
||||||
} else {
|
hls?.startLoad()
|
||||||
onUnavailable?.()
|
return
|
||||||
}
|
}
|
||||||
|
if (data.type === Hls.ErrorTypes.MEDIA_ERROR && recoverAttempts < 3) {
|
||||||
return () => {
|
recoverAttempts += 1
|
||||||
hls?.destroy()
|
hls?.recoverMediaError()
|
||||||
hlsRef.current = null
|
return
|
||||||
video.removeEventListener('loadedmetadata', startPlayback)
|
}
|
||||||
video.removeEventListener('error', onNativeError)
|
hls?.destroy()
|
||||||
}
|
hls = null
|
||||||
}, [slug, onUnavailable])
|
hlsRef.current = null
|
||||||
|
onUnavailable?.()
|
||||||
// Установить громкость (0..1); 0 = mute, >0 запоминаем как последний уровень для «размьютить».
|
})
|
||||||
const applyVolume = (value: number) => {
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
const video = videoRef.current
|
video.src = src
|
||||||
if (!video) return
|
video.addEventListener('loadedmetadata', startPlayback)
|
||||||
const clamped = Math.min(1, Math.max(0, value))
|
video.addEventListener('error', onNativeError)
|
||||||
video.volume = clamped
|
} else {
|
||||||
video.muted = clamped === 0
|
onUnavailable?.()
|
||||||
setMuted(clamped === 0)
|
}
|
||||||
if (clamped > 0) setVolume(clamped)
|
|
||||||
}
|
return () => {
|
||||||
|
hls?.destroy()
|
||||||
const toggleMute = () => {
|
hlsRef.current = null
|
||||||
const video = videoRef.current
|
video.removeEventListener('loadedmetadata', startPlayback)
|
||||||
if (!video) return
|
video.removeEventListener('error', onNativeError)
|
||||||
if (video.muted || video.volume === 0) {
|
}
|
||||||
applyVolume(volume > 0 ? volume : 0.5)
|
}, [slug, onUnavailable])
|
||||||
} else {
|
|
||||||
video.muted = true
|
// Установить громкость (0..1); 0 = mute, >0 запоминаем как последний уровень для «размьютить».
|
||||||
setMuted(true)
|
const applyVolume = (value: number) => {
|
||||||
}
|
const video = videoRef.current
|
||||||
}
|
if (!video) return
|
||||||
|
const clamped = Math.min(1, Math.max(0, value))
|
||||||
const toggleFullscreen = () => {
|
video.volume = clamped
|
||||||
const container = containerRef.current
|
video.muted = clamped === 0
|
||||||
if (!container) return
|
setMuted(clamped === 0)
|
||||||
if (document.fullscreenElement) void document.exitFullscreen()
|
if (clamped > 0) setVolume(clamped)
|
||||||
else void container.requestFullscreen().catch(() => undefined)
|
}
|
||||||
}
|
|
||||||
|
const toggleMute = () => {
|
||||||
return (
|
const video = videoRef.current
|
||||||
<div
|
if (!video) return
|
||||||
ref={containerRef}
|
if (video.muted || video.volume === 0) {
|
||||||
onMouseMove={revealControls}
|
applyVolume(volume > 0 ? volume : 0.5)
|
||||||
onMouseLeave={() => {
|
} else {
|
||||||
clearHideTimer()
|
video.muted = true
|
||||||
if (videoRef.current && !videoRef.current.paused) setControlsVisible(false)
|
setMuted(true)
|
||||||
}}
|
}
|
||||||
className={`relative aspect-video w-full overflow-hidden rounded-md border border-border bg-black ${
|
}
|
||||||
controlsVisible ? '' : 'cursor-none'
|
|
||||||
}`}
|
const toggleFullscreen = () => {
|
||||||
>
|
const container = containerRef.current
|
||||||
<video
|
if (!container) return
|
||||||
ref={videoRef}
|
if (document.fullscreenElement) void document.exitFullscreen()
|
||||||
playsInline
|
else void container.requestFullscreen().catch(() => undefined)
|
||||||
muted
|
}
|
||||||
className="h-full w-full"
|
|
||||||
onDoubleClick={toggleFullscreen}
|
return (
|
||||||
onPlay={scheduleHide}
|
<div
|
||||||
onPause={() => {
|
ref={containerRef}
|
||||||
clearHideTimer()
|
onMouseMove={revealControls}
|
||||||
setControlsVisible(true)
|
onMouseLeave={() => {
|
||||||
}}
|
clearHideTimer()
|
||||||
/>
|
if (videoRef.current && !videoRef.current.paused) setControlsVisible(false)
|
||||||
|
}}
|
||||||
<div
|
className={`relative aspect-video w-full overflow-hidden rounded-md border border-border bg-black ${
|
||||||
onMouseEnter={() => {
|
controlsVisible ? '' : 'cursor-none'
|
||||||
overControlsRef.current = true
|
}`}
|
||||||
clearHideTimer()
|
>
|
||||||
setControlsVisible(true)
|
<video
|
||||||
}}
|
ref={videoRef}
|
||||||
onMouseLeave={() => {
|
playsInline
|
||||||
overControlsRef.current = false
|
muted
|
||||||
scheduleHide()
|
className="h-full w-full"
|
||||||
}}
|
style={
|
||||||
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 ${
|
channel && channel.analogFilterStrength > 0
|
||||||
controlsVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
|
? {
|
||||||
}`}
|
filter: `saturate(${1 + channel.analogFilterStrength * 0.4}) contrast(${1 + channel.analogFilterStrength * 0.15}) blur(${channel.analogFilterStrength * 0.6}px)`,
|
||||||
>
|
}
|
||||||
<div className="flex items-center gap-2">
|
: undefined
|
||||||
<button type="button" onClick={toggleMute} aria-label="mute">
|
}
|
||||||
{muted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
onDoubleClick={toggleFullscreen}
|
||||||
</button>
|
onPlay={scheduleHide}
|
||||||
<input
|
onPause={() => {
|
||||||
type="range"
|
clearHideTimer()
|
||||||
min={0}
|
setControlsVisible(true)
|
||||||
max={1}
|
}}
|
||||||
step={0.05}
|
/>
|
||||||
value={muted ? 0 : volume}
|
|
||||||
onChange={(e) => applyVolume(Number(e.target.value))}
|
{channel && channel.analogFilterStrength > 0 && (
|
||||||
aria-label={t('air.volume')}
|
<AnalogFilter strength={channel.analogFilterStrength} />
|
||||||
className="h-1 w-20 cursor-pointer accent-emerald-400"
|
)}
|
||||||
/>
|
{channel?.logoImageId && (
|
||||||
</div>
|
<ChannelLogo
|
||||||
|
imageId={channel.logoImageId}
|
||||||
<span className="ml-auto flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-red-500">
|
corner={channel.logoCorner}
|
||||||
<span className="h-2 w-2 animate-pulse rounded-full bg-red-500" />
|
opacity={channel.logoOpacity}
|
||||||
{t('air.live')}
|
/>
|
||||||
</span>
|
)}
|
||||||
|
{channel?.showClock && <ScreenClock />}
|
||||||
<button type="button" onClick={toggleFullscreen} aria-label="fullscreen">
|
{nextUp && <NextUpBanner title={nextUp} />}
|
||||||
<Maximize className="h-5 w-5" />
|
{flash && channel && <ChannelFlash number={channel.number} name={channel.name} />}
|
||||||
</button>
|
|
||||||
</div>
|
<div
|
||||||
</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 { apiRequest } from '@/shared/api/client'
|
||||||
import type { PublicChannelDto, PublicEpgEntryDto } from '@/shared/api/types'
|
import type { PublicChannelDto, PublicEpgEntryDto } from '@/shared/api/types'
|
||||||
|
|
||||||
/** Ссылка на изображение общего реестра (постеры/кадры) — по id из публичных DTO. */
|
/** Ссылка на изображение общего реестра (постеры/кадры) — по id из публичных DTO. */
|
||||||
export function imageUrl(imageId: string) {
|
export function imageUrl(imageId: string) {
|
||||||
return `/api/images/${imageId}`
|
return `/api/images/${imageId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listChannels() {
|
export function listChannels() {
|
||||||
return apiRequest<PublicChannelDto[]>('/channels')
|
return apiRequest<PublicChannelDto[]>('/channels')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Выдаёт httpOnly-cookie tw_stream — после этого <video> сможет грузить плейлист и сегменты. */
|
/** Что включено глобально на стороне зрителя (сейчас — переключение по номерам). */
|
||||||
export function watchChannel(slug: string) {
|
export function getViewerFeatures() {
|
||||||
return apiRequest<void>(`/channels/${slug}/watch`, { method: 'POST' })
|
return apiRequest<{ channelNumbersEnabled: boolean }>('/channels/features')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEpg(slug: string, from?: Date, to?: Date) {
|
/** Выдаёт httpOnly-cookie tw_stream — после этого <video> сможет грузить плейлист и сегменты. */
|
||||||
const query = new URLSearchParams()
|
export function watchChannel(slug: string) {
|
||||||
if (from) query.set('from', from.toISOString())
|
return apiRequest<void>(`/channels/${slug}/watch`, { method: 'POST' })
|
||||||
if (to) query.set('to', to.toISOString())
|
}
|
||||||
const qs = query.toString()
|
|
||||||
return apiRequest<PublicEpgEntryDto[]>(`/channels/${slug}/epg${qs ? `?${qs}` : ''}`)
|
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 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 = {
|
export type RoleDto = {
|
||||||
id: string
|
id: string
|
||||||
@@ -360,6 +377,7 @@ export type ChannelDto = {
|
|||||||
bumper: BumperSettings
|
bumper: BumperSettings
|
||||||
bumperTemplates: BumperTemplateDto[]
|
bumperTemplates: BumperTemplateDto[]
|
||||||
fillerAssetId: string | null
|
fillerAssetId: string | null
|
||||||
|
viewer: ViewerSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Сетка канала (шаблон → слои → слоты) ──────────────────────────────────
|
// ── Сетка канала (шаблон → слои → слоты) ──────────────────────────────────
|
||||||
@@ -466,6 +484,10 @@ export type AudienceWindow = { from: string; to: string; maxAudience: ShowAudien
|
|||||||
export type PlanningRules = {
|
export type PlanningRules = {
|
||||||
maxAudienceByTime?: AudienceWindow[] | null
|
maxAudienceByTime?: AudienceWindow[] | null
|
||||||
maxRepeatsInWindow?: { windowDays: number; max: number } | null
|
maxRepeatsInWindow?: { windowDays: number; max: number } | null
|
||||||
|
/** Пост-проверки: считаются по готовой ленте и только предупреждают. */
|
||||||
|
maxBreakMinutesPerHour?: number | null
|
||||||
|
maxGenreSharePercent?: number | null
|
||||||
|
maxFallbackSharePercent?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleTemplateDto = {
|
export type ScheduleTemplateDto = {
|
||||||
@@ -493,6 +515,9 @@ export type PlanningWarningKind =
|
|||||||
| 'RepeatSourceEmpty'
|
| 'RepeatSourceEmpty'
|
||||||
| 'FallbackEmpty'
|
| 'FallbackEmpty'
|
||||||
| 'CandidatesFiltered'
|
| 'CandidatesFiltered'
|
||||||
|
| 'BreakLimitExceeded'
|
||||||
|
| 'GenreShareExceeded'
|
||||||
|
| 'FallbackShareExceeded'
|
||||||
|
|
||||||
export type PlanningWarningDto = {
|
export type PlanningWarningDto = {
|
||||||
kind: PlanningWarningKind
|
kind: PlanningWarningKind
|
||||||
@@ -502,6 +527,72 @@ export type PlanningWarningDto = {
|
|||||||
|
|
||||||
export type ApplyResultDto = { added: number; warnings: 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` приходит без ассета — он рендерится при применении. */
|
/** Что попало в ленту предпросмотра. `Bumper` приходит без ассета — он рендерится при применении. */
|
||||||
export type PlannedItemKind = 'Program' | 'Fallback' | 'SignOff' | 'Ad' | 'Promo' | 'Bumper'
|
export type PlannedItemKind = 'Program' | 'Fallback' | 'SignOff' | 'Ad' | 'Promo' | 'Bumper'
|
||||||
|
|
||||||
@@ -543,9 +634,16 @@ export type PublicChannelDto = {
|
|||||||
id: string
|
id: string
|
||||||
slug: string
|
slug: string
|
||||||
name: string
|
name: string
|
||||||
|
/** Номер канала для переключения по номерам или null. */
|
||||||
|
number: number | null
|
||||||
currentShowId: string | null
|
currentShowId: string | null
|
||||||
currentShowName: string | null
|
currentShowName: string | null
|
||||||
currentShowPosterImageId: string | null
|
currentShowPosterImageId: string | null
|
||||||
|
logoImageId: string | null
|
||||||
|
logoCorner: LogoCorner
|
||||||
|
logoOpacity: number
|
||||||
|
showClock: boolean
|
||||||
|
analogFilterStrength: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PublicEpgEntryDto = {
|
export type PublicEpgEntryDto = {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ const resources = {
|
|||||||
noChannels: 'Пока нет доступных каналов. Загляните позже.',
|
noChannels: 'Пока нет доступных каналов. Загляните позже.',
|
||||||
offline: 'Канал сейчас не в эфире',
|
offline: 'Канал сейчас не в эфире',
|
||||||
offlineHint: 'Нет расписания или контента. Загляните позже.',
|
offlineHint: 'Нет расписания или контента. Загляните позже.',
|
||||||
|
numbersHint: '↑ / ↓ — переключение каналов по номерам',
|
||||||
retry: 'Повторить',
|
retry: 'Повторить',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
@@ -378,7 +379,65 @@ const resources = {
|
|||||||
previewDays_one: '{{count}} сутки',
|
previewDays_one: '{{count}} сутки',
|
||||||
previewDays_few: '{{count}} суток',
|
previewDays_few: '{{count}} суток',
|
||||||
previewDays_many: '{{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: {
|
previewKinds: {
|
||||||
Program: 'Программа',
|
Program: 'Программа',
|
||||||
Fallback: 'Фон',
|
Fallback: 'Фон',
|
||||||
@@ -454,6 +513,9 @@ const resources = {
|
|||||||
RepeatSourceEmpty: 'Нечего повторять',
|
RepeatSourceEmpty: 'Нечего повторять',
|
||||||
FallbackEmpty: 'Нечем закрыть паузы',
|
FallbackEmpty: 'Нечем закрыть паузы',
|
||||||
CandidatesFiltered: 'Возрастной потолок отсёк всех',
|
CandidatesFiltered: 'Возрастной потолок отсёк всех',
|
||||||
|
BreakLimitExceeded: 'Врезок в часе больше потолка',
|
||||||
|
GenreShareExceeded: 'Доля жанра выше нормы',
|
||||||
|
FallbackShareExceeded: 'Фона в эфире больше нормы',
|
||||||
},
|
},
|
||||||
|
|
||||||
title: 'Каналы',
|
title: 'Каналы',
|
||||||
@@ -565,6 +627,9 @@ const resources = {
|
|||||||
'Когда выключено — новые пользователи не могут регистрироваться сами, учётки заводит только администратор.',
|
'Когда выключено — новые пользователи не могут регистрироваться сами, учётки заводит только администратор.',
|
||||||
registrationLabel: 'Разрешить регистрацию на сайте',
|
registrationLabel: 'Разрешить регистрацию на сайте',
|
||||||
preferredAudio: 'Предпочитаемые озвучки',
|
preferredAudio: 'Предпочитаемые озвучки',
|
||||||
|
channelNumbers: 'Переключение каналов по номерам',
|
||||||
|
channelNumbersHint:
|
||||||
|
'Зритель переключает каналы стрелками, как на телевизоре. Сетка каналов остаётся всегда.',
|
||||||
preferredAudioHint:
|
preferredAudioHint:
|
||||||
'Коды языков через запятую в порядке приоритета (напр. «rus, eng»). Если в файле есть дорожка с таким языком — при обработке выбирается она (по порядку); иначе — выбор ffmpeg по умолчанию. Применяется к новым обработкам.',
|
'Коды языков через запятую в порядке приоритета (напр. «rus, eng»). Если в файле есть дорожка с таким языком — при обработке выбирается она (по порядку); иначе — выбор ffmpeg по умолчанию. Применяется к новым обработкам.',
|
||||||
},
|
},
|
||||||
@@ -974,7 +1039,65 @@ const resources = {
|
|||||||
previewHint: 'A run against the current rules: nothing is written, slot cursors do not move.',
|
previewHint: 'A run against the current rules: nothing is written, slot cursors do not move.',
|
||||||
previewDays_one: '{{count}} day',
|
previewDays_one: '{{count}} day',
|
||||||
previewDays_other: '{{count}} days',
|
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: {
|
previewKinds: {
|
||||||
Program: 'Programme',
|
Program: 'Programme',
|
||||||
Fallback: 'Background',
|
Fallback: 'Background',
|
||||||
@@ -1045,6 +1168,9 @@ const resources = {
|
|||||||
RepeatSourceEmpty: 'Nothing to repeat',
|
RepeatSourceEmpty: 'Nothing to repeat',
|
||||||
FallbackEmpty: 'Nothing to fill pauses with',
|
FallbackEmpty: 'Nothing to fill pauses with',
|
||||||
CandidatesFiltered: 'The age cap ruled out every candidate',
|
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',
|
title: 'Channels',
|
||||||
@@ -1155,6 +1281,9 @@ const resources = {
|
|||||||
registrationHint:
|
registrationHint:
|
||||||
'When off, new users cannot sign up themselves — only an administrator can create accounts.',
|
'When off, new users cannot sign up themselves — only an administrator can create accounts.',
|
||||||
registrationLabel: 'Allow public registration',
|
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',
|
preferredAudio: 'Preferred audio tracks',
|
||||||
preferredAudioHint:
|
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.',
|
'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