Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f699576582 | ||
|
|
8494eb5e6a |
@@ -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
|
||||||
|
);
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ public static class SettingsEndpoints
|
|||||||
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
|
||||||
);
|
);
|
||||||
@@ -49,5 +50,6 @@ public static class SettingsEndpoints
|
|||||||
|
|
||||||
public sealed record UpdateSiteSettingsBody(
|
public sealed record UpdateSiteSettingsBody(
|
||||||
bool RegistrationEnabled,
|
bool RegistrationEnabled,
|
||||||
string? PreferredAudioLanguages
|
string? PreferredAudioLanguages,
|
||||||
|
bool ChannelNumbersEnabled
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ public static class StreamingEndpoints
|
|||||||
// Публичный 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>>();
|
||||||
|
// Что включено на стороне зрителя: сейчас только переключение по номерам (см. 6.8).
|
||||||
|
channels.MapGet("/features", ViewerFeatures).Produces<ViewerFeaturesDto>();
|
||||||
channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent);
|
channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent);
|
||||||
channels.MapGet("/{slug}/epg", Epg);
|
channels.MapGet("/{slug}/epg", Epg);
|
||||||
|
|
||||||
@@ -43,6 +45,14 @@ public static class StreamingEndpoints
|
|||||||
return Results.Ok(result);
|
return Results.Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ViewerFeatures(
|
||||||
|
ISiteSettings siteSettings,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
) =>
|
||||||
|
Results.Ok(
|
||||||
|
new ViewerFeaturesDto(await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken))
|
||||||
|
);
|
||||||
|
|
||||||
private static IResult Watch(
|
private static IResult Watch(
|
||||||
string slug,
|
string slug,
|
||||||
ICurrentUser currentUser,
|
ICurrentUser currentUser,
|
||||||
@@ -182,3 +192,6 @@ public static class StreamingEndpoints
|
|||||||
return sb.ToString();
|
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,
|
||||||
@@ -106,7 +154,8 @@ public static class TemplateEndpoints
|
|||||||
templateId,
|
templateId,
|
||||||
body.Name,
|
body.Name,
|
||||||
body.FallbackGroupId,
|
body.FallbackGroupId,
|
||||||
body.DefaultJunctionId
|
body.DefaultJunctionId,
|
||||||
|
body.Rules
|
||||||
),
|
),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
@@ -197,7 +246,8 @@ public static class TemplateEndpoints
|
|||||||
public sealed record UpdateTemplateBody(
|
public sealed record UpdateTemplateBody(
|
||||||
string Name,
|
string Name,
|
||||||
Guid? FallbackGroupId,
|
Guid? FallbackGroupId,
|
||||||
Guid? DefaultJunctionId
|
Guid? DefaultJunctionId,
|
||||||
|
PlanningRules? Rules
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record CreateLayerBody(string Name, int Priority);
|
public sealed record CreateLayerBody(string Name, int Priority);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,4 +13,9 @@ public interface ISiteSettings
|
|||||||
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>
|
||||||
@@ -258,7 +279,19 @@ public sealed class GridScheduleGenerator(
|
|||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var elementsByGroup = await expander.ExpandAsync(groupIds, channel.Id, cancellationToken);
|
// Правила канала: детское время резолвится на каждый слот по его времени, а под потолок
|
||||||
|
// повторов нужна история показов — её глубина и задаётся окном правила.
|
||||||
|
var rules = PlanningRules.FromJson(template.RulesJson);
|
||||||
|
var repeatLimit = rules?.MaxRepeatsInWindow is { WindowDays: > 0, Max: > 0 } limit
|
||||||
|
? new RepeatLimit(limit.WindowDays, limit.Max)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var elementsByGroup = await expander.ExpandAsync(
|
||||||
|
groupIds,
|
||||||
|
channel.Id,
|
||||||
|
cancellationToken,
|
||||||
|
repeatLimit is null ? null : startUtc.AddDays(-repeatLimit.WindowDays)
|
||||||
|
);
|
||||||
|
|
||||||
var slotIds = scheduled.Select(s => s.Slot.Id).Distinct().ToList();
|
var slotIds = scheduled.Select(s => s.Slot.Id).Distinct().ToList();
|
||||||
var states = await dbContext
|
var states = await dbContext
|
||||||
@@ -308,7 +341,13 @@ public sealed class GridScheduleGenerator(
|
|||||||
junctions,
|
junctions,
|
||||||
elementsByGroup,
|
elementsByGroup,
|
||||||
channel
|
channel
|
||||||
)
|
),
|
||||||
|
rules?.AudienceAt(
|
||||||
|
TimeOnly.FromDateTime(
|
||||||
|
item.StartUtc.ToOffset(TimeSpan.FromMinutes(channel.UtcOffsetMinutes)).DateTime
|
||||||
|
)
|
||||||
|
),
|
||||||
|
repeatLimit
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
using TeleWave.Domain.Media;
|
using TeleWave.Domain.Media;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
using TeleWave.Domain.Programming.Planning;
|
using TeleWave.Domain.Programming.Planning;
|
||||||
@@ -17,10 +18,13 @@ namespace TeleWave.Application.Programming.Planning;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GroupExpander(IAppDbContext dbContext)
|
public sealed class GroupExpander(IAppDbContext dbContext)
|
||||||
{
|
{
|
||||||
|
/// <param name="historyFrom">С какого момента нужна история показов для потолка повторов;
|
||||||
|
/// null — история не нужна и не загружается.</param>
|
||||||
public async Task<IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>>> ExpandAsync(
|
public async Task<IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>>> ExpandAsync(
|
||||||
IReadOnlyCollection<Guid> groupIds,
|
IReadOnlyCollection<Guid> groupIds,
|
||||||
Guid channelId,
|
Guid channelId,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken,
|
||||||
|
DateTimeOffset? historyFrom = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = new Dictionary<Guid, IReadOnlyList<PlanningElement>>();
|
var result = new Dictionary<Guid, IReadOnlyList<PlanningElement>>();
|
||||||
@@ -61,6 +65,7 @@ public sealed class GroupExpander(IAppDbContext dbContext)
|
|||||||
.Select(s => new
|
.Select(s => new
|
||||||
{
|
{
|
||||||
s.Id,
|
s.Id,
|
||||||
|
s.Audience,
|
||||||
Episodes = s
|
Episodes = s
|
||||||
.Episodes.OrderBy(e => e.Position)
|
.Episodes.OrderBy(e => e.Position)
|
||||||
.Select(e => new { e.MediaAssetId, e.Position })
|
.Select(e => new { e.MediaAssetId, e.Position })
|
||||||
@@ -78,6 +83,12 @@ public sealed class GroupExpander(IAppDbContext dbContext)
|
|||||||
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
|
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
|
||||||
|
|
||||||
var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken);
|
var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken);
|
||||||
|
var recentPlays = await LoadRecentPlaysAsync(
|
||||||
|
channelId,
|
||||||
|
showIds,
|
||||||
|
historyFrom,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
List<PlanningUnit> UnitsOfShow(Guid showId)
|
List<PlanningUnit> UnitsOfShow(Guid showId)
|
||||||
{
|
{
|
||||||
@@ -110,7 +121,9 @@ public sealed class GroupExpander(IAppDbContext dbContext)
|
|||||||
item.Weight,
|
item.Weight,
|
||||||
item.Position,
|
item.Position,
|
||||||
UnitsOfShow(item.ElementId),
|
UnitsOfShow(item.ElementId),
|
||||||
lastPlayed.TryGetValue(item.ElementId, out var played) ? played : null
|
lastPlayed.TryGetValue(item.ElementId, out var played) ? played : null,
|
||||||
|
episodes.TryGetValue(item.ElementId, out var show) ? show.Audience : null,
|
||||||
|
recentPlays.TryGetValue(item.ElementId, out var plays) ? plays : null
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@@ -136,7 +149,21 @@ public sealed class GroupExpander(IAppDbContext dbContext)
|
|||||||
.Select(id => lastPlayed.TryGetValue(id, out var p) ? p : (DateTimeOffset?)null)
|
.Select(id => lastPlayed.TryGetValue(id, out var p) ? p : (DateTimeOffset?)null)
|
||||||
.Where(p => p is not null)
|
.Where(p => p is not null)
|
||||||
.DefaultIfEmpty(null)
|
.DefaultIfEmpty(null)
|
||||||
.Max()
|
.Max(),
|
||||||
|
// Категория коллекции — строжайшая среди частей: франшиза идёт целиком,
|
||||||
|
// и одна взрослая часть делает взрослой всю.
|
||||||
|
partShowIds
|
||||||
|
.Select(id =>
|
||||||
|
episodes.TryGetValue(id, out var part) ? part.Audience : (ShowAudience?)null
|
||||||
|
)
|
||||||
|
.Where(a => a is not null)
|
||||||
|
.DefaultIfEmpty(null)
|
||||||
|
.Max(),
|
||||||
|
partShowIds
|
||||||
|
.SelectMany(id =>
|
||||||
|
recentPlays.TryGetValue(id, out var p) ? p : []
|
||||||
|
)
|
||||||
|
.ToList()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -168,4 +195,38 @@ public sealed class GroupExpander(IAppDbContext dbContext)
|
|||||||
.GroupBy(e => e.ShowId!.Value)
|
.GroupBy(e => e.ShowId!.Value)
|
||||||
.Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) })
|
.Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) })
|
||||||
.ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken);
|
.ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Старты показов за окно потолка повторов. Загружается только когда правило задано: без него
|
||||||
|
/// это лишние сотни строк на каждый прогон.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<Dictionary<Guid, IReadOnlyList<DateTimeOffset>>> LoadRecentPlaysAsync(
|
||||||
|
Guid channelId,
|
||||||
|
IReadOnlyCollection<Guid> showIds,
|
||||||
|
DateTimeOffset? from,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (from is not { } since || showIds.Count == 0)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var plays = await dbContext
|
||||||
|
.ScheduleEntries.AsNoTracking()
|
||||||
|
.Where(e =>
|
||||||
|
e.ChannelId == channelId
|
||||||
|
&& e.Kind == ScheduleEntryKind.Program
|
||||||
|
&& e.ShowId != null
|
||||||
|
&& showIds.Contains(e.ShowId!.Value)
|
||||||
|
&& e.StartsAtUtc >= since
|
||||||
|
)
|
||||||
|
.Select(e => new { ShowId = e.ShowId!.Value, e.StartsAtUtc })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return plays
|
||||||
|
.GroupBy(p => p.ShowId)
|
||||||
|
.ToDictionary(
|
||||||
|
g => g.Key,
|
||||||
|
g => (IReadOnlyList<DateTimeOffset>)g.Select(p => p.StartsAtUtc).ToList()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Programming.Templates;
|
||||||
|
using TeleWave.Domain.Programming.Planning;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Programming.Planning;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Пост-проверки по готовой ленте (см. 3.8, 5.2). Только предупреждения: пересборки слота при
|
||||||
|
/// нарушении нет намеренно — она сделала бы результат зависимым от порядка проверок и обесценила
|
||||||
|
/// бы трейс «почему это здесь».
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PostCheckRunner(IAppDbContext dbContext)
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<PlanningWarning>> RunAsync(
|
||||||
|
IReadOnlyList<PlannedItem> items,
|
||||||
|
PlanningRules? rules,
|
||||||
|
int utcOffsetMinutes,
|
||||||
|
TimeOnly dayStartTime,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (rules is null || items.Count == 0)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var warnings = new List<PlanningWarning>();
|
||||||
|
var offset = TimeSpan.FromMinutes(utcOffsetMinutes);
|
||||||
|
|
||||||
|
if (rules.MaxBreakMinutesPerHour is { } breakCap and > 0)
|
||||||
|
warnings.AddRange(CheckBreaks(items, offset, breakCap));
|
||||||
|
|
||||||
|
if (rules.MaxFallbackSharePercent is { } fallbackCap and > 0)
|
||||||
|
warnings.AddRange(CheckFallbackShare(items, fallbackCap));
|
||||||
|
|
||||||
|
if (rules.MaxGenreSharePercent is { } genreCap and > 0)
|
||||||
|
warnings.AddRange(
|
||||||
|
await CheckGenreShareAsync(items, offset, dayStartTime, genreCap, cancellationToken)
|
||||||
|
);
|
||||||
|
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<PlanningWarning> CheckBreaks(
|
||||||
|
IReadOnlyList<PlannedItem> items,
|
||||||
|
TimeSpan offset,
|
||||||
|
int capMinutes
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var byHour = items
|
||||||
|
.Where(IsBreak)
|
||||||
|
.GroupBy(i => Truncate(i.StartsAtUtc + offset, TimeSpan.FromHours(1)))
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
Hour = g.Key,
|
||||||
|
Minutes = g.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes),
|
||||||
|
})
|
||||||
|
.Where(x => x.Minutes > capMinutes)
|
||||||
|
.OrderBy(x => x.Hour);
|
||||||
|
|
||||||
|
foreach (var hour in byHour)
|
||||||
|
yield return new PlanningWarning(
|
||||||
|
PlanningWarningKind.BreakLimitExceeded,
|
||||||
|
null,
|
||||||
|
$"{hour.Hour:dd.MM HH:mm} — врезок {hour.Minutes:0} мин при потолке {capMinutes}."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<PlanningWarning> CheckFallbackShare(
|
||||||
|
IReadOnlyList<PlannedItem> items,
|
||||||
|
int capPercent
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var total = items.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes);
|
||||||
|
if (total <= 0)
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
// Конец вещания в долю фона не входит: это осознанная настройка, а не нехватка контента.
|
||||||
|
var fallback = items
|
||||||
|
.Where(i => i.Kind == PlannedItemKind.Fallback)
|
||||||
|
.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes);
|
||||||
|
|
||||||
|
var share = fallback / total * 100;
|
||||||
|
if (share > capPercent)
|
||||||
|
yield return new PlanningWarning(
|
||||||
|
PlanningWarningKind.FallbackShareExceeded,
|
||||||
|
null,
|
||||||
|
$"Фон занял {share:0}% эфира при норме {capPercent}% — контента не хватает."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Доля жанра за вещательные сутки. Считается по основному жанру шоу: у контента их бывает
|
||||||
|
/// несколько, но «доля боевиков за день» осмысленна только по одному, иначе сумма долей
|
||||||
|
/// перевалит за сто процентов и порог потеряет смысл.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<IReadOnlyList<PlanningWarning>> CheckGenreShareAsync(
|
||||||
|
IReadOnlyList<PlannedItem> items,
|
||||||
|
TimeSpan offset,
|
||||||
|
TimeOnly dayStartTime,
|
||||||
|
int capPercent,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var showIds = items
|
||||||
|
.Where(i => i.Kind == PlannedItemKind.Program && i.ShowId is not null)
|
||||||
|
.Select(i => i.ShowId!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
if (showIds.Count == 0)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var primaryGenre = await dbContext
|
||||||
|
.Shows.AsNoTracking()
|
||||||
|
.Where(s => showIds.Contains(s.Id))
|
||||||
|
.Select(s => new
|
||||||
|
{
|
||||||
|
s.Id,
|
||||||
|
GenreId = s.Genres.Where(g => g.IsPrimary).Select(g => (Guid?)g.GenreId).FirstOrDefault(),
|
||||||
|
})
|
||||||
|
.Where(s => s.GenreId != null)
|
||||||
|
.ToDictionaryAsync(s => s.Id, s => s.GenreId!.Value, cancellationToken);
|
||||||
|
if (primaryGenre.Count == 0)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var genreNames = await dbContext
|
||||||
|
.Genres.AsNoTracking()
|
||||||
|
.Where(g => primaryGenre.Values.Contains(g.Id))
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
|
||||||
|
|
||||||
|
var warnings = new List<PlanningWarning>();
|
||||||
|
var days = items
|
||||||
|
.Where(i => i.Kind == PlannedItemKind.Program && i.ShowId is not null)
|
||||||
|
.GroupBy(i => BroadcastDate(i.StartsAtUtc + offset, dayStartTime));
|
||||||
|
|
||||||
|
foreach (var day in days)
|
||||||
|
{
|
||||||
|
var total = day.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes);
|
||||||
|
if (total <= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var byGenre = day.Where(i => primaryGenre.ContainsKey(i.ShowId!.Value))
|
||||||
|
.GroupBy(i => primaryGenre[i.ShowId!.Value])
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
GenreId = g.Key,
|
||||||
|
Minutes = g.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes),
|
||||||
|
})
|
||||||
|
.Where(g => g.Minutes / total * 100 > capPercent);
|
||||||
|
|
||||||
|
foreach (var genre in byGenre)
|
||||||
|
warnings.Add(
|
||||||
|
new PlanningWarning(
|
||||||
|
PlanningWarningKind.GenreShareExceeded,
|
||||||
|
null,
|
||||||
|
$"{day.Key:dd.MM} — «{(genreNames.TryGetValue(genre.GenreId, out var name) ? name : genre.GenreId)}» "
|
||||||
|
+ $"занял {genre.Minutes / total * 100:0}% суток при норме {capPercent}%."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsBreak(PlannedItem item) =>
|
||||||
|
item.Kind is PlannedItemKind.Ad or PlannedItemKind.Promo or PlannedItemKind.Bumper;
|
||||||
|
|
||||||
|
private static DateTimeOffset Truncate(DateTimeOffset value, TimeSpan step) =>
|
||||||
|
value.AddTicks(-(value.Ticks % step.Ticks));
|
||||||
|
|
||||||
|
/// <summary>Вещательные сутки момента: ночь до <paramref name="dayStartTime"/> — вчерашний день.</summary>
|
||||||
|
private static DateOnly BroadcastDate(DateTimeOffset channelTime, TimeOnly dayStartTime) =>
|
||||||
|
DateOnly.FromDateTime(
|
||||||
|
TimeOnly.FromDateTime(channelTime.DateTime) >= dayStartTime
|
||||||
|
? channelTime.Date
|
||||||
|
: channelTime.Date.AddDays(-1)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Domain.Programming;
|
||||||
|
using TeleWave.Domain.Programming.Planning;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Programming.Planning.Trace;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Цепочка происхождения записи — «почему это здесь» (см. 6.5). Трейс пишется в момент генерации
|
||||||
|
/// и хранится в самой записи: восстановить его потом невозможно, состав групп и правила меняются.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record GetEntryTraceQuery(Guid EntryId) : IQuery<Result<EntryTraceDto>>;
|
||||||
|
|
||||||
|
public sealed record EntryTraceDto(
|
||||||
|
Guid EntryId,
|
||||||
|
DateTimeOffset StartsAtUtc,
|
||||||
|
DateTimeOffset EndsAtUtc,
|
||||||
|
string? ShowName,
|
||||||
|
int? EpisodeIndex,
|
||||||
|
/// <summary>Слой и слот, из которых выросла запись; null — трейс не писался (старая запись).</summary>
|
||||||
|
string? LayerName,
|
||||||
|
int? LayerPriority,
|
||||||
|
string? SlotTitle,
|
||||||
|
SlotKind? SlotKind,
|
||||||
|
int? SlotWeekday,
|
||||||
|
TimeOnly? SlotTargetStart,
|
||||||
|
int? SlotDurationMinutes,
|
||||||
|
string? GroupName,
|
||||||
|
int? GroupItemCount,
|
||||||
|
SlotStrategyKind? Strategy,
|
||||||
|
int? CooldownDays,
|
||||||
|
/// <summary>Сколько кандидатов осталось после остывания (null — выбор шёл без него).</summary>
|
||||||
|
int? CandidatesAfterCooldown,
|
||||||
|
int DriftMinutes,
|
||||||
|
bool Snapped,
|
||||||
|
string? JunctionName
|
||||||
|
);
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Broadcast;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Application.Programming.Templates;
|
||||||
|
using TeleWave.Domain.Programming;
|
||||||
|
using TeleWave.Domain.Programming.Planning;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Programming.Planning.Trace;
|
||||||
|
|
||||||
|
public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext)
|
||||||
|
: IQueryHandler<GetEntryTraceQuery, Result<EntryTraceDto>>
|
||||||
|
{
|
||||||
|
/// <summary>Те же настройки, что при записи трейса генератором.</summary>
|
||||||
|
private static readonly JsonSerializerOptions TraceJsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<Result<EntryTraceDto>> Handle(
|
||||||
|
GetEntryTraceQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var entry = await dbContext
|
||||||
|
.ScheduleEntries.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(e => e.Id == query.EntryId, cancellationToken);
|
||||||
|
if (entry is null)
|
||||||
|
return Result.Failure<EntryTraceDto>(ChannelErrors.NotFound);
|
||||||
|
|
||||||
|
var showName =
|
||||||
|
entry.ShowId is { } showId
|
||||||
|
? await dbContext
|
||||||
|
.Shows.AsNoTracking()
|
||||||
|
.Where(s => s.Id == showId)
|
||||||
|
.Select(s => s.Name)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var trace = Parse(entry.TraceJson);
|
||||||
|
if (trace is null)
|
||||||
|
return Result.Success(
|
||||||
|
new EntryTraceDto(
|
||||||
|
entry.Id,
|
||||||
|
entry.StartsAtUtc,
|
||||||
|
entry.EndsAtUtc,
|
||||||
|
showName,
|
||||||
|
entry.EpisodeIndex,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Слот мог быть удалён или изменён после генерации — трейс от этого не портится, просто
|
||||||
|
// часть подписей окажется пустой.
|
||||||
|
var slot = trace.SlotId is { } slotId
|
||||||
|
? await dbContext.Slots.AsNoTracking().FirstOrDefaultAsync(s => s.Id == slotId, cancellationToken)
|
||||||
|
: null;
|
||||||
|
var layer = slot is null
|
||||||
|
? null
|
||||||
|
: await dbContext
|
||||||
|
.GridLayers.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(l => l.Id == slot.LayerId, cancellationToken);
|
||||||
|
|
||||||
|
var group = slot?.GroupId is { } groupId
|
||||||
|
? await dbContext
|
||||||
|
.Groups.AsNoTracking()
|
||||||
|
.Where(g => g.Id == groupId)
|
||||||
|
.Select(g => new { g.Name, g.ItemCount })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var strategy = SlotStrategy.FromJson(slot?.StrategyJson);
|
||||||
|
|
||||||
|
var junctionId = slot?.JunctionAfterId ?? slot?.JunctionBetweenId;
|
||||||
|
var junctionName = junctionId is { } id
|
||||||
|
? await dbContext
|
||||||
|
.JunctionTemplates.AsNoTracking()
|
||||||
|
.Where(j => j.Id == id)
|
||||||
|
.Select(j => j.Name)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return Result.Success(
|
||||||
|
new EntryTraceDto(
|
||||||
|
entry.Id,
|
||||||
|
entry.StartsAtUtc,
|
||||||
|
entry.EndsAtUtc,
|
||||||
|
showName,
|
||||||
|
entry.EpisodeIndex,
|
||||||
|
layer?.Name,
|
||||||
|
layer?.Priority,
|
||||||
|
slot?.Title,
|
||||||
|
trace.SlotKind,
|
||||||
|
slot?.Weekday,
|
||||||
|
slot?.TargetStart,
|
||||||
|
slot?.TargetDurationMinutes,
|
||||||
|
group?.Name,
|
||||||
|
group?.ItemCount,
|
||||||
|
trace.Strategy,
|
||||||
|
strategy?.CooldownDays,
|
||||||
|
trace.CandidatesAfterCooldown,
|
||||||
|
trace.DriftMinutes,
|
||||||
|
trace.Snapped,
|
||||||
|
junctionName
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlanTrace? Parse(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<PlanTrace>(json, TraceJsonOptions);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Programming.Templates.CopyTemplate;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Копирует сетку канала на другой канал: слои, слоты, стыки и правила. Группы не копируются —
|
||||||
|
/// они общие для всех каналов. Прежний шаблон канала-приёмника заменяется целиком.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CopyTemplateCommand(Guid SourceChannelId, Guid TargetChannelId)
|
||||||
|
: ICommand<Result<CopyTemplateResultDto>>;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Что скопировалось. <paramref name="DroppedBumperRefs"/> — врезки-заставки, для которых на канале
|
||||||
|
/// -приёмнике не нашлось блока с таким же именем: ссылка снята, врезку надо донастроить руками.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CopyTemplateResultDto(
|
||||||
|
int Layers,
|
||||||
|
int Slots,
|
||||||
|
int Junctions,
|
||||||
|
int DroppedBumperRefs
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed class CopyTemplateCommandValidator : AbstractValidator<CopyTemplateCommand>
|
||||||
|
{
|
||||||
|
public CopyTemplateCommandValidator() =>
|
||||||
|
RuleFor(x => x.TargetChannelId)
|
||||||
|
.NotEqual(x => x.SourceChannelId)
|
||||||
|
.WithMessage("Копировать сетку саму на себя бессмысленно.");
|
||||||
|
}
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Broadcast;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Domain.Programming;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Programming.Templates.CopyTemplate;
|
||||||
|
|
||||||
|
public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
|
||||||
|
: ICommandHandler<CopyTemplateCommand, Result<CopyTemplateResultDto>>
|
||||||
|
{
|
||||||
|
public async Task<Result<CopyTemplateResultDto>> Handle(
|
||||||
|
CopyTemplateCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var target = await dbContext
|
||||||
|
.Channels.Include(c => c.BumperTemplates)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == command.TargetChannelId, cancellationToken);
|
||||||
|
if (target is null)
|
||||||
|
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.NotFound);
|
||||||
|
|
||||||
|
var source = await dbContext
|
||||||
|
.ScheduleTemplates.AsNoTracking()
|
||||||
|
.Include(t => t.Layers)
|
||||||
|
.ThenInclude(l => l.Slots)
|
||||||
|
.FirstOrDefaultAsync(t => t.ChannelId == command.SourceChannelId, cancellationToken);
|
||||||
|
if (source is null)
|
||||||
|
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.TemplateNotFound);
|
||||||
|
|
||||||
|
var sourceJunctions = await dbContext
|
||||||
|
.JunctionTemplates.AsNoTracking()
|
||||||
|
.Include(j => j.Elements)
|
||||||
|
.Where(j => j.ChannelId == command.SourceChannelId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Заставки живут на канале и на диске, поэтому не копируются: врезка ищет блок с таким же
|
||||||
|
// именем у приёмника, а не найдя — остаётся без ссылки, и это возвращается в отчёте.
|
||||||
|
var bumperByName = target
|
||||||
|
.BumperTemplates.GroupBy(t => t.Name)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||||
|
var sourceBumperNames = await dbContext
|
||||||
|
.Channels.AsNoTracking()
|
||||||
|
.Where(c => c.Id == command.SourceChannelId)
|
||||||
|
.SelectMany(c => c.BumperTemplates)
|
||||||
|
.Select(t => new { t.Id, t.Name })
|
||||||
|
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
|
||||||
|
|
||||||
|
var junctionMap = new Dictionary<Guid, Guid>();
|
||||||
|
var droppedBumperRefs = 0;
|
||||||
|
|
||||||
|
foreach (var junction in sourceJunctions)
|
||||||
|
{
|
||||||
|
var copy = JunctionTemplate.Create(target.Id, junction.Name);
|
||||||
|
junctionMap[junction.Id] = copy.Id;
|
||||||
|
|
||||||
|
foreach (var element in junction.Elements.OrderBy(e => e.Position))
|
||||||
|
{
|
||||||
|
var created = copy.AddElement(element.Kind);
|
||||||
|
|
||||||
|
Guid? bumperTemplateId = null;
|
||||||
|
if (element.Kind == JunctionElementKind.Bumper)
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
element.BumperTemplateId is { } sourceId
|
||||||
|
&& sourceBumperNames.TryGetValue(sourceId, out var name)
|
||||||
|
&& bumperByName.TryGetValue(name, out var mapped)
|
||||||
|
)
|
||||||
|
bumperTemplateId = mapped;
|
||||||
|
else
|
||||||
|
droppedBumperRefs++;
|
||||||
|
}
|
||||||
|
|
||||||
|
created.Update(
|
||||||
|
element.Kind,
|
||||||
|
element.GroupId,
|
||||||
|
bumperTemplateId,
|
||||||
|
element.AmountMode,
|
||||||
|
element.AmountValue,
|
||||||
|
element.IsRequired,
|
||||||
|
element.ConditionsJson
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
dbContext.JunctionTemplates.Add(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя,
|
||||||
|
// а «добавить поверх» дало бы кашу из пересекающихся слотов.
|
||||||
|
var existing = await dbContext
|
||||||
|
.ScheduleTemplates.Where(t => t.ChannelId == target.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
dbContext.ScheduleTemplates.RemoveRange(existing);
|
||||||
|
await dbContext
|
||||||
|
.JunctionTemplates.Where(j =>
|
||||||
|
j.ChannelId == target.Id && !junctionMap.Values.Contains(j.Id)
|
||||||
|
)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
|
||||||
|
var copyTemplate = ScheduleTemplate.Create(target.Id, source.Name);
|
||||||
|
copyTemplate.SetFallbackGroup(source.FallbackGroupId);
|
||||||
|
copyTemplate.SetRules(source.RulesJson);
|
||||||
|
if (source.DefaultJunctionId is { } defaultJunction && junctionMap.TryGetValue(defaultJunction, out var mappedDefault))
|
||||||
|
copyTemplate.SetDefaultJunction(mappedDefault);
|
||||||
|
|
||||||
|
var layers = 0;
|
||||||
|
var slots = 0;
|
||||||
|
|
||||||
|
foreach (var layer in source.Layers.OrderByDescending(l => l.Priority))
|
||||||
|
{
|
||||||
|
// Фоновый слой у нового шаблона уже есть — в него переносим слоты, а не заводим второй.
|
||||||
|
var copyLayer = layer.IsBackground
|
||||||
|
? copyTemplate.Background!
|
||||||
|
: copyTemplate.AddLayer(layer.Name, layer.Priority);
|
||||||
|
copyLayer.Update(layer.Name, layer.Priority, layer.ApplicabilityJson, layer.IsEnabled);
|
||||||
|
if (!layer.IsBackground)
|
||||||
|
layers++;
|
||||||
|
|
||||||
|
foreach (var slot in layer.Slots)
|
||||||
|
{
|
||||||
|
var copySlot = copyLayer.AddSlot(
|
||||||
|
slot.Title,
|
||||||
|
slot.TargetStart,
|
||||||
|
slot.TargetDurationMinutes,
|
||||||
|
slot.Daypart,
|
||||||
|
slot.SlotKind,
|
||||||
|
slot.Weekday
|
||||||
|
);
|
||||||
|
copySlot.UpdateTiming(
|
||||||
|
slot.Weekday,
|
||||||
|
slot.TargetStart,
|
||||||
|
slot.TargetDurationMinutes,
|
||||||
|
slot.Daypart,
|
||||||
|
slot.IsAnchor,
|
||||||
|
slot.MaxDriftMinutes,
|
||||||
|
slot.SnapToMinutes
|
||||||
|
);
|
||||||
|
copySlot.UpdateContent(
|
||||||
|
slot.Title,
|
||||||
|
slot.SlotKind,
|
||||||
|
slot.GroupId,
|
||||||
|
slot.StrategyJson,
|
||||||
|
slot.RepeatSourceJson,
|
||||||
|
slot.BlockMode,
|
||||||
|
slot.BlockValue,
|
||||||
|
slot.OverflowPolicy,
|
||||||
|
Map(slot.JunctionBetweenId, junctionMap),
|
||||||
|
Map(slot.JunctionAfterId, junctionMap)
|
||||||
|
);
|
||||||
|
slots++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dbContext.ScheduleTemplates.Add(copyTemplate);
|
||||||
|
target.SetTemplate(copyTemplate.Id);
|
||||||
|
|
||||||
|
return Result.Success(
|
||||||
|
new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid? Map(Guid? id, IReadOnlyDictionary<Guid, Guid> map) =>
|
||||||
|
id is { } value && map.TryGetValue(value, out var mapped) ? mapped : null;
|
||||||
|
}
|
||||||
+1
@@ -88,6 +88,7 @@ public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext)
|
|||||||
template.Name,
|
template.Name,
|
||||||
template.FallbackGroupId,
|
template.FallbackGroupId,
|
||||||
template.DefaultJunctionId,
|
template.DefaultJunctionId,
|
||||||
|
PlanningRules.FromJson(template.RulesJson),
|
||||||
template.Revision,
|
template.Revision,
|
||||||
template.AppliedRevision,
|
template.AppliedRevision,
|
||||||
template.HasPendingChanges,
|
template.HasPendingChanges,
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext)
|
|||||||
template.Rename(command.Name);
|
template.Rename(command.Name);
|
||||||
template.SetFallbackGroup(command.FallbackGroupId);
|
template.SetFallbackGroup(command.FallbackGroupId);
|
||||||
template.SetDefaultJunction(command.DefaultJunctionId);
|
template.SetDefaultJunction(command.DefaultJunctionId);
|
||||||
|
template.SetRules(command.Rules?.ToJson());
|
||||||
template.MarkChanged();
|
template.MarkChanged();
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ public sealed record UpdateLayerCommand(
|
|||||||
|
|
||||||
public sealed record DeleteLayerCommand(Guid LayerId) : ICommand<Result>;
|
public sealed record DeleteLayerCommand(Guid LayerId) : ICommand<Result>;
|
||||||
|
|
||||||
/// <summary>Имя шаблона и аварийная группа, играющая, когда пуст даже фоновый слой.</summary>
|
/// <summary>Имя шаблона, аварийная группа, стык по умолчанию и правила отбора кандидатов.</summary>
|
||||||
public sealed record UpdateTemplateCommand(
|
public sealed record UpdateTemplateCommand(
|
||||||
Guid TemplateId,
|
Guid TemplateId,
|
||||||
string Name,
|
string Name,
|
||||||
Guid? FallbackGroupId,
|
Guid? FallbackGroupId,
|
||||||
Guid? DefaultJunctionId
|
Guid? DefaultJunctionId,
|
||||||
|
PlanningRules? Rules
|
||||||
) : ICommand<Result>;
|
) : ICommand<Result>;
|
||||||
|
|
||||||
public sealed class CreateLayerCommandValidator : AbstractValidator<CreateLayerCommand>
|
public sealed class CreateLayerCommandValidator : AbstractValidator<CreateLayerCommand>
|
||||||
@@ -45,5 +46,22 @@ public sealed class UpdateLayerCommandValidator : AbstractValidator<UpdateLayerC
|
|||||||
|
|
||||||
public sealed class UpdateTemplateCommandValidator : AbstractValidator<UpdateTemplateCommand>
|
public sealed class UpdateTemplateCommandValidator : AbstractValidator<UpdateTemplateCommand>
|
||||||
{
|
{
|
||||||
public UpdateTemplateCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
public UpdateTemplateCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||||
|
|
||||||
|
// Год — верхняя граница окна повторов: за ним правило перестаёт что-либо значить, а история
|
||||||
|
// всё равно ограничена глубиной хранения ленты.
|
||||||
|
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.WindowDays)
|
||||||
|
.InclusiveBetween(1, 365)
|
||||||
|
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
|
||||||
|
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.Max)
|
||||||
|
.InclusiveBetween(1, 1000)
|
||||||
|
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
|
||||||
|
|
||||||
|
RuleForEach(x => x.Rules!.MaxAudienceByTime)
|
||||||
|
.Must(window => window.From != window.To)
|
||||||
|
.WithMessage("Окно нулевой длины ничего не ограничивает.")
|
||||||
|
.When(x => x.Rules?.MaxAudienceByTime is not null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Programming.Templates;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Окно детского времени: с <paramref name="From"/> до <paramref name="To"/> во времени канала
|
||||||
|
/// в эфир идёт только контент не строже <paramref name="MaxAudience"/>. Окно может переходить
|
||||||
|
/// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AudienceWindow(TimeOnly From, TimeOnly To, ShowAudience MaxAudience)
|
||||||
|
{
|
||||||
|
public bool Contains(TimeOnly moment) =>
|
||||||
|
From <= To ? moment >= From && moment < To : moment >= From || moment < To;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают
|
||||||
|
/// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость.
|
||||||
|
///
|
||||||
|
/// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени
|
||||||
|
/// и так задаются временем, а потолок повторов осмыслен только целиком по каналу.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PlanningRules(
|
||||||
|
IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null,
|
||||||
|
/// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary>
|
||||||
|
RepeatLimitRule? MaxRepeatsInWindow = null,
|
||||||
|
// ── Пост-проверки: считаются по готовой ленте, дают предупреждения и ничего не переигрывают. ──
|
||||||
|
/// <summary>Потолок врезок в часе, минуты (null — не проверять).</summary>
|
||||||
|
int? MaxBreakMinutesPerHour = null,
|
||||||
|
/// <summary>Потолок доли одного жанра за вещательные сутки, проценты (null — не проверять).</summary>
|
||||||
|
int? MaxGenreSharePercent = null,
|
||||||
|
/// <summary>Потолок доли эфира, отданной фону, проценты (null — не проверять).</summary>
|
||||||
|
int? MaxFallbackSharePercent = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions Options = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Возрастной потолок в указанный момент времени канала (null — без ограничения).</summary>
|
||||||
|
public ShowAudience? AudienceAt(TimeOnly moment)
|
||||||
|
{
|
||||||
|
if (MaxAudienceByTime is not { Count: > 0 } windows)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Пересекающиеся окна разрешаются в пользу строгого: детское время не должно
|
||||||
|
// отменяться более широким окном, случайно наложенным сверху.
|
||||||
|
ShowAudience? strictest = null;
|
||||||
|
foreach (var window in windows.Where(w => w.Contains(moment)))
|
||||||
|
strictest = strictest is { } current && current <= window.MaxAudience
|
||||||
|
? current
|
||||||
|
: window.MaxAudience;
|
||||||
|
return strictest;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||||
|
|
||||||
|
public static PlanningRules? FromJson(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<PlanningRules>(json, Options);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record RepeatLimitRule(int WindowDays, int Max);
|
||||||
@@ -41,6 +41,8 @@ public sealed record ScheduleTemplateDto(
|
|||||||
string Name,
|
string Name,
|
||||||
Guid? FallbackGroupId,
|
Guid? FallbackGroupId,
|
||||||
Guid? DefaultJunctionId,
|
Guid? DefaultJunctionId,
|
||||||
|
/// <summary>Правила отбора кандидатов канала: детское время и потолок повторов.</summary>
|
||||||
|
PlanningRules? Rules,
|
||||||
int Revision,
|
int Revision,
|
||||||
int AppliedRevision,
|
int AppliedRevision,
|
||||||
/// <summary>Есть ли правки правил, ещё не применённые к эфиру.</summary>
|
/// <summary>Есть ли правки правил, ещё не применённые к эфиру.</summary>
|
||||||
|
|||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Programming.Templates.Validate;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Проверки сетки по правилам, до генерации (см. 5.1). Ничего не считает по ленте — только по
|
||||||
|
/// шаблону и статистике групп, поэтому дёшево и вызывается на каждом открытии редактора.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ValidateTemplateQuery(Guid ChannelId)
|
||||||
|
: IQuery<Result<IReadOnlyList<TemplateIssueDto>>>;
|
||||||
|
|
||||||
|
public enum TemplateIssueKind
|
||||||
|
{
|
||||||
|
/// <summary>Слот ссылается на пустую группу — место закроет фон.</summary>
|
||||||
|
GroupEmpty = 0,
|
||||||
|
|
||||||
|
/// <summary>Позиций в группе меньше, чем выходов слота в неделю.</summary>
|
||||||
|
GroupTooSmall = 1,
|
||||||
|
|
||||||
|
/// <summary>Интервал суток не покрыт ни одним слотом, даже фоновым.</summary>
|
||||||
|
GridGap = 2,
|
||||||
|
|
||||||
|
/// <summary>Два слота одного слоя перекрываются — сыграет только первый.</summary>
|
||||||
|
SlotOverlap = 3,
|
||||||
|
|
||||||
|
/// <summary>Остывание длиннее, чем группа успевает прокрутиться.</summary>
|
||||||
|
CooldownUnreachable = 4,
|
||||||
|
|
||||||
|
/// <summary>В группе есть контент строже, чем разрешает окно детского времени.</summary>
|
||||||
|
AudienceConflict = 5,
|
||||||
|
|
||||||
|
/// <summary>Слот без группы: контент брать неоткуда.</summary>
|
||||||
|
GroupMissing = 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Насколько это больно: <c>Error</c> — в эфире будет фон вместо контента.</summary>
|
||||||
|
public enum TemplateIssueSeverity
|
||||||
|
{
|
||||||
|
Warning = 0,
|
||||||
|
Error = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record TemplateIssueDto(
|
||||||
|
TemplateIssueKind Kind,
|
||||||
|
TemplateIssueSeverity Severity,
|
||||||
|
Guid? LayerId,
|
||||||
|
Guid? SlotId,
|
||||||
|
string Details
|
||||||
|
);
|
||||||
+306
@@ -0,0 +1,306 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Broadcast;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
using TeleWave.Domain.Programming;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Programming.Templates.Validate;
|
||||||
|
|
||||||
|
public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
|
||||||
|
: IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
|
||||||
|
{
|
||||||
|
private const int MinutesInDay = 24 * 60;
|
||||||
|
|
||||||
|
public async Task<Result<IReadOnlyList<TemplateIssueDto>>> Handle(
|
||||||
|
ValidateTemplateQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var channel = await dbContext
|
||||||
|
.Channels.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
||||||
|
if (channel is null)
|
||||||
|
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.NotFound);
|
||||||
|
|
||||||
|
var template = await dbContext
|
||||||
|
.ScheduleTemplates.AsNoTracking()
|
||||||
|
.Include(t => t.Layers)
|
||||||
|
.ThenInclude(l => l.Slots)
|
||||||
|
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
|
||||||
|
if (template is null)
|
||||||
|
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.TemplateNotFound);
|
||||||
|
|
||||||
|
var layers = template.Layers.Where(l => l.IsEnabled).ToList();
|
||||||
|
var groupIds = layers
|
||||||
|
.SelectMany(l => l.Slots)
|
||||||
|
.Select(s => s.GroupId)
|
||||||
|
.Where(id => id is not null)
|
||||||
|
.Select(id => id!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var groups = await dbContext
|
||||||
|
.Groups.AsNoTracking()
|
||||||
|
.Where(g => groupIds.Contains(g.Id))
|
||||||
|
.Select(g => new GroupStats(g.Id, g.Name, g.ItemCount))
|
||||||
|
.ToDictionaryAsync(g => g.Id, cancellationToken);
|
||||||
|
|
||||||
|
var rules = PlanningRules.FromJson(template.RulesJson);
|
||||||
|
var strictest = await LoadStrictestAudienceAsync(groupIds, cancellationToken);
|
||||||
|
var dayStart = channel.DayStartTime;
|
||||||
|
|
||||||
|
var issues = new List<TemplateIssueDto>();
|
||||||
|
|
||||||
|
foreach (var layer in layers)
|
||||||
|
{
|
||||||
|
issues.AddRange(FindOverlaps(layer, dayStart));
|
||||||
|
|
||||||
|
foreach (var slot in layer.Slots.Where(s => s.SlotKind == SlotKind.Content))
|
||||||
|
issues.AddRange(CheckSlot(layer, slot, groups, strictest, rules));
|
||||||
|
}
|
||||||
|
|
||||||
|
issues.AddRange(FindGaps(layers, dayStart));
|
||||||
|
|
||||||
|
return Result.Success<IReadOnlyList<TemplateIssueDto>>(issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record GroupStats(Guid Id, string Name, int ItemCount);
|
||||||
|
|
||||||
|
private static IEnumerable<TemplateIssueDto> CheckSlot(
|
||||||
|
GridLayer layer,
|
||||||
|
Slot slot,
|
||||||
|
IReadOnlyDictionary<Guid, GroupStats> groups,
|
||||||
|
IReadOnlyDictionary<Guid, ShowAudience> strictest,
|
||||||
|
PlanningRules? rules
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (slot.GroupId is not { } groupId || !groups.TryGetValue(groupId, out var group))
|
||||||
|
{
|
||||||
|
yield return new TemplateIssueDto(
|
||||||
|
TemplateIssueKind.GroupMissing,
|
||||||
|
TemplateIssueSeverity.Error,
|
||||||
|
layer.Id,
|
||||||
|
slot.Id,
|
||||||
|
$"У слота «{slot.Title}» не выбрана группа — место закроет фон."
|
||||||
|
);
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.ItemCount == 0)
|
||||||
|
{
|
||||||
|
yield return new TemplateIssueDto(
|
||||||
|
TemplateIssueKind.GroupEmpty,
|
||||||
|
TemplateIssueSeverity.Error,
|
||||||
|
layer.Id,
|
||||||
|
slot.Id,
|
||||||
|
$"Группа «{group.Name}» пуста — слот «{slot.Title}» заполнит фон."
|
||||||
|
);
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var perWeek = OccurrencesPerWeek(slot);
|
||||||
|
if (group.ItemCount < perWeek)
|
||||||
|
yield return new TemplateIssueDto(
|
||||||
|
TemplateIssueKind.GroupTooSmall,
|
||||||
|
TemplateIssueSeverity.Warning,
|
||||||
|
layer.Id,
|
||||||
|
slot.Id,
|
||||||
|
$"В группе «{group.Name}» {group.ItemCount} позиций при {perWeek} выходах в неделю — "
|
||||||
|
+ "повторы пойдут чаще, чем раз в неделю."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Остывание считается по числу выходов: за N дней слот выйдет N × (выходов в день) раз,
|
||||||
|
// и если это больше состава группы, отсекать будет некого.
|
||||||
|
var strategy = SlotStrategy.FromJson(slot.StrategyJson);
|
||||||
|
if (
|
||||||
|
strategy is { Type: SlotStrategyType.RandomWithCooldown, CooldownDays: > 0 }
|
||||||
|
&& strategy.CooldownDays * perWeek / 7.0 > group.ItemCount
|
||||||
|
)
|
||||||
|
yield return new TemplateIssueDto(
|
||||||
|
TemplateIssueKind.CooldownUnreachable,
|
||||||
|
TemplateIssueSeverity.Warning,
|
||||||
|
layer.Id,
|
||||||
|
slot.Id,
|
||||||
|
$"Остывание {strategy.CooldownDays} дней невыполнимо при {group.ItemCount} позициях "
|
||||||
|
+ $"в группе «{group.Name}»."
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
rules?.AudienceAt(slot.TargetStart) is { } maxAudience
|
||||||
|
&& strictest.TryGetValue(groupId, out var groupAudience)
|
||||||
|
&& groupAudience > maxAudience
|
||||||
|
)
|
||||||
|
yield return new TemplateIssueDto(
|
||||||
|
TemplateIssueKind.AudienceConflict,
|
||||||
|
TemplateIssueSeverity.Warning,
|
||||||
|
layer.Id,
|
||||||
|
slot.Id,
|
||||||
|
$"В группе «{group.Name}» есть контент категории «{groupAudience}», а слот "
|
||||||
|
+ $"«{slot.Title}» стоит во времени не строже «{maxAudience}»."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Сколько раз слот выходит в неделю: без дня недели — каждый день.</summary>
|
||||||
|
private static int OccurrencesPerWeek(Slot slot) => slot.Weekday is null ? 7 : 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Пересечения слотов внутри одного слоя. Внутри слоя приоритетов нет, поэтому сыграет первый
|
||||||
|
/// по времени, а второй молча пропадёт — это стоит показать до генерации.
|
||||||
|
/// </summary>
|
||||||
|
private static IEnumerable<TemplateIssueDto> FindOverlaps(GridLayer layer, TimeOnly dayStart)
|
||||||
|
{
|
||||||
|
var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList();
|
||||||
|
|
||||||
|
for (var i = 0; i < slots.Count; i++)
|
||||||
|
for (var j = i + 1; j < slots.Count; j++)
|
||||||
|
{
|
||||||
|
var a = slots[i];
|
||||||
|
var b = slots[j];
|
||||||
|
if (!SameDays(a, b))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var aFrom = OffsetInDay(a.TargetStart, dayStart);
|
||||||
|
var bFrom = OffsetInDay(b.TargetStart, dayStart);
|
||||||
|
if (aFrom < bFrom + b.TargetDurationMinutes && bFrom < aFrom + a.TargetDurationMinutes)
|
||||||
|
yield return new TemplateIssueDto(
|
||||||
|
TemplateIssueKind.SlotOverlap,
|
||||||
|
TemplateIssueSeverity.Warning,
|
||||||
|
layer.Id,
|
||||||
|
b.Id,
|
||||||
|
$"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Слот без дня недели идёт каждый день, поэтому пересекается с любым.</summary>
|
||||||
|
private static bool SameDays(Slot a, Slot b) =>
|
||||||
|
a.Weekday is null || b.Weekday is null || a.Weekday == b.Weekday;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Интервалы вещательных суток, не покрытые ни одним слотом. Проверяется по каждому дню недели:
|
||||||
|
/// дыра во вторник ночью не видна, если смотреть на неделю целиком.
|
||||||
|
/// </summary>
|
||||||
|
private static IEnumerable<TemplateIssueDto> FindGaps(
|
||||||
|
IReadOnlyList<GridLayer> layers,
|
||||||
|
TimeOnly dayStart
|
||||||
|
)
|
||||||
|
{
|
||||||
|
foreach (var weekday in new[] { 1, 2, 3, 4, 5, 6, 0 })
|
||||||
|
{
|
||||||
|
var intervals = layers
|
||||||
|
.SelectMany(l => l.Slots)
|
||||||
|
.Where(s => s.Weekday is null || s.Weekday == weekday)
|
||||||
|
.Select(s =>
|
||||||
|
{
|
||||||
|
var from = OffsetInDay(s.TargetStart, dayStart);
|
||||||
|
return (From: from, To: from + s.TargetDurationMinutes);
|
||||||
|
})
|
||||||
|
.OrderBy(i => i.From)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var cursor = 0;
|
||||||
|
foreach (var interval in intervals)
|
||||||
|
{
|
||||||
|
if (interval.From > cursor)
|
||||||
|
yield return Gap(weekday, cursor, interval.From, dayStart);
|
||||||
|
cursor = Math.Max(cursor, Math.Min(interval.To, MinutesInDay));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor < MinutesInDay)
|
||||||
|
yield return Gap(weekday, cursor, MinutesInDay, dayStart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TemplateIssueDto Gap(int weekday, int from, int to, TimeOnly dayStart)
|
||||||
|
{
|
||||||
|
var day = new[] { "вс", "пн", "вт", "ср", "чт", "пт", "сб" }[weekday];
|
||||||
|
return new TemplateIssueDto(
|
||||||
|
TemplateIssueKind.GridGap,
|
||||||
|
TemplateIssueSeverity.Error,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
$"Не покрыто: {day} {Clock(from, dayStart)}–{Clock(to, dayStart)}."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Clock(int offsetMinutes, TimeOnly dayStart)
|
||||||
|
{
|
||||||
|
var minutes = ((int)dayStart.ToTimeSpan().TotalMinutes + offsetMinutes) % MinutesInDay;
|
||||||
|
return $"{minutes / 60:00}:{minutes % 60:00}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int OffsetInDay(TimeOnly time, TimeOnly dayStart)
|
||||||
|
{
|
||||||
|
var diff = (int)(time.ToTimeSpan() - dayStart.ToTimeSpan()).TotalMinutes;
|
||||||
|
return diff >= 0 ? diff : diff + MinutesInDay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Строжайшая категория среди позиций каждой группы: именно она конфликтует с детским временем.
|
||||||
|
/// Коллекция берётся по строжайшей части — франшиза идёт целиком.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
|
||||||
|
IReadOnlyCollection<Guid> groupIds,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (groupIds.Count == 0)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var items = await dbContext
|
||||||
|
.GroupItems.AsNoTracking()
|
||||||
|
.Where(i => groupIds.Contains(i.GroupId))
|
||||||
|
.Select(i => new
|
||||||
|
{
|
||||||
|
i.GroupId,
|
||||||
|
i.ElementKind,
|
||||||
|
i.ElementId,
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var showIds = items
|
||||||
|
.Where(i => i.ElementKind == GroupElementKind.Show)
|
||||||
|
.Select(i => i.ElementId)
|
||||||
|
.ToList();
|
||||||
|
var collectionIds = items
|
||||||
|
.Where(i => i.ElementKind == GroupElementKind.Collection)
|
||||||
|
.Select(i => i.ElementId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var partsByCollection = await dbContext
|
||||||
|
.CollectionItems.AsNoTracking()
|
||||||
|
.Where(i => collectionIds.Contains(i.CollectionId))
|
||||||
|
.Select(i => new { i.CollectionId, i.ShowId })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var audiences = await dbContext
|
||||||
|
.Shows.AsNoTracking()
|
||||||
|
.Where(s => showIds.Contains(s.Id) || partsByCollection.Select(p => p.ShowId).Contains(s.Id))
|
||||||
|
.Select(s => new { s.Id, s.Audience })
|
||||||
|
.ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken);
|
||||||
|
|
||||||
|
var result = new Dictionary<Guid, ShowAudience>();
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
var candidates =
|
||||||
|
item.ElementKind == GroupElementKind.Show
|
||||||
|
? [item.ElementId]
|
||||||
|
: partsByCollection
|
||||||
|
.Where(p => p.CollectionId == item.ElementId)
|
||||||
|
.Select(p => p.ShowId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var showId in candidates)
|
||||||
|
{
|
||||||
|
if (!audiences.TryGetValue(showId, out var audience))
|
||||||
|
continue;
|
||||||
|
if (!result.TryGetValue(item.GroupId, out var current) || audience > current)
|
||||||
|
result[item.GroupId] = audience;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -13,6 +13,7 @@ public sealed class GetSiteSettingsQueryHandler(ISiteSettings siteSettings)
|
|||||||
{
|
{
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,8 @@ public static class SettingKeys
|
|||||||
/// <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
|
||||||
|
);
|
||||||
|
|||||||
+2
-1
@@ -5,5 +5,6 @@ namespace TeleWave.Application.Settings.UpdateSiteSettings;
|
|||||||
|
|
||||||
public sealed record UpdateSiteSettingsCommand(
|
public sealed record UpdateSiteSettingsCommand(
|
||||||
bool RegistrationEnabled,
|
bool RegistrationEnabled,
|
||||||
string PreferredAudioLanguages
|
string PreferredAudioLanguages,
|
||||||
|
bool ChannelNumbersEnabled
|
||||||
) : ICommand<Result>;
|
) : ICommand<Result>;
|
||||||
|
|||||||
+4
@@ -25,6 +25,10 @@ public sealed class UpdateSiteSettingsCommandHandler(ISiteSettings siteSettings)
|
|||||||
.Select(x => x.ToLowerInvariant())
|
.Select(x => x.ToLowerInvariant())
|
||||||
);
|
);
|
||||||
await siteSettings.SetPreferredAudioLanguagesAsync(languages, cancellationToken);
|
await siteSettings.SetPreferredAudioLanguagesAsync(languages, cancellationToken);
|
||||||
|
await siteSettings.SetChannelNumbersEnabledAsync(
|
||||||
|
command.ChannelNumbersEnabled,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-2
@@ -16,12 +16,22 @@ public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
|
|||||||
var channels = await dbContext
|
var channels = await dbContext
|
||||||
.Channels.AsNoTracking()
|
.Channels.AsNoTracking()
|
||||||
.Where(c => c.IsEnabled)
|
.Where(c => c.IsEnabled)
|
||||||
.OrderBy(c => c.Name)
|
// Каналы без номера — в конец: на телевизоре порядок задаёт номер, а имя лишь
|
||||||
|
// разрешает ничью между ненумерованными.
|
||||||
|
.OrderBy(c => c.Number == null)
|
||||||
|
.ThenBy(c => c.Number)
|
||||||
|
.ThenBy(c => c.Name)
|
||||||
.Select(c => new
|
.Select(c => new
|
||||||
{
|
{
|
||||||
c.Id,
|
c.Id,
|
||||||
c.Slug,
|
c.Slug,
|
||||||
c.Name,
|
c.Name,
|
||||||
|
c.Number,
|
||||||
|
c.LogoImageId,
|
||||||
|
c.LogoCorner,
|
||||||
|
c.LogoOpacity,
|
||||||
|
c.ShowClock,
|
||||||
|
c.AnalogFilterStrength,
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
if (channels.Count == 0)
|
if (channels.Count == 0)
|
||||||
@@ -67,9 +77,15 @@ public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
|
|||||||
c.Id,
|
c.Id,
|
||||||
c.Slug,
|
c.Slug,
|
||||||
c.Name,
|
c.Name,
|
||||||
|
c.Number,
|
||||||
show is null ? null : showId,
|
show is null ? null : showId,
|
||||||
show?.Name,
|
show?.Name,
|
||||||
show?.PosterImageId
|
show?.PosterImageId,
|
||||||
|
c.LogoImageId,
|
||||||
|
c.LogoCorner,
|
||||||
|
c.LogoOpacity,
|
||||||
|
c.ShowClock,
|
||||||
|
c.AnalogFilterStrength
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|||||||
@@ -6,9 +6,17 @@ public sealed record PublicChannelDto(
|
|||||||
Guid Id,
|
Guid Id,
|
||||||
string Slug,
|
string Slug,
|
||||||
string Name,
|
string Name,
|
||||||
|
/// <summary>Номер канала для переключения по номерам или null, если не задан.</summary>
|
||||||
|
int? Number,
|
||||||
Guid? CurrentShowId,
|
Guid? CurrentShowId,
|
||||||
string? CurrentShowName,
|
string? CurrentShowName,
|
||||||
Guid? CurrentShowPosterImageId
|
Guid? CurrentShowPosterImageId,
|
||||||
|
/// <summary>Что рисовать поверх картинки — оверлеи считает клиент, ffmpeg их не касается.</summary>
|
||||||
|
Guid? LogoImageId,
|
||||||
|
LogoCorner LogoCorner,
|
||||||
|
double LogoOpacity,
|
||||||
|
bool ShowClock,
|
||||||
|
double AnalogFilterStrength
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Запись публичного телегида с метаданными (без имён файлов и номеров серий).</summary>
|
/// <summary>Запись публичного телегида с метаданными (без имён файлов и номеров серий).</summary>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -22,7 +22,11 @@ public static class ElementSelector
|
|||||||
IRandomSource random
|
IRandomSource random
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var playable = slot.Elements.Where(e => e.Units.Count > 0).ToList();
|
// Возрастной потолок — жёсткое «нельзя», поэтому отсекает до любой стратегии и до курсора:
|
||||||
|
// начатый в общее время сериал не должен доигрываться в детское.
|
||||||
|
var playable = slot
|
||||||
|
.Elements.Where(e => e.Units.Count > 0 && IsAllowedByAudience(slot, e))
|
||||||
|
.ToList();
|
||||||
if (playable.Count == 0)
|
if (playable.Count == 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
@@ -95,10 +99,14 @@ public static class ElementSelector
|
|||||||
if (current is not null && HasUnitsLeft(slot, current))
|
if (current is not null && HasUnitsLeft(slot, current))
|
||||||
return Continue(slot, current);
|
return Continue(slot, current);
|
||||||
|
|
||||||
|
// Потолок повторов — тоже жёсткий фильтр, но применяется только здесь: в последовательной
|
||||||
|
// стратегии он выбрасывал бы очередную серию сериала и рвал порядок показа.
|
||||||
|
var withinLimit = ApplyRepeatLimit(slot, playable, moment);
|
||||||
|
|
||||||
var cooldown = TimeSpan.FromDays(Math.Max(0, slot.Strategy.CooldownDays));
|
var cooldown = TimeSpan.FromDays(Math.Max(0, slot.Strategy.CooldownDays));
|
||||||
var eligible = cooldown <= TimeSpan.Zero
|
var eligible = cooldown <= TimeSpan.Zero
|
||||||
? playable
|
? withinLimit
|
||||||
: playable
|
: withinLimit
|
||||||
.Where(e => e.LastPlayedUtc is not { } last || moment - last >= cooldown)
|
.Where(e => e.LastPlayedUtc is not { } last || moment - last >= cooldown)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -106,16 +114,47 @@ public static class ElementSelector
|
|||||||
if (exhausted)
|
if (exhausted)
|
||||||
{
|
{
|
||||||
// Остывание отсекло всех. Либо игнорируем его на этот выход, либо берём самый давний —
|
// Остывание отсекло всех. Либо игнорируем его на этот выход, либо берём самый давний —
|
||||||
// пустой эфир хуже раннего повтора в обоих случаях.
|
// пустой эфир хуже раннего повтора в обоих случаях. Откат идёт по уже суженному потолком
|
||||||
|
// повторов пулу: иначе более слабое правило воскрешало бы отсечённое более строгим.
|
||||||
eligible = slot.Strategy.IgnoreCooldownWhenExhausted
|
eligible = slot.Strategy.IgnoreCooldownWhenExhausted
|
||||||
? playable
|
? withinLimit
|
||||||
: [playable.OrderBy(e => e.LastPlayedUtc ?? DateTimeOffset.MinValue).First()];
|
: [withinLimit.OrderBy(e => e.LastPlayedUtc ?? DateTimeOffset.MinValue).First()];
|
||||||
}
|
}
|
||||||
|
|
||||||
var picked = WeightedPick(eligible, random);
|
var picked = WeightedPick(eligible, random);
|
||||||
return new ElementPick(picked, 0, eligible.Count, exhausted);
|
return new ElementPick(picked, 0, eligible.Count, exhausted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Проходит ли элемент по возрастному потолку слота. Категории упорядочены по строгости, поэтому
|
||||||
|
/// сравнение — обычное «не строже». Элемент без категории не отсекается: неизвестное не значит
|
||||||
|
/// «взрослое», а молчаливое выбрасывание такого контента искало бы потом весь вечер.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsAllowedByAudience(PlanningSlot slot, PlanningElement element) =>
|
||||||
|
slot.MaxAudience is not { } max || element.Audience is not { } audience || audience <= max;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Отсекает элементы, выбранные за период чаще потолка. История — старты уже записанных показов;
|
||||||
|
/// без неё (правило не задано или лента не загружена) фильтр не работает и никого не выбрасывает.
|
||||||
|
/// </summary>
|
||||||
|
private static List<PlanningElement> ApplyRepeatLimit(
|
||||||
|
PlanningSlot slot,
|
||||||
|
List<PlanningElement> playable,
|
||||||
|
DateTimeOffset moment
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (slot.RepeatLimit is not { } limit || limit.WindowDays <= 0 || limit.Max <= 0)
|
||||||
|
return playable;
|
||||||
|
|
||||||
|
var from = moment.AddDays(-limit.WindowDays);
|
||||||
|
var filtered = playable
|
||||||
|
.Where(e => (e.RecentPlaysUtc?.Count(p => p >= from) ?? 0) < limit.Max)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Все упёрлись в потолок — правило не должно оставлять слот пустым: пусть решает остывание.
|
||||||
|
return filtered.Count == 0 ? playable : filtered;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Продолжение текущего элемента с позиции курсора.</summary>
|
/// <summary>Продолжение текущего элемента с позиции курсора.</summary>
|
||||||
private static ElementPick Continue(PlanningSlot slot, PlanningElement element)
|
private static ElementPick Continue(PlanningSlot slot, PlanningElement element)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using TeleWave.Domain.Library;
|
||||||
|
|
||||||
namespace TeleWave.Domain.Programming.Planning;
|
namespace TeleWave.Domain.Programming.Planning;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -16,9 +18,16 @@ public sealed record PlanningElement(
|
|||||||
int Weight,
|
int Weight,
|
||||||
int Position,
|
int Position,
|
||||||
IReadOnlyList<PlanningUnit> Units,
|
IReadOnlyList<PlanningUnit> Units,
|
||||||
DateTimeOffset? LastPlayedUtc = null
|
DateTimeOffset? LastPlayedUtc = null,
|
||||||
|
/// <summary>Категория аудитории (у коллекции — строжайшая из частей); по ней работает детское время.</summary>
|
||||||
|
ShowAudience? Audience = null,
|
||||||
|
/// <summary>Старты недавних показов в этом канале — по ним считается потолок повторов за период.</summary>
|
||||||
|
IReadOnlyList<DateTimeOffset>? RecentPlaysUtc = null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>Потолок повторов: не чаще <paramref name="Max"/> раз за <paramref name="WindowDays"/> суток.</summary>
|
||||||
|
public sealed record RepeatLimit(int WindowDays, int Max);
|
||||||
|
|
||||||
/// <summary>Стратегия выбора элемента, приведённая к виду, понятному чистому планировщику.</summary>
|
/// <summary>Стратегия выбора элемента, приведённая к виду, понятному чистому планировщику.</summary>
|
||||||
public sealed record PlanningStrategy(
|
public sealed record PlanningStrategy(
|
||||||
SlotStrategyKind Kind,
|
SlotStrategyKind Kind,
|
||||||
@@ -66,7 +75,11 @@ public sealed record PlanningSlot(
|
|||||||
/// <summary>Врезки между единицами внутри блока.</summary>
|
/// <summary>Врезки между единицами внутри блока.</summary>
|
||||||
PlanningJunction? JunctionBetween = null,
|
PlanningJunction? JunctionBetween = null,
|
||||||
/// <summary>Врезки в конце блока.</summary>
|
/// <summary>Врезки в конце блока.</summary>
|
||||||
PlanningJunction? JunctionAfter = null
|
PlanningJunction? JunctionAfter = null,
|
||||||
|
/// <summary>Возрастной потолок в это время суток (null — без ограничения). Жёсткий фильтр.</summary>
|
||||||
|
ShowAudience? MaxAudience = null,
|
||||||
|
/// <summary>Потолок повторов за период (null — без ограничения). Жёсткий фильтр.</summary>
|
||||||
|
RepeatLimit? RepeatLimit = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes);
|
public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes);
|
||||||
@@ -189,4 +202,18 @@ public enum PlanningWarningKind
|
|||||||
|
|
||||||
/// <summary>Пусто даже в фоне — в ленте образуется дыра.</summary>
|
/// <summary>Пусто даже в фоне — в ленте образуется дыра.</summary>
|
||||||
FallbackEmpty = 4,
|
FallbackEmpty = 4,
|
||||||
|
|
||||||
|
/// <summary>Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата.</summary>
|
||||||
|
CandidatesFiltered = 5,
|
||||||
|
|
||||||
|
// ── Пост-проверки: считаются по готовой ленте и ничего не переигрывают (см. 3.8). ──
|
||||||
|
|
||||||
|
/// <summary>Врезок в часе больше заданного потолка.</summary>
|
||||||
|
BreakLimitExceeded = 6,
|
||||||
|
|
||||||
|
/// <summary>Доля одного жанра за сутки выше заданной.</summary>
|
||||||
|
GenreShareExceeded = 7,
|
||||||
|
|
||||||
|
/// <summary>Фон занял больше эфира, чем считается нормой.</summary>
|
||||||
|
FallbackShareExceeded = 8,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,12 +244,26 @@ public static class SchedulePlanner
|
|||||||
var pick = ElementSelector.Select(slot, cursor, random);
|
var pick = ElementSelector.Select(slot, cursor, random);
|
||||||
if (pick is null)
|
if (pick is null)
|
||||||
{
|
{
|
||||||
|
// Пустая группа и отсечённая фильтром — разные беды: во втором случае контент есть,
|
||||||
|
// но не подходит по правилам, и админу надо чинить правило, а не состав группы.
|
||||||
|
var filteredOut =
|
||||||
|
slot.Elements.Any(e => e.Units.Count > 0)
|
||||||
|
&& !slot.Elements.Any(e =>
|
||||||
|
e.Units.Count > 0 && ElementSelector.IsAllowedByAudience(slot, e)
|
||||||
|
);
|
||||||
|
|
||||||
warnings.Add(
|
warnings.Add(
|
||||||
new PlanningWarning(
|
filteredOut
|
||||||
PlanningWarningKind.SlotEmpty,
|
? new PlanningWarning(
|
||||||
slot.SlotId,
|
PlanningWarningKind.CandidatesFiltered,
|
||||||
"Слот не дал контента — место закрыл фон."
|
slot.SlotId,
|
||||||
)
|
"Возрастной потолок отсёк всех кандидатов — место закрыл фон."
|
||||||
|
)
|
||||||
|
: new PlanningWarning(
|
||||||
|
PlanningWarningKind.SlotEmpty,
|
||||||
|
slot.SlotId,
|
||||||
|
"Слот не дал контента — место закрыл фон."
|
||||||
|
)
|
||||||
);
|
);
|
||||||
return FillWithFallback(
|
return FillWithFallback(
|
||||||
cursor,
|
cursor,
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ public class ScheduleTemplate
|
|||||||
/// <summary>Стык по умолчанию — для слотов, у которых свой не задан.</summary>
|
/// <summary>Стык по умолчанию — для слотов, у которых свой не задан.</summary>
|
||||||
public Guid? DefaultJunctionId { get; private set; }
|
public Guid? DefaultJunctionId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Правила отбора кандидатов (детское время, потолок повторов) в JSON. Домен их не разбирает —
|
||||||
|
/// схема живёт в Application, как и у стратегий слотов и условий стыка.
|
||||||
|
/// </summary>
|
||||||
|
public string? RulesJson { get; private set; }
|
||||||
|
|
||||||
/// <summary>Номер правки правил; входит в кэш-ключи и историю.</summary>
|
/// <summary>Номер правки правил; входит в кэш-ключи и историю.</summary>
|
||||||
public int Revision { get; private set; }
|
public int Revision { get; private set; }
|
||||||
|
|
||||||
@@ -63,6 +69,9 @@ public class ScheduleTemplate
|
|||||||
|
|
||||||
public void SetDefaultJunction(Guid? junctionId) => DefaultJunctionId = junctionId;
|
public void SetDefaultJunction(Guid? junctionId) => DefaultJunctionId = junctionId;
|
||||||
|
|
||||||
|
public void SetRules(string? rulesJson) =>
|
||||||
|
RulesJson = string.IsNullOrWhiteSpace(rulesJson) ? null : rulesJson;
|
||||||
|
|
||||||
/// <summary>Отметить, что правила изменились — эфир пойдёт по старым до применения.</summary>
|
/// <summary>Отметить, что правила изменились — эфир пойдёт по старым до применения.</summary>
|
||||||
public void MarkChanged() => Revision++;
|
public void MarkChanged() => Revision++;
|
||||||
|
|
||||||
|
|||||||
@@ -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
+1371
File diff suppressed because it is too large
Load Diff
+28
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class TemplatePlanningRules : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "RulesJson",
|
||||||
|
table: "ScheduleTemplates",
|
||||||
|
type: "jsonb",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RulesJson",
|
||||||
|
table: "ScheduleTemplates");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1386
File diff suppressed because it is too large
Load Diff
+73
@@ -0,0 +1,73 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ChannelViewerSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<double>(
|
||||||
|
name: "AnalogFilterStrength",
|
||||||
|
table: "Channels",
|
||||||
|
type: "double precision",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0.0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "LogoCorner",
|
||||||
|
table: "Channels",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "LogoImageId",
|
||||||
|
table: "Channels",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<double>(
|
||||||
|
name: "LogoOpacity",
|
||||||
|
table: "Channels",
|
||||||
|
type: "double precision",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0.0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "ShowClock",
|
||||||
|
table: "Channels",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AnalogFilterStrength",
|
||||||
|
table: "Channels");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LogoCorner",
|
||||||
|
table: "Channels");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LogoImageId",
|
||||||
|
table: "Channels");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LogoOpacity",
|
||||||
|
table: "Channels");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ShowClock",
|
||||||
|
table: "Channels");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -318,6 +318,9 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
b.Property<Guid>("Id")
|
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)
|
||||||
@@ -928,6 +943,9 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
b.Property<int>("Revision")
|
b.Property<int>("Revision")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("RulesJson")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("ChannelId");
|
b.HasIndex("ChannelId");
|
||||||
|
|||||||
+1
@@ -10,6 +10,7 @@ public class ScheduleTemplateConfiguration : IEntityTypeConfiguration<ScheduleTe
|
|||||||
public void Configure(EntityTypeBuilder<ScheduleTemplate> builder)
|
public void Configure(EntityTypeBuilder<ScheduleTemplate> builder)
|
||||||
{
|
{
|
||||||
builder.Property(x => x.Name).IsRequired().HasMaxLength(256);
|
builder.Property(x => x.Name).IsRequired().HasMaxLength(256);
|
||||||
|
builder.Property(x => x.RulesJson).HasColumnType("jsonb");
|
||||||
builder.HasIndex(x => x.ChannelId);
|
builder.HasIndex(x => x.ChannelId);
|
||||||
|
|
||||||
builder
|
builder
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ public sealed class SiteSettings(IAppDbContext dbContext) : ISiteSettings
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
) => UpsertAsync(SettingKeys.PreferredAudioLanguages, value, cancellationToken);
|
) => UpsertAsync(SettingKeys.PreferredAudioLanguages, value, cancellationToken);
|
||||||
|
|
||||||
|
public Task<bool> AreChannelNumbersEnabledAsync(CancellationToken cancellationToken) =>
|
||||||
|
dbContext.GetBoolSettingAsync(SettingKeys.ChannelNumbersEnabled, false, cancellationToken);
|
||||||
|
|
||||||
|
public Task SetChannelNumbersEnabledAsync(bool enabled, CancellationToken cancellationToken) =>
|
||||||
|
UpsertAsync(SettingKeys.ChannelNumbersEnabled, enabled ? "true" : "false", cancellationToken);
|
||||||
|
|
||||||
private async Task UpsertAsync(string key, string value, CancellationToken cancellationToken)
|
private async Task UpsertAsync(string key, string value, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var existing = await dbContext.AppSettings.FirstOrDefaultAsync(
|
var existing = await dbContext.AppSettings.FirstOrDefaultAsync(
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
using TeleWave.Application.Programming.Planning;
|
||||||
|
using TeleWave.Application.Programming.Templates;
|
||||||
|
using TeleWave.Domain.Programming;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Tests.Programming;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Применимость слоёв (см. 3.4) и разрешение перекрытий: какие слоты реально действуют в сутки.
|
||||||
|
/// </summary>
|
||||||
|
public class LayerApplicabilityTests
|
||||||
|
{
|
||||||
|
private static readonly TimeOnly DayStart = new(6, 0);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Empty_CoversEveryDate()
|
||||||
|
{
|
||||||
|
var applicability = new LayerApplicability();
|
||||||
|
|
||||||
|
Assert.True(applicability.IsEmpty);
|
||||||
|
Assert.True(applicability.Covers(new DateOnly(2026, 3, 17)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(2026, 12, 25, true)] // внутри, до Нового года
|
||||||
|
[InlineData(2026, 1, 3, true)] // внутри, после Нового года
|
||||||
|
[InlineData(2026, 12, 20, true)] // ровно начало
|
||||||
|
[InlineData(2026, 1, 8, true)] // ровно конец
|
||||||
|
[InlineData(2026, 12, 19, false)] // за день до начала
|
||||||
|
[InlineData(2026, 1, 9, false)] // на следующий день после конца
|
||||||
|
[InlineData(2026, 6, 15, false)] // середина года
|
||||||
|
public void AnnualRange_CrossingNewYear_IsInclusiveOnBothEnds(
|
||||||
|
int year,
|
||||||
|
int month,
|
||||||
|
int day,
|
||||||
|
bool expected
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// «20 декабря — 8 января» задаётся один раз и работает в любом году, поэтому сравнение идёт
|
||||||
|
// по паре (месяц, день), а не по датам.
|
||||||
|
var applicability = new LayerApplicability(
|
||||||
|
AnnualRanges: [new AnnualRange(12, 20, 1, 8)]
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Equal(expected, applicability.Covers(new DateOnly(year, month, day)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnnualRange_WithinOneYear_DoesNotWrap()
|
||||||
|
{
|
||||||
|
var applicability = new LayerApplicability(AnnualRanges: [new AnnualRange(6, 1, 8, 31)]);
|
||||||
|
|
||||||
|
Assert.True(applicability.Covers(new DateOnly(2026, 7, 4)));
|
||||||
|
Assert.False(applicability.Covers(new DateOnly(2026, 1, 4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sections_AreCombinedWithOr()
|
||||||
|
{
|
||||||
|
// Понедельник ИЛИ конкретная дата: суббота из списка дат проходит, обычная суббота — нет.
|
||||||
|
var applicability = new LayerApplicability(
|
||||||
|
Weekdays: [1],
|
||||||
|
SpecificDates: [new DateOnly(2026, 3, 21)]
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.True(applicability.Covers(new DateOnly(2026, 3, 16))); // понедельник
|
||||||
|
Assert.True(applicability.Covers(new DateOnly(2026, 3, 21))); // суббота из списка
|
||||||
|
Assert.False(applicability.Covers(new DateOnly(2026, 3, 14))); // другая суббота
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_SkipsSlotsCoveredByHigherPriorityLayer()
|
||||||
|
{
|
||||||
|
// Слот младшего слоя пропускается целиком, а не обрезается: половина слота означала бы
|
||||||
|
// половину настройки — своей группы и стратегии у половинки нет.
|
||||||
|
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
|
||||||
|
var top = template.AddLayer("Прайм", 100);
|
||||||
|
var bottom = template.AddLayer("Обычный", 50);
|
||||||
|
|
||||||
|
AddSlot(top, "Кино", new TimeOnly(20, 0), 120);
|
||||||
|
AddSlot(bottom, "Сериал", new TimeOnly(20, 30), 60);
|
||||||
|
AddSlot(bottom, "Ночь", new TimeOnly(23, 0), 60);
|
||||||
|
|
||||||
|
var titles = Build(template).Select(s => s.Slot.Title).Distinct().ToList();
|
||||||
|
|
||||||
|
Assert.Contains("Кино", titles);
|
||||||
|
Assert.Contains("Ночь", titles);
|
||||||
|
Assert.DoesNotContain("Сериал", titles);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_DropsLayersThatDoNotApplyOnTheDate()
|
||||||
|
{
|
||||||
|
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
|
||||||
|
var newYear = template.AddLayer("Новогодний", 100);
|
||||||
|
newYear.Update(
|
||||||
|
newYear.Name,
|
||||||
|
newYear.Priority,
|
||||||
|
new LayerApplicability(AnnualRanges: [new AnnualRange(12, 20, 1, 8)]).ToJson(),
|
||||||
|
isEnabled: true
|
||||||
|
);
|
||||||
|
AddSlot(newYear, "Ирония судьбы", new TimeOnly(20, 0), 180);
|
||||||
|
|
||||||
|
var usual = template.AddLayer("Обычный", 50);
|
||||||
|
AddSlot(usual, "Вечерний сериал", new TimeOnly(20, 0), 60);
|
||||||
|
|
||||||
|
var march = Build(template, new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero))
|
||||||
|
.Select(s => s.Slot.Title)
|
||||||
|
.ToList();
|
||||||
|
var december = Build(template, new DateTimeOffset(2026, 12, 25, 12, 0, 0, TimeSpan.Zero))
|
||||||
|
.Select(s => s.Slot.Title)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.Contains("Вечерний сериал", march);
|
||||||
|
Assert.DoesNotContain("Ирония судьбы", march);
|
||||||
|
Assert.Contains("Ирония судьбы", december);
|
||||||
|
Assert.DoesNotContain("Вечерний сериал", december);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_DisabledLayerIsIgnored()
|
||||||
|
{
|
||||||
|
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
|
||||||
|
var layer = template.AddLayer("Выключённый", 100);
|
||||||
|
AddSlot(layer, "Ничего", new TimeOnly(20, 0), 60);
|
||||||
|
layer.Update(layer.Name, layer.Priority, null, isEnabled: false);
|
||||||
|
|
||||||
|
Assert.Empty(Build(template));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddSlot(GridLayer layer, string title, TimeOnly start, int durationMinutes) =>
|
||||||
|
layer.AddSlot(title, start, durationMinutes);
|
||||||
|
|
||||||
|
private static IReadOnlyList<ScheduledSlot> Build(
|
||||||
|
ScheduleTemplate template,
|
||||||
|
DateTimeOffset? from = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var start = from ?? new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
return EffectiveGridBuilder.Build(
|
||||||
|
template,
|
||||||
|
utcOffsetMinutes: 180,
|
||||||
|
DayStart,
|
||||||
|
start,
|
||||||
|
start.AddHours(18)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using TeleWave.Application.Programming.Templates;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Tests.Programming;
|
||||||
|
|
||||||
|
/// <summary>Окна детского времени: разрешение по времени суток, включая переход через полночь.</summary>
|
||||||
|
public class PlanningRulesTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(7, 0, true)]
|
||||||
|
[InlineData(22, 59, true)]
|
||||||
|
[InlineData(6, 0, true)] // ровно начало
|
||||||
|
[InlineData(23, 0, false)] // ровно конец — уже вне окна
|
||||||
|
[InlineData(2, 0, false)]
|
||||||
|
public void AudienceAt_DayWindow(int hour, int minute, bool inside)
|
||||||
|
{
|
||||||
|
var rules = new PlanningRules(
|
||||||
|
[new AudienceWindow(new TimeOnly(6, 0), new TimeOnly(23, 0), ShowAudience.Teen)]
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = rules.AudienceAt(new TimeOnly(hour, minute));
|
||||||
|
|
||||||
|
Assert.Equal(inside ? ShowAudience.Teen : null, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(23, 30, true)]
|
||||||
|
[InlineData(3, 0, true)]
|
||||||
|
[InlineData(12, 0, false)]
|
||||||
|
public void AudienceAt_WindowCrossingMidnight(int hour, int minute, bool inside)
|
||||||
|
{
|
||||||
|
// «С 23:00 до 06:00» — ночное окно, границы сравниваются в обратную сторону.
|
||||||
|
var rules = new PlanningRules(
|
||||||
|
[new AudienceWindow(new TimeOnly(23, 0), new TimeOnly(6, 0), ShowAudience.Adult)]
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = rules.AudienceAt(new TimeOnly(hour, minute));
|
||||||
|
|
||||||
|
Assert.Equal(inside ? ShowAudience.Adult : null, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AudienceAt_OverlappingWindows_TakesTheStrictest()
|
||||||
|
{
|
||||||
|
// Широкое окно, случайно наложенное поверх детского, не должно его отменять.
|
||||||
|
var rules = new PlanningRules(
|
||||||
|
[
|
||||||
|
new AudienceWindow(new TimeOnly(6, 0), new TimeOnly(23, 0), ShowAudience.General),
|
||||||
|
new AudienceWindow(new TimeOnly(7, 0), new TimeOnly(10, 0), ShowAudience.Kids),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Equal(ShowAudience.Kids, rules.AudienceAt(new TimeOnly(8, 0)));
|
||||||
|
Assert.Equal(ShowAudience.General, rules.AudienceAt(new TimeOnly(12, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AudienceAt_WithoutWindows_IsUnlimited()
|
||||||
|
{
|
||||||
|
Assert.Null(new PlanningRules().AudienceAt(new TimeOnly(3, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Json_RoundTrips()
|
||||||
|
{
|
||||||
|
var rules = new PlanningRules(
|
||||||
|
[new AudienceWindow(new TimeOnly(6, 0), new TimeOnly(23, 0), ShowAudience.Teen)],
|
||||||
|
new RepeatLimitRule(7, 2),
|
||||||
|
MaxBreakMinutesPerHour: 12,
|
||||||
|
MaxGenreSharePercent: 40,
|
||||||
|
MaxFallbackSharePercent: 15
|
||||||
|
);
|
||||||
|
|
||||||
|
var restored = PlanningRules.FromJson(rules.ToJson());
|
||||||
|
|
||||||
|
// Сравниваем по полям: у record со списком внутри равенство ссылочное, и `Assert.Equal`
|
||||||
|
// на самих правилах проверял бы не то.
|
||||||
|
Assert.NotNull(restored);
|
||||||
|
Assert.Equal(rules.MaxAudienceByTime!, restored.MaxAudienceByTime!);
|
||||||
|
Assert.Equal(rules.MaxRepeatsInWindow, restored.MaxRepeatsInWindow);
|
||||||
|
Assert.Equal(rules.MaxBreakMinutesPerHour, restored.MaxBreakMinutesPerHour);
|
||||||
|
Assert.Equal(rules.MaxGenreSharePercent, restored.MaxGenreSharePercent);
|
||||||
|
Assert.Equal(rules.MaxFallbackSharePercent, restored.MaxFallbackSharePercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromJson_Garbage_IsNull()
|
||||||
|
{
|
||||||
|
// Битый JSON не должен ронять генерацию: правило просто считается незаданным.
|
||||||
|
Assert.Null(PlanningRules.FromJson("{"));
|
||||||
|
Assert.Null(PlanningRules.FromJson(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,39 @@ public class ChannelTests
|
|||||||
Assert.Equal(BumperSelection.AlwaysFirst, channel.BumperSelection);
|
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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
using TeleWave.Domain.Broadcast.Scheduling;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
using TeleWave.Domain.Programming;
|
||||||
|
using TeleWave.Domain.Programming.Planning;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeleWave.Domain.Tests.Programming;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Жёсткие фильтры кандидатов (см. 3.8): детское время и потолок повторов. Оба отсекают до жребия,
|
||||||
|
/// поэтому проверяются на выборе элемента, а не на готовой ленте.
|
||||||
|
/// </summary>
|
||||||
|
public class CandidateFilterTests
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset T0 = new(2026, 1, 5, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
private sealed class FirstAlways : IRandomSource
|
||||||
|
{
|
||||||
|
public int Next(int maxExclusive) => 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlanningElement Element(
|
||||||
|
ShowAudience? audience = null,
|
||||||
|
int position = 0,
|
||||||
|
DateTimeOffset? lastPlayed = null,
|
||||||
|
IReadOnlyList<DateTimeOffset>? recentPlays = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var showId = Guid.NewGuid();
|
||||||
|
return new PlanningElement(
|
||||||
|
GroupElementKind.Show,
|
||||||
|
Guid.NewGuid(),
|
||||||
|
Weight: 1,
|
||||||
|
position,
|
||||||
|
[new PlanningUnit(Guid.NewGuid(), TimeSpan.FromMinutes(30), showId, 0)],
|
||||||
|
lastPlayed,
|
||||||
|
audience,
|
||||||
|
recentPlays
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlanningSlot Slot(
|
||||||
|
IReadOnlyList<PlanningElement> elements,
|
||||||
|
ShowAudience? maxAudience = null,
|
||||||
|
RepeatLimit? repeatLimit = null,
|
||||||
|
SlotStrategyKind strategy = SlotStrategyKind.RandomWithCooldown
|
||||||
|
) =>
|
||||||
|
new(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
T0,
|
||||||
|
TargetDurationMinutes: 60,
|
||||||
|
SlotKind.Content,
|
||||||
|
IsAnchor: false,
|
||||||
|
MaxDriftMinutes: 30,
|
||||||
|
SnapToMinutes: null,
|
||||||
|
SlotBlockMode.FillSlot,
|
||||||
|
BlockValue: 1,
|
||||||
|
OverflowPolicy.ContinueNext,
|
||||||
|
new PlanningStrategy(strategy),
|
||||||
|
elements,
|
||||||
|
Cursor: null,
|
||||||
|
MaxAudience: maxAudience,
|
||||||
|
RepeatLimit: repeatLimit
|
||||||
|
);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Audience_FiltersOutStricterContent()
|
||||||
|
{
|
||||||
|
var kids = Element(ShowAudience.Kids);
|
||||||
|
var adult = Element(ShowAudience.Adult, position: 1);
|
||||||
|
|
||||||
|
var pick = ElementSelector.Select(
|
||||||
|
Slot([adult, kids], maxAudience: ShowAudience.Family),
|
||||||
|
T0,
|
||||||
|
new FirstAlways()
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.NotNull(pick);
|
||||||
|
Assert.Equal(kids.ElementId, pick.Element.ElementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Audience_WithoutLimit_KeepsEverything()
|
||||||
|
{
|
||||||
|
var adult = Element(ShowAudience.Adult);
|
||||||
|
|
||||||
|
var pick = ElementSelector.Select(Slot([adult]), T0, new FirstAlways());
|
||||||
|
|
||||||
|
Assert.NotNull(pick);
|
||||||
|
Assert.Equal(adult.ElementId, pick.Element.ElementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Audience_UnknownCategory_IsNotDropped()
|
||||||
|
{
|
||||||
|
// Неизвестная категория — не повод выбрасывать: иначе контент исчезал бы из эфира молча.
|
||||||
|
var unknown = Element(audience: null);
|
||||||
|
|
||||||
|
var pick = ElementSelector.Select(
|
||||||
|
Slot([unknown], maxAudience: ShowAudience.Kids),
|
||||||
|
T0,
|
||||||
|
new FirstAlways()
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.NotNull(pick);
|
||||||
|
Assert.Equal(unknown.ElementId, pick.Element.ElementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Audience_AllFilteredOut_ReturnsNothing()
|
||||||
|
{
|
||||||
|
var pick = ElementSelector.Select(
|
||||||
|
Slot([Element(ShowAudience.Adult)], maxAudience: ShowAudience.Kids),
|
||||||
|
T0,
|
||||||
|
new FirstAlways()
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Null(pick);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Audience_AppliesToSequentialStrategyToo()
|
||||||
|
{
|
||||||
|
var adult = Element(ShowAudience.Adult, position: 0);
|
||||||
|
var teen = Element(ShowAudience.Teen, position: 1);
|
||||||
|
|
||||||
|
var pick = ElementSelector.Select(
|
||||||
|
Slot([adult, teen], maxAudience: ShowAudience.Teen, strategy: SlotStrategyKind.Sequential),
|
||||||
|
T0,
|
||||||
|
new FirstAlways()
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.NotNull(pick);
|
||||||
|
Assert.Equal(teen.ElementId, pick.Element.ElementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RepeatLimit_DropsElementsOverTheCap()
|
||||||
|
{
|
||||||
|
var overCap = Element(recentPlays: [T0.AddDays(-1), T0.AddDays(-2)]);
|
||||||
|
var fresh = Element(position: 1, recentPlays: [T0.AddDays(-1)]);
|
||||||
|
|
||||||
|
var pick = ElementSelector.Select(
|
||||||
|
Slot([overCap, fresh], repeatLimit: new RepeatLimit(WindowDays: 7, Max: 2)),
|
||||||
|
T0,
|
||||||
|
new FirstAlways()
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.NotNull(pick);
|
||||||
|
Assert.Equal(fresh.ElementId, pick.Element.ElementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RepeatLimit_IgnoresPlaysOutsideTheWindow()
|
||||||
|
{
|
||||||
|
// Показы старше окна не считаются — иначе правило «не чаще двух раз в неделю» запирало бы
|
||||||
|
// элемент навсегда.
|
||||||
|
var old = Element(recentPlays: [T0.AddDays(-30), T0.AddDays(-31)]);
|
||||||
|
|
||||||
|
var pick = ElementSelector.Select(
|
||||||
|
Slot([old], repeatLimit: new RepeatLimit(WindowDays: 7, Max: 2)),
|
||||||
|
T0,
|
||||||
|
new FirstAlways()
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.NotNull(pick);
|
||||||
|
Assert.Equal(old.ElementId, pick.Element.ElementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RepeatLimit_WhenEveryoneIsOverTheCap_StillPicksSomething()
|
||||||
|
{
|
||||||
|
// Пустой эфир хуже раннего повтора — как и при исчерпанном остывании.
|
||||||
|
var a = Element(recentPlays: [T0.AddDays(-1), T0.AddDays(-2)]);
|
||||||
|
var b = Element(position: 1, recentPlays: [T0.AddDays(-1), T0.AddDays(-2)]);
|
||||||
|
|
||||||
|
var pick = ElementSelector.Select(
|
||||||
|
Slot([a, b], repeatLimit: new RepeatLimit(WindowDays: 7, Max: 1)),
|
||||||
|
T0,
|
||||||
|
new FirstAlways()
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.NotNull(pick);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RepeatLimit_AppliesBeforeCooldown()
|
||||||
|
{
|
||||||
|
// Порядок проверяется через исчерпание: потолок повторов выбрасывает «давний» элемент ещё
|
||||||
|
// до остывания, поэтому в вырожденном случае остаётся свежий. В обратном порядке остывание
|
||||||
|
// оставило бы давний, и выбор был бы другим.
|
||||||
|
var overCap = Element(
|
||||||
|
lastPlayed: T0.AddDays(-10),
|
||||||
|
recentPlays: [T0.AddDays(-10), T0.AddDays(-9), T0.AddDays(-8)]
|
||||||
|
);
|
||||||
|
var recent = Element(position: 1, lastPlayed: T0.AddHours(-1), recentPlays: [T0.AddHours(-1)]);
|
||||||
|
|
||||||
|
var slot = Slot([overCap, recent], repeatLimit: new RepeatLimit(WindowDays: 30, Max: 2)) with
|
||||||
|
{
|
||||||
|
Strategy = new PlanningStrategy(SlotStrategyKind.RandomWithCooldown, CooldownDays: 2),
|
||||||
|
};
|
||||||
|
|
||||||
|
var pick = ElementSelector.Select(slot, T0, new FirstAlways());
|
||||||
|
|
||||||
|
Assert.NotNull(pick);
|
||||||
|
Assert.Equal(recent.ElementId, pick.Element.ElementId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-21
@@ -18,8 +18,19 @@
|
|||||||
(`BumperMinIntervalMinutes`, обе `*Chance`, `NextBumperIndex`) удалены — условия показа живут
|
(`BumperMinIntervalMinutes`, обе `*Chance`, `NextBumperIndex`) удалены — условия показа живут
|
||||||
в элементе стыка; из `BumperSelection` убрана «ротация», у которой не было реализации.
|
в элементе стыка; из `BumperSelection` убрана «ротация», у которой не было реализации.
|
||||||
|
|
||||||
**Срезы 3 и 4** не начинались, кроме того, что уже понадобилось раньше: слоты повтора и конца
|
**Срез 3 закрыт целиком.** Применимость слоёв правится из UI, слои переупорядочиваются
|
||||||
вещания сделаны вместе с планировщиком, применимость слоёв и панель слоёв — частично.
|
перетаскиванием и выключаются, сетку можно посмотреть на конкретную дату, слоты двигаются
|
||||||
|
и растягиваются мышью, день копируется на другие дни. Добавлены жёсткие фильтры кандидатов —
|
||||||
|
детское время и потолок повторов.
|
||||||
|
|
||||||
|
**Срез 4 закрыт целиком.** Проверки по правилам показываются прямо в редакторе, пост-проверки
|
||||||
|
дают предупреждения по готовой ленте, у предпросмотра появилась вкладка «Проблемы» с тепловой
|
||||||
|
картой повторов, у каждой записи — трейс «почему это здесь», применение идёт через диф
|
||||||
|
с подсветкой ближайших суток, сетка копируется на другой канал.
|
||||||
|
|
||||||
|
**Смежная зрительская часть закрыта.** Номера каналов с сортировкой публичного списка,
|
||||||
|
переключение по номерам стрелками (глобальный флаг рядом с флагом регистрации), логотип-оверлей,
|
||||||
|
часы, плашка «Далее» и аналоговый фильтр. Всё опционально и по умолчанию выключено.
|
||||||
|
|
||||||
Ничего из этого не проверялось на живой базе: только сборка, юнит-тесты и typecheck.
|
Ничего из этого не проверялось на живой базе: только сборка, юнит-тесты и typecheck.
|
||||||
|
|
||||||
@@ -307,19 +318,17 @@ N новых позиций по фильтру».
|
|||||||
|
|
||||||
## Срез 3 — гибкость и календарь
|
## Срез 3 — гибкость и календарь
|
||||||
|
|
||||||
#### [~] 3.1. Применимость слоёв
|
#### [x] 3.1. Применимость слоёв
|
||||||
`Domain` `Infrastructure` `Application`
|
`Domain` `Infrastructure` `Application`
|
||||||
|
|
||||||
> Частично: модель применимости и разрешение слоёв на дату готовы; нет UI правки применимости.
|
|
||||||
|
|
||||||
`applicability`: дни недели, разовые диапазоны дат, ежегодные диапазоны, конкретные даты (см. 3.4).
|
`applicability`: дни недели, разовые диапазоны дат, ежегодные диапазоны, конкретные даты (см. 3.4).
|
||||||
Приоритеты, разрешение конфликтов. Юнит-тесты на пересекающиеся слои и на границу года в ежегодном
|
Приоритеты, разрешение конфликтов. Юнит-тесты на пересекающиеся слои и на границу года в ежегодном
|
||||||
диапазоне.
|
диапазоне.
|
||||||
|
|
||||||
#### [~] 3.2. Фильтры кандидатов
|
#### [x] 3.2. Фильтры кандидатов
|
||||||
`Domain`
|
`Domain`
|
||||||
|
|
||||||
> Частично: остывание работает; возрастной фильтр по времени и потолок повторов не сделаны.
|
|
||||||
|
|
||||||
`maxAudienceByTime`, `cooldown`, `maxRepeatsInWindow` (см. 3.8) — применяются **до** взвешенного
|
`maxAudienceByTime`, `cooldown`, `maxRepeatsInWindow` (см. 3.8) — применяются **до** взвешенного
|
||||||
выбора. История берётся из материализованной ленты. `fallback` стратегии, когда остывание отсекло
|
выбора. История берётся из материализованной ленты. `fallback` стратегии, когда остывание отсекло
|
||||||
@@ -337,15 +346,14 @@ N новых позиций по фильтру».
|
|||||||
Конец вещания: заполнение зацикленным ассетом, пометка в EPG «эфир не ведётся», отображение
|
Конец вещания: заполнение зацикленным ассетом, пометка в EPG «эфир не ведётся», отображение
|
||||||
в программе.
|
в программе.
|
||||||
|
|
||||||
#### [~] 3.5. Панель слоёв
|
#### [x] 3.5. Панель слоёв
|
||||||
`Frontend`
|
`Frontend`
|
||||||
|
|
||||||
> Частично: список слоёв с приоритетом и выбором готов; нет drag-переупорядочивания и переключателя даты.
|
|
||||||
|
|
||||||
Список слоёв с видимостью и приоритетом, drag для переупорядочивания, выбор редактируемого слоя,
|
Список слоёв с видимостью и приоритетом, drag для переупорядочивания, выбор редактируемого слоя,
|
||||||
штриховка перекрытых слотов, переключатель даты («показать сетку на 25 декабря»).
|
штриховка перекрытых слотов, переключатель даты («показать сетку на 25 декабря»).
|
||||||
|
|
||||||
#### [ ] 3.6. Drag & drop в календаре
|
#### [x] 3.6. Drag & drop в календаре
|
||||||
`Frontend`
|
`Frontend`
|
||||||
|
|
||||||
Перетаскивание слотов и изменение длительности за края, копирование дня на другие дни, копирование
|
Перетаскивание слотов и изменение длительности за края, копирование дня на другие дни, копирование
|
||||||
@@ -355,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`
|
||||||
|
|
||||||
Глубокая копия шаблона, слоёв и слотов на другой канал; группы не копируются, они общие.
|
Глубокая копия шаблона, слоёв и слотов на другой канал; группы не копируются, они общие.
|
||||||
@@ -396,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-фильтр или шейдер, по умолчанию выключен.
|
||||||
|
|||||||
@@ -8,29 +8,51 @@ import { HttpError } from '@/shared/api/client'
|
|||||||
import type { GridLayerDto, SlotDto } from '@/shared/api/types'
|
import type { GridLayerDto, SlotDto } 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 { 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,
|
||||||
deleteLayer,
|
deleteLayer,
|
||||||
getChannel,
|
getChannel,
|
||||||
getChannelTemplate,
|
getChannelTemplate,
|
||||||
getSchedule,
|
getSchedule,
|
||||||
|
listChannels,
|
||||||
|
toSlotBody,
|
||||||
|
updateLayer,
|
||||||
|
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 { 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 { 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'
|
||||||
|
|
||||||
export function ChannelDetail({ channelId }: { channelId: string }) {
|
export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [draft, setDraft] = useState<SlotDraft | null>(null)
|
const [draft, setDraft] = useState<SlotDraft | null>(null)
|
||||||
const [activeLayerId, setActiveLayerId] = useState<string | null>(null)
|
const [activeLayerId, setActiveLayerId] = useState<string | null>(null)
|
||||||
|
const [viewDate, setViewDate] = useState<string>('')
|
||||||
|
const [applicabilityLayer, setApplicabilityLayer] = useState<GridLayerDto | null>(null)
|
||||||
|
// День, который копируем, и отмеченные дни-приёмники.
|
||||||
|
const [copySource, setCopySource] = useState<number | null>(null)
|
||||||
|
const [copyTargets, setCopyTargets] = useState<number[]>([])
|
||||||
|
const [applyOpen, setApplyOpen] = useState(false)
|
||||||
|
const [traceEntryId, setTraceEntryId] = useState<string | null>(null)
|
||||||
|
const [copyToChannel, setCopyToChannel] = useState('')
|
||||||
|
|
||||||
const { data: channel, isLoading } = useQuery({
|
const { data: channel, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'channels', channelId],
|
queryKey: ['admin', 'channels', channelId],
|
||||||
@@ -55,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)
|
||||||
@@ -85,6 +110,97 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
|||||||
onError,
|
onError,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const toggleLayerMutation = useMutation({
|
||||||
|
mutationFn: (layer: GridLayerDto) =>
|
||||||
|
updateLayer(layer.id, {
|
||||||
|
name: layer.name,
|
||||||
|
priority: layer.priority,
|
||||||
|
applicability: layer.applicability,
|
||||||
|
isEnabled: !layer.isEnabled,
|
||||||
|
}),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Порядок слоёв задаётся перетаскиванием, а хранится приоритетом. Раздаём приоритеты с шагом 10
|
||||||
|
* снизу вверх: шаг оставляет место, чтобы следующая вставка не переписывала весь список.
|
||||||
|
*/
|
||||||
|
const reorderLayersMutation = useMutation({
|
||||||
|
mutationFn: async (layerIdsTopFirst: string[]) => {
|
||||||
|
const byId = new Map(template!.layers.map((l) => [l.id, l]))
|
||||||
|
const total = layerIdsTopFirst.length
|
||||||
|
await Promise.all(
|
||||||
|
layerIdsTopFirst.map((id, index) => {
|
||||||
|
const layer = byId.get(id)
|
||||||
|
const priority = (total - index) * 10
|
||||||
|
if (!layer || layer.priority === priority) return Promise.resolve()
|
||||||
|
return updateLayer(id, {
|
||||||
|
name: layer.name,
|
||||||
|
priority,
|
||||||
|
applicability: layer.applicability,
|
||||||
|
isEnabled: layer.isEnabled,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const copyTemplateMutation = useMutation({
|
||||||
|
mutationFn: (targetChannelId: string) => copyTemplateTo(channelId, targetChannelId),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setCopyToChannel('')
|
||||||
|
toast.success(
|
||||||
|
t('admin.channels.templateCopied', { layers: result.layers, slots: result.slots }),
|
||||||
|
)
|
||||||
|
if (result.droppedBumperRefs > 0)
|
||||||
|
toast.error(
|
||||||
|
t('admin.channels.copyDroppedBumpers', { count: result.droppedBumperRefs }),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const moveSlotMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
slot,
|
||||||
|
weekday,
|
||||||
|
startMinutes,
|
||||||
|
}: {
|
||||||
|
slot: SlotDto
|
||||||
|
weekday: number
|
||||||
|
startMinutes: number
|
||||||
|
}) => updateSlot(slot.id, { ...toSlotBody(slot), weekday, targetStart: toTime(startMinutes) }),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const resizeSlotMutation = useMutation({
|
||||||
|
mutationFn: ({ slot, minutes }: { slot: SlotDto; minutes: number }) =>
|
||||||
|
updateSlot(slot.id, { ...toSlotBody(slot), targetDurationMinutes: minutes }),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Копирование дня: слоты «каждый день» не копируются — они и так есть во всех колонках. */
|
||||||
|
const copyDayMutation = useMutation({
|
||||||
|
mutationFn: async ({ from, to }: { from: number; to: number[] }) => {
|
||||||
|
const sources = (template?.layers ?? []).flatMap((layer) =>
|
||||||
|
layer.slots.filter((slot) => slot.weekday === from).map((slot) => ({ layer, slot })),
|
||||||
|
)
|
||||||
|
for (const weekday of to)
|
||||||
|
for (const { layer, slot } of sources)
|
||||||
|
await createSlot(layer.id, { ...toSlotBody(slot), weekday })
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setCopySource(null)
|
||||||
|
invalidate()
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
if (isLoading || !channel) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
if (isLoading || !channel) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
||||||
|
|
||||||
const layerForNewSlot =
|
const layerForNewSlot =
|
||||||
@@ -126,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>
|
||||||
@@ -159,19 +275,129 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
|||||||
<LayerList
|
<LayerList
|
||||||
template={template}
|
template={template}
|
||||||
activeLayerId={layerForNewSlot ?? null}
|
activeLayerId={layerForNewSlot ?? null}
|
||||||
|
viewDate={viewDate || null}
|
||||||
onSelect={(layer) => setActiveLayerId(layer.id)}
|
onSelect={(layer) => setActiveLayerId(layer.id)}
|
||||||
onDelete={(layer) => deleteLayerMutation.mutate(layer)}
|
onDelete={(layer) => deleteLayerMutation.mutate(layer)}
|
||||||
|
onToggle={(layer) => toggleLayerMutation.mutate(layer)}
|
||||||
|
onReorder={(order) => reorderLayersMutation.mutate(order)}
|
||||||
|
onEditApplicability={setApplicabilityLayer}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.channels.layersHint')}</p>
|
<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} />
|
||||||
|
|
||||||
|
{/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">{t('admin.channels.showForDate')}</span>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="h-8 w-40"
|
||||||
|
value={viewDate}
|
||||||
|
onChange={(e) => setViewDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
{viewDate && (
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setViewDate('')}>
|
||||||
|
{t('admin.channels.allDates')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Копирование дня: сначала выбирается источник, потом дни-приёмники. */}
|
||||||
|
{copySource !== null && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
|
||||||
|
<span>
|
||||||
|
{t('admin.channels.copyDayFrom', {
|
||||||
|
day: t(`admin.channels.weekdays.${copySource}`),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{[1, 2, 3, 4, 5, 6, 0]
|
||||||
|
.filter((day) => day !== copySource)
|
||||||
|
.map((day) => (
|
||||||
|
<label key={day} className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={copyTargets.includes(day)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCopyTargets((current) =>
|
||||||
|
e.target.checked
|
||||||
|
? [...current, day]
|
||||||
|
: current.filter((d) => d !== day),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{t(`admin.channels.weekdays.${day}`)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={copyTargets.length === 0 || copyDayMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
copyDayMutation.mutate({ from: copySource, to: copyTargets })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('admin.channels.copy')}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setCopySource(null)}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ScheduleGrid
|
<ScheduleGrid
|
||||||
template={template}
|
template={template}
|
||||||
selectedSlotId={draft?.slot?.id ?? null}
|
selectedSlotId={draft?.slot?.id ?? null}
|
||||||
|
viewDate={viewDate || null}
|
||||||
onSelectSlot={openSlot}
|
onSelectSlot={openSlot}
|
||||||
onAddSlot={openNewSlot}
|
onAddSlot={openNewSlot}
|
||||||
|
onMoveSlot={(slot, weekday, startMinutes) =>
|
||||||
|
moveSlotMutation.mutate({ slot, weekday, startMinutes })
|
||||||
|
}
|
||||||
|
onResizeSlot={(slot, minutes) => resizeSlotMutation.mutate({ slot, minutes })}
|
||||||
|
onCopyDay={(weekday) => {
|
||||||
|
setCopySource(weekday)
|
||||||
|
setCopyTargets([])
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{draft && (
|
{draft && (
|
||||||
<SlotInspector
|
<SlotInspector
|
||||||
@@ -186,6 +412,10 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
|||||||
</CollapsibleCard>
|
</CollapsibleCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{template && (
|
||||||
|
<RulesCard template={template} onChanged={invalidate} onError={onError} />
|
||||||
|
)}
|
||||||
|
|
||||||
<JunctionsCard
|
<JunctionsCard
|
||||||
channel={channel}
|
channel={channel}
|
||||||
template={template}
|
template={template}
|
||||||
@@ -195,7 +425,36 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
|||||||
|
|
||||||
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
|
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
|
||||||
|
|
||||||
<SchedulePreview entries={schedule ?? []} />
|
<ViewerCard channel={channel} onSaved={invalidate} onError={onError} />
|
||||||
|
|
||||||
|
{applicabilityLayer && (
|
||||||
|
<LayerApplicabilityDialog
|
||||||
|
layer={applicabilityLayer}
|
||||||
|
onClose={() => setApplicabilityLayer(null)}
|
||||||
|
onChanged={invalidate}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SchedulePreview entries={schedule ?? []} onShowTrace={setTraceEntryId} />
|
||||||
|
|
||||||
|
{applyOpen && (
|
||||||
|
<ApplyDialog
|
||||||
|
channelId={channelId}
|
||||||
|
utcOffsetMinutes={channel.utcOffsetMinutes}
|
||||||
|
pending={applyMutation.isPending}
|
||||||
|
onApply={() => applyMutation.mutate()}
|
||||||
|
onClose={() => setApplyOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{traceEntryId && (
|
||||||
|
<EntryTraceDialog
|
||||||
|
entryId={traceEntryId}
|
||||||
|
utcOffsetMinutes={channel.utcOffsetMinutes}
|
||||||
|
onClose={() => setTraceEntryId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +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,
|
||||||
|
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() {
|
||||||
@@ -42,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,
|
||||||
@@ -63,6 +74,29 @@ export function applyChannelTemplate(channelId: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Проверки сетки по правилам — считаются по шаблону, без прогона генератора. */
|
||||||
|
export function getTemplateIssues(channelId: string) {
|
||||||
|
return apiRequest<TemplateIssueDto[]>(`/admin/channels/${channelId}/template/issues`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Что изменится в эфире, если применить сейчас. Прогон сухой — лента не трогается. */
|
||||||
|
export function getApplyDiff(channelId: string) {
|
||||||
|
return apiRequest<ScheduleDiffDto>(`/admin/channels/${channelId}/template/diff`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Копия сетки на другой канал: слои, слоты, стыки и правила. Группы общие и не копируются. */
|
||||||
|
export function copyTemplateTo(channelId: string, targetChannelId: string) {
|
||||||
|
return apiRequest<CopyTemplateResultDto>(
|
||||||
|
`/admin/channels/${channelId}/template/copy-to/${targetChannelId}`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Цепочка происхождения записи, записанная в момент генерации. */
|
||||||
|
export function getEntryTrace(entryId: string) {
|
||||||
|
return apiRequest<EntryTraceDto>(`/admin/channels/entries/${entryId}/trace`)
|
||||||
|
}
|
||||||
|
|
||||||
/** Сухой прогон по текущим правилам: ничего не пишет и не двигает курсоры слотов. */
|
/** Сухой прогон по текущим правилам: ничего не пишет и не двигает курсоры слотов. */
|
||||||
export function previewTemplate(channelId: string, days: number) {
|
export function previewTemplate(channelId: string, days: number) {
|
||||||
const query = new URLSearchParams({ days: String(days) })
|
const query = new URLSearchParams({ days: String(days) })
|
||||||
@@ -73,7 +107,12 @@ export function previewTemplate(channelId: string, days: number) {
|
|||||||
|
|
||||||
export function updateTemplate(
|
export function updateTemplate(
|
||||||
templateId: string,
|
templateId: string,
|
||||||
body: { name: string; fallbackGroupId: string | null; defaultJunctionId: string | null },
|
body: {
|
||||||
|
name: string
|
||||||
|
fallbackGroupId: string | null
|
||||||
|
defaultJunctionId: string | null
|
||||||
|
rules: PlanningRules | null
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
return apiRequest<void>(`/admin/templates/${templateId}`, { method: 'PUT', body })
|
return apiRequest<void>(`/admin/templates/${templateId}`, { method: 'PUT', body })
|
||||||
}
|
}
|
||||||
@@ -104,6 +143,12 @@ export function deleteLayer(layerId: string) {
|
|||||||
/** Тело слота: то же для создания и правки (см. SlotInput на сервере). */
|
/** Тело слота: то же для создания и правки (см. SlotInput на сервере). */
|
||||||
export type SlotBody = Omit<SlotDto, 'id' | 'layerId' | 'groupName'>
|
export type SlotBody = Omit<SlotDto, 'id' | 'layerId' | 'groupName'>
|
||||||
|
|
||||||
|
/** Слот из ответа сервера → тело запроса: отбрасываем то, что сервер проставляет сам. */
|
||||||
|
export function toSlotBody(slot: SlotDto): SlotBody {
|
||||||
|
const { id: _id, layerId: _layerId, groupName: _groupName, ...body } = slot
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
export function createSlot(layerId: string, body: SlotBody) {
|
export function createSlot(layerId: string, body: SlotBody) {
|
||||||
return apiRequest<CreatedIdResponse>(`/admin/layers/${layerId}/slots`, { method: 'POST', body })
|
return apiRequest<CreatedIdResponse>(`/admin/layers/${layerId}/slots`, { method: 'POST', body })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/shared/ui/dialog'
|
||||||
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
import { getApplyDiff } from '../api'
|
||||||
|
import { formatChannelTime } from '../lib/format'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Диф перед применением (см. 6.6). Изменения в ближайшие сутки подсвечены отдельно — это самая
|
||||||
|
* частая причина случайного ущерба: у зрителя из-под носа уезжает то, что он уже видит в программе.
|
||||||
|
*/
|
||||||
|
export function ApplyDialog({
|
||||||
|
channelId,
|
||||||
|
utcOffsetMinutes,
|
||||||
|
pending,
|
||||||
|
onApply,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
channelId: string
|
||||||
|
utcOffsetMinutes: number
|
||||||
|
pending: boolean
|
||||||
|
onApply: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { data, isFetching } = useQuery({
|
||||||
|
queryKey: ['admin', 'channels', channelId, 'diff'],
|
||||||
|
queryFn: () => getApplyDiff(channelId),
|
||||||
|
staleTime: 0,
|
||||||
|
gcTime: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('admin.channels.apply')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isFetching || !data
|
||||||
|
? t('common.loading')
|
||||||
|
: t('admin.channels.diffSummary', { total: data.total, changed: data.changed })}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<div className="flex flex-col gap-2 text-sm">
|
||||||
|
{data.changedSoon > 0 && (
|
||||||
|
<p className="flex items-center gap-1.5 text-amber-500">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||||
|
{t('admin.channels.diffSoon', { count: data.changedSoon })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.changes.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">{t('admin.channels.diffNoChanges')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="max-h-80 divide-y divide-border overflow-y-auto text-xs">
|
||||||
|
{data.changes.map((change, index) => (
|
||||||
|
<li
|
||||||
|
key={`${change.startsAtUtc}-${index}`}
|
||||||
|
className={cn('flex items-center gap-2 py-1', change.soon && 'text-amber-500')}
|
||||||
|
>
|
||||||
|
<span className="w-24 shrink-0 tabular-nums text-muted-foreground">
|
||||||
|
{formatChannelTime(change.startsAtUtc, utcOffsetMinutes)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{change.before ?? '—'}</span>
|
||||||
|
<span className="shrink-0 text-muted-foreground">→</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{change.after ?? '—'}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.changed > data.changes.length && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.channels.andMore', { count: data.changed - data.changes.length })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button size="sm" variant="outline" onClick={onClose}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" disabled={pending} onClick={onApply}>
|
||||||
|
{t('admin.channels.apply')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/shared/ui/dialog'
|
||||||
|
import { getEntryTrace } from '../api'
|
||||||
|
import { formatChannelTime } from '../lib/format'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Почему это здесь» (см. 6.5): цепочка происхождения записи. Трейс пишется в момент генерации —
|
||||||
|
* восстановить его потом нельзя, поэтому у старых записей часть строк будет пустой.
|
||||||
|
*/
|
||||||
|
export function EntryTraceDialog({
|
||||||
|
entryId,
|
||||||
|
utcOffsetMinutes,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
entryId: string
|
||||||
|
utcOffsetMinutes: number
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['admin', 'entries', entryId, 'trace'],
|
||||||
|
queryFn: () => getEntryTrace(entryId),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{data
|
||||||
|
? `${data.showName ?? '—'} · ${formatChannelTime(data.startsAtUtc, utcOffsetMinutes)}`
|
||||||
|
: t('common.loading')}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<dl className="grid grid-cols-[110px_1fr] gap-x-3 gap-y-1.5 text-sm">
|
||||||
|
<Row label={t('admin.channels.traceLayer')}>
|
||||||
|
{data.layerName
|
||||||
|
? `${data.layerName}${data.layerPriority !== null ? ` (${t('admin.channels.priority')} ${data.layerPriority})` : ''}`
|
||||||
|
: null}
|
||||||
|
</Row>
|
||||||
|
<Row label={t('admin.channels.traceSlot')}>
|
||||||
|
{data.slotTitle
|
||||||
|
? [
|
||||||
|
data.slotTitle,
|
||||||
|
data.slotWeekday === null
|
||||||
|
? t('admin.channels.everyDay')
|
||||||
|
: t(`admin.channels.weekdays.${data.slotWeekday}`),
|
||||||
|
data.slotTargetStart?.slice(0, 5),
|
||||||
|
data.slotDurationMinutes ? `${data.slotDurationMinutes} мин` : null,
|
||||||
|
data.driftMinutes !== 0
|
||||||
|
? t('admin.channels.traceDrift', { minutes: data.driftMinutes })
|
||||||
|
: null,
|
||||||
|
data.snapped ? t('admin.channels.traceSnapped') : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
: null}
|
||||||
|
</Row>
|
||||||
|
<Row label={t('admin.channels.traceGroup')}>
|
||||||
|
{data.groupName
|
||||||
|
? `${data.groupName}${data.groupItemCount !== null ? ` (${data.groupItemCount})` : ''}`
|
||||||
|
: null}
|
||||||
|
</Row>
|
||||||
|
<Row label={t('admin.channels.traceStrategy')}>
|
||||||
|
{data.strategy
|
||||||
|
? [
|
||||||
|
t(`admin.channels.strategies.${data.strategy}`),
|
||||||
|
data.cooldownDays
|
||||||
|
? t('admin.channels.traceCooldown', { days: data.cooldownDays })
|
||||||
|
: null,
|
||||||
|
data.candidatesAfterCooldown !== null
|
||||||
|
? t('admin.channels.traceCandidates', {
|
||||||
|
count: data.candidatesAfterCooldown,
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
: null}
|
||||||
|
</Row>
|
||||||
|
<Row label={t('admin.channels.traceJunction')}>{data.junctionName}</Row>
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted-foreground">{label}</dt>
|
||||||
|
<dd>{children || '—'}</dd>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -97,6 +97,7 @@ export function JunctionsCard({
|
|||||||
name: template!.name,
|
name: template!.name,
|
||||||
fallbackGroupId: template!.fallbackGroupId,
|
fallbackGroupId: template!.fallbackGroupId,
|
||||||
defaultJunctionId: junctionId,
|
defaultJunctionId: junctionId,
|
||||||
|
rules: template!.rules,
|
||||||
}),
|
}),
|
||||||
onSuccess: onChanged,
|
onSuccess: onChanged,
|
||||||
onError,
|
onError,
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { AnnualRange, DateRange, GridLayerDto, LayerApplicability } from '@/shared/api/types'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/shared/ui/dialog'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { updateLayer } from '../api'
|
||||||
|
import { isEmpty, toIsoDate } from '../lib/applicability'
|
||||||
|
|
||||||
|
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Когда действует слой (см. 3.4). Разделы объединяются по ИЛИ: слой применим, если дата подходит
|
||||||
|
* хотя бы под одно условие. Пустая применимость — слой действует всегда.
|
||||||
|
*/
|
||||||
|
export function LayerApplicabilityDialog({
|
||||||
|
layer,
|
||||||
|
onClose,
|
||||||
|
onChanged,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
layer: GridLayerDto
|
||||||
|
onClose: () => void
|
||||||
|
onChanged: () => void
|
||||||
|
onError: (error: unknown) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [name, setName] = useState(layer.name)
|
||||||
|
const [weekdays, setWeekdays] = useState<number[]>(layer.applicability?.weekdays ?? [])
|
||||||
|
const [dateRanges, setDateRanges] = useState<DateRange[]>(layer.applicability?.dateRanges ?? [])
|
||||||
|
const [annualRanges, setAnnualRanges] = useState<AnnualRange[]>(
|
||||||
|
layer.applicability?.annualRanges ?? [],
|
||||||
|
)
|
||||||
|
const [specificDates, setSpecificDates] = useState<string[]>(
|
||||||
|
layer.applicability?.specificDates ?? [],
|
||||||
|
)
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const applicability: LayerApplicability = {
|
||||||
|
weekdays: weekdays.length > 0 ? [...weekdays].sort((a, b) => a - b) : null,
|
||||||
|
dateRanges: dateRanges.length > 0 ? dateRanges : null,
|
||||||
|
annualRanges: annualRanges.length > 0 ? annualRanges : null,
|
||||||
|
specificDates: specificDates.length > 0 ? specificDates : null,
|
||||||
|
}
|
||||||
|
return updateLayer(layer.id, {
|
||||||
|
name: name.trim() || layer.name,
|
||||||
|
priority: layer.priority,
|
||||||
|
// Пустую применимость отправляем как null — «действует всегда» и «пустые списки» это одно
|
||||||
|
// и то же, но null читается однозначно.
|
||||||
|
applicability: isEmpty(applicability) ? null : applicability,
|
||||||
|
isEnabled: layer.isEnabled,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
onChanged()
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleWeekday = (day: number) =>
|
||||||
|
setWeekdays((current) =>
|
||||||
|
current.includes(day) ? current.filter((d) => d !== day) : [...current, day],
|
||||||
|
)
|
||||||
|
|
||||||
|
const today = toIsoDate(new Date())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('admin.channels.layerApplicability')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('admin.channels.applicabilityHint')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex max-h-[60vh] flex-col gap-4 overflow-y-auto text-sm">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.layerName')}</Label>
|
||||||
|
<Input value={name} maxLength={128} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.applicabilityWeekdays')}</Label>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{WEEKDAYS.map((day) => (
|
||||||
|
<button
|
||||||
|
key={day}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleWeekday(day)}
|
||||||
|
className={
|
||||||
|
weekdays.includes(day)
|
||||||
|
? 'rounded border border-primary bg-primary/15 px-2 py-1 text-xs text-primary'
|
||||||
|
: 'rounded border border-border px-2 py-1 text-xs text-muted-foreground'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(`admin.channels.weekdays.${day}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title={t('admin.channels.applicabilityDateRanges')}
|
||||||
|
onAdd={() => setDateRanges((c) => [...c, { from: today, to: today }])}
|
||||||
|
empty={dateRanges.length === 0}
|
||||||
|
>
|
||||||
|
{dateRanges.map((range, index) => (
|
||||||
|
<li key={index} className="flex flex-wrap items-end gap-2">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="w-40"
|
||||||
|
value={range.from}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDateRanges((c) =>
|
||||||
|
c.map((r, i) => (i === index ? { ...r, from: e.target.value } : r)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="w-40"
|
||||||
|
value={range.to}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDateRanges((c) =>
|
||||||
|
c.map((r, i) => (i === index ? { ...r, to: e.target.value } : r)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setDateRanges((c) => c.filter((_, i) => i !== index))}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title={t('admin.channels.applicabilityAnnual')}
|
||||||
|
onAdd={() =>
|
||||||
|
setAnnualRanges((c) => [...c, { fromMonth: 12, fromDay: 20, toMonth: 1, toDay: 8 }])
|
||||||
|
}
|
||||||
|
empty={annualRanges.length === 0}
|
||||||
|
>
|
||||||
|
{annualRanges.map((range, index) => (
|
||||||
|
<li key={index} className="flex flex-wrap items-end gap-2">
|
||||||
|
<MonthDay
|
||||||
|
value={range}
|
||||||
|
prefix="from"
|
||||||
|
onChange={(part) =>
|
||||||
|
setAnnualRanges((c) => c.map((r, i) => (i === index ? { ...r, ...part } : r)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="pb-2 text-muted-foreground">—</span>
|
||||||
|
<MonthDay
|
||||||
|
value={range}
|
||||||
|
prefix="to"
|
||||||
|
onChange={(part) =>
|
||||||
|
setAnnualRanges((c) => c.map((r, i) => (i === index ? { ...r, ...part } : r)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setAnnualRanges((c) => c.filter((_, i) => i !== index))}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title={t('admin.channels.applicabilityDates')}
|
||||||
|
onAdd={() => setSpecificDates((c) => [...c, today])}
|
||||||
|
empty={specificDates.length === 0}
|
||||||
|
>
|
||||||
|
{specificDates.map((date, index) => (
|
||||||
|
<li key={index} className="flex items-end gap-2">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="w-40"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSpecificDates((c) => c.map((d, i) => (i === index ? e.target.value : d)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setSpecificDates((c) => c.filter((_, i) => i !== index))}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button size="sm" variant="outline" onClick={onClose}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
onAdd,
|
||||||
|
empty,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
onAdd: () => void
|
||||||
|
empty: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label>{title}</Label>
|
||||||
|
<Button size="sm" variant="ghost" onClick={onAdd}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{empty ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.channels.applicabilityNone')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-2">{children}</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пара «месяц / день» ежегодного периода — год у него намеренно отсутствует. */
|
||||||
|
function MonthDay({
|
||||||
|
value,
|
||||||
|
prefix,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: AnnualRange
|
||||||
|
prefix: 'from' | 'to'
|
||||||
|
onChange: (part: Partial<AnnualRange>) => void
|
||||||
|
}) {
|
||||||
|
const month = prefix === 'from' ? value.fromMonth : value.toMonth
|
||||||
|
const day = prefix === 'from' ? value.fromDay : value.toDay
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="flex items-end gap-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={12}
|
||||||
|
className="w-16"
|
||||||
|
value={month}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(
|
||||||
|
prefix === 'from'
|
||||||
|
? { fromMonth: Number(e.target.value) }
|
||||||
|
: { toMonth: Number(e.target.value) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={31}
|
||||||
|
className="w-16"
|
||||||
|
value={day}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(
|
||||||
|
prefix === 'from'
|
||||||
|
? { fromDay: Number(e.target.value) }
|
||||||
|
: { toDay: Number(e.target.value) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
SHOW_AUDIENCES,
|
||||||
|
type AudienceWindow,
|
||||||
|
type PlanningRules,
|
||||||
|
type ScheduleTemplateDto,
|
||||||
|
type ShowAudience,
|
||||||
|
} from '@/shared/api/types'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { updateTemplate } from '../api'
|
||||||
|
import { CollapsibleCard } from './CollapsibleCard'
|
||||||
|
|
||||||
|
const EMPTY_WINDOW: AudienceWindow = { from: '06:00:00', to: '23:00:00', maxAudience: 'Teen' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие
|
||||||
|
* фильтры — они отсекают недопустимое до жребия, поэтому не требуют пересборки и не ломают
|
||||||
|
* воспроизводимость. Как и правка сетки, эфира сами по себе не двигают.
|
||||||
|
*/
|
||||||
|
export function RulesCard({
|
||||||
|
template,
|
||||||
|
onChanged,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
template: ScheduleTemplateDto
|
||||||
|
onChanged: () => void
|
||||||
|
onError: (error: unknown) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [windows, setWindows] = useState<AudienceWindow[]>(
|
||||||
|
() => template.rules?.maxAudienceByTime ?? [],
|
||||||
|
)
|
||||||
|
const [limitOn, setLimitOn] = useState(() => template.rules?.maxRepeatsInWindow != null)
|
||||||
|
const [windowDays, setWindowDays] = useState(
|
||||||
|
() => template.rules?.maxRepeatsInWindow?.windowDays ?? 7,
|
||||||
|
)
|
||||||
|
const [max, setMax] = useState(() => template.rules?.maxRepeatsInWindow?.max ?? 2)
|
||||||
|
const [breakCap, setBreakCap] = useState(() => template.rules?.maxBreakMinutesPerHour ?? 0)
|
||||||
|
const [genreCap, setGenreCap] = useState(() => template.rules?.maxGenreSharePercent ?? 0)
|
||||||
|
const [fallbackCap, setFallbackCap] = useState(() => template.rules?.maxFallbackSharePercent ?? 0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setWindows(template.rules?.maxAudienceByTime ?? [])
|
||||||
|
setLimitOn(template.rules?.maxRepeatsInWindow != null)
|
||||||
|
setWindowDays(template.rules?.maxRepeatsInWindow?.windowDays ?? 7)
|
||||||
|
setMax(template.rules?.maxRepeatsInWindow?.max ?? 2)
|
||||||
|
setBreakCap(template.rules?.maxBreakMinutesPerHour ?? 0)
|
||||||
|
setGenreCap(template.rules?.maxGenreSharePercent ?? 0)
|
||||||
|
setFallbackCap(template.rules?.maxFallbackSharePercent ?? 0)
|
||||||
|
}, [template])
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const rules: PlanningRules = {
|
||||||
|
maxAudienceByTime: windows.length > 0 ? windows : null,
|
||||||
|
maxRepeatsInWindow: limitOn ? { windowDays, max } : null,
|
||||||
|
// Ноль означает «не проверять»: отдельного выключателя на каждый порог не нужно.
|
||||||
|
maxBreakMinutesPerHour: breakCap > 0 ? breakCap : null,
|
||||||
|
maxGenreSharePercent: genreCap > 0 ? genreCap : null,
|
||||||
|
maxFallbackSharePercent: fallbackCap > 0 ? fallbackCap : null,
|
||||||
|
}
|
||||||
|
return updateTemplate(template.id, {
|
||||||
|
name: template.name,
|
||||||
|
fallbackGroupId: template.fallbackGroupId,
|
||||||
|
defaultJunctionId: template.defaultJunctionId,
|
||||||
|
rules,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: onChanged,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const patchWindow = (index: number, part: Partial<AudienceWindow>) =>
|
||||||
|
setWindows((current) => current.map((w, i) => (i === index ? { ...w, ...part } : w)))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleCard title={t('admin.channels.rules')}>
|
||||||
|
<div className="flex flex-col gap-4 text-sm">
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.channels.rulesHint')}</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t('admin.channels.audienceWindows')}
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setWindows((c) => [...c, EMPTY_WINDOW])}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{windows.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.channels.noAudienceWindows')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{windows.map((window, index) => (
|
||||||
|
<li key={index} className="flex flex-wrap items-end gap-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.from')}</Label>
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
className="w-28"
|
||||||
|
value={window.from.slice(0, 5)}
|
||||||
|
onChange={(e) => patchWindow(index, { from: `${e.target.value}:00` })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.to')}</Label>
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
className="w-28"
|
||||||
|
value={window.to.slice(0, 5)}
|
||||||
|
onChange={(e) => patchWindow(index, { to: `${e.target.value}:00` })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.maxAudience')}</Label>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||||
|
value={window.maxAudience}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchWindow(index, { maxAudience: e.target.value as ShowAudience })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{SHOW_AUDIENCES.map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{t(`admin.shows.audiences.${value}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setWindows((c) => c.filter((_, i) => i !== index))}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.channels.audienceWindowsHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 border-t border-border pt-4">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={limitOn}
|
||||||
|
onChange={(e) => setLimitOn(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t('admin.channels.repeatLimit')}
|
||||||
|
</label>
|
||||||
|
{limitOn && (
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.repeatWindowDays')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
className="w-28"
|
||||||
|
value={windowDays}
|
||||||
|
onChange={(e) => setWindowDays(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.repeatMax')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="w-28"
|
||||||
|
value={max}
|
||||||
|
onChange={(e) => setMax(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.channels.repeatLimitHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 border-t border-border pt-4">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t('admin.channels.postChecks')}
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.breakLimit')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="w-28"
|
||||||
|
value={breakCap}
|
||||||
|
onChange={(e) => setBreakCap(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.genreShare')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
className="w-28"
|
||||||
|
value={genreCap}
|
||||||
|
onChange={(e) => setGenreCap(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.fallbackShare')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
className="w-28"
|
||||||
|
value={fallbackCap}
|
||||||
|
onChange={(e) => setFallbackCap(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.channels.postChecksHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import { Anchor, Plus } from 'lucide-react'
|
import { Anchor, CalendarRange, Copy, GripVertical, Plus, Repeat } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { GridLayerDto, ScheduleTemplateDto, SlotDto } from '@/shared/api/types'
|
import type { GridLayerDto, ScheduleTemplateDto, SlotDto } from '@/shared/api/types'
|
||||||
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 { coversDate, isEmpty } from '../lib/applicability'
|
||||||
|
|
||||||
const HOUR_HEIGHT = 44
|
const HOUR_HEIGHT = 44
|
||||||
|
|
||||||
|
/** Шаг сетки при перетаскивании и растягивании — минуты. */
|
||||||
|
const SNAP_MINUTES = 15
|
||||||
|
|
||||||
|
const snap = (minutes: number) => Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES
|
||||||
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0]
|
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0]
|
||||||
|
|
||||||
/** Цвет блока — по дейпарту: сетка должна читаться одним взглядом, без легенды. */
|
/** Цвет блока — по дейпарту: сетка должна читаться одним взглядом, без легенды. */
|
||||||
@@ -43,13 +50,23 @@ function slotsOfDay(layers: GridLayerDto[], weekday: number) {
|
|||||||
export function ScheduleGrid({
|
export function ScheduleGrid({
|
||||||
template,
|
template,
|
||||||
selectedSlotId,
|
selectedSlotId,
|
||||||
|
viewDate,
|
||||||
onSelectSlot,
|
onSelectSlot,
|
||||||
onAddSlot,
|
onAddSlot,
|
||||||
|
onMoveSlot,
|
||||||
|
onResizeSlot,
|
||||||
|
onCopyDay,
|
||||||
}: {
|
}: {
|
||||||
template: ScheduleTemplateDto
|
template: ScheduleTemplateDto
|
||||||
selectedSlotId: string | null
|
selectedSlotId: string | null
|
||||||
|
/** Дата, на которую смотрим сетку («показать 25 декабря»); null — все слои разом. */
|
||||||
|
viewDate: string | null
|
||||||
onSelectSlot: (slot: SlotDto) => void
|
onSelectSlot: (slot: SlotDto) => void
|
||||||
onAddSlot: (weekday: number, startMinutes: number) => void
|
onAddSlot: (weekday: number, startMinutes: number) => void
|
||||||
|
/** Перенос слота: новое время старта и (для слота с днём недели) новый день. */
|
||||||
|
onMoveSlot: (slot: SlotDto, weekday: number, startMinutes: number) => void
|
||||||
|
onResizeSlot: (slot: SlotDto, durationMinutes: number) => void
|
||||||
|
onCopyDay: (fromWeekday: number) => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const dayStart = template.dayStartTime.slice(0, 5)
|
const dayStart = template.dayStartTime.slice(0, 5)
|
||||||
@@ -58,8 +75,61 @@ export function ScheduleGrid({
|
|||||||
// Подписи часов идут от начала вещательных суток, а не от полуночи.
|
// Подписи часов идут от начала вещательных суток, а не от полуночи.
|
||||||
const hours = Array.from({ length: 24 }, (_, i) => (dayStartMinutes / 60 + i) % 24)
|
const hours = Array.from({ length: 24 }, (_, i) => (dayStartMinutes / 60 + i) % 24)
|
||||||
|
|
||||||
|
// На выбранную дату показываем только те слои, которые в этот день действуют, — иначе сетка
|
||||||
|
// «на 25 декабря» показывала бы и обычный день, и новогодний одновременно.
|
||||||
|
const day = viewDate ? parseIsoDate(viewDate) : null
|
||||||
|
const applicable = day
|
||||||
|
? template.layers.filter((layer) => coversDate(layer.applicability, day))
|
||||||
|
: template.layers
|
||||||
|
|
||||||
// Слои отсортированы по убыванию приоритета: слот, лежащий под более приоритетным, штрихуем.
|
// Слои отсортированы по убыванию приоритета: слот, лежащий под более приоритетным, штрихуем.
|
||||||
const ordered = [...template.layers].sort((a, b) => b.priority - a.priority)
|
const ordered = [...applicable].sort((a, b) => b.priority - a.priority)
|
||||||
|
const highlightWeekday = day?.getDay() ?? null
|
||||||
|
|
||||||
|
const [dragged, setDragged] = useState<SlotDto | null>(null)
|
||||||
|
const [resizing, setResizing] = useState<{ slot: SlotDto; minutes: number } | null>(null)
|
||||||
|
|
||||||
|
/** Позиция курсора в колонке дня — минуты суток, округлённые до шага сетки. */
|
||||||
|
const minutesAt = (clientY: number, column: HTMLElement) => {
|
||||||
|
const rect = column.getBoundingClientRect()
|
||||||
|
const offset = Math.max(0, Math.min(rect.height, clientY - rect.top))
|
||||||
|
const fromDayStart = snap((offset / HOUR_HEIGHT) * 60)
|
||||||
|
return (dayStartMinutes + fromDayStart) % (24 * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
const drop = (event: React.DragEvent<HTMLDivElement>, weekday: number) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!dragged) return
|
||||||
|
// Слот «каждый день» при переносе таким и остаётся: молча превратить его в слот одного дня
|
||||||
|
// значило бы убрать его сразу из шести колонок.
|
||||||
|
onMoveSlot(dragged, dragged.weekday ?? weekday, minutesAt(event.clientY, event.currentTarget))
|
||||||
|
setDragged(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Растягивание за нижний край: пока тянем — видно новую высоту, отпустили — сохраняем. */
|
||||||
|
const startResize = (event: React.MouseEvent, slot: SlotDto, column: HTMLElement) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
const from = offsetInDay(slot.targetStart, dayStart)
|
||||||
|
|
||||||
|
const move = (moveEvent: MouseEvent) => {
|
||||||
|
const rect = column.getBoundingClientRect()
|
||||||
|
const offset = Math.max(0, Math.min(rect.height, moveEvent.clientY - rect.top))
|
||||||
|
const end = snap((offset / HOUR_HEIGHT) * 60)
|
||||||
|
setResizing({ slot, minutes: Math.max(SNAP_MINUTES, end - from) })
|
||||||
|
}
|
||||||
|
const up = () => {
|
||||||
|
window.removeEventListener('mousemove', move)
|
||||||
|
window.removeEventListener('mouseup', up)
|
||||||
|
setResizing((current) => {
|
||||||
|
if (current && current.minutes !== slot.targetDurationMinutes)
|
||||||
|
onResizeSlot(slot, current.minutes)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
window.addEventListener('mousemove', move)
|
||||||
|
window.addEventListener('mouseup', up)
|
||||||
|
}
|
||||||
|
|
||||||
const isCovered = (slot: SlotDto, layer: GridLayerDto, weekday: number) => {
|
const isCovered = (slot: SlotDto, layer: GridLayerDto, weekday: number) => {
|
||||||
const from = offsetInDay(slot.targetStart, dayStart)
|
const from = offsetInDay(slot.targetStart, dayStart)
|
||||||
@@ -82,8 +152,22 @@ export function ScheduleGrid({
|
|||||||
<div className="grid grid-cols-[56px_repeat(7,1fr)] border-b border-border text-xs text-muted-foreground">
|
<div className="grid grid-cols-[56px_repeat(7,1fr)] border-b border-border text-xs text-muted-foreground">
|
||||||
<div className="px-2 py-1">{dayStart}</div>
|
<div className="px-2 py-1">{dayStart}</div>
|
||||||
{WEEKDAYS.map((weekday) => (
|
{WEEKDAYS.map((weekday) => (
|
||||||
<div key={weekday} className="px-2 py-1 text-center font-medium">
|
<div
|
||||||
|
key={weekday}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center gap-1 px-2 py-1 font-medium',
|
||||||
|
highlightWeekday === weekday && 'text-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{t(`admin.channels.weekdays.${weekday}`)}
|
{t(`admin.channels.weekdays.${weekday}`)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t('admin.channels.copyDay')}
|
||||||
|
className="opacity-40 hover:opacity-100"
|
||||||
|
onClick={() => onCopyDay(weekday)}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -104,8 +188,14 @@ export function ScheduleGrid({
|
|||||||
{WEEKDAYS.map((weekday) => (
|
{WEEKDAYS.map((weekday) => (
|
||||||
<div
|
<div
|
||||||
key={weekday}
|
key={weekday}
|
||||||
className="relative border-l border-border"
|
className={cn(
|
||||||
|
'relative border-l border-border',
|
||||||
|
highlightWeekday === weekday && 'bg-primary/5',
|
||||||
|
dragged && 'bg-primary/10',
|
||||||
|
)}
|
||||||
style={{ height: HOUR_HEIGHT * 24 }}
|
style={{ height: HOUR_HEIGHT * 24 }}
|
||||||
|
onDragOver={(e) => dragged && e.preventDefault()}
|
||||||
|
onDrop={(e) => drop(e, weekday)}
|
||||||
>
|
>
|
||||||
{hours.map((_, index) => (
|
{hours.map((_, index) => (
|
||||||
<button
|
<button
|
||||||
@@ -123,29 +213,43 @@ export function ScheduleGrid({
|
|||||||
{slotsOfDay(ordered, weekday).map(({ slot, layer }) => {
|
{slotsOfDay(ordered, weekday).map(({ slot, layer }) => {
|
||||||
const from = offsetInDay(slot.targetStart, dayStart)
|
const from = offsetInDay(slot.targetStart, dayStart)
|
||||||
const covered = isCovered(slot, layer, weekday)
|
const covered = isCovered(slot, layer, weekday)
|
||||||
|
const minutes =
|
||||||
|
resizing?.slot.id === slot.id ? resizing.minutes : slot.targetDurationMinutes
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={`${slot.id}-${weekday}`}
|
key={`${slot.id}-${weekday}`}
|
||||||
type="button"
|
draggable
|
||||||
|
onDragStart={() => setDragged(slot)}
|
||||||
|
onDragEnd={() => setDragged(null)}
|
||||||
onClick={() => onSelectSlot(slot)}
|
onClick={() => onSelectSlot(slot)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-x-1 overflow-hidden rounded border px-1.5 py-0.5 text-left text-[11px] leading-tight',
|
'absolute inset-x-1 cursor-grab overflow-hidden rounded border px-1.5 py-0.5 text-left text-[11px] leading-tight',
|
||||||
DAYPART_CLASS[slot.daypart] ?? DAYPART_CLASS.Day,
|
DAYPART_CLASS[slot.daypart] ?? DAYPART_CLASS.Day,
|
||||||
selectedSlotId === slot.id && 'ring-2 ring-primary',
|
selectedSlotId === slot.id && 'ring-2 ring-primary',
|
||||||
|
dragged?.id === slot.id && 'opacity-50',
|
||||||
// Перекрытый слот виден, но приглушён: он не сыграет, пока лежит под старшим слоем.
|
// Перекрытый слот виден, но приглушён: он не сыграет, пока лежит под старшим слоем.
|
||||||
covered && 'opacity-40 [background-image:repeating-linear-gradient(45deg,transparent,transparent_4px,rgba(0,0,0,.15)_4px,rgba(0,0,0,.15)_8px)]',
|
covered && 'opacity-40 [background-image:repeating-linear-gradient(45deg,transparent,transparent_4px,rgba(0,0,0,.15)_4px,rgba(0,0,0,.15)_8px)]',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
top: (from / 60) * HOUR_HEIGHT,
|
top: (from / 60) * HOUR_HEIGHT,
|
||||||
height: Math.max(16, (slot.targetDurationMinutes / 60) * HOUR_HEIGHT - 2),
|
height: Math.max(16, (minutes / 60) * HOUR_HEIGHT - 2),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-1 font-medium">
|
<span className="flex items-center gap-1 font-medium">
|
||||||
{slot.isAnchor && <Anchor className="h-3 w-3 shrink-0" />}
|
{slot.isAnchor && <Anchor className="h-3 w-3 shrink-0" />}
|
||||||
{slot.targetStart.slice(0, 5)}
|
{slot.targetStart.slice(0, 5)}
|
||||||
|
{slot.weekday === null && <Repeat className="h-3 w-3 shrink-0 opacity-60" />}
|
||||||
</span>
|
</span>
|
||||||
<span className="block truncate">{slot.title}</span>
|
<span className="block truncate">{slot.title}</span>
|
||||||
</button>
|
<span
|
||||||
|
role="presentation"
|
||||||
|
title={t('admin.channels.resizeSlot')}
|
||||||
|
className="absolute inset-x-0 bottom-0 h-1.5 cursor-ns-resize hover:bg-primary/40"
|
||||||
|
onMouseDown={(e) =>
|
||||||
|
startResize(e, slot, e.currentTarget.parentElement!.parentElement!)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -156,48 +260,113 @@ export function ScheduleGrid({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Панель слоёв: видимость, приоритет и выбор редактируемого. */
|
/**
|
||||||
|
* Панель слоёв: видимость, приоритет (перетаскиванием), применимость и выбор редактируемого.
|
||||||
|
* Выше в списке — приоритетнее; фоновый слой всегда внизу и не двигается.
|
||||||
|
*/
|
||||||
export function LayerList({
|
export function LayerList({
|
||||||
template,
|
template,
|
||||||
activeLayerId,
|
activeLayerId,
|
||||||
|
viewDate,
|
||||||
onSelect,
|
onSelect,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onToggle,
|
||||||
|
onReorder,
|
||||||
|
onEditApplicability,
|
||||||
}: {
|
}: {
|
||||||
template: ScheduleTemplateDto
|
template: ScheduleTemplateDto
|
||||||
activeLayerId: string | null
|
activeLayerId: string | null
|
||||||
|
viewDate: string | null
|
||||||
onSelect: (layer: GridLayerDto) => void
|
onSelect: (layer: GridLayerDto) => void
|
||||||
onDelete: (layer: GridLayerDto) => void
|
onDelete: (layer: GridLayerDto) => void
|
||||||
|
onToggle: (layer: GridLayerDto) => void
|
||||||
|
onReorder: (layerIdsTopFirst: string[]) => void
|
||||||
|
onEditApplicability: (layer: GridLayerDto) => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const [dragged, setDragged] = useState<string | null>(null)
|
||||||
|
|
||||||
const ordered = [...template.layers].sort((a, b) => b.priority - a.priority)
|
const ordered = [...template.layers].sort((a, b) => b.priority - a.priority)
|
||||||
|
const day = viewDate ? parseIsoDate(viewDate) : null
|
||||||
|
|
||||||
|
const dropOn = (targetId: string) => {
|
||||||
|
if (!dragged || dragged === targetId) return
|
||||||
|
const movable = ordered.filter((l) => !l.isBackground).map((l) => l.id)
|
||||||
|
const order = movable.filter((id) => id !== dragged)
|
||||||
|
const at = order.indexOf(targetId)
|
||||||
|
// Бросок на фоновый слой означает «в самый низ»: он в порядке не участвует.
|
||||||
|
order.splice(at === -1 ? order.length : at, 0, dragged)
|
||||||
|
setDragged(null)
|
||||||
|
onReorder(order)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="divide-y divide-border text-sm">
|
<ul className="divide-y divide-border text-sm">
|
||||||
{ordered.map((layer) => (
|
{ordered.map((layer) => {
|
||||||
<li key={layer.id} className="flex items-center gap-2 py-1.5">
|
const inactiveToday = day !== null && !coversDate(layer.applicability, day)
|
||||||
<button
|
return (
|
||||||
type="button"
|
<li
|
||||||
className={cn(
|
key={layer.id}
|
||||||
'min-w-0 flex-1 truncate text-left',
|
draggable={!layer.isBackground}
|
||||||
activeLayerId === layer.id && 'text-primary',
|
onDragStart={() => setDragged(layer.id)}
|
||||||
)}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
onClick={() => onSelect(layer)}
|
onDrop={() => dropOn(layer.id)}
|
||||||
|
className={cn('flex items-center gap-1.5 py-1.5', inactiveToday && 'opacity-40')}
|
||||||
>
|
>
|
||||||
{layer.name}
|
{layer.isBackground ? (
|
||||||
</button>
|
<span className="w-4 shrink-0" />
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">
|
) : (
|
||||||
{layer.isBackground ? t('admin.channels.background') : layer.priority}
|
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
|
||||||
</span>
|
)}
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">
|
<input
|
||||||
{layer.slots.length}
|
type="checkbox"
|
||||||
</span>
|
className="shrink-0"
|
||||||
{!layer.isBackground && (
|
title={t('admin.channels.layerVisible')}
|
||||||
<Button size="sm" variant="ghost" onClick={() => onDelete(layer)}>
|
checked={layer.isEnabled}
|
||||||
×
|
onChange={() => onToggle(layer)}
|
||||||
</Button>
|
/>
|
||||||
)}
|
<button
|
||||||
</li>
|
type="button"
|
||||||
))}
|
className={cn(
|
||||||
|
'min-w-0 flex-1 truncate text-left',
|
||||||
|
activeLayerId === layer.id && 'text-primary',
|
||||||
|
)}
|
||||||
|
onClick={() => onSelect(layer)}
|
||||||
|
>
|
||||||
|
{layer.name}
|
||||||
|
</button>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{layer.isBackground ? t('admin.channels.background') : layer.slots.length}
|
||||||
|
</span>
|
||||||
|
{!layer.isBackground && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
title={t('admin.channels.layerApplicability')}
|
||||||
|
onClick={() => onEditApplicability(layer)}
|
||||||
|
>
|
||||||
|
<CalendarRange
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4',
|
||||||
|
isEmpty(layer.applicability) ? 'text-muted-foreground' : 'text-primary',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => onDelete(layer)}>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** «2026-12-25» → локальная дата. `new Date(iso)` разобрал бы её как UTC и сместил день. */
|
||||||
|
function parseIsoDate(iso: string): Date {
|
||||||
|
const [year, month, day] = iso.split('-').map(Number)
|
||||||
|
return new Date(year, month - 1, day)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
|
import { HelpCircle } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { ScheduleEntryDto } from '@/shared/api/types'
|
import type { ScheduleEntryDto } from '@/shared/api/types'
|
||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { formatTime } from '../lib/format'
|
import { formatTime } from '../lib/format'
|
||||||
|
|
||||||
export function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) {
|
export function SchedulePreview({
|
||||||
|
entries,
|
||||||
|
onShowTrace,
|
||||||
|
}: {
|
||||||
|
entries: ScheduleEntryDto[]
|
||||||
|
onShowTrace: (entryId: string) => void
|
||||||
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
if (entries.length === 0)
|
if (entries.length === 0)
|
||||||
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||||
@@ -11,7 +18,7 @@ export function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) {
|
|||||||
return (
|
return (
|
||||||
<ul className="flex flex-col divide-y divide-border text-sm">
|
<ul className="flex flex-col divide-y divide-border text-sm">
|
||||||
{entries.slice(0, 40).map((e) => (
|
{entries.slice(0, 40).map((e) => (
|
||||||
<li key={e.id} className="flex items-center gap-3 py-1.5">
|
<li key={e.id} className="group flex items-center gap-3 py-1.5">
|
||||||
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
||||||
{formatTime(e.startsAtUtc)}
|
{formatTime(e.startsAtUtc)}
|
||||||
</span>
|
</span>
|
||||||
@@ -45,6 +52,14 @@ export function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) {
|
|||||||
)}
|
)}
|
||||||
</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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -16,7 +16,14 @@ import { Button } from '@/shared/ui/button'
|
|||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { createSlot, deleteSlot, listJunctions, updateSlot, type SlotBody } from '../api'
|
import {
|
||||||
|
createSlot,
|
||||||
|
deleteSlot,
|
||||||
|
listJunctions,
|
||||||
|
toSlotBody,
|
||||||
|
updateSlot,
|
||||||
|
type SlotBody,
|
||||||
|
} from '../api'
|
||||||
|
|
||||||
const DAYPARTS: Daypart[] = ['Morning', 'Day', 'Prime', 'Night']
|
const DAYPARTS: Daypart[] = ['Morning', 'Day', 'Prime', 'Night']
|
||||||
const SLOT_KINDS: SlotKind[] = ['Content', 'Repeat', 'SignOff']
|
const SLOT_KINDS: SlotKind[] = ['Content', 'Repeat', 'SignOff']
|
||||||
@@ -28,11 +35,6 @@ const SNAP_OPTIONS = [0, 5, 10, 15, 30]
|
|||||||
/** Черновик слота: новый (layerId + предзаполненные время/день) либо существующий. */
|
/** Черновик слота: новый (layerId + предзаполненные время/день) либо существующий. */
|
||||||
export type SlotDraft = { layerId: string; slot: SlotDto | null; defaults?: Partial<SlotBody> }
|
export type SlotDraft = { layerId: string; slot: SlotDto | null; defaults?: Partial<SlotBody> }
|
||||||
|
|
||||||
function toBody(slot: SlotDto): SlotBody {
|
|
||||||
const { id: _id, layerId: _layerId, groupName: _groupName, ...body } = slot
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyBody(defaults?: Partial<SlotBody>): SlotBody {
|
function emptyBody(defaults?: Partial<SlotBody>): SlotBody {
|
||||||
return {
|
return {
|
||||||
title: '',
|
title: '',
|
||||||
@@ -74,11 +76,11 @@ export function SlotInspector({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [body, setBody] = useState<SlotBody>(() =>
|
const [body, setBody] = useState<SlotBody>(() =>
|
||||||
draft.slot ? toBody(draft.slot) : emptyBody(draft.defaults),
|
draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults),
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBody(draft.slot ? toBody(draft.slot) : emptyBody(draft.defaults))
|
setBody(draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults))
|
||||||
}, [draft])
|
}, [draft])
|
||||||
|
|
||||||
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
|
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { AlertTriangle, CircleAlert } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { SlotDto, TemplateIssueDto } from '@/shared/api/types'
|
||||||
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
import { getTemplateIssues } from '../api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверки по правилам (см. 5.1). Считаются на сервере по шаблону, без прогона генератора, поэтому
|
||||||
|
* показываются прямо в редакторе и обновляются вместе с сеткой.
|
||||||
|
*/
|
||||||
|
export function TemplateIssues({
|
||||||
|
channelId,
|
||||||
|
slotsById,
|
||||||
|
onGoToSlot,
|
||||||
|
}: {
|
||||||
|
channelId: string
|
||||||
|
slotsById: Map<string, SlotDto>
|
||||||
|
onGoToSlot: (slot: SlotDto) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { data: issues } = useQuery({
|
||||||
|
queryKey: ['admin', 'channels', channelId, 'issues'],
|
||||||
|
queryFn: () => getTemplateIssues(channelId),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!issues || issues.length === 0) return null
|
||||||
|
|
||||||
|
const errors = issues.filter((i) => i.severity === 'Error')
|
||||||
|
const warnings = issues.filter((i) => i.severity === 'Warning')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 rounded-md border border-border px-3 py-2 text-xs">
|
||||||
|
<span className="font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t('admin.channels.issues', { errors: errors.length, warnings: warnings.length })}
|
||||||
|
</span>
|
||||||
|
<ul className="flex flex-col gap-0.5">
|
||||||
|
{[...errors, ...warnings].map((issue, index) => (
|
||||||
|
<IssueRow
|
||||||
|
key={`${issue.kind}-${index}`}
|
||||||
|
issue={issue}
|
||||||
|
slot={issue.slotId ? slotsById.get(issue.slotId) : undefined}
|
||||||
|
onGoToSlot={onGoToSlot}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function IssueRow({
|
||||||
|
issue,
|
||||||
|
slot,
|
||||||
|
onGoToSlot,
|
||||||
|
}: {
|
||||||
|
issue: TemplateIssueDto
|
||||||
|
slot: SlotDto | undefined
|
||||||
|
onGoToSlot: (slot: SlotDto) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const Icon = issue.severity === 'Error' ? CircleAlert : AlertTriangle
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="flex items-start gap-1.5">
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
'mt-0.5 h-3 w-3 shrink-0',
|
||||||
|
issue.severity === 'Error' ? 'text-red-500' : 'text-amber-500',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t(`admin.channels.issueKinds.${issue.kind}`)}:{' '}
|
||||||
|
</span>
|
||||||
|
{issue.details}
|
||||||
|
</span>
|
||||||
|
{slot && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 text-primary hover:underline"
|
||||||
|
onClick={() => onGoToSlot(slot)}
|
||||||
|
>
|
||||||
|
{t('admin.channels.goToSlot')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ 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> = {
|
const KIND_COLORS: Record<PlannedItemKind, string> = {
|
||||||
Program: 'bg-primary/70',
|
Program: 'bg-primary/70',
|
||||||
@@ -29,7 +30,7 @@ export function TemplatePreview({ channelId }: { channelId: string }) {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [days, setDays] = useState(1)
|
const [days, setDays] = useState(1)
|
||||||
const [tab, setTab] = useState<'programme' | 'tape'>('programme')
|
const [tab, setTab] = useState<'programme' | 'tape' | 'problems'>('programme')
|
||||||
|
|
||||||
const { data, isFetching } = useQuery({
|
const { data, isFetching } = useQuery({
|
||||||
queryKey: ['admin', 'channels', channelId, 'preview', days],
|
queryKey: ['admin', 'channels', channelId, 'preview', days],
|
||||||
@@ -70,7 +71,7 @@ export function TemplatePreview({ channelId }: { channelId: string }) {
|
|||||||
{open && data && (
|
{open && data && (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex gap-2 border-b border-border text-xs uppercase tracking-wide">
|
<div className="flex gap-2 border-b border-border text-xs uppercase tracking-wide">
|
||||||
{(['programme', 'tape'] as const).map((value) => (
|
{(['programme', 'tape', 'problems'] as const).map((value) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -81,21 +82,14 @@ export function TemplatePreview({ channelId }: { channelId: string }) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t(`admin.channels.previewTabs.${value}`)}
|
{t(`admin.channels.previewTabs.${value}`)}
|
||||||
|
{value === 'problems' && data.warnings.length > 0 && ` · ${data.warnings.length}`}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'programme' ? <Programme preview={data} /> : <Tape preview={data} />}
|
{tab === 'programme' && <Programme preview={data} />}
|
||||||
|
{tab === 'tape' && <Tape preview={data} />}
|
||||||
{data.warnings.length > 0 && (
|
{tab === 'problems' && <Problems preview={data} />}
|
||||||
<ul className="flex flex-col gap-1 text-xs text-amber-500">
|
|
||||||
{data.warnings.map((warning, index) => (
|
|
||||||
<li key={`${warning.kind}-${index}`}>
|
|
||||||
{t(`admin.channels.warnings.${warning.kind}`)}: {warning.details}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -203,3 +197,131 @@ function TapeRow({ item, preview }: { item: PreviewItemDto; preview: SchedulePre
|
|||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Предупреждения, сгруппированные по виду: десять однотипных строк читаются как одна проблема. */
|
||||||
|
function Problems({ preview }: { preview: SchedulePreviewDto }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<string, string[]>()
|
||||||
|
for (const warning of preview.warnings) {
|
||||||
|
const list = map.get(warning.kind) ?? []
|
||||||
|
list.push(warning.details)
|
||||||
|
map.set(warning.kind, list)
|
||||||
|
}
|
||||||
|
return [...map.entries()]
|
||||||
|
}, [preview])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{grouped.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('admin.channels.noProblems')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-2 text-xs">
|
||||||
|
{grouped.map(([kind, details]) => (
|
||||||
|
<li key={kind} className="flex flex-col gap-0.5">
|
||||||
|
<span className="font-medium text-amber-500">
|
||||||
|
{t(`admin.channels.warnings.${kind}`)} · {details.length}
|
||||||
|
</span>
|
||||||
|
{details.slice(0, 20).map((detail, index) => (
|
||||||
|
<span key={index} className="text-muted-foreground">
|
||||||
|
{detail}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{details.length > 20 && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t('admin.channels.andMore', { count: details.length - 20 })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RepeatHeatmap preview={preview} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тепловая карта повторов: матрица «шоу × вещательные сутки», яркость — число показов. Сразу видно,
|
||||||
|
* что один фильм крутится четыре раза за неделю.
|
||||||
|
*/
|
||||||
|
function RepeatHeatmap({ preview }: { preview: SchedulePreviewDto }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { days, rows } = useMemo(() => {
|
||||||
|
const counts = new Map<string, Map<string, number>>()
|
||||||
|
const dayKeys = new Set<string>()
|
||||||
|
|
||||||
|
for (const item of preview.items) {
|
||||||
|
if (item.kind !== 'Program' || !item.title) continue
|
||||||
|
const day = toIsoDate(channelTime(item.startsAtUtc, preview.utcOffsetMinutes))
|
||||||
|
dayKeys.add(day)
|
||||||
|
const row = counts.get(item.title) ?? new Map<string, number>()
|
||||||
|
row.set(day, (row.get(day) ?? 0) + 1)
|
||||||
|
counts.set(item.title, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedDays = [...dayKeys].sort()
|
||||||
|
const sortedRows = [...counts.entries()]
|
||||||
|
.map(([title, byDay]) => ({
|
||||||
|
title,
|
||||||
|
byDay,
|
||||||
|
total: [...byDay.values()].reduce((sum, n) => sum + n, 0),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.total - a.total)
|
||||||
|
.slice(0, 25)
|
||||||
|
|
||||||
|
return { days: sortedDays, rows: sortedRows }
|
||||||
|
}, [preview])
|
||||||
|
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
|
||||||
|
const peak = Math.max(1, ...rows.flatMap((row) => [...row.byDay.values()]))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t('admin.channels.heatmap')}
|
||||||
|
</span>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="text-[11px]">
|
||||||
|
<thead className="text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-1 text-left font-medium" />
|
||||||
|
{days.map((day) => (
|
||||||
|
<th key={day} className="px-1 font-medium">
|
||||||
|
{day.slice(8)}.{day.slice(5, 7)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="px-1 font-medium">{t('admin.channels.heatmapTotal')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={row.title}>
|
||||||
|
<td className="max-w-56 truncate px-1" title={row.title}>
|
||||||
|
{row.title}
|
||||||
|
</td>
|
||||||
|
{days.map((day) => {
|
||||||
|
const count = row.byDay.get(day) ?? 0
|
||||||
|
return (
|
||||||
|
<td key={day} className="px-0.5 py-0.5">
|
||||||
|
<span
|
||||||
|
className="block h-4 w-6 rounded-sm bg-primary text-center text-[10px] leading-4"
|
||||||
|
style={{ opacity: count === 0 ? 0.06 : 0.25 + (count / peak) * 0.75 }}
|
||||||
|
>
|
||||||
|
{count > 0 ? count : ''}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<td className="px-1 tabular-nums text-muted-foreground">{row.total}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { imageUrl } from '@/features/admin/images/api'
|
||||||
|
import { ImageGallery } from '@/features/admin/images/ImageGallery'
|
||||||
|
import type { ChannelDto, LogoCorner, ViewerSettings } from '@/shared/api/types'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { updateViewerSettings } from '../api'
|
||||||
|
import { CollapsibleCard } from './CollapsibleCard'
|
||||||
|
|
||||||
|
const CORNERS: LogoCorner[] = ['TopLeft', 'TopRight', 'BottomLeft', 'BottomRight']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Как канал выглядит у зрителя (см. 6.8): логотип, часы, аналоговый фильтр. Всё рисуется на клиенте
|
||||||
|
* поверх видео и по умолчанию выключено — канал без логотипа и без шума остаётся нормальным каналом.
|
||||||
|
*/
|
||||||
|
export function ViewerCard({
|
||||||
|
channel,
|
||||||
|
onSaved,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
channel: ChannelDto
|
||||||
|
onSaved: () => void
|
||||||
|
onError: (error: unknown) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [viewer, setViewer] = useState<ViewerSettings>(channel.viewer)
|
||||||
|
const [galleryOpen, setGalleryOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => setViewer(channel.viewer), [channel])
|
||||||
|
|
||||||
|
const patch = (part: Partial<ViewerSettings>) => setViewer((prev) => ({ ...prev, ...part }))
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => updateViewerSettings(channel.id, viewer),
|
||||||
|
onSuccess: onSaved,
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleCard title={t('admin.channels.viewer')}>
|
||||||
|
<div className="flex flex-col gap-4 text-sm">
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.channels.viewerHint')}</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.logo')}</Label>
|
||||||
|
<div className="flex h-16 w-24 items-center justify-center rounded-md border border-border bg-muted/30">
|
||||||
|
{viewer.logoImageId ? (
|
||||||
|
<img
|
||||||
|
src={imageUrl(viewer.logoImageId)}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">{t('admin.channels.noLogo')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
|
||||||
|
{t('admin.channels.pickLogo')}
|
||||||
|
</Button>
|
||||||
|
{viewer.logoImageId && (
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => patch({ logoImageId: null })}>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<ImageGallery
|
||||||
|
open={galleryOpen}
|
||||||
|
onOpenChange={setGalleryOpen}
|
||||||
|
category="Library"
|
||||||
|
onSelect={(image) => patch({ logoImageId: image.id })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewer.logoImageId && (
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.logoCorner')}</Label>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||||
|
value={viewer.logoCorner}
|
||||||
|
onChange={(e) => patch({ logoCorner: e.target.value as LogoCorner })}
|
||||||
|
>
|
||||||
|
{CORNERS.map((corner) => (
|
||||||
|
<option key={corner} value={corner}>
|
||||||
|
{t(`admin.channels.corners.${corner}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.logoOpacity')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
className="w-28"
|
||||||
|
value={viewer.logoOpacity}
|
||||||
|
onChange={(e) => patch({ logoOpacity: clamp01(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={viewer.showClock}
|
||||||
|
onChange={(e) => patch({ showClock: e.target.checked })}
|
||||||
|
/>
|
||||||
|
{t('admin.channels.showClock')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.channels.analogFilter')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
className="w-28"
|
||||||
|
value={viewer.analogFilterStrength}
|
||||||
|
onChange={(e) => patch({ analogFilterStrength: clamp01(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.channels.analogFilterHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сила и прозрачность живут в 0..1: пустой ввод трактуем как ноль, а не как NaN. */
|
||||||
|
function clamp01(value: string): number {
|
||||||
|
const n = Number(value)
|
||||||
|
return Number.isNaN(n) ? 0 : Math.min(1, Math.max(0, n))
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { LayerApplicability } from '@/shared/api/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Действует ли слой в эту дату вещательных суток. Зеркало серверного `LayerApplicability.Covers`
|
||||||
|
* (`TeleWave.Application/Programming/Templates/LayerApplicability.cs`) — источник правды там,
|
||||||
|
* здесь только подсветка сетки. Меняя одно, правьте второе.
|
||||||
|
*/
|
||||||
|
export function coversDate(applicability: LayerApplicability | null, date: Date): boolean {
|
||||||
|
if (!applicability || isEmpty(applicability)) return true
|
||||||
|
|
||||||
|
const weekday = date.getDay()
|
||||||
|
if (applicability.weekdays?.includes(weekday)) return true
|
||||||
|
|
||||||
|
const iso = toIsoDate(date)
|
||||||
|
if (applicability.specificDates?.includes(iso)) return true
|
||||||
|
|
||||||
|
if (applicability.dateRanges?.some((r) => iso >= r.from && iso <= r.to)) return true
|
||||||
|
|
||||||
|
// Ежегодный период может пересекать Новый год — сравниваем по паре (месяц, день).
|
||||||
|
const value = (date.getMonth() + 1) * 100 + date.getDate()
|
||||||
|
return (
|
||||||
|
applicability.annualRanges?.some((r) => {
|
||||||
|
const from = r.fromMonth * 100 + r.fromDay
|
||||||
|
const to = r.toMonth * 100 + r.toDay
|
||||||
|
return from <= to ? value >= from && value <= to : value >= from || value <= to
|
||||||
|
}) ?? false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEmpty(applicability: LayerApplicability | null): boolean {
|
||||||
|
if (!applicability) return true
|
||||||
|
return (
|
||||||
|
!applicability.weekdays?.length &&
|
||||||
|
!applicability.dateRanges?.length &&
|
||||||
|
!applicability.annualRanges?.length &&
|
||||||
|
!applicability.specificDates?.length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «2026-12-25» из локальной даты — без ухода в UTC, иначе вечер уезжает на день назад. */
|
||||||
|
export function toIsoDate(date: Date): string {
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(
|
||||||
|
date.getDate(),
|
||||||
|
).padStart(2, '0')}`
|
||||||
|
}
|
||||||
@@ -10,6 +10,12 @@ export function formatTime(iso: string | null) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Минуты суток → «HH:MM:00» — формат `TimeOnly` на сервере. */
|
||||||
|
export function toTime(minutes: number): string {
|
||||||
|
const m = ((minutes % (24 * 60)) + 24 * 60) % (24 * 60)
|
||||||
|
return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}:00`
|
||||||
|
}
|
||||||
|
|
||||||
/** Минуты суток → «HH:MM». */
|
/** Минуты суток → «HH:MM». */
|
||||||
export function formatMinute(minute: number | null) {
|
export function formatMinute(minute: number | null) {
|
||||||
if (minute == null) return '—'
|
if (minute == null) return '—'
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export function SettingsPanel() {
|
|||||||
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({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'settings'],
|
queryKey: ['admin', 'settings'],
|
||||||
@@ -23,11 +24,17 @@ export function SettingsPanel() {
|
|||||||
if (data) {
|
if (data) {
|
||||||
setRegistrationEnabled(data.registrationEnabled)
|
setRegistrationEnabled(data.registrationEnabled)
|
||||||
setPreferredAudioLanguages(data.preferredAudioLanguages)
|
setPreferredAudioLanguages(data.preferredAudioLanguages)
|
||||||
|
setChannelNumbersEnabled(data.channelNumbersEnabled)
|
||||||
}
|
}
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: () => updateSiteSettings({ registrationEnabled, preferredAudioLanguages }),
|
mutationFn: () =>
|
||||||
|
updateSiteSettings({
|
||||||
|
registrationEnabled,
|
||||||
|
preferredAudioLanguages,
|
||||||
|
channelNumbersEnabled,
|
||||||
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(t('settings.saved'))
|
toast.success(t('settings.saved'))
|
||||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
|
void queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
|
||||||
@@ -73,6 +80,22 @@ export function SettingsPanel() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<Button size="sm" disabled={isLoading || save.isPending} onClick={() => save.mutate()}>
|
<Button size="sm" disabled={isLoading || save.isPending} onClick={() => save.mutate()}>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ 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' })
|
||||||
@@ -19,6 +19,7 @@ export function AirPage() {
|
|||||||
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 handleUnavailable = useCallback(() => setPlayerError(true), [])
|
||||||
const retry = () => {
|
const retry = () => {
|
||||||
@@ -30,6 +31,50 @@ export function AirPage() {
|
|||||||
queryKey: ['air', 'channels'],
|
queryKey: ['air', 'channels'],
|
||||||
queryFn: listChannels,
|
queryFn: listChannels,
|
||||||
})
|
})
|
||||||
|
const { data: features } = useQuery({ queryKey: ['air', 'features'], queryFn: getViewerFeatures })
|
||||||
|
|
||||||
|
const numbersEnabled = features?.channelNumbersEnabled ?? false
|
||||||
|
const currentChannel = channels?.find((c) => c.slug === selected)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переключение по номерам: список уже отсортирован сервером, поэтому «вверх-вниз» — это шаг
|
||||||
|
* по нему. Короткий чёрный кадр с номером ставится сразу, до готовности потока.
|
||||||
|
*/
|
||||||
|
const step = useCallback(
|
||||||
|
(delta: number) => {
|
||||||
|
if (!channels || channels.length === 0) return
|
||||||
|
const index = channels.findIndex((c) => c.slug === selected)
|
||||||
|
const next = channels[(index + delta + channels.length) % channels.length]
|
||||||
|
if (!next || next.slug === selected) return
|
||||||
|
setFlash(true)
|
||||||
|
setSelected(next.slug)
|
||||||
|
},
|
||||||
|
[channels, selected],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!flash) return
|
||||||
|
const id = window.setTimeout(() => setFlash(false), 900)
|
||||||
|
return () => window.clearTimeout(id)
|
||||||
|
}, [flash, selected])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!numbersEnabled) return
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
// Не перехватываем стрелки, пока фокус в поле ввода: там они двигают каретку.
|
||||||
|
const target = event.target as HTMLElement | null
|
||||||
|
if (target && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return
|
||||||
|
if (event.key === 'ArrowUp' || event.key === 'PageUp') {
|
||||||
|
event.preventDefault()
|
||||||
|
step(-1)
|
||||||
|
} else if (event.key === 'ArrowDown' || event.key === 'PageDown') {
|
||||||
|
event.preventDefault()
|
||||||
|
step(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [numbersEnabled, step])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selected && channels && channels.length > 0) setSelected(channels[0].slug)
|
if (!selected && channels && channels.length > 0) setSelected(channels[0].slug)
|
||||||
@@ -84,6 +129,12 @@ export function AirPage() {
|
|||||||
|
|
||||||
const { current, upcoming, currentEntry } = useMemo(() => buildGuide(epg ?? []), [epg])
|
const { current, upcoming, currentEntry } = useMemo(() => buildGuide(epg ?? []), [epg])
|
||||||
|
|
||||||
|
// Плашка «Далее» — только на исходе программы: висеть весь эфир ей незачем.
|
||||||
|
const nextUp =
|
||||||
|
current && upcoming.length > 0 && new Date(current.endsAtUtc).getTime() - Date.now() < 60_000
|
||||||
|
? upcoming[0].showName
|
||||||
|
: null
|
||||||
|
|
||||||
if (isLoading) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
if (isLoading) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
||||||
|
|
||||||
if (!channels || channels.length === 0)
|
if (!channels || channels.length === 0)
|
||||||
@@ -96,7 +147,12 @@ export function AirPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
|
||||||
|
{numbersEnabled && (
|
||||||
|
<span className="text-xs text-muted-foreground">{t('air.numbersHint')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
|
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
|
||||||
<aside className="flex gap-2 overflow-x-auto md:flex-col md:overflow-visible">
|
<aside className="flex gap-2 overflow-x-auto md:flex-col md:overflow-visible">
|
||||||
@@ -109,6 +165,11 @@ export function AirPage() {
|
|||||||
selected === channel.slug && 'border-primary text-primary',
|
selected === channel.slug && 'border-primary text-primary',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{numbersEnabled && channel.number !== null && (
|
||||||
|
<span className="w-6 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
|
||||||
|
{channel.number}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{channel.currentShowPosterImageId ? (
|
{channel.currentShowPosterImageId ? (
|
||||||
<img
|
<img
|
||||||
src={imageUrl(channel.currentShowPosterImageId)}
|
src={imageUrl(channel.currentShowPosterImageId)}
|
||||||
@@ -148,6 +209,9 @@ export function AirPage() {
|
|||||||
<ChannelPlayer
|
<ChannelPlayer
|
||||||
key={`${selected}-${attempt}`}
|
key={`${selected}-${attempt}`}
|
||||||
slug={selected}
|
slug={selected}
|
||||||
|
channel={currentChannel}
|
||||||
|
nextUp={nextUp}
|
||||||
|
flash={flash}
|
||||||
onUnavailable={handleUnavailable}
|
onUnavailable={handleUnavailable}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ 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'
|
||||||
|
import {
|
||||||
|
AnalogFilter,
|
||||||
|
ChannelFlash,
|
||||||
|
ChannelLogo,
|
||||||
|
NextUpBanner,
|
||||||
|
ScreenClock,
|
||||||
|
} from './PlayerOverlays'
|
||||||
|
|
||||||
const STORAGE_KEY = 'tw:player'
|
const STORAGE_KEY = 'tw:player'
|
||||||
|
|
||||||
@@ -28,9 +36,18 @@ function readStoredAudio(): { volume: number; muted: boolean } {
|
|||||||
*/
|
*/
|
||||||
export function ChannelPlayer({
|
export function ChannelPlayer({
|
||||||
slug,
|
slug,
|
||||||
|
channel,
|
||||||
|
nextUp,
|
||||||
|
flash,
|
||||||
onUnavailable,
|
onUnavailable,
|
||||||
}: {
|
}: {
|
||||||
slug: string
|
slug: string
|
||||||
|
/** Канал, чьи оверлеи рисуем. Всё опционально: канал без логотипа и без шума — норма. */
|
||||||
|
channel?: PublicChannelDto
|
||||||
|
/** Название следующей программы, когда до неё осталось меньше минуты. */
|
||||||
|
nextUp?: string | null
|
||||||
|
/** Показать чёрный кадр с номером — переключение по номерам, как на телевизоре. */
|
||||||
|
flash?: boolean
|
||||||
onUnavailable?: () => void
|
onUnavailable?: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -189,6 +206,13 @@ export function ChannelPlayer({
|
|||||||
playsInline
|
playsInline
|
||||||
muted
|
muted
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
|
style={
|
||||||
|
channel && channel.analogFilterStrength > 0
|
||||||
|
? {
|
||||||
|
filter: `saturate(${1 + channel.analogFilterStrength * 0.4}) contrast(${1 + channel.analogFilterStrength * 0.15}) blur(${channel.analogFilterStrength * 0.6}px)`,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onDoubleClick={toggleFullscreen}
|
onDoubleClick={toggleFullscreen}
|
||||||
onPlay={scheduleHide}
|
onPlay={scheduleHide}
|
||||||
onPause={() => {
|
onPause={() => {
|
||||||
@@ -197,6 +221,20 @@ export function ChannelPlayer({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{channel && channel.analogFilterStrength > 0 && (
|
||||||
|
<AnalogFilter strength={channel.analogFilterStrength} />
|
||||||
|
)}
|
||||||
|
{channel?.logoImageId && (
|
||||||
|
<ChannelLogo
|
||||||
|
imageId={channel.logoImageId}
|
||||||
|
corner={channel.logoCorner}
|
||||||
|
opacity={channel.logoOpacity}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{channel?.showClock && <ScreenClock />}
|
||||||
|
{nextUp && <NextUpBanner title={nextUp} />}
|
||||||
|
{flash && channel && <ChannelFlash number={channel.number} name={channel.name} />}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
overControlsRef.current = true
|
overControlsRef.current = true
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,11 @@ export function listChannels() {
|
|||||||
return apiRequest<PublicChannelDto[]>('/channels')
|
return apiRequest<PublicChannelDto[]>('/channels')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Что включено глобально на стороне зрителя (сейчас — переключение по номерам). */
|
||||||
|
export function getViewerFeatures() {
|
||||||
|
return apiRequest<{ channelNumbersEnabled: boolean }>('/channels/features')
|
||||||
|
}
|
||||||
|
|
||||||
/** Выдаёт httpOnly-cookie tw_stream — после этого <video> сможет грузить плейлист и сегменты. */
|
/** Выдаёт httpOnly-cookie tw_stream — после этого <video> сможет грузить плейлист и сегменты. */
|
||||||
export function watchChannel(slug: string) {
|
export function watchChannel(slug: string) {
|
||||||
return apiRequest<void>(`/channels/${slug}/watch`, { method: 'POST' })
|
return apiRequest<void>(`/channels/${slug}/watch`, { method: 'POST' })
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Сетка канала (шаблон → слои → слоты) ──────────────────────────────────
|
// ── Сетка канала (шаблон → слои → слоты) ──────────────────────────────────
|
||||||
@@ -459,6 +477,19 @@ export type GridLayerDto = {
|
|||||||
slots: SlotDto[]
|
slots: SlotDto[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Окно детского времени: до `to` в эфир идёт контент не строже `maxAudience`. */
|
||||||
|
export type AudienceWindow = { from: string; to: string; maxAudience: ShowAudience }
|
||||||
|
|
||||||
|
/** Правила отбора кандидатов канала — жёсткие фильтры, применяются до жребия. */
|
||||||
|
export type PlanningRules = {
|
||||||
|
maxAudienceByTime?: AudienceWindow[] | null
|
||||||
|
maxRepeatsInWindow?: { windowDays: number; max: number } | null
|
||||||
|
/** Пост-проверки: считаются по готовой ленте и только предупреждают. */
|
||||||
|
maxBreakMinutesPerHour?: number | null
|
||||||
|
maxGenreSharePercent?: number | null
|
||||||
|
maxFallbackSharePercent?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
export type ScheduleTemplateDto = {
|
export type ScheduleTemplateDto = {
|
||||||
id: string
|
id: string
|
||||||
channelId: string
|
channelId: string
|
||||||
@@ -466,6 +497,8 @@ export type ScheduleTemplateDto = {
|
|||||||
fallbackGroupId: string | null
|
fallbackGroupId: string | null
|
||||||
/** Стык, который берётся, когда слот своего не задал. */
|
/** Стык, который берётся, когда слот своего не задал. */
|
||||||
defaultJunctionId: string | null
|
defaultJunctionId: string | null
|
||||||
|
/** Детское время и потолок повторов. */
|
||||||
|
rules: PlanningRules | null
|
||||||
revision: number
|
revision: number
|
||||||
appliedRevision: number
|
appliedRevision: number
|
||||||
/** Есть ли правки правил, не применённые к эфиру. */
|
/** Есть ли правки правил, не применённые к эфиру. */
|
||||||
@@ -481,6 +514,10 @@ export type PlanningWarningKind =
|
|||||||
| 'CooldownExhausted'
|
| 'CooldownExhausted'
|
||||||
| 'RepeatSourceEmpty'
|
| 'RepeatSourceEmpty'
|
||||||
| 'FallbackEmpty'
|
| 'FallbackEmpty'
|
||||||
|
| 'CandidatesFiltered'
|
||||||
|
| 'BreakLimitExceeded'
|
||||||
|
| 'GenreShareExceeded'
|
||||||
|
| 'FallbackShareExceeded'
|
||||||
|
|
||||||
export type PlanningWarningDto = {
|
export type PlanningWarningDto = {
|
||||||
kind: PlanningWarningKind
|
kind: PlanningWarningKind
|
||||||
@@ -490,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'
|
||||||
|
|
||||||
@@ -531,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 = {
|
||||||
|
|||||||
+197
-28
@@ -66,6 +66,7 @@ const resources = {
|
|||||||
noChannels: 'Пока нет доступных каналов. Загляните позже.',
|
noChannels: 'Пока нет доступных каналов. Загляните позже.',
|
||||||
offline: 'Канал сейчас не в эфире',
|
offline: 'Канал сейчас не в эфире',
|
||||||
offlineHint: 'Нет расписания или контента. Загляните позже.',
|
offlineHint: 'Нет расписания или контента. Загляните позже.',
|
||||||
|
numbersHint: '↑ / ↓ — переключение каналов по номерам',
|
||||||
retry: 'Повторить',
|
retry: 'Повторить',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
@@ -341,13 +342,102 @@ const resources = {
|
|||||||
snapOff: 'нет',
|
snapOff: 'нет',
|
||||||
bumperConditionsHint:
|
bumperConditionsHint:
|
||||||
'Как часто ставить заставку и на каких переходах — условия элемента стыка, а не настройка канала.',
|
'Как часто ставить заставку и на каких переходах — условия элемента стыка, а не настройка канала.',
|
||||||
|
resizeSlot: 'Потянуть за край — длительность',
|
||||||
|
copyDay: 'Копировать день',
|
||||||
|
copyDayFrom: 'Копировать {{day}} в:',
|
||||||
|
copy: 'Копировать',
|
||||||
|
layerVisible: 'Показывать слой',
|
||||||
|
layerName: 'Название слоя',
|
||||||
|
layerApplicability: 'Когда действует',
|
||||||
|
applicabilityHint:
|
||||||
|
'Разделы объединяются по ИЛИ: слой действует, если дата подходит хотя бы под одно условие. Ничего не заполнено — действует всегда.',
|
||||||
|
applicabilityWeekdays: 'Дни недели',
|
||||||
|
applicabilityDateRanges: 'Диапазоны дат',
|
||||||
|
applicabilityAnnual: 'Ежегодно (месяц / день)',
|
||||||
|
applicabilityDates: 'Конкретные даты',
|
||||||
|
applicabilityNone: 'не задано',
|
||||||
|
showForDate: 'Сетка на дату',
|
||||||
|
allDates: 'Все слои',
|
||||||
|
rules: 'Правила отбора',
|
||||||
|
rulesHint:
|
||||||
|
'Жёсткие фильтры: отсекают неподходящее до жребия. Как и правка сетки, эфир не двигают — нужно применить.',
|
||||||
|
audienceWindows: 'Детское время',
|
||||||
|
noAudienceWindows: 'Окон нет — возраст ничем не ограничен.',
|
||||||
|
audienceWindowsHint:
|
||||||
|
'В окне в эфир идёт контент не строже выбранной категории. Окно может переходить через полночь. Контент без категории не отсекается.',
|
||||||
|
from: 'С',
|
||||||
|
to: 'До',
|
||||||
|
maxAudience: 'Не строже',
|
||||||
|
repeatLimit: 'Потолок повторов',
|
||||||
|
repeatWindowDays: 'Окно, суток',
|
||||||
|
repeatMax: 'Не чаще, раз',
|
||||||
|
repeatLimitHint:
|
||||||
|
'Считается по уже записанной ленте. Если потолка достигли все кандидаты, слот всё равно заполняется: пустой эфир хуже раннего повтора.',
|
||||||
preview: 'Предпросмотр',
|
preview: 'Предпросмотр',
|
||||||
previewHide: 'Свернуть предпросмотр',
|
previewHide: 'Свернуть предпросмотр',
|
||||||
previewHint: 'Прогон по текущим правилам: ничего не пишется, курсоры слотов не двигаются.',
|
previewHint: 'Прогон по текущим правилам: ничего не пишется, курсоры слотов не двигаются.',
|
||||||
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: 'Фон',
|
||||||
@@ -422,6 +512,10 @@ const resources = {
|
|||||||
CooldownExhausted: 'Остывание отсекло всех',
|
CooldownExhausted: 'Остывание отсекло всех',
|
||||||
RepeatSourceEmpty: 'Нечего повторять',
|
RepeatSourceEmpty: 'Нечего повторять',
|
||||||
FallbackEmpty: 'Нечем закрыть паузы',
|
FallbackEmpty: 'Нечем закрыть паузы',
|
||||||
|
CandidatesFiltered: 'Возрастной потолок отсёк всех',
|
||||||
|
BreakLimitExceeded: 'Врезок в часе больше потолка',
|
||||||
|
GenreShareExceeded: 'Доля жанра выше нормы',
|
||||||
|
FallbackShareExceeded: 'Фона в эфире больше нормы',
|
||||||
},
|
},
|
||||||
|
|
||||||
title: 'Каналы',
|
title: 'Каналы',
|
||||||
@@ -509,19 +603,7 @@ const resources = {
|
|||||||
preferredNone: 'Окна не заданы — предпочтений по времени нет.',
|
preferredNone: 'Окна не заданы — предпочтений по времени нет.',
|
||||||
preferredAddWindow: 'Добавить окно',
|
preferredAddWindow: 'Добавить окно',
|
||||||
preferredBadRange: 'начало ≥ конца',
|
preferredBadRange: 'начало ≥ конца',
|
||||||
ads: 'Реклама',
|
noSchedule: 'Расписание ещё не построено',
|
||||||
pickAd: 'Выберите ролик',
|
|
||||||
noAds: 'Пул рекламы пуст',
|
|
||||||
overrides: 'Марафоны / override',
|
|
||||||
modes: { Exclusive: 'Эксклюзив', Boost: 'Буст' },
|
|
||||||
overrideRecurrence: 'Повтор',
|
|
||||||
recurrenceOneTime: 'Разово',
|
|
||||||
recurrenceWeekly: 'Еженедельно',
|
|
||||||
from: 'С',
|
|
||||||
to: 'По',
|
|
||||||
noOverrides: 'Override не заданы',
|
|
||||||
schedule: 'Расписание (12 ч)',
|
|
||||||
noSchedule: 'Расписание ещё не построено — нажмите «Пересобрать»',
|
|
||||||
},
|
},
|
||||||
maintenance: {
|
maintenance: {
|
||||||
title: 'Обслуживание',
|
title: 'Обслуживание',
|
||||||
@@ -545,6 +627,9 @@ const resources = {
|
|||||||
'Когда выключено — новые пользователи не могут регистрироваться сами, учётки заводит только администратор.',
|
'Когда выключено — новые пользователи не могут регистрироваться сами, учётки заводит только администратор.',
|
||||||
registrationLabel: 'Разрешить регистрацию на сайте',
|
registrationLabel: 'Разрешить регистрацию на сайте',
|
||||||
preferredAudio: 'Предпочитаемые озвучки',
|
preferredAudio: 'Предпочитаемые озвучки',
|
||||||
|
channelNumbers: 'Переключение каналов по номерам',
|
||||||
|
channelNumbersHint:
|
||||||
|
'Зритель переключает каналы стрелками, как на телевизоре. Сетка каналов остаётся всегда.',
|
||||||
preferredAudioHint:
|
preferredAudioHint:
|
||||||
'Коды языков через запятую в порядке приоритета (напр. «rus, eng»). Если в файле есть дорожка с таким языком — при обработке выбирается она (по порядку); иначе — выбор ffmpeg по умолчанию. Применяется к новым обработкам.',
|
'Коды языков через запятую в порядке приоритета (напр. «rus, eng»). Если в файле есть дорожка с таким языком — при обработке выбирается она (по порядку); иначе — выбор ffmpeg по умолчанию. Применяется к новым обработкам.',
|
||||||
},
|
},
|
||||||
@@ -918,12 +1003,101 @@ const resources = {
|
|||||||
snapOff: 'off',
|
snapOff: 'off',
|
||||||
bumperConditionsHint:
|
bumperConditionsHint:
|
||||||
'How often a bumper is inserted and on which transitions is a junction-element condition, not a channel setting.',
|
'How often a bumper is inserted and on which transitions is a junction-element condition, not a channel setting.',
|
||||||
|
resizeSlot: 'Drag the edge to change duration',
|
||||||
|
copyDay: 'Copy day',
|
||||||
|
copyDayFrom: 'Copy {{day}} to:',
|
||||||
|
copy: 'Copy',
|
||||||
|
layerVisible: 'Show layer',
|
||||||
|
layerName: 'Layer name',
|
||||||
|
layerApplicability: 'When it applies',
|
||||||
|
applicabilityHint:
|
||||||
|
'Sections are OR-ed: the layer applies when the date matches at least one condition. Nothing filled in — it always applies.',
|
||||||
|
applicabilityWeekdays: 'Weekdays',
|
||||||
|
applicabilityDateRanges: 'Date ranges',
|
||||||
|
applicabilityAnnual: 'Yearly (month / day)',
|
||||||
|
applicabilityDates: 'Specific dates',
|
||||||
|
applicabilityNone: 'not set',
|
||||||
|
showForDate: 'Grid for date',
|
||||||
|
allDates: 'All layers',
|
||||||
|
rules: 'Candidate rules',
|
||||||
|
rulesHint:
|
||||||
|
'Hard filters: they cut out what is not allowed before the draw. Like grid edits, they do not move the air — apply to take effect.',
|
||||||
|
audienceWindows: 'Family hours',
|
||||||
|
noAudienceWindows: 'No windows — the age is not limited.',
|
||||||
|
audienceWindowsHint:
|
||||||
|
'Inside a window only content no stricter than the chosen category airs. A window may cross midnight. Content with no category is never dropped.',
|
||||||
|
from: 'From',
|
||||||
|
to: 'To',
|
||||||
|
maxAudience: 'No stricter than',
|
||||||
|
repeatLimit: 'Repeat cap',
|
||||||
|
repeatWindowDays: 'Window, days',
|
||||||
|
repeatMax: 'At most, times',
|
||||||
|
repeatLimitHint:
|
||||||
|
'Counted against the already recorded tape. If every candidate hits the cap the slot is still filled: empty air is worse than an early repeat.',
|
||||||
preview: 'Preview',
|
preview: 'Preview',
|
||||||
previewHide: 'Hide preview',
|
previewHide: 'Hide preview',
|
||||||
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',
|
||||||
@@ -993,6 +1167,10 @@ const resources = {
|
|||||||
CooldownExhausted: 'Cooldown ruled out every candidate',
|
CooldownExhausted: 'Cooldown ruled out every candidate',
|
||||||
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',
|
||||||
|
BreakLimitExceeded: 'Breaks in an hour exceed the cap',
|
||||||
|
GenreShareExceeded: 'Genre share above the norm',
|
||||||
|
FallbackShareExceeded: 'Background share above the norm',
|
||||||
},
|
},
|
||||||
|
|
||||||
title: 'Channels',
|
title: 'Channels',
|
||||||
@@ -1080,19 +1258,7 @@ const resources = {
|
|||||||
preferredNone: 'No windows set — no time preference.',
|
preferredNone: 'No windows set — no time preference.',
|
||||||
preferredAddWindow: 'Add window',
|
preferredAddWindow: 'Add window',
|
||||||
preferredBadRange: 'start ≥ end',
|
preferredBadRange: 'start ≥ end',
|
||||||
ads: 'Ads',
|
noSchedule: 'Schedule not built yet',
|
||||||
pickAd: 'Pick an ad',
|
|
||||||
noAds: 'Ad pool is empty',
|
|
||||||
overrides: 'Marathons / overrides',
|
|
||||||
modes: { Exclusive: 'Exclusive', Boost: 'Boost' },
|
|
||||||
overrideRecurrence: 'Repeat',
|
|
||||||
recurrenceOneTime: 'One-time',
|
|
||||||
recurrenceWeekly: 'Weekly',
|
|
||||||
from: 'From',
|
|
||||||
to: 'To',
|
|
||||||
noOverrides: 'No overrides set',
|
|
||||||
schedule: 'Schedule (12h)',
|
|
||||||
noSchedule: 'Schedule not built yet — click “Rebuild”',
|
|
||||||
},
|
},
|
||||||
maintenance: {
|
maintenance: {
|
||||||
title: 'Maintenance',
|
title: 'Maintenance',
|
||||||
@@ -1115,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