Add interstitial endpoints and enhance media and template functionalities: implement MapInterstitialEndpoints in Program.cs, add preview functionality for media assets in MediaEndpoints, and introduce template preview capabilities in TemplateEndpoints. Remove obsolete bumper settings from Channel and related classes to streamline configuration.
build / backend (push) Successful in 5m58s
build / frontend (push) Successful in 47s
tests / backend-tests (push) Successful in 5m57s

This commit is contained in:
Leonid Pershin
2026-07-26 14:03:53 +03:00
parent 66040a8841
commit 7b12a06d1b
53 changed files with 4076 additions and 469 deletions
@@ -0,0 +1,64 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Library.Interstitials;
using TeleWave.Application.Library.Interstitials.ImportInterstitials;
using TeleWave.Application.Library.Interstitials.ListInterstitialBlocks;
using TeleWave.Application.Library.Interstitials.ListInterstitials;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Ролики-врезки: тот же <c>Show(Kind = Interstitial)</c>, но со своим экраном (см. 6.7). Правка
/// названия и удаление идут через обычные эндпоинты шоу — здесь только то, чего у библиотеки нет:
/// список с длительностями, блоки и импорт загруженных файлов.
/// </summary>
public static class InterstitialEndpoints
{
public static IEndpointRouteBuilder MapInterstitialEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/interstitials")
.WithTags("Admin.Interstitials")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", List).Produces<IReadOnlyList<InterstitialDto>>();
admin.MapGet("/blocks", ListBlocks).Produces<IReadOnlyList<InterstitialBlockDto>>();
admin.MapPost("/import", Import).Produces<ImportInterstitialsResponse>();
return app;
}
private static async Task<IResult> List(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListInterstitialsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> ListBlocks(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListInterstitialBlocksQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> Import(
ImportInterstitialsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ImportInterstitialsCommand(body.MediaAssetIds),
cancellationToken
);
return result.IsSuccess
? Results.Ok(new ImportInterstitialsResponse(result.Value))
: result.ToHttpResult();
}
}
public sealed record ImportInterstitialsBody(IReadOnlyList<Guid> MediaAssetIds);
public sealed record ImportInterstitialsResponse(int Imported);
@@ -1,3 +1,5 @@
using System.Text;
using System.Text.RegularExpressions;
using LiteCqrs;
using Microsoft.Extensions.Options;
using TeleWave.Api.Common;
@@ -16,6 +18,8 @@ namespace TeleWave.Api.Endpoints;
public static class MediaEndpoints
{
private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled);
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/media")
@@ -27,6 +31,11 @@ public static class MediaEndpoints
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
// Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
// по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment);
return app;
}
@@ -135,6 +144,56 @@ public static class MediaEndpoints
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
return result.ToHttpResult();
}
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
{
string indexPath;
try
{
indexPath = paths.SegmentPath(id, "index.m3u8");
}
catch (UnauthorizedAccessException)
{
return Results.NotFound();
}
if (!File.Exists(indexPath))
return Results.NotFound();
var baseUrl = $"/api/admin/media/{id}/preview/";
var sb = new StringBuilder();
foreach (var line in File.ReadLines(indexPath))
{
var trimmed = line.Trim();
if (trimmed.Length == 0)
continue;
// Директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
.Append('\n');
}
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
}
private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths)
{
if (!SegmentFileName.IsMatch(file))
return Results.NotFound();
string path;
try
{
path = paths.SegmentPath(id, file);
}
catch (UnauthorizedAccessException)
{
return Results.NotFound();
}
if (!File.Exists(path))
return Results.NotFound();
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
}
public sealed record UploadMediaResponse(Guid Id);
@@ -1,6 +1,7 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Programming.Planning.ApplyTemplate;
using TeleWave.Application.Programming.Planning.Preview;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.CreateSlot;
using TeleWave.Application.Programming.Templates.DeleteSlot;
@@ -34,6 +35,10 @@ public static class TemplateEndpoints
admin
.MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate)
.Produces<ApplyResultDto>();
// Предпросмотр — тот же генератор, но без записи и без продвижения курсоров.
admin
.MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
.Produces<SchedulePreviewDto>();
admin
.MapPost("/templates/{templateId:guid}/layers", CreateLayer)
@@ -75,6 +80,20 @@ public static class TemplateEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> PreviewTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken,
int days = 1
)
{
var result = await sender.Send(
new PreviewScheduleQuery(channelId, days),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateTemplate(
Guid templateId,
UpdateTemplateBody body,
+1
View File
@@ -125,6 +125,7 @@ app.MapAdminUserEndpoints();
app.MapMediaEndpoints();
app.MapShowEndpoints();
app.MapGenreEndpoints();
app.MapInterstitialEndpoints();
app.MapCollectionEndpoints();
app.MapGroupEndpoints();
app.MapTemplateEndpoints();
@@ -5,13 +5,7 @@ namespace TeleWave.Application.Broadcast;
public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled);
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).</summary>
public sealed record BumperSettingsDto(
BumperFont Font,
int MinIntervalMinutes,
BumperSelection Selection,
double ShowChangeChance,
double EpisodeChangeChance
);
public sealed record BumperSettingsDto(BumperFont Font, BumperSelection Selection);
/// <summary>Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.</summary>
public sealed record BumperTextVariantDto(
@@ -1,80 +1,74 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.GetChannel;
public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetChannelQuery, Result<ChannelDto>>
{
public async Task<Result<ChannelDto>> Handle(
GetChannelQuery query,
CancellationToken cancellationToken
)
{
// Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно
// (GetChannelTemplateQuery) — здесь только собственные свойства канала.
var channel = await dbContext
.Channels.AsNoTracking()
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
if (channel is null)
return Result.Failure<ChannelDto>(ChannelErrors.NotFound);
var bumperTemplates = channel
.BumperTemplates.OrderBy(t => t.Position)
.Select(t => new BumperTemplateDto(
t.Id,
t.Position,
t.IsDefault,
t.Name,
t.BackgroundColor,
t.BackgroundColor2,
t.AccentColor,
t.TextColor,
t.BackgroundImageId,
t.AudioExtension is not null,
t.AudioDurationSeconds,
t.Variants.OrderBy(v => v.Position)
.Select(v => new BumperTextVariantDto(
v.Id,
v.Position,
v.Name,
v.Kind,
v.NowLabel,
v.NextLabel,
v.Line1,
v.Line2,
v.Trigger,
v.Weight
))
.ToList()
))
.ToList();
return Result.Success(
new ChannelDto(
channel.Id,
channel.Name,
channel.Slug,
channel.IsEnabled,
channel.Number,
channel.UtcOffsetMinutes,
channel.DayStartTime,
channel.TemplateId,
channel.BumpersEnabled,
new BumperSettingsDto(
channel.BumperFont,
channel.BumperMinIntervalMinutes,
channel.BumperSelection,
channel.BumperShowChangeChance,
channel.BumperEpisodeChangeChance
),
bumperTemplates,
channel.FillerAssetId
)
);
}
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.GetChannel;
public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetChannelQuery, Result<ChannelDto>>
{
public async Task<Result<ChannelDto>> Handle(
GetChannelQuery query,
CancellationToken cancellationToken
)
{
// Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно
// (GetChannelTemplateQuery) — здесь только собственные свойства канала.
var channel = await dbContext
.Channels.AsNoTracking()
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
if (channel is null)
return Result.Failure<ChannelDto>(ChannelErrors.NotFound);
var bumperTemplates = channel
.BumperTemplates.OrderBy(t => t.Position)
.Select(t => new BumperTemplateDto(
t.Id,
t.Position,
t.IsDefault,
t.Name,
t.BackgroundColor,
t.BackgroundColor2,
t.AccentColor,
t.TextColor,
t.BackgroundImageId,
t.AudioExtension is not null,
t.AudioDurationSeconds,
t.Variants.OrderBy(v => v.Position)
.Select(v => new BumperTextVariantDto(
v.Id,
v.Position,
v.Name,
v.Kind,
v.NowLabel,
v.NextLabel,
v.Line1,
v.Line2,
v.Trigger,
v.Weight
))
.ToList()
))
.ToList();
return Result.Success(
new ChannelDto(
channel.Id,
channel.Name,
channel.Slug,
channel.IsEnabled,
channel.Number,
channel.UtcOffsetMinutes,
channel.DayStartTime,
channel.TemplateId,
channel.BumpersEnabled,
new BumperSettingsDto(channel.BumperFont, channel.BumperSelection),
bumperTemplates,
channel.FillerAssetId
)
);
}
}
@@ -13,11 +13,6 @@ public sealed record UpdateChannelSettingsCommand(
Guid? FillerAssetId
) : ICommand<Result>;
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>).</summary>
public sealed record BumperSettingsInput(
BumperFont Font,
int MinIntervalMinutes,
BumperSelection Selection,
double ShowChangeChance,
double EpisodeChangeChance
);
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>). Условия
/// показа сюда не входят — они задаются на элементе стыка.</summary>
public sealed record BumperSettingsInput(BumperFont Font, BumperSelection Selection);
@@ -36,13 +36,7 @@ public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
command.BumpersEnabled,
command.FillerAssetId
);
channel.UpdateBumperSettings(
command.Bumper.Font,
command.Bumper.MinIntervalMinutes,
command.Bumper.Selection,
command.Bumper.ShowChangeChance,
command.Bumper.EpisodeChangeChance
);
channel.UpdateBumperSettings(command.Bumper.Font, command.Bumper.Selection);
return Result.Success();
}
}
@@ -8,9 +8,5 @@ public sealed class UpdateChannelSettingsCommandValidator
public UpdateChannelSettingsCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
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);
}
}
@@ -0,0 +1,12 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Interstitials.ImportInterstitials;
/// <summary>
/// Превращает готовые медиа-ассеты в ролики: по одному <c>Show(Kind = Interstitial)</c> на файл,
/// имя — имя файла без расширения. Ассеты, уже привязанные к какому-нибудь шоу, пропускаются —
/// так повторный импорт того же выделения не плодит дубли.
/// </summary>
public sealed record ImportInterstitialsCommand(IReadOnlyList<Guid> MediaAssetIds)
: ICommand<Result<int>>;
@@ -0,0 +1,52 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.Interstitials.ImportInterstitials;
public sealed class ImportInterstitialsCommandHandler(IAppDbContext dbContext)
: ICommandHandler<ImportInterstitialsCommand, Result<int>>
{
public async Task<Result<int>> Handle(
ImportInterstitialsCommand command,
CancellationToken cancellationToken
)
{
var ids = command.MediaAssetIds.Distinct().ToList();
var assets = await dbContext
.MediaAssets.AsNoTracking()
.Where(a => ids.Contains(a.Id))
.Select(a => new { a.Id, a.OriginalFileName })
.ToListAsync(cancellationToken);
if (assets.Count == 0)
return Result.Failure<int>(ShowErrors.AssetNotFound);
var alreadyUsed = await dbContext
.Shows.SelectMany(s => s.Episodes)
.Where(e => ids.Contains(e.MediaAssetId))
.Select(e => e.MediaAssetId)
.ToListAsync(cancellationToken);
var used = alreadyUsed.ToHashSet();
var created = 0;
foreach (var asset in assets.Where(a => !used.Contains(a.Id)))
{
var show = Show.Create(ClipName(asset.OriginalFileName), ShowKind.Interstitial);
show.AddEpisode(asset.Id);
dbContext.Shows.Add(show);
created++;
}
return Result.Success(created);
}
/// <summary>Имя ролика — имя файла без расширения; пустое (файл вида «.mp4») заменяем самим файлом.</summary>
private static string ClipName(string fileName)
{
var name = Path.GetFileNameWithoutExtension(fileName);
return string.IsNullOrWhiteSpace(name) ? fileName : name;
}
}
@@ -0,0 +1,10 @@
using FluentValidation;
namespace TeleWave.Application.Library.Interstitials.ImportInterstitials;
public sealed class ImportInterstitialsCommandValidator
: AbstractValidator<ImportInterstitialsCommand>
{
public ImportInterstitialsCommandValidator() =>
RuleFor(x => x.MediaAssetIds).NotEmpty().Must(ids => ids.Count <= 500);
}
@@ -0,0 +1,28 @@
using TeleWave.Domain.Media;
namespace TeleWave.Application.Library.Interstitials;
/// <summary>
/// Ролик на экране «Ролики». Это то же <c>Show(Kind = Interstitial)</c>, но показывается по-другому:
/// у ролика нет ни года, ни постера, ни серий — важна длительность, поэтому она приходит сразу,
/// а не вторым запросом за медиа-ассетом.
/// </summary>
public sealed record InterstitialDto(
Guid Id,
string Name,
Guid? MediaAssetId,
MediaAssetStatus? AssetStatus,
double? DurationSeconds,
DateTimeOffset CreatedAt
);
/// <summary>
/// Рекламный блок — коллекция, целиком собранная из роликов. Отдельного типа под блок нет
/// (см. 3.7): блок и есть коллекция, здесь она показывается со своей суммарной длительностью.
/// </summary>
public sealed record InterstitialBlockDto(
Guid Id,
string Name,
int ItemCount,
double DurationSeconds
);
@@ -0,0 +1,6 @@
using LiteCqrs;
namespace TeleWave.Application.Library.Interstitials.ListInterstitialBlocks;
/// <summary>Коллекции, целиком собранные из роликов, — рекламные блоки экрана «Ролики».</summary>
public sealed record ListInterstitialBlocksQuery : IQuery<IReadOnlyList<InterstitialBlockDto>>;
@@ -0,0 +1,55 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.Interstitials.ListInterstitialBlocks;
public sealed class ListInterstitialBlocksQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListInterstitialBlocksQuery, IReadOnlyList<InterstitialBlockDto>>
{
public async Task<IReadOnlyList<InterstitialBlockDto>> Handle(
ListInterstitialBlocksQuery query,
CancellationToken cancellationToken
)
{
var collections = await dbContext
.Collections.AsNoTracking()
.Include(c => c.Items)
.OrderBy(c => c.Name)
.ToListAsync(cancellationToken);
// «Блок» — не отдельная сущность, а признак состава: коллекция целиком из роликов. Смешанные
// коллекции (франшизы) остаются на своём экране и сюда не попадают.
var showIds = collections.SelectMany(c => c.Items.Select(i => i.ShowId)).Distinct().ToList();
var clips = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id) && s.Kind == ShowKind.Interstitial)
.Select(s => new
{
s.Id,
AssetIds = s.Episodes.Select(e => e.MediaAssetId).ToList(),
})
.ToDictionaryAsync(s => s.Id, s => s.AssetIds, cancellationToken);
var assetIds = clips.Values.SelectMany(ids => ids).Distinct().ToList();
var durations = await dbContext
.MediaAssets.AsNoTracking()
.Where(a => assetIds.Contains(a.Id) && a.Duration != null)
.Select(a => new { a.Id, a.Duration })
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value.TotalSeconds, cancellationToken);
return collections
.Where(c => c.Items.Count > 0 && c.Items.All(i => clips.ContainsKey(i.ShowId)))
.Select(c => new InterstitialBlockDto(
c.Id,
c.Name,
c.Items.Count,
c.Items.Sum(i =>
clips[i.ShowId]
.Sum(assetId => durations.TryGetValue(assetId, out var d) ? d : 0)
)
))
.ToList();
}
}
@@ -0,0 +1,5 @@
using LiteCqrs;
namespace TeleWave.Application.Library.Interstitials.ListInterstitials;
public sealed record ListInterstitialsQuery : IQuery<IReadOnlyList<InterstitialDto>>;
@@ -0,0 +1,60 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.Interstitials.ListInterstitials;
public sealed class ListInterstitialsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListInterstitialsQuery, IReadOnlyList<InterstitialDto>>
{
public async Task<IReadOnlyList<InterstitialDto>> Handle(
ListInterstitialsQuery query,
CancellationToken cancellationToken
)
{
var shows = await dbContext
.Shows.AsNoTracking()
.Include(s => s.Episodes)
.Where(s => s.Kind == ShowKind.Interstitial)
.OrderBy(s => s.Name)
.ToListAsync(cancellationToken);
// У ролика ровно одна «серия» — берём её ассет, чтобы показать длительность и статус обработки.
var assetIds = shows
.Select(s => s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).FirstOrDefault())
.Where(id => id != Guid.Empty)
.Distinct()
.ToList();
var assets = await dbContext
.MediaAssets.AsNoTracking()
.Where(a => assetIds.Contains(a.Id))
.Select(a => new
{
a.Id,
a.Status,
a.Duration,
})
.ToDictionaryAsync(a => a.Id, cancellationToken);
return shows
.Select(s =>
{
var assetId = s
.Episodes.OrderBy(e => e.Position)
.Select(e => (Guid?)e.MediaAssetId)
.FirstOrDefault();
var asset =
assetId is { } id && assets.TryGetValue(id, out var a) ? a : null;
return new InterstitialDto(
s.Id,
s.Name,
assetId,
asset?.Status,
asset?.Duration?.TotalSeconds,
s.CreatedAt
);
})
.ToList();
}
}
@@ -13,7 +13,7 @@ public static class ShowErrors
public static readonly Error SingleAlreadyHasEpisode = Error.Conflict(
"Shows.SingleAlreadyHasEpisode",
"Полнометражка/разовый выпуск может содержать только одну серию."
"Больше одной серии бывает только у сериала."
);
public static readonly Error AssetNotFound = Error.NotFound(
@@ -146,6 +146,39 @@ public sealed class GridScheduleGenerator(
return new GenerationReport(added, result.Warnings);
}
/// <summary>
/// Сухой прогон: считает, каким получился бы эфир по текущим правилам, но ничего не пишет —
/// ни ленты, ни курсоров слотов, ни отметки о применении. Заставки остаются резервом известной
/// длины: рендер долгий и может упасть, поэтому он делается только при реальном применении.
/// </summary>
public async Task<PlanningResult?> PreviewAsync(
Guid channelId,
DateTimeOffset from,
int days,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
if (channel is null || channel.TemplateId is null)
return null;
var template = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.FirstOrDefaultAsync(t => t.Id == channel.TemplateId, cancellationToken);
if (template is null)
return null;
var horizonEnd = from.AddDays(Math.Clamp(days, 1, Math.Max(1, _options.HorizonDays)));
var input = await BuildInputAsync(channel, template, from, horizonEnd, cancellationToken);
return Domain.Programming.Planning.SchedulePlanner.Plan(input, random);
}
/// <summary>
/// Чистит прошлое сверх окна хранения. Окно должно покрывать самое долгое остывание среди правил —
/// история показов берётся из самой ленты, отдельного журнала нет.
@@ -0,0 +1,33 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Planning.ApplyTemplate;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Preview;
/// <summary>
/// Прогон генератора по текущим правилам без записи: что было бы в эфире, если применить сейчас.
/// Курсоры слотов не двигаются, поэтому предпросмотр можно жать сколько угодно раз.
/// </summary>
public sealed record PreviewScheduleQuery(Guid ChannelId, int Days = 1)
: IQuery<Result<SchedulePreviewDto>>;
/// <summary>Запись предполагаемой ленты. Заставка приходит без ассета — он рендерится при применении.</summary>
public sealed record PreviewItemDto(
PlannedItemKind Kind,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
Guid? ShowId,
string? Title,
Guid? SlotId,
string? SlotTitle
);
public sealed record SchedulePreviewDto(
DateTimeOffset FromUtc,
DateTimeOffset ToUtc,
/// <summary>Время канала: лента отдаётся в UTC, а показывается в нём.</summary>
int UtcOffsetMinutes,
IReadOnlyList<PreviewItemDto> Items,
IReadOnlyList<PlanningWarningDto> Warnings
);
@@ -0,0 +1,112 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Planning.ApplyTemplate;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Preview;
public sealed class PreviewScheduleQueryHandler(
IAppDbContext dbContext,
GridScheduleGenerator generator
) : IQueryHandler<PreviewScheduleQuery, Result<SchedulePreviewDto>>
{
public async Task<Result<SchedulePreviewDto>> Handle(
PreviewScheduleQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.Where(c => c.Id == query.ChannelId)
.Select(c => new { c.UtcOffsetMinutes })
.FirstOrDefaultAsync(cancellationToken);
if (channel is null)
return Result.Failure<SchedulePreviewDto>(ChannelErrors.NotFound);
var from = DateTimeOffset.UtcNow;
var result = await generator.PreviewAsync(
query.ChannelId,
from,
query.Days,
cancellationToken
);
if (result is null)
return Result.Failure<SchedulePreviewDto>(ChannelErrors.TemplateNotFound);
var showNames = await LoadShowNamesAsync(result.Items, cancellationToken);
var slotTitles = await LoadSlotTitlesAsync(result.Items, cancellationToken);
var items = result
.Items.Select(item => new PreviewItemDto(
item.Kind,
item.StartsAtUtc,
item.EndsAtUtc,
item.ShowId,
item.ShowId is { } showId && showNames.TryGetValue(showId, out var name)
? name
: null,
item.SlotId,
item.SlotId is { } slotId && slotTitles.TryGetValue(slotId, out var title)
? title
: null
))
.ToList();
var to = items.Count == 0 ? from : items[^1].EndsAtUtc;
return Result.Success(
new SchedulePreviewDto(
from,
to,
channel.UtcOffsetMinutes,
items,
result
.Warnings.Select(w => new PlanningWarningDto(w.Kind, w.SlotId, w.Details))
.ToList()
)
);
}
private async Task<Dictionary<Guid, string>> LoadShowNamesAsync(
IReadOnlyList<PlannedItem> items,
CancellationToken cancellationToken
)
{
var ids = items
.Select(i => i.ShowId)
.Where(id => id is not null && id != Guid.Empty)
.Select(id => id!.Value)
.Distinct()
.ToList();
if (ids.Count == 0)
return [];
return await dbContext
.Shows.AsNoTracking()
.Where(s => ids.Contains(s.Id))
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
}
private async Task<Dictionary<Guid, string>> LoadSlotTitlesAsync(
IReadOnlyList<PlannedItem> items,
CancellationToken cancellationToken
)
{
var ids = items
.Select(i => i.SlotId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
if (ids.Count == 0)
return [];
return await dbContext
.Slots.AsNoTracking()
.Where(s => ids.Contains(s.Id))
.ToDictionaryAsync(s => s.Id, s => s.Title, cancellationToken);
}
}
@@ -1,17 +1,17 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>Как выбирать блок заставки на каждом переходе между шоу.</summary>
/// <summary>
/// Как выбирать подблок заставки на переходе. Значения заданы явно: прежний вариант «по кругу»
/// (0) убран — курсора ротации в новом пайплайне нет, и он молча вырождался в случайный выбор.
/// </summary>
public enum BumperSelection
{
/// <summary>По кругу в порядке блоков (курсор <see cref="Channel.NextBumperIndex"/>).</summary>
Rotation,
/// <summary>Случайный подблок на каждом переходе (равновероятно).</summary>
Random = 1,
/// <summary>Случайный блок на каждом переходе (равновероятно).</summary>
Random,
/// <summary>Всегда первый (дефолтный) подблок.</summary>
AlwaysFirst = 2,
/// <summary>Всегда первый (дефолтный) блок.</summary>
AlwaysFirst,
/// <summary>Случайный блок с учётом веса подблока (<see cref="BumperTextVariant.Weight"/>).</summary>
WeightedRandom,
/// <summary>Случайный подблок с учётом веса (<see cref="BumperTextVariant.Weight"/>).</summary>
WeightedRandom = 3,
}
+145 -176
View File
@@ -1,176 +1,145 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Канал линейного эфира. Что и когда идёт в эфире, определяет шаблон сетки (<see cref="TemplateId"/>,
/// см. <c>Domain/Programming</c>); канал хранит только собственные свойства: время, номер, аварийный
/// филлер и общие настройки заставок.
/// </summary>
public class Channel
{
private readonly List<BumperTemplate> _bumperTemplates = new();
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public string Slug { get; private set; } = string.Empty;
public bool IsEnabled { get; private set; }
/// <summary>Точка отсчёта эфирной ленты (UTC) — база для MEDIA-SEQUENCE на этапе раздачи.</summary>
public DateTimeOffset EpochUtc { get; private set; }
/// <summary>
/// Номер канала — на телевизоре канал это номер, а не карточка в сетке. Переключение по номерам
/// включается глобальным флагом настроек сайта; null — номер не задан.
/// </summary>
public int? Number { get; private set; }
/// <summary>
/// Смещение времени канала от UTC в минутах (по умолчанию 180 — московское). Фиксированный
/// оффсет, а не IANA-зона: с переводом часов сутки становятся 23- или 25-часовыми, и сетке
/// понадобилось бы отдельное правило подрезки. Появятся каналы в зонах с DST — перейдём на IANA.
/// </summary>
public int UtcOffsetMinutes { get; private set; }
/// <summary>
/// Начало вещательных суток в времени канала (по умолчанию 06:00). Ночной блок с 00:00 до 06:00
/// относится к предыдущему дню: «ночь с пятницы на субботу» — это пятница.
/// </summary>
public TimeOnly DayStartTime { get; private set; }
/// <summary>Активный шаблон сетки канала (один на канал).</summary>
public Guid? TemplateId { get; private set; }
public const int DefaultUtcOffsetMinutes = 180;
public static readonly TimeOnly DefaultDayStartTime = new(6, 0);
// ── Настройки ТВ-заставок. В срезе 2 условия показа переезжают в элементы стыка,
// здесь останется только общий для канала шрифт. ──
/// <summary>Вставлять ли ТВ-заставки на переходах между разными шоу.</summary>
public bool BumpersEnabled { get; private set; }
/// <summary>Как выбирать блок заставки на каждом переходе (по кругу/случайно/всегда первый).</summary>
public BumperSelection BumperSelection { get; private set; }
/// <summary>Курсор ротации блоков заставок.</summary>
public int NextBumperIndex { get; private set; }
public BumperFont BumperFont { get; private set; }
/// <summary>Не вставлять заставку чаще, чем раз в N минут (0 — на каждом подходящем переходе).</summary>
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";
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
public Guid? FillerAssetId { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
/// <summary>Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.</summary>
public IReadOnlyList<BumperTemplate> BumperTemplates => _bumperTemplates;
private Channel() { }
public static Channel Create(string name, string slug, DateTimeOffset epochUtc)
{
var channel = new Channel
{
Id = Guid.NewGuid(),
Name = name,
Slug = slug,
IsEnabled = true,
EpochUtc = epochUtc,
BumpersEnabled = false,
BumperSelection = BumperSelection.Rotation,
NextBumperIndex = 0,
BumperFont = BumperFont.Sans,
BumperMinIntervalMinutes = 0,
BumperShowChangeChance = 1.0,
BumperEpisodeChangeChance = 1.0,
UtcOffsetMinutes = DefaultUtcOffsetMinutes,
DayStartTime = DefaultDayStartTime,
CreatedAt = DateTimeOffset.UtcNow,
};
// На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл).
channel._bumperTemplates.Add(BumperTemplate.Create(channel.Id, 0, DefaultTemplateName));
return channel;
}
public void UpdateSettings(
string name,
bool isEnabled,
bool bumpersEnabled,
Guid? fillerAssetId
)
{
Name = name;
IsEnabled = isEnabled;
BumpersEnabled = bumpersEnabled;
FillerAssetId = fillerAssetId;
}
/// <summary>
/// Общие настройки ТВ-заставок канала: шрифт, мин. интервал, стратегия выбора подблока и
/// вероятности появления на смене шоу / между блоками одного шоу (0..1).
/// </summary>
public void UpdateBumperSettings(
BumperFont font,
int minIntervalMinutes,
BumperSelection selection,
double showChangeChance,
double episodeChangeChance
)
{
BumperFont = font;
BumperMinIntervalMinutes = Math.Max(0, minIntervalMinutes);
BumperSelection = selection;
BumperShowChangeChance = Math.Clamp(showChangeChance, 0.0, 1.0);
BumperEpisodeChangeChance = Math.Clamp(episodeChangeChance, 0.0, 1.0);
}
/// <summary>Добавить блок заставки в конец списка. Возвращает созданный блок.</summary>
public BumperTemplate AddBumperTemplate(string name)
{
var nextPosition =
_bumperTemplates.Count == 0 ? 0 : _bumperTemplates.Max(t => t.Position) + 1;
var template = BumperTemplate.Create(Id, nextPosition, name);
_bumperTemplates.Add(template);
return template;
}
public BumperTemplate? FindBumperTemplate(Guid templateId) =>
_bumperTemplates.FirstOrDefault(t => t.Id == templateId);
/// <summary>Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false.</summary>
public bool RemoveBumperTemplate(Guid templateId)
{
var template = _bumperTemplates.FirstOrDefault(t => t.Id == templateId);
if (template is null || template.IsDefault)
return false;
_bumperTemplates.Remove(template);
return true;
}
/// <summary>Планировщик двигает курсор ротации блоков заставок по мере вставки.</summary>
public void SetNextBumperIndex(int index) => NextBumperIndex = index;
/// <summary>Привязать активный шаблон сетки.</summary>
public void SetTemplate(Guid? templateId) => TemplateId = templateId;
/// <summary>
/// Настройки времени канала: номер, смещение от UTC и начало вещательных суток. Смещение
/// ограничено сутками — за пределами этого диапазона сетка потеряла бы связь с календарём.
/// </summary>
public void UpdateTimeSettings(int? number, int utcOffsetMinutes, TimeOnly dayStartTime)
{
Number = number is > 0 ? number : null;
UtcOffsetMinutes = Math.Clamp(utcOffsetMinutes, -12 * 60, 14 * 60);
DayStartTime = dayStartTime;
}
}
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Канал линейного эфира. Что и когда идёт в эфире, определяет шаблон сетки (<see cref="TemplateId"/>,
/// см. <c>Domain/Programming</c>); канал хранит только собственные свойства: время, номер, аварийный
/// филлер и общие настройки заставок.
/// </summary>
public class Channel
{
private readonly List<BumperTemplate> _bumperTemplates = new();
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public string Slug { get; private set; } = string.Empty;
public bool IsEnabled { get; private set; }
/// <summary>Точка отсчёта эфирной ленты (UTC) — база для MEDIA-SEQUENCE на этапе раздачи.</summary>
public DateTimeOffset EpochUtc { get; private set; }
/// <summary>
/// Номер канала — на телевизоре канал это номер, а не карточка в сетке. Переключение по номерам
/// включается глобальным флагом настроек сайта; null — номер не задан.
/// </summary>
public int? Number { get; private set; }
/// <summary>
/// Смещение времени канала от UTC в минутах (по умолчанию 180 — московское). Фиксированный
/// оффсет, а не IANA-зона: с переводом часов сутки становятся 23- или 25-часовыми, и сетке
/// понадобилось бы отдельное правило подрезки. Появятся каналы в зонах с DST — перейдём на IANA.
/// </summary>
public int UtcOffsetMinutes { get; private set; }
/// <summary>
/// Начало вещательных суток в времени канала (по умолчанию 06:00). Ночной блок с 00:00 до 06:00
/// относится к предыдущему дню: «ночь с пятницы на субботу» — это пятница.
/// </summary>
public TimeOnly DayStartTime { get; private set; }
/// <summary>Активный шаблон сетки канала (один на канал).</summary>
public Guid? TemplateId { get; private set; }
public const int DefaultUtcOffsetMinutes = 180;
public static readonly TimeOnly DefaultDayStartTime = new(6, 0);
// ── Настройки ТВ-заставок. Условия показа (как часто, на смене шоу или между сериями)
// живут в элементах стыка; на канале осталось только общее для всех заставок. ──
/// <summary>Вставлять ли ТВ-заставки вообще: общий выключатель канала.</summary>
public bool BumpersEnabled { get; private set; }
/// <summary>Как выбирать подблок заставки на переходе (случайно/по весам/всегда первый).</summary>
public BumperSelection BumperSelection { get; private set; }
public BumperFont BumperFont { get; private set; }
private const string DefaultTemplateName = "Заставка 1";
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
public Guid? FillerAssetId { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
/// <summary>Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.</summary>
public IReadOnlyList<BumperTemplate> BumperTemplates => _bumperTemplates;
private Channel() { }
public static Channel Create(string name, string slug, DateTimeOffset epochUtc)
{
var channel = new Channel
{
Id = Guid.NewGuid(),
Name = name,
Slug = slug,
IsEnabled = true,
EpochUtc = epochUtc,
BumpersEnabled = false,
BumperSelection = BumperSelection.WeightedRandom,
BumperFont = BumperFont.Sans,
UtcOffsetMinutes = DefaultUtcOffsetMinutes,
DayStartTime = DefaultDayStartTime,
CreatedAt = DateTimeOffset.UtcNow,
};
// На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл).
channel._bumperTemplates.Add(BumperTemplate.Create(channel.Id, 0, DefaultTemplateName));
return channel;
}
public void UpdateSettings(
string name,
bool isEnabled,
bool bumpersEnabled,
Guid? fillerAssetId
)
{
Name = name;
IsEnabled = isEnabled;
BumpersEnabled = bumpersEnabled;
FillerAssetId = fillerAssetId;
}
/// <summary>Общие настройки ТВ-заставок канала: шрифт и стратегия выбора подблока.</summary>
public void UpdateBumperSettings(BumperFont font, BumperSelection selection)
{
BumperFont = font;
BumperSelection = selection;
}
/// <summary>Добавить блок заставки в конец списка. Возвращает созданный блок.</summary>
public BumperTemplate AddBumperTemplate(string name)
{
var nextPosition =
_bumperTemplates.Count == 0 ? 0 : _bumperTemplates.Max(t => t.Position) + 1;
var template = BumperTemplate.Create(Id, nextPosition, name);
_bumperTemplates.Add(template);
return template;
}
public BumperTemplate? FindBumperTemplate(Guid templateId) =>
_bumperTemplates.FirstOrDefault(t => t.Id == templateId);
/// <summary>Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false.</summary>
public bool RemoveBumperTemplate(Guid templateId)
{
var template = _bumperTemplates.FirstOrDefault(t => t.Id == templateId);
if (template is null || template.IsDefault)
return false;
_bumperTemplates.Remove(template);
return true;
}
/// <summary>Привязать активный шаблон сетки.</summary>
public void SetTemplate(Guid? templateId) => TemplateId = templateId;
/// <summary>
/// Настройки времени канала: номер, смещение от UTC и начало вещательных суток. Смещение
/// ограничено сутками — за пределами этого диапазона сетка потеряла бы связь с календарём.
/// </summary>
public void UpdateTimeSettings(int? number, int utcOffsetMinutes, TimeOnly dayStartTime)
{
Number = number is > 0 ? number : null;
UtcOffsetMinutes = Math.Clamp(utcOffsetMinutes, -12 * 60, 14 * 60);
DayStartTime = dayStartTime;
}
}
+4 -2
View File
@@ -109,7 +109,7 @@ public class Show
{
if (!CanAddEpisode)
throw new InvalidOperationException(
"Полнометражка (ShowKind.Single) может содержать только одну серию."
"Только сериал может содержать больше одной серии."
);
var nextPosition = _episodes.Count == 0 ? 0 : _episodes.Max(e => e.Position) + 1;
@@ -127,7 +127,9 @@ public class Show
return true;
}
public bool CanAddEpisode => Kind != ShowKind.Single || _episodes.Count == 0;
/// <summary>Несколько серий бывает только у сериала. У полнометражки и у ролика-врезки серия ровно
/// одна: ролик с тремя сериями вёл бы себя в планировщике как мини-сериал, а задуман как единица.</summary>
public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0;
/// <summary>Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null.</summary>
public void ApplyMetadata(
@@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class DropDeadBumperSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Значение 0 (прежняя «ротация») из перечисления убрано: курсора ротации больше нет,
// и выбор молча вырождался в случайный. Переводим такие каналы на выбор по весам.
migrationBuilder.Sql(
"""UPDATE "Channels" SET "BumperSelection" = 3 WHERE "BumperSelection" = 0;"""
);
migrationBuilder.DropColumn(
name: "BumperEpisodeChangeChance",
table: "Channels");
migrationBuilder.DropColumn(
name: "BumperMinIntervalMinutes",
table: "Channels");
migrationBuilder.DropColumn(
name: "BumperShowChangeChance",
table: "Channels");
migrationBuilder.DropColumn(
name: "NextBumperIndex",
table: "Channels");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "BumperEpisodeChangeChance",
table: "Channels",
type: "double precision",
nullable: false,
defaultValue: 0.0);
migrationBuilder.AddColumn<int>(
name: "BumperMinIntervalMinutes",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<double>(
name: "BumperShowChangeChance",
table: "Channels",
type: "double precision",
nullable: false,
defaultValue: 0.0);
migrationBuilder.AddColumn<int>(
name: "NextBumperIndex",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0);
}
}
}
@@ -318,21 +318,12 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid>("Id")
.HasColumnType("uuid");
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");
@@ -356,9 +347,6 @@ namespace TeleWave.Infrastructure.Migrations
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("NextBumperIndex")
.HasColumnType("integer");
b.Property<int?>("Number")
.HasColumnType("integer");
@@ -56,13 +56,7 @@ public class ChannelHandlersTests
"c",
true,
true,
new BumperSettingsInput(
BumperFont.Sans,
5,
BumperSelection.WeightedRandom,
0.5,
0.2
),
new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom),
null
),
CancellationToken.None
@@ -72,8 +66,7 @@ public class ChannelHandlersTests
await using var verify = fixture.New();
var stored = await verify.Channels.FindAsync(channel.Id);
Assert.Equal(0.5, stored!.BumperShowChangeChance);
Assert.Equal(0.2, stored.BumperEpisodeChangeChance);
Assert.Equal(BumperFont.Sans, stored!.BumperFont);
Assert.Equal(BumperSelection.WeightedRandom, stored.BumperSelection);
}
@@ -40,13 +40,7 @@ public class ValidatorTests
public void UpdateChannelSettings_ChecksRanges()
{
var v = new UpdateChannelSettingsCommandValidator();
var bumper = new BumperSettingsInput(
BumperFont.Sans,
10,
BumperSelection.Rotation,
0.5,
0.5
);
var bumper = new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom);
var good = new UpdateChannelSettingsCommand(
Guid.NewGuid(),
@@ -59,9 +53,6 @@ public class ValidatorTests
Assert.True(v.Validate(good).IsValid);
Assert.False(v.Validate(good with { Name = "" }).IsValid);
Assert.False(
v.Validate(good with { Bumper = bumper with { ShowChangeChance = 2 } }).IsValid
);
}
@@ -20,9 +20,7 @@ public class ChannelTests
Assert.True(channel.IsEnabled);
Assert.Equal(Epoch, channel.EpochUtc);
Assert.False(channel.BumpersEnabled);
Assert.Equal(BumperSelection.Rotation, channel.BumperSelection);
Assert.Equal(1.0, channel.BumperShowChangeChance);
Assert.Equal(1.0, channel.BumperEpisodeChangeChance);
Assert.Equal(BumperSelection.WeightedRandom, channel.BumperSelection);
Assert.Equal(Channel.DefaultUtcOffsetMinutes, channel.UtcOffsetMinutes);
Assert.Equal(Channel.DefaultDayStartTime, channel.DayStartTime);
@@ -46,32 +44,15 @@ public class ChannelTests
Assert.Equal(filler, channel.FillerAssetId);
}
[Theory]
[InlineData(-1, 2.0, -5.0, 1.0, 0.0)]
[InlineData(30, 0.3, 0.9, 0.3, 0.9)]
public void UpdateBumperSettings_ClampsChancesAndInterval(
int interval,
double show,
double episode,
double expectedShow,
double expectedEpisode
)
[Fact]
public void UpdateBumperSettings_ChangesFontAndSelection()
{
var channel = NewChannel();
channel.UpdateBumperSettings(
BumperFont.Serif,
interval,
BumperSelection.WeightedRandom,
show,
episode
);
channel.UpdateBumperSettings(BumperFont.Serif, BumperSelection.AlwaysFirst);
Assert.Equal(BumperFont.Serif, channel.BumperFont);
Assert.Equal(Math.Max(0, interval), channel.BumperMinIntervalMinutes);
Assert.Equal(BumperSelection.WeightedRandom, channel.BumperSelection);
Assert.Equal(expectedShow, channel.BumperShowChangeChance);
Assert.Equal(expectedEpisode, channel.BumperEpisodeChangeChance);
Assert.Equal(BumperSelection.AlwaysFirst, channel.BumperSelection);
}
[Fact]