Update scheduling parameters and refactor channel endpoints: extend HorizonDays to 7 and RetentionDays to 90 in appsettings.json. Consolidate channel-related endpoint logic by removing obsolete files and enhancing the ShowEndpoints with audience and genre management capabilities. Improve error handling and streamline command handlers for channel operations.
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.AddChannelAd;
|
||||
|
||||
public sealed record AddChannelAdCommand(Guid ChannelId, Guid MediaAssetId)
|
||||
: ICommand<Result<Guid>>;
|
||||
@@ -1,35 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.AddChannelAd;
|
||||
|
||||
public sealed class AddChannelAdCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<AddChannelAdCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
AddChannelAdCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.Include(c => c.Ads)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure<Guid>(ChannelErrors.NotFound);
|
||||
|
||||
var assetExists = await dbContext.MediaAssets.AnyAsync(
|
||||
a => a.Id == command.MediaAssetId,
|
||||
cancellationToken
|
||||
);
|
||||
if (!assetExists)
|
||||
return Result.Failure<Guid>(ChannelErrors.AssetNotFound);
|
||||
|
||||
if (channel.HasAd(command.MediaAssetId))
|
||||
return Result.Failure<Guid>(ChannelErrors.AdAlreadyAdded);
|
||||
|
||||
var ad = channel.AddAd(command.MediaAssetId);
|
||||
return Result.Success(ad.Id);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.AddChannelShow;
|
||||
|
||||
public sealed record AddChannelShowCommand(
|
||||
Guid ChannelId,
|
||||
Guid ShowId,
|
||||
int Weight,
|
||||
BlockMode BlockMode,
|
||||
int BlockValue
|
||||
) : ICommand<Result<Guid>>;
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.AddChannelShow;
|
||||
|
||||
public sealed class AddChannelShowCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<AddChannelShowCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
AddChannelShowCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.Include(c => c.Shows)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure<Guid>(ChannelErrors.NotFound);
|
||||
|
||||
var showExists = await dbContext.Shows.AnyAsync(
|
||||
s => s.Id == command.ShowId,
|
||||
cancellationToken
|
||||
);
|
||||
if (!showExists)
|
||||
return Result.Failure<Guid>(ChannelErrors.ShowNotFound);
|
||||
|
||||
if (channel.HasShow(command.ShowId))
|
||||
return Result.Failure<Guid>(ChannelErrors.ShowAlreadyAdded);
|
||||
|
||||
var channelShow = channel.AddShow(
|
||||
command.ShowId,
|
||||
command.Weight,
|
||||
command.BlockMode,
|
||||
command.BlockValue
|
||||
);
|
||||
return Result.Success(channelShow.Id);
|
||||
}
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.AddChannelShow;
|
||||
|
||||
public sealed class AddChannelShowCommandValidator : AbstractValidator<AddChannelShowCommand>
|
||||
{
|
||||
public AddChannelShowCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Weight).InclusiveBetween(1, 1000);
|
||||
RuleFor(x => x.BlockValue).InclusiveBetween(1, 10000);
|
||||
}
|
||||
}
|
||||
+128
-123
@@ -1,123 +1,128 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed class RenderBumperPreviewCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IBumperRenderer renderer,
|
||||
IBumperTemplateStorage storage,
|
||||
IImageStore imageStore,
|
||||
IOptions<BumperOptions> bumperOptions,
|
||||
IOptions<StreamingOptions> streamingOptions
|
||||
) : ICommandHandler<RenderBumperPreviewCommand, Result>
|
||||
{
|
||||
private readonly BumperOptions _bumper = bumperOptions.Value;
|
||||
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
||||
|
||||
/// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary>
|
||||
private const int DefaultBumperDurationSeconds = 8;
|
||||
|
||||
public async Task<Result> Handle(
|
||||
RenderBumperPreviewCommand query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.Include(c => c.BumperTemplates)
|
||||
.ThenInclude(t => t.Variants)
|
||||
.Include(c => c.Shows)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure(ChannelErrors.NotFound);
|
||||
|
||||
var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId);
|
||||
if (template is null)
|
||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
||||
|
||||
var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
|
||||
var fontFile =
|
||||
channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans;
|
||||
|
||||
// Фон блока — из общего реестра по id (общий для всех подблоков).
|
||||
string? backgroundPath = null;
|
||||
if (template.BackgroundImageId is { } bgId)
|
||||
{
|
||||
var bgExt = await dbContext
|
||||
.Images.AsNoTracking()
|
||||
.Where(i => i.Id == bgId)
|
||||
.Select(i => i.FileExtension)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (bgExt is not null)
|
||||
backgroundPath = imageStore.ResolvePath(bgId, bgExt);
|
||||
}
|
||||
|
||||
var seconds = template.AudioDurationSeconds is { } d and > 0
|
||||
? d
|
||||
: DefaultBumperDurationSeconds;
|
||||
var aligned = (int)(
|
||||
Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds
|
||||
);
|
||||
var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
|
||||
|
||||
// Рендерим каждый подблок в свой ассет-превью (id по подблоку).
|
||||
foreach (var variant in template.Variants.OrderBy(v => v.Position))
|
||||
{
|
||||
var free = variant.Kind == BumperTextKind.Free;
|
||||
var spec = new BumperRenderSpec(
|
||||
aligned,
|
||||
_bumper.Width,
|
||||
_bumper.Height,
|
||||
template.BackgroundColor,
|
||||
template.BackgroundColor2,
|
||||
template.AccentColor,
|
||||
template.TextColor,
|
||||
fontFile,
|
||||
free ? "" : variant.NowLabel,
|
||||
free ? "" : fromName,
|
||||
free ? "" : variant.NextLabel,
|
||||
free ? "" : toName,
|
||||
backgroundPath,
|
||||
audioPath,
|
||||
// Постер зависит от конкретного «следующего» шоу — в превью не подставляем.
|
||||
null,
|
||||
free,
|
||||
variant.Line1,
|
||||
variant.Line2
|
||||
);
|
||||
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
|
||||
}
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
/// <summary>Примерные названия «из/в» — берём первые два шоу канала, иначе заглушки.</summary>
|
||||
private async Task<(string From, string To)> SampleNamesAsync(
|
||||
Channel channel,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var showIds = channel.Shows.Select(s => s.ShowId).Distinct().Take(2).ToList();
|
||||
var names =
|
||||
showIds.Count == 0
|
||||
? []
|
||||
: await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.Select(s => s.Name)
|
||||
.Take(2)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (
|
||||
names.ElementAtOrDefault(0) ?? "Первое шоу",
|
||||
names.ElementAtOrDefault(1) ?? "Второе шоу"
|
||||
);
|
||||
}
|
||||
}
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed class RenderBumperPreviewCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IBumperRenderer renderer,
|
||||
IBumperTemplateStorage storage,
|
||||
IImageStore imageStore,
|
||||
IOptions<BumperOptions> bumperOptions,
|
||||
IOptions<StreamingOptions> streamingOptions
|
||||
) : ICommandHandler<RenderBumperPreviewCommand, Result>
|
||||
{
|
||||
private readonly BumperOptions _bumper = bumperOptions.Value;
|
||||
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
||||
|
||||
/// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary>
|
||||
private const int DefaultBumperDurationSeconds = 8;
|
||||
|
||||
public async Task<Result> Handle(
|
||||
RenderBumperPreviewCommand query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.Include(c => c.BumperTemplates)
|
||||
.ThenInclude(t => t.Variants)
|
||||
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure(ChannelErrors.NotFound);
|
||||
|
||||
var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId);
|
||||
if (template is null)
|
||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
||||
|
||||
var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
|
||||
var fontFile =
|
||||
channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans;
|
||||
|
||||
// Фон блока — из общего реестра по id (общий для всех подблоков).
|
||||
string? backgroundPath = null;
|
||||
if (template.BackgroundImageId is { } bgId)
|
||||
{
|
||||
var bgExt = await dbContext
|
||||
.Images.AsNoTracking()
|
||||
.Where(i => i.Id == bgId)
|
||||
.Select(i => i.FileExtension)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (bgExt is not null)
|
||||
backgroundPath = imageStore.ResolvePath(bgId, bgExt);
|
||||
}
|
||||
|
||||
var seconds = template.AudioDurationSeconds is { } d and > 0
|
||||
? d
|
||||
: DefaultBumperDurationSeconds;
|
||||
var aligned = (int)(
|
||||
Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds
|
||||
);
|
||||
var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
|
||||
|
||||
// Рендерим каждый подблок в свой ассет-превью (id по подблоку).
|
||||
foreach (var variant in template.Variants.OrderBy(v => v.Position))
|
||||
{
|
||||
var free = variant.Kind == BumperTextKind.Free;
|
||||
var spec = new BumperRenderSpec(
|
||||
aligned,
|
||||
_bumper.Width,
|
||||
_bumper.Height,
|
||||
template.BackgroundColor,
|
||||
template.BackgroundColor2,
|
||||
template.AccentColor,
|
||||
template.TextColor,
|
||||
fontFile,
|
||||
free ? "" : variant.NowLabel,
|
||||
free ? "" : fromName,
|
||||
free ? "" : variant.NextLabel,
|
||||
free ? "" : toName,
|
||||
backgroundPath,
|
||||
audioPath,
|
||||
// Постер зависит от конкретного «следующего» шоу — в превью не подставляем.
|
||||
null,
|
||||
free,
|
||||
variant.Line1,
|
||||
variant.Line2
|
||||
);
|
||||
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
|
||||
}
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала:
|
||||
/// так превью показывает реальные названия этого канала, а не случайные из библиотеки.
|
||||
/// </summary>
|
||||
private async Task<(string From, string To)> SampleNamesAsync(
|
||||
Channel channel,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var names = await (
|
||||
from slot in dbContext.Slots.AsNoTracking()
|
||||
join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
|
||||
join item in dbContext.GroupItems.AsNoTracking()
|
||||
on slot.GroupId equals item.GroupId
|
||||
join show in dbContext.Shows.AsNoTracking() on item.ElementId equals show.Id
|
||||
where layer.TemplateId == channel.TemplateId
|
||||
&& item.ElementKind == GroupElementKind.Show
|
||||
select show.Name
|
||||
)
|
||||
.Distinct()
|
||||
.Take(2)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (
|
||||
names.ElementAtOrDefault(0) ?? "Первое шоу",
|
||||
names.ElementAtOrDefault(1) ?? "Второе шоу"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,92 +1,59 @@
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast;
|
||||
|
||||
public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled);
|
||||
|
||||
public sealed record ChannelShowDto(
|
||||
Guid Id,
|
||||
Guid ShowId,
|
||||
string ShowName,
|
||||
int Weight,
|
||||
BlockMode BlockMode,
|
||||
int BlockValue,
|
||||
bool IsEnabled,
|
||||
int NextEpisodeIndex,
|
||||
int PreferredWeightMultiplier,
|
||||
IReadOnlyList<HourWindowDto> PreferredHours
|
||||
);
|
||||
|
||||
/// <summary>Окно предпочтительных часов [StartHour, EndHour) суток (UTC).</summary>
|
||||
public sealed record HourWindowDto(int StartHour, int EndHour);
|
||||
|
||||
public sealed record ChannelAdDto(Guid Id, Guid MediaAssetId, string? AssetName, int Position);
|
||||
|
||||
public sealed record OverrideShowDto(Guid ShowId, string ShowName, int Weight);
|
||||
|
||||
public sealed record ProgrammingOverrideDto(
|
||||
Guid Id,
|
||||
OverrideMode Mode,
|
||||
OverrideRecurrence Recurrence,
|
||||
DateTimeOffset? StartsAtUtc,
|
||||
DateTimeOffset? EndsAtUtc,
|
||||
int? DayOfWeek,
|
||||
int? StartMinute,
|
||||
int? EndMinute,
|
||||
IReadOnlyList<OverrideShowDto> Shows
|
||||
);
|
||||
|
||||
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).</summary>
|
||||
public sealed record BumperSettingsDto(
|
||||
BumperFont Font,
|
||||
int MinIntervalMinutes,
|
||||
BumperSelection Selection,
|
||||
double ShowChangeChance,
|
||||
double EpisodeChangeChance
|
||||
);
|
||||
|
||||
/// <summary>Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.</summary>
|
||||
public sealed record BumperTextVariantDto(
|
||||
Guid Id,
|
||||
int Position,
|
||||
string Name,
|
||||
BumperTextKind Kind,
|
||||
string NowLabel,
|
||||
string NextLabel,
|
||||
string Line1,
|
||||
string Line2,
|
||||
BumperTrigger Trigger,
|
||||
int Weight
|
||||
);
|
||||
|
||||
/// <summary>Блок заставки: своё оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
|
||||
public sealed record BumperTemplateDto(
|
||||
Guid Id,
|
||||
int Position,
|
||||
bool IsDefault,
|
||||
string Name,
|
||||
string BackgroundColor,
|
||||
string BackgroundColor2,
|
||||
string AccentColor,
|
||||
string TextColor,
|
||||
Guid? BackgroundImageId,
|
||||
bool HasAudio,
|
||||
double? AudioDurationSeconds,
|
||||
IReadOnlyList<BumperTextVariantDto> Variants
|
||||
);
|
||||
|
||||
public sealed record ChannelDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Slug,
|
||||
bool IsEnabled,
|
||||
AdInsertion AdInsertion,
|
||||
int AdsPerBreak,
|
||||
bool BumpersEnabled,
|
||||
BumperSettingsDto Bumper,
|
||||
IReadOnlyList<BumperTemplateDto> BumperTemplates,
|
||||
Guid? FillerAssetId,
|
||||
IReadOnlyList<ChannelShowDto> Shows,
|
||||
IReadOnlyList<ChannelAdDto> Ads,
|
||||
IReadOnlyList<ProgrammingOverrideDto> Overrides
|
||||
);
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
/// <summary>Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.</summary>
|
||||
public sealed record BumperTextVariantDto(
|
||||
Guid Id,
|
||||
int Position,
|
||||
string Name,
|
||||
BumperTextKind Kind,
|
||||
string NowLabel,
|
||||
string NextLabel,
|
||||
string Line1,
|
||||
string Line2,
|
||||
BumperTrigger Trigger,
|
||||
int Weight
|
||||
);
|
||||
|
||||
/// <summary>Блок заставки: своё оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
|
||||
public sealed record BumperTemplateDto(
|
||||
Guid Id,
|
||||
int Position,
|
||||
bool IsDefault,
|
||||
string Name,
|
||||
string BackgroundColor,
|
||||
string BackgroundColor2,
|
||||
string AccentColor,
|
||||
string TextColor,
|
||||
Guid? BackgroundImageId,
|
||||
bool HasAudio,
|
||||
double? AudioDurationSeconds,
|
||||
IReadOnlyList<BumperTextVariantDto> Variants
|
||||
);
|
||||
|
||||
public sealed record ChannelDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Slug,
|
||||
bool IsEnabled,
|
||||
int? Number,
|
||||
int UtcOffsetMinutes,
|
||||
TimeOnly DayStartTime,
|
||||
Guid? TemplateId,
|
||||
bool BumpersEnabled,
|
||||
BumperSettingsDto Bumper,
|
||||
IReadOnlyList<BumperTemplateDto> BumperTemplates,
|
||||
Guid? FillerAssetId
|
||||
);
|
||||
|
||||
@@ -1,83 +1,54 @@
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast;
|
||||
|
||||
public static class ChannelErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound("Channels.NotFound", "Канал не найден.");
|
||||
|
||||
public static readonly Error DuplicateSlug = Error.Conflict(
|
||||
"Channels.DuplicateSlug",
|
||||
"Канал с таким slug уже существует."
|
||||
);
|
||||
|
||||
public static readonly Error ShowAlreadyAdded = Error.Conflict(
|
||||
"Channels.ShowAlreadyAdded",
|
||||
"Это шоу уже добавлено в канал."
|
||||
);
|
||||
|
||||
public static readonly Error ShowNotFound = Error.NotFound(
|
||||
"Channels.ShowNotFound",
|
||||
"Шоу не найдено в библиотеке."
|
||||
);
|
||||
|
||||
public static readonly Error ChannelShowNotFound = Error.NotFound(
|
||||
"Channels.ChannelShowNotFound",
|
||||
"Шоу не найдено в канале."
|
||||
);
|
||||
|
||||
public static readonly Error AdAlreadyAdded = Error.Conflict(
|
||||
"Channels.AdAlreadyAdded",
|
||||
"Этот ролик уже в пуле рекламы канала."
|
||||
);
|
||||
|
||||
public static readonly Error AdNotFound = Error.NotFound(
|
||||
"Channels.AdNotFound",
|
||||
"Реклама не найдена в пуле канала."
|
||||
);
|
||||
|
||||
public static readonly Error BumperTemplateNotFound = Error.NotFound(
|
||||
"Channels.BumperTemplateNotFound",
|
||||
"Блок заставки не найден."
|
||||
);
|
||||
|
||||
public static readonly Error CannotRemoveDefaultBumperTemplate = Error.Validation(
|
||||
"Channels.CannotRemoveDefaultBumperTemplate",
|
||||
"Дефолтный блок заставки удалить нельзя."
|
||||
);
|
||||
|
||||
public static readonly Error BumperTextVariantNotFound = Error.NotFound(
|
||||
"Channels.BumperTextVariantNotFound",
|
||||
"Подблок заставки не найден."
|
||||
);
|
||||
|
||||
public static readonly Error CannotRemoveLastBumperTextVariant = Error.Validation(
|
||||
"Channels.CannotRemoveLastBumperTextVariant",
|
||||
"Нельзя удалить последний подблок — нужен хотя бы один."
|
||||
);
|
||||
|
||||
public static readonly Error AssetNotFound = Error.NotFound(
|
||||
"Channels.AssetNotFound",
|
||||
"Медиа-ассет не найден."
|
||||
);
|
||||
|
||||
public static readonly Error InvalidBumperFile = Error.Validation(
|
||||
"Channels.InvalidBumperFile",
|
||||
"Недопустимый файл заставки (формат или размер)."
|
||||
);
|
||||
|
||||
public static readonly Error OverrideNotFound = Error.NotFound(
|
||||
"Channels.OverrideNotFound",
|
||||
"Override не найден."
|
||||
);
|
||||
|
||||
public static readonly Error InvalidOverrideWindow = Error.Validation(
|
||||
"Channels.InvalidOverrideWindow",
|
||||
"Окончание override должно быть позже начала."
|
||||
);
|
||||
|
||||
public static readonly Error OverrideNeedsShow = Error.Validation(
|
||||
"Channels.OverrideNeedsShow",
|
||||
"Override должен ссылаться хотя бы на одно шоу."
|
||||
);
|
||||
}
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast;
|
||||
|
||||
public static class ChannelErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound("Channels.NotFound", "Канал не найден.");
|
||||
|
||||
public static readonly Error NumberTaken = Error.Conflict(
|
||||
"Channels.NumberTaken",
|
||||
"Канал с таким номером уже есть."
|
||||
);
|
||||
|
||||
public static readonly Error TemplateNotFound = Error.NotFound(
|
||||
"Channels.TemplateNotFound",
|
||||
"У канала нет шаблона сетки."
|
||||
);
|
||||
|
||||
public static readonly Error DuplicateSlug = Error.Conflict(
|
||||
"Channels.DuplicateSlug",
|
||||
"Канал с таким slug уже существует."
|
||||
);
|
||||
|
||||
public static readonly Error BumperTemplateNotFound = Error.NotFound(
|
||||
"Channels.BumperTemplateNotFound",
|
||||
"Блок заставки не найден."
|
||||
);
|
||||
|
||||
public static readonly Error CannotRemoveDefaultBumperTemplate = Error.Validation(
|
||||
"Channels.CannotRemoveDefaultBumperTemplate",
|
||||
"Дефолтный блок заставки удалить нельзя."
|
||||
);
|
||||
|
||||
public static readonly Error BumperTextVariantNotFound = Error.NotFound(
|
||||
"Channels.BumperTextVariantNotFound",
|
||||
"Подблок заставки не найден."
|
||||
);
|
||||
|
||||
public static readonly Error CannotRemoveLastBumperTextVariant = Error.Validation(
|
||||
"Channels.CannotRemoveLastBumperTextVariant",
|
||||
"Нельзя удалить последний подблок — нужен хотя бы один."
|
||||
);
|
||||
|
||||
public static readonly Error AssetNotFound = Error.NotFound(
|
||||
"Channels.AssetNotFound",
|
||||
"Медиа-ассет не найден."
|
||||
);
|
||||
|
||||
public static readonly Error InvalidBumperFile = Error.Validation(
|
||||
"Channels.InvalidBumperFile",
|
||||
"Недопустимый файл заставки (формат или размер)."
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
+7
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.CreateChannel;
|
||||
|
||||
@@ -22,7 +23,13 @@ public sealed class CreateChannelCommandHandler(IAppDbContext dbContext)
|
||||
return Result.Failure<Guid>(ChannelErrors.DuplicateSlug);
|
||||
|
||||
var channel = Channel.Create(command.Name, command.Slug, DateTimeOffset.UtcNow);
|
||||
|
||||
// Канал без шаблона вещать не может, поэтому шаблон с фоновым слоем заводится сразу.
|
||||
var template = ScheduleTemplate.Create(channel.Id, command.Name);
|
||||
channel.SetTemplate(template.Id);
|
||||
|
||||
dbContext.Channels.Add(channel);
|
||||
dbContext.ScheduleTemplates.Add(template);
|
||||
return Result.Success(channel.Id);
|
||||
}
|
||||
}
|
||||
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.CreateOverride;
|
||||
|
||||
public sealed record CreateProgrammingOverrideCommand(
|
||||
Guid ChannelId,
|
||||
OverrideMode Mode,
|
||||
OverrideRecurrence Recurrence,
|
||||
DateTimeOffset? StartsAtUtc,
|
||||
DateTimeOffset? EndsAtUtc,
|
||||
int? DayOfWeek,
|
||||
int? StartMinute,
|
||||
int? EndMinute,
|
||||
IReadOnlyList<OverrideShowInput> Shows
|
||||
) : ICommand<Result<Guid>>;
|
||||
-74
@@ -1,74 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.CreateOverride;
|
||||
|
||||
public sealed class CreateProgrammingOverrideCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<CreateProgrammingOverrideCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
CreateProgrammingOverrideCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var weekly = command.Recurrence == OverrideRecurrence.Weekly;
|
||||
if (weekly)
|
||||
{
|
||||
if (
|
||||
command.DayOfWeek is not (>= 0 and <= 6)
|
||||
|| command.StartMinute is not { } sm
|
||||
|| command.EndMinute is not { } em
|
||||
|| em <= sm
|
||||
|| sm < 0
|
||||
|| em > 1440
|
||||
)
|
||||
return Result.Failure<Guid>(ChannelErrors.InvalidOverrideWindow);
|
||||
}
|
||||
else if (
|
||||
command.StartsAtUtc is not { } start
|
||||
|| command.EndsAtUtc is not { } end
|
||||
|| end <= start
|
||||
)
|
||||
{
|
||||
return Result.Failure<Guid>(ChannelErrors.InvalidOverrideWindow);
|
||||
}
|
||||
|
||||
if (command.Shows.Count == 0)
|
||||
return Result.Failure<Guid>(ChannelErrors.OverrideNeedsShow);
|
||||
|
||||
var channel = await dbContext
|
||||
.Channels.Include(c => c.Overrides)
|
||||
.ThenInclude(o => o.Shows)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure<Guid>(ChannelErrors.NotFound);
|
||||
|
||||
var showIds = command.Shows.Select(s => s.ShowId).Distinct().ToList();
|
||||
var existingCount = await dbContext.Shows.CountAsync(
|
||||
s => showIds.Contains(s.Id),
|
||||
cancellationToken
|
||||
);
|
||||
if (existingCount != showIds.Count)
|
||||
return Result.Failure<Guid>(ChannelErrors.ShowNotFound);
|
||||
|
||||
var ovr = weekly
|
||||
? channel.AddWeeklyOverride(
|
||||
command.Mode,
|
||||
command.DayOfWeek!.Value,
|
||||
command.StartMinute!.Value,
|
||||
command.EndMinute!.Value
|
||||
)
|
||||
: channel.AddOverride(
|
||||
command.Mode,
|
||||
command.StartsAtUtc!.Value,
|
||||
command.EndsAtUtc!.Value
|
||||
);
|
||||
foreach (var show in command.Shows)
|
||||
ovr.AddShow(show.ShowId, show.Weight);
|
||||
|
||||
return Result.Success(ovr.Id);
|
||||
}
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.CreateOverride;
|
||||
|
||||
public sealed class CreateProgrammingOverrideCommandValidator
|
||||
: AbstractValidator<CreateProgrammingOverrideCommand>
|
||||
{
|
||||
public CreateProgrammingOverrideCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Shows).NotEmpty();
|
||||
RuleForEach(x => x.Shows)
|
||||
.ChildRules(s => s.RuleFor(i => i.Weight).InclusiveBetween(1, 1000));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace TeleWave.Application.Broadcast.CreateOverride;
|
||||
|
||||
public sealed record OverrideShowInput(Guid ShowId, int Weight);
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.DeleteOverride;
|
||||
|
||||
public sealed record DeleteProgrammingOverrideCommand(Guid ChannelId, Guid OverrideId)
|
||||
: ICommand<Result>;
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.DeleteOverride;
|
||||
|
||||
public sealed class DeleteProgrammingOverrideCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<DeleteProgrammingOverrideCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
DeleteProgrammingOverrideCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.Include(c => c.Overrides)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure(ChannelErrors.NotFound);
|
||||
|
||||
return channel.RemoveOverride(command.OverrideId)
|
||||
? Result.Success()
|
||||
: Result.Failure(ChannelErrors.OverrideNotFound);
|
||||
}
|
||||
}
|
||||
@@ -13,67 +13,16 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
// Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно
|
||||
// (GetChannelTemplateQuery) — здесь только собственные свойства канала.
|
||||
var channel = await dbContext
|
||||
.Channels.AsNoTracking()
|
||||
.Include(c => c.Shows)
|
||||
.ThenInclude(s => s.PreferredHours)
|
||||
.Include(c => c.Ads)
|
||||
.Include(c => c.BumperTemplates)
|
||||
.ThenInclude(t => t.Variants)
|
||||
.Include(c => c.Overrides)
|
||||
.ThenInclude(o => o.Shows)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure<ChannelDto>(ChannelErrors.NotFound);
|
||||
|
||||
var showIds = channel
|
||||
.Shows.Select(s => s.ShowId)
|
||||
.Concat(channel.Overrides.SelectMany(o => o.Shows.Select(s => s.ShowId)))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var showNames = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.Select(s => new { s.Id, s.Name })
|
||||
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
||||
|
||||
var poolAssetIds = channel.Ads.Select(a => a.MediaAssetId).Distinct().ToList();
|
||||
var assetNames = await dbContext
|
||||
.MediaAssets.AsNoTracking()
|
||||
.Where(a => poolAssetIds.Contains(a.Id))
|
||||
.Select(a => new { a.Id, a.OriginalFileName })
|
||||
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
|
||||
|
||||
string ShowName(Guid id) => showNames.GetValueOrDefault(id, "(удалено)");
|
||||
|
||||
var shows = channel
|
||||
.Shows.Select(s => new ChannelShowDto(
|
||||
s.Id,
|
||||
s.ShowId,
|
||||
ShowName(s.ShowId),
|
||||
s.Weight,
|
||||
s.BlockMode,
|
||||
s.BlockValue,
|
||||
s.IsEnabled,
|
||||
s.NextEpisodeIndex,
|
||||
s.PreferredWeightMultiplier,
|
||||
s.PreferredHours.OrderBy(h => h.StartHour)
|
||||
.Select(h => new HourWindowDto(h.StartHour, h.EndHour))
|
||||
.ToList()
|
||||
))
|
||||
.ToList();
|
||||
|
||||
var ads = channel
|
||||
.Ads.OrderBy(a => a.Position)
|
||||
.Select(a => new ChannelAdDto(
|
||||
a.Id,
|
||||
a.MediaAssetId,
|
||||
assetNames.GetValueOrDefault(a.MediaAssetId),
|
||||
a.Position
|
||||
))
|
||||
.ToList();
|
||||
|
||||
var bumperTemplates = channel
|
||||
.BumperTemplates.OrderBy(t => t.Position)
|
||||
.Select(t => new BumperTemplateDto(
|
||||
@@ -105,32 +54,16 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
||||
))
|
||||
.ToList();
|
||||
|
||||
var overrides = channel
|
||||
.Overrides.OrderBy(o => o.Recurrence)
|
||||
.ThenBy(o => o.StartsAtUtc)
|
||||
.ThenBy(o => o.DayOfWeek)
|
||||
.Select(o => new ProgrammingOverrideDto(
|
||||
o.Id,
|
||||
o.Mode,
|
||||
o.Recurrence,
|
||||
o.StartsAtUtc,
|
||||
o.EndsAtUtc,
|
||||
o.DayOfWeek,
|
||||
o.StartMinute,
|
||||
o.EndMinute,
|
||||
o.Shows.Select(s => new OverrideShowDto(s.ShowId, ShowName(s.ShowId), s.Weight))
|
||||
.ToList()
|
||||
))
|
||||
.ToList();
|
||||
|
||||
return Result.Success(
|
||||
new ChannelDto(
|
||||
channel.Id,
|
||||
channel.Name,
|
||||
channel.Slug,
|
||||
channel.IsEnabled,
|
||||
channel.AdInsertion,
|
||||
channel.AdsPerBreak,
|
||||
channel.Number,
|
||||
channel.UtcOffsetMinutes,
|
||||
channel.DayStartTime,
|
||||
channel.TemplateId,
|
||||
channel.BumpersEnabled,
|
||||
new BumperSettingsDto(
|
||||
channel.BumperFont,
|
||||
@@ -140,10 +73,7 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
||||
channel.BumperEpisodeChangeChance
|
||||
),
|
||||
bumperTemplates,
|
||||
channel.FillerAssetId,
|
||||
shows,
|
||||
ads,
|
||||
overrides
|
||||
channel.FillerAssetId
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.RegenerateSchedule;
|
||||
|
||||
public sealed record RegenerateChannelScheduleCommand(Guid ChannelId) : ICommand<Result>;
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Broadcast.Scheduling;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.RegenerateSchedule;
|
||||
|
||||
public sealed class RegenerateChannelScheduleCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
ScheduleGenerator generator
|
||||
) : ICommandHandler<RegenerateChannelScheduleCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
RegenerateChannelScheduleCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var exists = await dbContext.Channels.AnyAsync(
|
||||
c => c.Id == command.ChannelId,
|
||||
cancellationToken
|
||||
);
|
||||
if (!exists)
|
||||
return Result.Failure(ChannelErrors.NotFound);
|
||||
|
||||
// Генератор сам сохраняет изменения (удаление хвоста + новые записи + курсоры).
|
||||
await generator.GenerateAsync(
|
||||
command.ChannelId,
|
||||
DateTimeOffset.UtcNow,
|
||||
regenerate: true,
|
||||
cancellationToken
|
||||
);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.RemoveChannelAd;
|
||||
|
||||
public sealed record RemoveChannelAdCommand(Guid ChannelId, Guid ChannelAdId) : ICommand<Result>;
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.RemoveChannelAd;
|
||||
|
||||
public sealed class RemoveChannelAdCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<RemoveChannelAdCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
RemoveChannelAdCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.Include(c => c.Ads)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure(ChannelErrors.NotFound);
|
||||
|
||||
return channel.RemoveAd(command.ChannelAdId)
|
||||
? Result.Success()
|
||||
: Result.Failure(ChannelErrors.AdNotFound);
|
||||
}
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.RemoveChannelShow;
|
||||
|
||||
public sealed record RemoveChannelShowCommand(Guid ChannelId, Guid ChannelShowId)
|
||||
: ICommand<Result>;
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.RemoveChannelShow;
|
||||
|
||||
public sealed class RemoveChannelShowCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<RemoveChannelShowCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
RemoveChannelShowCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.Include(c => c.Shows)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure(ChannelErrors.NotFound);
|
||||
|
||||
return channel.RemoveShow(command.ChannelShowId)
|
||||
? Result.Success()
|
||||
: Result.Failure(ChannelErrors.ChannelShowNotFound);
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Резолвит ассеты ТВ-заставок для запланированных переходов: для каждой уникальной тройки
|
||||
/// «из→в→подблок» возвращает id ассета-заставки. Сам ffmpeg НЕ запускает — если готового (или уже
|
||||
/// рендерящегося) ассета в кэше нет, создаёт <see cref="MediaAsset"/> в статусе Pending, кэш-строку
|
||||
/// <see cref="BumperAsset"/> (с id канала/блока/подблока для восстановления спеки) и ставит в очередь
|
||||
/// фонового рендерера. Так тик планировщика не блокируется на ffmpeg, а его транзакция не держится во
|
||||
/// время рендера. До готовности ассета плейлист отдаёт филлер (см. GetLivePlaylistQueryHandler).
|
||||
/// </summary>
|
||||
public sealed class ScheduleBumperResolver(
|
||||
IAppDbContext dbContext,
|
||||
IBumperRenderQueue renderQueue,
|
||||
IOptions<BumperOptions> bumperOptions,
|
||||
IOptions<StreamingOptions> streamingOptions
|
||||
)
|
||||
{
|
||||
private readonly BumperOptions _bumper = bumperOptions.Value;
|
||||
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
||||
|
||||
/// <summary>
|
||||
/// Для каждой уникальной тройки «из→в→подблок» из запланированных заставок возвращает id ассета:
|
||||
/// готового/рендерящегося из кэша либо только что созданного Pending (поставлен в очередь рендера).
|
||||
/// </summary>
|
||||
public async Task<Dictionary<(Guid From, Guid To, Guid Variant), Guid>> ResolveAsync(
|
||||
Channel channel,
|
||||
IReadOnlyList<PlannedEntry> entries,
|
||||
IReadOnlyDictionary<Guid, string> showNames,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = new Dictionary<(Guid, Guid, Guid), Guid>();
|
||||
var variantsById = channel
|
||||
.BumperTemplates.SelectMany(t => t.Variants.Select(v => (Variant: v, Template: t)))
|
||||
.ToDictionary(x => x.Variant.Id);
|
||||
var combos = entries
|
||||
.Where(e =>
|
||||
e.Kind == ScheduleEntryKind.Bumper
|
||||
&& e.FromShowId is not null
|
||||
&& e.ToShowId is not null
|
||||
&& e.BumperVariantId is not null
|
||||
)
|
||||
.Select(e =>
|
||||
(
|
||||
From: e.FromShowId!.Value,
|
||||
To: e.ToShowId!.Value,
|
||||
Variant: e.BumperVariantId!.Value
|
||||
)
|
||||
)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (combos.Count == 0)
|
||||
return result;
|
||||
|
||||
var fromIds = combos.Select(c => c.From).Distinct().ToList();
|
||||
var toIds = combos.Select(c => c.To).Distinct().ToList();
|
||||
|
||||
// Постер шоу-получателя участвует в сигнатуре (как токен id) — грузим только id картинки;
|
||||
// абсолютный путь для рендера резолвит уже фоновый рендерер.
|
||||
var showIds = fromIds.Concat(toIds).Distinct().ToList();
|
||||
var posterImageByShow = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id) && s.PosterImageId != null)
|
||||
.Select(s => new { s.Id, ImageId = s.PosterImageId!.Value })
|
||||
.ToDictionaryAsync(s => s.Id, s => s.ImageId, cancellationToken);
|
||||
|
||||
// Кэш заставок (новейшие первыми) + статусы их ассетов: Ready/Pending/Processing переиспользуем,
|
||||
// Failed — рендерим заново.
|
||||
var cached = await dbContext
|
||||
.BumperAssets.AsNoTracking()
|
||||
.Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId))
|
||||
.OrderByDescending(b => b.CreatedAt)
|
||||
.Select(b => new
|
||||
{
|
||||
b.FromShowId,
|
||||
b.ToShowId,
|
||||
b.Signature,
|
||||
b.MediaAssetId,
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var cachedAssetIds = cached.Select(c => c.MediaAssetId).Distinct().ToList();
|
||||
var statusById = await dbContext
|
||||
.MediaAssets.AsNoTracking()
|
||||
.Where(a => cachedAssetIds.Contains(a.Id))
|
||||
.Select(a => new { a.Id, a.Status })
|
||||
.ToDictionaryAsync(a => a.Id, a => a.Status, cancellationToken);
|
||||
|
||||
foreach (var combo in combos)
|
||||
{
|
||||
if (result.ContainsKey(combo))
|
||||
continue;
|
||||
if (!variantsById.TryGetValue(combo.Variant, out var pair))
|
||||
continue;
|
||||
var (variant, template) = pair;
|
||||
|
||||
var fromName = showNames.GetValueOrDefault(combo.From, "…");
|
||||
var toName = showNames.GetValueOrDefault(combo.To, "…");
|
||||
var usePoster = variant.Kind == BumperTextKind.NowNext;
|
||||
var posterToken =
|
||||
usePoster && posterImageByShow.TryGetValue(combo.To, out var pid)
|
||||
? pid.ToString()
|
||||
: "-";
|
||||
var aligned = BumperDuration.Aligned(
|
||||
BumperDuration.TemplateSeconds(template),
|
||||
_segmentSeconds
|
||||
);
|
||||
var signature = ComputeSignature(
|
||||
channel,
|
||||
template,
|
||||
variant,
|
||||
fromName,
|
||||
toName,
|
||||
aligned,
|
||||
posterToken
|
||||
);
|
||||
|
||||
// Новейшая кэш-строка с этой сигнатурой (список упорядочен по CreatedAt DESC).
|
||||
var hit = cached.FirstOrDefault(c =>
|
||||
c.FromShowId == combo.From && c.ToShowId == combo.To && c.Signature == signature
|
||||
);
|
||||
if (
|
||||
hit is not null
|
||||
&& statusById.TryGetValue(hit.MediaAssetId, out var status)
|
||||
&& status != MediaAssetStatus.Failed
|
||||
)
|
||||
{
|
||||
result[combo] = hit.MediaAssetId; // Ready / Pending / Processing — переиспользуем
|
||||
continue;
|
||||
}
|
||||
|
||||
// Нет кэша либо прошлый рендер провалился — создаём Pending-ассет + кэш-строку, ставим в очередь.
|
||||
var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}");
|
||||
dbContext.MediaAssets.Add(asset);
|
||||
dbContext.BumperAssets.Add(
|
||||
BumperAsset.Create(
|
||||
channel.Id,
|
||||
template.Id,
|
||||
variant.Id,
|
||||
combo.From,
|
||||
combo.To,
|
||||
signature,
|
||||
asset.Id
|
||||
)
|
||||
);
|
||||
// Сигнал — лучший случай (строка станет видимой после коммита транзакции планировщика);
|
||||
// гарантия подхвата — периодический опрос БД фоновым рендерером.
|
||||
renderQueue.Enqueue(asset.Id);
|
||||
result[combo] = asset.Id;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сигнатура рендера = хэш всех входов заставки: общие настройки канала (шрифт/подписи/версия
|
||||
/// шаблона), оформление и файлы блока (цвета/фон/звук/ревизия), названия шоу и постер. Меняется —
|
||||
/// заставка пересобирается. Разделитель — unit separator (U+001F), чтобы поля не слипались.
|
||||
/// </summary>
|
||||
private string ComputeSignature(
|
||||
Channel channel,
|
||||
BumperTemplate template,
|
||||
BumperTextVariant variant,
|
||||
string fromName,
|
||||
string toName,
|
||||
int alignedDurationSeconds,
|
||||
string poster
|
||||
)
|
||||
{
|
||||
var raw = string.Join(
|
||||
'',
|
||||
_bumper.TemplateVersion,
|
||||
_bumper.Width,
|
||||
_bumper.Height,
|
||||
alignedDurationSeconds,
|
||||
channel.BumperFont,
|
||||
variant.Kind,
|
||||
variant.NowLabel,
|
||||
variant.NextLabel,
|
||||
variant.Line1,
|
||||
variant.Line2,
|
||||
template.BackgroundColor,
|
||||
template.BackgroundColor2,
|
||||
template.AccentColor,
|
||||
template.TextColor,
|
||||
template.Revision,
|
||||
template.BackgroundImageId?.ToString() ?? "-",
|
||||
template.AudioExtension ?? "-",
|
||||
fromName,
|
||||
toName,
|
||||
poster
|
||||
);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Оркестратор планирования: загружает конфигурацию канала и готовые ассеты, вызывает чистый
|
||||
/// <see cref="SchedulePlanner"/>, материализует записи и двигает курсоры. Используется фоновым
|
||||
/// планировщиком (расширение горизонта) и командой перегенерации (правка конфигурации канала).
|
||||
/// Ассеты заставок-переходов резолвит/рендерит <see cref="ScheduleBumperResolver"/> (общий контекст).
|
||||
/// </summary>
|
||||
public sealed class ScheduleGenerator(
|
||||
IAppDbContext dbContext,
|
||||
IRandomSource random,
|
||||
ScheduleBumperResolver bumperResolver,
|
||||
IOptions<SchedulerOptions> options,
|
||||
IOptions<StreamingOptions> streamingOptions
|
||||
)
|
||||
{
|
||||
private readonly SchedulerOptions _options = options.Value;
|
||||
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
||||
|
||||
/// <summary>
|
||||
/// Достраивает (или, при <paramref name="regenerate"/>, перестраивает будущий хвост) расписание
|
||||
/// канала до горизонта. Возвращает число добавленных записей (-1 — канал не найден/выключен).
|
||||
/// </summary>
|
||||
public async Task<int> GenerateAsync(
|
||||
Guid channelId,
|
||||
DateTimeOffset now,
|
||||
bool regenerate,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.Include(c => c.Shows)
|
||||
.ThenInclude(s => s.PreferredHours)
|
||||
.Include(c => c.Ads)
|
||||
.Include(c => c.BumperTemplates)
|
||||
.ThenInclude(t => t.Variants)
|
||||
.Include(c => c.Overrides)
|
||||
.ThenInclude(o => o.Shows)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
|
||||
|
||||
if (channel is null || !channel.IsEnabled)
|
||||
return -1;
|
||||
|
||||
// Сериализуем генерацию одного канала: фоновый тик и ручная перегенерация не должны читать
|
||||
// одну точку продолжения и оба дописывать хвост (иначе дубли/перекрытия записей). Advisory-lock
|
||||
// держится до коммита/отката транзакции ниже; при сбое рендера заставок весь прогон откатится.
|
||||
await using var transaction = await dbContext.BeginTransactionAsync(cancellationToken);
|
||||
await dbContext.AcquireChannelLockAsync(channelId, cancellationToken);
|
||||
|
||||
var horizonEnd = now.AddDays(_options.HorizonDays);
|
||||
|
||||
// Чистим прошлое сверх окна ретеншна.
|
||||
var retentionCutoff = now.AddHours(-_options.RetentionHours);
|
||||
await dbContext
|
||||
.ScheduleEntries.Where(e => e.ChannelId == channelId && e.EndsAtUtc < retentionCutoff)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
// Точка продолжения: конец последней сохранённой записи (для regenerate — только уже стартовавшей).
|
||||
var lastEnd = await dbContext
|
||||
.ScheduleEntries.Where(e =>
|
||||
e.ChannelId == channelId && (!regenerate || e.StartsAtUtc < now)
|
||||
)
|
||||
.MaxAsync(e => (DateTimeOffset?)e.EndsAtUtc, cancellationToken);
|
||||
|
||||
var startTime = lastEnd ?? now;
|
||||
if (startTime < now)
|
||||
startTime = now;
|
||||
|
||||
if (regenerate)
|
||||
await dbContext
|
||||
.ScheduleEntries.Where(e => e.ChannelId == channelId && e.StartsAtUtc >= now)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
if (startTime >= horizonEnd)
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return 0;
|
||||
}
|
||||
|
||||
var showNames = await LoadShowNamesAsync(channel, cancellationToken);
|
||||
var input = await BuildInputAsync(channel, startTime, horizonEnd, cancellationToken);
|
||||
var result = SchedulePlanner.Plan(input, random);
|
||||
|
||||
// Рендерим/достаём из кэша ассеты заставок для всех переходов плана (по паре шоу + подблоку).
|
||||
var bumperAssets = await bumperResolver.ResolveAsync(
|
||||
channel,
|
||||
result.Entries,
|
||||
showNames,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var added = 0;
|
||||
foreach (var entry in result.Entries)
|
||||
{
|
||||
ScheduleEntry? scheduleEntry = entry.Kind switch
|
||||
{
|
||||
ScheduleEntryKind.Program => ScheduleEntry.Program(
|
||||
channel.Id,
|
||||
entry.MediaAssetId,
|
||||
entry.StartsAtUtc,
|
||||
entry.EndsAtUtc,
|
||||
entry.ShowId!.Value,
|
||||
entry.EpisodeIndex!.Value
|
||||
),
|
||||
ScheduleEntryKind.Ad => ScheduleEntry.Ad(
|
||||
channel.Id,
|
||||
entry.MediaAssetId,
|
||||
entry.StartsAtUtc,
|
||||
entry.EndsAtUtc
|
||||
),
|
||||
ScheduleEntryKind.Bumper => BuildBumperEntry(channel.Id, entry, bumperAssets),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (scheduleEntry is null)
|
||||
continue;
|
||||
|
||||
dbContext.ScheduleEntries.Add(scheduleEntry);
|
||||
added++;
|
||||
}
|
||||
|
||||
foreach (var channelShow in channel.Shows)
|
||||
if (result.NextEpisodeIndexByChannelShow.TryGetValue(channelShow.Id, out var idx))
|
||||
channelShow.SetNextEpisodeIndex(idx);
|
||||
|
||||
channel.SetNextAdIndex(result.NextAdIndex);
|
||||
channel.SetNextBumperIndex(result.NextBumperIndex);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return added;
|
||||
}
|
||||
|
||||
private static ScheduleEntry? BuildBumperEntry(
|
||||
Guid channelId,
|
||||
PlannedEntry entry,
|
||||
IReadOnlyDictionary<(Guid From, Guid To, Guid Variant), Guid> bumperAssets
|
||||
)
|
||||
{
|
||||
// Заставка резолвится по паре шоу + выбранному подблоку (отрендерена/из кэша). Если рендер не
|
||||
// удался — пропускаем запись (слот заполнит филлер/следующая программа; длину планировщик учёл).
|
||||
if (
|
||||
entry.FromShowId is not { } from
|
||||
|| entry.ToShowId is not { } to
|
||||
|| entry.BumperVariantId is not { } variant
|
||||
|| !bumperAssets.TryGetValue((from, to, variant), out var assetId)
|
||||
)
|
||||
return null;
|
||||
|
||||
return ScheduleEntry.Bumper(
|
||||
channelId,
|
||||
assetId,
|
||||
entry.StartsAtUtc,
|
||||
entry.EndsAtUtc,
|
||||
entry.ToShowId,
|
||||
entry.BumperVariantId
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<Guid, string>> LoadShowNamesAsync(
|
||||
Channel channel,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var showIds = channel.Shows.Select(s => s.ShowId).Distinct().ToList();
|
||||
if (showIds.Count == 0)
|
||||
return new Dictionary<Guid, string>();
|
||||
|
||||
return await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.Select(s => new { s.Id, s.Name })
|
||||
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<PlannerInput> BuildInputAsync(
|
||||
Channel channel,
|
||||
DateTimeOffset startTime,
|
||||
DateTimeOffset horizonEnd,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var enabledShows = channel.Shows.Where(s => s.IsEnabled).ToList();
|
||||
var showIds = enabledShows.Select(s => s.ShowId).Distinct().ToList();
|
||||
|
||||
var shows = await dbContext
|
||||
.Shows.Include(s => s.Episodes)
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var episodesByShow = shows.ToDictionary(
|
||||
s => s.Id,
|
||||
s => s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).ToList()
|
||||
);
|
||||
|
||||
var candidateAssetIds = episodesByShow
|
||||
.Values.SelectMany(x => x)
|
||||
.Concat(channel.Ads.Select(a => a.MediaAssetId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var durations = await dbContext
|
||||
.MediaAssets.Where(a =>
|
||||
candidateAssetIds.Contains(a.Id)
|
||||
&& a.Status == MediaAssetStatus.Ready
|
||||
&& a.Duration != null
|
||||
)
|
||||
.Select(a => new { a.Id, a.Duration })
|
||||
.ToDictionaryAsync(x => x.Id, x => x.Duration!.Value, cancellationToken);
|
||||
|
||||
var plannerShows = new List<PlannerShow>();
|
||||
foreach (var channelShow in enabledShows)
|
||||
{
|
||||
if (!episodesByShow.TryGetValue(channelShow.ShowId, out var episodeIds))
|
||||
continue;
|
||||
var ready = episodeIds.Where(durations.ContainsKey).ToList();
|
||||
if (ready.Count == 0)
|
||||
continue;
|
||||
|
||||
plannerShows.Add(
|
||||
new PlannerShow(
|
||||
channelShow.Id,
|
||||
channelShow.ShowId,
|
||||
channelShow.Weight,
|
||||
channelShow.BlockMode,
|
||||
channelShow.BlockValue,
|
||||
ready,
|
||||
channelShow.NextEpisodeIndex,
|
||||
channelShow
|
||||
.PreferredHours.Select(h => new PlannerHourWindow(h.StartHour, h.EndHour))
|
||||
.ToList(),
|
||||
channelShow.PreferredWeightMultiplier
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
var adPool = channel
|
||||
.Ads.OrderBy(a => a.Position)
|
||||
.Select(a => a.MediaAssetId)
|
||||
.Where(durations.ContainsKey)
|
||||
.ToList();
|
||||
|
||||
// Подблоки заставок (плоский список): длительность слота — по звуку блока, выровнена на сегмент.
|
||||
var bumperVariants = channel
|
||||
.BumperTemplates.OrderBy(t => t.Position)
|
||||
.SelectMany(t =>
|
||||
{
|
||||
var dur = TimeSpan.FromSeconds(
|
||||
BumperDuration.Aligned(BumperDuration.TemplateSeconds(t), _segmentSeconds)
|
||||
);
|
||||
return t
|
||||
.Variants.OrderBy(v => v.Position)
|
||||
.Select(v => new PlannerBumperVariant(v.Id, t.Id, dur, v.Trigger, v.Weight));
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var overrides = channel
|
||||
.Overrides.Select(o => new PlannerOverride(
|
||||
o.Mode,
|
||||
o.Shows.Select(s => new PlannerOverrideShow(s.ShowId, s.Weight)).ToList(),
|
||||
o.Recurrence,
|
||||
o.StartsAtUtc,
|
||||
o.EndsAtUtc,
|
||||
o.DayOfWeek,
|
||||
o.StartMinute,
|
||||
o.EndMinute
|
||||
))
|
||||
.ToList();
|
||||
|
||||
var bumpers = new PlannerBumperConfig(
|
||||
channel.BumpersEnabled,
|
||||
TimeSpan.FromMinutes(channel.BumperMinIntervalMinutes),
|
||||
channel.BumperSelection,
|
||||
bumperVariants,
|
||||
channel.BumperShowChangeChance,
|
||||
channel.BumperEpisodeChangeChance
|
||||
);
|
||||
|
||||
return new PlannerInput(
|
||||
channel.Id,
|
||||
channel.AdInsertion,
|
||||
channel.AdsPerBreak,
|
||||
channel.NextAdIndex,
|
||||
plannerShows,
|
||||
adPool,
|
||||
durations,
|
||||
overrides,
|
||||
startTime,
|
||||
horizonEnd,
|
||||
bumpers,
|
||||
channel.NextBumperIndex
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
namespace TeleWave.Application.Broadcast.Scheduling;
|
||||
|
||||
public sealed class SchedulerOptions
|
||||
{
|
||||
public const string SectionName = "Scheduler";
|
||||
|
||||
/// <summary>На сколько дней вперёд держать материализованное расписание.</summary>
|
||||
public int HorizonDays { get; init; } = 3;
|
||||
|
||||
/// <summary>Сколько часов прошедшего расписания хранить (для EPG «что было»), затем чистить.</summary>
|
||||
public int RetentionHours { get; init; } = 24;
|
||||
|
||||
/// <summary>Период тика фонового планировщика, минуты.</summary>
|
||||
public int TickMinutes { get; init; } = 30;
|
||||
}
|
||||
namespace TeleWave.Application.Broadcast.Scheduling;
|
||||
|
||||
public sealed class SchedulerOptions
|
||||
{
|
||||
public const string SectionName = "Scheduler";
|
||||
|
||||
/// <summary>
|
||||
/// На сколько дней вперёд держать материализованное расписание. Неделя — часть замысла:
|
||||
/// «знать, что мультики будут в субботу в 9:30» работает, только если программа известна заранее.
|
||||
/// </summary>
|
||||
public int HorizonDays { get; init; } = 7;
|
||||
|
||||
/// <summary>
|
||||
/// Сколько дней прошедшего расписания хранить. Должно покрывать самое долгое остывание среди правил
|
||||
/// канала и самый глубокий повтор: история показов берётся из самой ленты, отдельного журнала нет.
|
||||
/// </summary>
|
||||
public int RetentionDays { get; init; } = 90;
|
||||
|
||||
/// <summary>Период тика фонового планировщика, минуты.</summary>
|
||||
public int TickMinutes { get; init; } = 30;
|
||||
}
|
||||
|
||||
+23
-25
@@ -1,25 +1,23 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||
|
||||
public sealed record UpdateChannelSettingsCommand(
|
||||
Guid ChannelId,
|
||||
string Name,
|
||||
bool IsEnabled,
|
||||
AdInsertion AdInsertion,
|
||||
int AdsPerBreak,
|
||||
bool BumpersEnabled,
|
||||
BumperSettingsInput Bumper,
|
||||
Guid? FillerAssetId
|
||||
) : ICommand<Result>;
|
||||
|
||||
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>).</summary>
|
||||
public sealed record BumperSettingsInput(
|
||||
BumperFont Font,
|
||||
int MinIntervalMinutes,
|
||||
BumperSelection Selection,
|
||||
double ShowChangeChance,
|
||||
double EpisodeChangeChance
|
||||
);
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||
|
||||
public sealed record UpdateChannelSettingsCommand(
|
||||
Guid ChannelId,
|
||||
string Name,
|
||||
bool IsEnabled,
|
||||
bool BumpersEnabled,
|
||||
BumperSettingsInput Bumper,
|
||||
Guid? FillerAssetId
|
||||
) : ICommand<Result>;
|
||||
|
||||
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>).</summary>
|
||||
public sealed record BumperSettingsInput(
|
||||
BumperFont Font,
|
||||
int MinIntervalMinutes,
|
||||
BumperSelection Selection,
|
||||
double ShowChangeChance,
|
||||
double EpisodeChangeChance
|
||||
);
|
||||
|
||||
+48
-50
@@ -1,50 +1,48 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||
|
||||
public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateChannelSettingsCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateChannelSettingsCommand 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.FillerAssetId is { } fillerId)
|
||||
{
|
||||
var exists = await dbContext.MediaAssets.AnyAsync(
|
||||
a => a.Id == fillerId,
|
||||
cancellationToken
|
||||
);
|
||||
if (!exists)
|
||||
return Result.Failure(ChannelErrors.AssetNotFound);
|
||||
}
|
||||
|
||||
channel.UpdateSettings(
|
||||
command.Name,
|
||||
command.IsEnabled,
|
||||
command.AdInsertion,
|
||||
command.AdsPerBreak,
|
||||
command.BumpersEnabled,
|
||||
command.FillerAssetId
|
||||
);
|
||||
channel.UpdateBumperSettings(
|
||||
command.Bumper.Font,
|
||||
command.Bumper.MinIntervalMinutes,
|
||||
command.Bumper.Selection,
|
||||
command.Bumper.ShowChangeChance,
|
||||
command.Bumper.EpisodeChangeChance
|
||||
);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||
|
||||
public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateChannelSettingsCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateChannelSettingsCommand 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.FillerAssetId is { } fillerId)
|
||||
{
|
||||
var exists = await dbContext.MediaAssets.AnyAsync(
|
||||
a => a.Id == fillerId,
|
||||
cancellationToken
|
||||
);
|
||||
if (!exists)
|
||||
return Result.Failure(ChannelErrors.AssetNotFound);
|
||||
}
|
||||
|
||||
channel.UpdateSettings(
|
||||
command.Name,
|
||||
command.IsEnabled,
|
||||
command.BumpersEnabled,
|
||||
command.FillerAssetId
|
||||
);
|
||||
channel.UpdateBumperSettings(
|
||||
command.Bumper.Font,
|
||||
command.Bumper.MinIntervalMinutes,
|
||||
command.Bumper.Selection,
|
||||
command.Bumper.ShowChangeChance,
|
||||
command.Bumper.EpisodeChangeChance
|
||||
);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
+16
-17
@@ -1,17 +1,16 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||
|
||||
public sealed class UpdateChannelSettingsCommandValidator
|
||||
: AbstractValidator<UpdateChannelSettingsCommand>
|
||||
{
|
||||
public UpdateChannelSettingsCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.AdsPerBreak).InclusiveBetween(0, 10);
|
||||
|
||||
RuleFor(x => x.Bumper.MinIntervalMinutes).InclusiveBetween(0, 1440);
|
||||
RuleFor(x => x.Bumper.ShowChangeChance).InclusiveBetween(0.0, 1.0);
|
||||
RuleFor(x => x.Bumper.EpisodeChangeChance).InclusiveBetween(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||
|
||||
public sealed class UpdateChannelSettingsCommandValidator
|
||||
: AbstractValidator<UpdateChannelSettingsCommand>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.UpdateChannelShow;
|
||||
|
||||
public sealed record UpdateChannelShowCommand(
|
||||
Guid ChannelId,
|
||||
Guid ChannelShowId,
|
||||
int Weight,
|
||||
BlockMode BlockMode,
|
||||
int BlockValue,
|
||||
bool IsEnabled,
|
||||
int PreferredWeightMultiplier,
|
||||
IReadOnlyList<HourWindowInput> PreferredHours
|
||||
) : ICommand<Result>;
|
||||
|
||||
/// <summary>Окно предпочтительных часов [StartHour, EndHour) суток (UTC).</summary>
|
||||
public sealed record HourWindowInput(int StartHour, int EndHour);
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.UpdateChannelShow;
|
||||
|
||||
public sealed class UpdateChannelShowCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateChannelShowCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateChannelShowCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext
|
||||
.Channels.Include(c => c.Shows)
|
||||
.ThenInclude(s => s.PreferredHours)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
||||
if (channel is null)
|
||||
return Result.Failure(ChannelErrors.NotFound);
|
||||
|
||||
var channelShow = channel.FindShow(command.ChannelShowId);
|
||||
if (channelShow is null)
|
||||
return Result.Failure(ChannelErrors.ChannelShowNotFound);
|
||||
|
||||
channelShow.Update(
|
||||
command.Weight,
|
||||
command.BlockMode,
|
||||
command.BlockValue,
|
||||
command.IsEnabled
|
||||
);
|
||||
channelShow.SetPreferredHours(
|
||||
command.PreferredWeightMultiplier,
|
||||
command.PreferredHours.Select(h => (h.StartHour, h.EndHour))
|
||||
);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.UpdateChannelShow;
|
||||
|
||||
public sealed class UpdateChannelShowCommandValidator : AbstractValidator<UpdateChannelShowCommand>
|
||||
{
|
||||
public UpdateChannelShowCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Weight).InclusiveBetween(1, 1000);
|
||||
RuleFor(x => x.BlockValue).InclusiveBetween(1, 10000);
|
||||
RuleFor(x => x.PreferredWeightMultiplier).InclusiveBetween(1, 100);
|
||||
RuleForEach(x => x.PreferredHours)
|
||||
.ChildRules(w =>
|
||||
{
|
||||
w.RuleFor(h => h.StartHour).InclusiveBetween(0, 23);
|
||||
w.RuleFor(h => h.EndHour).InclusiveBetween(1, 24);
|
||||
w.RuleFor(h => h)
|
||||
.Must(h => h.StartHour < h.EndHour)
|
||||
.WithMessage("Начало окна должно быть раньше конца.");
|
||||
});
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.UpdateChannelTime;
|
||||
|
||||
/// <summary>
|
||||
/// Номер канала и его время: смещение от UTC и начало вещательных суток. Отдельно от общих настроек
|
||||
/// канала, потому что те завязаны на старую ротацию и уйдут вместе с ней.
|
||||
/// </summary>
|
||||
public sealed record UpdateChannelTimeCommand(
|
||||
Guid ChannelId,
|
||||
int? Number,
|
||||
int UtcOffsetMinutes,
|
||||
TimeOnly DayStartTime
|
||||
) : ICommand<Result>;
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.UpdateChannelTime;
|
||||
|
||||
public sealed class UpdateChannelTimeCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateChannelTimeCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateChannelTimeCommand 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.Number is { } number)
|
||||
{
|
||||
var taken = await dbContext.Channels.AnyAsync(
|
||||
c => c.Id != channel.Id && c.Number == number,
|
||||
cancellationToken
|
||||
);
|
||||
if (taken)
|
||||
return Result.Failure(ChannelErrors.NumberTaken);
|
||||
}
|
||||
|
||||
channel.UpdateTimeSettings(command.Number, command.UtcOffsetMinutes, command.DayStartTime);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using TeleWave.Domain.Auth;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Images;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Domain.Settings;
|
||||
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
public interface IAppDbContext
|
||||
{
|
||||
DbSet<RefreshToken> RefreshTokens { get; }
|
||||
DbSet<MediaAsset> MediaAssets { get; }
|
||||
DbSet<Show> Shows { get; }
|
||||
DbSet<Channel> Channels { get; }
|
||||
DbSet<ScheduleEntry> ScheduleEntries { get; }
|
||||
DbSet<BumperTextVariant> BumperTextVariants { get; }
|
||||
DbSet<BumperAsset> BumperAssets { get; }
|
||||
DbSet<AppSetting> AppSettings { get; }
|
||||
DbSet<Image> Images { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Открывает явную транзакцию БД — для команд с несколькими операциями (в т.ч.
|
||||
/// <c>ExecuteDelete</c> в обход change-tracker), которые должны быть атомарны.</summary>
|
||||
Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Берёт транзакционную advisory-блокировку по каналу (снимается при коммите/откате).
|
||||
/// Сериализует генерацию расписания одного канала между фоновым тиком и ручной перегенерацией.
|
||||
/// Вызывать внутри открытой транзакции.</summary>
|
||||
Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken);
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using TeleWave.Domain.Auth;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Images;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Domain.Settings;
|
||||
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
public interface IAppDbContext
|
||||
{
|
||||
DbSet<RefreshToken> RefreshTokens { get; }
|
||||
DbSet<MediaAsset> MediaAssets { get; }
|
||||
DbSet<Show> Shows { get; }
|
||||
DbSet<Genre> Genres { get; }
|
||||
DbSet<GenreAlias> GenreAliases { get; }
|
||||
DbSet<ShowGenre> ShowGenres { get; }
|
||||
DbSet<Collection> Collections { get; }
|
||||
DbSet<CollectionItem> CollectionItems { get; }
|
||||
DbSet<Group> Groups { get; }
|
||||
DbSet<GroupItem> GroupItems { get; }
|
||||
DbSet<ScheduleTemplate> ScheduleTemplates { get; }
|
||||
DbSet<GridLayer> GridLayers { get; }
|
||||
DbSet<Slot> Slots { get; }
|
||||
DbSet<SlotState> SlotStates { get; }
|
||||
DbSet<JunctionTemplate> JunctionTemplates { get; }
|
||||
DbSet<JunctionElement> JunctionElements { get; }
|
||||
DbSet<Channel> Channels { get; }
|
||||
DbSet<ScheduleEntry> ScheduleEntries { get; }
|
||||
DbSet<BumperTextVariant> BumperTextVariants { get; }
|
||||
DbSet<BumperAsset> BumperAssets { get; }
|
||||
DbSet<AppSetting> AppSettings { get; }
|
||||
DbSet<Image> Images { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Открывает явную транзакцию БД — для команд с несколькими операциями (в т.ч.
|
||||
/// <c>ExecuteDelete</c> в обход change-tracker), которые должны быть атомарны.</summary>
|
||||
Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Берёт транзакционную advisory-блокировку по каналу (снимается при коммите/откате).
|
||||
/// Сериализует генерацию расписания одного канала между фоновым тиком и ручной перегенерацией.
|
||||
/// Вызывать внутри открытой транзакции.</summary>
|
||||
Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.AddCollectionShow;
|
||||
|
||||
public sealed record AddCollectionShowCommand(Guid CollectionId, Guid ShowId) : ICommand<Result>;
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.AddCollectionShow;
|
||||
|
||||
public sealed class AddCollectionShowCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<AddCollectionShowCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
AddCollectionShowCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var collection = await dbContext
|
||||
.Collections.Include(c => c.Items)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.CollectionId, cancellationToken);
|
||||
if (collection is null)
|
||||
return Result.Failure(CollectionErrors.NotFound);
|
||||
|
||||
if (!await dbContext.Shows.AnyAsync(s => s.Id == command.ShowId, cancellationToken))
|
||||
return Result.Failure(ShowErrors.NotFound);
|
||||
|
||||
return collection.AddShow(command.ShowId) is null
|
||||
? Result.Failure(CollectionErrors.ShowAlreadyAdded)
|
||||
: Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections;
|
||||
|
||||
/// <summary>Коллекция в списке: без состава, но со сводкой — сколько частей и сколько в них единиц
|
||||
/// воспроизведения (у сериала внутри коллекции их больше одной).</summary>
|
||||
public sealed record CollectionSummaryDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
Guid? PosterImageId,
|
||||
int ItemCount,
|
||||
int UnitCount,
|
||||
DateTimeOffset CreatedAt
|
||||
);
|
||||
|
||||
/// <summary>Позиция коллекции с данными шоу — чтобы список правился без второго запроса.</summary>
|
||||
public sealed record CollectionItemDto(
|
||||
Guid ShowId,
|
||||
int Position,
|
||||
string ShowName,
|
||||
ShowKind ShowKind,
|
||||
ShowAudience ShowAudience,
|
||||
int EpisodeCount,
|
||||
int? Year,
|
||||
Guid? PosterImageId
|
||||
);
|
||||
|
||||
public sealed record CollectionDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
Guid? PosterImageId,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<CollectionItemDto> Items
|
||||
);
|
||||
|
||||
/// <summary>Коллекция, в которую входит шоу — для блока «входит в коллекции» на экране шоу.</summary>
|
||||
public sealed record ShowCollectionRefDto(Guid Id, string Name, int Position);
|
||||
@@ -0,0 +1,21 @@
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections;
|
||||
|
||||
public static class CollectionErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound(
|
||||
"Collections.NotFound",
|
||||
"Коллекция не найдена."
|
||||
);
|
||||
|
||||
public static readonly Error ShowAlreadyAdded = Error.Conflict(
|
||||
"Collections.ShowAlreadyAdded",
|
||||
"Это шоу уже входит в коллекцию."
|
||||
);
|
||||
|
||||
public static readonly Error ShowNotInCollection = Error.NotFound(
|
||||
"Collections.ShowNotInCollection",
|
||||
"Шоу не входит в коллекцию."
|
||||
);
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.CreateCollection;
|
||||
|
||||
public sealed record CreateCollectionCommand(string Name, string? Description = null)
|
||||
: ICommand<Result<Guid>>;
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.CreateCollection;
|
||||
|
||||
public sealed class CreateCollectionCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<CreateCollectionCommand, Result<Guid>>
|
||||
{
|
||||
public Task<Result<Guid>> Handle(
|
||||
CreateCollectionCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var collection = Collection.Create(command.Name, command.Description);
|
||||
dbContext.Collections.Add(collection);
|
||||
return Task.FromResult(Result.Success(collection.Id));
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.CreateCollection;
|
||||
|
||||
public sealed class CreateCollectionCommandValidator : AbstractValidator<CreateCollectionCommand>
|
||||
{
|
||||
public CreateCollectionCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.Description).MaximumLength(2048);
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.DeleteCollection;
|
||||
|
||||
public sealed record DeleteCollectionCommand(Guid CollectionId) : ICommand<Result>;
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.DeleteCollection;
|
||||
|
||||
public sealed class DeleteCollectionCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
GroupMembershipCleaner groupCleaner
|
||||
) : ICommandHandler<DeleteCollectionCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
DeleteCollectionCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var collection = await dbContext.Collections.FirstOrDefaultAsync(
|
||||
c => c.Id == command.CollectionId,
|
||||
cancellationToken
|
||||
);
|
||||
if (collection is null)
|
||||
return Result.Failure(CollectionErrors.NotFound);
|
||||
|
||||
// Ссылка группы полиморфна — каскад БД её не снимет.
|
||||
await groupCleaner.RemoveElementAsync(
|
||||
GroupElementKind.Collection,
|
||||
command.CollectionId,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
dbContext.Collections.Remove(collection);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.GetCollection;
|
||||
|
||||
public sealed record GetCollectionQuery(Guid Id) : IQuery<Result<CollectionDto>>;
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.GetCollection;
|
||||
|
||||
public sealed class GetCollectionQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<GetCollectionQuery, Result<CollectionDto>>
|
||||
{
|
||||
public async Task<Result<CollectionDto>> Handle(
|
||||
GetCollectionQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var collection = await dbContext
|
||||
.Collections.AsNoTracking()
|
||||
.Include(c => c.Items)
|
||||
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
|
||||
if (collection is null)
|
||||
return Result.Failure<CollectionDto>(CollectionErrors.NotFound);
|
||||
|
||||
var showIds = collection.Items.Select(i => i.ShowId).ToList();
|
||||
var shows = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.Kind,
|
||||
s.Audience,
|
||||
s.Year,
|
||||
s.PosterImageId,
|
||||
EpisodeCount = s.Episodes.Count,
|
||||
})
|
||||
.ToDictionaryAsync(s => s.Id, cancellationToken);
|
||||
|
||||
var items = collection
|
||||
.Items.OrderBy(i => i.Position)
|
||||
.Select(i =>
|
||||
{
|
||||
shows.TryGetValue(i.ShowId, out var show);
|
||||
return new CollectionItemDto(
|
||||
i.ShowId,
|
||||
i.Position,
|
||||
show?.Name ?? "—",
|
||||
show?.Kind ?? default,
|
||||
show?.Audience ?? default,
|
||||
show?.EpisodeCount ?? 0,
|
||||
show?.Year,
|
||||
show?.PosterImageId
|
||||
);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Result.Success(
|
||||
new CollectionDto(
|
||||
collection.Id,
|
||||
collection.Name,
|
||||
collection.Description,
|
||||
collection.PosterImageId,
|
||||
collection.CreatedAt,
|
||||
items
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
using LiteCqrs;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.ListCollections;
|
||||
|
||||
public sealed record ListCollectionsQuery : IQuery<IReadOnlyList<CollectionSummaryDto>>;
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.ListCollections;
|
||||
|
||||
public sealed class ListCollectionsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListCollectionsQuery, IReadOnlyList<CollectionSummaryDto>>
|
||||
{
|
||||
public async Task<IReadOnlyList<CollectionSummaryDto>> Handle(
|
||||
ListCollectionsQuery 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 episodeCounts = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.Select(s => new { s.Id, Count = s.Episodes.Count })
|
||||
.ToDictionaryAsync(x => x.Id, x => x.Count, cancellationToken);
|
||||
|
||||
return collections
|
||||
.Select(c => new CollectionSummaryDto(
|
||||
c.Id,
|
||||
c.Name,
|
||||
c.Description,
|
||||
c.PosterImageId,
|
||||
c.Items.Count,
|
||||
c.Items.Sum(i => episodeCounts.TryGetValue(i.ShowId, out var n) ? n : 0),
|
||||
c.CreatedAt
|
||||
))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.RemoveCollectionShow;
|
||||
|
||||
public sealed record RemoveCollectionShowCommand(Guid CollectionId, Guid ShowId) : ICommand<Result>;
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.RemoveCollectionShow;
|
||||
|
||||
public sealed class RemoveCollectionShowCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<RemoveCollectionShowCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
RemoveCollectionShowCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var collection = await dbContext
|
||||
.Collections.Include(c => c.Items)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.CollectionId, cancellationToken);
|
||||
if (collection is null)
|
||||
return Result.Failure(CollectionErrors.NotFound);
|
||||
|
||||
return collection.RemoveShow(command.ShowId)
|
||||
? Result.Success()
|
||||
: Result.Failure(CollectionErrors.ShowNotInCollection);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.ReorderCollection;
|
||||
|
||||
/// <summary>Переставляет части коллекции в порядке <paramref name="ShowIdsInOrder"/>. Не упомянутые
|
||||
/// остаются после них, сохраняя относительный порядок.</summary>
|
||||
public sealed record ReorderCollectionCommand(Guid CollectionId, IReadOnlyList<Guid> ShowIdsInOrder)
|
||||
: ICommand<Result>;
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.ReorderCollection;
|
||||
|
||||
public sealed class ReorderCollectionCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<ReorderCollectionCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
ReorderCollectionCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var collection = await dbContext
|
||||
.Collections.Include(c => c.Items)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.CollectionId, cancellationToken);
|
||||
if (collection is null)
|
||||
return Result.Failure(CollectionErrors.NotFound);
|
||||
|
||||
collection.Reorder(command.ShowIdsInOrder);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.SetCollectionPoster;
|
||||
|
||||
/// <summary>Привязать/снять постер коллекции (<paramref name="ImageId"/> = null — отвязать).</summary>
|
||||
public sealed record SetCollectionPosterCommand(Guid CollectionId, Guid? ImageId) : ICommand<Result>;
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Images;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.SetCollectionPoster;
|
||||
|
||||
public sealed class SetCollectionPosterCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<SetCollectionPosterCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
SetCollectionPosterCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var collection = await dbContext.Collections.FirstOrDefaultAsync(
|
||||
c => c.Id == command.CollectionId,
|
||||
cancellationToken
|
||||
);
|
||||
if (collection is null)
|
||||
return Result.Failure(CollectionErrors.NotFound);
|
||||
|
||||
if (
|
||||
command.ImageId is { } imageId
|
||||
&& !await dbContext.Images.AnyAsync(i => i.Id == imageId, cancellationToken)
|
||||
)
|
||||
return Result.Failure(ImageErrors.NotFound);
|
||||
|
||||
collection.SetPosterImage(command.ImageId);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.UpdateCollection;
|
||||
|
||||
public sealed record UpdateCollectionCommand(Guid CollectionId, string Name, string? Description)
|
||||
: ICommand<Result>;
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.UpdateCollection;
|
||||
|
||||
public sealed class UpdateCollectionCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateCollectionCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateCollectionCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var collection = await dbContext.Collections.FirstOrDefaultAsync(
|
||||
c => c.Id == command.CollectionId,
|
||||
cancellationToken
|
||||
);
|
||||
if (collection is null)
|
||||
return Result.Failure(CollectionErrors.NotFound);
|
||||
|
||||
collection.Rename(command.Name, command.Description);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.UpdateCollection;
|
||||
|
||||
public sealed class UpdateCollectionCommandValidator : AbstractValidator<UpdateCollectionCommand>
|
||||
{
|
||||
public UpdateCollectionCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.Description).MaximumLength(2048);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,15 @@ using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Library.DeleteShow;
|
||||
|
||||
public sealed class DeleteShowCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<DeleteShowCommand, Result>
|
||||
public sealed class DeleteShowCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
GroupMembershipCleaner groupCleaner
|
||||
) : ICommandHandler<DeleteShowCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(DeleteShowCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -17,6 +21,13 @@ public sealed class DeleteShowCommandHandler(IAppDbContext dbContext)
|
||||
if (show is null)
|
||||
return Result.Failure(ShowErrors.NotFound);
|
||||
|
||||
// Позиции коллекций уходят каскадом БД, позиции групп — вручную: ссылка группы полиморфна.
|
||||
await groupCleaner.RemoveElementAsync(
|
||||
GroupElementKind.Show,
|
||||
command.ShowId,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
// TODO(этап 2+): запретить удаление шоу, пока оно привязано к каналу или будущему расписанию.
|
||||
dbContext.Shows.Remove(show);
|
||||
return Result.Success();
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres.CreateGenre;
|
||||
|
||||
/// <summary><paramref name="Aliases"/> — варианты написания для сопоставления с метаданными
|
||||
/// провайдеров; само название и ключ добавляются автоматически.</summary>
|
||||
public sealed record CreateGenreCommand(
|
||||
string Name,
|
||||
string Slug,
|
||||
IReadOnlyList<string>? Aliases = null
|
||||
) : ICommand<Result<Guid>>;
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres.CreateGenre;
|
||||
|
||||
public sealed class CreateGenreCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<CreateGenreCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
CreateGenreCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var slug = GenreAlias.Normalize(command.Slug);
|
||||
if (await dbContext.Genres.AnyAsync(g => g.Slug == slug, cancellationToken))
|
||||
return Result.Failure<Guid>(GenreErrors.SlugTaken);
|
||||
|
||||
var nextSortOrder = await dbContext.Genres.AnyAsync(cancellationToken)
|
||||
? await dbContext.Genres.MaxAsync(g => g.SortOrder, cancellationToken) + 1
|
||||
: 0;
|
||||
|
||||
var genre = Genre.Create(command.Name, slug, nextSortOrder);
|
||||
foreach (var alias in GenreAliasInput.Collect(command.Name, slug, command.Aliases))
|
||||
genre.AddAlias(alias);
|
||||
|
||||
var values = genre.Aliases.Select(a => a.Value).ToList();
|
||||
if (await dbContext.GenreAliases.AnyAsync(a => values.Contains(a.Value), cancellationToken))
|
||||
return Result.Failure<Guid>(GenreErrors.AliasTaken);
|
||||
|
||||
dbContext.Genres.Add(genre);
|
||||
return Result.Success(genre.Id);
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres.CreateGenre;
|
||||
|
||||
public sealed class CreateGenreCommandValidator : AbstractValidator<CreateGenreCommand>
|
||||
{
|
||||
public CreateGenreCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
||||
RuleFor(x => x.Slug).NotEmpty().MaximumLength(64);
|
||||
RuleForEach(x => x.Aliases).NotEmpty().MaximumLength(128);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres.DeleteGenre;
|
||||
|
||||
public sealed record DeleteGenreCommand(Guid GenreId) : ICommand<Result>;
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres.DeleteGenre;
|
||||
|
||||
public sealed class DeleteGenreCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<DeleteGenreCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
DeleteGenreCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var genre = await dbContext.Genres.FirstOrDefaultAsync(
|
||||
g => g.Id == command.GenreId,
|
||||
cancellationToken
|
||||
);
|
||||
if (genre is null)
|
||||
return Result.Failure(GenreErrors.NotFound);
|
||||
|
||||
if (genre.IsSystem)
|
||||
return Result.Failure(GenreErrors.SystemCannotBeDeleted);
|
||||
|
||||
// Связь ShowGenre→Genre настроена как Restrict: без этой проверки удаление упало бы
|
||||
// исключением БД вместо управляемой ошибки.
|
||||
if (await dbContext.ShowGenres.AnyAsync(sg => sg.GenreId == genre.Id, cancellationToken))
|
||||
return Result.Failure(GenreErrors.InUse);
|
||||
|
||||
dbContext.Genres.Remove(genre);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres;
|
||||
|
||||
/// <summary>Сборка набора вариантов написания из пользовательского ввода: к явно заданным всегда
|
||||
/// добавляются само название и ключ жанра — по ним провайдеры отдают жанр чаще всего.</summary>
|
||||
public static class GenreAliasInput
|
||||
{
|
||||
public static IReadOnlyList<string> Collect(
|
||||
string name,
|
||||
string slug,
|
||||
IReadOnlyList<string>? aliases
|
||||
) =>
|
||||
(aliases ?? [])
|
||||
.Append(name)
|
||||
.Append(slug)
|
||||
.Select(GenreAlias.Normalize)
|
||||
.Where(value => value.Length > 0)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TeleWave.Application.Library.Genres;
|
||||
|
||||
/// <summary>Жанр справочника для админки. <paramref name="ShowCount"/> — сколько шоу его используют
|
||||
/// (нужен, чтобы админ видел, что удаление заблокировано, ещё до попытки удалить).</summary>
|
||||
public sealed record GenreDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Slug,
|
||||
int SortOrder,
|
||||
bool IsSystem,
|
||||
IReadOnlyList<string> Aliases,
|
||||
int ShowCount
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres;
|
||||
|
||||
public static class GenreErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound("Genres.NotFound", "Жанр не найден.");
|
||||
|
||||
public static readonly Error SlugTaken = Error.Conflict(
|
||||
"Genres.SlugTaken",
|
||||
"Жанр с таким ключом уже существует."
|
||||
);
|
||||
|
||||
public static readonly Error AliasTaken = Error.Conflict(
|
||||
"Genres.AliasTaken",
|
||||
"Один из вариантов написания уже закреплён за другим жанром."
|
||||
);
|
||||
|
||||
public static readonly Error InUse = Error.Conflict(
|
||||
"Genres.InUse",
|
||||
"Жанр проставлен у шоу — сначала снимите его."
|
||||
);
|
||||
|
||||
public static readonly Error SystemCannotBeDeleted = Error.Conflict(
|
||||
"Genres.SystemCannotBeDeleted",
|
||||
"Системный жанр нельзя удалить."
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres;
|
||||
|
||||
/// <summary>
|
||||
/// Сопоставляет сырые обозначения жанров от внешних источников со справочником. Порядок исходного
|
||||
/// списка сохраняется: первый распознанный жанр становится основным у шоу, а провайдеры отдают
|
||||
/// жанры по убыванию значимости.
|
||||
///
|
||||
/// Нераспознанные обозначения молча отбрасываются: у шоу останутся те жанры, которые справочник
|
||||
/// знает, а расширить справочник — задача администратора (см. псевдонимы жанра).
|
||||
/// </summary>
|
||||
public sealed class GenreMatcher(IAppDbContext dbContext)
|
||||
{
|
||||
public async Task<IReadOnlyList<Guid>> MatchAsync(
|
||||
IEnumerable<string>? rawGenres,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var normalized = (rawGenres ?? [])
|
||||
.Select(GenreAlias.Normalize)
|
||||
.Where(value => value.Length > 0)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count == 0)
|
||||
return [];
|
||||
|
||||
var byValue = await dbContext
|
||||
.GenreAliases.AsNoTracking()
|
||||
.Where(a => normalized.Contains(a.Value))
|
||||
.ToDictionaryAsync(a => a.Value, a => a.GenreId, cancellationToken);
|
||||
|
||||
var result = new List<Guid>();
|
||||
foreach (var value in normalized)
|
||||
{
|
||||
// Один жанр приходит несколькими обозначениями (идентификатор + название) — берём первое
|
||||
// распознанное, дубликаты пропускаем.
|
||||
if (byValue.TryGetValue(value, out var genreId) && !result.Contains(genreId))
|
||||
result.Add(genreId);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using LiteCqrs;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres.ListGenres;
|
||||
|
||||
public sealed record ListGenresQuery : IQuery<IReadOnlyList<GenreDto>>;
|
||||
@@ -0,0 +1,40 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres.ListGenres;
|
||||
|
||||
public sealed class ListGenresQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListGenresQuery, IReadOnlyList<GenreDto>>
|
||||
{
|
||||
public async Task<IReadOnlyList<GenreDto>> Handle(
|
||||
ListGenresQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var genres = await dbContext
|
||||
.Genres.AsNoTracking()
|
||||
.Include(g => g.Aliases)
|
||||
.OrderBy(g => g.SortOrder)
|
||||
.ThenBy(g => g.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var usage = await dbContext
|
||||
.ShowGenres.AsNoTracking()
|
||||
.GroupBy(sg => sg.GenreId)
|
||||
.Select(g => new { GenreId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.GenreId, x => x.Count, cancellationToken);
|
||||
|
||||
return genres
|
||||
.Select(g => new GenreDto(
|
||||
g.Id,
|
||||
g.Name,
|
||||
g.Slug,
|
||||
g.SortOrder,
|
||||
g.IsSystem,
|
||||
g.Aliases.Select(a => a.Value).OrderBy(v => v, StringComparer.Ordinal).ToList(),
|
||||
usage.TryGetValue(g.Id, out var count) ? count : 0
|
||||
))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres.UpdateGenre;
|
||||
|
||||
public sealed record UpdateGenreCommand(
|
||||
Guid GenreId,
|
||||
string Name,
|
||||
int SortOrder,
|
||||
IReadOnlyList<string>? Aliases = null
|
||||
) : ICommand<Result>;
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres.UpdateGenre;
|
||||
|
||||
public sealed class UpdateGenreCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateGenreCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateGenreCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var genre = await dbContext
|
||||
.Genres.Include(g => g.Aliases)
|
||||
.FirstOrDefaultAsync(g => g.Id == command.GenreId, cancellationToken);
|
||||
if (genre is null)
|
||||
return Result.Failure(GenreErrors.NotFound);
|
||||
|
||||
var aliases = GenreAliasInput.Collect(command.Name, genre.Slug, command.Aliases);
|
||||
|
||||
// Псевдоним уникален по всему справочнику — проверяем, не занят ли он другим жанром.
|
||||
var taken = await dbContext
|
||||
.GenreAliases.Where(a => aliases.Contains(a.Value) && a.GenreId != genre.Id)
|
||||
.AnyAsync(cancellationToken);
|
||||
if (taken)
|
||||
return Result.Failure(GenreErrors.AliasTaken);
|
||||
|
||||
genre.Rename(command.Name);
|
||||
genre.SetSortOrder(command.SortOrder);
|
||||
genre.ReplaceAliases(aliases);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres.UpdateGenre;
|
||||
|
||||
public sealed class UpdateGenreCommandValidator : AbstractValidator<UpdateGenreCommand>
|
||||
{
|
||||
public UpdateGenreCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
||||
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
|
||||
RuleForEach(x => x.Aliases).NotEmpty().MaximumLength(128);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Library.Collections;
|
||||
|
||||
namespace TeleWave.Application.Library.GetShow;
|
||||
|
||||
@@ -16,10 +17,28 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
|
||||
var show = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Include(s => s.Episodes)
|
||||
.Include(s => s.Genres)
|
||||
.FirstOrDefaultAsync(s => s.Id == query.Id, cancellationToken);
|
||||
if (show is null)
|
||||
return Result.Failure<ShowDto>(ShowErrors.NotFound);
|
||||
|
||||
var genreIds = show.Genres.Select(g => g.GenreId).ToList();
|
||||
var genreNames = await dbContext
|
||||
.Genres.AsNoTracking()
|
||||
.Where(g => genreIds.Contains(g.Id))
|
||||
.Select(g => new { g.Id, g.Name, g.SortOrder })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var genreDtos = genreNames
|
||||
.OrderByDescending(g => show.Genres.First(sg => sg.GenreId == g.Id).IsPrimary)
|
||||
.ThenBy(g => g.SortOrder)
|
||||
.Select(g => new ShowGenreDto(
|
||||
g.Id,
|
||||
g.Name,
|
||||
show.Genres.First(sg => sg.GenreId == g.Id).IsPrimary
|
||||
))
|
||||
.ToList();
|
||||
|
||||
var episodes = show.Episodes.OrderBy(e => e.Position).ToList();
|
||||
var assetIds = episodes.Select(e => e.MediaAssetId).ToList();
|
||||
var assets = await dbContext
|
||||
@@ -55,6 +74,19 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var collections = await dbContext
|
||||
.CollectionItems.AsNoTracking()
|
||||
.Where(i => i.ShowId == show.Id)
|
||||
.Join(
|
||||
dbContext.Collections.AsNoTracking(),
|
||||
item => item.CollectionId,
|
||||
collection => collection.Id,
|
||||
(item, collection) =>
|
||||
new ShowCollectionRefDto(collection.Id, collection.Name, item.Position)
|
||||
)
|
||||
.OrderBy(c => c.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Success(
|
||||
new ShowDto(
|
||||
show.Id,
|
||||
@@ -67,7 +99,9 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
|
||||
show.MetadataExternalId,
|
||||
show.Year,
|
||||
show.PosterImageId,
|
||||
episodeDtos
|
||||
episodeDtos,
|
||||
genreDtos,
|
||||
collections
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,4 +2,10 @@ using LiteCqrs;
|
||||
|
||||
namespace TeleWave.Application.Library.ListShows;
|
||||
|
||||
public sealed record ListShowsQuery : IQuery<IReadOnlyList<ShowSummaryDto>>;
|
||||
/// <summary>
|
||||
/// <paramref name="GenreId"/> — оставить только шоу с этим жанром (основным или нет).
|
||||
/// <paramref name="Interstitials"/> — вернуть ролики-врезки вместо контента: у них своя страница,
|
||||
/// и в общей библиотеке они только мешали бы.
|
||||
/// </summary>
|
||||
public sealed record ListShowsQuery(Guid? GenreId = null, bool Interstitials = false)
|
||||
: IQuery<IReadOnlyList<ShowSummaryDto>>;
|
||||
|
||||
@@ -1,59 +1,85 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Application.Library.ListShows;
|
||||
|
||||
public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListShowsQuery, IReadOnlyList<ShowSummaryDto>>
|
||||
{
|
||||
public async Task<IReadOnlyList<ShowSummaryDto>> Handle(
|
||||
ListShowsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var shows = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Include(s => s.Episodes)
|
||||
.OrderBy(s => s.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Имена ассетов нужны, чтобы распознать сезоны (номера в модели не хранятся).
|
||||
var assetIds = shows
|
||||
.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var names = await dbContext
|
||||
.MediaAssets.AsNoTracking()
|
||||
.Where(a => assetIds.Contains(a.Id))
|
||||
.Select(a => new { a.Id, a.OriginalFileName })
|
||||
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
|
||||
|
||||
return shows
|
||||
.Select(s =>
|
||||
{
|
||||
var seasons = s
|
||||
.Episodes.Select(e =>
|
||||
names.TryGetValue(e.MediaAssetId, out var n)
|
||||
? EpisodeName.ParseSeason(n)
|
||||
: null
|
||||
)
|
||||
.Where(season => season is not null)
|
||||
.Distinct()
|
||||
.Count();
|
||||
return new ShowSummaryDto(
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.OriginalName,
|
||||
s.Kind,
|
||||
s.Audience,
|
||||
s.Episodes.Count,
|
||||
seasons,
|
||||
s.Year,
|
||||
s.PosterImageId is not null,
|
||||
s.CreatedAt
|
||||
);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Library.ListShows;
|
||||
|
||||
public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListShowsQuery, IReadOnlyList<ShowSummaryDto>>
|
||||
{
|
||||
public async Task<IReadOnlyList<ShowSummaryDto>> Handle(
|
||||
ListShowsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var source = dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Include(s => s.Episodes)
|
||||
.Include(s => s.Genres)
|
||||
.Where(s =>
|
||||
query.Interstitials
|
||||
? s.Kind == ShowKind.Interstitial
|
||||
: s.Kind != ShowKind.Interstitial
|
||||
);
|
||||
|
||||
var filtered = query.GenreId is { } genreId
|
||||
? source.Where(s => s.Genres.Any(g => g.GenreId == genreId))
|
||||
: source;
|
||||
|
||||
var shows = await filtered.OrderBy(s => s.Name).ToListAsync(cancellationToken);
|
||||
|
||||
// Названия только для основных жанров — в списке показывается один.
|
||||
var primaryIds = shows
|
||||
.Select(s => s.PrimaryGenreId)
|
||||
.Where(id => id is not null)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var genreNames = await dbContext
|
||||
.Genres.AsNoTracking()
|
||||
.Where(g => primaryIds.Contains(g.Id))
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
|
||||
|
||||
// Имена ассетов нужны, чтобы распознать сезоны (номера в модели не хранятся).
|
||||
var assetIds = shows
|
||||
.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var names = await dbContext
|
||||
.MediaAssets.AsNoTracking()
|
||||
.Where(a => assetIds.Contains(a.Id))
|
||||
.Select(a => new { a.Id, a.OriginalFileName })
|
||||
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
|
||||
|
||||
return shows
|
||||
.Select(s =>
|
||||
{
|
||||
var seasons = s
|
||||
.Episodes.Select(e =>
|
||||
names.TryGetValue(e.MediaAssetId, out var n)
|
||||
? EpisodeName.ParseSeason(n)
|
||||
: null
|
||||
)
|
||||
.Where(season => season is not null)
|
||||
.Distinct()
|
||||
.Count();
|
||||
return new ShowSummaryDto(
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.OriginalName,
|
||||
s.Kind,
|
||||
s.Audience,
|
||||
s.Episodes.Count,
|
||||
seasons,
|
||||
s.Year,
|
||||
s.PosterImageId is not null,
|
||||
s.CreatedAt,
|
||||
s.PrimaryGenreId is { } primaryId && genreNames.TryGetValue(primaryId, out var g)
|
||||
? g
|
||||
: null
|
||||
);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.SetShowGenres;
|
||||
|
||||
/// <summary>Полностью заменяет набор жанров шоу. <paramref name="PrimaryGenreId"/> — какой считать
|
||||
/// основным; если он не входит в набор, основным станет первый из списка.</summary>
|
||||
public sealed record SetShowGenresCommand(
|
||||
Guid ShowId,
|
||||
IReadOnlyList<Guid> GenreIds,
|
||||
Guid? PrimaryGenreId = null
|
||||
) : ICommand<Result>;
|
||||
@@ -0,0 +1,37 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Library.Genres;
|
||||
|
||||
namespace TeleWave.Application.Library.SetShowGenres;
|
||||
|
||||
public sealed class SetShowGenresCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<SetShowGenresCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
SetShowGenresCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var show = await dbContext
|
||||
.Shows.Include(s => s.Genres)
|
||||
.FirstOrDefaultAsync(s => s.Id == command.ShowId, cancellationToken);
|
||||
if (show is null)
|
||||
return Result.Failure(ShowErrors.NotFound);
|
||||
|
||||
var ids = command.GenreIds.Distinct().ToList();
|
||||
if (ids.Count > 0)
|
||||
{
|
||||
// Ссылка на несуществующий жанр упала бы нарушением внешнего ключа — проверяем заранее.
|
||||
var known = await dbContext
|
||||
.Genres.Where(g => ids.Contains(g.Id))
|
||||
.CountAsync(cancellationToken);
|
||||
if (known != ids.Count)
|
||||
return Result.Failure(GenreErrors.NotFound);
|
||||
}
|
||||
|
||||
show.SetGenres(ids, command.PrimaryGenreId);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using TeleWave.Application.Library.Collections;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
@@ -13,9 +14,14 @@ public sealed record ShowSummaryDto(
|
||||
int SeasonCount,
|
||||
int? Year,
|
||||
bool HasPoster,
|
||||
DateTimeOffset CreatedAt
|
||||
DateTimeOffset CreatedAt,
|
||||
/// <summary>Название основного жанра — в списке показываем только его, остальные видны в карточке шоу.</summary>
|
||||
string? PrimaryGenre = null
|
||||
);
|
||||
|
||||
/// <summary>Жанр, проставленный шоу.</summary>
|
||||
public sealed record ShowGenreDto(Guid Id, string Name, bool IsPrimary);
|
||||
|
||||
public sealed record EpisodeDto(
|
||||
Guid Id,
|
||||
Guid MediaAssetId,
|
||||
@@ -42,5 +48,8 @@ public sealed record ShowDto(
|
||||
string? MetadataExternalId,
|
||||
int? Year,
|
||||
Guid? PosterImageId,
|
||||
IReadOnlyList<EpisodeDto> Episodes
|
||||
IReadOnlyList<EpisodeDto> Episodes,
|
||||
IReadOnlyList<ShowGenreDto> Genres,
|
||||
/// <summary>Коллекции (франшизы), в которые входит шоу, с его позицией в каждой.</summary>
|
||||
IReadOnlyList<ShowCollectionRefDto> Collections
|
||||
);
|
||||
|
||||
+13
-5
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Library;
|
||||
using TeleWave.Application.Library.Genres;
|
||||
using TeleWave.Domain.Images;
|
||||
|
||||
namespace TeleWave.Application.Metadata.ApplyShowMetadata;
|
||||
@@ -11,7 +12,8 @@ public sealed class ApplyShowMetadataCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IMetadataProviderResolver resolver,
|
||||
IImageDownloader downloader,
|
||||
IImageStore imageStore
|
||||
IImageStore imageStore,
|
||||
GenreMatcher genreMatcher
|
||||
) : ICommandHandler<ApplyShowMetadataCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
@@ -19,10 +21,9 @@ public sealed class ApplyShowMetadataCommandHandler(
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var show = await dbContext.Shows.FirstOrDefaultAsync(
|
||||
s => s.Id == command.ShowId,
|
||||
cancellationToken
|
||||
);
|
||||
var show = await dbContext
|
||||
.Shows.Include(s => s.Genres)
|
||||
.FirstOrDefaultAsync(s => s.Id == command.ShowId, cancellationToken);
|
||||
if (show is null)
|
||||
return Result.Failure(ShowErrors.NotFound);
|
||||
|
||||
@@ -60,6 +61,13 @@ public sealed class ApplyShowMetadataCommandHandler(
|
||||
meta.Year,
|
||||
posterImageId
|
||||
);
|
||||
|
||||
// Жанры источника перезаписывают текущие, только если хоть один распознан справочником:
|
||||
// иначе применение метаданных к шоу с руками проставленными жанрами обнуляло бы их.
|
||||
var genreIds = await genreMatcher.MatchAsync(meta.Genres, cancellationToken);
|
||||
if (genreIds.Count > 0)
|
||||
show.SetGenres(genreIds);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,19 @@ public sealed record MetadataCandidate(
|
||||
string? PosterUrl
|
||||
);
|
||||
|
||||
/// <summary>Метаданные шоу из источника (PosterUrl — полный URL, скачивается локально при применении).</summary>
|
||||
/// <summary>
|
||||
/// Метаданные шоу из источника (PosterUrl — полный URL, скачивается локально при применении).
|
||||
/// <paramref name="Genres"/> — сырые обозначения жанров в том порядке, в каком их отдал источник:
|
||||
/// идентификаторы (<c>tmdb:28</c>) и названия вперемешку. Сопоставление со справочником —
|
||||
/// на стороне приложения (см. GenreMatcher); первый распознанный становится основным жанром шоу.
|
||||
/// </summary>
|
||||
public sealed record ShowMetadata(
|
||||
string ExternalId,
|
||||
string Title,
|
||||
int? Year,
|
||||
string? Overview,
|
||||
string? PosterUrl
|
||||
string? PosterUrl,
|
||||
IReadOnlyList<string>? Genres = null
|
||||
);
|
||||
|
||||
/// <summary>Метаданные серии (для этапа 2).</summary>
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.AddGroupElements;
|
||||
|
||||
public sealed record GroupElementRef(GroupElementKind ElementKind, Guid ElementId);
|
||||
|
||||
/// <summary>
|
||||
/// Добавляет элементы в конец группы. Массовая — так же добавляется и найденное правилом набора.
|
||||
/// Уже входящие в группу молча пропускаются: при добавлении полусотни позиций падать из-за одного
|
||||
/// повтора бессмысленно. Возвращает число реально добавленных.
|
||||
/// </summary>
|
||||
public sealed record AddGroupElementsCommand(Guid GroupId, IReadOnlyList<GroupElementRef> Elements)
|
||||
: ICommand<Result<int>>;
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.AddGroupElements;
|
||||
|
||||
public sealed class AddGroupElementsCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
GroupStatsService stats
|
||||
) : ICommandHandler<AddGroupElementsCommand, Result<int>>
|
||||
{
|
||||
public async Task<Result<int>> Handle(
|
||||
AddGroupElementsCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var group = await dbContext
|
||||
.Groups.Include(g => g.Items)
|
||||
.FirstOrDefaultAsync(g => g.Id == command.GroupId, cancellationToken);
|
||||
if (group is null)
|
||||
return Result.Failure<int>(GroupErrors.NotFound);
|
||||
|
||||
var requested = command.Elements.Distinct().ToList();
|
||||
|
||||
// Ссылки полиморфные, внешнего ключа нет — проверяем существование сами, иначе в группе
|
||||
// осядут позиции, которые никогда не развернутся в контент.
|
||||
var showIds = requested
|
||||
.Where(e => e.ElementKind == GroupElementKind.Show)
|
||||
.Select(e => e.ElementId)
|
||||
.ToList();
|
||||
var collectionIds = requested
|
||||
.Where(e => e.ElementKind == GroupElementKind.Collection)
|
||||
.Select(e => e.ElementId)
|
||||
.ToList();
|
||||
|
||||
var knownShows = await dbContext
|
||||
.Shows.Where(s => showIds.Contains(s.Id))
|
||||
.Select(s => s.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var knownCollections = await dbContext
|
||||
.Collections.Where(c => collectionIds.Contains(c.Id))
|
||||
.Select(c => c.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (knownShows.Count != showIds.Count || knownCollections.Count != collectionIds.Count)
|
||||
return Result.Failure<int>(GroupErrors.ElementNotFound);
|
||||
|
||||
var added = requested.Count(element =>
|
||||
group.AddElement(element.ElementKind, element.ElementId) is not null
|
||||
);
|
||||
|
||||
await stats.RecomputeAsync(group, cancellationToken);
|
||||
return Result.Success(added);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.CreateGroup;
|
||||
|
||||
public sealed record CreateGroupCommand(string Name, string? Description = null)
|
||||
: ICommand<Result<Guid>>;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.CreateGroup;
|
||||
|
||||
public sealed class CreateGroupCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<CreateGroupCommand, Result<Guid>>
|
||||
{
|
||||
public Task<Result<Guid>> Handle(CreateGroupCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var group = Group.Create(command.Name, command.Description);
|
||||
dbContext.Groups.Add(group);
|
||||
return Task.FromResult(Result.Success(group.Id));
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.CreateGroup;
|
||||
|
||||
public sealed class CreateGroupCommandValidator : AbstractValidator<CreateGroupCommand>
|
||||
{
|
||||
public CreateGroupCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.Description).MaximumLength(2048);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.DeleteGroup;
|
||||
|
||||
public sealed record DeleteGroupCommand(Guid GroupId) : ICommand<Result>;
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.DeleteGroup;
|
||||
|
||||
public sealed class DeleteGroupCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<DeleteGroupCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(DeleteGroupCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var group = await dbContext.Groups.FirstOrDefaultAsync(
|
||||
g => g.Id == command.GroupId,
|
||||
cancellationToken
|
||||
);
|
||||
if (group is null)
|
||||
return Result.Failure(GroupErrors.NotFound);
|
||||
|
||||
// TODO(срез 1C): запретить удаление, пока на группу ссылается слот сетки.
|
||||
dbContext.Groups.Remove(group);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.FindGroupCandidates;
|
||||
|
||||
/// <summary>
|
||||
/// Подбирает позиции по правилу набора. <paramref name="Filter"/> = null — берём правило, сохранённое
|
||||
/// у группы; передача фильтра явно нужна редактору, где его крутят до сохранения.
|
||||
/// </summary>
|
||||
public sealed record FindGroupCandidatesQuery(Guid GroupId, GroupFilter? Filter = null)
|
||||
: IQuery<Result<IReadOnlyList<GroupCandidateDto>>>;
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.FindGroupCandidates;
|
||||
|
||||
public sealed class FindGroupCandidatesQueryHandler(
|
||||
IAppDbContext dbContext,
|
||||
GroupElementResolver resolver
|
||||
) : IQueryHandler<FindGroupCandidatesQuery, Result<IReadOnlyList<GroupCandidateDto>>>
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<GroupCandidateDto>>> Handle(
|
||||
FindGroupCandidatesQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var group = await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Include(g => g.Items)
|
||||
.FirstOrDefaultAsync(g => g.Id == query.GroupId, cancellationToken);
|
||||
if (group is null)
|
||||
return Result.Failure<IReadOnlyList<GroupCandidateDto>>(GroupErrors.NotFound);
|
||||
|
||||
var filter = query.Filter ?? GroupFilter.FromJson(group.FilterJson);
|
||||
if (filter is null)
|
||||
return Result.Failure<IReadOnlyList<GroupCandidateDto>>(GroupErrors.FilterNotSet);
|
||||
|
||||
var wantsShows =
|
||||
filter.ElementKinds is null or { Count: 0 }
|
||||
|| filter.ElementKinds.Contains(GroupElementKind.Show);
|
||||
var wantsCollections =
|
||||
filter.ElementKinds is null or { Count: 0 }
|
||||
|| filter.ElementKinds.Contains(GroupElementKind.Collection);
|
||||
|
||||
var matchingShowIds = await MatchShowIdsAsync(filter, cancellationToken);
|
||||
|
||||
var elements = new List<(GroupElementKind Kind, Guid Id)>();
|
||||
if (wantsShows)
|
||||
elements.AddRange(matchingShowIds.Select(id => (GroupElementKind.Show, id)));
|
||||
|
||||
if (wantsCollections)
|
||||
{
|
||||
// Коллекция подходит, только если подходят все её части: иначе во «взрослую ночь» через
|
||||
// франшизу просочилось бы детское, а в «боевики» — комедия из той же серии фильмов.
|
||||
var collections = await dbContext
|
||||
.Collections.AsNoTracking()
|
||||
.Select(c => new { c.Id, ShowIds = c.Items.Select(i => i.ShowId).ToList() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var matching = matchingShowIds.ToHashSet();
|
||||
elements.AddRange(
|
||||
collections
|
||||
.Where(c => c.ShowIds.Count > 0 && c.ShowIds.All(matching.Contains))
|
||||
.Select(c => (GroupElementKind.Collection, c.Id))
|
||||
);
|
||||
}
|
||||
|
||||
var info = await resolver.ResolveAsync(elements, cancellationToken);
|
||||
var inGroup = group
|
||||
.Items.Select(i => (i.ElementKind, i.ElementId))
|
||||
.ToHashSet();
|
||||
|
||||
var result = new List<GroupCandidateDto>();
|
||||
foreach (var (kind, id) in elements)
|
||||
{
|
||||
if (!info.TryGetValue((kind, id), out var element))
|
||||
continue;
|
||||
if (!MatchesUnitDuration(filter, element))
|
||||
continue;
|
||||
|
||||
result.Add(
|
||||
new GroupCandidateDto(
|
||||
kind,
|
||||
id,
|
||||
element.Name,
|
||||
element.UnitCount,
|
||||
element.ShowKind,
|
||||
element.Audience,
|
||||
element.Year,
|
||||
element.PosterImageId,
|
||||
inGroup.Contains((kind, id))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return Result.Success<IReadOnlyList<GroupCandidateDto>>(
|
||||
result.OrderBy(c => c.ElementName, StringComparer.CurrentCultureIgnoreCase).ToList()
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<List<Guid>> MatchShowIdsAsync(
|
||||
GroupFilter filter,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var shows = dbContext.Shows.AsNoTracking();
|
||||
|
||||
if (filter.ShowKinds is { Count: > 0 } kinds)
|
||||
shows = shows.Where(s => kinds.Contains(s.Kind));
|
||||
|
||||
// Категории упорядочены по возрастанию строгости, поэтому «не строже» — обычное сравнение.
|
||||
if (filter.MaxAudience is { } maxAudience)
|
||||
shows = shows.Where(s => s.Audience <= maxAudience);
|
||||
|
||||
if (filter.YearMin is { } yearMin)
|
||||
shows = shows.Where(s => s.Year != null && s.Year >= yearMin);
|
||||
if (filter.YearMax is { } yearMax)
|
||||
shows = shows.Where(s => s.Year != null && s.Year <= yearMax);
|
||||
|
||||
// Жанры — «любой из»: шоу редко подходит под все перечисленные сразу.
|
||||
if (filter.GenreIds is { Count: > 0 } genreIds)
|
||||
shows = shows.Where(s => s.Genres.Any(g => genreIds.Contains(g.GenreId)));
|
||||
|
||||
return await shows.Select(s => s.Id).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Длительность проверяется по средней единице, а не по сумме: у сериала в фильтре «серии
|
||||
/// по 20–25 минут» осмысленна именно длина серии. Элементы без готовых ассетов длительности
|
||||
/// не имеют — их этот фильтр не отбрасывает, иначе из группы выпадало бы всё, что ещё
|
||||
/// обрабатывается.
|
||||
/// </summary>
|
||||
private static bool MatchesUnitDuration(GroupFilter filter, GroupElementInfo element)
|
||||
{
|
||||
if (filter.UnitMinutesMin is null && filter.UnitMinutesMax is null)
|
||||
return true;
|
||||
if (element.UnitCount == 0 || element.TotalDuration <= TimeSpan.Zero)
|
||||
return true;
|
||||
|
||||
var averageMinutes = element.TotalDuration.TotalMinutes / element.UnitCount;
|
||||
if (filter.UnitMinutesMin is { } min && averageMinutes < min)
|
||||
return false;
|
||||
if (filter.UnitMinutesMax is { } max && averageMinutes > max)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.GetGroup;
|
||||
|
||||
public sealed record GetGroupQuery(Guid Id) : IQuery<Result<GroupDto>>;
|
||||
@@ -0,0 +1,65 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.GetGroup;
|
||||
|
||||
public sealed class GetGroupQueryHandler(IAppDbContext dbContext, GroupElementResolver resolver)
|
||||
: IQueryHandler<GetGroupQuery, Result<GroupDto>>
|
||||
{
|
||||
public async Task<Result<GroupDto>> Handle(
|
||||
GetGroupQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var group = await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Include(g => g.Items)
|
||||
.FirstOrDefaultAsync(g => g.Id == query.Id, cancellationToken);
|
||||
if (group is null)
|
||||
return Result.Failure<GroupDto>(GroupErrors.NotFound);
|
||||
|
||||
var info = await resolver.ResolveAsync(
|
||||
group.Items.Select(i => (i.ElementKind, i.ElementId)),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var items = group
|
||||
.Items.OrderBy(i => i.Position)
|
||||
.Select(i =>
|
||||
{
|
||||
info.TryGetValue((i.ElementKind, i.ElementId), out var element);
|
||||
return new GroupItemDto(
|
||||
i.Id,
|
||||
i.ElementKind,
|
||||
i.ElementId,
|
||||
// Элемент мог исчезнуть из библиотеки между чисткой и чтением — показываем прочерк,
|
||||
// а не роняем весь экран группы.
|
||||
element?.Name ?? "—",
|
||||
i.Weight,
|
||||
i.Position,
|
||||
element?.UnitCount ?? 0,
|
||||
element?.ShowKind,
|
||||
element?.Audience,
|
||||
element?.Year,
|
||||
element?.PosterImageId
|
||||
);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Result.Success(
|
||||
new GroupDto(
|
||||
group.Id,
|
||||
group.Name,
|
||||
group.Description,
|
||||
GroupFilter.FromJson(group.FilterJson),
|
||||
group.ItemCount,
|
||||
group.UnitCount,
|
||||
group.TotalDuration.TotalSeconds,
|
||||
group.StatsComputedAt,
|
||||
items
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups;
|
||||
|
||||
/// <summary>Группа в списке — со сводкой, без состава.</summary>
|
||||
public sealed record GroupSummaryDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
int ItemCount,
|
||||
int UnitCount,
|
||||
double TotalDurationSeconds,
|
||||
bool HasFilter,
|
||||
DateTimeOffset? StatsComputedAt,
|
||||
DateTimeOffset CreatedAt
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Позиция группы с данными элемента. <paramref name="UnitCount"/> — сколько единиц воспроизведения
|
||||
/// она даёт: у фильма одна, у сериала и коллекции больше.
|
||||
/// </summary>
|
||||
public sealed record GroupItemDto(
|
||||
Guid Id,
|
||||
GroupElementKind ElementKind,
|
||||
Guid ElementId,
|
||||
string ElementName,
|
||||
int Weight,
|
||||
int Position,
|
||||
int UnitCount,
|
||||
ShowKind? ShowKind,
|
||||
ShowAudience? Audience,
|
||||
int? Year,
|
||||
Guid? PosterImageId
|
||||
);
|
||||
|
||||
public sealed record GroupDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
GroupFilter? Filter,
|
||||
int ItemCount,
|
||||
int UnitCount,
|
||||
double TotalDurationSeconds,
|
||||
DateTimeOffset? StatsComputedAt,
|
||||
IReadOnlyList<GroupItemDto> Items
|
||||
);
|
||||
|
||||
/// <summary>Кандидат, найденный правилом набора: ещё не в группе, но подходит под фильтр.</summary>
|
||||
public sealed record GroupCandidateDto(
|
||||
GroupElementKind ElementKind,
|
||||
Guid ElementId,
|
||||
string ElementName,
|
||||
int UnitCount,
|
||||
ShowKind? ShowKind,
|
||||
ShowAudience? Audience,
|
||||
int? Year,
|
||||
Guid? PosterImageId,
|
||||
bool AlreadyInGroup
|
||||
);
|
||||
@@ -0,0 +1,137 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups;
|
||||
|
||||
/// <summary>Сводка по элементу группы: как показать и сколько эфира он даёт.</summary>
|
||||
public sealed record GroupElementInfo(
|
||||
string Name,
|
||||
int UnitCount,
|
||||
TimeSpan TotalDuration,
|
||||
ShowKind? ShowKind,
|
||||
ShowAudience? Audience,
|
||||
int? Year,
|
||||
Guid? PosterImageId
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Разворачивает ссылки группы (шоу либо коллекция) в данные для отображения и статистики.
|
||||
/// Единица воспроизведения — серия: у фильма она одна, у сериала их столько же, сколько серий,
|
||||
/// у коллекции — сумма по всем её частям.
|
||||
///
|
||||
/// Длительность считается только по готовым ассетам: в группе может лежать шоу, чьё медиа ещё
|
||||
/// обрабатывается, и включать его в «118 часов» значило бы обещать эфир, которого пока нет.
|
||||
/// </summary>
|
||||
public sealed class GroupElementResolver(IAppDbContext dbContext)
|
||||
{
|
||||
public async Task<IReadOnlyDictionary<(GroupElementKind Kind, Guid Id), GroupElementInfo>> ResolveAsync(
|
||||
IEnumerable<(GroupElementKind Kind, Guid Id)> elements,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var requested = elements.Distinct().ToList();
|
||||
if (requested.Count == 0)
|
||||
return new Dictionary<(GroupElementKind, Guid), GroupElementInfo>();
|
||||
|
||||
var collectionIds = requested
|
||||
.Where(e => e.Kind == GroupElementKind.Collection)
|
||||
.Select(e => e.Id)
|
||||
.ToList();
|
||||
|
||||
var collections = await dbContext
|
||||
.Collections.AsNoTracking()
|
||||
.Where(c => collectionIds.Contains(c.Id))
|
||||
.Select(c => new
|
||||
{
|
||||
c.Id,
|
||||
c.Name,
|
||||
c.PosterImageId,
|
||||
ShowIds = c.Items.OrderBy(i => i.Position).Select(i => i.ShowId).ToList(),
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var showIds = requested
|
||||
.Where(e => e.Kind == GroupElementKind.Show)
|
||||
.Select(e => e.Id)
|
||||
.Concat(collections.SelectMany(c => c.ShowIds))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var shows = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.Kind,
|
||||
s.Audience,
|
||||
s.Year,
|
||||
s.PosterImageId,
|
||||
AssetIds = s.Episodes.Select(e => e.MediaAssetId).ToList(),
|
||||
})
|
||||
.ToDictionaryAsync(s => s.Id, cancellationToken);
|
||||
|
||||
var assetIds = shows.Values.SelectMany(s => s.AssetIds).Distinct().ToList();
|
||||
var durations = await dbContext
|
||||
.MediaAssets.AsNoTracking()
|
||||
.Where(a =>
|
||||
assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null
|
||||
)
|
||||
.Select(a => new { a.Id, a.Duration })
|
||||
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
|
||||
|
||||
TimeSpan DurationOf(IEnumerable<Guid> ids) =>
|
||||
ids.Aggregate(
|
||||
TimeSpan.Zero,
|
||||
(sum, id) => durations.TryGetValue(id, out var d) ? sum + d : sum
|
||||
);
|
||||
|
||||
var result = new Dictionary<(GroupElementKind, Guid), GroupElementInfo>();
|
||||
|
||||
foreach (var (kind, id) in requested)
|
||||
{
|
||||
if (kind == GroupElementKind.Show)
|
||||
{
|
||||
if (!shows.TryGetValue(id, out var show))
|
||||
continue;
|
||||
result[(kind, id)] = new GroupElementInfo(
|
||||
show.Name,
|
||||
show.AssetIds.Count,
|
||||
DurationOf(show.AssetIds),
|
||||
show.Kind,
|
||||
show.Audience,
|
||||
show.Year,
|
||||
show.PosterImageId
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
var collection = collections.FirstOrDefault(c => c.Id == id);
|
||||
if (collection is null)
|
||||
continue;
|
||||
|
||||
var parts = collection
|
||||
.ShowIds.Select(showId => shows.TryGetValue(showId, out var s) ? s : null)
|
||||
.Where(s => s is not null)
|
||||
.ToList();
|
||||
var partAssets = parts.SelectMany(p => p!.AssetIds).ToList();
|
||||
|
||||
result[(kind, id)] = new GroupElementInfo(
|
||||
collection.Name,
|
||||
partAssets.Count,
|
||||
DurationOf(partAssets),
|
||||
null,
|
||||
// Категория коллекции — самая строгая среди частей: по ней отбирают в детское время.
|
||||
parts.Count == 0 ? null : parts.Max(p => p!.Audience),
|
||||
parts.Count == 0 ? null : parts.Min(p => p!.Year),
|
||||
collection.PosterImageId
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups;
|
||||
|
||||
public static class GroupErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound("Groups.NotFound", "Группа не найдена.");
|
||||
|
||||
public static readonly Error ItemNotFound = Error.NotFound(
|
||||
"Groups.ItemNotFound",
|
||||
"Позиция в группе не найдена."
|
||||
);
|
||||
|
||||
public static readonly Error ElementNotFound = Error.NotFound(
|
||||
"Groups.ElementNotFound",
|
||||
"Шоу или коллекция не найдены."
|
||||
);
|
||||
|
||||
public static readonly Error ElementAlreadyAdded = Error.Conflict(
|
||||
"Groups.ElementAlreadyAdded",
|
||||
"Этот элемент уже входит в группу."
|
||||
);
|
||||
|
||||
public static readonly Error FilterNotSet = Error.Validation(
|
||||
"Groups.FilterNotSet",
|
||||
"У группы не задано правило набора."
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups;
|
||||
|
||||
/// <summary>
|
||||
/// Правило быстрого набора состава группы. Это не запрос, выполняемый при генерации: фильтр находит
|
||||
/// подходящие позиции, а добавляет их в группу отдельная команда. Пустые поля не ограничивают.
|
||||
/// </summary>
|
||||
public sealed record GroupFilter(
|
||||
IReadOnlyList<GroupElementKind>? ElementKinds = null,
|
||||
IReadOnlyList<ShowKind>? ShowKinds = null,
|
||||
IReadOnlyList<Guid>? GenreIds = null,
|
||||
ShowAudience? MaxAudience = null,
|
||||
int? YearMin = null,
|
||||
int? YearMax = null,
|
||||
int? UnitMinutesMin = null,
|
||||
int? UnitMinutesMax = null
|
||||
)
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||
|
||||
/// <summary>Разбирает сохранённое правило. Повреждённый JSON трактуется как отсутствие фильтра:
|
||||
/// группа продолжает работать своим явным составом, а админ переустановит правило.</summary>
|
||||
public static GroupFilter? FromJson(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<GroupFilter>(json, Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups;
|
||||
|
||||
/// <summary>
|
||||
/// Снимает элемент со всех групп при его удалении из библиотеки. Нужен потому, что ссылка группы
|
||||
/// полиморфна (шоу либо коллекция) и внешнего ключа под неё нет — каскад БД тут не сработает,
|
||||
/// а висячая позиция при генерации развернулась бы в пустоту.
|
||||
///
|
||||
/// Затронутым группам пересчитывается статистика: иначе в инспекторе слота осталось бы обещание
|
||||
/// эфира, которого больше нет.
|
||||
/// </summary>
|
||||
public sealed class GroupMembershipCleaner(IAppDbContext dbContext, GroupStatsService stats)
|
||||
{
|
||||
public async Task RemoveElementAsync(
|
||||
GroupElementKind kind,
|
||||
Guid elementId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var affectedGroupIds = await dbContext
|
||||
.GroupItems.Where(i => i.ElementKind == kind && i.ElementId == elementId)
|
||||
.Select(i => i.GroupId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (affectedGroupIds.Count == 0)
|
||||
return;
|
||||
|
||||
var groups = await dbContext
|
||||
.Groups.Include(g => g.Items)
|
||||
.Where(g => affectedGroupIds.Contains(g.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
group.RemoveElement(kind, elementId);
|
||||
await stats.RecomputeAsync(group, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups;
|
||||
|
||||
/// <summary>
|
||||
/// Пересчёт кэша статистики группы. Вызывается при каждой правке состава: цифры «342 позиции ·
|
||||
/// 118 ч» видны прямо в инспекторе слота, и устаревшая статистика там хуже, чем её отсутствие.
|
||||
/// </summary>
|
||||
public sealed class GroupStatsService(IAppDbContext dbContext, GroupElementResolver resolver)
|
||||
{
|
||||
public async Task RecomputeAsync(Group group, CancellationToken cancellationToken)
|
||||
{
|
||||
var info = await resolver.ResolveAsync(
|
||||
group.Items.Select(i => (i.ElementKind, i.ElementId)),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var unitCount = 0;
|
||||
var totalDuration = TimeSpan.Zero;
|
||||
foreach (var item in group.Items)
|
||||
{
|
||||
if (!info.TryGetValue((item.ElementKind, item.ElementId), out var element))
|
||||
continue;
|
||||
unitCount += element.UnitCount;
|
||||
totalDuration += element.TotalDuration;
|
||||
}
|
||||
|
||||
group.UpdateStats(group.Items.Count, unitCount, totalDuration, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>Пересчитать по идентификатору, загрузив состав. Возвращает false, если группы нет.</summary>
|
||||
public async Task<bool> RecomputeAsync(Guid groupId, CancellationToken cancellationToken)
|
||||
{
|
||||
var group = await dbContext
|
||||
.Groups.Include(g => g.Items)
|
||||
.FirstOrDefaultAsync(g => g.Id == groupId, cancellationToken);
|
||||
if (group is null)
|
||||
return false;
|
||||
|
||||
await RecomputeAsync(group, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using LiteCqrs;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.ListGroups;
|
||||
|
||||
public sealed record ListGroupsQuery : IQuery<IReadOnlyList<GroupSummaryDto>>;
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.ListGroups;
|
||||
|
||||
public sealed class ListGroupsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListGroupsQuery, IReadOnlyList<GroupSummaryDto>>
|
||||
{
|
||||
public async Task<IReadOnlyList<GroupSummaryDto>> Handle(
|
||||
ListGroupsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
// Список читает кэш статистики, не пересчитывая его: пересчёт идёт при правке состава.
|
||||
return await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.OrderBy(g => g.Name)
|
||||
.Select(g => new GroupSummaryDto(
|
||||
g.Id,
|
||||
g.Name,
|
||||
g.Description,
|
||||
g.ItemCount,
|
||||
g.UnitCount,
|
||||
g.TotalDuration.TotalSeconds,
|
||||
g.FilterJson != null,
|
||||
g.StatsComputedAt,
|
||||
g.CreatedAt
|
||||
))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.RemoveGroupItem;
|
||||
|
||||
public sealed record RemoveGroupItemCommand(Guid GroupId, Guid ItemId) : ICommand<Result>;
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.RemoveGroupItem;
|
||||
|
||||
public sealed class RemoveGroupItemCommandHandler(IAppDbContext dbContext, GroupStatsService stats)
|
||||
: ICommandHandler<RemoveGroupItemCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
RemoveGroupItemCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var group = await dbContext
|
||||
.Groups.Include(g => g.Items)
|
||||
.FirstOrDefaultAsync(g => g.Id == command.GroupId, cancellationToken);
|
||||
if (group is null)
|
||||
return Result.Failure(GroupErrors.NotFound);
|
||||
|
||||
if (!group.RemoveItem(command.ItemId))
|
||||
return Result.Failure(GroupErrors.ItemNotFound);
|
||||
|
||||
await stats.RecomputeAsync(group, cancellationToken);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user