Enhance channel and settings functionalities: introduce viewer settings in ChannelEndpoints, update SiteSettings to include channel number toggling, and refactor related data structures. Implement new endpoints for validating templates and diffing scheduling changes, improving overall user experience and configuration management.
build / backend (push) Successful in 1m54s
build / frontend (push) Successful in 43s
tests / backend-tests (push) Successful in 2m4s

This commit is contained in:
Leonid Pershin
2026-07-26 14:50:32 +03:00
parent 8494eb5e6a
commit f699576582
54 changed files with 5857 additions and 1631 deletions
@@ -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,
@@ -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
)
) )
); );
} }
@@ -0,0 +1,29 @@
using FluentValidation;
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.UpdateViewerSettings;
/// <summary>
/// Оверлеи и аналоговый фильтр канала (см. 6.8). Отдельной командой, а не в общих настройках:
/// это про то, как канал выглядит у зрителя, и правится другими руками и в другой момент.
/// </summary>
public sealed record UpdateViewerSettingsCommand(
Guid ChannelId,
Guid? LogoImageId,
LogoCorner LogoCorner,
double LogoOpacity,
bool ShowClock,
double AnalogFilterStrength
) : ICommand<Result>;
public sealed class UpdateViewerSettingsCommandValidator
: AbstractValidator<UpdateViewerSettingsCommand>
{
public UpdateViewerSettingsCommandValidator()
{
RuleFor(x => x.LogoOpacity).InclusiveBetween(0.0, 1.0);
RuleFor(x => x.AnalogFilterStrength).InclusiveBetween(0.0, 1.0);
}
}
@@ -0,0 +1,38 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.UpdateViewerSettings;
public sealed class UpdateViewerSettingsCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateViewerSettingsCommand, Result>
{
public async Task<Result> Handle(
UpdateViewerSettingsCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels.FirstOrDefaultAsync(
c => c.Id == command.ChannelId,
cancellationToken
);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
if (
command.LogoImageId is { } imageId
&& !await dbContext.Images.AnyAsync(i => i.Id == imageId, cancellationToken)
)
return Result.Failure(ChannelErrors.AssetNotFound);
channel.UpdateViewerSettings(
command.LogoImageId,
command.LogoCorner,
command.LogoOpacity,
command.ShowClock,
command.AnalogFilterStrength
);
return Result.Success();
}
}
@@ -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
);
@@ -0,0 +1,143 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Diff;
public sealed class PreviewApplyDiffQueryHandler(
IAppDbContext dbContext,
GridScheduleGenerator generator,
IOptions<SchedulerOptions> options
) : IQueryHandler<PreviewApplyDiffQuery, Result<ScheduleDiffDto>>
{
/// <summary>Сколько расхождений отдаём списком: остальное всё равно не читают, а счётчик честный.</summary>
private const int MaxChanges = 100;
public async Task<Result<ScheduleDiffDto>> Handle(
PreviewApplyDiffQuery query,
CancellationToken cancellationToken
)
{
var now = DateTimeOffset.UtcNow;
var future = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e => e.ChannelId == query.ChannelId && e.StartsAtUtc >= now)
.OrderBy(e => e.StartsAtUtc)
.ToListAsync(cancellationToken);
// Применение не трогает идущую сейчас запись, поэтому пересборка начинается с её конца —
// считать иначе значило бы показать диф, которого не будет.
var currentEnd = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e => e.ChannelId == query.ChannelId && e.StartsAtUtc < now && e.EndsAtUtc > now)
.MaxAsync(e => (DateTimeOffset?)e.EndsAtUtc, cancellationToken);
var result = await generator.PreviewAsync(
query.ChannelId,
currentEnd ?? now,
Math.Max(1, options.Value.HorizonDays),
cancellationToken
);
if (result is null)
return Result.Failure<ScheduleDiffDto>(ChannelErrors.TemplateNotFound);
var showNames = await LoadShowNamesAsync(future, result.Items, cancellationToken);
var soonUntil = now.AddHours(24);
var before = future.Select(e => Describe(e, showNames)).ToList();
var after = result.Items.Select(i => Describe(i, showNames)).ToList();
var changes = new List<ScheduleChangeDto>();
var changed = 0;
var changedSoon = 0;
for (var i = 0; i < Math.Max(before.Count, after.Count); i++)
{
var oldItem = i < before.Count ? before[i] : null;
var newItem = i < after.Count ? after[i] : null;
if (oldItem?.Label == newItem?.Label && oldItem?.StartsAtUtc == newItem?.StartsAtUtc)
continue;
var startsAt = newItem?.StartsAtUtc ?? oldItem!.StartsAtUtc;
var soon = startsAt < soonUntil;
changed++;
if (soon)
changedSoon++;
if (changes.Count < MaxChanges)
changes.Add(new ScheduleChangeDto(startsAt, oldItem?.Label, newItem?.Label, soon));
}
return Result.Success(new ScheduleDiffDto(future.Count, changed, changedSoon, changes));
}
private sealed record Described(DateTimeOffset StartsAtUtc, string Label);
private static Described Describe(ScheduleEntry entry, IReadOnlyDictionary<Guid, string> names) =>
new(
entry.StartsAtUtc,
entry.Kind switch
{
ScheduleEntryKind.Program => Title(entry.ShowId, entry.EpisodeIndex, names),
ScheduleEntryKind.Ad => $"реклама {Seconds(entry.EndsAtUtc - entry.StartsAtUtc)}",
ScheduleEntryKind.Bumper => "заставка",
ScheduleEntryKind.SignOff => "конец вещания",
_ => "фон",
}
);
private static Described Describe(PlannedItem item, IReadOnlyDictionary<Guid, string> names) =>
new(
item.StartsAtUtc,
item.Kind switch
{
PlannedItemKind.Program => Title(item.ShowId, item.UnitIndex, names),
PlannedItemKind.Ad or PlannedItemKind.Promo =>
$"реклама {Seconds(item.EndsAtUtc - item.StartsAtUtc)}",
PlannedItemKind.Bumper => "заставка",
PlannedItemKind.SignOff => "конец вещания",
_ => "фон",
}
);
private static string Title(
Guid? showId,
int? unitIndex,
IReadOnlyDictionary<Guid, string> names
)
{
var name = showId is { } id && names.TryGetValue(id, out var value) ? value : "—";
return unitIndex is { } index ? $"{name} · {index + 1}" : name;
}
private static string Seconds(TimeSpan duration) => $"{(int)duration.TotalSeconds} с";
private async Task<Dictionary<Guid, string>> LoadShowNamesAsync(
IReadOnlyList<ScheduleEntry> future,
IReadOnlyList<PlannedItem> planned,
CancellationToken cancellationToken
)
{
var ids = future
.Select(e => e.ShowId)
.Concat(planned.Select(i => i.ShowId))
.Where(id => id is not null && id != Guid.Empty)
.Select(id => id!.Value)
.Distinct()
.ToList();
if (ids.Count == 0)
return [];
return await dbContext
.Shows.AsNoTracking()
.Where(s => ids.Contains(s.Id))
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
}
}
@@ -31,6 +31,7 @@ public sealed class GridScheduleGenerator(
IAppDbContext dbContext, IAppDbContext dbContext,
GroupExpander expander, GroupExpander expander,
BumperResolver bumperResolver, BumperResolver bumperResolver,
PostCheckRunner postChecks,
IRandomSource random, IRandomSource random,
IOptions<SchedulerOptions> options, IOptions<SchedulerOptions> options,
IOptions<StreamingOptions> streamingOptions IOptions<StreamingOptions> streamingOptions
@@ -105,6 +106,16 @@ public sealed class GridScheduleGenerator(
); );
var result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random); var result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random);
// Пост-проверки считаются по готовой ленте и только предупреждают — переигрывать что-либо
// по их итогу мы намеренно не будем (см. 3.8).
var postWarnings = await postChecks.RunAsync(
result.Items,
PlanningRules.FromJson(template.RulesJson),
channel.UtcOffsetMinutes,
channel.DayStartTime,
cancellationToken
);
// Заставки резолвятся после сборки ленты: пара соседей известна только теперь. // Заставки резолвятся после сборки ленты: пара соседей известна только теперь.
var bumperAssets = await bumperResolver.ResolveAsync( var bumperAssets = await bumperResolver.ResolveAsync(
channel, channel,
@@ -143,7 +154,7 @@ public sealed class GridScheduleGenerator(
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
return new GenerationReport(added, result.Warnings); return new GenerationReport(added, [.. result.Warnings, .. postWarnings]);
} }
/// <summary> /// <summary>
@@ -176,7 +187,17 @@ public sealed class GridScheduleGenerator(
var horizonEnd = from.AddDays(Math.Clamp(days, 1, Math.Max(1, _options.HorizonDays))); var horizonEnd = from.AddDays(Math.Clamp(days, 1, Math.Max(1, _options.HorizonDays)));
var input = await BuildInputAsync(channel, template, from, horizonEnd, cancellationToken); var input = await BuildInputAsync(channel, template, from, horizonEnd, cancellationToken);
return Domain.Programming.Planning.SchedulePlanner.Plan(input, random); var result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random);
var postWarnings = await postChecks.RunAsync(
result.Items,
PlanningRules.FromJson(template.RulesJson),
channel.UtcOffsetMinutes,
channel.DayStartTime,
cancellationToken
);
return result with { Warnings = [.. result.Warnings, .. postWarnings] };
} }
/// <summary> /// <summary>
@@ -0,0 +1,177 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Templates;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning;
/// <summary>
/// Пост-проверки по готовой ленте (см. 3.8, 5.2). Только предупреждения: пересборки слота при
/// нарушении нет намеренно — она сделала бы результат зависимым от порядка проверок и обесценила
/// бы трейс «почему это здесь».
/// </summary>
public sealed class PostCheckRunner(IAppDbContext dbContext)
{
public async Task<IReadOnlyList<PlanningWarning>> RunAsync(
IReadOnlyList<PlannedItem> items,
PlanningRules? rules,
int utcOffsetMinutes,
TimeOnly dayStartTime,
CancellationToken cancellationToken
)
{
if (rules is null || items.Count == 0)
return [];
var warnings = new List<PlanningWarning>();
var offset = TimeSpan.FromMinutes(utcOffsetMinutes);
if (rules.MaxBreakMinutesPerHour is { } breakCap and > 0)
warnings.AddRange(CheckBreaks(items, offset, breakCap));
if (rules.MaxFallbackSharePercent is { } fallbackCap and > 0)
warnings.AddRange(CheckFallbackShare(items, fallbackCap));
if (rules.MaxGenreSharePercent is { } genreCap and > 0)
warnings.AddRange(
await CheckGenreShareAsync(items, offset, dayStartTime, genreCap, cancellationToken)
);
return warnings;
}
private static IEnumerable<PlanningWarning> CheckBreaks(
IReadOnlyList<PlannedItem> items,
TimeSpan offset,
int capMinutes
)
{
var byHour = items
.Where(IsBreak)
.GroupBy(i => Truncate(i.StartsAtUtc + offset, TimeSpan.FromHours(1)))
.Select(g => new
{
Hour = g.Key,
Minutes = g.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes),
})
.Where(x => x.Minutes > capMinutes)
.OrderBy(x => x.Hour);
foreach (var hour in byHour)
yield return new PlanningWarning(
PlanningWarningKind.BreakLimitExceeded,
null,
$"{hour.Hour:dd.MM HH:mm} — врезок {hour.Minutes:0} мин при потолке {capMinutes}."
);
}
private static IEnumerable<PlanningWarning> CheckFallbackShare(
IReadOnlyList<PlannedItem> items,
int capPercent
)
{
var total = items.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes);
if (total <= 0)
yield break;
// Конец вещания в долю фона не входит: это осознанная настройка, а не нехватка контента.
var fallback = items
.Where(i => i.Kind == PlannedItemKind.Fallback)
.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes);
var share = fallback / total * 100;
if (share > capPercent)
yield return new PlanningWarning(
PlanningWarningKind.FallbackShareExceeded,
null,
$"Фон занял {share:0}% эфира при норме {capPercent}% — контента не хватает."
);
}
/// <summary>
/// Доля жанра за вещательные сутки. Считается по основному жанру шоу: у контента их бывает
/// несколько, но «доля боевиков за день» осмысленна только по одному, иначе сумма долей
/// перевалит за сто процентов и порог потеряет смысл.
/// </summary>
private async Task<IReadOnlyList<PlanningWarning>> CheckGenreShareAsync(
IReadOnlyList<PlannedItem> items,
TimeSpan offset,
TimeOnly dayStartTime,
int capPercent,
CancellationToken cancellationToken
)
{
var showIds = items
.Where(i => i.Kind == PlannedItemKind.Program && i.ShowId is not null)
.Select(i => i.ShowId!.Value)
.Distinct()
.ToList();
if (showIds.Count == 0)
return [];
var primaryGenre = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
GenreId = s.Genres.Where(g => g.IsPrimary).Select(g => (Guid?)g.GenreId).FirstOrDefault(),
})
.Where(s => s.GenreId != null)
.ToDictionaryAsync(s => s.Id, s => s.GenreId!.Value, cancellationToken);
if (primaryGenre.Count == 0)
return [];
var genreNames = await dbContext
.Genres.AsNoTracking()
.Where(g => primaryGenre.Values.Contains(g.Id))
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
var warnings = new List<PlanningWarning>();
var days = items
.Where(i => i.Kind == PlannedItemKind.Program && i.ShowId is not null)
.GroupBy(i => BroadcastDate(i.StartsAtUtc + offset, dayStartTime));
foreach (var day in days)
{
var total = day.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes);
if (total <= 0)
continue;
var byGenre = day.Where(i => primaryGenre.ContainsKey(i.ShowId!.Value))
.GroupBy(i => primaryGenre[i.ShowId!.Value])
.Select(g => new
{
GenreId = g.Key,
Minutes = g.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes),
})
.Where(g => g.Minutes / total * 100 > capPercent);
foreach (var genre in byGenre)
warnings.Add(
new PlanningWarning(
PlanningWarningKind.GenreShareExceeded,
null,
$"{day.Key:dd.MM} — «{(genreNames.TryGetValue(genre.GenreId, out var name) ? name : genre.GenreId)}» "
+ $"занял {genre.Minutes / total * 100:0}% суток при норме {capPercent}%."
)
);
}
return warnings;
}
private static bool IsBreak(PlannedItem item) =>
item.Kind is PlannedItemKind.Ad or PlannedItemKind.Promo or PlannedItemKind.Bumper;
private static DateTimeOffset Truncate(DateTimeOffset value, TimeSpan step) =>
value.AddTicks(-(value.Ticks % step.Ticks));
/// <summary>Вещательные сутки момента: ночь до <paramref name="dayStartTime"/> — вчерашний день.</summary>
private static DateOnly BroadcastDate(DateTimeOffset channelTime, TimeOnly dayStartTime) =>
DateOnly.FromDateTime(
TimeOnly.FromDateTime(channelTime.DateTime) >= dayStartTime
? channelTime.Date
: channelTime.Date.AddDays(-1)
);
}
@@ -0,0 +1,37 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
/// <summary>
/// Цепочка происхождения записи — «почему это здесь» (см. 6.5). Трейс пишется в момент генерации
/// и хранится в самой записи: восстановить его потом невозможно, состав групп и правила меняются.
/// </summary>
public sealed record GetEntryTraceQuery(Guid EntryId) : IQuery<Result<EntryTraceDto>>;
public sealed record EntryTraceDto(
Guid EntryId,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
string? ShowName,
int? EpisodeIndex,
/// <summary>Слой и слот, из которых выросла запись; null — трейс не писался (старая запись).</summary>
string? LayerName,
int? LayerPriority,
string? SlotTitle,
SlotKind? SlotKind,
int? SlotWeekday,
TimeOnly? SlotTargetStart,
int? SlotDurationMinutes,
string? GroupName,
int? GroupItemCount,
SlotStrategyKind? Strategy,
int? CooldownDays,
/// <summary>Сколько кандидатов осталось после остывания (null — выбор шёл без него).</summary>
int? CandidatesAfterCooldown,
int DriftMinutes,
bool Snapped,
string? JunctionName
);
@@ -0,0 +1,141 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Templates;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetEntryTraceQuery, Result<EntryTraceDto>>
{
/// <summary>Те же настройки, что при записи трейса генератором.</summary>
private static readonly JsonSerializerOptions TraceJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
};
public async Task<Result<EntryTraceDto>> Handle(
GetEntryTraceQuery query,
CancellationToken cancellationToken
)
{
var entry = await dbContext
.ScheduleEntries.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == query.EntryId, cancellationToken);
if (entry is null)
return Result.Failure<EntryTraceDto>(ChannelErrors.NotFound);
var showName =
entry.ShowId is { } showId
? await dbContext
.Shows.AsNoTracking()
.Where(s => s.Id == showId)
.Select(s => s.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var trace = Parse(entry.TraceJson);
if (trace is null)
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
0,
false,
null
)
);
// Слот мог быть удалён или изменён после генерации — трейс от этого не портится, просто
// часть подписей окажется пустой.
var slot = trace.SlotId is { } slotId
? await dbContext.Slots.AsNoTracking().FirstOrDefaultAsync(s => s.Id == slotId, cancellationToken)
: null;
var layer = slot is null
? null
: await dbContext
.GridLayers.AsNoTracking()
.FirstOrDefaultAsync(l => l.Id == slot.LayerId, cancellationToken);
var group = slot?.GroupId is { } groupId
? await dbContext
.Groups.AsNoTracking()
.Where(g => g.Id == groupId)
.Select(g => new { g.Name, g.ItemCount })
.FirstOrDefaultAsync(cancellationToken)
: null;
var strategy = SlotStrategy.FromJson(slot?.StrategyJson);
var junctionId = slot?.JunctionAfterId ?? slot?.JunctionBetweenId;
var junctionName = junctionId is { } id
? await dbContext
.JunctionTemplates.AsNoTracking()
.Where(j => j.Id == id)
.Select(j => j.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
layer?.Name,
layer?.Priority,
slot?.Title,
trace.SlotKind,
slot?.Weekday,
slot?.TargetStart,
slot?.TargetDurationMinutes,
group?.Name,
group?.ItemCount,
trace.Strategy,
strategy?.CooldownDays,
trace.CandidatesAfterCooldown,
trace.DriftMinutes,
trace.Snapped,
junctionName
)
);
}
private static PlanTrace? Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<PlanTrace>(json, TraceJsonOptions);
}
catch (JsonException)
{
return null;
}
}
}
@@ -0,0 +1,31 @@
using FluentValidation;
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.CopyTemplate;
/// <summary>
/// Копирует сетку канала на другой канал: слои, слоты, стыки и правила. Группы не копируются —
/// они общие для всех каналов. Прежний шаблон канала-приёмника заменяется целиком.
/// </summary>
public sealed record CopyTemplateCommand(Guid SourceChannelId, Guid TargetChannelId)
: ICommand<Result<CopyTemplateResultDto>>;
/// <summary>
/// Что скопировалось. <paramref name="DroppedBumperRefs"/> — врезки-заставки, для которых на канале
/// -приёмнике не нашлось блока с таким же именем: ссылка снята, врезку надо донастроить руками.
/// </summary>
public sealed record CopyTemplateResultDto(
int Layers,
int Slots,
int Junctions,
int DroppedBumperRefs
);
public sealed class CopyTemplateCommandValidator : AbstractValidator<CopyTemplateCommand>
{
public CopyTemplateCommandValidator() =>
RuleFor(x => x.TargetChannelId)
.NotEqual(x => x.SourceChannelId)
.WithMessage("Копировать сетку саму на себя бессмысленно.");
}
@@ -0,0 +1,165 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.CopyTemplate;
public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CopyTemplateCommand, Result<CopyTemplateResultDto>>
{
public async Task<Result<CopyTemplateResultDto>> Handle(
CopyTemplateCommand command,
CancellationToken cancellationToken
)
{
var target = await dbContext
.Channels.Include(c => c.BumperTemplates)
.FirstOrDefaultAsync(c => c.Id == command.TargetChannelId, cancellationToken);
if (target is null)
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.NotFound);
var source = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.FirstOrDefaultAsync(t => t.ChannelId == command.SourceChannelId, cancellationToken);
if (source is null)
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.TemplateNotFound);
var sourceJunctions = await dbContext
.JunctionTemplates.AsNoTracking()
.Include(j => j.Elements)
.Where(j => j.ChannelId == command.SourceChannelId)
.ToListAsync(cancellationToken);
// Заставки живут на канале и на диске, поэтому не копируются: врезка ищет блок с таким же
// именем у приёмника, а не найдя — остаётся без ссылки, и это возвращается в отчёте.
var bumperByName = target
.BumperTemplates.GroupBy(t => t.Name)
.ToDictionary(g => g.Key, g => g.First().Id);
var sourceBumperNames = await dbContext
.Channels.AsNoTracking()
.Where(c => c.Id == command.SourceChannelId)
.SelectMany(c => c.BumperTemplates)
.Select(t => new { t.Id, t.Name })
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
var junctionMap = new Dictionary<Guid, Guid>();
var droppedBumperRefs = 0;
foreach (var junction in sourceJunctions)
{
var copy = JunctionTemplate.Create(target.Id, junction.Name);
junctionMap[junction.Id] = copy.Id;
foreach (var element in junction.Elements.OrderBy(e => e.Position))
{
var created = copy.AddElement(element.Kind);
Guid? bumperTemplateId = null;
if (element.Kind == JunctionElementKind.Bumper)
{
if (
element.BumperTemplateId is { } sourceId
&& sourceBumperNames.TryGetValue(sourceId, out var name)
&& bumperByName.TryGetValue(name, out var mapped)
)
bumperTemplateId = mapped;
else
droppedBumperRefs++;
}
created.Update(
element.Kind,
element.GroupId,
bumperTemplateId,
element.AmountMode,
element.AmountValue,
element.IsRequired,
element.ConditionsJson
);
}
dbContext.JunctionTemplates.Add(copy);
}
// Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя,
// а «добавить поверх» дало бы кашу из пересекающихся слотов.
var existing = await dbContext
.ScheduleTemplates.Where(t => t.ChannelId == target.Id)
.ToListAsync(cancellationToken);
dbContext.ScheduleTemplates.RemoveRange(existing);
await dbContext
.JunctionTemplates.Where(j =>
j.ChannelId == target.Id && !junctionMap.Values.Contains(j.Id)
)
.ExecuteDeleteAsync(cancellationToken);
var copyTemplate = ScheduleTemplate.Create(target.Id, source.Name);
copyTemplate.SetFallbackGroup(source.FallbackGroupId);
copyTemplate.SetRules(source.RulesJson);
if (source.DefaultJunctionId is { } defaultJunction && junctionMap.TryGetValue(defaultJunction, out var mappedDefault))
copyTemplate.SetDefaultJunction(mappedDefault);
var layers = 0;
var slots = 0;
foreach (var layer in source.Layers.OrderByDescending(l => l.Priority))
{
// Фоновый слой у нового шаблона уже есть — в него переносим слоты, а не заводим второй.
var copyLayer = layer.IsBackground
? copyTemplate.Background!
: copyTemplate.AddLayer(layer.Name, layer.Priority);
copyLayer.Update(layer.Name, layer.Priority, layer.ApplicabilityJson, layer.IsEnabled);
if (!layer.IsBackground)
layers++;
foreach (var slot in layer.Slots)
{
var copySlot = copyLayer.AddSlot(
slot.Title,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Daypart,
slot.SlotKind,
slot.Weekday
);
copySlot.UpdateTiming(
slot.Weekday,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Daypart,
slot.IsAnchor,
slot.MaxDriftMinutes,
slot.SnapToMinutes
);
copySlot.UpdateContent(
slot.Title,
slot.SlotKind,
slot.GroupId,
slot.StrategyJson,
slot.RepeatSourceJson,
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
Map(slot.JunctionBetweenId, junctionMap),
Map(slot.JunctionAfterId, junctionMap)
);
slots++;
}
}
dbContext.ScheduleTemplates.Add(copyTemplate);
target.SetTemplate(copyTemplate.Id);
return Result.Success(
new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs)
);
}
private static Guid? Map(Guid? id, IReadOnlyDictionary<Guid, Guid> map) =>
id is { } value && map.TryGetValue(value, out var mapped) ? mapped : null;
}
@@ -25,7 +25,14 @@ public sealed record AudienceWindow(TimeOnly From, TimeOnly To, ShowAudience Max
public sealed record PlanningRules( public sealed record PlanningRules(
IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null, IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null,
/// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary> /// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary>
RepeatLimitRule? MaxRepeatsInWindow = null RepeatLimitRule? MaxRepeatsInWindow = null,
// ── Пост-проверки: считаются по готовой ленте, дают предупреждения и ничего не переигрывают. ──
/// <summary>Потолок врезок в часе, минуты (null — не проверять).</summary>
int? MaxBreakMinutesPerHour = null,
/// <summary>Потолок доли одного жанра за вещательные сутки, проценты (null — не проверять).</summary>
int? MaxGenreSharePercent = null,
/// <summary>Потолок доли эфира, отданной фону, проценты (null — не проверять).</summary>
int? MaxFallbackSharePercent = null
) )
{ {
private static readonly JsonSerializerOptions Options = new() private static readonly JsonSerializerOptions Options = new()
@@ -0,0 +1,50 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Validate;
/// <summary>
/// Проверки сетки по правилам, до генерации (см. 5.1). Ничего не считает по ленте — только по
/// шаблону и статистике групп, поэтому дёшево и вызывается на каждом открытии редактора.
/// </summary>
public sealed record ValidateTemplateQuery(Guid ChannelId)
: IQuery<Result<IReadOnlyList<TemplateIssueDto>>>;
public enum TemplateIssueKind
{
/// <summary>Слот ссылается на пустую группу — место закроет фон.</summary>
GroupEmpty = 0,
/// <summary>Позиций в группе меньше, чем выходов слота в неделю.</summary>
GroupTooSmall = 1,
/// <summary>Интервал суток не покрыт ни одним слотом, даже фоновым.</summary>
GridGap = 2,
/// <summary>Два слота одного слоя перекрываются — сыграет только первый.</summary>
SlotOverlap = 3,
/// <summary>Остывание длиннее, чем группа успевает прокрутиться.</summary>
CooldownUnreachable = 4,
/// <summary>В группе есть контент строже, чем разрешает окно детского времени.</summary>
AudienceConflict = 5,
/// <summary>Слот без группы: контент брать неоткуда.</summary>
GroupMissing = 6,
}
/// <summary>Насколько это больно: <c>Error</c> — в эфире будет фон вместо контента.</summary>
public enum TemplateIssueSeverity
{
Warning = 0,
Error = 1,
}
public sealed record TemplateIssueDto(
TemplateIssueKind Kind,
TemplateIssueSeverity Severity,
Guid? LayerId,
Guid? SlotId,
string Details
);
@@ -0,0 +1,306 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Validate;
public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
{
private const int MinutesInDay = 24 * 60;
public async Task<Result<IReadOnlyList<TemplateIssueDto>>> Handle(
ValidateTemplateQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.NotFound);
var template = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
if (template is null)
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.TemplateNotFound);
var layers = template.Layers.Where(l => l.IsEnabled).ToList();
var groupIds = layers
.SelectMany(l => l.Slots)
.Select(s => s.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var groups = await dbContext
.Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id))
.Select(g => new GroupStats(g.Id, g.Name, g.ItemCount))
.ToDictionaryAsync(g => g.Id, cancellationToken);
var rules = PlanningRules.FromJson(template.RulesJson);
var strictest = await LoadStrictestAudienceAsync(groupIds, cancellationToken);
var dayStart = channel.DayStartTime;
var issues = new List<TemplateIssueDto>();
foreach (var layer in layers)
{
issues.AddRange(FindOverlaps(layer, dayStart));
foreach (var slot in layer.Slots.Where(s => s.SlotKind == SlotKind.Content))
issues.AddRange(CheckSlot(layer, slot, groups, strictest, rules));
}
issues.AddRange(FindGaps(layers, dayStart));
return Result.Success<IReadOnlyList<TemplateIssueDto>>(issues);
}
private sealed record GroupStats(Guid Id, string Name, int ItemCount);
private static IEnumerable<TemplateIssueDto> CheckSlot(
GridLayer layer,
Slot slot,
IReadOnlyDictionary<Guid, GroupStats> groups,
IReadOnlyDictionary<Guid, ShowAudience> strictest,
PlanningRules? rules
)
{
if (slot.GroupId is not { } groupId || !groups.TryGetValue(groupId, out var group))
{
yield return new TemplateIssueDto(
TemplateIssueKind.GroupMissing,
TemplateIssueSeverity.Error,
layer.Id,
slot.Id,
$"У слота «{slot.Title}» не выбрана группа — место закроет фон."
);
yield break;
}
if (group.ItemCount == 0)
{
yield return new TemplateIssueDto(
TemplateIssueKind.GroupEmpty,
TemplateIssueSeverity.Error,
layer.Id,
slot.Id,
$"Группа «{group.Name}» пуста — слот «{slot.Title}» заполнит фон."
);
yield break;
}
var perWeek = OccurrencesPerWeek(slot);
if (group.ItemCount < perWeek)
yield return new TemplateIssueDto(
TemplateIssueKind.GroupTooSmall,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"В группе «{group.Name}» {group.ItemCount} позиций при {perWeek} выходах в неделю — "
+ "повторы пойдут чаще, чем раз в неделю."
);
// Остывание считается по числу выходов: за N дней слот выйдет N × (выходов в день) раз,
// и если это больше состава группы, отсекать будет некого.
var strategy = SlotStrategy.FromJson(slot.StrategyJson);
if (
strategy is { Type: SlotStrategyType.RandomWithCooldown, CooldownDays: > 0 }
&& strategy.CooldownDays * perWeek / 7.0 > group.ItemCount
)
yield return new TemplateIssueDto(
TemplateIssueKind.CooldownUnreachable,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"Остывание {strategy.CooldownDays} дней невыполнимо при {group.ItemCount} позициях "
+ $"в группе «{group.Name}»."
);
if (
rules?.AudienceAt(slot.TargetStart) is { } maxAudience
&& strictest.TryGetValue(groupId, out var groupAudience)
&& groupAudience > maxAudience
)
yield return new TemplateIssueDto(
TemplateIssueKind.AudienceConflict,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"В группе «{group.Name}» есть контент категории «{groupAudience}», а слот "
+ $"«{slot.Title}» стоит во времени не строже «{maxAudience}»."
);
}
/// <summary>Сколько раз слот выходит в неделю: без дня недели — каждый день.</summary>
private static int OccurrencesPerWeek(Slot slot) => slot.Weekday is null ? 7 : 1;
/// <summary>
/// Пересечения слотов внутри одного слоя. Внутри слоя приоритетов нет, поэтому сыграет первый
/// по времени, а второй молча пропадёт — это стоит показать до генерации.
/// </summary>
private static IEnumerable<TemplateIssueDto> FindOverlaps(GridLayer layer, TimeOnly dayStart)
{
var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList();
for (var i = 0; i < slots.Count; i++)
for (var j = i + 1; j < slots.Count; j++)
{
var a = slots[i];
var b = slots[j];
if (!SameDays(a, b))
continue;
var aFrom = OffsetInDay(a.TargetStart, dayStart);
var bFrom = OffsetInDay(b.TargetStart, dayStart);
if (aFrom < bFrom + b.TargetDurationMinutes && bFrom < aFrom + a.TargetDurationMinutes)
yield return new TemplateIssueDto(
TemplateIssueKind.SlotOverlap,
TemplateIssueSeverity.Warning,
layer.Id,
b.Id,
$"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»."
);
}
}
/// <summary>Слот без дня недели идёт каждый день, поэтому пересекается с любым.</summary>
private static bool SameDays(Slot a, Slot b) =>
a.Weekday is null || b.Weekday is null || a.Weekday == b.Weekday;
/// <summary>
/// Интервалы вещательных суток, не покрытые ни одним слотом. Проверяется по каждому дню недели:
/// дыра во вторник ночью не видна, если смотреть на неделю целиком.
/// </summary>
private static IEnumerable<TemplateIssueDto> FindGaps(
IReadOnlyList<GridLayer> layers,
TimeOnly dayStart
)
{
foreach (var weekday in new[] { 1, 2, 3, 4, 5, 6, 0 })
{
var intervals = layers
.SelectMany(l => l.Slots)
.Where(s => s.Weekday is null || s.Weekday == weekday)
.Select(s =>
{
var from = OffsetInDay(s.TargetStart, dayStart);
return (From: from, To: from + s.TargetDurationMinutes);
})
.OrderBy(i => i.From)
.ToList();
var cursor = 0;
foreach (var interval in intervals)
{
if (interval.From > cursor)
yield return Gap(weekday, cursor, interval.From, dayStart);
cursor = Math.Max(cursor, Math.Min(interval.To, MinutesInDay));
}
if (cursor < MinutesInDay)
yield return Gap(weekday, cursor, MinutesInDay, dayStart);
}
}
private static TemplateIssueDto Gap(int weekday, int from, int to, TimeOnly dayStart)
{
var day = new[] { "вс", "пн", "вт", "ср", "чт", "пт", "сб" }[weekday];
return new TemplateIssueDto(
TemplateIssueKind.GridGap,
TemplateIssueSeverity.Error,
null,
null,
$"Не покрыто: {day} {Clock(from, dayStart)}{Clock(to, dayStart)}."
);
}
private static string Clock(int offsetMinutes, TimeOnly dayStart)
{
var minutes = ((int)dayStart.ToTimeSpan().TotalMinutes + offsetMinutes) % MinutesInDay;
return $"{minutes / 60:00}:{minutes % 60:00}";
}
private static int OffsetInDay(TimeOnly time, TimeOnly dayStart)
{
var diff = (int)(time.ToTimeSpan() - dayStart.ToTimeSpan()).TotalMinutes;
return diff >= 0 ? diff : diff + MinutesInDay;
}
/// <summary>
/// Строжайшая категория среди позиций каждой группы: именно она конфликтует с детским временем.
/// Коллекция берётся по строжайшей части — франшиза идёт целиком.
/// </summary>
private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
IReadOnlyCollection<Guid> groupIds,
CancellationToken cancellationToken
)
{
if (groupIds.Count == 0)
return [];
var items = await dbContext
.GroupItems.AsNoTracking()
.Where(i => groupIds.Contains(i.GroupId))
.Select(i => new
{
i.GroupId,
i.ElementKind,
i.ElementId,
})
.ToListAsync(cancellationToken);
var showIds = items
.Where(i => i.ElementKind == GroupElementKind.Show)
.Select(i => i.ElementId)
.ToList();
var collectionIds = items
.Where(i => i.ElementKind == GroupElementKind.Collection)
.Select(i => i.ElementId)
.ToList();
var partsByCollection = await dbContext
.CollectionItems.AsNoTracking()
.Where(i => collectionIds.Contains(i.CollectionId))
.Select(i => new { i.CollectionId, i.ShowId })
.ToListAsync(cancellationToken);
var audiences = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id) || partsByCollection.Select(p => p.ShowId).Contains(s.Id))
.Select(s => new { s.Id, s.Audience })
.ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken);
var result = new Dictionary<Guid, ShowAudience>();
foreach (var item in items)
{
var candidates =
item.ElementKind == GroupElementKind.Show
? [item.ElementId]
: partsByCollection
.Where(p => p.CollectionId == item.ElementId)
.Select(p => p.ShowId)
.ToList();
foreach (var showId in candidates)
{
if (!audiences.TryGetValue(showId, out var audience))
continue;
if (!result.TryGetValue(item.GroupId, out var current) || audience > current)
result[item.GroupId] = audience;
}
}
return result;
}
}
@@ -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
);
@@ -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>;
@@ -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();
} }
} }
@@ -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,
}
@@ -205,4 +205,15 @@ public enum PlanningWarningKind
/// <summary>Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата.</summary> /// <summary>Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата.</summary>
CandidatesFiltered = 5, CandidatesFiltered = 5,
// ── Пост-проверки: считаются по готовой ленте и ничего не переигрывают (см. 3.8). ──
/// <summary>Врезок в часе больше заданного потолка.</summary>
BreakLimitExceeded = 6,
/// <summary>Доля одного жанра за сутки выше заданной.</summary>
GenreShareExceeded = 7,
/// <summary>Фон занял больше эфира, чем считается нормой.</summary>
FallbackShareExceeded = 8,
} }
@@ -120,6 +120,7 @@ public static class DependencyInjection
services.AddScoped<SlotWriter>(); services.AddScoped<SlotWriter>();
services.AddScoped<GroupExpander>(); services.AddScoped<GroupExpander>();
services.AddScoped<BumperResolver>(); services.AddScoped<BumperResolver>();
services.AddScoped<PostCheckRunner>();
services.AddScoped<GridScheduleGenerator>(); services.AddScoped<GridScheduleGenerator>();
AddMedia(services, configuration); AddMedia(services, configuration);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,73 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ChannelViewerSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "AnalogFilterStrength",
table: "Channels",
type: "double precision",
nullable: false,
defaultValue: 0.0);
migrationBuilder.AddColumn<int>(
name: "LogoCorner",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<Guid>(
name: "LogoImageId",
table: "Channels",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "LogoOpacity",
table: "Channels",
type: "double precision",
nullable: false,
defaultValue: 0.0);
migrationBuilder.AddColumn<bool>(
name: "ShowClock",
table: "Channels",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AnalogFilterStrength",
table: "Channels");
migrationBuilder.DropColumn(
name: "LogoCorner",
table: "Channels");
migrationBuilder.DropColumn(
name: "LogoImageId",
table: "Channels");
migrationBuilder.DropColumn(
name: "LogoOpacity",
table: "Channels");
migrationBuilder.DropColumn(
name: "ShowClock",
table: "Channels");
}
}
}
@@ -318,6 +318,9 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid>("Id") 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)
@@ -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,94 @@
using TeleWave.Application.Programming.Templates;
using TeleWave.Domain.Library;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
/// <summary>Окна детского времени: разрешение по времени суток, включая переход через полночь.</summary>
public class PlanningRulesTests
{
[Theory]
[InlineData(7, 0, true)]
[InlineData(22, 59, true)]
[InlineData(6, 0, true)] // ровно начало
[InlineData(23, 0, false)] // ровно конец — уже вне окна
[InlineData(2, 0, false)]
public void AudienceAt_DayWindow(int hour, int minute, bool inside)
{
var rules = new PlanningRules(
[new AudienceWindow(new TimeOnly(6, 0), new TimeOnly(23, 0), ShowAudience.Teen)]
);
var result = rules.AudienceAt(new TimeOnly(hour, minute));
Assert.Equal(inside ? ShowAudience.Teen : null, result);
}
[Theory]
[InlineData(23, 30, true)]
[InlineData(3, 0, true)]
[InlineData(12, 0, false)]
public void AudienceAt_WindowCrossingMidnight(int hour, int minute, bool inside)
{
// «С 23:00 до 06:00» — ночное окно, границы сравниваются в обратную сторону.
var rules = new PlanningRules(
[new AudienceWindow(new TimeOnly(23, 0), new TimeOnly(6, 0), ShowAudience.Adult)]
);
var result = rules.AudienceAt(new TimeOnly(hour, minute));
Assert.Equal(inside ? ShowAudience.Adult : null, result);
}
[Fact]
public void AudienceAt_OverlappingWindows_TakesTheStrictest()
{
// Широкое окно, случайно наложенное поверх детского, не должно его отменять.
var rules = new PlanningRules(
[
new AudienceWindow(new TimeOnly(6, 0), new TimeOnly(23, 0), ShowAudience.General),
new AudienceWindow(new TimeOnly(7, 0), new TimeOnly(10, 0), ShowAudience.Kids),
]
);
Assert.Equal(ShowAudience.Kids, rules.AudienceAt(new TimeOnly(8, 0)));
Assert.Equal(ShowAudience.General, rules.AudienceAt(new TimeOnly(12, 0)));
}
[Fact]
public void AudienceAt_WithoutWindows_IsUnlimited()
{
Assert.Null(new PlanningRules().AudienceAt(new TimeOnly(3, 0)));
}
[Fact]
public void Json_RoundTrips()
{
var rules = new PlanningRules(
[new AudienceWindow(new TimeOnly(6, 0), new TimeOnly(23, 0), ShowAudience.Teen)],
new RepeatLimitRule(7, 2),
MaxBreakMinutesPerHour: 12,
MaxGenreSharePercent: 40,
MaxFallbackSharePercent: 15
);
var restored = PlanningRules.FromJson(rules.ToJson());
// Сравниваем по полям: у record со списком внутри равенство ссылочное, и `Assert.Equal`
// на самих правилах проверял бы не то.
Assert.NotNull(restored);
Assert.Equal(rules.MaxAudienceByTime!, restored.MaxAudienceByTime!);
Assert.Equal(rules.MaxRepeatsInWindow, restored.MaxRepeatsInWindow);
Assert.Equal(rules.MaxBreakMinutesPerHour, restored.MaxBreakMinutesPerHour);
Assert.Equal(rules.MaxGenreSharePercent, restored.MaxGenreSharePercent);
Assert.Equal(rules.MaxFallbackSharePercent, restored.MaxFallbackSharePercent);
}
[Fact]
public void FromJson_Garbage_IsNull()
{
// Битый JSON не должен ронять генерацию: правило просто считается незаданным.
Assert.Null(PlanningRules.FromJson("{"));
Assert.Null(PlanningRules.FromJson(null));
}
}
@@ -55,6 +55,39 @@ public class ChannelTests
Assert.Equal(BumperSelection.AlwaysFirst, channel.BumperSelection); Assert.Equal(BumperSelection.AlwaysFirst, channel.BumperSelection);
} }
[Theory]
[InlineData(-1.0, 2.0, 0.0, 1.0)]
[InlineData(0.4, 0.25, 0.4, 0.25)]
public void UpdateViewerSettings_ClampsStrengths(
double opacity,
double filter,
double expectedOpacity,
double expectedFilter
)
{
var channel = NewChannel();
var logo = Guid.NewGuid();
channel.UpdateViewerSettings(logo, LogoCorner.BottomRight, opacity, true, filter);
Assert.Equal(logo, channel.LogoImageId);
Assert.Equal(LogoCorner.BottomRight, channel.LogoCorner);
Assert.Equal(expectedOpacity, channel.LogoOpacity);
Assert.True(channel.ShowClock);
Assert.Equal(expectedFilter, channel.AnalogFilterStrength);
}
[Fact]
public void NewChannel_HasViewerOverlaysOff()
{
// Канал без логотипа, часов и шума — законная конфигурация, а не недонастроенная.
var channel = NewChannel();
Assert.Null(channel.LogoImageId);
Assert.False(channel.ShowClock);
Assert.Equal(0.0, channel.AnalogFilterStrength);
}
[Fact] [Fact]
public void AddBumperTemplate_AppendsWithNextPosition() public void AddBumperTemplate_AppendsWithNextPosition()
{ {
+19 -13
View File
@@ -23,7 +23,14 @@
и растягиваются мышью, день копируется на другие дни. Добавлены жёсткие фильтры кандидатов — и растягиваются мышью, день копируется на другие дни. Добавлены жёсткие фильтры кандидатов —
детское время и потолок повторов. детское время и потолок повторов.
**Срез 4** не начинался. **Срез 4 закрыт целиком.** Проверки по правилам показываются прямо в редакторе, пост-проверки
дают предупреждения по готовой ленте, у предпросмотра появилась вкладка «Проблемы» с тепловой
картой повторов, у каждой записи — трейс «почему это здесь», применение идёт через диф
с подсветкой ближайших суток, сетка копируется на другой канал.
**Смежная зрительская часть закрыта.** Номера каналов с сортировкой публичного списка,
переключение по номерам стрелками (глобальный флаг рядом с флагом регистрации), логотип-оверлей,
часы, плашка «Далее» и аналоговый фильтр. Всё опционально и по умолчанию выключено.
Ничего из этого не проверялось на живой базе: только сборка, юнит-тесты и typecheck. Ничего из этого не проверялось на живой базе: только сборка, юнит-тесты и typecheck.
@@ -356,36 +363,36 @@ N новых позиций по фильтру».
## Срез 4 — эксплуатация ## Срез 4 — эксплуатация
#### [ ] 4.1. Валидация до генерации #### [x] 4.1. Валидация до генерации
`Application` `Api` `Frontend` `Application` `Api` `Frontend`
Проверки из 5.1: нехватка контента, пустая группа, дыра в сетке, пересечение слотов, недостижимый Проверки из 5.1: нехватка контента, пустая группа, дыра в сетке, пересечение слотов, недостижимый
кулдаун, возрастной конфликт. Отдаются вместе с шаблоном, показываются в редакторе. кулдаун, возрастной конфликт. Отдаются вместе с шаблоном, показываются в редакторе.
#### [ ] 4.2. Пост-проверки #### [x] 4.2. Пост-проверки
`Application` `Application`
Потолок врезок в час, доля жанра за сутки, превышение дрейфа, доля эфира у фона (см. 3.8, 5.2). Потолок врезок в час, доля жанра за сутки, превышение дрейфа, доля эфира у фона (см. 3.8, 5.2).
Предупреждения, не ошибки — ничего не переигрывается. Предупреждения, не ошибки — ничего не переигрывается.
#### [ ] 4.3. Вкладка «Проблемы» и тепловая карта #### [x] 4.3. Вкладка «Проблемы» и тепловая карта
`Frontend` `Frontend`
Сгруппированные предупреждения с переходом к источнику; матрица «элемент × день» с яркостью Сгруппированные предупреждения с переходом к источнику; матрица «элемент × день» с яркостью
по числу показов. по числу показов.
#### [ ] 4.4. «Почему это здесь» #### [x] 4.4. «Почему это здесь»
`Api` `Frontend` `Api` `Frontend`
Отдача `trace` записи и экран цепочки происхождения (см. 6.5). Отдача `trace` записи и экран цепочки происхождения (см. 6.5).
#### [ ] 4.5. Диф перед применением #### [x] 4.5. Диф перед применением
`Application` `Api` `Frontend` `Application` `Api` `Frontend`
Сравнение текущего хвоста с пересчитанным, список изменений, отдельная подсветка ближайших суток Сравнение текущего хвоста с пересчитанным, список изменений, отдельная подсветка ближайших суток
(см. 6.6). (см. 6.6).
#### [ ] 4.6. Копирование шаблона #### [x] 4.6. Копирование шаблона
`Application` `Api` `Frontend` `Application` `Api` `Frontend`
Глубокая копия шаблона, слоёв и слотов на другой канал; группы не копируются, они общие. Глубокая копия шаблона, слоёв и слотов на другой канал; группы не копируются, они общие.
@@ -397,31 +404,30 @@ N новых позиций по фильтру».
От планировщика не зависит, делается параллельно в любой момент. Всё опционально и по умолчанию От планировщика не зависит, делается параллельно в любой момент. Всё опционально и по умолчанию
выключено (см. 6.8). выключено (см. 6.8).
#### [~] V.1. Номер канала #### [x] V.1. Номер канала
`Domain` `Application` `Api` `Frontend` `Domain` `Application` `Api` `Frontend`
> Частично: поле, уникальность и правка в настройках канала готовы; сортировки публичного списка нет.
`Channel.number`, уникальность, сортировка публичного списка по номеру. `Channel.number`, уникальность, сортировка публичного списка по номеру.
#### [ ] V.2. Переключение по номерам #### [x] V.2. Переключение по номерам
`Frontend` `Frontend`
Вверх-вниз по номерам, короткий чёрный кадр, номер в углу на секунду. Включается глобальным флагом Вверх-вниз по номерам, короткий чёрный кадр, номер в углу на секунду. Включается глобальным флагом
в `AppSetting` рядом с флагом регистрации. Сетка каналов остаётся вторым способом навигации. в `AppSetting` рядом с флагом регистрации. Сетка каналов остаётся вторым способом навигации.
#### [ ] V.3. Логотип канала #### [x] V.3. Логотип канала
`Domain` `Api` `Frontend` `Domain` `Api` `Frontend`
`logoImageId` (реестр изображений), угол и прозрачность. Оверлей поверх `<video>`, без касания `logoImageId` (реестр изображений), угол и прозрачность. Оверлей поверх `<video>`, без касания
ffmpeg. ffmpeg.
#### [ ] V.4. Часы и плашка «Далее» #### [x] V.4. Часы и плашка «Далее»
`Frontend` `Frontend`
Часы — опция канала. Плашка в конце программы по данным EPG. Часы — опция канала. Плашка в конце программы по данным EPG.
#### [ ] V.5. Аналоговый фильтр #### [x] V.5. Аналоговый фильтр
`Domain` `Frontend` `Domain` `Frontend`
`analogFilterStrength` на канале, CSS-фильтр или шейдер, по умолчанию выключен. `analogFilterStrength` на канале, CSS-фильтр или шейдер, по умолчанию выключен.
@@ -12,25 +12,31 @@ import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store' import { toast } from '@/shared/ui/toast-store'
import { import {
applyChannelTemplate, applyChannelTemplate,
copyTemplateTo,
createLayer, createLayer,
createSlot, createSlot,
deleteLayer, deleteLayer,
getChannel, getChannel,
getChannelTemplate, getChannelTemplate,
getSchedule, getSchedule,
listChannels,
toSlotBody, toSlotBody,
updateLayer, updateLayer,
updateSlot, updateSlot,
} from './api' } from './api'
import { ApplyDialog } from './components/ApplyDialog'
import { BumperCard } from './components/BumperCard' import { BumperCard } from './components/BumperCard'
import { CollapsibleCard } from './components/CollapsibleCard' import { CollapsibleCard } from './components/CollapsibleCard'
import { EntryTraceDialog } from './components/EntryTraceDialog'
import { JunctionsCard } from './components/JunctionsCard' import { JunctionsCard } from './components/JunctionsCard'
import { LayerApplicabilityDialog } from './components/LayerApplicabilityDialog' import { LayerApplicabilityDialog } from './components/LayerApplicabilityDialog'
import { LayerList, ScheduleGrid } from './components/ScheduleGrid' import { LayerList, ScheduleGrid } from './components/ScheduleGrid'
import { SchedulePreview } from './components/SchedulePreview' import { SchedulePreview } from './components/SchedulePreview'
import { RulesCard } from './components/RulesCard' import { RulesCard } from './components/RulesCard'
import { SettingsCard } from './components/SettingsCard' import { SettingsCard } from './components/SettingsCard'
import { TemplateIssues } from './components/TemplateIssues'
import { TemplatePreview } from './components/TemplatePreview' import { TemplatePreview } from './components/TemplatePreview'
import { ViewerCard } from './components/ViewerCard'
import { SlotInspector, type SlotDraft } from './components/SlotInspector' import { SlotInspector, type SlotDraft } from './components/SlotInspector'
import { toTime } from './lib/format' import { toTime } from './lib/format'
@@ -44,6 +50,9 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
// День, который копируем, и отмеченные дни-приёмники. // День, который копируем, и отмеченные дни-приёмники.
const [copySource, setCopySource] = useState<number | null>(null) const [copySource, setCopySource] = useState<number | null>(null)
const [copyTargets, setCopyTargets] = useState<number[]>([]) const [copyTargets, setCopyTargets] = useState<number[]>([])
const [applyOpen, setApplyOpen] = useState(false)
const [traceEntryId, setTraceEntryId] = useState<string | null>(null)
const [copyToChannel, setCopyToChannel] = useState('')
const { data: channel, isLoading } = useQuery({ const { data: channel, isLoading } = useQuery({
queryKey: ['admin', 'channels', channelId], queryKey: ['admin', 'channels', channelId],
@@ -68,9 +77,12 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
const onError = (error: unknown) => const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error')) toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const { data: channels } = useQuery({ queryKey: ['admin', 'channels'], queryFn: listChannels })
const applyMutation = useMutation({ const applyMutation = useMutation({
mutationFn: () => applyChannelTemplate(channelId), mutationFn: () => applyChannelTemplate(channelId),
onSuccess: (result) => { onSuccess: (result) => {
setApplyOpen(false)
toast.success(t('admin.channels.applied', { count: result.added })) toast.success(t('admin.channels.applied', { count: result.added }))
// Предупреждения показываем по одному: каждое указывает на конкретный слот. // Предупреждения показываем по одному: каждое указывает на конкретный слот.
for (const warning of result.warnings) for (const warning of result.warnings)
@@ -136,6 +148,21 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
onError, onError,
}) })
const copyTemplateMutation = useMutation({
mutationFn: (targetChannelId: string) => copyTemplateTo(channelId, targetChannelId),
onSuccess: (result) => {
setCopyToChannel('')
toast.success(
t('admin.channels.templateCopied', { layers: result.layers, slots: result.slots }),
)
if (result.droppedBumperRefs > 0)
toast.error(
t('admin.channels.copyDroppedBumpers', { count: result.droppedBumperRefs }),
)
},
onError,
})
const moveSlotMutation = useMutation({ const moveSlotMutation = useMutation({
mutationFn: ({ mutationFn: ({
slot, slot,
@@ -215,7 +242,7 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
{template?.hasPendingChanges && ( {template?.hasPendingChanges && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-amber-500/50 bg-amber-500/10 px-4 py-2 text-sm"> <div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-amber-500/50 bg-amber-500/10 px-4 py-2 text-sm">
<span>{t('admin.channels.pendingChanges')}</span> <span>{t('admin.channels.pendingChanges')}</span>
<Button size="sm" disabled={applyMutation.isPending} onClick={() => applyMutation.mutate()}> <Button size="sm" disabled={applyMutation.isPending} onClick={() => setApplyOpen(true)}>
<Send className="h-4 w-4" /> {t('admin.channels.apply')} <Send className="h-4 w-4" /> {t('admin.channels.apply')}
</Button> </Button>
</div> </div>
@@ -256,9 +283,48 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
onEditApplicability={setApplicabilityLayer} onEditApplicability={setApplicabilityLayer}
/> />
<p className="text-xs text-muted-foreground">{t('admin.channels.layersHint')}</p> <p className="text-xs text-muted-foreground">{t('admin.channels.layersHint')}</p>
{/* Копия сетки на другой канал: группы общие, поэтому переносятся только правила. */}
<div className="flex flex-col gap-1.5 border-t border-border pt-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.copyTemplate')}
</span>
<select
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
value={copyToChannel}
onChange={(e) => setCopyToChannel(e.target.value)}
>
<option value="">{t('admin.channels.pickTargetChannel')}</option>
{(channels ?? [])
.filter((c) => c.id !== channelId)
.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<Button
size="sm"
variant="outline"
disabled={!copyToChannel || copyTemplateMutation.isPending}
onClick={() => copyTemplateMutation.mutate(copyToChannel)}
>
{t('admin.channels.copy')}
</Button>
<p className="text-xs text-muted-foreground">
{t('admin.channels.copyTemplateHint')}
</p>
</div>
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<TemplateIssues
channelId={channelId}
slotsById={
new Map(template.layers.flatMap((l) => l.slots).map((slot) => [slot.id, slot]))
}
onGoToSlot={openSlot}
/>
<TemplatePreview channelId={channelId} /> <TemplatePreview channelId={channelId} />
{/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */} {/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */}
@@ -359,6 +425,8 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
<BumperCard channel={channel} onSaved={invalidate} onError={onError} /> <BumperCard channel={channel} onSaved={invalidate} onError={onError} />
<ViewerCard channel={channel} onSaved={invalidate} onError={onError} />
{applicabilityLayer && ( {applicabilityLayer && (
<LayerApplicabilityDialog <LayerApplicabilityDialog
layer={applicabilityLayer} layer={applicabilityLayer}
@@ -368,7 +436,25 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
/> />
)} )}
<SchedulePreview entries={schedule ?? []} /> <SchedulePreview entries={schedule ?? []} onShowTrace={setTraceEntryId} />
{applyOpen && (
<ApplyDialog
channelId={channelId}
utcOffsetMinutes={channel.utcOffsetMinutes}
pending={applyMutation.isPending}
onApply={() => applyMutation.mutate()}
onClose={() => setApplyOpen(false)}
/>
)}
{traceEntryId && (
<EntryTraceDialog
entryId={traceEntryId}
utcOffsetMinutes={channel.utcOffsetMinutes}
onClose={() => setTraceEntryId(null)}
/>
)}
</div> </div>
) )
} }
@@ -6,17 +6,22 @@ import type {
BumperTrigger, BumperTrigger,
ChannelDto, ChannelDto,
ChannelSummaryDto, ChannelSummaryDto,
CopyTemplateResultDto,
CreatedIdResponse, CreatedIdResponse,
EntryTraceDto,
JunctionAmountMode, JunctionAmountMode,
JunctionConditions, JunctionConditions,
JunctionElementKind, JunctionElementKind,
JunctionTemplateDto, JunctionTemplateDto,
LayerApplicability, LayerApplicability,
PlanningRules, PlanningRules,
ScheduleDiffDto,
ScheduleEntryDto, ScheduleEntryDto,
SchedulePreviewDto, SchedulePreviewDto,
ScheduleTemplateDto, ScheduleTemplateDto,
SlotDto, SlotDto,
TemplateIssueDto,
ViewerSettings,
} from '@/shared/api/types' } from '@/shared/api/types'
export function listChannels() { export function listChannels() {
@@ -43,6 +48,11 @@ export function updateChannelSettings(id: string, body: ChannelSettingsBody) {
return apiRequest<void>(`/admin/channels/${id}/settings`, { method: 'PUT', body }) return apiRequest<void>(`/admin/channels/${id}/settings`, { method: 'PUT', body })
} }
/** Оверлеи и аналоговый фильтр канала — как он выглядит у зрителя. */
export function updateViewerSettings(id: string, body: ViewerSettings) {
return apiRequest<void>(`/admin/channels/${id}/viewer`, { method: 'PUT', body })
}
/** Номер канала и его время: смещение от UTC и начало вещательных суток. */ /** Номер канала и его время: смещение от UTC и начало вещательных суток. */
export function updateChannelTime( export function updateChannelTime(
id: string, id: string,
@@ -64,6 +74,29 @@ export function applyChannelTemplate(channelId: string) {
}) })
} }
/** Проверки сетки по правилам — считаются по шаблону, без прогона генератора. */
export function getTemplateIssues(channelId: string) {
return apiRequest<TemplateIssueDto[]>(`/admin/channels/${channelId}/template/issues`)
}
/** Что изменится в эфире, если применить сейчас. Прогон сухой — лента не трогается. */
export function getApplyDiff(channelId: string) {
return apiRequest<ScheduleDiffDto>(`/admin/channels/${channelId}/template/diff`)
}
/** Копия сетки на другой канал: слои, слоты, стыки и правила. Группы общие и не копируются. */
export function copyTemplateTo(channelId: string, targetChannelId: string) {
return apiRequest<CopyTemplateResultDto>(
`/admin/channels/${channelId}/template/copy-to/${targetChannelId}`,
{ method: 'POST' },
)
}
/** Цепочка происхождения записи, записанная в момент генерации. */
export function getEntryTrace(entryId: string) {
return apiRequest<EntryTraceDto>(`/admin/channels/entries/${entryId}/trace`)
}
/** Сухой прогон по текущим правилам: ничего не пишет и не двигает курсоры слотов. */ /** Сухой прогон по текущим правилам: ничего не пишет и не двигает курсоры слотов. */
export function previewTemplate(channelId: string, days: number) { export function previewTemplate(channelId: string, days: number) {
const query = new URLSearchParams({ days: String(days) }) const query = new URLSearchParams({ days: String(days) })
@@ -0,0 +1,102 @@
import { useQuery } from '@tanstack/react-query'
import { AlertTriangle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { cn } from '@/shared/lib/cn'
import { getApplyDiff } from '../api'
import { formatChannelTime } from '../lib/format'
/**
* Диф перед применением (см. 6.6). Изменения в ближайшие сутки подсвечены отдельно — это самая
* частая причина случайного ущерба: у зрителя из-под носа уезжает то, что он уже видит в программе.
*/
export function ApplyDialog({
channelId,
utcOffsetMinutes,
pending,
onApply,
onClose,
}: {
channelId: string
utcOffsetMinutes: number
pending: boolean
onApply: () => void
onClose: () => void
}) {
const { t } = useTranslation()
const { data, isFetching } = useQuery({
queryKey: ['admin', 'channels', channelId, 'diff'],
queryFn: () => getApplyDiff(channelId),
staleTime: 0,
gcTime: 0,
})
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('admin.channels.apply')}</DialogTitle>
<DialogDescription>
{isFetching || !data
? t('common.loading')
: t('admin.channels.diffSummary', { total: data.total, changed: data.changed })}
</DialogDescription>
</DialogHeader>
{data && (
<div className="flex flex-col gap-2 text-sm">
{data.changedSoon > 0 && (
<p className="flex items-center gap-1.5 text-amber-500">
<AlertTriangle className="h-4 w-4 shrink-0" />
{t('admin.channels.diffSoon', { count: data.changedSoon })}
</p>
)}
{data.changes.length === 0 ? (
<p className="text-muted-foreground">{t('admin.channels.diffNoChanges')}</p>
) : (
<ul className="max-h-80 divide-y divide-border overflow-y-auto text-xs">
{data.changes.map((change, index) => (
<li
key={`${change.startsAtUtc}-${index}`}
className={cn('flex items-center gap-2 py-1', change.soon && 'text-amber-500')}
>
<span className="w-24 shrink-0 tabular-nums text-muted-foreground">
{formatChannelTime(change.startsAtUtc, utcOffsetMinutes)}
</span>
<span className="min-w-0 flex-1 truncate">{change.before ?? '—'}</span>
<span className="shrink-0 text-muted-foreground"></span>
<span className="min-w-0 flex-1 truncate">{change.after ?? '—'}</span>
</li>
))}
</ul>
)}
{data.changed > data.changes.length && (
<p className="text-xs text-muted-foreground">
{t('admin.channels.andMore', { count: data.changed - data.changes.length })}
</p>
)}
</div>
)}
<DialogFooter>
<Button size="sm" variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button size="sm" disabled={pending} onClick={onApply}>
{t('admin.channels.apply')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,104 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { getEntryTrace } from '../api'
import { formatChannelTime } from '../lib/format'
/**
* «Почему это здесь» (см. 6.5): цепочка происхождения записи. Трейс пишется в момент генерации —
* восстановить его потом нельзя, поэтому у старых записей часть строк будет пустой.
*/
export function EntryTraceDialog({
entryId,
utcOffsetMinutes,
onClose,
}: {
entryId: string
utcOffsetMinutes: number
onClose: () => void
}) {
const { t } = useTranslation()
const { data } = useQuery({
queryKey: ['admin', 'entries', entryId, 'trace'],
queryFn: () => getEntryTrace(entryId),
})
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{data
? `${data.showName ?? '—'} · ${formatChannelTime(data.startsAtUtc, utcOffsetMinutes)}`
: t('common.loading')}
</DialogTitle>
</DialogHeader>
{data && (
<dl className="grid grid-cols-[110px_1fr] gap-x-3 gap-y-1.5 text-sm">
<Row label={t('admin.channels.traceLayer')}>
{data.layerName
? `${data.layerName}${data.layerPriority !== null ? ` (${t('admin.channels.priority')} ${data.layerPriority})` : ''}`
: null}
</Row>
<Row label={t('admin.channels.traceSlot')}>
{data.slotTitle
? [
data.slotTitle,
data.slotWeekday === null
? t('admin.channels.everyDay')
: t(`admin.channels.weekdays.${data.slotWeekday}`),
data.slotTargetStart?.slice(0, 5),
data.slotDurationMinutes ? `${data.slotDurationMinutes} мин` : null,
data.driftMinutes !== 0
? t('admin.channels.traceDrift', { minutes: data.driftMinutes })
: null,
data.snapped ? t('admin.channels.traceSnapped') : null,
]
.filter(Boolean)
.join(' · ')
: null}
</Row>
<Row label={t('admin.channels.traceGroup')}>
{data.groupName
? `${data.groupName}${data.groupItemCount !== null ? ` (${data.groupItemCount})` : ''}`
: null}
</Row>
<Row label={t('admin.channels.traceStrategy')}>
{data.strategy
? [
t(`admin.channels.strategies.${data.strategy}`),
data.cooldownDays
? t('admin.channels.traceCooldown', { days: data.cooldownDays })
: null,
data.candidatesAfterCooldown !== null
? t('admin.channels.traceCandidates', {
count: data.candidatesAfterCooldown,
})
: null,
]
.filter(Boolean)
.join(' · ')
: null}
</Row>
<Row label={t('admin.channels.traceJunction')}>{data.junctionName}</Row>
</dl>
)}
</DialogContent>
</Dialog>
)
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<>
<dt className="text-muted-foreground">{label}</dt>
<dd>{children || '—'}</dd>
</>
)
}
@@ -40,12 +40,18 @@ export function RulesCard({
() => template.rules?.maxRepeatsInWindow?.windowDays ?? 7, () => template.rules?.maxRepeatsInWindow?.windowDays ?? 7,
) )
const [max, setMax] = useState(() => template.rules?.maxRepeatsInWindow?.max ?? 2) const [max, setMax] = useState(() => template.rules?.maxRepeatsInWindow?.max ?? 2)
const [breakCap, setBreakCap] = useState(() => template.rules?.maxBreakMinutesPerHour ?? 0)
const [genreCap, setGenreCap] = useState(() => template.rules?.maxGenreSharePercent ?? 0)
const [fallbackCap, setFallbackCap] = useState(() => template.rules?.maxFallbackSharePercent ?? 0)
useEffect(() => { useEffect(() => {
setWindows(template.rules?.maxAudienceByTime ?? []) setWindows(template.rules?.maxAudienceByTime ?? [])
setLimitOn(template.rules?.maxRepeatsInWindow != null) setLimitOn(template.rules?.maxRepeatsInWindow != null)
setWindowDays(template.rules?.maxRepeatsInWindow?.windowDays ?? 7) setWindowDays(template.rules?.maxRepeatsInWindow?.windowDays ?? 7)
setMax(template.rules?.maxRepeatsInWindow?.max ?? 2) setMax(template.rules?.maxRepeatsInWindow?.max ?? 2)
setBreakCap(template.rules?.maxBreakMinutesPerHour ?? 0)
setGenreCap(template.rules?.maxGenreSharePercent ?? 0)
setFallbackCap(template.rules?.maxFallbackSharePercent ?? 0)
}, [template]) }, [template])
const save = useMutation({ const save = useMutation({
@@ -53,6 +59,10 @@ export function RulesCard({
const rules: PlanningRules = { const rules: PlanningRules = {
maxAudienceByTime: windows.length > 0 ? windows : null, maxAudienceByTime: windows.length > 0 ? windows : null,
maxRepeatsInWindow: limitOn ? { windowDays, max } : 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, { return updateTemplate(template.id, {
name: template.name, name: template.name,
@@ -178,6 +188,47 @@ export function RulesCard({
<p className="text-xs text-muted-foreground">{t('admin.channels.repeatLimitHint')}</p> <p className="text-xs text-muted-foreground">{t('admin.channels.repeatLimitHint')}</p>
</div> </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"> <div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}> <Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')} {t('common.save')}
@@ -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>
@@ -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))
}
@@ -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')}
+66 -2
View File
@@ -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>
)
}
+5
View File
@@ -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' })
+99 -1
View File
@@ -18,7 +18,24 @@ export type AuthResponse = {
export type RegistrationStatus = { enabled: boolean } export type RegistrationStatus = { enabled: boolean }
export type SiteSettings = { registrationEnabled: boolean; preferredAudioLanguages: string } export type SiteSettings = {
registrationEnabled: boolean
preferredAudioLanguages: string
/** Переключение каналов по номерам у зрителя; сетка каналов остаётся всегда. */
channelNumbersEnabled: boolean
}
/** Угол экрана для логотипа-оверлея. */
export type LogoCorner = 'TopLeft' | 'TopRight' | 'BottomLeft' | 'BottomRight'
/** Как канал выглядит у зрителя — всё опционально и по умолчанию выключено (см. 6.8). */
export type ViewerSettings = {
logoImageId: string | null
logoCorner: LogoCorner
logoOpacity: number
showClock: boolean
analogFilterStrength: number
}
export type RoleDto = { export type RoleDto = {
id: string id: string
@@ -360,6 +377,7 @@ export type ChannelDto = {
bumper: BumperSettings bumper: BumperSettings
bumperTemplates: BumperTemplateDto[] bumperTemplates: BumperTemplateDto[]
fillerAssetId: string | null fillerAssetId: string | null
viewer: ViewerSettings
} }
// ── Сетка канала (шаблон → слои → слоты) ────────────────────────────────── // ── Сетка канала (шаблон → слои → слоты) ──────────────────────────────────
@@ -466,6 +484,10 @@ export type AudienceWindow = { from: string; to: string; maxAudience: ShowAudien
export type PlanningRules = { export type PlanningRules = {
maxAudienceByTime?: AudienceWindow[] | null maxAudienceByTime?: AudienceWindow[] | null
maxRepeatsInWindow?: { windowDays: number; max: number } | null maxRepeatsInWindow?: { windowDays: number; max: number } | null
/** Пост-проверки: считаются по готовой ленте и только предупреждают. */
maxBreakMinutesPerHour?: number | null
maxGenreSharePercent?: number | null
maxFallbackSharePercent?: number | null
} }
export type ScheduleTemplateDto = { export type ScheduleTemplateDto = {
@@ -493,6 +515,9 @@ export type PlanningWarningKind =
| 'RepeatSourceEmpty' | 'RepeatSourceEmpty'
| 'FallbackEmpty' | 'FallbackEmpty'
| 'CandidatesFiltered' | 'CandidatesFiltered'
| 'BreakLimitExceeded'
| 'GenreShareExceeded'
| 'FallbackShareExceeded'
export type PlanningWarningDto = { export type PlanningWarningDto = {
kind: PlanningWarningKind kind: PlanningWarningKind
@@ -502,6 +527,72 @@ export type PlanningWarningDto = {
export type ApplyResultDto = { added: number; warnings: PlanningWarningDto[] } export type ApplyResultDto = { added: number; warnings: PlanningWarningDto[] }
/** Что изменится в эфире, если применить правила сейчас (см. 6.6). */
export type ScheduleChangeDto = {
startsAtUtc: string
before: string | null
after: string | null
/** Попадает в ближайшие сутки — подсвечивается отдельно. */
soon: boolean
}
export type ScheduleDiffDto = {
total: number
changed: number
changedSoon: number
changes: ScheduleChangeDto[]
}
/** Цепочка происхождения записи — «почему это здесь» (см. 6.5). */
export type EntryTraceDto = {
entryId: string
startsAtUtc: string
endsAtUtc: string
showName: string | null
episodeIndex: number | null
layerName: string | null
layerPriority: number | null
slotTitle: string | null
slotKind: SlotKind | null
slotWeekday: number | null
slotTargetStart: string | null
slotDurationMinutes: number | null
groupName: string | null
groupItemCount: number | null
strategy: SlotStrategyType | null
cooldownDays: number | null
candidatesAfterCooldown: number | null
driftMinutes: number
snapped: boolean
junctionName: string | null
}
export type CopyTemplateResultDto = {
layers: number
slots: number
junctions: number
/** Врезки-заставки, для которых на канале-приёмнике не нашлось блока с таким же именем. */
droppedBumperRefs: number
}
/** Проверки сетки по правилам, до генерации (см. 5.1). */
export type TemplateIssueKind =
| 'GroupEmpty'
| 'GroupTooSmall'
| 'GridGap'
| 'SlotOverlap'
| 'CooldownUnreachable'
| 'AudienceConflict'
| 'GroupMissing'
export type TemplateIssueDto = {
kind: TemplateIssueKind
severity: 'Warning' | 'Error'
layerId: string | null
slotId: string | null
details: string
}
/** Что попало в ленту предпросмотра. `Bumper` приходит без ассета — он рендерится при применении. */ /** Что попало в ленту предпросмотра. `Bumper` приходит без ассета — он рендерится при применении. */
export type PlannedItemKind = 'Program' | 'Fallback' | 'SignOff' | 'Ad' | 'Promo' | 'Bumper' export type PlannedItemKind = 'Program' | 'Fallback' | 'SignOff' | 'Ad' | 'Promo' | 'Bumper'
@@ -543,9 +634,16 @@ export type PublicChannelDto = {
id: string id: string
slug: string slug: string
name: string name: string
/** Номер канала для переключения по номерам или null. */
number: number | null
currentShowId: string | null currentShowId: string | null
currentShowName: string | null currentShowName: string | null
currentShowPosterImageId: string | null currentShowPosterImageId: string | null
logoImageId: string | null
logoCorner: LogoCorner
logoOpacity: number
showClock: boolean
analogFilterStrength: number
} }
export type PublicEpgEntryDto = { export type PublicEpgEntryDto = {
+131 -2
View File
@@ -66,6 +66,7 @@ const resources = {
noChannels: 'Пока нет доступных каналов. Загляните позже.', noChannels: 'Пока нет доступных каналов. Загляните позже.',
offline: 'Канал сейчас не в эфире', offline: 'Канал сейчас не в эфире',
offlineHint: 'Нет расписания или контента. Загляните позже.', offlineHint: 'Нет расписания или контента. Загляните позже.',
numbersHint: '↑ / ↓ — переключение каналов по номерам',
retry: 'Повторить', retry: 'Повторить',
}, },
settings: { settings: {
@@ -378,7 +379,65 @@ const resources = {
previewDays_one: '{{count}} сутки', previewDays_one: '{{count}} сутки',
previewDays_few: '{{count}} суток', previewDays_few: '{{count}} суток',
previewDays_many: '{{count}} суток', previewDays_many: '{{count}} суток',
previewTabs: { programme: 'Программа', tape: 'Лента' }, previewTabs: { programme: 'Программа', tape: 'Лента', problems: 'Проблемы' },
noProblems: 'Проблем нет',
andMore: 'и ещё {{count}}',
heatmap: 'Повторы: шоу × сутки',
heatmapTotal: 'всего',
issues: 'Проверки: ошибок {{errors}}, предупреждений {{warnings}}',
goToSlot: 'к слоту',
issueKinds: {
GroupEmpty: 'Пустая группа',
GroupTooSmall: 'Мало контента',
GridGap: 'Дыра в сетке',
SlotOverlap: 'Слоты пересекаются',
CooldownUnreachable: 'Недостижимое остывание',
AudienceConflict: 'Возрастной конфликт',
GroupMissing: 'Группа не выбрана',
},
viewer: 'Как выглядит у зрителя',
viewerHint:
'Оверлеи рисуются поверх картинки на клиенте — видео не перекодируется. Всё по умолчанию выключено.',
logo: 'Логотип',
noLogo: 'нет',
pickLogo: 'Выбрать логотип',
logoCorner: 'Угол',
logoOpacity: 'Прозрачность',
corners: {
TopLeft: 'Слева вверху',
TopRight: 'Справа вверху',
BottomLeft: 'Слева внизу',
BottomRight: 'Справа внизу',
},
showClock: 'Показывать часы',
analogFilter: 'Аналоговый фильтр',
analogFilterHint: 'Сила 0..1. Ноль — выключен; переборщить очень легко.',
whyHere: 'Почему это здесь',
priority: 'приоритет',
traceLayer: 'Слой',
traceSlot: 'Слот',
traceGroup: 'Группа',
traceStrategy: 'Стратегия',
traceJunction: 'Врезки',
traceDrift: 'дрейф {{minutes}} мин',
traceSnapped: 'старт округлён',
traceCooldown: 'остывание {{days}} дн.',
traceCandidates: 'кандидатов после остывания: {{count}}',
diffSummary: 'Затронет {{total}} записей, изменятся {{changed}}',
diffSoon: 'В ближайшие сутки изменится записей: {{count}}',
diffNoChanges: 'Эфир не изменится',
copyTemplate: 'Копировать сетку',
pickTargetChannel: 'Выберите канал',
copyTemplateHint:
'Слои, слоты, стыки и правила уедут на выбранный канал, его прежняя сетка заменится. Группы общие и не копируются.',
templateCopied: 'Скопировано: слоёв {{layers}}, слотов {{slots}}',
copyDroppedBumpers: 'Врезок без блока заставки: {{count}} — донастройте руками',
postChecks: 'Пост-проверки',
breakLimit: 'Потолок врезок в час, мин',
genreShare: 'Потолок доли жанра за сутки, %',
fallbackShare: 'Потолок доли фона, %',
postChecksHint:
'Пост-проверки считаются по готовой ленте и только предупреждают — ничего не переигрывается.',
previewKinds: { previewKinds: {
Program: 'Программа', Program: 'Программа',
Fallback: 'Фон', Fallback: 'Фон',
@@ -454,6 +513,9 @@ const resources = {
RepeatSourceEmpty: 'Нечего повторять', RepeatSourceEmpty: 'Нечего повторять',
FallbackEmpty: 'Нечем закрыть паузы', FallbackEmpty: 'Нечем закрыть паузы',
CandidatesFiltered: 'Возрастной потолок отсёк всех', CandidatesFiltered: 'Возрастной потолок отсёк всех',
BreakLimitExceeded: 'Врезок в часе больше потолка',
GenreShareExceeded: 'Доля жанра выше нормы',
FallbackShareExceeded: 'Фона в эфире больше нормы',
}, },
title: 'Каналы', title: 'Каналы',
@@ -565,6 +627,9 @@ const resources = {
'Когда выключено — новые пользователи не могут регистрироваться сами, учётки заводит только администратор.', 'Когда выключено — новые пользователи не могут регистрироваться сами, учётки заводит только администратор.',
registrationLabel: 'Разрешить регистрацию на сайте', registrationLabel: 'Разрешить регистрацию на сайте',
preferredAudio: 'Предпочитаемые озвучки', preferredAudio: 'Предпочитаемые озвучки',
channelNumbers: 'Переключение каналов по номерам',
channelNumbersHint:
'Зритель переключает каналы стрелками, как на телевизоре. Сетка каналов остаётся всегда.',
preferredAudioHint: preferredAudioHint:
'Коды языков через запятую в порядке приоритета (напр. «rus, eng»). Если в файле есть дорожка с таким языком — при обработке выбирается она (по порядку); иначе — выбор ffmpeg по умолчанию. Применяется к новым обработкам.', 'Коды языков через запятую в порядке приоритета (напр. «rus, eng»). Если в файле есть дорожка с таким языком — при обработке выбирается она (по порядку); иначе — выбор ffmpeg по умолчанию. Применяется к новым обработкам.',
}, },
@@ -974,7 +1039,65 @@ const resources = {
previewHint: 'A run against the current rules: nothing is written, slot cursors do not move.', previewHint: 'A run against the current rules: nothing is written, slot cursors do not move.',
previewDays_one: '{{count}} day', previewDays_one: '{{count}} day',
previewDays_other: '{{count}} days', previewDays_other: '{{count}} days',
previewTabs: { programme: 'Programme', tape: 'Tape' }, previewTabs: { programme: 'Programme', tape: 'Tape', problems: 'Problems' },
noProblems: 'No problems',
andMore: 'and {{count}} more',
heatmap: 'Repeats: show × day',
heatmapTotal: 'total',
issues: 'Checks: {{errors}} errors, {{warnings}} warnings',
goToSlot: 'to slot',
issueKinds: {
GroupEmpty: 'Empty group',
GroupTooSmall: 'Too little content',
GridGap: 'Gap in the grid',
SlotOverlap: 'Slots overlap',
CooldownUnreachable: 'Unreachable cooldown',
AudienceConflict: 'Age conflict',
GroupMissing: 'No group selected',
},
viewer: 'How viewers see it',
viewerHint:
'Overlays are drawn on the client on top of the picture — the video is not re-encoded. Everything is off by default.',
logo: 'Logo',
noLogo: 'none',
pickLogo: 'Pick a logo',
logoCorner: 'Corner',
logoOpacity: 'Opacity',
corners: {
TopLeft: 'Top left',
TopRight: 'Top right',
BottomLeft: 'Bottom left',
BottomRight: 'Bottom right',
},
showClock: 'Show a clock',
analogFilter: 'Analog filter',
analogFilterHint: 'Strength 0..1. Zero is off; it is very easy to overdo.',
whyHere: 'Why is this here',
priority: 'priority',
traceLayer: 'Layer',
traceSlot: 'Slot',
traceGroup: 'Group',
traceStrategy: 'Strategy',
traceJunction: 'Breaks',
traceDrift: 'drift {{minutes}} min',
traceSnapped: 'start snapped',
traceCooldown: 'cooldown {{days}} d.',
traceCandidates: 'candidates after cooldown: {{count}}',
diffSummary: 'Affects {{total}} entries, {{changed}} will change',
diffSoon: 'Entries changing within 24 hours: {{count}}',
diffNoChanges: 'The air will not change',
copyTemplate: 'Copy grid',
pickTargetChannel: 'Pick a channel',
copyTemplateHint:
'Layers, slots, junctions and rules move to the chosen channel, replacing its grid. Groups are shared and not copied.',
templateCopied: 'Copied: {{layers}} layers, {{slots}} slots',
copyDroppedBumpers: 'Breaks left without a bumper block: {{count}} — set them up by hand',
postChecks: 'Post-checks',
breakLimit: 'Breaks per hour cap, min',
genreShare: 'Genre share per day cap, %',
fallbackShare: 'Background share cap, %',
postChecksHint:
'Post-checks run against the finished tape and only warn — nothing is replanned.',
previewKinds: { previewKinds: {
Program: 'Programme', Program: 'Programme',
Fallback: 'Background', Fallback: 'Background',
@@ -1045,6 +1168,9 @@ const resources = {
RepeatSourceEmpty: 'Nothing to repeat', RepeatSourceEmpty: 'Nothing to repeat',
FallbackEmpty: 'Nothing to fill pauses with', FallbackEmpty: 'Nothing to fill pauses with',
CandidatesFiltered: 'The age cap ruled out every candidate', CandidatesFiltered: 'The age cap ruled out every candidate',
BreakLimitExceeded: 'Breaks in an hour exceed the cap',
GenreShareExceeded: 'Genre share above the norm',
FallbackShareExceeded: 'Background share above the norm',
}, },
title: 'Channels', title: 'Channels',
@@ -1155,6 +1281,9 @@ const resources = {
registrationHint: registrationHint:
'When off, new users cannot sign up themselves — only an administrator can create accounts.', 'When off, new users cannot sign up themselves — only an administrator can create accounts.',
registrationLabel: 'Allow public registration', registrationLabel: 'Allow public registration',
channelNumbers: 'Switch channels by number',
channelNumbersHint:
'Viewers switch channels with the arrow keys, like on a TV set. The channel grid stays available regardless.',
preferredAudio: 'Preferred audio tracks', preferredAudio: 'Preferred audio tracks',
preferredAudioHint: preferredAudioHint:
'Comma-separated language codes in priority order (e.g. "rus, eng"). If a file has a track in one of these languages, it is picked during processing (by order); otherwise ffmpeg default. Applies to new processing.', 'Comma-separated language codes in priority order (e.g. "rus, eng"). If a file has a track in one of these languages, it is picked during processing (by order); otherwise ffmpeg default. Applies to new processing.',