Enhance channel and settings functionalities: introduce viewer settings in ChannelEndpoints, update SiteSettings to include channel number toggling, and refactor related data structures. Implement new endpoints for validating templates and diffing scheduling changes, improving overall user experience and configuration management.
This commit is contained in:
@@ -49,5 +49,16 @@ public sealed record ChannelDto(
|
||||
bool BumpersEnabled,
|
||||
BumperSettingsDto Bumper,
|
||||
IReadOnlyList<BumperTemplateDto> BumperTemplates,
|
||||
Guid? FillerAssetId
|
||||
Guid? FillerAssetId,
|
||||
/// <summary>Оверлеи и фильтр зрительской части — всё опционально (см. 6.8).</summary>
|
||||
ViewerSettingsDto Viewer
|
||||
);
|
||||
|
||||
/// <summary>Как канал выглядит у зрителя: логотип-оверлей, часы, аналоговый фильтр.</summary>
|
||||
public sealed record ViewerSettingsDto(
|
||||
Guid? LogoImageId,
|
||||
LogoCorner LogoCorner,
|
||||
double LogoOpacity,
|
||||
bool ShowClock,
|
||||
double AnalogFilterStrength
|
||||
);
|
||||
|
||||
@@ -67,7 +67,14 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
||||
channel.BumpersEnabled,
|
||||
new BumperSettingsDto(channel.BumperFont, channel.BumperSelection),
|
||||
bumperTemplates,
|
||||
channel.FillerAssetId
|
||||
channel.FillerAssetId,
|
||||
new ViewerSettingsDto(
|
||||
channel.LogoImageId,
|
||||
channel.LogoCorner,
|
||||
channel.LogoOpacity,
|
||||
channel.ShowClock,
|
||||
channel.AnalogFilterStrength
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
using FluentValidation;
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.UpdateViewerSettings;
|
||||
|
||||
/// <summary>
|
||||
/// Оверлеи и аналоговый фильтр канала (см. 6.8). Отдельной командой, а не в общих настройках:
|
||||
/// это про то, как канал выглядит у зрителя, и правится другими руками и в другой момент.
|
||||
/// </summary>
|
||||
public sealed record UpdateViewerSettingsCommand(
|
||||
Guid ChannelId,
|
||||
Guid? LogoImageId,
|
||||
LogoCorner LogoCorner,
|
||||
double LogoOpacity,
|
||||
bool ShowClock,
|
||||
double AnalogFilterStrength
|
||||
) : ICommand<Result>;
|
||||
|
||||
public sealed class UpdateViewerSettingsCommandValidator
|
||||
: AbstractValidator<UpdateViewerSettingsCommand>
|
||||
{
|
||||
public UpdateViewerSettingsCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.LogoOpacity).InclusiveBetween(0.0, 1.0);
|
||||
RuleFor(x => x.AnalogFilterStrength).InclusiveBetween(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.UpdateViewerSettings;
|
||||
|
||||
public sealed class UpdateViewerSettingsCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateViewerSettingsCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateViewerSettingsCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext.Channels.FirstOrDefaultAsync(
|
||||
c => c.Id == command.ChannelId,
|
||||
cancellationToken
|
||||
);
|
||||
if (channel is null)
|
||||
return Result.Failure(ChannelErrors.NotFound);
|
||||
|
||||
if (
|
||||
command.LogoImageId is { } imageId
|
||||
&& !await dbContext.Images.AnyAsync(i => i.Id == imageId, cancellationToken)
|
||||
)
|
||||
return Result.Failure(ChannelErrors.AssetNotFound);
|
||||
|
||||
channel.UpdateViewerSettings(
|
||||
command.LogoImageId,
|
||||
command.LogoCorner,
|
||||
command.LogoOpacity,
|
||||
command.ShowClock,
|
||||
command.AnalogFilterStrength
|
||||
);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>Доступ к глобальным настройкам сайта (key-value), скрывающий хранилище от хендлеров.</summary>
|
||||
public interface ISiteSettings
|
||||
{
|
||||
Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Пишет значение в контекст (сохранение — за UnitOfWorkBehavior вызывающей команды).</summary>
|
||||
Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
|
||||
/// (напр. «rus,eng»); пусто — без предпочтения.</summary>
|
||||
Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task SetPreferredAudioLanguagesAsync(string value, CancellationToken cancellationToken);
|
||||
}
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>Доступ к глобальным настройкам сайта (key-value), скрывающий хранилище от хендлеров.</summary>
|
||||
public interface ISiteSettings
|
||||
{
|
||||
Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Пишет значение в контекст (сохранение — за UnitOfWorkBehavior вызывающей команды).</summary>
|
||||
Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
|
||||
/// (напр. «rus,eng»); пусто — без предпочтения.</summary>
|
||||
Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task SetPreferredAudioLanguagesAsync(string value, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Разрешено ли переключение каналов по номерам (см. 6.8).</summary>
|
||||
Task<bool> AreChannelNumbersEnabledAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task SetChannelNumbersEnabledAsync(bool enabled, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Planning.Diff;
|
||||
|
||||
/// <summary>
|
||||
/// Что изменится в эфире, если применить правила сейчас (см. 6.6). Прогон сухой: ни лента,
|
||||
/// ни курсоры слотов не трогаются.
|
||||
/// </summary>
|
||||
public sealed record PreviewApplyDiffQuery(Guid ChannelId) : IQuery<Result<ScheduleDiffDto>>;
|
||||
|
||||
/// <summary>Одно расхождение: что стоит в эфире сейчас и что встанет после применения.</summary>
|
||||
public sealed record ScheduleChangeDto(
|
||||
DateTimeOffset StartsAtUtc,
|
||||
string? Before,
|
||||
string? After,
|
||||
/// <summary>Попадает ли в ближайшие сутки — самая частая причина случайного ущерба.</summary>
|
||||
bool Soon
|
||||
);
|
||||
|
||||
public sealed record ScheduleDiffDto(
|
||||
/// <summary>Сколько будущих записей затрагивает пересборка.</summary>
|
||||
int Total,
|
||||
int Changed,
|
||||
int ChangedSoon,
|
||||
/// <summary>Первые расхождения; сколько осталось за списком, видно по <c>Changed</c>.</summary>
|
||||
IReadOnlyList<ScheduleChangeDto> Changes
|
||||
);
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Broadcast.Scheduling;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Programming.Planning;
|
||||
|
||||
namespace TeleWave.Application.Programming.Planning.Diff;
|
||||
|
||||
public sealed class PreviewApplyDiffQueryHandler(
|
||||
IAppDbContext dbContext,
|
||||
GridScheduleGenerator generator,
|
||||
IOptions<SchedulerOptions> options
|
||||
) : IQueryHandler<PreviewApplyDiffQuery, Result<ScheduleDiffDto>>
|
||||
{
|
||||
/// <summary>Сколько расхождений отдаём списком: остальное всё равно не читают, а счётчик честный.</summary>
|
||||
private const int MaxChanges = 100;
|
||||
|
||||
public async Task<Result<ScheduleDiffDto>> Handle(
|
||||
PreviewApplyDiffQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var future = await dbContext
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.Where(e => e.ChannelId == query.ChannelId && e.StartsAtUtc >= now)
|
||||
.OrderBy(e => e.StartsAtUtc)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Применение не трогает идущую сейчас запись, поэтому пересборка начинается с её конца —
|
||||
// считать иначе значило бы показать диф, которого не будет.
|
||||
var currentEnd = await dbContext
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.Where(e => e.ChannelId == query.ChannelId && e.StartsAtUtc < now && e.EndsAtUtc > now)
|
||||
.MaxAsync(e => (DateTimeOffset?)e.EndsAtUtc, cancellationToken);
|
||||
|
||||
var result = await generator.PreviewAsync(
|
||||
query.ChannelId,
|
||||
currentEnd ?? now,
|
||||
Math.Max(1, options.Value.HorizonDays),
|
||||
cancellationToken
|
||||
);
|
||||
if (result is null)
|
||||
return Result.Failure<ScheduleDiffDto>(ChannelErrors.TemplateNotFound);
|
||||
|
||||
var showNames = await LoadShowNamesAsync(future, result.Items, cancellationToken);
|
||||
var soonUntil = now.AddHours(24);
|
||||
|
||||
var before = future.Select(e => Describe(e, showNames)).ToList();
|
||||
var after = result.Items.Select(i => Describe(i, showNames)).ToList();
|
||||
|
||||
var changes = new List<ScheduleChangeDto>();
|
||||
var changed = 0;
|
||||
var changedSoon = 0;
|
||||
|
||||
for (var i = 0; i < Math.Max(before.Count, after.Count); i++)
|
||||
{
|
||||
var oldItem = i < before.Count ? before[i] : null;
|
||||
var newItem = i < after.Count ? after[i] : null;
|
||||
if (oldItem?.Label == newItem?.Label && oldItem?.StartsAtUtc == newItem?.StartsAtUtc)
|
||||
continue;
|
||||
|
||||
var startsAt = newItem?.StartsAtUtc ?? oldItem!.StartsAtUtc;
|
||||
var soon = startsAt < soonUntil;
|
||||
|
||||
changed++;
|
||||
if (soon)
|
||||
changedSoon++;
|
||||
if (changes.Count < MaxChanges)
|
||||
changes.Add(new ScheduleChangeDto(startsAt, oldItem?.Label, newItem?.Label, soon));
|
||||
}
|
||||
|
||||
return Result.Success(new ScheduleDiffDto(future.Count, changed, changedSoon, changes));
|
||||
}
|
||||
|
||||
private sealed record Described(DateTimeOffset StartsAtUtc, string Label);
|
||||
|
||||
private static Described Describe(ScheduleEntry entry, IReadOnlyDictionary<Guid, string> names) =>
|
||||
new(
|
||||
entry.StartsAtUtc,
|
||||
entry.Kind switch
|
||||
{
|
||||
ScheduleEntryKind.Program => Title(entry.ShowId, entry.EpisodeIndex, names),
|
||||
ScheduleEntryKind.Ad => $"реклама {Seconds(entry.EndsAtUtc - entry.StartsAtUtc)}",
|
||||
ScheduleEntryKind.Bumper => "заставка",
|
||||
ScheduleEntryKind.SignOff => "конец вещания",
|
||||
_ => "фон",
|
||||
}
|
||||
);
|
||||
|
||||
private static Described Describe(PlannedItem item, IReadOnlyDictionary<Guid, string> names) =>
|
||||
new(
|
||||
item.StartsAtUtc,
|
||||
item.Kind switch
|
||||
{
|
||||
PlannedItemKind.Program => Title(item.ShowId, item.UnitIndex, names),
|
||||
PlannedItemKind.Ad or PlannedItemKind.Promo =>
|
||||
$"реклама {Seconds(item.EndsAtUtc - item.StartsAtUtc)}",
|
||||
PlannedItemKind.Bumper => "заставка",
|
||||
PlannedItemKind.SignOff => "конец вещания",
|
||||
_ => "фон",
|
||||
}
|
||||
);
|
||||
|
||||
private static string Title(
|
||||
Guid? showId,
|
||||
int? unitIndex,
|
||||
IReadOnlyDictionary<Guid, string> names
|
||||
)
|
||||
{
|
||||
var name = showId is { } id && names.TryGetValue(id, out var value) ? value : "—";
|
||||
return unitIndex is { } index ? $"{name} · {index + 1}" : name;
|
||||
}
|
||||
|
||||
private static string Seconds(TimeSpan duration) => $"{(int)duration.TotalSeconds} с";
|
||||
|
||||
private async Task<Dictionary<Guid, string>> LoadShowNamesAsync(
|
||||
IReadOnlyList<ScheduleEntry> future,
|
||||
IReadOnlyList<PlannedItem> planned,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var ids = future
|
||||
.Select(e => e.ShowId)
|
||||
.Concat(planned.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);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ public sealed class GridScheduleGenerator(
|
||||
IAppDbContext dbContext,
|
||||
GroupExpander expander,
|
||||
BumperResolver bumperResolver,
|
||||
PostCheckRunner postChecks,
|
||||
IRandomSource random,
|
||||
IOptions<SchedulerOptions> options,
|
||||
IOptions<StreamingOptions> streamingOptions
|
||||
@@ -105,6 +106,16 @@ public sealed class GridScheduleGenerator(
|
||||
);
|
||||
var result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random);
|
||||
|
||||
// Пост-проверки считаются по готовой ленте и только предупреждают — переигрывать что-либо
|
||||
// по их итогу мы намеренно не будем (см. 3.8).
|
||||
var postWarnings = await postChecks.RunAsync(
|
||||
result.Items,
|
||||
PlanningRules.FromJson(template.RulesJson),
|
||||
channel.UtcOffsetMinutes,
|
||||
channel.DayStartTime,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
// Заставки резолвятся после сборки ленты: пара соседей известна только теперь.
|
||||
var bumperAssets = await bumperResolver.ResolveAsync(
|
||||
channel,
|
||||
@@ -143,7 +154,7 @@ public sealed class GridScheduleGenerator(
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return new GenerationReport(added, result.Warnings);
|
||||
return new GenerationReport(added, [.. result.Warnings, .. postWarnings]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -176,7 +187,17 @@ public sealed class GridScheduleGenerator(
|
||||
|
||||
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);
|
||||
var result = Domain.Programming.Planning.SchedulePlanner.Plan(input, random);
|
||||
|
||||
var postWarnings = await postChecks.RunAsync(
|
||||
result.Items,
|
||||
PlanningRules.FromJson(template.RulesJson),
|
||||
channel.UtcOffsetMinutes,
|
||||
channel.DayStartTime,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return result with { Warnings = [.. result.Warnings, .. postWarnings] };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Domain.Programming.Planning;
|
||||
|
||||
namespace TeleWave.Application.Programming.Planning;
|
||||
|
||||
/// <summary>
|
||||
/// Пост-проверки по готовой ленте (см. 3.8, 5.2). Только предупреждения: пересборки слота при
|
||||
/// нарушении нет намеренно — она сделала бы результат зависимым от порядка проверок и обесценила
|
||||
/// бы трейс «почему это здесь».
|
||||
/// </summary>
|
||||
public sealed class PostCheckRunner(IAppDbContext dbContext)
|
||||
{
|
||||
public async Task<IReadOnlyList<PlanningWarning>> RunAsync(
|
||||
IReadOnlyList<PlannedItem> items,
|
||||
PlanningRules? rules,
|
||||
int utcOffsetMinutes,
|
||||
TimeOnly dayStartTime,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (rules is null || items.Count == 0)
|
||||
return [];
|
||||
|
||||
var warnings = new List<PlanningWarning>();
|
||||
var offset = TimeSpan.FromMinutes(utcOffsetMinutes);
|
||||
|
||||
if (rules.MaxBreakMinutesPerHour is { } breakCap and > 0)
|
||||
warnings.AddRange(CheckBreaks(items, offset, breakCap));
|
||||
|
||||
if (rules.MaxFallbackSharePercent is { } fallbackCap and > 0)
|
||||
warnings.AddRange(CheckFallbackShare(items, fallbackCap));
|
||||
|
||||
if (rules.MaxGenreSharePercent is { } genreCap and > 0)
|
||||
warnings.AddRange(
|
||||
await CheckGenreShareAsync(items, offset, dayStartTime, genreCap, cancellationToken)
|
||||
);
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private static IEnumerable<PlanningWarning> CheckBreaks(
|
||||
IReadOnlyList<PlannedItem> items,
|
||||
TimeSpan offset,
|
||||
int capMinutes
|
||||
)
|
||||
{
|
||||
var byHour = items
|
||||
.Where(IsBreak)
|
||||
.GroupBy(i => Truncate(i.StartsAtUtc + offset, TimeSpan.FromHours(1)))
|
||||
.Select(g => new
|
||||
{
|
||||
Hour = g.Key,
|
||||
Minutes = g.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes),
|
||||
})
|
||||
.Where(x => x.Minutes > capMinutes)
|
||||
.OrderBy(x => x.Hour);
|
||||
|
||||
foreach (var hour in byHour)
|
||||
yield return new PlanningWarning(
|
||||
PlanningWarningKind.BreakLimitExceeded,
|
||||
null,
|
||||
$"{hour.Hour:dd.MM HH:mm} — врезок {hour.Minutes:0} мин при потолке {capMinutes}."
|
||||
);
|
||||
}
|
||||
|
||||
private static IEnumerable<PlanningWarning> CheckFallbackShare(
|
||||
IReadOnlyList<PlannedItem> items,
|
||||
int capPercent
|
||||
)
|
||||
{
|
||||
var total = items.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes);
|
||||
if (total <= 0)
|
||||
yield break;
|
||||
|
||||
// Конец вещания в долю фона не входит: это осознанная настройка, а не нехватка контента.
|
||||
var fallback = items
|
||||
.Where(i => i.Kind == PlannedItemKind.Fallback)
|
||||
.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes);
|
||||
|
||||
var share = fallback / total * 100;
|
||||
if (share > capPercent)
|
||||
yield return new PlanningWarning(
|
||||
PlanningWarningKind.FallbackShareExceeded,
|
||||
null,
|
||||
$"Фон занял {share:0}% эфира при норме {capPercent}% — контента не хватает."
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Доля жанра за вещательные сутки. Считается по основному жанру шоу: у контента их бывает
|
||||
/// несколько, но «доля боевиков за день» осмысленна только по одному, иначе сумма долей
|
||||
/// перевалит за сто процентов и порог потеряет смысл.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<PlanningWarning>> CheckGenreShareAsync(
|
||||
IReadOnlyList<PlannedItem> items,
|
||||
TimeSpan offset,
|
||||
TimeOnly dayStartTime,
|
||||
int capPercent,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var showIds = items
|
||||
.Where(i => i.Kind == PlannedItemKind.Program && i.ShowId is not null)
|
||||
.Select(i => i.ShowId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (showIds.Count == 0)
|
||||
return [];
|
||||
|
||||
var primaryGenre = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
GenreId = s.Genres.Where(g => g.IsPrimary).Select(g => (Guid?)g.GenreId).FirstOrDefault(),
|
||||
})
|
||||
.Where(s => s.GenreId != null)
|
||||
.ToDictionaryAsync(s => s.Id, s => s.GenreId!.Value, cancellationToken);
|
||||
if (primaryGenre.Count == 0)
|
||||
return [];
|
||||
|
||||
var genreNames = await dbContext
|
||||
.Genres.AsNoTracking()
|
||||
.Where(g => primaryGenre.Values.Contains(g.Id))
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
|
||||
|
||||
var warnings = new List<PlanningWarning>();
|
||||
var days = items
|
||||
.Where(i => i.Kind == PlannedItemKind.Program && i.ShowId is not null)
|
||||
.GroupBy(i => BroadcastDate(i.StartsAtUtc + offset, dayStartTime));
|
||||
|
||||
foreach (var day in days)
|
||||
{
|
||||
var total = day.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes);
|
||||
if (total <= 0)
|
||||
continue;
|
||||
|
||||
var byGenre = day.Where(i => primaryGenre.ContainsKey(i.ShowId!.Value))
|
||||
.GroupBy(i => primaryGenre[i.ShowId!.Value])
|
||||
.Select(g => new
|
||||
{
|
||||
GenreId = g.Key,
|
||||
Minutes = g.Sum(i => (i.EndsAtUtc - i.StartsAtUtc).TotalMinutes),
|
||||
})
|
||||
.Where(g => g.Minutes / total * 100 > capPercent);
|
||||
|
||||
foreach (var genre in byGenre)
|
||||
warnings.Add(
|
||||
new PlanningWarning(
|
||||
PlanningWarningKind.GenreShareExceeded,
|
||||
null,
|
||||
$"{day.Key:dd.MM} — «{(genreNames.TryGetValue(genre.GenreId, out var name) ? name : genre.GenreId)}» "
|
||||
+ $"занял {genre.Minutes / total * 100:0}% суток при норме {capPercent}%."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private static bool IsBreak(PlannedItem item) =>
|
||||
item.Kind is PlannedItemKind.Ad or PlannedItemKind.Promo or PlannedItemKind.Bumper;
|
||||
|
||||
private static DateTimeOffset Truncate(DateTimeOffset value, TimeSpan step) =>
|
||||
value.AddTicks(-(value.Ticks % step.Ticks));
|
||||
|
||||
/// <summary>Вещательные сутки момента: ночь до <paramref name="dayStartTime"/> — вчерашний день.</summary>
|
||||
private static DateOnly BroadcastDate(DateTimeOffset channelTime, TimeOnly dayStartTime) =>
|
||||
DateOnly.FromDateTime(
|
||||
TimeOnly.FromDateTime(channelTime.DateTime) >= dayStartTime
|
||||
? channelTime.Date
|
||||
: channelTime.Date.AddDays(-1)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Domain.Programming.Planning;
|
||||
|
||||
namespace TeleWave.Application.Programming.Planning.Trace;
|
||||
|
||||
/// <summary>
|
||||
/// Цепочка происхождения записи — «почему это здесь» (см. 6.5). Трейс пишется в момент генерации
|
||||
/// и хранится в самой записи: восстановить его потом невозможно, состав групп и правила меняются.
|
||||
/// </summary>
|
||||
public sealed record GetEntryTraceQuery(Guid EntryId) : IQuery<Result<EntryTraceDto>>;
|
||||
|
||||
public sealed record EntryTraceDto(
|
||||
Guid EntryId,
|
||||
DateTimeOffset StartsAtUtc,
|
||||
DateTimeOffset EndsAtUtc,
|
||||
string? ShowName,
|
||||
int? EpisodeIndex,
|
||||
/// <summary>Слой и слот, из которых выросла запись; null — трейс не писался (старая запись).</summary>
|
||||
string? LayerName,
|
||||
int? LayerPriority,
|
||||
string? SlotTitle,
|
||||
SlotKind? SlotKind,
|
||||
int? SlotWeekday,
|
||||
TimeOnly? SlotTargetStart,
|
||||
int? SlotDurationMinutes,
|
||||
string? GroupName,
|
||||
int? GroupItemCount,
|
||||
SlotStrategyKind? Strategy,
|
||||
int? CooldownDays,
|
||||
/// <summary>Сколько кандидатов осталось после остывания (null — выбор шёл без него).</summary>
|
||||
int? CandidatesAfterCooldown,
|
||||
int DriftMinutes,
|
||||
bool Snapped,
|
||||
string? JunctionName
|
||||
);
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Domain.Programming.Planning;
|
||||
|
||||
namespace TeleWave.Application.Programming.Planning.Trace;
|
||||
|
||||
public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<GetEntryTraceQuery, Result<EntryTraceDto>>
|
||||
{
|
||||
/// <summary>Те же настройки, что при записи трейса генератором.</summary>
|
||||
private static readonly JsonSerializerOptions TraceJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
public async Task<Result<EntryTraceDto>> Handle(
|
||||
GetEntryTraceQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var entry = await dbContext
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.Id == query.EntryId, cancellationToken);
|
||||
if (entry is null)
|
||||
return Result.Failure<EntryTraceDto>(ChannelErrors.NotFound);
|
||||
|
||||
var showName =
|
||||
entry.ShowId is { } showId
|
||||
? await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => s.Id == showId)
|
||||
.Select(s => s.Name)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
var trace = Parse(entry.TraceJson);
|
||||
if (trace is null)
|
||||
return Result.Success(
|
||||
new EntryTraceDto(
|
||||
entry.Id,
|
||||
entry.StartsAtUtc,
|
||||
entry.EndsAtUtc,
|
||||
showName,
|
||||
entry.EpisodeIndex,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
false,
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
// Слот мог быть удалён или изменён после генерации — трейс от этого не портится, просто
|
||||
// часть подписей окажется пустой.
|
||||
var slot = trace.SlotId is { } slotId
|
||||
? await dbContext.Slots.AsNoTracking().FirstOrDefaultAsync(s => s.Id == slotId, cancellationToken)
|
||||
: null;
|
||||
var layer = slot is null
|
||||
? null
|
||||
: await dbContext
|
||||
.GridLayers.AsNoTracking()
|
||||
.FirstOrDefaultAsync(l => l.Id == slot.LayerId, cancellationToken);
|
||||
|
||||
var group = slot?.GroupId is { } groupId
|
||||
? await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Where(g => g.Id == groupId)
|
||||
.Select(g => new { g.Name, g.ItemCount })
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
var strategy = SlotStrategy.FromJson(slot?.StrategyJson);
|
||||
|
||||
var junctionId = slot?.JunctionAfterId ?? slot?.JunctionBetweenId;
|
||||
var junctionName = junctionId is { } id
|
||||
? await dbContext
|
||||
.JunctionTemplates.AsNoTracking()
|
||||
.Where(j => j.Id == id)
|
||||
.Select(j => j.Name)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
|
||||
return Result.Success(
|
||||
new EntryTraceDto(
|
||||
entry.Id,
|
||||
entry.StartsAtUtc,
|
||||
entry.EndsAtUtc,
|
||||
showName,
|
||||
entry.EpisodeIndex,
|
||||
layer?.Name,
|
||||
layer?.Priority,
|
||||
slot?.Title,
|
||||
trace.SlotKind,
|
||||
slot?.Weekday,
|
||||
slot?.TargetStart,
|
||||
slot?.TargetDurationMinutes,
|
||||
group?.Name,
|
||||
group?.ItemCount,
|
||||
trace.Strategy,
|
||||
strategy?.CooldownDays,
|
||||
trace.CandidatesAfterCooldown,
|
||||
trace.DriftMinutes,
|
||||
trace.Snapped,
|
||||
junctionName
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private static PlanTrace? Parse(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<PlanTrace>(json, TraceJsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
using FluentValidation;
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.CopyTemplate;
|
||||
|
||||
/// <summary>
|
||||
/// Копирует сетку канала на другой канал: слои, слоты, стыки и правила. Группы не копируются —
|
||||
/// они общие для всех каналов. Прежний шаблон канала-приёмника заменяется целиком.
|
||||
/// </summary>
|
||||
public sealed record CopyTemplateCommand(Guid SourceChannelId, Guid TargetChannelId)
|
||||
: ICommand<Result<CopyTemplateResultDto>>;
|
||||
|
||||
/// <summary>
|
||||
/// Что скопировалось. <paramref name="DroppedBumperRefs"/> — врезки-заставки, для которых на канале
|
||||
/// -приёмнике не нашлось блока с таким же именем: ссылка снята, врезку надо донастроить руками.
|
||||
/// </summary>
|
||||
public sealed record CopyTemplateResultDto(
|
||||
int Layers,
|
||||
int Slots,
|
||||
int Junctions,
|
||||
int DroppedBumperRefs
|
||||
);
|
||||
|
||||
public sealed class CopyTemplateCommandValidator : AbstractValidator<CopyTemplateCommand>
|
||||
{
|
||||
public CopyTemplateCommandValidator() =>
|
||||
RuleFor(x => x.TargetChannelId)
|
||||
.NotEqual(x => x.SourceChannelId)
|
||||
.WithMessage("Копировать сетку саму на себя бессмысленно.");
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.CopyTemplate;
|
||||
|
||||
public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<CopyTemplateCommand, Result<CopyTemplateResultDto>>
|
||||
{
|
||||
public async Task<Result<CopyTemplateResultDto>> Handle(
|
||||
CopyTemplateCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var target = await dbContext
|
||||
.Channels.Include(c => c.BumperTemplates)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.TargetChannelId, cancellationToken);
|
||||
if (target is null)
|
||||
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.NotFound);
|
||||
|
||||
var source = await dbContext
|
||||
.ScheduleTemplates.AsNoTracking()
|
||||
.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.FirstOrDefaultAsync(t => t.ChannelId == command.SourceChannelId, cancellationToken);
|
||||
if (source is null)
|
||||
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.TemplateNotFound);
|
||||
|
||||
var sourceJunctions = await dbContext
|
||||
.JunctionTemplates.AsNoTracking()
|
||||
.Include(j => j.Elements)
|
||||
.Where(j => j.ChannelId == command.SourceChannelId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Заставки живут на канале и на диске, поэтому не копируются: врезка ищет блок с таким же
|
||||
// именем у приёмника, а не найдя — остаётся без ссылки, и это возвращается в отчёте.
|
||||
var bumperByName = target
|
||||
.BumperTemplates.GroupBy(t => t.Name)
|
||||
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||
var sourceBumperNames = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.Where(c => c.Id == command.SourceChannelId)
|
||||
.SelectMany(c => c.BumperTemplates)
|
||||
.Select(t => new { t.Id, t.Name })
|
||||
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
|
||||
|
||||
var junctionMap = new Dictionary<Guid, Guid>();
|
||||
var droppedBumperRefs = 0;
|
||||
|
||||
foreach (var junction in sourceJunctions)
|
||||
{
|
||||
var copy = JunctionTemplate.Create(target.Id, junction.Name);
|
||||
junctionMap[junction.Id] = copy.Id;
|
||||
|
||||
foreach (var element in junction.Elements.OrderBy(e => e.Position))
|
||||
{
|
||||
var created = copy.AddElement(element.Kind);
|
||||
|
||||
Guid? bumperTemplateId = null;
|
||||
if (element.Kind == JunctionElementKind.Bumper)
|
||||
{
|
||||
if (
|
||||
element.BumperTemplateId is { } sourceId
|
||||
&& sourceBumperNames.TryGetValue(sourceId, out var name)
|
||||
&& bumperByName.TryGetValue(name, out var mapped)
|
||||
)
|
||||
bumperTemplateId = mapped;
|
||||
else
|
||||
droppedBumperRefs++;
|
||||
}
|
||||
|
||||
created.Update(
|
||||
element.Kind,
|
||||
element.GroupId,
|
||||
bumperTemplateId,
|
||||
element.AmountMode,
|
||||
element.AmountValue,
|
||||
element.IsRequired,
|
||||
element.ConditionsJson
|
||||
);
|
||||
}
|
||||
|
||||
dbContext.JunctionTemplates.Add(copy);
|
||||
}
|
||||
|
||||
// Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя,
|
||||
// а «добавить поверх» дало бы кашу из пересекающихся слотов.
|
||||
var existing = await dbContext
|
||||
.ScheduleTemplates.Where(t => t.ChannelId == target.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
dbContext.ScheduleTemplates.RemoveRange(existing);
|
||||
await dbContext
|
||||
.JunctionTemplates.Where(j =>
|
||||
j.ChannelId == target.Id && !junctionMap.Values.Contains(j.Id)
|
||||
)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
var copyTemplate = ScheduleTemplate.Create(target.Id, source.Name);
|
||||
copyTemplate.SetFallbackGroup(source.FallbackGroupId);
|
||||
copyTemplate.SetRules(source.RulesJson);
|
||||
if (source.DefaultJunctionId is { } defaultJunction && junctionMap.TryGetValue(defaultJunction, out var mappedDefault))
|
||||
copyTemplate.SetDefaultJunction(mappedDefault);
|
||||
|
||||
var layers = 0;
|
||||
var slots = 0;
|
||||
|
||||
foreach (var layer in source.Layers.OrderByDescending(l => l.Priority))
|
||||
{
|
||||
// Фоновый слой у нового шаблона уже есть — в него переносим слоты, а не заводим второй.
|
||||
var copyLayer = layer.IsBackground
|
||||
? copyTemplate.Background!
|
||||
: copyTemplate.AddLayer(layer.Name, layer.Priority);
|
||||
copyLayer.Update(layer.Name, layer.Priority, layer.ApplicabilityJson, layer.IsEnabled);
|
||||
if (!layer.IsBackground)
|
||||
layers++;
|
||||
|
||||
foreach (var slot in layer.Slots)
|
||||
{
|
||||
var copySlot = copyLayer.AddSlot(
|
||||
slot.Title,
|
||||
slot.TargetStart,
|
||||
slot.TargetDurationMinutes,
|
||||
slot.Daypart,
|
||||
slot.SlotKind,
|
||||
slot.Weekday
|
||||
);
|
||||
copySlot.UpdateTiming(
|
||||
slot.Weekday,
|
||||
slot.TargetStart,
|
||||
slot.TargetDurationMinutes,
|
||||
slot.Daypart,
|
||||
slot.IsAnchor,
|
||||
slot.MaxDriftMinutes,
|
||||
slot.SnapToMinutes
|
||||
);
|
||||
copySlot.UpdateContent(
|
||||
slot.Title,
|
||||
slot.SlotKind,
|
||||
slot.GroupId,
|
||||
slot.StrategyJson,
|
||||
slot.RepeatSourceJson,
|
||||
slot.BlockMode,
|
||||
slot.BlockValue,
|
||||
slot.OverflowPolicy,
|
||||
Map(slot.JunctionBetweenId, junctionMap),
|
||||
Map(slot.JunctionAfterId, junctionMap)
|
||||
);
|
||||
slots++;
|
||||
}
|
||||
}
|
||||
|
||||
dbContext.ScheduleTemplates.Add(copyTemplate);
|
||||
target.SetTemplate(copyTemplate.Id);
|
||||
|
||||
return Result.Success(
|
||||
new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs)
|
||||
);
|
||||
}
|
||||
|
||||
private static Guid? Map(Guid? id, IReadOnlyDictionary<Guid, Guid> map) =>
|
||||
id is { } value && map.TryGetValue(value, out var mapped) ? mapped : null;
|
||||
}
|
||||
@@ -1,72 +1,79 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
/// <summary>
|
||||
/// Окно детского времени: с <paramref name="From"/> до <paramref name="To"/> во времени канала
|
||||
/// в эфир идёт только контент не строже <paramref name="MaxAudience"/>. Окно может переходить
|
||||
/// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону.
|
||||
/// </summary>
|
||||
public sealed record AudienceWindow(TimeOnly From, TimeOnly To, ShowAudience MaxAudience)
|
||||
{
|
||||
public bool Contains(TimeOnly moment) =>
|
||||
From <= To ? moment >= From && moment < To : moment >= From || moment < To;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают
|
||||
/// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость.
|
||||
///
|
||||
/// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени
|
||||
/// и так задаются временем, а потолок повторов осмыслен только целиком по каналу.
|
||||
/// </summary>
|
||||
public sealed record PlanningRules(
|
||||
IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null,
|
||||
/// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary>
|
||||
RepeatLimitRule? MaxRepeatsInWindow = null
|
||||
)
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary>Возрастной потолок в указанный момент времени канала (null — без ограничения).</summary>
|
||||
public ShowAudience? AudienceAt(TimeOnly moment)
|
||||
{
|
||||
if (MaxAudienceByTime is not { Count: > 0 } windows)
|
||||
return null;
|
||||
|
||||
// Пересекающиеся окна разрешаются в пользу строгого: детское время не должно
|
||||
// отменяться более широким окном, случайно наложенным сверху.
|
||||
ShowAudience? strictest = null;
|
||||
foreach (var window in windows.Where(w => w.Contains(moment)))
|
||||
strictest = strictest is { } current && current <= window.MaxAudience
|
||||
? current
|
||||
: window.MaxAudience;
|
||||
return strictest;
|
||||
}
|
||||
|
||||
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||
|
||||
public static PlanningRules? FromJson(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<PlanningRules>(json, Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RepeatLimitRule(int WindowDays, int Max);
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates;
|
||||
|
||||
/// <summary>
|
||||
/// Окно детского времени: с <paramref name="From"/> до <paramref name="To"/> во времени канала
|
||||
/// в эфир идёт только контент не строже <paramref name="MaxAudience"/>. Окно может переходить
|
||||
/// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону.
|
||||
/// </summary>
|
||||
public sealed record AudienceWindow(TimeOnly From, TimeOnly To, ShowAudience MaxAudience)
|
||||
{
|
||||
public bool Contains(TimeOnly moment) =>
|
||||
From <= To ? moment >= From && moment < To : moment >= From || moment < To;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают
|
||||
/// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость.
|
||||
///
|
||||
/// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени
|
||||
/// и так задаются временем, а потолок повторов осмыслен только целиком по каналу.
|
||||
/// </summary>
|
||||
public sealed record PlanningRules(
|
||||
IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null,
|
||||
/// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary>
|
||||
RepeatLimitRule? MaxRepeatsInWindow = null,
|
||||
// ── Пост-проверки: считаются по готовой ленте, дают предупреждения и ничего не переигрывают. ──
|
||||
/// <summary>Потолок врезок в часе, минуты (null — не проверять).</summary>
|
||||
int? MaxBreakMinutesPerHour = null,
|
||||
/// <summary>Потолок доли одного жанра за вещательные сутки, проценты (null — не проверять).</summary>
|
||||
int? MaxGenreSharePercent = null,
|
||||
/// <summary>Потолок доли эфира, отданной фону, проценты (null — не проверять).</summary>
|
||||
int? MaxFallbackSharePercent = null
|
||||
)
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary>Возрастной потолок в указанный момент времени канала (null — без ограничения).</summary>
|
||||
public ShowAudience? AudienceAt(TimeOnly moment)
|
||||
{
|
||||
if (MaxAudienceByTime is not { Count: > 0 } windows)
|
||||
return null;
|
||||
|
||||
// Пересекающиеся окна разрешаются в пользу строгого: детское время не должно
|
||||
// отменяться более широким окном, случайно наложенным сверху.
|
||||
ShowAudience? strictest = null;
|
||||
foreach (var window in windows.Where(w => w.Contains(moment)))
|
||||
strictest = strictest is { } current && current <= window.MaxAudience
|
||||
? current
|
||||
: window.MaxAudience;
|
||||
return strictest;
|
||||
}
|
||||
|
||||
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||
|
||||
public static PlanningRules? FromJson(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<PlanningRules>(json, Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RepeatLimitRule(int WindowDays, int Max);
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Validate;
|
||||
|
||||
/// <summary>
|
||||
/// Проверки сетки по правилам, до генерации (см. 5.1). Ничего не считает по ленте — только по
|
||||
/// шаблону и статистике групп, поэтому дёшево и вызывается на каждом открытии редактора.
|
||||
/// </summary>
|
||||
public sealed record ValidateTemplateQuery(Guid ChannelId)
|
||||
: IQuery<Result<IReadOnlyList<TemplateIssueDto>>>;
|
||||
|
||||
public enum TemplateIssueKind
|
||||
{
|
||||
/// <summary>Слот ссылается на пустую группу — место закроет фон.</summary>
|
||||
GroupEmpty = 0,
|
||||
|
||||
/// <summary>Позиций в группе меньше, чем выходов слота в неделю.</summary>
|
||||
GroupTooSmall = 1,
|
||||
|
||||
/// <summary>Интервал суток не покрыт ни одним слотом, даже фоновым.</summary>
|
||||
GridGap = 2,
|
||||
|
||||
/// <summary>Два слота одного слоя перекрываются — сыграет только первый.</summary>
|
||||
SlotOverlap = 3,
|
||||
|
||||
/// <summary>Остывание длиннее, чем группа успевает прокрутиться.</summary>
|
||||
CooldownUnreachable = 4,
|
||||
|
||||
/// <summary>В группе есть контент строже, чем разрешает окно детского времени.</summary>
|
||||
AudienceConflict = 5,
|
||||
|
||||
/// <summary>Слот без группы: контент брать неоткуда.</summary>
|
||||
GroupMissing = 6,
|
||||
}
|
||||
|
||||
/// <summary>Насколько это больно: <c>Error</c> — в эфире будет фон вместо контента.</summary>
|
||||
public enum TemplateIssueSeverity
|
||||
{
|
||||
Warning = 0,
|
||||
Error = 1,
|
||||
}
|
||||
|
||||
public sealed record TemplateIssueDto(
|
||||
TemplateIssueKind Kind,
|
||||
TemplateIssueSeverity Severity,
|
||||
Guid? LayerId,
|
||||
Guid? SlotId,
|
||||
string Details
|
||||
);
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Templates.Validate;
|
||||
|
||||
public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
|
||||
{
|
||||
private const int MinutesInDay = 24 * 60;
|
||||
|
||||
public async Task<Result<IReadOnlyList<TemplateIssueDto>>> Handle(
|
||||
ValidateTemplateQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.NotFound);
|
||||
|
||||
var template = await dbContext
|
||||
.ScheduleTemplates.AsNoTracking()
|
||||
.Include(t => t.Layers)
|
||||
.ThenInclude(l => l.Slots)
|
||||
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
|
||||
if (template is null)
|
||||
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.TemplateNotFound);
|
||||
|
||||
var layers = template.Layers.Where(l => l.IsEnabled).ToList();
|
||||
var groupIds = layers
|
||||
.SelectMany(l => l.Slots)
|
||||
.Select(s => s.GroupId)
|
||||
.Where(id => id is not null)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var groups = await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Where(g => groupIds.Contains(g.Id))
|
||||
.Select(g => new GroupStats(g.Id, g.Name, g.ItemCount))
|
||||
.ToDictionaryAsync(g => g.Id, cancellationToken);
|
||||
|
||||
var rules = PlanningRules.FromJson(template.RulesJson);
|
||||
var strictest = await LoadStrictestAudienceAsync(groupIds, cancellationToken);
|
||||
var dayStart = channel.DayStartTime;
|
||||
|
||||
var issues = new List<TemplateIssueDto>();
|
||||
|
||||
foreach (var layer in layers)
|
||||
{
|
||||
issues.AddRange(FindOverlaps(layer, dayStart));
|
||||
|
||||
foreach (var slot in layer.Slots.Where(s => s.SlotKind == SlotKind.Content))
|
||||
issues.AddRange(CheckSlot(layer, slot, groups, strictest, rules));
|
||||
}
|
||||
|
||||
issues.AddRange(FindGaps(layers, dayStart));
|
||||
|
||||
return Result.Success<IReadOnlyList<TemplateIssueDto>>(issues);
|
||||
}
|
||||
|
||||
private sealed record GroupStats(Guid Id, string Name, int ItemCount);
|
||||
|
||||
private static IEnumerable<TemplateIssueDto> CheckSlot(
|
||||
GridLayer layer,
|
||||
Slot slot,
|
||||
IReadOnlyDictionary<Guid, GroupStats> groups,
|
||||
IReadOnlyDictionary<Guid, ShowAudience> strictest,
|
||||
PlanningRules? rules
|
||||
)
|
||||
{
|
||||
if (slot.GroupId is not { } groupId || !groups.TryGetValue(groupId, out var group))
|
||||
{
|
||||
yield return new TemplateIssueDto(
|
||||
TemplateIssueKind.GroupMissing,
|
||||
TemplateIssueSeverity.Error,
|
||||
layer.Id,
|
||||
slot.Id,
|
||||
$"У слота «{slot.Title}» не выбрана группа — место закроет фон."
|
||||
);
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (group.ItemCount == 0)
|
||||
{
|
||||
yield return new TemplateIssueDto(
|
||||
TemplateIssueKind.GroupEmpty,
|
||||
TemplateIssueSeverity.Error,
|
||||
layer.Id,
|
||||
slot.Id,
|
||||
$"Группа «{group.Name}» пуста — слот «{slot.Title}» заполнит фон."
|
||||
);
|
||||
yield break;
|
||||
}
|
||||
|
||||
var perWeek = OccurrencesPerWeek(slot);
|
||||
if (group.ItemCount < perWeek)
|
||||
yield return new TemplateIssueDto(
|
||||
TemplateIssueKind.GroupTooSmall,
|
||||
TemplateIssueSeverity.Warning,
|
||||
layer.Id,
|
||||
slot.Id,
|
||||
$"В группе «{group.Name}» {group.ItemCount} позиций при {perWeek} выходах в неделю — "
|
||||
+ "повторы пойдут чаще, чем раз в неделю."
|
||||
);
|
||||
|
||||
// Остывание считается по числу выходов: за N дней слот выйдет N × (выходов в день) раз,
|
||||
// и если это больше состава группы, отсекать будет некого.
|
||||
var strategy = SlotStrategy.FromJson(slot.StrategyJson);
|
||||
if (
|
||||
strategy is { Type: SlotStrategyType.RandomWithCooldown, CooldownDays: > 0 }
|
||||
&& strategy.CooldownDays * perWeek / 7.0 > group.ItemCount
|
||||
)
|
||||
yield return new TemplateIssueDto(
|
||||
TemplateIssueKind.CooldownUnreachable,
|
||||
TemplateIssueSeverity.Warning,
|
||||
layer.Id,
|
||||
slot.Id,
|
||||
$"Остывание {strategy.CooldownDays} дней невыполнимо при {group.ItemCount} позициях "
|
||||
+ $"в группе «{group.Name}»."
|
||||
);
|
||||
|
||||
if (
|
||||
rules?.AudienceAt(slot.TargetStart) is { } maxAudience
|
||||
&& strictest.TryGetValue(groupId, out var groupAudience)
|
||||
&& groupAudience > maxAudience
|
||||
)
|
||||
yield return new TemplateIssueDto(
|
||||
TemplateIssueKind.AudienceConflict,
|
||||
TemplateIssueSeverity.Warning,
|
||||
layer.Id,
|
||||
slot.Id,
|
||||
$"В группе «{group.Name}» есть контент категории «{groupAudience}», а слот "
|
||||
+ $"«{slot.Title}» стоит во времени не строже «{maxAudience}»."
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Сколько раз слот выходит в неделю: без дня недели — каждый день.</summary>
|
||||
private static int OccurrencesPerWeek(Slot slot) => slot.Weekday is null ? 7 : 1;
|
||||
|
||||
/// <summary>
|
||||
/// Пересечения слотов внутри одного слоя. Внутри слоя приоритетов нет, поэтому сыграет первый
|
||||
/// по времени, а второй молча пропадёт — это стоит показать до генерации.
|
||||
/// </summary>
|
||||
private static IEnumerable<TemplateIssueDto> FindOverlaps(GridLayer layer, TimeOnly dayStart)
|
||||
{
|
||||
var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList();
|
||||
|
||||
for (var i = 0; i < slots.Count; i++)
|
||||
for (var j = i + 1; j < slots.Count; j++)
|
||||
{
|
||||
var a = slots[i];
|
||||
var b = slots[j];
|
||||
if (!SameDays(a, b))
|
||||
continue;
|
||||
|
||||
var aFrom = OffsetInDay(a.TargetStart, dayStart);
|
||||
var bFrom = OffsetInDay(b.TargetStart, dayStart);
|
||||
if (aFrom < bFrom + b.TargetDurationMinutes && bFrom < aFrom + a.TargetDurationMinutes)
|
||||
yield return new TemplateIssueDto(
|
||||
TemplateIssueKind.SlotOverlap,
|
||||
TemplateIssueSeverity.Warning,
|
||||
layer.Id,
|
||||
b.Id,
|
||||
$"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Слот без дня недели идёт каждый день, поэтому пересекается с любым.</summary>
|
||||
private static bool SameDays(Slot a, Slot b) =>
|
||||
a.Weekday is null || b.Weekday is null || a.Weekday == b.Weekday;
|
||||
|
||||
/// <summary>
|
||||
/// Интервалы вещательных суток, не покрытые ни одним слотом. Проверяется по каждому дню недели:
|
||||
/// дыра во вторник ночью не видна, если смотреть на неделю целиком.
|
||||
/// </summary>
|
||||
private static IEnumerable<TemplateIssueDto> FindGaps(
|
||||
IReadOnlyList<GridLayer> layers,
|
||||
TimeOnly dayStart
|
||||
)
|
||||
{
|
||||
foreach (var weekday in new[] { 1, 2, 3, 4, 5, 6, 0 })
|
||||
{
|
||||
var intervals = layers
|
||||
.SelectMany(l => l.Slots)
|
||||
.Where(s => s.Weekday is null || s.Weekday == weekday)
|
||||
.Select(s =>
|
||||
{
|
||||
var from = OffsetInDay(s.TargetStart, dayStart);
|
||||
return (From: from, To: from + s.TargetDurationMinutes);
|
||||
})
|
||||
.OrderBy(i => i.From)
|
||||
.ToList();
|
||||
|
||||
var cursor = 0;
|
||||
foreach (var interval in intervals)
|
||||
{
|
||||
if (interval.From > cursor)
|
||||
yield return Gap(weekday, cursor, interval.From, dayStart);
|
||||
cursor = Math.Max(cursor, Math.Min(interval.To, MinutesInDay));
|
||||
}
|
||||
|
||||
if (cursor < MinutesInDay)
|
||||
yield return Gap(weekday, cursor, MinutesInDay, dayStart);
|
||||
}
|
||||
}
|
||||
|
||||
private static TemplateIssueDto Gap(int weekday, int from, int to, TimeOnly dayStart)
|
||||
{
|
||||
var day = new[] { "вс", "пн", "вт", "ср", "чт", "пт", "сб" }[weekday];
|
||||
return new TemplateIssueDto(
|
||||
TemplateIssueKind.GridGap,
|
||||
TemplateIssueSeverity.Error,
|
||||
null,
|
||||
null,
|
||||
$"Не покрыто: {day} {Clock(from, dayStart)}–{Clock(to, dayStart)}."
|
||||
);
|
||||
}
|
||||
|
||||
private static string Clock(int offsetMinutes, TimeOnly dayStart)
|
||||
{
|
||||
var minutes = ((int)dayStart.ToTimeSpan().TotalMinutes + offsetMinutes) % MinutesInDay;
|
||||
return $"{minutes / 60:00}:{minutes % 60:00}";
|
||||
}
|
||||
|
||||
private static int OffsetInDay(TimeOnly time, TimeOnly dayStart)
|
||||
{
|
||||
var diff = (int)(time.ToTimeSpan() - dayStart.ToTimeSpan()).TotalMinutes;
|
||||
return diff >= 0 ? diff : diff + MinutesInDay;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Строжайшая категория среди позиций каждой группы: именно она конфликтует с детским временем.
|
||||
/// Коллекция берётся по строжайшей части — франшиза идёт целиком.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
|
||||
IReadOnlyCollection<Guid> groupIds,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (groupIds.Count == 0)
|
||||
return [];
|
||||
|
||||
var items = await dbContext
|
||||
.GroupItems.AsNoTracking()
|
||||
.Where(i => groupIds.Contains(i.GroupId))
|
||||
.Select(i => new
|
||||
{
|
||||
i.GroupId,
|
||||
i.ElementKind,
|
||||
i.ElementId,
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var showIds = items
|
||||
.Where(i => i.ElementKind == GroupElementKind.Show)
|
||||
.Select(i => i.ElementId)
|
||||
.ToList();
|
||||
var collectionIds = items
|
||||
.Where(i => i.ElementKind == GroupElementKind.Collection)
|
||||
.Select(i => i.ElementId)
|
||||
.ToList();
|
||||
|
||||
var partsByCollection = await dbContext
|
||||
.CollectionItems.AsNoTracking()
|
||||
.Where(i => collectionIds.Contains(i.CollectionId))
|
||||
.Select(i => new { i.CollectionId, i.ShowId })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var audiences = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id) || partsByCollection.Select(p => p.ShowId).Contains(s.Id))
|
||||
.Select(s => new { s.Id, s.Audience })
|
||||
.ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken);
|
||||
|
||||
var result = new Dictionary<Guid, ShowAudience>();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var candidates =
|
||||
item.ElementKind == GroupElementKind.Show
|
||||
? [item.ElementId]
|
||||
: partsByCollection
|
||||
.Where(p => p.CollectionId == item.ElementId)
|
||||
.Select(p => p.ShowId)
|
||||
.ToList();
|
||||
|
||||
foreach (var showId in candidates)
|
||||
{
|
||||
if (!audiences.TryGetValue(showId, out var audience))
|
||||
continue;
|
||||
if (!result.TryGetValue(item.GroupId, out var current) || audience > current)
|
||||
result[item.GroupId] = audience;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+19
-18
@@ -1,18 +1,19 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Application.Settings.GetSiteSettings;
|
||||
|
||||
public sealed class GetSiteSettingsQueryHandler(ISiteSettings siteSettings)
|
||||
: IQueryHandler<GetSiteSettingsQuery, SiteSettingsDto>
|
||||
{
|
||||
public async Task<SiteSettingsDto> Handle(
|
||||
GetSiteSettingsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var registrationEnabled = await siteSettings.IsRegistrationEnabledAsync(cancellationToken);
|
||||
var preferredAudio = await siteSettings.GetPreferredAudioLanguagesAsync(cancellationToken);
|
||||
return new SiteSettingsDto(registrationEnabled, preferredAudio);
|
||||
}
|
||||
}
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Application.Settings.GetSiteSettings;
|
||||
|
||||
public sealed class GetSiteSettingsQueryHandler(ISiteSettings siteSettings)
|
||||
: IQueryHandler<GetSiteSettingsQuery, SiteSettingsDto>
|
||||
{
|
||||
public async Task<SiteSettingsDto> Handle(
|
||||
GetSiteSettingsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var registrationEnabled = await siteSettings.IsRegistrationEnabledAsync(cancellationToken);
|
||||
var preferredAudio = await siteSettings.GetPreferredAudioLanguagesAsync(cancellationToken);
|
||||
var channelNumbers = await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken);
|
||||
return new SiteSettingsDto(registrationEnabled, preferredAudio, channelNumbers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
namespace TeleWave.Application.Settings;
|
||||
|
||||
/// <summary>Стабильные ключи глобальных настроек сайта в key-value хранилище (AppSetting).</summary>
|
||||
public static class SettingKeys
|
||||
{
|
||||
/// <summary>Разрешена ли открытая регистрация пользователей (по умолчанию — нет).</summary>
|
||||
public const string RegistrationEnabled = "registration.enabled";
|
||||
|
||||
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
|
||||
/// (напр. «rus,eng»). При наличии дорожки с таким языком она выбирается первой; иначе — дефолт ffmpeg.</summary>
|
||||
public const string PreferredAudioLanguages = "media.preferredAudioLanguages";
|
||||
}
|
||||
namespace TeleWave.Application.Settings;
|
||||
|
||||
/// <summary>Стабильные ключи глобальных настроек сайта в key-value хранилище (AppSetting).</summary>
|
||||
public static class SettingKeys
|
||||
{
|
||||
/// <summary>Разрешена ли открытая регистрация пользователей (по умолчанию — нет).</summary>
|
||||
public const string RegistrationEnabled = "registration.enabled";
|
||||
|
||||
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
|
||||
/// (напр. «rus,eng»). При наличии дорожки с таким языком она выбирается первой; иначе — дефолт ffmpeg.</summary>
|
||||
public const string PreferredAudioLanguages = "media.preferredAudioLanguages";
|
||||
|
||||
/// <summary>Разрешено ли зрителю переключать каналы по номерам, как на телевизоре (по умолчанию — нет).
|
||||
/// Сетка каналов остаётся вторым способом навигации всегда.</summary>
|
||||
public const string ChannelNumbersEnabled = "viewer.channelNumbersEnabled";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
namespace TeleWave.Application.Settings;
|
||||
|
||||
/// <summary>Глобальные настройки сайта, управляемые администратором.</summary>
|
||||
public sealed record SiteSettingsDto(bool RegistrationEnabled, string PreferredAudioLanguages);
|
||||
namespace TeleWave.Application.Settings;
|
||||
|
||||
/// <summary>Глобальные настройки сайта, управляемые администратором.</summary>
|
||||
public sealed record SiteSettingsDto(
|
||||
bool RegistrationEnabled,
|
||||
string PreferredAudioLanguages,
|
||||
/// <summary>Переключение каналов по номерам у зрителя — сетка каналов остаётся всегда.</summary>
|
||||
bool ChannelNumbersEnabled
|
||||
);
|
||||
|
||||
+10
-9
@@ -1,9 +1,10 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Settings.UpdateSiteSettings;
|
||||
|
||||
public sealed record UpdateSiteSettingsCommand(
|
||||
bool RegistrationEnabled,
|
||||
string PreferredAudioLanguages
|
||||
) : ICommand<Result>;
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Settings.UpdateSiteSettings;
|
||||
|
||||
public sealed record UpdateSiteSettingsCommand(
|
||||
bool RegistrationEnabled,
|
||||
string PreferredAudioLanguages,
|
||||
bool ChannelNumbersEnabled
|
||||
) : ICommand<Result>;
|
||||
|
||||
+34
-30
@@ -1,30 +1,34 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Settings.UpdateSiteSettings;
|
||||
|
||||
public sealed class UpdateSiteSettingsCommandHandler(ISiteSettings siteSettings)
|
||||
: ICommandHandler<UpdateSiteSettingsCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateSiteSettingsCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
// Сохранение выполняет UnitOfWorkBehavior команды.
|
||||
await siteSettings.SetRegistrationEnabledAsync(
|
||||
command.RegistrationEnabled,
|
||||
cancellationToken
|
||||
);
|
||||
// Нормализуем список языков: без пробелов, в нижнем регистре, пустые отбрасываем.
|
||||
var languages = string.Join(
|
||||
',',
|
||||
(command.PreferredAudioLanguages ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(x => x.ToLowerInvariant())
|
||||
);
|
||||
await siteSettings.SetPreferredAudioLanguagesAsync(languages, cancellationToken);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Settings.UpdateSiteSettings;
|
||||
|
||||
public sealed class UpdateSiteSettingsCommandHandler(ISiteSettings siteSettings)
|
||||
: ICommandHandler<UpdateSiteSettingsCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateSiteSettingsCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
// Сохранение выполняет UnitOfWorkBehavior команды.
|
||||
await siteSettings.SetRegistrationEnabledAsync(
|
||||
command.RegistrationEnabled,
|
||||
cancellationToken
|
||||
);
|
||||
// Нормализуем список языков: без пробелов, в нижнем регистре, пустые отбрасываем.
|
||||
var languages = string.Join(
|
||||
',',
|
||||
(command.PreferredAudioLanguages ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(x => x.ToLowerInvariant())
|
||||
);
|
||||
await siteSettings.SetPreferredAudioLanguagesAsync(languages, cancellationToken);
|
||||
await siteSettings.SetChannelNumbersEnabledAsync(
|
||||
command.ChannelNumbersEnabled,
|
||||
cancellationToken
|
||||
);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
+93
-77
@@ -1,77 +1,93 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Streaming.ListPublicChannels;
|
||||
|
||||
public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListPublicChannelsQuery, IReadOnlyList<PublicChannelDto>>
|
||||
{
|
||||
public async Task<IReadOnlyList<PublicChannelDto>> Handle(
|
||||
ListPublicChannelsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channels = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.Where(c => c.IsEnabled)
|
||||
.OrderBy(c => c.Name)
|
||||
.Select(c => new
|
||||
{
|
||||
c.Id,
|
||||
c.Slug,
|
||||
c.Name,
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
if (channels.Count == 0)
|
||||
return [];
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var channelIds = channels.Select(c => c.Id).ToList();
|
||||
|
||||
// Что идёт прямо сейчас на каждом канале (программа) — для постера-обложки плитки.
|
||||
var currentByChannel = await dbContext
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.Where(e =>
|
||||
channelIds.Contains(e.ChannelId)
|
||||
&& e.Kind == ScheduleEntryKind.Program
|
||||
&& e.StartsAtUtc <= now
|
||||
&& e.EndsAtUtc > now
|
||||
&& e.ShowId != null
|
||||
)
|
||||
.Select(e => new { e.ChannelId, ShowId = e.ShowId!.Value })
|
||||
.ToListAsync(cancellationToken);
|
||||
var currentShowByChannel = currentByChannel
|
||||
.GroupBy(x => x.ChannelId)
|
||||
.ToDictionary(g => g.Key, g => g.First().ShowId);
|
||||
|
||||
var showIds = currentShowByChannel.Values.Distinct().ToList();
|
||||
var shows = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.PosterImageId,
|
||||
})
|
||||
.ToDictionaryAsync(s => s.Id, cancellationToken);
|
||||
|
||||
return channels
|
||||
.Select(c =>
|
||||
{
|
||||
var showId = currentShowByChannel.GetValueOrDefault(c.Id);
|
||||
var show = showId != Guid.Empty ? shows.GetValueOrDefault(showId) : null;
|
||||
return new PublicChannelDto(
|
||||
c.Id,
|
||||
c.Slug,
|
||||
c.Name,
|
||||
show is null ? null : showId,
|
||||
show?.Name,
|
||||
show?.PosterImageId
|
||||
);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Streaming.ListPublicChannels;
|
||||
|
||||
public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListPublicChannelsQuery, IReadOnlyList<PublicChannelDto>>
|
||||
{
|
||||
public async Task<IReadOnlyList<PublicChannelDto>> Handle(
|
||||
ListPublicChannelsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channels = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.Where(c => c.IsEnabled)
|
||||
// Каналы без номера — в конец: на телевизоре порядок задаёт номер, а имя лишь
|
||||
// разрешает ничью между ненумерованными.
|
||||
.OrderBy(c => c.Number == null)
|
||||
.ThenBy(c => c.Number)
|
||||
.ThenBy(c => c.Name)
|
||||
.Select(c => new
|
||||
{
|
||||
c.Id,
|
||||
c.Slug,
|
||||
c.Name,
|
||||
c.Number,
|
||||
c.LogoImageId,
|
||||
c.LogoCorner,
|
||||
c.LogoOpacity,
|
||||
c.ShowClock,
|
||||
c.AnalogFilterStrength,
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
if (channels.Count == 0)
|
||||
return [];
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var channelIds = channels.Select(c => c.Id).ToList();
|
||||
|
||||
// Что идёт прямо сейчас на каждом канале (программа) — для постера-обложки плитки.
|
||||
var currentByChannel = await dbContext
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.Where(e =>
|
||||
channelIds.Contains(e.ChannelId)
|
||||
&& e.Kind == ScheduleEntryKind.Program
|
||||
&& e.StartsAtUtc <= now
|
||||
&& e.EndsAtUtc > now
|
||||
&& e.ShowId != null
|
||||
)
|
||||
.Select(e => new { e.ChannelId, ShowId = e.ShowId!.Value })
|
||||
.ToListAsync(cancellationToken);
|
||||
var currentShowByChannel = currentByChannel
|
||||
.GroupBy(x => x.ChannelId)
|
||||
.ToDictionary(g => g.Key, g => g.First().ShowId);
|
||||
|
||||
var showIds = currentShowByChannel.Values.Distinct().ToList();
|
||||
var shows = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.PosterImageId,
|
||||
})
|
||||
.ToDictionaryAsync(s => s.Id, cancellationToken);
|
||||
|
||||
return channels
|
||||
.Select(c =>
|
||||
{
|
||||
var showId = currentShowByChannel.GetValueOrDefault(c.Id);
|
||||
var show = showId != Guid.Empty ? shows.GetValueOrDefault(showId) : null;
|
||||
return new PublicChannelDto(
|
||||
c.Id,
|
||||
c.Slug,
|
||||
c.Name,
|
||||
c.Number,
|
||||
show is null ? null : showId,
|
||||
show?.Name,
|
||||
show?.PosterImageId,
|
||||
c.LogoImageId,
|
||||
c.LogoCorner,
|
||||
c.LogoOpacity,
|
||||
c.ShowClock,
|
||||
c.AnalogFilterStrength
|
||||
);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,42 @@
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Streaming;
|
||||
|
||||
public sealed record PublicChannelDto(
|
||||
Guid Id,
|
||||
string Slug,
|
||||
string Name,
|
||||
Guid? CurrentShowId,
|
||||
string? CurrentShowName,
|
||||
Guid? CurrentShowPosterImageId
|
||||
);
|
||||
|
||||
/// <summary>Запись публичного телегида с метаданными (без имён файлов и номеров серий).</summary>
|
||||
public sealed record PublicEpgEntryDto(
|
||||
ScheduleEntryKind Kind,
|
||||
DateTimeOffset StartsAtUtc,
|
||||
DateTimeOffset EndsAtUtc,
|
||||
Guid? ShowId,
|
||||
string? ShowName,
|
||||
Guid? ShowPosterImageId,
|
||||
Guid? EpisodeId,
|
||||
string? EpisodeTitle,
|
||||
string? EpisodeOverview,
|
||||
Guid? EpisodeStillImageId
|
||||
);
|
||||
|
||||
public sealed record LiveSegmentDto(Guid AssetId, int LocalIndex, bool Discontinuity);
|
||||
|
||||
public sealed record LivePlaylistDto(
|
||||
long MediaSequence,
|
||||
int TargetDuration,
|
||||
IReadOnlyList<LiveSegmentDto> Segments
|
||||
);
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Streaming;
|
||||
|
||||
public sealed record PublicChannelDto(
|
||||
Guid Id,
|
||||
string Slug,
|
||||
string Name,
|
||||
/// <summary>Номер канала для переключения по номерам или null, если не задан.</summary>
|
||||
int? Number,
|
||||
Guid? CurrentShowId,
|
||||
string? CurrentShowName,
|
||||
Guid? CurrentShowPosterImageId,
|
||||
/// <summary>Что рисовать поверх картинки — оверлеи считает клиент, ffmpeg их не касается.</summary>
|
||||
Guid? LogoImageId,
|
||||
LogoCorner LogoCorner,
|
||||
double LogoOpacity,
|
||||
bool ShowClock,
|
||||
double AnalogFilterStrength
|
||||
);
|
||||
|
||||
/// <summary>Запись публичного телегида с метаданными (без имён файлов и номеров серий).</summary>
|
||||
public sealed record PublicEpgEntryDto(
|
||||
ScheduleEntryKind Kind,
|
||||
DateTimeOffset StartsAtUtc,
|
||||
DateTimeOffset EndsAtUtc,
|
||||
Guid? ShowId,
|
||||
string? ShowName,
|
||||
Guid? ShowPosterImageId,
|
||||
Guid? EpisodeId,
|
||||
string? EpisodeTitle,
|
||||
string? EpisodeOverview,
|
||||
Guid? EpisodeStillImageId
|
||||
);
|
||||
|
||||
public sealed record LiveSegmentDto(Guid AssetId, int LocalIndex, bool Discontinuity);
|
||||
|
||||
public sealed record LivePlaylistDto(
|
||||
long MediaSequence,
|
||||
int TargetDuration,
|
||||
IReadOnlyList<LiveSegmentDto> Segments
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user