Репозиторий хранил фронтенд в CRLF, а часть бэкенда — вперемешку, хотя CI и Docker-сборка работают под Linux. Прибиваем LF атрибутом `* text=auto eol=lf` и разово нормализуем дерево, чтобы форматтеры не переписывали файлы целиком на каждом прогоне. Коммит чисто механический: изменений содержимого нет, только концы строк. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
76 lines
3.9 KiB
C#
76 lines
3.9 KiB
C#
using System.Text.Json;
|
||
using System.Text.Json.Serialization;
|
||
using TeleWave.Domain.Library;
|
||
|
||
namespace TeleWave.Application.Programming.Templates;
|
||
|
||
/// <summary>
|
||
/// Окно детского времени: с <paramref name="From"/> до <paramref name="To"/> во времени канала
|
||
/// в эфир идёт только контент не строже <paramref name="MaxAudience"/>. Окно может переходить
|
||
/// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону.
|
||
/// </summary>
|
||
public sealed record AudienceWindow(TimeOnly From, TimeOnly To, ShowAudience MaxAudience)
|
||
{
|
||
public bool Contains(TimeOnly moment) =>
|
||
From <= To ? moment >= From && moment < To : moment >= From || moment < To;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают
|
||
/// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость.
|
||
///
|
||
/// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени
|
||
/// и так задаются временем, а потолок повторов осмыслен только целиком по каналу.
|
||
/// </summary>
|
||
public sealed record PlanningRules(
|
||
IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null,
|
||
/// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary>
|
||
RepeatLimitRule? MaxRepeatsInWindow = null,
|
||
// ── Пост-проверки: считаются по готовой ленте, дают предупреждения и ничего не переигрывают. ──
|
||
/// <summary>Потолок врезок в часе, минуты (null — не проверять).</summary>
|
||
int? MaxBreakMinutesPerHour = null,
|
||
/// <summary>Потолок доли одного жанра за вещательные сутки, проценты (null — не проверять).</summary>
|
||
int? MaxGenreSharePercent = null,
|
||
/// <summary>Потолок доли эфира, отданной фону, проценты (null — не проверять).</summary>
|
||
int? MaxFallbackSharePercent = null
|
||
)
|
||
{
|
||
private static readonly JsonSerializerOptions Options = new()
|
||
{
|
||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||
Converters = { new JsonStringEnumConverter() },
|
||
};
|
||
|
||
/// <summary>Возрастной потолок в указанный момент времени канала (null — без ограничения).</summary>
|
||
public ShowAudience? AudienceAt(TimeOnly moment)
|
||
{
|
||
if (MaxAudienceByTime is not { Count: > 0 } windows)
|
||
return null;
|
||
|
||
// Пересекающиеся окна разрешаются в пользу строгого: детское время не должно
|
||
// отменяться более широким окном, случайно наложенным сверху.
|
||
var applicable = windows.Where(w => w.Contains(moment)).Select(w => w.MaxAudience).ToList();
|
||
return applicable.Count > 0 ? applicable.Min() : null;
|
||
}
|
||
|
||
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||
|
||
public static PlanningRules? FromJson(string? json)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(json))
|
||
return null;
|
||
|
||
try
|
||
{
|
||
return JsonSerializer.Deserialize<PlanningRules>(json, Options);
|
||
}
|
||
catch (JsonException)
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
public sealed record RepeatLimitRule(int WindowDays, int Max);
|