Enhance channel management: add support for preferred weight multipliers and preferred hours in channel shows, update related data models and API endpoints, and implement validation for new properties. Refactor scheduling logic to utilize preferred hours for weight adjustments during show planning.

This commit is contained in:
Leonid Pershin
2026-07-25 18:44:28 +03:00
parent 358adbff21
commit 8718e8b6bf
36 changed files with 2921 additions and 97 deletions
@@ -209,7 +209,9 @@ public static class ChannelEndpoints
body.Weight, body.Weight,
body.BlockMode, body.BlockMode,
body.BlockValue, body.BlockValue,
body.IsEnabled body.IsEnabled,
body.PreferredWeightMultiplier,
body.PreferredHours ?? []
), ),
cancellationToken cancellationToken
); );
@@ -401,7 +403,8 @@ public static class ChannelEndpoints
body.NextLabel, body.NextLabel,
body.Line1, body.Line1,
body.Line2, body.Line2,
body.Trigger body.Trigger,
body.Weight
), ),
cancellationToken cancellationToken
); );
@@ -604,7 +607,9 @@ public sealed record UpdateChannelShowBody(
int Weight, int Weight,
BlockMode BlockMode, BlockMode BlockMode,
int BlockValue, int BlockValue,
bool IsEnabled bool IsEnabled,
int PreferredWeightMultiplier,
IReadOnlyList<HourWindowInput> PreferredHours
); );
public sealed record AddChannelAdBody(Guid MediaAssetId); public sealed record AddChannelAdBody(Guid MediaAssetId);
@@ -622,7 +627,8 @@ public sealed record UpdateBumperVariantBody(
string NextLabel, string NextLabel,
string Line1, string Line1,
string Line2, string Line2,
BumperTrigger Trigger BumperTrigger Trigger,
int Weight
); );
public sealed record UpdateBumperTemplateBody( public sealed record UpdateBumperTemplateBody(
@@ -4,7 +4,7 @@ using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers; namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Обновить подблок: имя, режим текста, текст и правило показа.</summary> /// <summary>Обновить подблок: имя, режим текста, текст, правило показа и вес.</summary>
public sealed record UpdateBumperTextVariantCommand( public sealed record UpdateBumperTextVariantCommand(
Guid ChannelId, Guid ChannelId,
Guid TemplateId, Guid TemplateId,
@@ -15,5 +15,6 @@ public sealed record UpdateBumperTextVariantCommand(
string NextLabel, string NextLabel,
string Line1, string Line1,
string Line2, string Line2,
BumperTrigger Trigger BumperTrigger Trigger,
int Weight
) : ICommand<Result>; ) : ICommand<Result>;
@@ -35,7 +35,8 @@ public sealed class UpdateBumperTextVariantCommandHandler(IAppDbContext dbContex
command.NextLabel, command.NextLabel,
command.Line1, command.Line1,
command.Line2, command.Line2,
command.Trigger command.Trigger,
command.Weight
); );
return Result.Success(); return Result.Success();
} }
@@ -12,5 +12,6 @@ public sealed class UpdateBumperTextVariantCommandValidator
RuleFor(x => x.NextLabel).MaximumLength(64); RuleFor(x => x.NextLabel).MaximumLength(64);
RuleFor(x => x.Line1).MaximumLength(120); RuleFor(x => x.Line1).MaximumLength(120);
RuleFor(x => x.Line2).MaximumLength(120); RuleFor(x => x.Line2).MaximumLength(120);
RuleFor(x => x.Weight).InclusiveBetween(0, 1000);
} }
} }
@@ -12,9 +12,14 @@ public sealed record ChannelShowDto(
BlockMode BlockMode, BlockMode BlockMode,
int BlockValue, int BlockValue,
bool IsEnabled, bool IsEnabled,
int NextEpisodeIndex int NextEpisodeIndex,
int PreferredWeightMultiplier,
IReadOnlyList<HourWindowDto> PreferredHours
); );
/// <summary>Окно предпочтительных часов [StartHour, EndHour) суток (UTC).</summary>
public sealed record HourWindowDto(int StartHour, int EndHour);
public sealed record ChannelAdDto(Guid Id, Guid MediaAssetId, string? AssetName, int Position); public sealed record ChannelAdDto(Guid Id, Guid MediaAssetId, string? AssetName, int Position);
public sealed record OverrideShowDto(Guid ShowId, string ShowName, int Weight); public sealed record OverrideShowDto(Guid ShowId, string ShowName, int Weight);
@@ -31,10 +36,12 @@ public sealed record ProgrammingOverrideDto(
public sealed record BumperSettingsDto( public sealed record BumperSettingsDto(
BumperFont Font, BumperFont Font,
int MinIntervalMinutes, int MinIntervalMinutes,
BumperSelection Selection BumperSelection Selection,
double ShowChangeChance,
double EpisodeChangeChance
); );
/// <summary>Подблок (текст-вариант): свой текст + правило показа поверх стиля/звука блока.</summary> /// <summary>Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.</summary>
public sealed record BumperTextVariantDto( public sealed record BumperTextVariantDto(
Guid Id, Guid Id,
int Position, int Position,
@@ -44,7 +51,8 @@ public sealed record BumperTextVariantDto(
string NextLabel, string NextLabel,
string Line1, string Line1,
string Line2, string Line2,
BumperTrigger Trigger BumperTrigger Trigger,
int Weight
); );
/// <summary>Блок заставки: своё оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary> /// <summary>Блок заставки: своё оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
@@ -15,6 +15,7 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
{ {
var channel = await dbContext.Channels.AsNoTracking() var channel = await dbContext.Channels.AsNoTracking()
.Include(c => c.Shows) .Include(c => c.Shows)
.ThenInclude(s => s.PreferredHours)
.Include(c => c.Ads) .Include(c => c.Ads)
.Include(c => c.BumperTemplates) .Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants) .ThenInclude(t => t.Variants)
@@ -51,7 +52,12 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
s.BlockMode, s.BlockMode,
s.BlockValue, s.BlockValue,
s.IsEnabled, s.IsEnabled,
s.NextEpisodeIndex s.NextEpisodeIndex,
s.PreferredWeightMultiplier,
s.PreferredHours
.OrderBy(h => h.StartHour)
.Select(h => new HourWindowDto(h.StartHour, h.EndHour))
.ToList()
)) ))
.ToList(); .ToList();
@@ -90,7 +96,8 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
v.NextLabel, v.NextLabel,
v.Line1, v.Line1,
v.Line2, v.Line2,
v.Trigger v.Trigger,
v.Weight
)) ))
.ToList() .ToList()
)) ))
@@ -121,7 +128,9 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
new BumperSettingsDto( new BumperSettingsDto(
channel.BumperFont, channel.BumperFont,
channel.BumperMinIntervalMinutes, channel.BumperMinIntervalMinutes,
channel.BumperSelection channel.BumperSelection,
channel.BumperShowChangeChance,
channel.BumperEpisodeChangeChance
), ),
bumperTemplates, bumperTemplates,
channel.FillerAssetId, channel.FillerAssetId,
@@ -37,6 +37,19 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
.Select(s => new { s.Id, s.Name }) .Select(s => new { s.Id, s.Name })
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
// Подблоки заставок в окне — чтобы показать в расписании, какая именно заставка и с каким текстом.
var variantIds = entries
.Where(e => e.Kind == Domain.Broadcast.ScheduleEntryKind.Bumper && e.BumperVariantId != null)
.Select(e => e.BumperVariantId!.Value)
.Distinct()
.ToList();
var variants = variantIds.Count == 0
? []
: await dbContext.BumperTextVariants.AsNoTracking()
.Where(v => variantIds.Contains(v.Id))
.ToListAsync(cancellationToken);
var variantsById = variants.ToDictionary(v => v.Id);
// Имена ассетов программ — чтобы показать реальную метку S16E03 в расписании админки. // Имена ассетов программ — чтобы показать реальную метку S16E03 в расписании админки.
var assetIds = entries var assetIds = entries
.Where(e => e.Kind == Domain.Broadcast.ScheduleEntryKind.Program) .Where(e => e.Kind == Domain.Broadcast.ScheduleEntryKind.Program)
@@ -48,8 +61,25 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
.Select(a => new { a.Id, a.OriginalFileName }) .Select(a => new { a.Id, a.OriginalFileName })
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken); .ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
var dtos = entries // «Из какого шоу» для заставки берём из ближайшей предыдущей программы в упорядоченном окне.
.Select(e => new ScheduleEntryDto( Guid? prevProgramShowId = null;
var dtos = new List<ScheduleEntryDto>(entries.Count);
foreach (var e in entries)
{
string? bumperName = null;
string? bumperText = null;
if (
e.Kind == Domain.Broadcast.ScheduleEntryKind.Bumper
&& e.BumperVariantId is { } vid
&& variantsById.TryGetValue(vid, out var variant)
)
{
bumperName = variant.Name;
bumperText = BumperText(variant, prevProgramShowId, e.ShowId, showNames);
}
dtos.Add(
new ScheduleEntryDto(
e.Id, e.Id,
e.Kind, e.Kind,
e.MediaAssetId, e.MediaAssetId,
@@ -58,10 +88,39 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
e.ShowId, e.ShowId,
e.ShowId is { } sid ? showNames.GetValueOrDefault(sid) : null, e.ShowId is { } sid ? showNames.GetValueOrDefault(sid) : null,
e.EpisodeIndex, e.EpisodeIndex,
assetNames.TryGetValue(e.MediaAssetId, out var name) ? EpisodeName.ParseLabel(name) : null assetNames.TryGetValue(e.MediaAssetId, out var name) ? EpisodeName.ParseLabel(name) : null,
)) bumperName,
.ToList(); bumperText
)
);
if (e.Kind == Domain.Broadcast.ScheduleEntryKind.Program)
prevProgramShowId = e.ShowId;
}
return Result.Success<IReadOnlyList<ScheduleEntryDto>>(dtos); return Result.Success<IReadOnlyList<ScheduleEntryDto>>(dtos);
} }
/// <summary>
/// Текст заставки для метки в расписании: для «Сейчас/Далее» — подписи + названия шоу (из→в),
/// для свободного текста — заданные строки. Возвращает null, если показывать нечего.
/// </summary>
private static string? BumperText(
Domain.Broadcast.BumperTextVariant variant,
Guid? fromShowId,
Guid? toShowId,
IReadOnlyDictionary<Guid, string> showNames
)
{
if (variant.Kind == Domain.Broadcast.BumperTextKind.Free)
{
var parts = new[] { variant.Line1, variant.Line2 }
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToArray();
return parts.Length == 0 ? null : string.Join(" · ", parts);
}
string Name(Guid? id) => id is { } g ? showNames.GetValueOrDefault(g, "…") : "…";
return $"{variant.NowLabel} {Name(fromShowId)} · {variant.NextLabel} {Name(toShowId)}";
}
} }
@@ -11,5 +11,8 @@ public sealed record ScheduleEntryDto(
Guid? ShowId, Guid? ShowId,
string? ShowName, string? ShowName,
int? EpisodeIndex, int? EpisodeIndex,
string? SeasonEpisode string? SeasonEpisode,
// Для заставок (Kind == Bumper): имя подблока и его текст — для метки в админ-расписании.
string? BumperName = null,
string? BumperText = null
); );
@@ -51,6 +51,7 @@ public sealed class ScheduleGenerator(
{ {
var channel = await dbContext.Channels var channel = await dbContext.Channels
.Include(c => c.Shows) .Include(c => c.Shows)
.ThenInclude(s => s.PreferredHours)
.Include(c => c.Ads) .Include(c => c.Ads)
.Include(c => c.BumperTemplates) .Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants) .ThenInclude(t => t.Variants)
@@ -161,7 +162,14 @@ public sealed class ScheduleGenerator(
) )
return null; return null;
return ScheduleEntry.Bumper(channelId, assetId, entry.StartsAtUtc, entry.EndsAtUtc, entry.ToShowId); return ScheduleEntry.Bumper(
channelId,
assetId,
entry.StartsAtUtc,
entry.EndsAtUtc,
entry.ToShowId,
entry.BumperVariantId
);
} }
/// <summary> /// <summary>
@@ -525,7 +533,11 @@ public sealed class ScheduleGenerator(
channelShow.BlockMode, channelShow.BlockMode,
channelShow.BlockValue, channelShow.BlockValue,
ready, ready,
channelShow.NextEpisodeIndex channelShow.NextEpisodeIndex,
channelShow.PreferredHours
.Select(h => new PlannerHourWindow(h.StartHour, h.EndHour))
.ToList(),
channelShow.PreferredWeightMultiplier
) )
); );
} }
@@ -544,7 +556,7 @@ public sealed class ScheduleGenerator(
var dur = TimeSpan.FromSeconds(AlignedDurationSeconds(TemplateDurationSeconds(t))); var dur = TimeSpan.FromSeconds(AlignedDurationSeconds(TemplateDurationSeconds(t)));
return t.Variants return t.Variants
.OrderBy(v => v.Position) .OrderBy(v => v.Position)
.Select(v => new PlannerBumperVariant(v.Id, t.Id, dur, v.Trigger)); .Select(v => new PlannerBumperVariant(v.Id, t.Id, dur, v.Trigger, v.Weight));
}) })
.ToList(); .ToList();
@@ -561,7 +573,9 @@ public sealed class ScheduleGenerator(
channel.BumpersEnabled, channel.BumpersEnabled,
TimeSpan.FromMinutes(channel.BumperMinIntervalMinutes), TimeSpan.FromMinutes(channel.BumperMinIntervalMinutes),
channel.BumperSelection, channel.BumperSelection,
bumperVariants bumperVariants,
channel.BumperShowChangeChance,
channel.BumperEpisodeChangeChance
); );
return new PlannerInput( return new PlannerInput(
@@ -19,5 +19,7 @@ public sealed record UpdateChannelSettingsCommand(
public sealed record BumperSettingsInput( public sealed record BumperSettingsInput(
BumperFont Font, BumperFont Font,
int MinIntervalMinutes, int MinIntervalMinutes,
BumperSelection Selection BumperSelection Selection,
double ShowChangeChance,
double EpisodeChangeChance
); );
@@ -38,7 +38,9 @@ public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
channel.UpdateBumperSettings( channel.UpdateBumperSettings(
command.Bumper.Font, command.Bumper.Font,
command.Bumper.MinIntervalMinutes, command.Bumper.MinIntervalMinutes,
command.Bumper.Selection command.Bumper.Selection,
command.Bumper.ShowChangeChance,
command.Bumper.EpisodeChangeChance
); );
return Result.Success(); return Result.Success();
} }
@@ -11,5 +11,7 @@ public sealed class UpdateChannelSettingsCommandValidator
RuleFor(x => x.AdsPerBreak).InclusiveBetween(0, 10); RuleFor(x => x.AdsPerBreak).InclusiveBetween(0, 10);
RuleFor(x => x.Bumper.MinIntervalMinutes).InclusiveBetween(0, 1440); RuleFor(x => x.Bumper.MinIntervalMinutes).InclusiveBetween(0, 1440);
RuleFor(x => x.Bumper.ShowChangeChance).InclusiveBetween(0.0, 1.0);
RuleFor(x => x.Bumper.EpisodeChangeChance).InclusiveBetween(0.0, 1.0);
} }
} }
@@ -10,5 +10,10 @@ public sealed record UpdateChannelShowCommand(
int Weight, int Weight,
BlockMode BlockMode, BlockMode BlockMode,
int BlockValue, int BlockValue,
bool IsEnabled bool IsEnabled,
int PreferredWeightMultiplier,
IReadOnlyList<HourWindowInput> PreferredHours
) : ICommand<Result>; ) : ICommand<Result>;
/// <summary>Окно предпочтительных часов [StartHour, EndHour) суток (UTC).</summary>
public sealed record HourWindowInput(int StartHour, int EndHour);
@@ -15,6 +15,7 @@ public sealed class UpdateChannelShowCommandHandler(IAppDbContext dbContext)
{ {
var channel = await dbContext.Channels var channel = await dbContext.Channels
.Include(c => c.Shows) .Include(c => c.Shows)
.ThenInclude(s => s.PreferredHours)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null) if (channel is null)
return Result.Failure(ChannelErrors.NotFound); return Result.Failure(ChannelErrors.NotFound);
@@ -24,6 +25,10 @@ public sealed class UpdateChannelShowCommandHandler(IAppDbContext dbContext)
return Result.Failure(ChannelErrors.ChannelShowNotFound); return Result.Failure(ChannelErrors.ChannelShowNotFound);
channelShow.Update(command.Weight, command.BlockMode, command.BlockValue, command.IsEnabled); channelShow.Update(command.Weight, command.BlockMode, command.BlockValue, command.IsEnabled);
channelShow.SetPreferredHours(
command.PreferredWeightMultiplier,
command.PreferredHours.Select(h => (h.StartHour, h.EndHour))
);
return Result.Success(); return Result.Success();
} }
} }
@@ -8,5 +8,13 @@ public sealed class UpdateChannelShowCommandValidator : AbstractValidator<Update
{ {
RuleFor(x => x.Weight).InclusiveBetween(1, 1000); RuleFor(x => x.Weight).InclusiveBetween(1, 1000);
RuleFor(x => x.BlockValue).InclusiveBetween(1, 10000); RuleFor(x => x.BlockValue).InclusiveBetween(1, 10000);
RuleFor(x => x.PreferredWeightMultiplier).InclusiveBetween(1, 100);
RuleForEach(x => x.PreferredHours).ChildRules(w =>
{
w.RuleFor(h => h.StartHour).InclusiveBetween(0, 23);
w.RuleFor(h => h.EndHour).InclusiveBetween(1, 24);
w.RuleFor(h => h).Must(h => h.StartHour < h.EndHour)
.WithMessage("Начало окна должно быть раньше конца.");
});
} }
} }
@@ -15,6 +15,7 @@ public interface IAppDbContext
DbSet<Show> Shows { get; } DbSet<Show> Shows { get; }
DbSet<Channel> Channels { get; } DbSet<Channel> Channels { get; }
DbSet<ScheduleEntry> ScheduleEntries { get; } DbSet<ScheduleEntry> ScheduleEntries { get; }
DbSet<BumperTextVariant> BumperTextVariants { get; }
DbSet<BumperAsset> BumperAssets { get; } DbSet<BumperAsset> BumperAssets { get; }
DbSet<AppSetting> AppSettings { get; } DbSet<AppSetting> AppSettings { get; }
DbSet<Image> Images { get; } DbSet<Image> Images { get; }
@@ -6,9 +6,12 @@ public enum BumperSelection
/// <summary>По кругу в порядке блоков (курсор <see cref="Channel.NextBumperIndex"/>).</summary> /// <summary>По кругу в порядке блоков (курсор <see cref="Channel.NextBumperIndex"/>).</summary>
Rotation, Rotation,
/// <summary>Случайный блок на каждом переходе.</summary> /// <summary>Случайный блок на каждом переходе (равновероятно).</summary>
Random, Random,
/// <summary>Всегда первый (дефолтный) блок.</summary> /// <summary>Всегда первый (дефолтный) блок.</summary>
AlwaysFirst, AlwaysFirst,
/// <summary>Случайный блок с учётом веса подблока (<see cref="BumperTextVariant.Weight"/>).</summary>
WeightedRandom,
} }
@@ -24,8 +24,13 @@ public class BumperTextVariant
public BumperTrigger Trigger { get; private set; } public BumperTrigger Trigger { get; private set; }
/// <summary>Вес при стратегии <see cref="BumperSelection.WeightedRandom"/> (0 — не выбирается). Иначе игнорируется.</summary>
public int Weight { get; private set; } = DefaultWeight;
public DateTimeOffset CreatedAt { get; private set; } public DateTimeOffset CreatedAt { get; private set; }
public const int DefaultWeight = 1;
public const string DefaultNowLabel = "СЕЙЧАС"; public const string DefaultNowLabel = "СЕЙЧАС";
public const string DefaultNextLabel = "ДАЛЕЕ"; public const string DefaultNextLabel = "ДАЛЕЕ";
@@ -49,6 +54,7 @@ public class BumperTextVariant
Line1 = string.Empty, Line1 = string.Empty,
Line2 = string.Empty, Line2 = string.Empty,
Trigger = trigger, Trigger = trigger,
Weight = DefaultWeight,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
}; };
@@ -59,7 +65,8 @@ public class BumperTextVariant
string nextLabel, string nextLabel,
string line1, string line1,
string line2, string line2,
BumperTrigger trigger BumperTrigger trigger,
int weight
) )
{ {
Name = name; Name = name;
@@ -69,6 +76,7 @@ public class BumperTextVariant
Line1 = line1; Line1 = line1;
Line2 = line2; Line2 = line2;
Trigger = trigger; Trigger = trigger;
Weight = Math.Max(0, weight);
} }
/// <summary>Подходит ли подблок для перехода: <paramref name="isShowChange"/> — сменилось ли шоу.</summary> /// <summary>Подходит ли подблок для перехода: <paramref name="isShowChange"/> — сменилось ли шоу.</summary>
@@ -39,6 +39,12 @@ public class Channel
/// <summary>Не вставлять заставку чаще, чем раз в N минут (0 — на каждом подходящем переходе).</summary> /// <summary>Не вставлять заставку чаще, чем раз в N минут (0 — на каждом подходящем переходе).</summary>
public int BumperMinIntervalMinutes { get; private set; } public int BumperMinIntervalMinutes { get; private set; }
/// <summary>Вероятность заставки на смене шоу (0..1; 1 — на каждой смене, 0 — никогда).</summary>
public double BumperShowChangeChance { get; private set; } = 1.0;
/// <summary>Вероятность заставки между блоками одного шоу (0..1; напр. 0.3 — примерно в 30% случаев).</summary>
public double BumperEpisodeChangeChance { get; private set; } = 1.0;
private const string DefaultTemplateName = "Заставка 1"; private const string DefaultTemplateName = "Заставка 1";
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary> /// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
@@ -76,6 +82,8 @@ public class Channel
NextBumperIndex = 0, NextBumperIndex = 0,
BumperFont = BumperFont.Sans, BumperFont = BumperFont.Sans,
BumperMinIntervalMinutes = 0, BumperMinIntervalMinutes = 0,
BumperShowChangeChance = 1.0,
BumperEpisodeChangeChance = 1.0,
NextAdIndex = 0, NextAdIndex = 0,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
}; };
@@ -101,16 +109,23 @@ public class Channel
FillerAssetId = fillerAssetId; FillerAssetId = fillerAssetId;
} }
/// <summary>Общие настройки ТВ-заставок канала: шрифт, мин. интервал и стратегия выбора подблока.</summary> /// <summary>
/// Общие настройки ТВ-заставок канала: шрифт, мин. интервал, стратегия выбора подблока и
/// вероятности появления на смене шоу / между блоками одного шоу (0..1).
/// </summary>
public void UpdateBumperSettings( public void UpdateBumperSettings(
BumperFont font, BumperFont font,
int minIntervalMinutes, int minIntervalMinutes,
BumperSelection selection BumperSelection selection,
double showChangeChance,
double episodeChangeChance
) )
{ {
BumperFont = font; BumperFont = font;
BumperMinIntervalMinutes = Math.Max(0, minIntervalMinutes); BumperMinIntervalMinutes = Math.Max(0, minIntervalMinutes);
BumperSelection = selection; BumperSelection = selection;
BumperShowChangeChance = Math.Clamp(showChangeChance, 0.0, 1.0);
BumperEpisodeChangeChance = Math.Clamp(episodeChangeChance, 0.0, 1.0);
} }
/// <summary>Добавить блок заставки в конец списка. Возвращает созданный блок.</summary> /// <summary>Добавить блок заставки в конец списка. Возвращает созданный блок.</summary>
@@ -7,6 +7,8 @@ namespace TeleWave.Domain.Broadcast;
/// </summary> /// </summary>
public class ChannelShow public class ChannelShow
{ {
private readonly List<ChannelShowHour> _preferredHours = new();
public Guid Id { get; private set; } public Guid Id { get; private set; }
public Guid ChannelId { get; private set; } public Guid ChannelId { get; private set; }
public Guid ShowId { get; private set; } public Guid ShowId { get; private set; }
@@ -21,6 +23,14 @@ public class ChannelShow
/// <summary>Индекс следующей серии для этого канала (0-based в упорядоченном списке серий шоу).</summary> /// <summary>Индекс следующей серии для этого канала (0-based в упорядоченном списке серий шоу).</summary>
public int NextEpisodeIndex { get; private set; } public int NextEpisodeIndex { get; private set; }
/// <summary>Во сколько раз усиливать вес шоу в предпочтительные часы (1 — без буста).</summary>
public int PreferredWeightMultiplier { get; private set; } = DefaultPreferredWeightMultiplier;
/// <summary>Окна предпочтительных часов (пусто — шоу без предпочтений, вес не меняется).</summary>
public IReadOnlyList<ChannelShowHour> PreferredHours => _preferredHours;
public const int DefaultPreferredWeightMultiplier = 3;
private ChannelShow() { } private ChannelShow() { }
internal static ChannelShow Create( internal static ChannelShow Create(
@@ -40,6 +50,7 @@ public class ChannelShow
BlockValue = blockValue, BlockValue = blockValue,
IsEnabled = true, IsEnabled = true,
NextEpisodeIndex = 0, NextEpisodeIndex = 0,
PreferredWeightMultiplier = DefaultPreferredWeightMultiplier,
}; };
public void Update(int weight, BlockMode blockMode, int blockValue, bool isEnabled) public void Update(int weight, BlockMode blockMode, int blockValue, bool isEnabled)
@@ -50,6 +61,25 @@ public class ChannelShow
IsEnabled = isEnabled; IsEnabled = isEnabled;
} }
/// <summary>
/// Задаёт множитель веса и полностью заменяет набор окон предпочтительных часов. Окна нормализуются:
/// отбрасываются некорректные ([0,24], start &lt; end), совпадающие схлопываются.
/// </summary>
public void SetPreferredHours(int multiplier, IEnumerable<(int StartHour, int EndHour)> windows)
{
PreferredWeightMultiplier = Math.Max(1, multiplier);
_preferredHours.Clear();
foreach (var (start, end) in windows
.Where(w => w.StartHour >= 0 && w.EndHour <= 24 && w.StartHour < w.EndHour)
.Distinct())
{
_preferredHours.Add(ChannelShowHour.Create(Id, start, end));
}
}
/// <summary>Час суток (0..23) попадает в одно из окон предпочтительных часов.</summary>
public bool IsPreferredAt(int hour) => _preferredHours.Any(w => w.Contains(hour));
/// <summary>Планировщик двигает курсор по мере постановки серий в расписание.</summary> /// <summary>Планировщик двигает курсор по мере постановки серий в расписание.</summary>
public void SetNextEpisodeIndex(int index) => NextEpisodeIndex = index; public void SetNextEpisodeIndex(int index) => NextEpisodeIndex = index;
} }
@@ -0,0 +1,32 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Окно предпочтительных часов для шоу на канале: полуинтервал часов суток [StartHour, EndHour)
/// в UTC. В эти часы вес шоу в ротации умножается на <see cref="ChannelShow.PreferredWeightMultiplier"/>.
/// Ночные окна задаются двумя записями (напр. 22–24 и 0–2), заворот через полночь не поддерживается.
/// </summary>
public class ChannelShowHour
{
public Guid Id { get; private set; }
public Guid ChannelShowId { get; private set; }
/// <summary>Начало окна — час суток (0..23).</summary>
public int StartHour { get; private set; }
/// <summary>Конец окна (исключительно) — час суток (1..24).</summary>
public int EndHour { get; private set; }
private ChannelShowHour() { }
internal static ChannelShowHour Create(Guid channelShowId, int startHour, int endHour) =>
new()
{
Id = Guid.NewGuid(),
ChannelShowId = channelShowId,
StartHour = startHour,
EndHour = endHour,
};
/// <summary>Попадает ли час суток (0..23) в это окно.</summary>
public bool Contains(int hour) => hour >= StartHour && hour < EndHour;
}
@@ -19,6 +19,9 @@ public class ScheduleEntry
/// <summary>Индекс серии в упорядоченном списке шоу (для EPG).</summary> /// <summary>Индекс серии в упорядоченном списке шоу (для EPG).</summary>
public int? EpisodeIndex { get; private set; } public int? EpisodeIndex { get; private set; }
/// <summary>Подблок заставки (<see cref="BumperTextVariant"/>), которым отрендерена запись — для метки в админ-расписании.</summary>
public Guid? BumperVariantId { get; private set; }
private ScheduleEntry() { } private ScheduleEntry() { }
public static ScheduleEntry Program( public static ScheduleEntry Program(
@@ -63,7 +66,8 @@ public class ScheduleEntry
Guid mediaAssetId, Guid mediaAssetId,
DateTimeOffset startsAtUtc, DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc, DateTimeOffset endsAtUtc,
Guid? showId Guid? showId,
Guid? bumperVariantId
) => ) =>
new() new()
{ {
@@ -74,5 +78,6 @@ public class ScheduleEntry
StartsAtUtc = startsAtUtc, StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc, EndsAtUtc = endsAtUtc,
ShowId = showId, ShowId = showId,
BumperVariantId = bumperVariantId,
}; };
} }
@@ -40,7 +40,8 @@ public static class SchedulePlanner
var pick = WeightedPick(candidates, random); var pick = WeightedPick(candidates, random);
// ТВ-заставка на переходе. Из подходящих подблоков (по правилу показа vs контексту) // ТВ-заставка на переходе. Из подходящих подблоков (по правилу показа vs контексту)
// резервируем слот выбранного блока — ассет подставит оркестратор. // резервируем слот выбранного блока — ассет подставит оркестратор. Само появление
// ограничено мин. интервалом и вероятностью для типа перехода (смена шоу / между блоками).
if ( if (
prevShowId is { } prev prevShowId is { } prev
&& input.Bumpers is { Enabled: true } bumper && input.Bumpers is { Enabled: true } bumper
@@ -50,11 +51,16 @@ public static class SchedulePlanner
|| cursor - last >= bumper.MinInterval || cursor - last >= bumper.MinInterval
) )
) )
{
var isShowChange = prev != pick.ShowId;
var chance = isShowChange ? bumper.ShowChangeChance : bumper.EpisodeChangeChance;
if (RollChance(chance, random))
{ {
var bumperStart = cursor; var bumperStart = cursor;
if (TryPlaceBumper(entries, bumper, prev, pick.ShowId, random, ref nextBumper, ref cursor)) if (TryPlaceBumper(entries, bumper, prev, pick.ShowId, random, ref nextBumper, ref cursor))
lastBumperAt = bumperStart; lastBumperAt = bumperStart;
} }
}
var blockStart = cursor; var blockStart = cursor;
@@ -121,6 +127,9 @@ public static class SchedulePlanner
case BumperSelection.Random: case BumperSelection.Random:
variant = eligible[random.Next(eligible.Count)]; variant = eligible[random.Next(eligible.Count)];
break; break;
case BumperSelection.WeightedRandom:
variant = WeightedPickVariant(eligible, random);
break;
case BumperSelection.AlwaysFirst: case BumperSelection.AlwaysFirst:
variant = eligible[0]; variant = eligible[0];
break; break;
@@ -158,6 +167,38 @@ public static class SchedulePlanner
_ => true, _ => true,
}; };
/// <summary>Прошла ли проверка вероятности появления (chance 0..1). 1 — всегда, 0 — никогда.</summary>
private static bool RollChance(double chance, IRandomSource random)
{
if (chance >= 1.0)
return true;
if (chance <= 0.0)
return false;
return random.Next(10000) < (int)Math.Round(chance * 10000);
}
/// <summary>Взвешенный случайный выбор подблока по <see cref="PlannerBumperVariant.Weight"/> (нулевые веса → равновероятно).</summary>
private static PlannerBumperVariant WeightedPickVariant(
List<PlannerBumperVariant> eligible,
IRandomSource random
)
{
var total = eligible.Sum(v => Math.Max(0, v.Weight));
if (total <= 0)
return eligible[random.Next(eligible.Count)];
var roll = random.Next(total);
var acc = 0;
foreach (var v in eligible)
{
acc += Math.Max(0, v.Weight);
if (roll < acc)
return v;
}
return eligible[^1];
}
private static List<(PlannerShow Show, int Weight)> ResolvePolicy( private static List<(PlannerShow Show, int Weight)> ResolvePolicy(
DateTimeOffset moment, DateTimeOffset moment,
PlannerInput input, PlannerInput input,
@@ -182,12 +223,25 @@ public static class SchedulePlanner
// Override ссылается на пустые/неготовые шоу — откатываемся к базовой ротации. // Override ссылается на пустые/неготовые шоу — откатываемся к базовой ротации.
} }
var hour = moment.UtcDateTime.Hour;
return input.Shows return input.Shows
.Where(s => s.Weight > 0 && s.EpisodeAssetIds.Count > 0) .Where(s => s.Weight > 0 && s.EpisodeAssetIds.Count > 0)
.Select(s => (s, s.Weight)) .Select(s => (s, EffectiveWeight(s, hour)))
.ToList(); .ToList();
} }
/// <summary>Вес шоу с учётом предпочтительных часов: в окне — усиливается множителем, иначе базовый.</summary>
private static int EffectiveWeight(PlannerShow show, int hour)
{
if (
show.PreferredWeightMultiplier > 1
&& show.PreferredHours is { Count: > 0 } windows
&& windows.Any(w => w.Contains(hour))
)
return show.Weight * show.PreferredWeightMultiplier;
return show.Weight;
}
private static PlannerShow WeightedPick( private static PlannerShow WeightedPick(
List<(PlannerShow Show, int Weight)> candidates, List<(PlannerShow Show, int Weight)> candidates,
IRandomSource random IRandomSource random
@@ -8,9 +8,17 @@ public sealed record PlannerShow(
BlockMode BlockMode, BlockMode BlockMode,
int BlockValue, int BlockValue,
IReadOnlyList<Guid> EpisodeAssetIds, IReadOnlyList<Guid> EpisodeAssetIds,
int NextEpisodeIndex int NextEpisodeIndex,
IReadOnlyList<PlannerHourWindow>? PreferredHours = null,
int PreferredWeightMultiplier = 1
); );
/// <summary>Окно предпочтительных часов [StartHour, EndHour) суток (UTC) для планировщика.</summary>
public sealed record PlannerHourWindow(int StartHour, int EndHour)
{
public bool Contains(int hour) => hour >= StartHour && hour < EndHour;
}
/// <summary>Override в терминах планировщика: окно + режим + шоу с весами.</summary> /// <summary>Override в терминах планировщика: окно + режим + шоу с весами.</summary>
public sealed record PlannerOverride( public sealed record PlannerOverride(
DateTimeOffset StartsAtUtc, DateTimeOffset StartsAtUtc,
@@ -25,23 +33,28 @@ public sealed record PlannerOverrideShow(Guid ShowId, int Weight);
/// Политика ТВ-заставок на переходах. Планировщик из подходящих подблоков (<see cref="Variants"/>, /// Политика ТВ-заставок на переходах. Планировщик из подходящих подблоков (<see cref="Variants"/>,
/// фильтр по <see cref="PlannerBumperVariant.Trigger"/> и контексту перехода) выбирает один по стратегии /// фильтр по <see cref="PlannerBumperVariant.Trigger"/> и контексту перехода) выбирает один по стратегии
/// <see cref="Selection"/> и резервирует слот длины его блока. Ассет подставляет оркестратор. /// <see cref="Selection"/> и резервирует слот длины его блока. Ассет подставляет оркестратор.
/// <see cref="ShowChangeChance"/>/<see cref="EpisodeChangeChance"/> — вероятность самого появления
/// заставки на смене шоу / между блоками одного шоу (0..1).
/// </summary> /// </summary>
public sealed record PlannerBumperConfig( public sealed record PlannerBumperConfig(
bool Enabled, bool Enabled,
TimeSpan MinInterval, TimeSpan MinInterval,
BumperSelection Selection, BumperSelection Selection,
IReadOnlyList<PlannerBumperVariant> Variants IReadOnlyList<PlannerBumperVariant> Variants,
double ShowChangeChance = 1.0,
double EpisodeChangeChance = 1.0
); );
/// <summary> /// <summary>
/// Подблок заставки в терминах планировщика: id варианта + id родительского блока (стиль/звук) + /// Подблок заставки в терминах планировщика: id варианта + id родительского блока (стиль/звук) +
/// длительность слота (кратна сегменту) + правило показа. /// длительность слота (кратна сегменту) + правило показа + вес (для <see cref="BumperSelection.WeightedRandom"/>).
/// </summary> /// </summary>
public sealed record PlannerBumperVariant( public sealed record PlannerBumperVariant(
Guid VariantId, Guid VariantId,
Guid TemplateId, Guid TemplateId,
TimeSpan Duration, TimeSpan Duration,
BumperTrigger Trigger BumperTrigger Trigger,
int Weight = 1
); );
/// <summary>Полный вход планировщика для одного прогона по каналу.</summary> /// <summary>Полный вход планировщика для одного прогона по каналу.</summary>
@@ -0,0 +1,956 @@
// <auto-generated />
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("20260725105210_BumperChancesWeightsAndScheduleVariant")]
partial class BumperChancesWeightsAndScheduleVariant
{
/// <inheritdoc />
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<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReplacedByTokenHash")
.HasColumnType("text");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FromShowId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<string>("Signature")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("ToShowId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("FromShowId", "ToShowId", "Signature");
b.ToTable("BumperAssets");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("AccentColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<double?>("AudioDurationSeconds")
.HasColumnType("double precision");
b.Property<string>("AudioExtension")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("BackgroundColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("BackgroundColor2")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid?>("BackgroundImageId")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("Revision")
.HasColumnType("integer");
b.Property<string>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("BumperTemplateId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<string>("Line1")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Line2")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("NextLabel")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("NowLabel")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("Trigger")
.HasColumnType("integer");
b.Property<int>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("AdInsertion")
.HasColumnType("integer");
b.Property<int>("AdsPerBreak")
.HasColumnType("integer");
b.Property<double>("BumperEpisodeChangeChance")
.HasColumnType("double precision");
b.Property<int>("BumperFont")
.HasColumnType("integer");
b.Property<int>("BumperMinIntervalMinutes")
.HasColumnType("integer");
b.Property<int>("BumperSelection")
.HasColumnType("integer");
b.Property<double>("BumperShowChangeChance")
.HasColumnType("double precision");
b.Property<bool>("BumpersEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("EpochUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("FillerAssetId")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("NextAdIndex")
.HasColumnType("integer");
b.Property<int>("NextBumperIndex")
.HasColumnType("integer");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "Position");
b.ToTable("ChannelAd");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("BlockMode")
.HasColumnType("integer");
b.Property<int>("BlockValue")
.HasColumnType("integer");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<int>("NextEpisodeIndex")
.HasColumnType("integer");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "ShowId");
b.ToTable("ChannelShow");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ProgrammingOverrideId")
.HasColumnType("uuid");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ProgrammingOverrideId");
b.ToTable("OverrideShow");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Mode")
.HasColumnType("integer");
b.Property<DateTimeOffset>("StartsAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc");
b.ToTable("ProgrammingOverride");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid?>("BumperVariantId")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int?>("EpisodeIndex")
.HasColumnType("integer");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<Guid?>("ShowId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("StartsAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ChannelId", "EndsAtUtc");
b.HasIndex("ChannelId", "ShowId");
b.HasIndex("ChannelId", "StartsAtUtc");
b.ToTable("ScheduleEntries");
});
modelBuilder.Entity("TeleWave.Domain.Images.Image", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("Category")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FileExtension")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("OriginalFileName")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Id");
b.HasIndex("Category", "CreatedAt");
b.ToTable("Images");
});
modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<string>("MetadataExternalId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("MetadataProvider")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("OriginalName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("PosterImageId")
.HasColumnType("uuid");
b.Property<int?>("Year")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateOnly?>("AirDate")
.HasColumnType("date");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("Episode")
.HasColumnType("integer");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<string>("Overview")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int?>("Season")
.HasColumnType("integer");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<Guid?>("StillImageId")
.HasColumnType("uuid");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Id");
b.HasIndex("MediaAssetId");
b.HasIndex("ShowId", "Position");
b.ToTable("ShowEpisode");
});
modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("AudioCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<TimeSpan?>("Duration")
.HasColumnType("interval");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int?>("Height")
.HasColumnType("integer");
b.Property<string>("OriginalExtension")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("RelativePath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int?>("SegmentCount")
.HasColumnType("integer");
b.Property<int?>("SegmentSeconds")
.HasColumnType("integer");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("VideoCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int?>("Width")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Status");
b.ToTable("MediaAssets");
});
modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Key");
b.ToTable("AppSettings");
});
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsBlocked")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("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<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", 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<System.Guid>", 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.Broadcast.ChannelAd", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Ads")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Shows")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null)
.WithMany("Shows")
.HasForeignKey("ProgrammingOverrideId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Overrides")
.HasForeignKey("ChannelId")
.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.Broadcast.BumperTemplate", b =>
{
b.Navigation("Variants");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
{
b.Navigation("Ads");
b.Navigation("BumperTemplates");
b.Navigation("Overrides");
b.Navigation("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.Navigation("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
{
b.Navigation("Episodes");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,123 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class BumperChancesWeightsAndScheduleVariant : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId",
table: "BumperTextVariant");
migrationBuilder.DropPrimaryKey(
name: "PK_BumperTextVariant",
table: "BumperTextVariant");
migrationBuilder.RenameTable(
name: "BumperTextVariant",
newName: "BumperTextVariants");
migrationBuilder.RenameIndex(
name: "IX_BumperTextVariant_BumperTemplateId_Position",
table: "BumperTextVariants",
newName: "IX_BumperTextVariants_BumperTemplateId_Position");
migrationBuilder.AddColumn<Guid>(
name: "BumperVariantId",
table: "ScheduleEntries",
type: "uuid",
nullable: true);
// Существующим каналам — 1.0 (заставка на каждом подходящем переходе, как было до фичи).
migrationBuilder.AddColumn<double>(
name: "BumperEpisodeChangeChance",
table: "Channels",
type: "double precision",
nullable: false,
defaultValue: 1.0);
migrationBuilder.AddColumn<double>(
name: "BumperShowChangeChance",
table: "Channels",
type: "double precision",
nullable: false,
defaultValue: 1.0);
migrationBuilder.AddColumn<int>(
name: "Weight",
table: "BumperTextVariants",
type: "integer",
nullable: false,
defaultValue: 1);
migrationBuilder.AddPrimaryKey(
name: "PK_BumperTextVariants",
table: "BumperTextVariants",
column: "Id");
migrationBuilder.AddForeignKey(
name: "FK_BumperTextVariants_BumperTemplate_BumperTemplateId",
table: "BumperTextVariants",
column: "BumperTemplateId",
principalTable: "BumperTemplate",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_BumperTextVariants_BumperTemplate_BumperTemplateId",
table: "BumperTextVariants");
migrationBuilder.DropPrimaryKey(
name: "PK_BumperTextVariants",
table: "BumperTextVariants");
migrationBuilder.DropColumn(
name: "BumperVariantId",
table: "ScheduleEntries");
migrationBuilder.DropColumn(
name: "BumperEpisodeChangeChance",
table: "Channels");
migrationBuilder.DropColumn(
name: "BumperShowChangeChance",
table: "Channels");
migrationBuilder.DropColumn(
name: "Weight",
table: "BumperTextVariants");
migrationBuilder.RenameTable(
name: "BumperTextVariants",
newName: "BumperTextVariant");
migrationBuilder.RenameIndex(
name: "IX_BumperTextVariants_BumperTemplateId_Position",
table: "BumperTextVariant",
newName: "IX_BumperTextVariant_BumperTemplateId_Position");
migrationBuilder.AddPrimaryKey(
name: "PK_BumperTextVariant",
table: "BumperTextVariant",
column: "Id");
migrationBuilder.AddForeignKey(
name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId",
table: "BumperTextVariant",
column: "BumperTemplateId",
principalTable: "BumperTemplate",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}
@@ -0,0 +1,996 @@
// <auto-generated />
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("20260725154034_ChannelShowPreferredHours")]
partial class ChannelShowPreferredHours
{
/// <inheritdoc />
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<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReplacedByTokenHash")
.HasColumnType("text");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FromShowId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<string>("Signature")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("ToShowId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("FromShowId", "ToShowId", "Signature");
b.ToTable("BumperAssets");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("AccentColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<double?>("AudioDurationSeconds")
.HasColumnType("double precision");
b.Property<string>("AudioExtension")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("BackgroundColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("BackgroundColor2")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid?>("BackgroundImageId")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("Revision")
.HasColumnType("integer");
b.Property<string>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("BumperTemplateId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<string>("Line1")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Line2")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("NextLabel")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("NowLabel")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("Trigger")
.HasColumnType("integer");
b.Property<int>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("AdInsertion")
.HasColumnType("integer");
b.Property<int>("AdsPerBreak")
.HasColumnType("integer");
b.Property<double>("BumperEpisodeChangeChance")
.HasColumnType("double precision");
b.Property<int>("BumperFont")
.HasColumnType("integer");
b.Property<int>("BumperMinIntervalMinutes")
.HasColumnType("integer");
b.Property<int>("BumperSelection")
.HasColumnType("integer");
b.Property<double>("BumperShowChangeChance")
.HasColumnType("double precision");
b.Property<bool>("BumpersEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("EpochUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("FillerAssetId")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("NextAdIndex")
.HasColumnType("integer");
b.Property<int>("NextBumperIndex")
.HasColumnType("integer");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "Position");
b.ToTable("ChannelAd");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("BlockMode")
.HasColumnType("integer");
b.Property<int>("BlockValue")
.HasColumnType("integer");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<int>("NextEpisodeIndex")
.HasColumnType("integer");
b.Property<int>("PreferredWeightMultiplier")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(3);
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "ShowId");
b.ToTable("ChannelShow");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelShowId")
.HasColumnType("uuid");
b.Property<int>("EndHour")
.HasColumnType("integer");
b.Property<int>("StartHour")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelShowId");
b.ToTable("ChannelShowHour");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ProgrammingOverrideId")
.HasColumnType("uuid");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ProgrammingOverrideId");
b.ToTable("OverrideShow");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Mode")
.HasColumnType("integer");
b.Property<DateTimeOffset>("StartsAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc");
b.ToTable("ProgrammingOverride");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid?>("BumperVariantId")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int?>("EpisodeIndex")
.HasColumnType("integer");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<Guid?>("ShowId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("StartsAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ChannelId", "EndsAtUtc");
b.HasIndex("ChannelId", "ShowId");
b.HasIndex("ChannelId", "StartsAtUtc");
b.ToTable("ScheduleEntries");
});
modelBuilder.Entity("TeleWave.Domain.Images.Image", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("Category")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FileExtension")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("OriginalFileName")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Id");
b.HasIndex("Category", "CreatedAt");
b.ToTable("Images");
});
modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<string>("MetadataExternalId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("MetadataProvider")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("OriginalName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("PosterImageId")
.HasColumnType("uuid");
b.Property<int?>("Year")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateOnly?>("AirDate")
.HasColumnType("date");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("Episode")
.HasColumnType("integer");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<string>("Overview")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int?>("Season")
.HasColumnType("integer");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<Guid?>("StillImageId")
.HasColumnType("uuid");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Id");
b.HasIndex("MediaAssetId");
b.HasIndex("ShowId", "Position");
b.ToTable("ShowEpisode");
});
modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("AudioCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<TimeSpan?>("Duration")
.HasColumnType("interval");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int?>("Height")
.HasColumnType("integer");
b.Property<string>("OriginalExtension")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("RelativePath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int?>("SegmentCount")
.HasColumnType("integer");
b.Property<int?>("SegmentSeconds")
.HasColumnType("integer");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("VideoCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int?>("Width")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Status");
b.ToTable("MediaAssets");
});
modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Key");
b.ToTable("AppSettings");
});
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsBlocked")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("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<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", 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<System.Guid>", 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.Broadcast.ChannelAd", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Ads")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Shows")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.ChannelShow", null)
.WithMany("PreferredHours")
.HasForeignKey("ChannelShowId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null)
.WithMany("Shows")
.HasForeignKey("ProgrammingOverrideId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Overrides")
.HasForeignKey("ChannelId")
.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.Broadcast.BumperTemplate", b =>
{
b.Navigation("Variants");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
{
b.Navigation("Ads");
b.Navigation("BumperTemplates");
b.Navigation("Overrides");
b.Navigation("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
{
b.Navigation("PreferredHours");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.Navigation("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
{
b.Navigation("Episodes");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,58 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ChannelShowPreferredHours : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PreferredWeightMultiplier",
table: "ChannelShow",
type: "integer",
nullable: false,
defaultValue: 3);
migrationBuilder.CreateTable(
name: "ChannelShowHour",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelShowId = table.Column<Guid>(type: "uuid", nullable: false),
StartHour = table.Column<int>(type: "integer", nullable: false),
EndHour = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelShowHour", x => x.Id);
table.ForeignKey(
name: "FK_ChannelShowHour_ChannelShow_ChannelShowId",
column: x => x.ChannelShowId,
principalTable: "ChannelShow",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ChannelShowHour_ChannelShowId",
table: "ChannelShowHour",
column: "ChannelShowId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ChannelShowHour");
migrationBuilder.DropColumn(
name: "PreferredWeightMultiplier",
table: "ChannelShow");
}
}
}
@@ -292,11 +292,16 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<int>("Trigger") b.Property<int>("Trigger")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<int>("Weight")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1);
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("BumperTemplateId", "Position"); b.HasIndex("BumperTemplateId", "Position");
b.ToTable("BumperTextVariant"); b.ToTable("BumperTextVariants");
}); });
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
@@ -310,6 +315,9 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<int>("AdsPerBreak") b.Property<int>("AdsPerBreak")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<double>("BumperEpisodeChangeChance")
.HasColumnType("double precision");
b.Property<int>("BumperFont") b.Property<int>("BumperFont")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -319,6 +327,9 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<int>("BumperSelection") b.Property<int>("BumperSelection")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<double>("BumperShowChangeChance")
.HasColumnType("double precision");
b.Property<bool>("BumpersEnabled") b.Property<bool>("BumpersEnabled")
.HasColumnType("boolean"); .HasColumnType("boolean");
@@ -399,6 +410,11 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<int>("NextEpisodeIndex") b.Property<int>("NextEpisodeIndex")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<int>("PreferredWeightMultiplier")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(3);
b.Property<Guid>("ShowId") b.Property<Guid>("ShowId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -412,6 +428,27 @@ namespace TeleWave.Infrastructure.Migrations
b.ToTable("ChannelShow"); b.ToTable("ChannelShow");
}); });
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelShowId")
.HasColumnType("uuid");
b.Property<int>("EndHour")
.HasColumnType("integer");
b.Property<int>("StartHour")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelShowId");
b.ToTable("ChannelShowHour");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -462,6 +499,9 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid>("Id") b.Property<Guid>("Id")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<Guid?>("BumperVariantId")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId") b.Property<Guid>("ChannelId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -881,6 +921,15 @@ namespace TeleWave.Infrastructure.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.ChannelShow", null)
.WithMany("PreferredHours")
.HasForeignKey("ChannelShowId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
{ {
b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null) b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null)
@@ -924,6 +973,11 @@ namespace TeleWave.Infrastructure.Migrations
b.Navigation("Shows"); b.Navigation("Shows");
}); });
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
{
b.Navigation("PreferredHours");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{ {
b.Navigation("Shows"); b.Navigation("Shows");
@@ -25,6 +25,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<Show> Shows => Set<Show>(); public DbSet<Show> Shows => Set<Show>();
public DbSet<Channel> Channels => Set<Channel>(); public DbSet<Channel> Channels => Set<Channel>();
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>(); public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
public DbSet<BumperTextVariant> BumperTextVariants => Set<BumperTextVariant>();
public DbSet<BumperAsset> BumperAssets => Set<BumperAsset>(); public DbSet<BumperAsset> BumperAssets => Set<BumperAsset>();
public DbSet<AppSetting> AppSettings => Set<AppSetting>(); public DbSet<AppSetting> AppSettings => Set<AppSetting>();
public DbSet<Image> Images => Set<Image>(); public DbSet<Image> Images => Set<Image>();
@@ -48,6 +48,23 @@ public class ChannelShowConfiguration : IEntityTypeConfiguration<ChannelShow>
public void Configure(EntityTypeBuilder<ChannelShow> builder) public void Configure(EntityTypeBuilder<ChannelShow> builder)
{ {
builder.HasIndex(x => new { x.ChannelId, x.ShowId }); builder.HasIndex(x => new { x.ChannelId, x.ShowId });
builder.Property(x => x.PreferredWeightMultiplier)
.HasDefaultValue(ChannelShow.DefaultPreferredWeightMultiplier);
builder
.HasMany(x => x.PreferredHours)
.WithOne()
.HasForeignKey(h => h.ChannelShowId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.PreferredHours).UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
public class ChannelShowHourConfiguration : IEntityTypeConfiguration<ChannelShowHour>
{
public void Configure(EntityTypeBuilder<ChannelShowHour> builder)
{
builder.HasIndex(x => x.ChannelShowId);
} }
} }
@@ -90,6 +107,7 @@ public class BumperTextVariantConfiguration : IEntityTypeConfiguration<BumperTex
builder.Property(x => x.NextLabel).IsRequired().HasMaxLength(64); builder.Property(x => x.NextLabel).IsRequired().HasMaxLength(64);
builder.Property(x => x.Line1).IsRequired().HasMaxLength(120); builder.Property(x => x.Line1).IsRequired().HasMaxLength(120);
builder.Property(x => x.Line2).IsRequired().HasMaxLength(120); builder.Property(x => x.Line2).IsRequired().HasMaxLength(120);
builder.Property(x => x.Weight).HasDefaultValue(BumperTextVariant.DefaultWeight);
} }
} }
@@ -415,6 +415,108 @@ public class SchedulePlannerTests
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper); Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
} }
[Fact]
public void Bumpers_ShowChangeChanceZero_SuppressesBumpersOnShowChange()
{
// Два шоу, ротация выбора подблока (без random в пути заставки) → чередование гарантировано,
// каждый переход — смена шоу. ShowChangeChance=0 глушит их, EpisodeChangeChance=1 не при чём.
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
var input = BaseInput([a, b], durations, Start.AddMinutes(120)) with
{
Bumpers = Bumper(true, BumperTrigger.Both, TimeSpan.Zero) with { ShowChangeChance = 0.0 },
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1));
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
}
[Fact]
public void Bumpers_EpisodeChangeChanceZero_SuppressesBumpersBetweenEpisodes()
{
// Одно шоу → каждый переход между блоками одного шоу. EpisodeChangeChance=0 глушит все заставки.
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
var durations = Durations((a.EpisodeAssetIds[0], 20));
var input = BaseInput([a], durations, Start.AddMinutes(120)) with
{
Bumpers = Bumper(true, BumperTrigger.Both, TimeSpan.Zero) with { EpisodeChangeChance = 0.0 },
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0));
Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper);
}
[Fact]
public void Bumpers_WeightedRandom_NeverPicksZeroWeightVariant()
{
// Взвешенный выбор: подблок с весом 0 не выбирается никогда, весь трафик уходит на t1 (вес 5).
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
var durations = Durations((a.EpisodeAssetIds[0], 20));
Guid t0 = Guid.NewGuid(),
t1 = Guid.NewGuid();
var input = BaseInput([a], durations, Start.AddMinutes(120)) with
{
Bumpers = Bumper(
true,
BumperTrigger.Both,
TimeSpan.Zero,
BumperSelection.WeightedRandom,
new PlannerBumperVariant(Guid.NewGuid(), t0, TimeSpan.FromSeconds(8), BumperTrigger.Both, 0),
new PlannerBumperVariant(Guid.NewGuid(), t1, TimeSpan.FromSeconds(8), BumperTrigger.Both, 5)
),
};
var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1, 2, 3, 4));
var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList();
Assert.NotEmpty(bumpers);
Assert.All(bumpers, e => Assert.Equal(t1, e.BumperTemplateId));
}
[Fact]
public void PreferredHours_BoostsWeight_InsideWindow()
{
// Час старта — 0 (UTC). Окно b покрывает его, множитель 100 → b доминирует, хотя базовые веса равны.
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
var b = new PlannerShow(
Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0,
PreferredHours: [new PlannerHourWindow(0, 24)], PreferredWeightMultiplier: 100);
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
var input = BaseInput([a, b], durations, Start.AddMinutes(60));
var result = SchedulePlanner.Plan(input, new FixedRandom(50));
var programs = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList();
Assert.NotEmpty(programs);
Assert.All(programs, e => Assert.Equal(b.ShowId, e.ShowId));
}
[Fact]
public void PreferredHours_OutsideWindow_KeepsBaseWeight()
{
// Час старта — 0, окно b — [10,12): не покрывает → буста нет, при FixedRandom(50) берётся первое шоу (a).
var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0);
var b = new PlannerShow(
Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0,
PreferredHours: [new PlannerHourWindow(10, 12)], PreferredWeightMultiplier: 100);
var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20));
var input = BaseInput([a, b], durations, Start.AddMinutes(60));
var result = SchedulePlanner.Plan(input, new FixedRandom(50));
var programs = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList();
Assert.NotEmpty(programs);
Assert.All(programs, e => Assert.Equal(a.ShowId, e.ShowId));
}
[Fact] [Fact]
public void NoPlayableShows_ReturnsEmpty() public void NoPlayableShows_ReturnsEmpty()
{ {
@@ -18,6 +18,7 @@ import type {
BumperTextVariantDto, BumperTextVariantDto,
BumperTrigger, BumperTrigger,
ChannelShowDto, ChannelShowDto,
HourWindow,
OverrideMode, OverrideMode,
ScheduleEntryDto, ScheduleEntryDto,
} from '@/shared/api/types' } from '@/shared/api/types'
@@ -381,6 +382,13 @@ function cssColor(value: string): string {
return v return v
} }
/** Ограничивает вероятность появления заставки диапазоном 0..1 (пустой ввод → 0). */
function clampChance(value: string): number {
const n = Number(value)
if (Number.isNaN(n)) return 0
return Math.min(1, Math.max(0, n))
}
function BumperCard({ function BumperCard({
channel, channel,
onSaved, onSaved,
@@ -461,6 +469,9 @@ function BumperCard({
<SelectContent> <SelectContent>
<SelectItem value="Rotation">{t('admin.channels.bumperSelectionRotation')}</SelectItem> <SelectItem value="Rotation">{t('admin.channels.bumperSelectionRotation')}</SelectItem>
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem> <SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
<SelectItem value="WeightedRandom">
{t('admin.channels.bumperSelectionWeighted')}
</SelectItem>
<SelectItem value="AlwaysFirst"> <SelectItem value="AlwaysFirst">
{t('admin.channels.bumperSelectionAlwaysFirst')} {t('admin.channels.bumperSelectionAlwaysFirst')}
</SelectItem> </SelectItem>
@@ -489,6 +500,34 @@ function BumperCard({
onChange={(e) => setField('minIntervalMinutes', Number(e.target.value))} onChange={(e) => setField('minIntervalMinutes', Number(e.target.value))}
/> />
</div> </div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperShowChangeChance')}</Label>
<Input
type="number"
min={0}
max={1}
step={0.05}
value={bumper.showChangeChance}
onChange={(e) => setField('showChangeChance', clampChance(e.target.value))}
/>
<span className="text-xs text-muted-foreground">
{t('admin.channels.bumperShowChangeChanceHint')}
</span>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperEpisodeChangeChance')}</Label>
<Input
type="number"
min={0}
max={1}
step={0.05}
value={bumper.episodeChangeChance}
onChange={(e) => setField('episodeChangeChance', clampChance(e.target.value))}
/>
<span className="text-xs text-muted-foreground">
{t('admin.channels.bumperEpisodeChangeChanceHint')}
</span>
</div>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}> <Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
@@ -737,6 +776,7 @@ function BumperVariantEditor({
line1: variant.line1, line1: variant.line1,
line2: variant.line2, line2: variant.line2,
trigger: variant.trigger, trigger: variant.trigger,
weight: variant.weight,
}) })
useEffect(() => { useEffect(() => {
@@ -748,6 +788,7 @@ function BumperVariantEditor({
line1: variant.line1, line1: variant.line1,
line2: variant.line2, line2: variant.line2,
trigger: variant.trigger, trigger: variant.trigger,
weight: variant.weight,
}) })
}, [variant]) }, [variant])
@@ -805,6 +846,19 @@ function BumperVariantEditor({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperVariantWeight')}</Label>
<Input
type="number"
min={0}
max={1000}
value={form.weight}
onChange={(e) => set('weight', Math.max(0, Math.round(Number(e.target.value)) || 0))}
/>
<span className="text-xs text-muted-foreground">
{t('admin.channels.bumperVariantWeightHint')}
</span>
</div>
</div> </div>
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
@@ -1188,15 +1242,31 @@ function ChannelShowRow({
const [blockMode, setBlockMode] = useState<BlockMode>(row.blockMode) const [blockMode, setBlockMode] = useState<BlockMode>(row.blockMode)
const [blockValue, setBlockValue] = useState(row.blockValue) const [blockValue, setBlockValue] = useState(row.blockValue)
const [isEnabled, setIsEnabled] = useState(row.isEnabled) const [isEnabled, setIsEnabled] = useState(row.isEnabled)
const [expanded, setExpanded] = useState(false)
const [multiplier, setMultiplier] = useState(row.preferredWeightMultiplier)
const [hours, setHours] = useState<HourWindow[]>(row.preferredHours)
const save = useMutation({ const save = useMutation({
mutationFn: () => mutationFn: () =>
updateChannelShow(channelId, row.id, { weight, blockMode, blockValue, isEnabled }), updateChannelShow(channelId, row.id, {
weight,
blockMode,
blockValue,
isEnabled,
preferredWeightMultiplier: multiplier,
preferredHours: hours.filter((h) => h.startHour < h.endHour),
}),
onSuccess: onChanged, onSuccess: onChanged,
onError, onError,
}) })
const addHour = () => setHours((h) => [...h, { startHour: 18, endHour: 23 }])
const setHour = (i: number, patch: Partial<HourWindow>) =>
setHours((h) => h.map((w, idx) => (idx === i ? { ...w, ...patch } : w)))
const removeHour = (i: number) => setHours((h) => h.filter((_, idx) => idx !== i))
return ( return (
<>
<tr className="border-b border-border last:border-0"> <tr className="border-b border-border last:border-0">
<td className="py-2">{row.showName}</td> <td className="py-2">{row.showName}</td>
<td className="py-2"> <td className="py-2">
@@ -1236,7 +1306,16 @@ function ChannelShowRow({
/> />
</td> </td>
<td className="py-2"> <td className="py-2">
<div className="flex gap-2"> <div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="ghost"
className="whitespace-nowrap"
onClick={() => setExpanded((v) => !v)}
>
{t('admin.channels.preferredHours')}
{hours.length > 0 ? ` (${hours.length})` : ''}
</Button>
<Button size="sm" variant="outline" disabled={save.isPending} onClick={() => save.mutate()}> <Button size="sm" variant="outline" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')} {t('common.save')}
</Button> </Button>
@@ -1246,6 +1325,93 @@ function ChannelShowRow({
</div> </div>
</td> </td>
</tr> </tr>
{expanded && (
<tr className="border-b border-border last:border-0">
<td colSpan={5} className="bg-muted/30 py-3">
<div className="flex flex-col gap-3 pl-1">
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">
{t('admin.channels.preferredMultiplier')}
</Label>
<Input
type="number"
min={1}
max={100}
value={multiplier}
onChange={(e) => setMultiplier(Math.max(1, Math.round(Number(e.target.value)) || 1))}
className="h-8 w-20"
/>
<span className="text-xs text-muted-foreground">
{t('admin.channels.preferredHoursHint')}
</span>
</div>
{hours.length === 0 && (
<p className="text-xs text-muted-foreground">{t('admin.channels.preferredNone')}</p>
)}
{hours.map((w, i) => (
<div key={i} className="flex items-center gap-2">
<HourSelect
value={w.startHour}
from={0}
to={23}
onChange={(v) => setHour(i, { startHour: v })}
/>
<span className="text-muted-foreground"></span>
<HourSelect
value={w.endHour}
from={1}
to={24}
onChange={(v) => setHour(i, { endHour: v })}
/>
{w.startHour >= w.endHour && (
<span className="text-xs text-destructive">
{t('admin.channels.preferredBadRange')}
</span>
)}
<Button size="sm" variant="ghost" onClick={() => removeHour(i)}>
{t('common.delete')}
</Button>
</div>
))}
<div>
<Button size="sm" variant="outline" onClick={addHour}>
{t('admin.channels.preferredAddWindow')}
</Button>
</div>
</div>
</td>
</tr>
)}
</>
)
}
/** Выпадающий выбор часа суток (значения from..to включительно), формат «HH:00». */
function HourSelect({
value,
from,
to,
onChange,
}: {
value: number
from: number
to: number
onChange: (v: number) => void
}) {
const options = Array.from({ length: to - from + 1 }, (_, i) => from + i)
return (
<Select value={String(value)} onValueChange={(v) => onChange(Number(v))}>
<SelectTrigger className="h-8 w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((h) => (
<SelectItem key={h} value={String(h)}>
{String(h).padStart(2, '0')}:00
</SelectItem>
))}
</SelectContent>
</Select>
) )
} }
@@ -1386,7 +1552,18 @@ function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) {
{e.kind === 'Ad' ? ( {e.kind === 'Ad' ? (
<Badge variant="muted">{t('air.ad')}</Badge> <Badge variant="muted">{t('air.ad')}</Badge>
) : e.kind === 'Bumper' ? ( ) : e.kind === 'Bumper' ? (
<Badge variant="muted">{t('air.bumper')}</Badge> <span className="flex min-w-0 items-center gap-2">
<Badge variant="muted" className="shrink-0">
{t('air.bumper')}
</Badge>
{(e.bumperName || e.bumperText) && (
<span className="min-w-0 truncate text-muted-foreground">
{e.bumperName}
{e.bumperName && e.bumperText ? ' · ' : ''}
{e.bumperText}
</span>
)}
</span>
) : ( ) : (
<span> <span>
{e.showName ?? '—'} {e.showName ?? '—'}
+10 -1
View File
@@ -8,6 +8,7 @@ import type {
ChannelDto, ChannelDto,
ChannelSummaryDto, ChannelSummaryDto,
CreatedIdResponse, CreatedIdResponse,
HourWindow,
OverrideMode, OverrideMode,
ScheduleEntryDto, ScheduleEntryDto,
} from '@/shared/api/types' } from '@/shared/api/types'
@@ -52,7 +53,14 @@ export function addChannelShow(id: string, body: ChannelShowBody) {
export function updateChannelShow( export function updateChannelShow(
id: string, id: string,
channelShowId: string, channelShowId: string,
body: { weight: number; blockMode: BlockMode; blockValue: number; isEnabled: boolean }, body: {
weight: number
blockMode: BlockMode
blockValue: number
isEnabled: boolean
preferredWeightMultiplier: number
preferredHours: HourWindow[]
},
) { ) {
return apiRequest<void>(`/admin/channels/${id}/shows/${channelShowId}`, { method: 'PUT', body }) return apiRequest<void>(`/admin/channels/${id}/shows/${channelShowId}`, { method: 'PUT', body })
} }
@@ -147,6 +155,7 @@ export type BumperVariantBody = {
line1: string line1: string
line2: string line2: string
trigger: BumperTrigger trigger: BumperTrigger
weight: number
} }
export function addBumperVariant(id: string, templateId: string, name: string) { export function addBumperVariant(id: string, templateId: string, name: string) {
+16 -1
View File
@@ -127,7 +127,7 @@ export type AdInsertion = 'BetweenBlocks' | 'BetweenEpisodes'
export type OverrideMode = 'Exclusive' | 'Boost' export type OverrideMode = 'Exclusive' | 'Boost'
export type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper' export type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper'
export type BumperFont = 'Sans' | 'Serif' export type BumperFont = 'Sans' | 'Serif'
export type BumperSelection = 'Rotation' | 'Random' | 'AlwaysFirst' export type BumperSelection = 'Rotation' | 'Random' | 'AlwaysFirst' | 'WeightedRandom'
export type BumperTextKind = 'NowNext' | 'Free' export type BumperTextKind = 'NowNext' | 'Free'
export type BumperTrigger = 'OnShowChange' | 'BetweenEpisodes' | 'Both' export type BumperTrigger = 'OnShowChange' | 'BetweenEpisodes' | 'Both'
@@ -136,6 +136,10 @@ export type BumperSettings = {
font: BumperFont font: BumperFont
minIntervalMinutes: number minIntervalMinutes: number
selection: BumperSelection selection: BumperSelection
/** Вероятность заставки на смене шоу (0..1). */
showChangeChance: number
/** Вероятность заставки между блоками одного шоу (0..1). */
episodeChangeChance: number
} }
/** Подблок (текст-вариант): свой текст + правило показа поверх стиля/звука блока. */ /** Подблок (текст-вариант): свой текст + правило показа поверх стиля/звука блока. */
@@ -149,6 +153,8 @@ export type BumperTextVariantDto = {
line1: string line1: string
line2: string line2: string
trigger: BumperTrigger trigger: BumperTrigger
/** Вес при стратегии выбора «случайно взвешенный» (0 — не выбирается). */
weight: number
} }
export type BumperTemplateDto = { export type BumperTemplateDto = {
@@ -173,6 +179,9 @@ export type ChannelSummaryDto = {
isEnabled: boolean isEnabled: boolean
} }
/** Окно предпочтительных часов [startHour, endHour) суток (UTC). */
export type HourWindow = { startHour: number; endHour: number }
export type ChannelShowDto = { export type ChannelShowDto = {
id: string id: string
showId: string showId: string
@@ -182,6 +191,9 @@ export type ChannelShowDto = {
blockValue: number blockValue: number
isEnabled: boolean isEnabled: boolean
nextEpisodeIndex: number nextEpisodeIndex: number
/** Во сколько раз усиливать вес в предпочтительные часы (1 — без буста). */
preferredWeightMultiplier: number
preferredHours: HourWindow[]
} }
export type ChannelAdDto = { export type ChannelAdDto = {
@@ -227,6 +239,9 @@ export type ScheduleEntryDto = {
showName: string | null showName: string | null
episodeIndex: number | null episodeIndex: number | null
seasonEpisode: string | null seasonEpisode: string | null
/** Для заставок: имя подблока и его текст — для метки в расписании. */
bumperName: string | null
bumperText: string | null
} }
// ── Публичный эфир ───────────────────────────────────────────────────────── // ── Публичный эфир ─────────────────────────────────────────────────────────
+28
View File
@@ -203,8 +203,13 @@ const resources = {
bumperSelection: 'Выбор блока', bumperSelection: 'Выбор блока',
bumperSelectionRotation: 'По кругу', bumperSelectionRotation: 'По кругу',
bumperSelectionRandom: 'Случайно', bumperSelectionRandom: 'Случайно',
bumperSelectionWeighted: 'Случайно взвешенный',
bumperSelectionAlwaysFirst: 'Всегда первый', bumperSelectionAlwaysFirst: 'Всегда первый',
bumperMinInterval: 'Мин. интервал, мин', bumperMinInterval: 'Мин. интервал, мин',
bumperShowChangeChance: 'Вероятность на смене шоу',
bumperShowChangeChanceHint: '0..1: 1 — на каждой смене, 0 — никогда',
bumperEpisodeChangeChance: 'Вероятность между сериями',
bumperEpisodeChangeChanceHint: '0..1: напр. 0.3 — примерно в 30% переходов между сериями',
bumperFont: 'Шрифт', bumperFont: 'Шрифт',
bumperFontSans: 'Гротеск', bumperFontSans: 'Гротеск',
bumperFontSerif: 'Антиква', bumperFontSerif: 'Антиква',
@@ -234,6 +239,8 @@ const resources = {
bumperTriggerOnShowChange: 'При смене шоу', bumperTriggerOnShowChange: 'При смене шоу',
bumperTriggerBetweenEpisodes: 'Между сериями', bumperTriggerBetweenEpisodes: 'Между сериями',
bumperTriggerBoth: 'Оба', bumperTriggerBoth: 'Оба',
bumperVariantWeight: 'Вес',
bumperVariantWeightHint: 'Для стратегии «случайно взвешенный»: чем больше — тем чаще (0 — не выбирается)',
bumperDefault: 'по умолчанию', bumperDefault: 'по умолчанию',
bumperSeconds: 'с', bumperSeconds: 'с',
bumperDefaultDuration: '≈8 с (джингл)', bumperDefaultDuration: '≈8 с (джингл)',
@@ -262,6 +269,13 @@ const resources = {
blockDuration: 'По времени', blockDuration: 'По времени',
episodes: 'серий', episodes: 'серий',
minutes: 'минут', minutes: 'минут',
preferredHours: 'Часы',
preferredMultiplier: 'Множитель веса',
preferredHoursHint:
'В выбранные часы вес шоу умножается — оно чаще попадает в эфир. Время в UTC.',
preferredNone: 'Окна не заданы — предпочтений по времени нет.',
preferredAddWindow: 'Добавить окно',
preferredBadRange: 'начало ≥ конца',
ads: 'Реклама', ads: 'Реклама',
pickAd: 'Выберите ролик', pickAd: 'Выберите ролик',
noAds: 'Пул рекламы пуст', noAds: 'Пул рекламы пуст',
@@ -523,8 +537,13 @@ const resources = {
bumperSelection: 'Block selection', bumperSelection: 'Block selection',
bumperSelectionRotation: 'Rotation', bumperSelectionRotation: 'Rotation',
bumperSelectionRandom: 'Random', bumperSelectionRandom: 'Random',
bumperSelectionWeighted: 'Weighted random',
bumperSelectionAlwaysFirst: 'Always first', bumperSelectionAlwaysFirst: 'Always first',
bumperMinInterval: 'Min interval, min', bumperMinInterval: 'Min interval, min',
bumperShowChangeChance: 'Chance on show change',
bumperShowChangeChanceHint: '0..1: 1 — every change, 0 — never',
bumperEpisodeChangeChance: 'Chance between episodes',
bumperEpisodeChangeChanceHint: '0..1: e.g. 0.3 — about 30% of same-show transitions',
bumperFont: 'Font', bumperFont: 'Font',
bumperFontSans: 'Sans', bumperFontSans: 'Sans',
bumperFontSerif: 'Serif', bumperFontSerif: 'Serif',
@@ -554,6 +573,8 @@ const resources = {
bumperTriggerOnShowChange: 'Show change', bumperTriggerOnShowChange: 'Show change',
bumperTriggerBetweenEpisodes: 'Between episodes', bumperTriggerBetweenEpisodes: 'Between episodes',
bumperTriggerBoth: 'Both', bumperTriggerBoth: 'Both',
bumperVariantWeight: 'Weight',
bumperVariantWeightHint: 'For the “weighted random” strategy: higher = more often (0 — never picked)',
bumperDefault: 'default', bumperDefault: 'default',
bumperSeconds: 's', bumperSeconds: 's',
bumperDefaultDuration: '≈8 s (jingle)', bumperDefaultDuration: '≈8 s (jingle)',
@@ -582,6 +603,13 @@ const resources = {
blockDuration: 'By time', blockDuration: 'By time',
episodes: 'episodes', episodes: 'episodes',
minutes: 'minutes', minutes: 'minutes',
preferredHours: 'Hours',
preferredMultiplier: 'Weight multiplier',
preferredHoursHint:
'During the selected hours the shows weight is multiplied, so it airs more often. Times are UTC.',
preferredNone: 'No windows set — no time preference.',
preferredAddWindow: 'Add window',
preferredBadRange: 'start ≥ end',
ads: 'Ads', ads: 'Ads',
pickAd: 'Pick an ad', pickAd: 'Pick an ad',
noAds: 'Ad pool is empty', noAds: 'Ad pool is empty',