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.
This commit is contained in:
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-8
@@ -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);
|
||||
|
||||
+1
-7
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -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>>;
|
||||
+52
@@ -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;
|
||||
}
|
||||
}
|
||||
+10
@@ -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
|
||||
);
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
|
||||
namespace TeleWave.Application.Library.Interstitials.ListInterstitialBlocks;
|
||||
|
||||
/// <summary>Коллекции, целиком собранные из роликов, — рекламные блоки экрана «Ролики».</summary>
|
||||
public sealed record ListInterstitialBlocksQuery : IQuery<IReadOnlyList<InterstitialBlockDto>>;
|
||||
+55
@@ -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();
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
using LiteCqrs;
|
||||
|
||||
namespace TeleWave.Application.Library.Interstitials.ListInterstitials;
|
||||
|
||||
public sealed record ListInterstitialsQuery : IQuery<IReadOnlyList<InterstitialDto>>;
|
||||
+60
@@ -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
|
||||
);
|
||||
+112
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user