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
@@ -4,7 +4,7 @@ using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Обновить подблок: имя, режим текста, текст и правило показа.</summary>
/// <summary>Обновить подблок: имя, режим текста, текст, правило показа и вес.</summary>
public sealed record UpdateBumperTextVariantCommand(
Guid ChannelId,
Guid TemplateId,
@@ -15,5 +15,6 @@ public sealed record UpdateBumperTextVariantCommand(
string NextLabel,
string Line1,
string Line2,
BumperTrigger Trigger
BumperTrigger Trigger,
int Weight
) : ICommand<Result>;
@@ -35,7 +35,8 @@ public sealed class UpdateBumperTextVariantCommandHandler(IAppDbContext dbContex
command.NextLabel,
command.Line1,
command.Line2,
command.Trigger
command.Trigger,
command.Weight
);
return Result.Success();
}
@@ -12,5 +12,6 @@ public sealed class UpdateBumperTextVariantCommandValidator
RuleFor(x => x.NextLabel).MaximumLength(64);
RuleFor(x => x.Line1).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,
int BlockValue,
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 OverrideShowDto(Guid ShowId, string ShowName, int Weight);
@@ -31,10 +36,12 @@ public sealed record ProgrammingOverrideDto(
public sealed record BumperSettingsDto(
BumperFont Font,
int MinIntervalMinutes,
BumperSelection Selection
BumperSelection Selection,
double ShowChangeChance,
double EpisodeChangeChance
);
/// <summary>Подблок (текст-вариант): свой текст + правило показа поверх стиля/звука блока.</summary>
/// <summary>Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.</summary>
public sealed record BumperTextVariantDto(
Guid Id,
int Position,
@@ -44,7 +51,8 @@ public sealed record BumperTextVariantDto(
string NextLabel,
string Line1,
string Line2,
BumperTrigger Trigger
BumperTrigger Trigger,
int Weight
);
/// <summary>Блок заставки: своё оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
@@ -15,6 +15,7 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
{
var channel = await dbContext.Channels.AsNoTracking()
.Include(c => c.Shows)
.ThenInclude(s => s.PreferredHours)
.Include(c => c.Ads)
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
@@ -51,7 +52,12 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
s.BlockMode,
s.BlockValue,
s.IsEnabled,
s.NextEpisodeIndex
s.NextEpisodeIndex,
s.PreferredWeightMultiplier,
s.PreferredHours
.OrderBy(h => h.StartHour)
.Select(h => new HourWindowDto(h.StartHour, h.EndHour))
.ToList()
))
.ToList();
@@ -90,7 +96,8 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
v.NextLabel,
v.Line1,
v.Line2,
v.Trigger
v.Trigger,
v.Weight
))
.ToList()
))
@@ -121,7 +128,9 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
new BumperSettingsDto(
channel.BumperFont,
channel.BumperMinIntervalMinutes,
channel.BumperSelection
channel.BumperSelection,
channel.BumperShowChangeChance,
channel.BumperEpisodeChangeChance
),
bumperTemplates,
channel.FillerAssetId,
@@ -37,6 +37,19 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
.Select(s => new { s.Id, s.Name })
.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 в расписании админки.
var assetIds = entries
.Where(e => e.Kind == Domain.Broadcast.ScheduleEntryKind.Program)
@@ -48,20 +61,66 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
.Select(a => new { a.Id, a.OriginalFileName })
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
var dtos = entries
.Select(e => new ScheduleEntryDto(
e.Id,
e.Kind,
e.MediaAssetId,
e.StartsAtUtc,
e.EndsAtUtc,
e.ShowId,
e.ShowId is { } sid ? showNames.GetValueOrDefault(sid) : null,
e.EpisodeIndex,
assetNames.TryGetValue(e.MediaAssetId, out var name) ? EpisodeName.ParseLabel(name) : null
))
.ToList();
// «Из какого шоу» для заставки берём из ближайшей предыдущей программы в упорядоченном окне.
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.Kind,
e.MediaAssetId,
e.StartsAtUtc,
e.EndsAtUtc,
e.ShowId,
e.ShowId is { } sid ? showNames.GetValueOrDefault(sid) : null,
e.EpisodeIndex,
assetNames.TryGetValue(e.MediaAssetId, out var name) ? EpisodeName.ParseLabel(name) : null,
bumperName,
bumperText
)
);
if (e.Kind == Domain.Broadcast.ScheduleEntryKind.Program)
prevProgramShowId = e.ShowId;
}
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,
string? ShowName,
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
.Include(c => c.Shows)
.ThenInclude(s => s.PreferredHours)
.Include(c => c.Ads)
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
@@ -161,7 +162,14 @@ public sealed class ScheduleGenerator(
)
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>
@@ -525,7 +533,11 @@ public sealed class ScheduleGenerator(
channelShow.BlockMode,
channelShow.BlockValue,
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)));
return t.Variants
.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();
@@ -561,7 +573,9 @@ public sealed class ScheduleGenerator(
channel.BumpersEnabled,
TimeSpan.FromMinutes(channel.BumperMinIntervalMinutes),
channel.BumperSelection,
bumperVariants
bumperVariants,
channel.BumperShowChangeChance,
channel.BumperEpisodeChangeChance
);
return new PlannerInput(
@@ -19,5 +19,7 @@ public sealed record UpdateChannelSettingsCommand(
public sealed record BumperSettingsInput(
BumperFont Font,
int MinIntervalMinutes,
BumperSelection Selection
BumperSelection Selection,
double ShowChangeChance,
double EpisodeChangeChance
);
@@ -38,7 +38,9 @@ public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
channel.UpdateBumperSettings(
command.Bumper.Font,
command.Bumper.MinIntervalMinutes,
command.Bumper.Selection
command.Bumper.Selection,
command.Bumper.ShowChangeChance,
command.Bumper.EpisodeChangeChance
);
return Result.Success();
}
@@ -11,5 +11,7 @@ public sealed class UpdateChannelSettingsCommandValidator
RuleFor(x => x.AdsPerBreak).InclusiveBetween(0, 10);
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,
BlockMode BlockMode,
int BlockValue,
bool IsEnabled
bool IsEnabled,
int PreferredWeightMultiplier,
IReadOnlyList<HourWindowInput> PreferredHours
) : 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
.Include(c => c.Shows)
.ThenInclude(s => s.PreferredHours)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
@@ -24,6 +25,10 @@ public sealed class UpdateChannelShowCommandHandler(IAppDbContext dbContext)
return Result.Failure(ChannelErrors.ChannelShowNotFound);
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();
}
}
@@ -8,5 +8,13 @@ public sealed class UpdateChannelShowCommandValidator : AbstractValidator<Update
{
RuleFor(x => x.Weight).InclusiveBetween(1, 1000);
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<Channel> Channels { get; }
DbSet<ScheduleEntry> ScheduleEntries { get; }
DbSet<BumperTextVariant> BumperTextVariants { get; }
DbSet<BumperAsset> BumperAssets { get; }
DbSet<AppSetting> AppSettings { get; }
DbSet<Image> Images { get; }