Add broadcast scheduling features: implement Show and Channel entities, enhance AppDbContext and DependencyInjection for broadcasting, and update API routing. Include migration for new database schema and update documentation for broadcast-related functionalities.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.AddChannelAd;
|
||||
|
||||
public sealed record AddChannelAdCommand(Guid ChannelId, Guid MediaAssetId) : ICommand<Result<Guid>>;
|
||||
@@ -0,0 +1,35 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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>>;
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
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
@@ -0,0 +1,12 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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
|
||||
);
|
||||
|
||||
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,
|
||||
DateTimeOffset StartsAtUtc,
|
||||
DateTimeOffset EndsAtUtc,
|
||||
IReadOnlyList<OverrideShowDto> Shows
|
||||
);
|
||||
|
||||
public sealed record ChannelDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Slug,
|
||||
bool IsEnabled,
|
||||
AdInsertion AdInsertion,
|
||||
int AdsPerBreak,
|
||||
Guid? FillerAssetId,
|
||||
IReadOnlyList<ChannelShowDto> Shows,
|
||||
IReadOnlyList<ChannelAdDto> Ads,
|
||||
IReadOnlyList<ProgrammingOverrideDto> Overrides
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
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 AssetNotFound = Error.NotFound(
|
||||
"Channels.AssetNotFound",
|
||||
"Медиа-ассет не найден."
|
||||
);
|
||||
|
||||
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 должен ссылаться хотя бы на одно шоу."
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.CreateChannel;
|
||||
|
||||
public sealed record CreateChannelCommand(string Name, string Slug) : ICommand<Result<Guid>>;
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.CreateChannel;
|
||||
|
||||
public sealed class CreateChannelCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<CreateChannelCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
CreateChannelCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var slugTaken = await dbContext.Channels.AnyAsync(
|
||||
c => c.Slug == command.Slug,
|
||||
cancellationToken
|
||||
);
|
||||
if (slugTaken)
|
||||
return Result.Failure<Guid>(ChannelErrors.DuplicateSlug);
|
||||
|
||||
var channel = Channel.Create(command.Name, command.Slug, DateTimeOffset.UtcNow);
|
||||
dbContext.Channels.Add(channel);
|
||||
return Result.Success(channel.Id);
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.CreateChannel;
|
||||
|
||||
public sealed class CreateChannelCommandValidator : AbstractValidator<CreateChannelCommand>
|
||||
{
|
||||
public CreateChannelCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.Slug)
|
||||
.NotEmpty()
|
||||
.MaximumLength(128)
|
||||
.Matches("^[a-z0-9]+(-[a-z0-9]+)*$")
|
||||
.WithMessage("Slug — только строчные латинские буквы, цифры и дефисы.");
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.CreateOverride;
|
||||
|
||||
public sealed record CreateProgrammingOverrideCommand(
|
||||
Guid ChannelId,
|
||||
OverrideMode Mode,
|
||||
DateTimeOffset StartsAtUtc,
|
||||
DateTimeOffset EndsAtUtc,
|
||||
IReadOnlyList<OverrideShowInput> Shows
|
||||
) : ICommand<Result<Guid>>;
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
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
|
||||
)
|
||||
{
|
||||
if (command.EndsAtUtc <= command.StartsAtUtc)
|
||||
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 = channel.AddOverride(command.Mode, command.StartsAtUtc, command.EndsAtUtc);
|
||||
foreach (var show in command.Shows)
|
||||
ovr.AddShow(show.ShowId, show.Weight);
|
||||
|
||||
return Result.Success(ovr.Id);
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace TeleWave.Application.Broadcast.CreateOverride;
|
||||
|
||||
public sealed record OverrideShowInput(Guid ShowId, int Weight);
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.DeleteOverride;
|
||||
|
||||
public sealed record DeleteProgrammingOverrideCommand(Guid ChannelId, Guid OverrideId)
|
||||
: 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.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.GetChannel;
|
||||
|
||||
public sealed record GetChannelQuery(Guid Id) : IQuery<Result<ChannelDto>>;
|
||||
@@ -0,0 +1,93 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.GetChannel;
|
||||
|
||||
public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<GetChannelQuery, Result<ChannelDto>>
|
||||
{
|
||||
public async Task<Result<ChannelDto>> Handle(
|
||||
GetChannelQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channel = await dbContext.Channels.AsNoTracking()
|
||||
.Include(c => c.Shows)
|
||||
.Include(c => c.Ads)
|
||||
.Include(c => c.Overrides)
|
||||
.ThenInclude(o => o.Shows)
|
||||
.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 adAssetIds = channel.Ads.Select(a => a.MediaAssetId).ToList();
|
||||
var assetNames = await dbContext.MediaAssets.AsNoTracking()
|
||||
.Where(a => adAssetIds.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
|
||||
))
|
||||
.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 overrides = channel.Overrides
|
||||
.OrderBy(o => o.StartsAtUtc)
|
||||
.Select(o => new ProgrammingOverrideDto(
|
||||
o.Id,
|
||||
o.Mode,
|
||||
o.StartsAtUtc,
|
||||
o.EndsAtUtc,
|
||||
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.FillerAssetId,
|
||||
shows,
|
||||
ads,
|
||||
overrides
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.GetSchedule;
|
||||
|
||||
public sealed record GetChannelScheduleQuery(
|
||||
Guid ChannelId,
|
||||
DateTimeOffset FromUtc,
|
||||
DateTimeOffset ToUtc
|
||||
) : IQuery<Result<IReadOnlyList<ScheduleEntryDto>>>;
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.GetSchedule;
|
||||
|
||||
public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<GetChannelScheduleQuery, Result<IReadOnlyList<ScheduleEntryDto>>>
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<ScheduleEntryDto>>> Handle(
|
||||
GetChannelScheduleQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var channelExists = await dbContext.Channels.AnyAsync(
|
||||
c => c.Id == query.ChannelId,
|
||||
cancellationToken
|
||||
);
|
||||
if (!channelExists)
|
||||
return Result.Failure<IReadOnlyList<ScheduleEntryDto>>(ChannelErrors.NotFound);
|
||||
|
||||
// Пересекающиеся с окном [from, to) записи.
|
||||
var entries = await dbContext.ScheduleEntries.AsNoTracking()
|
||||
.Where(e =>
|
||||
e.ChannelId == query.ChannelId
|
||||
&& e.StartsAtUtc < query.ToUtc
|
||||
&& e.EndsAtUtc > query.FromUtc
|
||||
)
|
||||
.OrderBy(e => e.StartsAtUtc)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var showIds = entries.Where(e => e.ShowId != null).Select(e => e.ShowId!.Value).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 dtos = entries
|
||||
.Select(e => new ScheduleEntryDto(
|
||||
e.Id,
|
||||
e.Kind,
|
||||
e.MediaAssetId,
|
||||
e.StartsAtUtc,
|
||||
e.EndsAtUtc,
|
||||
e.ShowId,
|
||||
e.ShowId is { } sid ? showNames.GetValueOrDefault(sid) : null,
|
||||
e.EpisodeIndex
|
||||
))
|
||||
.ToList();
|
||||
|
||||
return Result.Success<IReadOnlyList<ScheduleEntryDto>>(dtos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using LiteCqrs;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.ListChannels;
|
||||
|
||||
public sealed record ListChannelsQuery : IQuery<IReadOnlyList<ChannelSummaryDto>>;
|
||||
@@ -0,0 +1,20 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.ListChannels;
|
||||
|
||||
public sealed class ListChannelsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListChannelsQuery, IReadOnlyList<ChannelSummaryDto>>
|
||||
{
|
||||
public async Task<IReadOnlyList<ChannelSummaryDto>> Handle(
|
||||
ListChannelsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
return await dbContext.Channels.AsNoTracking()
|
||||
.OrderBy(c => c.Name)
|
||||
.Select(c => new ChannelSummaryDto(c.Id, c.Name, c.Slug, c.IsEnabled))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.RegenerateSchedule;
|
||||
|
||||
public sealed record RegenerateChannelScheduleCommand(Guid ChannelId) : ICommand<Result>;
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.RemoveChannelAd;
|
||||
|
||||
public sealed record RemoveChannelAdCommand(Guid ChannelId, Guid ChannelAdId) : 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.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);
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.RemoveChannelShow;
|
||||
|
||||
public sealed record RemoveChannelShowCommand(Guid ChannelId, Guid ChannelShowId) : 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.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast;
|
||||
|
||||
public sealed record ScheduleEntryDto(
|
||||
Guid Id,
|
||||
ScheduleEntryKind Kind,
|
||||
Guid MediaAssetId,
|
||||
DateTimeOffset StartsAtUtc,
|
||||
DateTimeOffset EndsAtUtc,
|
||||
Guid? ShowId,
|
||||
string? ShowName,
|
||||
int? EpisodeIndex
|
||||
);
|
||||
@@ -0,0 +1,187 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Оркестратор планирования: загружает конфигурацию канала и готовые ассеты, вызывает чистый
|
||||
/// <see cref="SchedulePlanner"/>, материализует записи и двигает курсоры. Используется фоновым
|
||||
/// планировщиком (расширение горизонта) и командой перегенерации (правка конфигурации канала).
|
||||
/// </summary>
|
||||
public sealed class ScheduleGenerator(
|
||||
IAppDbContext dbContext,
|
||||
IRandomSource random,
|
||||
IOptions<SchedulerOptions> options
|
||||
)
|
||||
{
|
||||
private readonly SchedulerOptions _options = options.Value;
|
||||
|
||||
/// <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)
|
||||
.Include(c => c.Ads)
|
||||
.Include(c => c.Overrides)
|
||||
.ThenInclude(o => o.Shows)
|
||||
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
|
||||
|
||||
if (channel is null || !channel.IsEnabled)
|
||||
return -1;
|
||||
|
||||
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);
|
||||
return 0;
|
||||
}
|
||||
|
||||
var input = await BuildInputAsync(channel, startTime, horizonEnd, cancellationToken);
|
||||
var result = SchedulePlanner.Plan(input, random);
|
||||
|
||||
foreach (var entry in result.Entries)
|
||||
{
|
||||
var scheduleEntry = entry.Kind == ScheduleEntryKind.Program
|
||||
? ScheduleEntry.Program(
|
||||
channel.Id,
|
||||
entry.MediaAssetId,
|
||||
entry.StartsAtUtc,
|
||||
entry.EndsAtUtc,
|
||||
entry.ShowId!.Value,
|
||||
entry.EpisodeIndex!.Value
|
||||
)
|
||||
: ScheduleEntry.Ad(channel.Id, entry.MediaAssetId, entry.StartsAtUtc, entry.EndsAtUtc);
|
||||
dbContext.ScheduleEntries.Add(scheduleEntry);
|
||||
}
|
||||
|
||||
foreach (var channelShow in channel.Shows)
|
||||
if (result.NextEpisodeIndexByChannelShow.TryGetValue(channelShow.Id, out var idx))
|
||||
channelShow.SetNextEpisodeIndex(idx);
|
||||
|
||||
channel.SetNextAdIndex(result.NextAdIndex);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return result.Entries.Count;
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
var adPool = channel.Ads
|
||||
.OrderBy(a => a.Position)
|
||||
.Select(a => a.MediaAssetId)
|
||||
.Where(durations.ContainsKey)
|
||||
.ToList();
|
||||
|
||||
var overrides = channel.Overrides
|
||||
.Select(o => new PlannerOverride(
|
||||
o.StartsAtUtc,
|
||||
o.EndsAtUtc,
|
||||
o.Mode,
|
||||
o.Shows.Select(s => new PlannerOverrideShow(s.ShowId, s.Weight)).ToList()
|
||||
))
|
||||
.ToList();
|
||||
|
||||
return new PlannerInput(
|
||||
channel.Id,
|
||||
channel.AdInsertion,
|
||||
channel.AdsPerBreak,
|
||||
channel.NextAdIndex,
|
||||
plannerShows,
|
||||
adPool,
|
||||
durations,
|
||||
overrides,
|
||||
startTime,
|
||||
horizonEnd
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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;
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
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,
|
||||
Guid? FillerAssetId
|
||||
) : ICommand<Result>;
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
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.FillerAssetId
|
||||
);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
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
|
||||
) : 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.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)
|
||||
.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);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Domain.Auth;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
@@ -8,6 +10,9 @@ public interface IAppDbContext
|
||||
{
|
||||
DbSet<RefreshToken> RefreshTokens { get; }
|
||||
DbSet<MediaAsset> MediaAssets { get; }
|
||||
DbSet<Show> Shows { get; }
|
||||
DbSet<Channel> Channels { get; }
|
||||
DbSet<ScheduleEntry> ScheduleEntries { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.AddEpisode;
|
||||
|
||||
public sealed record AddEpisodeCommand(Guid ShowId, Guid MediaAssetId) : ICommand<Result<Guid>>;
|
||||
@@ -0,0 +1,35 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.AddEpisode;
|
||||
|
||||
public sealed class AddEpisodeCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<AddEpisodeCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
AddEpisodeCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var show = await dbContext.Shows
|
||||
.Include(s => s.Episodes)
|
||||
.FirstOrDefaultAsync(s => s.Id == command.ShowId, cancellationToken);
|
||||
if (show is null)
|
||||
return Result.Failure<Guid>(ShowErrors.NotFound);
|
||||
|
||||
if (!show.CanAddEpisode)
|
||||
return Result.Failure<Guid>(ShowErrors.SingleAlreadyHasEpisode);
|
||||
|
||||
var assetExists = await dbContext.MediaAssets.AnyAsync(
|
||||
a => a.Id == command.MediaAssetId,
|
||||
cancellationToken
|
||||
);
|
||||
if (!assetExists)
|
||||
return Result.Failure<Guid>(ShowErrors.AssetNotFound);
|
||||
|
||||
var episode = show.AddEpisode(command.MediaAssetId);
|
||||
return Result.Success(episode.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Library.CreateShow;
|
||||
|
||||
public sealed record CreateShowCommand(string Name, ShowKind Kind, string? Description)
|
||||
: ICommand<Result<Guid>>;
|
||||
@@ -0,0 +1,17 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Library.CreateShow;
|
||||
|
||||
public sealed class CreateShowCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<CreateShowCommand, Result<Guid>>
|
||||
{
|
||||
public Task<Result<Guid>> Handle(CreateShowCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var show = Show.Create(command.Name, command.Kind, command.Description);
|
||||
dbContext.Shows.Add(show);
|
||||
return Task.FromResult(Result.Success(show.Id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Library.CreateShow;
|
||||
|
||||
public sealed class CreateShowCommandValidator : AbstractValidator<CreateShowCommand>
|
||||
{
|
||||
public CreateShowCommandValidator()
|
||||
{
|
||||
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.Library.DeleteShow;
|
||||
|
||||
public sealed record DeleteShowCommand(Guid ShowId) : ICommand<Result>;
|
||||
@@ -0,0 +1,24 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.DeleteShow;
|
||||
|
||||
public sealed class DeleteShowCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<DeleteShowCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(DeleteShowCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var show = await dbContext.Shows.FirstOrDefaultAsync(
|
||||
s => s.Id == command.ShowId,
|
||||
cancellationToken
|
||||
);
|
||||
if (show is null)
|
||||
return Result.Failure(ShowErrors.NotFound);
|
||||
|
||||
// TODO(этап 2+): запретить удаление шоу, пока оно привязано к каналу или будущему расписанию.
|
||||
dbContext.Shows.Remove(show);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.GetShow;
|
||||
|
||||
public sealed record GetShowQuery(Guid Id) : IQuery<Result<ShowDto>>;
|
||||
@@ -0,0 +1,51 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.GetShow;
|
||||
|
||||
public sealed class GetShowQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<GetShowQuery, Result<ShowDto>>
|
||||
{
|
||||
public async Task<Result<ShowDto>> Handle(GetShowQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var show = await dbContext.Shows.AsNoTracking()
|
||||
.Include(s => s.Episodes)
|
||||
.FirstOrDefaultAsync(s => s.Id == query.Id, cancellationToken);
|
||||
if (show is null)
|
||||
return Result.Failure<ShowDto>(ShowErrors.NotFound);
|
||||
|
||||
var episodes = show.Episodes.OrderBy(e => e.Position).ToList();
|
||||
var assetIds = episodes.Select(e => e.MediaAssetId).ToList();
|
||||
var assets = await dbContext.MediaAssets.AsNoTracking()
|
||||
.Where(a => assetIds.Contains(a.Id))
|
||||
.Select(a => new
|
||||
{
|
||||
a.Id,
|
||||
a.OriginalFileName,
|
||||
a.Status,
|
||||
a.Duration,
|
||||
})
|
||||
.ToDictionaryAsync(a => a.Id, cancellationToken);
|
||||
|
||||
var episodeDtos = episodes
|
||||
.Select(e =>
|
||||
{
|
||||
assets.TryGetValue(e.MediaAssetId, out var asset);
|
||||
return new EpisodeDto(
|
||||
e.Id,
|
||||
e.MediaAssetId,
|
||||
e.Position,
|
||||
asset?.OriginalFileName,
|
||||
asset?.Status,
|
||||
asset?.Duration?.TotalSeconds
|
||||
);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Result.Success(
|
||||
new ShowDto(show.Id, show.Name, show.Kind, show.Description, episodeDtos)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using LiteCqrs;
|
||||
|
||||
namespace TeleWave.Application.Library.ListShows;
|
||||
|
||||
public sealed record ListShowsQuery : IQuery<IReadOnlyList<ShowSummaryDto>>;
|
||||
@@ -0,0 +1,20 @@
|
||||
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
|
||||
)
|
||||
{
|
||||
return await dbContext.Shows.AsNoTracking()
|
||||
.OrderBy(s => s.Name)
|
||||
.Select(s => new ShowSummaryDto(s.Id, s.Name, s.Kind, s.Episodes.Count, s.CreatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.RemoveEpisode;
|
||||
|
||||
public sealed record RemoveEpisodeCommand(Guid ShowId, Guid EpisodeId) : ICommand<Result>;
|
||||
@@ -0,0 +1,26 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.RemoveEpisode;
|
||||
|
||||
public sealed class RemoveEpisodeCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<RemoveEpisodeCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
RemoveEpisodeCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var show = await dbContext.Shows
|
||||
.Include(s => s.Episodes)
|
||||
.FirstOrDefaultAsync(s => s.Id == command.ShowId, cancellationToken);
|
||||
if (show is null)
|
||||
return Result.Failure(ShowErrors.NotFound);
|
||||
|
||||
return show.RemoveEpisode(command.EpisodeId)
|
||||
? Result.Success()
|
||||
: Result.Failure(ShowErrors.EpisodeNotFound);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Application.Library;
|
||||
|
||||
public sealed record ShowSummaryDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
ShowKind Kind,
|
||||
int EpisodeCount,
|
||||
DateTimeOffset CreatedAt
|
||||
);
|
||||
|
||||
public sealed record EpisodeDto(
|
||||
Guid Id,
|
||||
Guid MediaAssetId,
|
||||
int Position,
|
||||
string? AssetName,
|
||||
MediaAssetStatus? AssetStatus,
|
||||
double? DurationSeconds
|
||||
);
|
||||
|
||||
public sealed record ShowDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
ShowKind Kind,
|
||||
string? Description,
|
||||
IReadOnlyList<EpisodeDto> Episodes
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library;
|
||||
|
||||
public static class ShowErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound("Shows.NotFound", "Шоу не найдено.");
|
||||
|
||||
public static readonly Error EpisodeNotFound = Error.NotFound(
|
||||
"Shows.EpisodeNotFound",
|
||||
"Серия не найдена."
|
||||
);
|
||||
|
||||
public static readonly Error SingleAlreadyHasEpisode = Error.Conflict(
|
||||
"Shows.SingleAlreadyHasEpisode",
|
||||
"Полнометражка/разовый выпуск может содержать только одну серию."
|
||||
);
|
||||
|
||||
public static readonly Error AssetNotFound = Error.NotFound(
|
||||
"Shows.AssetNotFound",
|
||||
"Медиа-ассет для серии не найден."
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
Reference in New Issue
Block a user