diff --git a/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs index 524f324..be2dcfc 100644 --- a/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs @@ -106,7 +106,8 @@ public static class TemplateEndpoints templateId, body.Name, body.FallbackGroupId, - body.DefaultJunctionId + body.DefaultJunctionId, + body.Rules ), cancellationToken ); @@ -197,7 +198,8 @@ public static class TemplateEndpoints public sealed record UpdateTemplateBody( string Name, Guid? FallbackGroupId, - Guid? DefaultJunctionId + Guid? DefaultJunctionId, + PlanningRules? Rules ); public sealed record CreateLayerBody(string Name, int Priority); diff --git a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs index beb8d8d..d74823b 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs @@ -258,7 +258,19 @@ public sealed class GridScheduleGenerator( .Distinct() .ToList(); - var elementsByGroup = await expander.ExpandAsync(groupIds, channel.Id, cancellationToken); + // Правила канала: детское время резолвится на каждый слот по его времени, а под потолок + // повторов нужна история показов — её глубина и задаётся окном правила. + var rules = PlanningRules.FromJson(template.RulesJson); + var repeatLimit = rules?.MaxRepeatsInWindow is { WindowDays: > 0, Max: > 0 } limit + ? new RepeatLimit(limit.WindowDays, limit.Max) + : null; + + var elementsByGroup = await expander.ExpandAsync( + groupIds, + channel.Id, + cancellationToken, + repeatLimit is null ? null : startUtc.AddDays(-repeatLimit.WindowDays) + ); var slotIds = scheduled.Select(s => s.Slot.Id).Distinct().ToList(); var states = await dbContext @@ -308,7 +320,13 @@ public sealed class GridScheduleGenerator( junctions, elementsByGroup, channel - ) + ), + rules?.AudienceAt( + TimeOnly.FromDateTime( + item.StartUtc.ToOffset(TimeSpan.FromMinutes(channel.UtcOffsetMinutes)).DateTime + ) + ), + repeatLimit ) ); } diff --git a/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs b/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs index c0befa6..0336386 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/GroupExpander.cs @@ -1,171 +1,232 @@ -using Microsoft.EntityFrameworkCore; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Domain.Broadcast; -using TeleWave.Domain.Media; -using TeleWave.Domain.Programming; -using TeleWave.Domain.Programming.Planning; - -namespace TeleWave.Application.Programming.Planning; - -/// -/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал — -/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается), -/// фильм — в одну единицу. -/// -/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё -/// обрабатывается, значит получить дыру в раздаче. -/// -public sealed class GroupExpander(IAppDbContext dbContext) -{ - public async Task>> ExpandAsync( - IReadOnlyCollection groupIds, - Guid channelId, - CancellationToken cancellationToken - ) - { - var result = new Dictionary>(); - if (groupIds.Count == 0) - return result; - - var groups = await dbContext - .Groups.AsNoTracking() - .Include(g => g.Items) - .Where(g => groupIds.Contains(g.Id)) - .ToListAsync(cancellationToken); - - var collectionIds = groups - .SelectMany(g => g.Items) - .Where(i => i.ElementKind == GroupElementKind.Collection) - .Select(i => i.ElementId) - .Distinct() - .ToList(); - - var collectionParts = await dbContext - .CollectionItems.AsNoTracking() - .Where(i => collectionIds.Contains(i.CollectionId)) - .OrderBy(i => i.Position) - .Select(i => new { i.CollectionId, i.ShowId }) - .ToListAsync(cancellationToken); - - var showIds = groups - .SelectMany(g => g.Items) - .Where(i => i.ElementKind == GroupElementKind.Show) - .Select(i => i.ElementId) - .Concat(collectionParts.Select(p => p.ShowId)) - .Distinct() - .ToList(); - - var episodes = await dbContext - .Shows.AsNoTracking() - .Where(s => showIds.Contains(s.Id)) - .Select(s => new - { - s.Id, - Episodes = s - .Episodes.OrderBy(e => e.Position) - .Select(e => new { e.MediaAssetId, e.Position }) - .ToList(), - }) - .ToDictionaryAsync(s => s.Id, cancellationToken); - - var assetIds = episodes.Values.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId)).Distinct().ToList(); - var durations = await dbContext - .MediaAssets.AsNoTracking() - .Where(a => - assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null - ) - .Select(a => new { a.Id, a.Duration }) - .ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken); - - var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken); - - List UnitsOfShow(Guid showId) - { - if (!episodes.TryGetValue(showId, out var show)) - return []; - - return show - .Episodes.Where(e => durations.ContainsKey(e.MediaAssetId)) - .Select((e, index) => new PlanningUnit( - e.MediaAssetId, - durations[e.MediaAssetId], - showId, - index - )) - .ToList(); - } - - foreach (var group in groups) - { - var elements = new List(); - - foreach (var item in group.Items.OrderBy(i => i.Position)) - { - if (item.ElementKind == GroupElementKind.Show) - { - elements.Add( - new PlanningElement( - item.ElementKind, - item.ElementId, - item.Weight, - item.Position, - UnitsOfShow(item.ElementId), - lastPlayed.TryGetValue(item.ElementId, out var played) ? played : null - ) - ); - continue; - } - - var partShowIds = collectionParts - .Where(p => p.CollectionId == item.ElementId) - .Select(p => p.ShowId) - .ToList(); - - var units = partShowIds.SelectMany(UnitsOfShow).ToList(); - - elements.Add( - new PlanningElement( - item.ElementKind, - item.ElementId, - item.Weight, - item.Position, - units, - // Остывание коллекции считается по самой свежей из её частей: показ любой - // из них означает, что франшиза недавно была в эфире. - partShowIds - .Select(id => lastPlayed.TryGetValue(id, out var p) ? p : (DateTimeOffset?)null) - .Where(p => p is not null) - .DefaultIfEmpty(null) - .Max() - ) - ); - } - - result[group.Id] = elements; - } - - return result; - } - - /// - /// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного - /// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное - /// остывание среди правил. - /// - private async Task> LoadLastPlayedAsync( - Guid channelId, - IReadOnlyCollection showIds, - CancellationToken cancellationToken - ) => - await dbContext - .ScheduleEntries.AsNoTracking() - .Where(e => - e.ChannelId == channelId - && e.Kind == ScheduleEntryKind.Program - && e.ShowId != null - && showIds.Contains(e.ShowId!.Value) - ) - .GroupBy(e => e.ShowId!.Value) - .Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) }) - .ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken); -} +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using TeleWave.Domain.Programming; +using TeleWave.Domain.Programming.Planning; + +namespace TeleWave.Application.Programming.Planning; + +/// +/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал — +/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается), +/// фильм — в одну единицу. +/// +/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё +/// обрабатывается, значит получить дыру в раздаче. +/// +public sealed class GroupExpander(IAppDbContext dbContext) +{ + /// С какого момента нужна история показов для потолка повторов; + /// null — история не нужна и не загружается. + public async Task>> ExpandAsync( + IReadOnlyCollection groupIds, + Guid channelId, + CancellationToken cancellationToken, + DateTimeOffset? historyFrom = null + ) + { + var result = new Dictionary>(); + if (groupIds.Count == 0) + return result; + + var groups = await dbContext + .Groups.AsNoTracking() + .Include(g => g.Items) + .Where(g => groupIds.Contains(g.Id)) + .ToListAsync(cancellationToken); + + var collectionIds = groups + .SelectMany(g => g.Items) + .Where(i => i.ElementKind == GroupElementKind.Collection) + .Select(i => i.ElementId) + .Distinct() + .ToList(); + + var collectionParts = await dbContext + .CollectionItems.AsNoTracking() + .Where(i => collectionIds.Contains(i.CollectionId)) + .OrderBy(i => i.Position) + .Select(i => new { i.CollectionId, i.ShowId }) + .ToListAsync(cancellationToken); + + var showIds = groups + .SelectMany(g => g.Items) + .Where(i => i.ElementKind == GroupElementKind.Show) + .Select(i => i.ElementId) + .Concat(collectionParts.Select(p => p.ShowId)) + .Distinct() + .ToList(); + + var episodes = await dbContext + .Shows.AsNoTracking() + .Where(s => showIds.Contains(s.Id)) + .Select(s => new + { + s.Id, + s.Audience, + Episodes = s + .Episodes.OrderBy(e => e.Position) + .Select(e => new { e.MediaAssetId, e.Position }) + .ToList(), + }) + .ToDictionaryAsync(s => s.Id, cancellationToken); + + var assetIds = episodes.Values.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId)).Distinct().ToList(); + var durations = await dbContext + .MediaAssets.AsNoTracking() + .Where(a => + assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null + ) + .Select(a => new { a.Id, a.Duration }) + .ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken); + + var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken); + var recentPlays = await LoadRecentPlaysAsync( + channelId, + showIds, + historyFrom, + cancellationToken + ); + + List UnitsOfShow(Guid showId) + { + if (!episodes.TryGetValue(showId, out var show)) + return []; + + return show + .Episodes.Where(e => durations.ContainsKey(e.MediaAssetId)) + .Select((e, index) => new PlanningUnit( + e.MediaAssetId, + durations[e.MediaAssetId], + showId, + index + )) + .ToList(); + } + + foreach (var group in groups) + { + var elements = new List(); + + foreach (var item in group.Items.OrderBy(i => i.Position)) + { + if (item.ElementKind == GroupElementKind.Show) + { + elements.Add( + new PlanningElement( + item.ElementKind, + item.ElementId, + item.Weight, + item.Position, + UnitsOfShow(item.ElementId), + lastPlayed.TryGetValue(item.ElementId, out var played) ? played : null, + episodes.TryGetValue(item.ElementId, out var show) ? show.Audience : null, + recentPlays.TryGetValue(item.ElementId, out var plays) ? plays : null + ) + ); + continue; + } + + var partShowIds = collectionParts + .Where(p => p.CollectionId == item.ElementId) + .Select(p => p.ShowId) + .ToList(); + + var units = partShowIds.SelectMany(UnitsOfShow).ToList(); + + elements.Add( + new PlanningElement( + item.ElementKind, + item.ElementId, + item.Weight, + item.Position, + units, + // Остывание коллекции считается по самой свежей из её частей: показ любой + // из них означает, что франшиза недавно была в эфире. + partShowIds + .Select(id => lastPlayed.TryGetValue(id, out var p) ? p : (DateTimeOffset?)null) + .Where(p => p is not null) + .DefaultIfEmpty(null) + .Max(), + // Категория коллекции — строжайшая среди частей: франшиза идёт целиком, + // и одна взрослая часть делает взрослой всю. + partShowIds + .Select(id => + episodes.TryGetValue(id, out var part) ? part.Audience : (ShowAudience?)null + ) + .Where(a => a is not null) + .DefaultIfEmpty(null) + .Max(), + partShowIds + .SelectMany(id => + recentPlays.TryGetValue(id, out var p) ? p : [] + ) + .ToList() + ) + ); + } + + result[group.Id] = elements; + } + + return result; + } + + /// + /// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного + /// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное + /// остывание среди правил. + /// + private async Task> LoadLastPlayedAsync( + Guid channelId, + IReadOnlyCollection showIds, + CancellationToken cancellationToken + ) => + await dbContext + .ScheduleEntries.AsNoTracking() + .Where(e => + e.ChannelId == channelId + && e.Kind == ScheduleEntryKind.Program + && e.ShowId != null + && showIds.Contains(e.ShowId!.Value) + ) + .GroupBy(e => e.ShowId!.Value) + .Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) }) + .ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken); + + /// + /// Старты показов за окно потолка повторов. Загружается только когда правило задано: без него + /// это лишние сотни строк на каждый прогон. + /// + private async Task>> LoadRecentPlaysAsync( + Guid channelId, + IReadOnlyCollection showIds, + DateTimeOffset? from, + CancellationToken cancellationToken + ) + { + if (from is not { } since || showIds.Count == 0) + return []; + + var plays = await dbContext + .ScheduleEntries.AsNoTracking() + .Where(e => + e.ChannelId == channelId + && e.Kind == ScheduleEntryKind.Program + && e.ShowId != null + && showIds.Contains(e.ShowId!.Value) + && e.StartsAtUtc >= since + ) + .Select(e => new { ShowId = e.ShowId!.Value, e.StartsAtUtc }) + .ToListAsync(cancellationToken); + + return plays + .GroupBy(p => p.ShowId) + .ToDictionary( + g => g.Key, + g => (IReadOnlyList)g.Select(p => p.StartsAtUtc).ToList() + ); + } +} diff --git a/backend/src/TeleWave.Application/Programming/Templates/GetTemplate/GetChannelTemplateQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/GetTemplate/GetChannelTemplateQueryHandler.cs index a1eeaa7..7ae1b50 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/GetTemplate/GetChannelTemplateQueryHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/GetTemplate/GetChannelTemplateQueryHandler.cs @@ -88,6 +88,7 @@ public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext) template.Name, template.FallbackGroupId, template.DefaultJunctionId, + PlanningRules.FromJson(template.RulesJson), template.Revision, template.AppliedRevision, template.HasPendingChanges, diff --git a/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommandHandlers.cs b/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommandHandlers.cs index 855d23e..cd59d7d 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommandHandlers.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommandHandlers.cs @@ -92,6 +92,7 @@ public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext) template.Rename(command.Name); template.SetFallbackGroup(command.FallbackGroupId); template.SetDefaultJunction(command.DefaultJunctionId); + template.SetRules(command.Rules?.ToJson()); template.MarkChanged(); return Result.Success(); } diff --git a/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommands.cs b/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommands.cs index 4c45572..c33d925 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommands.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Layers/LayerCommands.cs @@ -17,12 +17,13 @@ public sealed record UpdateLayerCommand( public sealed record DeleteLayerCommand(Guid LayerId) : ICommand; -/// Имя шаблона и аварийная группа, играющая, когда пуст даже фоновый слой. +/// Имя шаблона, аварийная группа, стык по умолчанию и правила отбора кандидатов. public sealed record UpdateTemplateCommand( Guid TemplateId, string Name, Guid? FallbackGroupId, - Guid? DefaultJunctionId + Guid? DefaultJunctionId, + PlanningRules? Rules ) : ICommand; public sealed class CreateLayerCommandValidator : AbstractValidator @@ -45,5 +46,22 @@ public sealed class UpdateLayerCommandValidator : AbstractValidator { - public UpdateTemplateCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(256); + public UpdateTemplateCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(256); + + // Год — верхняя граница окна повторов: за ним правило перестаёт что-либо значить, а история + // всё равно ограничена глубиной хранения ленты. + RuleFor(x => x.Rules!.MaxRepeatsInWindow!.WindowDays) + .InclusiveBetween(1, 365) + .When(x => x.Rules?.MaxRepeatsInWindow is not null); + RuleFor(x => x.Rules!.MaxRepeatsInWindow!.Max) + .InclusiveBetween(1, 1000) + .When(x => x.Rules?.MaxRepeatsInWindow is not null); + + RuleForEach(x => x.Rules!.MaxAudienceByTime) + .Must(window => window.From != window.To) + .WithMessage("Окно нулевой длины ничего не ограничивает.") + .When(x => x.Rules?.MaxAudienceByTime is not null); + } } diff --git a/backend/src/TeleWave.Application/Programming/Templates/PlanningRules.cs b/backend/src/TeleWave.Application/Programming/Templates/PlanningRules.cs new file mode 100644 index 0000000..552f2aa --- /dev/null +++ b/backend/src/TeleWave.Application/Programming/Templates/PlanningRules.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Programming.Templates; + +/// +/// Окно детского времени: с до во времени канала +/// в эфир идёт только контент не строже . Окно может переходить +/// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону. +/// +public sealed record AudienceWindow(TimeOnly From, TimeOnly To, ShowAudience MaxAudience) +{ + public bool Contains(TimeOnly moment) => + From <= To ? moment >= From && moment < To : moment >= From || moment < To; +} + +/// +/// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают +/// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость. +/// +/// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени +/// и так задаются временем, а потолок повторов осмыслен только целиком по каналу. +/// +public sealed record PlanningRules( + IReadOnlyList? MaxAudienceByTime = null, + /// Не чаще Max раз за WindowDays суток (null — без ограничения). + RepeatLimitRule? MaxRepeatsInWindow = null +) +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() }, + }; + + /// Возрастной потолок в указанный момент времени канала (null — без ограничения). + public ShowAudience? AudienceAt(TimeOnly moment) + { + if (MaxAudienceByTime is not { Count: > 0 } windows) + return null; + + // Пересекающиеся окна разрешаются в пользу строгого: детское время не должно + // отменяться более широким окном, случайно наложенным сверху. + ShowAudience? strictest = null; + foreach (var window in windows.Where(w => w.Contains(moment))) + strictest = strictest is { } current && current <= window.MaxAudience + ? current + : window.MaxAudience; + return strictest; + } + + public string ToJson() => JsonSerializer.Serialize(this, Options); + + public static PlanningRules? FromJson(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + return JsonSerializer.Deserialize(json, Options); + } + catch (JsonException) + { + return null; + } + } +} + +public sealed record RepeatLimitRule(int WindowDays, int Max); diff --git a/backend/src/TeleWave.Application/Programming/Templates/TemplateDtos.cs b/backend/src/TeleWave.Application/Programming/Templates/TemplateDtos.cs index edb0488..7902874 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/TemplateDtos.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/TemplateDtos.cs @@ -41,6 +41,8 @@ public sealed record ScheduleTemplateDto( string Name, Guid? FallbackGroupId, Guid? DefaultJunctionId, + /// Правила отбора кандидатов канала: детское время и потолок повторов. + PlanningRules? Rules, int Revision, int AppliedRevision, /// Есть ли правки правил, ещё не применённые к эфиру. diff --git a/backend/src/TeleWave.Domain/Programming/Planning/ElementSelector.cs b/backend/src/TeleWave.Domain/Programming/Planning/ElementSelector.cs index 586d0fc..1118216 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/ElementSelector.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/ElementSelector.cs @@ -22,7 +22,11 @@ public static class ElementSelector IRandomSource random ) { - var playable = slot.Elements.Where(e => e.Units.Count > 0).ToList(); + // Возрастной потолок — жёсткое «нельзя», поэтому отсекает до любой стратегии и до курсора: + // начатый в общее время сериал не должен доигрываться в детское. + var playable = slot + .Elements.Where(e => e.Units.Count > 0 && IsAllowedByAudience(slot, e)) + .ToList(); if (playable.Count == 0) return null; @@ -95,10 +99,14 @@ public static class ElementSelector if (current is not null && HasUnitsLeft(slot, current)) return Continue(slot, current); + // Потолок повторов — тоже жёсткий фильтр, но применяется только здесь: в последовательной + // стратегии он выбрасывал бы очередную серию сериала и рвал порядок показа. + var withinLimit = ApplyRepeatLimit(slot, playable, moment); + var cooldown = TimeSpan.FromDays(Math.Max(0, slot.Strategy.CooldownDays)); var eligible = cooldown <= TimeSpan.Zero - ? playable - : playable + ? withinLimit + : withinLimit .Where(e => e.LastPlayedUtc is not { } last || moment - last >= cooldown) .ToList(); @@ -106,16 +114,47 @@ public static class ElementSelector if (exhausted) { // Остывание отсекло всех. Либо игнорируем его на этот выход, либо берём самый давний — - // пустой эфир хуже раннего повтора в обоих случаях. + // пустой эфир хуже раннего повтора в обоих случаях. Откат идёт по уже суженному потолком + // повторов пулу: иначе более слабое правило воскрешало бы отсечённое более строгим. eligible = slot.Strategy.IgnoreCooldownWhenExhausted - ? playable - : [playable.OrderBy(e => e.LastPlayedUtc ?? DateTimeOffset.MinValue).First()]; + ? withinLimit + : [withinLimit.OrderBy(e => e.LastPlayedUtc ?? DateTimeOffset.MinValue).First()]; } var picked = WeightedPick(eligible, random); return new ElementPick(picked, 0, eligible.Count, exhausted); } + /// + /// Проходит ли элемент по возрастному потолку слота. Категории упорядочены по строгости, поэтому + /// сравнение — обычное «не строже». Элемент без категории не отсекается: неизвестное не значит + /// «взрослое», а молчаливое выбрасывание такого контента искало бы потом весь вечер. + /// + public static bool IsAllowedByAudience(PlanningSlot slot, PlanningElement element) => + slot.MaxAudience is not { } max || element.Audience is not { } audience || audience <= max; + + /// + /// Отсекает элементы, выбранные за период чаще потолка. История — старты уже записанных показов; + /// без неё (правило не задано или лента не загружена) фильтр не работает и никого не выбрасывает. + /// + private static List ApplyRepeatLimit( + PlanningSlot slot, + List playable, + DateTimeOffset moment + ) + { + if (slot.RepeatLimit is not { } limit || limit.WindowDays <= 0 || limit.Max <= 0) + return playable; + + var from = moment.AddDays(-limit.WindowDays); + var filtered = playable + .Where(e => (e.RecentPlaysUtc?.Count(p => p >= from) ?? 0) < limit.Max) + .ToList(); + + // Все упёрлись в потолок — правило не должно оставлять слот пустым: пусть решает остывание. + return filtered.Count == 0 ? playable : filtered; + } + /// Продолжение текущего элемента с позиции курсора. private static ElementPick Continue(PlanningSlot slot, PlanningElement element) { diff --git a/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs b/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs index 4c600b9..bc638d0 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/PlanningModels.cs @@ -1,3 +1,5 @@ +using TeleWave.Domain.Library; + namespace TeleWave.Domain.Programming.Planning; /// @@ -16,9 +18,16 @@ public sealed record PlanningElement( int Weight, int Position, IReadOnlyList Units, - DateTimeOffset? LastPlayedUtc = null + DateTimeOffset? LastPlayedUtc = null, + /// Категория аудитории (у коллекции — строжайшая из частей); по ней работает детское время. + ShowAudience? Audience = null, + /// Старты недавних показов в этом канале — по ним считается потолок повторов за период. + IReadOnlyList? RecentPlaysUtc = null ); +/// Потолок повторов: не чаще раз за суток. +public sealed record RepeatLimit(int WindowDays, int Max); + /// Стратегия выбора элемента, приведённая к виду, понятному чистому планировщику. public sealed record PlanningStrategy( SlotStrategyKind Kind, @@ -66,7 +75,11 @@ public sealed record PlanningSlot( /// Врезки между единицами внутри блока. PlanningJunction? JunctionBetween = null, /// Врезки в конце блока. - PlanningJunction? JunctionAfter = null + PlanningJunction? JunctionAfter = null, + /// Возрастной потолок в это время суток (null — без ограничения). Жёсткий фильтр. + ShowAudience? MaxAudience = null, + /// Потолок повторов за период (null — без ограничения). Жёсткий фильтр. + RepeatLimit? RepeatLimit = null ) { public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes); @@ -189,4 +202,7 @@ public enum PlanningWarningKind /// Пусто даже в фоне — в ленте образуется дыра. FallbackEmpty = 4, + + /// Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата. + CandidatesFiltered = 5, } diff --git a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs index a897447..5a89429 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs @@ -244,12 +244,26 @@ public static class SchedulePlanner var pick = ElementSelector.Select(slot, cursor, random); if (pick is null) { + // Пустая группа и отсечённая фильтром — разные беды: во втором случае контент есть, + // но не подходит по правилам, и админу надо чинить правило, а не состав группы. + var filteredOut = + slot.Elements.Any(e => e.Units.Count > 0) + && !slot.Elements.Any(e => + e.Units.Count > 0 && ElementSelector.IsAllowedByAudience(slot, e) + ); + warnings.Add( - new PlanningWarning( - PlanningWarningKind.SlotEmpty, - slot.SlotId, - "Слот не дал контента — место закрыл фон." - ) + filteredOut + ? new PlanningWarning( + PlanningWarningKind.CandidatesFiltered, + slot.SlotId, + "Возрастной потолок отсёк всех кандидатов — место закрыл фон." + ) + : new PlanningWarning( + PlanningWarningKind.SlotEmpty, + slot.SlotId, + "Слот не дал контента — место закрыл фон." + ) ); return FillWithFallback( cursor, diff --git a/backend/src/TeleWave.Domain/Programming/ScheduleTemplate.cs b/backend/src/TeleWave.Domain/Programming/ScheduleTemplate.cs index 7cc3af9..535db57 100644 --- a/backend/src/TeleWave.Domain/Programming/ScheduleTemplate.cs +++ b/backend/src/TeleWave.Domain/Programming/ScheduleTemplate.cs @@ -22,6 +22,12 @@ public class ScheduleTemplate /// Стык по умолчанию — для слотов, у которых свой не задан. public Guid? DefaultJunctionId { get; private set; } + /// + /// Правила отбора кандидатов (детское время, потолок повторов) в JSON. Домен их не разбирает — + /// схема живёт в Application, как и у стратегий слотов и условий стыка. + /// + public string? RulesJson { get; private set; } + /// Номер правки правил; входит в кэш-ключи и историю. public int Revision { get; private set; } @@ -63,6 +69,9 @@ public class ScheduleTemplate public void SetDefaultJunction(Guid? junctionId) => DefaultJunctionId = junctionId; + public void SetRules(string? rulesJson) => + RulesJson = string.IsNullOrWhiteSpace(rulesJson) ? null : rulesJson; + /// Отметить, что правила изменились — эфир пойдёт по старым до применения. public void MarkChanged() => Revision++; diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726110935_TemplatePlanningRules.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726110935_TemplatePlanningRules.Designer.cs new file mode 100644 index 0000000..c171982 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726110935_TemplatePlanningRules.Designer.cs @@ -0,0 +1,1371 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260726110935_TemplatePlanningRules")] + partial class TemplatePlanningRules + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.Property("VariantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Line2") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NextLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NowLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DayStartTime") + .HasColumnType("time without time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UtcOffsetMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique() + .HasFilter("\"Number\" IS NOT NULL"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TraceJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.HasIndex("ChannelId", "ShowId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Images.Image", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Category", "CreatedAt"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Collections"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ShowId"); + + b.HasIndex("CollectionId", "Position"); + + b.HasIndex("CollectionId", "ShowId") + .IsUnique(); + + b.ToTable("CollectionItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("Value") + .IsUnique(); + + b.ToTable("GenreAliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Audience") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillImageId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.HasKey("ShowId", "GenreId"); + + b.HasIndex("GenreId"); + + b.ToTable("ShowGenres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProcessingDuration") + .HasColumnType("interval"); + + b.Property("ProcessingStartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicabilityJson") + .HasColumnType("jsonb"); + + b.Property("IsBackground") + .HasColumnType("boolean"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Priority"); + + b.ToTable("GridLayers"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FilterJson") + .HasColumnType("jsonb"); + + b.Property("ItemCount") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StatsComputedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalDuration") + .HasColumnType("interval"); + + b.Property("UnitCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ElementId") + .HasColumnType("uuid"); + + b.Property("ElementKind") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ElementKind", "ElementId"); + + b.HasIndex("GroupId", "Position"); + + b.HasIndex("GroupId", "ElementKind", "ElementId") + .IsUnique(); + + b.ToTable("GroupItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AmountMode") + .HasColumnType("integer"); + + b.Property("AmountValue") + .HasColumnType("integer"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("ConditionsJson") + .HasColumnType("jsonb"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsRequired") + .HasColumnType("boolean"); + + b.Property("JunctionTemplateId") + .HasColumnType("uuid"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("JunctionTemplateId", "Position"); + + b.ToTable("JunctionElements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("JunctionTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppliedRevision") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultJunctionId") + .HasColumnType("uuid"); + + b.Property("FallbackGroupId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("RulesJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("ScheduleTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("Daypart") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsAnchor") + .HasColumnType("boolean"); + + b.Property("JunctionAfterId") + .HasColumnType("uuid"); + + b.Property("JunctionBetweenId") + .HasColumnType("uuid"); + + b.Property("LayerId") + .HasColumnType("uuid"); + + b.Property("MaxDriftMinutes") + .HasColumnType("integer"); + + b.Property("OverflowPolicy") + .HasColumnType("integer"); + + b.Property("RepeatSourceJson") + .HasColumnType("jsonb"); + + b.Property("SlotKind") + .HasColumnType("integer"); + + b.Property("SnapToMinutes") + .HasColumnType("integer"); + + b.Property("StrategyJson") + .HasColumnType("jsonb"); + + b.Property("TargetDurationMinutes") + .HasColumnType("integer"); + + b.Property("TargetStart") + .HasColumnType("time without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Weekday") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("LayerId", "TargetStart"); + + b.ToTable("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("CurrentElementId") + .HasColumnType("uuid"); + + b.Property("CurrentElementKind") + .HasColumnType("integer"); + + b.Property("NextUnitIndex") + .HasColumnType("integer"); + + b.HasKey("SlotId"); + + b.ToTable("SlotStates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("BumperTemplates") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.HasOne("TeleWave.Domain.Library.Collection", null) + .WithMany("Items") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany() + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany("Aliases") + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Genres") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.HasOne("TeleWave.Domain.Programming.ScheduleTemplate", null) + .WithMany("Layers") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.JunctionTemplate", null) + .WithMany("Elements") + .HasForeignKey("JunctionTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.GridLayer", null) + .WithMany("Slots") + .HasForeignKey("LayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.HasOne("TeleWave.Domain.Programming.Slot", null) + .WithOne() + .HasForeignKey("TeleWave.Domain.Programming.SlotState", "SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("BumperTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Navigation("Aliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Navigation("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Navigation("Elements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Navigation("Layers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726110935_TemplatePlanningRules.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726110935_TemplatePlanningRules.cs new file mode 100644 index 0000000..1fe9fa5 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726110935_TemplatePlanningRules.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class TemplatePlanningRules : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RulesJson", + table: "ScheduleTemplates", + type: "jsonb", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RulesJson", + table: "ScheduleTemplates"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 57f574e..d54b644 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -928,6 +928,9 @@ namespace TeleWave.Infrastructure.Migrations b.Property("Revision") .HasColumnType("integer"); + b.Property("RulesJson") + .HasColumnType("jsonb"); + b.HasKey("Id"); b.HasIndex("ChannelId"); diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ScheduleTemplateConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ScheduleTemplateConfiguration.cs index 73efe8c..ee723a2 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ScheduleTemplateConfiguration.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ScheduleTemplateConfiguration.cs @@ -10,6 +10,7 @@ public class ScheduleTemplateConfiguration : IEntityTypeConfiguration builder) { builder.Property(x => x.Name).IsRequired().HasMaxLength(256); + builder.Property(x => x.RulesJson).HasColumnType("jsonb"); builder.HasIndex(x => x.ChannelId); builder diff --git a/backend/tests/TeleWave.Application.Tests/Programming/LayerApplicabilityTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/LayerApplicabilityTests.cs new file mode 100644 index 0000000..43aba33 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Programming/LayerApplicabilityTests.cs @@ -0,0 +1,148 @@ +using TeleWave.Application.Programming.Planning; +using TeleWave.Application.Programming.Templates; +using TeleWave.Domain.Programming; +using Xunit; + +namespace TeleWave.Application.Tests.Programming; + +/// +/// Применимость слоёв (см. 3.4) и разрешение перекрытий: какие слоты реально действуют в сутки. +/// +public class LayerApplicabilityTests +{ + private static readonly TimeOnly DayStart = new(6, 0); + + [Fact] + public void Empty_CoversEveryDate() + { + var applicability = new LayerApplicability(); + + Assert.True(applicability.IsEmpty); + Assert.True(applicability.Covers(new DateOnly(2026, 3, 17))); + } + + [Theory] + [InlineData(2026, 12, 25, true)] // внутри, до Нового года + [InlineData(2026, 1, 3, true)] // внутри, после Нового года + [InlineData(2026, 12, 20, true)] // ровно начало + [InlineData(2026, 1, 8, true)] // ровно конец + [InlineData(2026, 12, 19, false)] // за день до начала + [InlineData(2026, 1, 9, false)] // на следующий день после конца + [InlineData(2026, 6, 15, false)] // середина года + public void AnnualRange_CrossingNewYear_IsInclusiveOnBothEnds( + int year, + int month, + int day, + bool expected + ) + { + // «20 декабря — 8 января» задаётся один раз и работает в любом году, поэтому сравнение идёт + // по паре (месяц, день), а не по датам. + var applicability = new LayerApplicability( + AnnualRanges: [new AnnualRange(12, 20, 1, 8)] + ); + + Assert.Equal(expected, applicability.Covers(new DateOnly(year, month, day))); + } + + [Fact] + public void AnnualRange_WithinOneYear_DoesNotWrap() + { + var applicability = new LayerApplicability(AnnualRanges: [new AnnualRange(6, 1, 8, 31)]); + + Assert.True(applicability.Covers(new DateOnly(2026, 7, 4))); + Assert.False(applicability.Covers(new DateOnly(2026, 1, 4))); + } + + [Fact] + public void Sections_AreCombinedWithOr() + { + // Понедельник ИЛИ конкретная дата: суббота из списка дат проходит, обычная суббота — нет. + var applicability = new LayerApplicability( + Weekdays: [1], + SpecificDates: [new DateOnly(2026, 3, 21)] + ); + + Assert.True(applicability.Covers(new DateOnly(2026, 3, 16))); // понедельник + Assert.True(applicability.Covers(new DateOnly(2026, 3, 21))); // суббота из списка + Assert.False(applicability.Covers(new DateOnly(2026, 3, 14))); // другая суббота + } + + [Fact] + public void Build_SkipsSlotsCoveredByHigherPriorityLayer() + { + // Слот младшего слоя пропускается целиком, а не обрезается: половина слота означала бы + // половину настройки — своей группы и стратегии у половинки нет. + var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка"); + var top = template.AddLayer("Прайм", 100); + var bottom = template.AddLayer("Обычный", 50); + + AddSlot(top, "Кино", new TimeOnly(20, 0), 120); + AddSlot(bottom, "Сериал", new TimeOnly(20, 30), 60); + AddSlot(bottom, "Ночь", new TimeOnly(23, 0), 60); + + var titles = Build(template).Select(s => s.Slot.Title).Distinct().ToList(); + + Assert.Contains("Кино", titles); + Assert.Contains("Ночь", titles); + Assert.DoesNotContain("Сериал", titles); + } + + [Fact] + public void Build_DropsLayersThatDoNotApplyOnTheDate() + { + var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка"); + var newYear = template.AddLayer("Новогодний", 100); + newYear.Update( + newYear.Name, + newYear.Priority, + new LayerApplicability(AnnualRanges: [new AnnualRange(12, 20, 1, 8)]).ToJson(), + isEnabled: true + ); + AddSlot(newYear, "Ирония судьбы", new TimeOnly(20, 0), 180); + + var usual = template.AddLayer("Обычный", 50); + AddSlot(usual, "Вечерний сериал", new TimeOnly(20, 0), 60); + + var march = Build(template, new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero)) + .Select(s => s.Slot.Title) + .ToList(); + var december = Build(template, new DateTimeOffset(2026, 12, 25, 12, 0, 0, TimeSpan.Zero)) + .Select(s => s.Slot.Title) + .ToList(); + + Assert.Contains("Вечерний сериал", march); + Assert.DoesNotContain("Ирония судьбы", march); + Assert.Contains("Ирония судьбы", december); + Assert.DoesNotContain("Вечерний сериал", december); + } + + [Fact] + public void Build_DisabledLayerIsIgnored() + { + var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка"); + var layer = template.AddLayer("Выключённый", 100); + AddSlot(layer, "Ничего", new TimeOnly(20, 0), 60); + layer.Update(layer.Name, layer.Priority, null, isEnabled: false); + + Assert.Empty(Build(template)); + } + + private static void AddSlot(GridLayer layer, string title, TimeOnly start, int durationMinutes) => + layer.AddSlot(title, start, durationMinutes); + + private static IReadOnlyList Build( + ScheduleTemplate template, + DateTimeOffset? from = null + ) + { + var start = from ?? new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero); + return EffectiveGridBuilder.Build( + template, + utcOffsetMinutes: 180, + DayStart, + start, + start.AddHours(18) + ); + } +} diff --git a/backend/tests/TeleWave.Domain.Tests/Programming/CandidateFilterTests.cs b/backend/tests/TeleWave.Domain.Tests/Programming/CandidateFilterTests.cs new file mode 100644 index 0000000..3b49e8b --- /dev/null +++ b/backend/tests/TeleWave.Domain.Tests/Programming/CandidateFilterTests.cs @@ -0,0 +1,208 @@ +using TeleWave.Domain.Broadcast.Scheduling; +using TeleWave.Domain.Library; +using TeleWave.Domain.Programming; +using TeleWave.Domain.Programming.Planning; +using Xunit; + +namespace TeleWave.Domain.Tests.Programming; + +/// +/// Жёсткие фильтры кандидатов (см. 3.8): детское время и потолок повторов. Оба отсекают до жребия, +/// поэтому проверяются на выборе элемента, а не на готовой ленте. +/// +public class CandidateFilterTests +{ + private static readonly DateTimeOffset T0 = new(2026, 1, 5, 12, 0, 0, TimeSpan.Zero); + + private sealed class FirstAlways : IRandomSource + { + public int Next(int maxExclusive) => 0; + } + + private static PlanningElement Element( + ShowAudience? audience = null, + int position = 0, + DateTimeOffset? lastPlayed = null, + IReadOnlyList? recentPlays = null + ) + { + var showId = Guid.NewGuid(); + return new PlanningElement( + GroupElementKind.Show, + Guid.NewGuid(), + Weight: 1, + position, + [new PlanningUnit(Guid.NewGuid(), TimeSpan.FromMinutes(30), showId, 0)], + lastPlayed, + audience, + recentPlays + ); + } + + private static PlanningSlot Slot( + IReadOnlyList elements, + ShowAudience? maxAudience = null, + RepeatLimit? repeatLimit = null, + SlotStrategyKind strategy = SlotStrategyKind.RandomWithCooldown + ) => + new( + Guid.NewGuid(), + T0, + TargetDurationMinutes: 60, + SlotKind.Content, + IsAnchor: false, + MaxDriftMinutes: 30, + SnapToMinutes: null, + SlotBlockMode.FillSlot, + BlockValue: 1, + OverflowPolicy.ContinueNext, + new PlanningStrategy(strategy), + elements, + Cursor: null, + MaxAudience: maxAudience, + RepeatLimit: repeatLimit + ); + + [Fact] + public void Audience_FiltersOutStricterContent() + { + var kids = Element(ShowAudience.Kids); + var adult = Element(ShowAudience.Adult, position: 1); + + var pick = ElementSelector.Select( + Slot([adult, kids], maxAudience: ShowAudience.Family), + T0, + new FirstAlways() + ); + + Assert.NotNull(pick); + Assert.Equal(kids.ElementId, pick.Element.ElementId); + } + + [Fact] + public void Audience_WithoutLimit_KeepsEverything() + { + var adult = Element(ShowAudience.Adult); + + var pick = ElementSelector.Select(Slot([adult]), T0, new FirstAlways()); + + Assert.NotNull(pick); + Assert.Equal(adult.ElementId, pick.Element.ElementId); + } + + [Fact] + public void Audience_UnknownCategory_IsNotDropped() + { + // Неизвестная категория — не повод выбрасывать: иначе контент исчезал бы из эфира молча. + var unknown = Element(audience: null); + + var pick = ElementSelector.Select( + Slot([unknown], maxAudience: ShowAudience.Kids), + T0, + new FirstAlways() + ); + + Assert.NotNull(pick); + Assert.Equal(unknown.ElementId, pick.Element.ElementId); + } + + [Fact] + public void Audience_AllFilteredOut_ReturnsNothing() + { + var pick = ElementSelector.Select( + Slot([Element(ShowAudience.Adult)], maxAudience: ShowAudience.Kids), + T0, + new FirstAlways() + ); + + Assert.Null(pick); + } + + [Fact] + public void Audience_AppliesToSequentialStrategyToo() + { + var adult = Element(ShowAudience.Adult, position: 0); + var teen = Element(ShowAudience.Teen, position: 1); + + var pick = ElementSelector.Select( + Slot([adult, teen], maxAudience: ShowAudience.Teen, strategy: SlotStrategyKind.Sequential), + T0, + new FirstAlways() + ); + + Assert.NotNull(pick); + Assert.Equal(teen.ElementId, pick.Element.ElementId); + } + + [Fact] + public void RepeatLimit_DropsElementsOverTheCap() + { + var overCap = Element(recentPlays: [T0.AddDays(-1), T0.AddDays(-2)]); + var fresh = Element(position: 1, recentPlays: [T0.AddDays(-1)]); + + var pick = ElementSelector.Select( + Slot([overCap, fresh], repeatLimit: new RepeatLimit(WindowDays: 7, Max: 2)), + T0, + new FirstAlways() + ); + + Assert.NotNull(pick); + Assert.Equal(fresh.ElementId, pick.Element.ElementId); + } + + [Fact] + public void RepeatLimit_IgnoresPlaysOutsideTheWindow() + { + // Показы старше окна не считаются — иначе правило «не чаще двух раз в неделю» запирало бы + // элемент навсегда. + var old = Element(recentPlays: [T0.AddDays(-30), T0.AddDays(-31)]); + + var pick = ElementSelector.Select( + Slot([old], repeatLimit: new RepeatLimit(WindowDays: 7, Max: 2)), + T0, + new FirstAlways() + ); + + Assert.NotNull(pick); + Assert.Equal(old.ElementId, pick.Element.ElementId); + } + + [Fact] + public void RepeatLimit_WhenEveryoneIsOverTheCap_StillPicksSomething() + { + // Пустой эфир хуже раннего повтора — как и при исчерпанном остывании. + var a = Element(recentPlays: [T0.AddDays(-1), T0.AddDays(-2)]); + var b = Element(position: 1, recentPlays: [T0.AddDays(-1), T0.AddDays(-2)]); + + var pick = ElementSelector.Select( + Slot([a, b], repeatLimit: new RepeatLimit(WindowDays: 7, Max: 1)), + T0, + new FirstAlways() + ); + + Assert.NotNull(pick); + } + + [Fact] + public void RepeatLimit_AppliesBeforeCooldown() + { + // Порядок проверяется через исчерпание: потолок повторов выбрасывает «давний» элемент ещё + // до остывания, поэтому в вырожденном случае остаётся свежий. В обратном порядке остывание + // оставило бы давний, и выбор был бы другим. + var overCap = Element( + lastPlayed: T0.AddDays(-10), + recentPlays: [T0.AddDays(-10), T0.AddDays(-9), T0.AddDays(-8)] + ); + var recent = Element(position: 1, lastPlayed: T0.AddHours(-1), recentPlays: [T0.AddHours(-1)]); + + var slot = Slot([overCap, recent], repeatLimit: new RepeatLimit(WindowDays: 30, Max: 2)) with + { + Strategy = new PlanningStrategy(SlotStrategyKind.RandomWithCooldown, CooldownDays: 2), + }; + + var pick = ElementSelector.Select(slot, T0, new FirstAlways()); + + Assert.NotNull(pick); + Assert.Equal(recent.ElementId, pick.Element.ElementId); + } +} diff --git a/docs/tv-scheduler-tasks.md b/docs/tv-scheduler-tasks.md index 73fb5a2..9610c4e 100644 --- a/docs/tv-scheduler-tasks.md +++ b/docs/tv-scheduler-tasks.md @@ -18,8 +18,12 @@ (`BumperMinIntervalMinutes`, обе `*Chance`, `NextBumperIndex`) удалены — условия показа живут в элементе стыка; из `BumperSelection` убрана «ротация», у которой не было реализации. -**Срезы 3 и 4** не начинались, кроме того, что уже понадобилось раньше: слоты повтора и конца -вещания сделаны вместе с планировщиком, применимость слоёв и панель слоёв — частично. +**Срез 3 закрыт целиком.** Применимость слоёв правится из UI, слои переупорядочиваются +перетаскиванием и выключаются, сетку можно посмотреть на конкретную дату, слоты двигаются +и растягиваются мышью, день копируется на другие дни. Добавлены жёсткие фильтры кандидатов — +детское время и потолок повторов. + +**Срез 4** не начинался. Ничего из этого не проверялось на живой базе: только сборка, юнит-тесты и typecheck. @@ -307,19 +311,17 @@ N новых позиций по фильтру». ## Срез 3 — гибкость и календарь -#### [~] 3.1. Применимость слоёв +#### [x] 3.1. Применимость слоёв `Domain` `Infrastructure` `Application` -> Частично: модель применимости и разрешение слоёв на дату готовы; нет UI правки применимости. `applicability`: дни недели, разовые диапазоны дат, ежегодные диапазоны, конкретные даты (см. 3.4). Приоритеты, разрешение конфликтов. Юнит-тесты на пересекающиеся слои и на границу года в ежегодном диапазоне. -#### [~] 3.2. Фильтры кандидатов +#### [x] 3.2. Фильтры кандидатов `Domain` -> Частично: остывание работает; возрастной фильтр по времени и потолок повторов не сделаны. `maxAudienceByTime`, `cooldown`, `maxRepeatsInWindow` (см. 3.8) — применяются **до** взвешенного выбора. История берётся из материализованной ленты. `fallback` стратегии, когда остывание отсекло @@ -337,15 +339,14 @@ N новых позиций по фильтру». Конец вещания: заполнение зацикленным ассетом, пометка в EPG «эфир не ведётся», отображение в программе. -#### [~] 3.5. Панель слоёв +#### [x] 3.5. Панель слоёв `Frontend` -> Частично: список слоёв с приоритетом и выбором готов; нет drag-переупорядочивания и переключателя даты. Список слоёв с видимостью и приоритетом, drag для переупорядочивания, выбор редактируемого слоя, штриховка перекрытых слотов, переключатель даты («показать сетку на 25 декабря»). -#### [ ] 3.6. Drag & drop в календаре +#### [x] 3.6. Drag & drop в календаре `Frontend` Перетаскивание слотов и изменение длительности за края, копирование дня на другие дни, копирование diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index 9101949..17222d8 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -8,29 +8,42 @@ import { HttpError } from '@/shared/api/client' import type { GridLayerDto, SlotDto } from '@/shared/api/types' import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' import { toast } from '@/shared/ui/toast-store' import { applyChannelTemplate, createLayer, + createSlot, deleteLayer, getChannel, getChannelTemplate, getSchedule, + toSlotBody, + updateLayer, + updateSlot, } from './api' import { BumperCard } from './components/BumperCard' import { CollapsibleCard } from './components/CollapsibleCard' import { JunctionsCard } from './components/JunctionsCard' +import { LayerApplicabilityDialog } from './components/LayerApplicabilityDialog' import { LayerList, ScheduleGrid } from './components/ScheduleGrid' import { SchedulePreview } from './components/SchedulePreview' +import { RulesCard } from './components/RulesCard' import { SettingsCard } from './components/SettingsCard' import { TemplatePreview } from './components/TemplatePreview' import { SlotInspector, type SlotDraft } from './components/SlotInspector' +import { toTime } from './lib/format' export function ChannelDetail({ channelId }: { channelId: string }) { const { t } = useTranslation() const queryClient = useQueryClient() const [draft, setDraft] = useState(null) const [activeLayerId, setActiveLayerId] = useState(null) + const [viewDate, setViewDate] = useState('') + const [applicabilityLayer, setApplicabilityLayer] = useState(null) + // День, который копируем, и отмеченные дни-приёмники. + const [copySource, setCopySource] = useState(null) + const [copyTargets, setCopyTargets] = useState([]) const { data: channel, isLoading } = useQuery({ queryKey: ['admin', 'channels', channelId], @@ -85,6 +98,82 @@ export function ChannelDetail({ channelId }: { channelId: string }) { onError, }) + const toggleLayerMutation = useMutation({ + mutationFn: (layer: GridLayerDto) => + updateLayer(layer.id, { + name: layer.name, + priority: layer.priority, + applicability: layer.applicability, + isEnabled: !layer.isEnabled, + }), + onSuccess: invalidate, + onError, + }) + + /** + * Порядок слоёв задаётся перетаскиванием, а хранится приоритетом. Раздаём приоритеты с шагом 10 + * снизу вверх: шаг оставляет место, чтобы следующая вставка не переписывала весь список. + */ + const reorderLayersMutation = useMutation({ + mutationFn: async (layerIdsTopFirst: string[]) => { + const byId = new Map(template!.layers.map((l) => [l.id, l])) + const total = layerIdsTopFirst.length + await Promise.all( + layerIdsTopFirst.map((id, index) => { + const layer = byId.get(id) + const priority = (total - index) * 10 + if (!layer || layer.priority === priority) return Promise.resolve() + return updateLayer(id, { + name: layer.name, + priority, + applicability: layer.applicability, + isEnabled: layer.isEnabled, + }) + }), + ) + }, + onSuccess: invalidate, + onError, + }) + + const moveSlotMutation = useMutation({ + mutationFn: ({ + slot, + weekday, + startMinutes, + }: { + slot: SlotDto + weekday: number + startMinutes: number + }) => updateSlot(slot.id, { ...toSlotBody(slot), weekday, targetStart: toTime(startMinutes) }), + onSuccess: invalidate, + onError, + }) + + const resizeSlotMutation = useMutation({ + mutationFn: ({ slot, minutes }: { slot: SlotDto; minutes: number }) => + updateSlot(slot.id, { ...toSlotBody(slot), targetDurationMinutes: minutes }), + onSuccess: invalidate, + onError, + }) + + /** Копирование дня: слоты «каждый день» не копируются — они и так есть во всех колонках. */ + const copyDayMutation = useMutation({ + mutationFn: async ({ from, to }: { from: number; to: number[] }) => { + const sources = (template?.layers ?? []).flatMap((layer) => + layer.slots.filter((slot) => slot.weekday === from).map((slot) => ({ layer, slot })), + ) + for (const weekday of to) + for (const { layer, slot } of sources) + await createSlot(layer.id, { ...toSlotBody(slot), weekday }) + }, + onSuccess: () => { + setCopySource(null) + invalidate() + }, + onError, + }) + if (isLoading || !channel) return

{t('common.loading')}

const layerForNewSlot = @@ -159,19 +248,90 @@ export function ChannelDetail({ channelId }: { channelId: string }) { setActiveLayerId(layer.id)} onDelete={(layer) => deleteLayerMutation.mutate(layer)} + onToggle={(layer) => toggleLayerMutation.mutate(layer)} + onReorder={(order) => reorderLayersMutation.mutate(order)} + onEditApplicability={setApplicabilityLayer} />

{t('admin.channels.layersHint')}

+ + {/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */} +
+ {t('admin.channels.showForDate')} + setViewDate(e.target.value)} + /> + {viewDate && ( + + )} +
+ + {/* Копирование дня: сначала выбирается источник, потом дни-приёмники. */} + {copySource !== null && ( +
+ + {t('admin.channels.copyDayFrom', { + day: t(`admin.channels.weekdays.${copySource}`), + })} + + {[1, 2, 3, 4, 5, 6, 0] + .filter((day) => day !== copySource) + .map((day) => ( + + ))} + + +
+ )} + + moveSlotMutation.mutate({ slot, weekday, startMinutes }) + } + onResizeSlot={(slot, minutes) => resizeSlotMutation.mutate({ slot, minutes })} + onCopyDay={(weekday) => { + setCopySource(weekday) + setCopyTargets([]) + }} /> {draft && ( )} + {template && ( + + )} + + {applicabilityLayer && ( + setApplicabilityLayer(null)} + onChanged={invalidate} + onError={onError} + /> + )} +
) diff --git a/frontend/src/features/admin/channels/api.ts b/frontend/src/features/admin/channels/api.ts index 7f29a26..5ccf803 100644 --- a/frontend/src/features/admin/channels/api.ts +++ b/frontend/src/features/admin/channels/api.ts @@ -12,6 +12,7 @@ import type { JunctionElementKind, JunctionTemplateDto, LayerApplicability, + PlanningRules, ScheduleEntryDto, SchedulePreviewDto, ScheduleTemplateDto, @@ -73,7 +74,12 @@ export function previewTemplate(channelId: string, days: number) { export function updateTemplate( templateId: string, - body: { name: string; fallbackGroupId: string | null; defaultJunctionId: string | null }, + body: { + name: string + fallbackGroupId: string | null + defaultJunctionId: string | null + rules: PlanningRules | null + }, ) { return apiRequest(`/admin/templates/${templateId}`, { method: 'PUT', body }) } @@ -104,6 +110,12 @@ export function deleteLayer(layerId: string) { /** Тело слота: то же для создания и правки (см. SlotInput на сервере). */ export type SlotBody = Omit +/** Слот из ответа сервера → тело запроса: отбрасываем то, что сервер проставляет сам. */ +export function toSlotBody(slot: SlotDto): SlotBody { + const { id: _id, layerId: _layerId, groupName: _groupName, ...body } = slot + return body +} + export function createSlot(layerId: string, body: SlotBody) { return apiRequest(`/admin/layers/${layerId}/slots`, { method: 'POST', body }) } diff --git a/frontend/src/features/admin/channels/components/JunctionsCard.tsx b/frontend/src/features/admin/channels/components/JunctionsCard.tsx index 66d2afe..3ac2809 100644 --- a/frontend/src/features/admin/channels/components/JunctionsCard.tsx +++ b/frontend/src/features/admin/channels/components/JunctionsCard.tsx @@ -97,6 +97,7 @@ export function JunctionsCard({ name: template!.name, fallbackGroupId: template!.fallbackGroupId, defaultJunctionId: junctionId, + rules: template!.rules, }), onSuccess: onChanged, onError, diff --git a/frontend/src/features/admin/channels/components/LayerApplicabilityDialog.tsx b/frontend/src/features/admin/channels/components/LayerApplicabilityDialog.tsx new file mode 100644 index 0000000..337dfed --- /dev/null +++ b/frontend/src/features/admin/channels/components/LayerApplicabilityDialog.tsx @@ -0,0 +1,300 @@ +import { useMutation } from '@tanstack/react-query' +import { Plus, Trash2 } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { AnnualRange, DateRange, GridLayerDto, LayerApplicability } from '@/shared/api/types' +import { Button } from '@/shared/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/shared/ui/dialog' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { updateLayer } from '../api' +import { isEmpty, toIsoDate } from '../lib/applicability' + +const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0] + +/** + * Когда действует слой (см. 3.4). Разделы объединяются по ИЛИ: слой применим, если дата подходит + * хотя бы под одно условие. Пустая применимость — слой действует всегда. + */ +export function LayerApplicabilityDialog({ + layer, + onClose, + onChanged, + onError, +}: { + layer: GridLayerDto + onClose: () => void + onChanged: () => void + onError: (error: unknown) => void +}) { + const { t } = useTranslation() + const [name, setName] = useState(layer.name) + const [weekdays, setWeekdays] = useState(layer.applicability?.weekdays ?? []) + const [dateRanges, setDateRanges] = useState(layer.applicability?.dateRanges ?? []) + const [annualRanges, setAnnualRanges] = useState( + layer.applicability?.annualRanges ?? [], + ) + const [specificDates, setSpecificDates] = useState( + layer.applicability?.specificDates ?? [], + ) + + const save = useMutation({ + mutationFn: () => { + const applicability: LayerApplicability = { + weekdays: weekdays.length > 0 ? [...weekdays].sort((a, b) => a - b) : null, + dateRanges: dateRanges.length > 0 ? dateRanges : null, + annualRanges: annualRanges.length > 0 ? annualRanges : null, + specificDates: specificDates.length > 0 ? specificDates : null, + } + return updateLayer(layer.id, { + name: name.trim() || layer.name, + priority: layer.priority, + // Пустую применимость отправляем как null — «действует всегда» и «пустые списки» это одно + // и то же, но null читается однозначно. + applicability: isEmpty(applicability) ? null : applicability, + isEnabled: layer.isEnabled, + }) + }, + onSuccess: () => { + onChanged() + onClose() + }, + onError, + }) + + const toggleWeekday = (day: number) => + setWeekdays((current) => + current.includes(day) ? current.filter((d) => d !== day) : [...current, day], + ) + + const today = toIsoDate(new Date()) + + return ( + !open && onClose()}> + + + {t('admin.channels.layerApplicability')} + {t('admin.channels.applicabilityHint')} + + +
+
+ + setName(e.target.value)} /> +
+ +
+ +
+ {WEEKDAYS.map((day) => ( + + ))} +
+
+ +
setDateRanges((c) => [...c, { from: today, to: today }])} + empty={dateRanges.length === 0} + > + {dateRanges.map((range, index) => ( +
  • + + setDateRanges((c) => + c.map((r, i) => (i === index ? { ...r, from: e.target.value } : r)), + ) + } + /> + + setDateRanges((c) => + c.map((r, i) => (i === index ? { ...r, to: e.target.value } : r)), + ) + } + /> + +
  • + ))} +
    + +
    + setAnnualRanges((c) => [...c, { fromMonth: 12, fromDay: 20, toMonth: 1, toDay: 8 }]) + } + empty={annualRanges.length === 0} + > + {annualRanges.map((range, index) => ( +
  • + + setAnnualRanges((c) => c.map((r, i) => (i === index ? { ...r, ...part } : r))) + } + /> + + + setAnnualRanges((c) => c.map((r, i) => (i === index ? { ...r, ...part } : r))) + } + /> + +
  • + ))} +
    + +
    setSpecificDates((c) => [...c, today])} + empty={specificDates.length === 0} + > + {specificDates.map((date, index) => ( +
  • + + setSpecificDates((c) => c.map((d, i) => (i === index ? e.target.value : d))) + } + /> + +
  • + ))} +
    +
    + + + + + +
    +
    + ) +} + +function Section({ + title, + onAdd, + empty, + children, +}: { + title: string + onAdd: () => void + empty: boolean + children: React.ReactNode +}) { + const { t } = useTranslation() + return ( +
    +
    + + +
    + {empty ? ( +

    {t('admin.channels.applicabilityNone')}

    + ) : ( +
      {children}
    + )} +
    + ) +} + +/** Пара «месяц / день» ежегодного периода — год у него намеренно отсутствует. */ +function MonthDay({ + value, + prefix, + onChange, +}: { + value: AnnualRange + prefix: 'from' | 'to' + onChange: (part: Partial) => void +}) { + const month = prefix === 'from' ? value.fromMonth : value.toMonth + const day = prefix === 'from' ? value.fromDay : value.toDay + + return ( + + + onChange( + prefix === 'from' + ? { fromMonth: Number(e.target.value) } + : { toMonth: Number(e.target.value) }, + ) + } + /> + + onChange( + prefix === 'from' + ? { fromDay: Number(e.target.value) } + : { toDay: Number(e.target.value) }, + ) + } + /> + + ) +} diff --git a/frontend/src/features/admin/channels/components/RulesCard.tsx b/frontend/src/features/admin/channels/components/RulesCard.tsx new file mode 100644 index 0000000..5c54e33 --- /dev/null +++ b/frontend/src/features/admin/channels/components/RulesCard.tsx @@ -0,0 +1,189 @@ +import { useMutation } from '@tanstack/react-query' +import { Plus, Trash2 } from 'lucide-react' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + SHOW_AUDIENCES, + type AudienceWindow, + type PlanningRules, + type ScheduleTemplateDto, + type ShowAudience, +} from '@/shared/api/types' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { updateTemplate } from '../api' +import { CollapsibleCard } from './CollapsibleCard' + +const EMPTY_WINDOW: AudienceWindow = { from: '06:00:00', to: '23:00:00', maxAudience: 'Teen' } + +/** + * Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие + * фильтры — они отсекают недопустимое до жребия, поэтому не требуют пересборки и не ломают + * воспроизводимость. Как и правка сетки, эфира сами по себе не двигают. + */ +export function RulesCard({ + template, + onChanged, + onError, +}: { + template: ScheduleTemplateDto + onChanged: () => void + onError: (error: unknown) => void +}) { + const { t } = useTranslation() + const [windows, setWindows] = useState( + () => template.rules?.maxAudienceByTime ?? [], + ) + const [limitOn, setLimitOn] = useState(() => template.rules?.maxRepeatsInWindow != null) + const [windowDays, setWindowDays] = useState( + () => template.rules?.maxRepeatsInWindow?.windowDays ?? 7, + ) + const [max, setMax] = useState(() => template.rules?.maxRepeatsInWindow?.max ?? 2) + + useEffect(() => { + setWindows(template.rules?.maxAudienceByTime ?? []) + setLimitOn(template.rules?.maxRepeatsInWindow != null) + setWindowDays(template.rules?.maxRepeatsInWindow?.windowDays ?? 7) + setMax(template.rules?.maxRepeatsInWindow?.max ?? 2) + }, [template]) + + const save = useMutation({ + mutationFn: () => { + const rules: PlanningRules = { + maxAudienceByTime: windows.length > 0 ? windows : null, + maxRepeatsInWindow: limitOn ? { windowDays, max } : null, + } + return updateTemplate(template.id, { + name: template.name, + fallbackGroupId: template.fallbackGroupId, + defaultJunctionId: template.defaultJunctionId, + rules, + }) + }, + onSuccess: onChanged, + onError, + }) + + const patchWindow = (index: number, part: Partial) => + setWindows((current) => current.map((w, i) => (i === index ? { ...w, ...part } : w))) + + return ( + +
    +

    {t('admin.channels.rulesHint')}

    + +
    +
    +

    + {t('admin.channels.audienceWindows')} +

    + +
    + + {windows.length === 0 ? ( +

    {t('admin.channels.noAudienceWindows')}

    + ) : ( +
      + {windows.map((window, index) => ( +
    • +
      + + patchWindow(index, { from: `${e.target.value}:00` })} + /> +
      +
      + + patchWindow(index, { to: `${e.target.value}:00` })} + /> +
      +
      + + +
      + +
    • + ))} +
    + )} +

    {t('admin.channels.audienceWindowsHint')}

    +
    + +
    + + {limitOn && ( +
    +
    + + setWindowDays(Number(e.target.value))} + /> +
    +
    + + setMax(Number(e.target.value))} + /> +
    +
    + )} +

    {t('admin.channels.repeatLimitHint')}

    +
    + +
    + +
    +
    +
    + ) +} diff --git a/frontend/src/features/admin/channels/components/ScheduleGrid.tsx b/frontend/src/features/admin/channels/components/ScheduleGrid.tsx index a1fca43..d5d8b57 100644 --- a/frontend/src/features/admin/channels/components/ScheduleGrid.tsx +++ b/frontend/src/features/admin/channels/components/ScheduleGrid.tsx @@ -1,203 +1,372 @@ -import { Anchor, Plus } from 'lucide-react' -import { useTranslation } from 'react-i18next' -import type { GridLayerDto, ScheduleTemplateDto, SlotDto } from '@/shared/api/types' -import { Button } from '@/shared/ui/button' -import { cn } from '@/shared/lib/cn' - -const HOUR_HEIGHT = 44 -const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0] - -/** Цвет блока — по дейпарту: сетка должна читаться одним взглядом, без легенды. */ -const DAYPART_CLASS: Record = { - Morning: 'bg-amber-500/20 border-amber-500/40', - Day: 'bg-sky-500/20 border-sky-500/40', - Prime: 'bg-violet-500/25 border-violet-500/50', - Night: 'bg-slate-500/20 border-slate-500/40', -} - -function minutesOf(time: string): number { - const [h, m] = time.split(':') - return Number(h) * 60 + Number(m) -} - -/** - * Смещение слота от начала вещательных суток. Ночной блок (00:00–06:00 при старте суток в 06:00) - * принадлежит предыдущему дню, поэтому его смещение больше суточного, а не отрицательное. - */ -function offsetInDay(slotStart: string, dayStart: string): number { - const diff = minutesOf(slotStart) - minutesOf(dayStart) - return diff >= 0 ? diff : diff + 24 * 60 -} - -/** Слоты, попадающие в колонку дня: слот без дня недели идёт каждый день. */ -function slotsOfDay(layers: GridLayerDto[], weekday: number) { - return layers - .filter((layer) => layer.isEnabled) - .flatMap((layer) => - layer.slots - .filter((slot) => slot.weekday === null || slot.weekday === weekday) - .map((slot) => ({ slot, layer })), - ) -} - -export function ScheduleGrid({ - template, - selectedSlotId, - onSelectSlot, - onAddSlot, -}: { - template: ScheduleTemplateDto - selectedSlotId: string | null - onSelectSlot: (slot: SlotDto) => void - onAddSlot: (weekday: number, startMinutes: number) => void -}) { - const { t } = useTranslation() - const dayStart = template.dayStartTime.slice(0, 5) - const dayStartMinutes = minutesOf(dayStart) - - // Подписи часов идут от начала вещательных суток, а не от полуночи. - const hours = Array.from({ length: 24 }, (_, i) => (dayStartMinutes / 60 + i) % 24) - - // Слои отсортированы по убыванию приоритета: слот, лежащий под более приоритетным, штрихуем. - const ordered = [...template.layers].sort((a, b) => b.priority - a.priority) - - const isCovered = (slot: SlotDto, layer: GridLayerDto, weekday: number) => { - const from = offsetInDay(slot.targetStart, dayStart) - const to = from + slot.targetDurationMinutes - return ordered - .filter((other) => other.isEnabled && other.priority > layer.priority) - .some((other) => - other.slots - .filter((s) => s.weekday === null || s.weekday === weekday) - .some((s) => { - const otherFrom = offsetInDay(s.targetStart, dayStart) - return from < otherFrom + s.targetDurationMinutes && otherFrom < to - }), - ) - } - - return ( -
    -
    -
    -
    {dayStart}
    - {WEEKDAYS.map((weekday) => ( -
    - {t(`admin.channels.weekdays.${weekday}`)} -
    - ))} -
    - -
    -
    - {hours.map((hour, index) => ( -
    - {hour.toString().padStart(2, '0')}:00 -
    - ))} -
    - - {WEEKDAYS.map((weekday) => ( -
    - {hours.map((_, index) => ( - - ))} - - {slotsOfDay(ordered, weekday).map(({ slot, layer }) => { - const from = offsetInDay(slot.targetStart, dayStart) - const covered = isCovered(slot, layer, weekday) - return ( - - ) - })} -
    - ))} -
    -
    -
    - ) -} - -/** Панель слоёв: видимость, приоритет и выбор редактируемого. */ -export function LayerList({ - template, - activeLayerId, - onSelect, - onDelete, -}: { - template: ScheduleTemplateDto - activeLayerId: string | null - onSelect: (layer: GridLayerDto) => void - onDelete: (layer: GridLayerDto) => void -}) { - const { t } = useTranslation() - const ordered = [...template.layers].sort((a, b) => b.priority - a.priority) - - return ( -
      - {ordered.map((layer) => ( -
    • - - - {layer.isBackground ? t('admin.channels.background') : layer.priority} - - - {layer.slots.length} - - {!layer.isBackground && ( - - )} -
    • - ))} -
    - ) -} +import { Anchor, CalendarRange, Copy, GripVertical, Plus, Repeat } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { GridLayerDto, ScheduleTemplateDto, SlotDto } from '@/shared/api/types' +import { Button } from '@/shared/ui/button' +import { cn } from '@/shared/lib/cn' +import { coversDate, isEmpty } from '../lib/applicability' + +const HOUR_HEIGHT = 44 + +/** Шаг сетки при перетаскивании и растягивании — минуты. */ +const SNAP_MINUTES = 15 + +const snap = (minutes: number) => Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES +const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0] + +/** Цвет блока — по дейпарту: сетка должна читаться одним взглядом, без легенды. */ +const DAYPART_CLASS: Record = { + Morning: 'bg-amber-500/20 border-amber-500/40', + Day: 'bg-sky-500/20 border-sky-500/40', + Prime: 'bg-violet-500/25 border-violet-500/50', + Night: 'bg-slate-500/20 border-slate-500/40', +} + +function minutesOf(time: string): number { + const [h, m] = time.split(':') + return Number(h) * 60 + Number(m) +} + +/** + * Смещение слота от начала вещательных суток. Ночной блок (00:00–06:00 при старте суток в 06:00) + * принадлежит предыдущему дню, поэтому его смещение больше суточного, а не отрицательное. + */ +function offsetInDay(slotStart: string, dayStart: string): number { + const diff = minutesOf(slotStart) - minutesOf(dayStart) + return diff >= 0 ? diff : diff + 24 * 60 +} + +/** Слоты, попадающие в колонку дня: слот без дня недели идёт каждый день. */ +function slotsOfDay(layers: GridLayerDto[], weekday: number) { + return layers + .filter((layer) => layer.isEnabled) + .flatMap((layer) => + layer.slots + .filter((slot) => slot.weekday === null || slot.weekday === weekday) + .map((slot) => ({ slot, layer })), + ) +} + +export function ScheduleGrid({ + template, + selectedSlotId, + viewDate, + onSelectSlot, + onAddSlot, + onMoveSlot, + onResizeSlot, + onCopyDay, +}: { + template: ScheduleTemplateDto + selectedSlotId: string | null + /** Дата, на которую смотрим сетку («показать 25 декабря»); null — все слои разом. */ + viewDate: string | null + onSelectSlot: (slot: SlotDto) => void + onAddSlot: (weekday: number, startMinutes: number) => void + /** Перенос слота: новое время старта и (для слота с днём недели) новый день. */ + onMoveSlot: (slot: SlotDto, weekday: number, startMinutes: number) => void + onResizeSlot: (slot: SlotDto, durationMinutes: number) => void + onCopyDay: (fromWeekday: number) => void +}) { + const { t } = useTranslation() + const dayStart = template.dayStartTime.slice(0, 5) + const dayStartMinutes = minutesOf(dayStart) + + // Подписи часов идут от начала вещательных суток, а не от полуночи. + const hours = Array.from({ length: 24 }, (_, i) => (dayStartMinutes / 60 + i) % 24) + + // На выбранную дату показываем только те слои, которые в этот день действуют, — иначе сетка + // «на 25 декабря» показывала бы и обычный день, и новогодний одновременно. + const day = viewDate ? parseIsoDate(viewDate) : null + const applicable = day + ? template.layers.filter((layer) => coversDate(layer.applicability, day)) + : template.layers + + // Слои отсортированы по убыванию приоритета: слот, лежащий под более приоритетным, штрихуем. + const ordered = [...applicable].sort((a, b) => b.priority - a.priority) + const highlightWeekday = day?.getDay() ?? null + + const [dragged, setDragged] = useState(null) + const [resizing, setResizing] = useState<{ slot: SlotDto; minutes: number } | null>(null) + + /** Позиция курсора в колонке дня — минуты суток, округлённые до шага сетки. */ + const minutesAt = (clientY: number, column: HTMLElement) => { + const rect = column.getBoundingClientRect() + const offset = Math.max(0, Math.min(rect.height, clientY - rect.top)) + const fromDayStart = snap((offset / HOUR_HEIGHT) * 60) + return (dayStartMinutes + fromDayStart) % (24 * 60) + } + + const drop = (event: React.DragEvent, weekday: number) => { + event.preventDefault() + if (!dragged) return + // Слот «каждый день» при переносе таким и остаётся: молча превратить его в слот одного дня + // значило бы убрать его сразу из шести колонок. + onMoveSlot(dragged, dragged.weekday ?? weekday, minutesAt(event.clientY, event.currentTarget)) + setDragged(null) + } + + /** Растягивание за нижний край: пока тянем — видно новую высоту, отпустили — сохраняем. */ + const startResize = (event: React.MouseEvent, slot: SlotDto, column: HTMLElement) => { + event.preventDefault() + event.stopPropagation() + const from = offsetInDay(slot.targetStart, dayStart) + + const move = (moveEvent: MouseEvent) => { + const rect = column.getBoundingClientRect() + const offset = Math.max(0, Math.min(rect.height, moveEvent.clientY - rect.top)) + const end = snap((offset / HOUR_HEIGHT) * 60) + setResizing({ slot, minutes: Math.max(SNAP_MINUTES, end - from) }) + } + const up = () => { + window.removeEventListener('mousemove', move) + window.removeEventListener('mouseup', up) + setResizing((current) => { + if (current && current.minutes !== slot.targetDurationMinutes) + onResizeSlot(slot, current.minutes) + return null + }) + } + window.addEventListener('mousemove', move) + window.addEventListener('mouseup', up) + } + + const isCovered = (slot: SlotDto, layer: GridLayerDto, weekday: number) => { + const from = offsetInDay(slot.targetStart, dayStart) + const to = from + slot.targetDurationMinutes + return ordered + .filter((other) => other.isEnabled && other.priority > layer.priority) + .some((other) => + other.slots + .filter((s) => s.weekday === null || s.weekday === weekday) + .some((s) => { + const otherFrom = offsetInDay(s.targetStart, dayStart) + return from < otherFrom + s.targetDurationMinutes && otherFrom < to + }), + ) + } + + return ( +
    +
    +
    +
    {dayStart}
    + {WEEKDAYS.map((weekday) => ( +
    + {t(`admin.channels.weekdays.${weekday}`)} + +
    + ))} +
    + +
    +
    + {hours.map((hour, index) => ( +
    + {hour.toString().padStart(2, '0')}:00 +
    + ))} +
    + + {WEEKDAYS.map((weekday) => ( +
    dragged && e.preventDefault()} + onDrop={(e) => drop(e, weekday)} + > + {hours.map((_, index) => ( + + ))} + + {slotsOfDay(ordered, weekday).map(({ slot, layer }) => { + const from = offsetInDay(slot.targetStart, dayStart) + const covered = isCovered(slot, layer, weekday) + const minutes = + resizing?.slot.id === slot.id ? resizing.minutes : slot.targetDurationMinutes + return ( +
    setDragged(slot)} + onDragEnd={() => setDragged(null)} + onClick={() => onSelectSlot(slot)} + className={cn( + 'absolute inset-x-1 cursor-grab overflow-hidden rounded border px-1.5 py-0.5 text-left text-[11px] leading-tight', + DAYPART_CLASS[slot.daypart] ?? DAYPART_CLASS.Day, + selectedSlotId === slot.id && 'ring-2 ring-primary', + dragged?.id === slot.id && 'opacity-50', + // Перекрытый слот виден, но приглушён: он не сыграет, пока лежит под старшим слоем. + covered && 'opacity-40 [background-image:repeating-linear-gradient(45deg,transparent,transparent_4px,rgba(0,0,0,.15)_4px,rgba(0,0,0,.15)_8px)]', + )} + style={{ + top: (from / 60) * HOUR_HEIGHT, + height: Math.max(16, (minutes / 60) * HOUR_HEIGHT - 2), + }} + > + + {slot.isAnchor && } + {slot.targetStart.slice(0, 5)} + {slot.weekday === null && } + + {slot.title} + + startResize(e, slot, e.currentTarget.parentElement!.parentElement!) + } + /> +
    + ) + })} +
    + ))} +
    +
    +
    + ) +} + +/** + * Панель слоёв: видимость, приоритет (перетаскиванием), применимость и выбор редактируемого. + * Выше в списке — приоритетнее; фоновый слой всегда внизу и не двигается. + */ +export function LayerList({ + template, + activeLayerId, + viewDate, + onSelect, + onDelete, + onToggle, + onReorder, + onEditApplicability, +}: { + template: ScheduleTemplateDto + activeLayerId: string | null + viewDate: string | null + onSelect: (layer: GridLayerDto) => void + onDelete: (layer: GridLayerDto) => void + onToggle: (layer: GridLayerDto) => void + onReorder: (layerIdsTopFirst: string[]) => void + onEditApplicability: (layer: GridLayerDto) => void +}) { + const { t } = useTranslation() + const [dragged, setDragged] = useState(null) + + const ordered = [...template.layers].sort((a, b) => b.priority - a.priority) + const day = viewDate ? parseIsoDate(viewDate) : null + + const dropOn = (targetId: string) => { + if (!dragged || dragged === targetId) return + const movable = ordered.filter((l) => !l.isBackground).map((l) => l.id) + const order = movable.filter((id) => id !== dragged) + const at = order.indexOf(targetId) + // Бросок на фоновый слой означает «в самый низ»: он в порядке не участвует. + order.splice(at === -1 ? order.length : at, 0, dragged) + setDragged(null) + onReorder(order) + } + + return ( +
      + {ordered.map((layer) => { + const inactiveToday = day !== null && !coversDate(layer.applicability, day) + return ( +
    • setDragged(layer.id)} + onDragOver={(e) => e.preventDefault()} + onDrop={() => dropOn(layer.id)} + className={cn('flex items-center gap-1.5 py-1.5', inactiveToday && 'opacity-40')} + > + {layer.isBackground ? ( + + ) : ( + + )} + onToggle(layer)} + /> + + + {layer.isBackground ? t('admin.channels.background') : layer.slots.length} + + {!layer.isBackground && ( + <> + + + + )} +
    • + ) + })} +
    + ) +} + +/** «2026-12-25» → локальная дата. `new Date(iso)` разобрал бы её как UTC и сместил день. */ +function parseIsoDate(iso: string): Date { + const [year, month, day] = iso.split('-').map(Number) + return new Date(year, month - 1, day) +} diff --git a/frontend/src/features/admin/channels/components/SlotInspector.tsx b/frontend/src/features/admin/channels/components/SlotInspector.tsx index 361265a..ca49aee 100644 --- a/frontend/src/features/admin/channels/components/SlotInspector.tsx +++ b/frontend/src/features/admin/channels/components/SlotInspector.tsx @@ -16,7 +16,14 @@ import { Button } from '@/shared/ui/button' import { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' import { toast } from '@/shared/ui/toast-store' -import { createSlot, deleteSlot, listJunctions, updateSlot, type SlotBody } from '../api' +import { + createSlot, + deleteSlot, + listJunctions, + toSlotBody, + updateSlot, + type SlotBody, +} from '../api' const DAYPARTS: Daypart[] = ['Morning', 'Day', 'Prime', 'Night'] const SLOT_KINDS: SlotKind[] = ['Content', 'Repeat', 'SignOff'] @@ -28,11 +35,6 @@ const SNAP_OPTIONS = [0, 5, 10, 15, 30] /** Черновик слота: новый (layerId + предзаполненные время/день) либо существующий. */ export type SlotDraft = { layerId: string; slot: SlotDto | null; defaults?: Partial } -function toBody(slot: SlotDto): SlotBody { - const { id: _id, layerId: _layerId, groupName: _groupName, ...body } = slot - return body -} - function emptyBody(defaults?: Partial): SlotBody { return { title: '', @@ -74,11 +76,11 @@ export function SlotInspector({ }) { const { t } = useTranslation() const [body, setBody] = useState(() => - draft.slot ? toBody(draft.slot) : emptyBody(draft.defaults), + draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults), ) useEffect(() => { - setBody(draft.slot ? toBody(draft.slot) : emptyBody(draft.defaults)) + setBody(draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults)) }, [draft]) const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups }) diff --git a/frontend/src/features/admin/channels/lib/applicability.ts b/frontend/src/features/admin/channels/lib/applicability.ts new file mode 100644 index 0000000..c749949 --- /dev/null +++ b/frontend/src/features/admin/channels/lib/applicability.ts @@ -0,0 +1,45 @@ +import type { LayerApplicability } from '@/shared/api/types' + +/** + * Действует ли слой в эту дату вещательных суток. Зеркало серверного `LayerApplicability.Covers` + * (`TeleWave.Application/Programming/Templates/LayerApplicability.cs`) — источник правды там, + * здесь только подсветка сетки. Меняя одно, правьте второе. + */ +export function coversDate(applicability: LayerApplicability | null, date: Date): boolean { + if (!applicability || isEmpty(applicability)) return true + + const weekday = date.getDay() + if (applicability.weekdays?.includes(weekday)) return true + + const iso = toIsoDate(date) + if (applicability.specificDates?.includes(iso)) return true + + if (applicability.dateRanges?.some((r) => iso >= r.from && iso <= r.to)) return true + + // Ежегодный период может пересекать Новый год — сравниваем по паре (месяц, день). + const value = (date.getMonth() + 1) * 100 + date.getDate() + return ( + applicability.annualRanges?.some((r) => { + const from = r.fromMonth * 100 + r.fromDay + const to = r.toMonth * 100 + r.toDay + return from <= to ? value >= from && value <= to : value >= from || value <= to + }) ?? false + ) +} + +export function isEmpty(applicability: LayerApplicability | null): boolean { + if (!applicability) return true + return ( + !applicability.weekdays?.length && + !applicability.dateRanges?.length && + !applicability.annualRanges?.length && + !applicability.specificDates?.length + ) +} + +/** «2026-12-25» из локальной даты — без ухода в UTC, иначе вечер уезжает на день назад. */ +export function toIsoDate(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String( + date.getDate(), + ).padStart(2, '0')}` +} diff --git a/frontend/src/features/admin/channels/lib/format.ts b/frontend/src/features/admin/channels/lib/format.ts index e6a3515..0ef7e51 100644 --- a/frontend/src/features/admin/channels/lib/format.ts +++ b/frontend/src/features/admin/channels/lib/format.ts @@ -10,6 +10,12 @@ export function formatTime(iso: string | null) { }) } +/** Минуты суток → «HH:MM:00» — формат `TimeOnly` на сервере. */ +export function toTime(minutes: number): string { + const m = ((minutes % (24 * 60)) + 24 * 60) % (24 * 60) + return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}:00` +} + /** Минуты суток → «HH:MM». */ export function formatMinute(minute: number | null) { if (minute == null) return '—' diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 8efc689..e7191bc 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -459,6 +459,15 @@ export type GridLayerDto = { slots: SlotDto[] } +/** Окно детского времени: до `to` в эфир идёт контент не строже `maxAudience`. */ +export type AudienceWindow = { from: string; to: string; maxAudience: ShowAudience } + +/** Правила отбора кандидатов канала — жёсткие фильтры, применяются до жребия. */ +export type PlanningRules = { + maxAudienceByTime?: AudienceWindow[] | null + maxRepeatsInWindow?: { windowDays: number; max: number } | null +} + export type ScheduleTemplateDto = { id: string channelId: string @@ -466,6 +475,8 @@ export type ScheduleTemplateDto = { fallbackGroupId: string | null /** Стык, который берётся, когда слот своего не задал. */ defaultJunctionId: string | null + /** Детское время и потолок повторов. */ + rules: PlanningRules | null revision: number appliedRevision: number /** Есть ли правки правил, не применённые к эфиру. */ @@ -481,6 +492,7 @@ export type PlanningWarningKind = | 'CooldownExhausted' | 'RepeatSourceEmpty' | 'FallbackEmpty' + | 'CandidatesFiltered' export type PlanningWarningDto = { kind: PlanningWarningKind diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index c4388fc..086f276 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -341,6 +341,37 @@ const resources = { snapOff: 'нет', bumperConditionsHint: 'Как часто ставить заставку и на каких переходах — условия элемента стыка, а не настройка канала.', + resizeSlot: 'Потянуть за край — длительность', + copyDay: 'Копировать день', + copyDayFrom: 'Копировать {{day}} в:', + copy: 'Копировать', + layerVisible: 'Показывать слой', + layerName: 'Название слоя', + layerApplicability: 'Когда действует', + applicabilityHint: + 'Разделы объединяются по ИЛИ: слой действует, если дата подходит хотя бы под одно условие. Ничего не заполнено — действует всегда.', + applicabilityWeekdays: 'Дни недели', + applicabilityDateRanges: 'Диапазоны дат', + applicabilityAnnual: 'Ежегодно (месяц / день)', + applicabilityDates: 'Конкретные даты', + applicabilityNone: 'не задано', + showForDate: 'Сетка на дату', + allDates: 'Все слои', + rules: 'Правила отбора', + rulesHint: + 'Жёсткие фильтры: отсекают неподходящее до жребия. Как и правка сетки, эфир не двигают — нужно применить.', + audienceWindows: 'Детское время', + noAudienceWindows: 'Окон нет — возраст ничем не ограничен.', + audienceWindowsHint: + 'В окне в эфир идёт контент не строже выбранной категории. Окно может переходить через полночь. Контент без категории не отсекается.', + from: 'С', + to: 'До', + maxAudience: 'Не строже', + repeatLimit: 'Потолок повторов', + repeatWindowDays: 'Окно, суток', + repeatMax: 'Не чаще, раз', + repeatLimitHint: + 'Считается по уже записанной ленте. Если потолка достигли все кандидаты, слот всё равно заполняется: пустой эфир хуже раннего повтора.', preview: 'Предпросмотр', previewHide: 'Свернуть предпросмотр', previewHint: 'Прогон по текущим правилам: ничего не пишется, курсоры слотов не двигаются.', @@ -422,6 +453,7 @@ const resources = { CooldownExhausted: 'Остывание отсекло всех', RepeatSourceEmpty: 'Нечего повторять', FallbackEmpty: 'Нечем закрыть паузы', + CandidatesFiltered: 'Возрастной потолок отсёк всех', }, title: 'Каналы', @@ -509,19 +541,7 @@ const resources = { preferredNone: 'Окна не заданы — предпочтений по времени нет.', preferredAddWindow: 'Добавить окно', preferredBadRange: 'начало ≥ конца', - ads: 'Реклама', - pickAd: 'Выберите ролик', - noAds: 'Пул рекламы пуст', - overrides: 'Марафоны / override', - modes: { Exclusive: 'Эксклюзив', Boost: 'Буст' }, - overrideRecurrence: 'Повтор', - recurrenceOneTime: 'Разово', - recurrenceWeekly: 'Еженедельно', - from: 'С', - to: 'По', - noOverrides: 'Override не заданы', - schedule: 'Расписание (12 ч)', - noSchedule: 'Расписание ещё не построено — нажмите «Пересобрать»', + noSchedule: 'Расписание ещё не построено', }, maintenance: { title: 'Обслуживание', @@ -918,6 +938,37 @@ const resources = { snapOff: 'off', bumperConditionsHint: 'How often a bumper is inserted and on which transitions is a junction-element condition, not a channel setting.', + resizeSlot: 'Drag the edge to change duration', + copyDay: 'Copy day', + copyDayFrom: 'Copy {{day}} to:', + copy: 'Copy', + layerVisible: 'Show layer', + layerName: 'Layer name', + layerApplicability: 'When it applies', + applicabilityHint: + 'Sections are OR-ed: the layer applies when the date matches at least one condition. Nothing filled in — it always applies.', + applicabilityWeekdays: 'Weekdays', + applicabilityDateRanges: 'Date ranges', + applicabilityAnnual: 'Yearly (month / day)', + applicabilityDates: 'Specific dates', + applicabilityNone: 'not set', + showForDate: 'Grid for date', + allDates: 'All layers', + rules: 'Candidate rules', + rulesHint: + 'Hard filters: they cut out what is not allowed before the draw. Like grid edits, they do not move the air — apply to take effect.', + audienceWindows: 'Family hours', + noAudienceWindows: 'No windows — the age is not limited.', + audienceWindowsHint: + 'Inside a window only content no stricter than the chosen category airs. A window may cross midnight. Content with no category is never dropped.', + from: 'From', + to: 'To', + maxAudience: 'No stricter than', + repeatLimit: 'Repeat cap', + repeatWindowDays: 'Window, days', + repeatMax: 'At most, times', + repeatLimitHint: + 'Counted against the already recorded tape. If every candidate hits the cap the slot is still filled: empty air is worse than an early repeat.', preview: 'Preview', previewHide: 'Hide preview', previewHint: 'A run against the current rules: nothing is written, slot cursors do not move.', @@ -993,6 +1044,7 @@ const resources = { CooldownExhausted: 'Cooldown ruled out every candidate', RepeatSourceEmpty: 'Nothing to repeat', FallbackEmpty: 'Nothing to fill pauses with', + CandidatesFiltered: 'The age cap ruled out every candidate', }, title: 'Channels', @@ -1080,19 +1132,7 @@ const resources = { preferredNone: 'No windows set — no time preference.', preferredAddWindow: 'Add window', preferredBadRange: 'start ≥ end', - ads: 'Ads', - pickAd: 'Pick an ad', - noAds: 'Ad pool is empty', - overrides: 'Marathons / overrides', - modes: { Exclusive: 'Exclusive', Boost: 'Boost' }, - overrideRecurrence: 'Repeat', - recurrenceOneTime: 'One-time', - recurrenceWeekly: 'Weekly', - from: 'From', - to: 'To', - noOverrides: 'No overrides set', - schedule: 'Schedule (12h)', - noSchedule: 'Schedule not built yet — click “Rebuild”', + noSchedule: 'Schedule not built yet', }, maintenance: { title: 'Maintenance',