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:
+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();
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.ReorderGroup;
|
||||
|
||||
/// <summary>Порядок позиций (важен для последовательных стратегий). Не упомянутые остаются после
|
||||
/// перечисленных, сохраняя относительный порядок.</summary>
|
||||
public sealed record ReorderGroupCommand(Guid GroupId, IReadOnlyList<Guid> ItemIdsInOrder)
|
||||
: 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.Programming.Groups.ReorderGroup;
|
||||
|
||||
public sealed class ReorderGroupCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<ReorderGroupCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
ReorderGroupCommand 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);
|
||||
|
||||
group.Reorder(command.ItemIdsInOrder);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.SetGroupItemWeight;
|
||||
|
||||
/// <summary>Вес позиции при случайном выборе (0 — исключить, не удаляя из группы).</summary>
|
||||
public sealed record SetGroupItemWeightCommand(Guid GroupId, Guid ItemId, int Weight)
|
||||
: ICommand<Result>;
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.SetGroupItemWeight;
|
||||
|
||||
public sealed class SetGroupItemWeightCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<SetGroupItemWeightCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
SetGroupItemWeightCommand 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);
|
||||
|
||||
// Вес не влияет на объём контента — статистику пересчитывать незачем.
|
||||
return group.SetWeight(command.ItemId, command.Weight)
|
||||
? Result.Success()
|
||||
: Result.Failure(GroupErrors.ItemNotFound);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.UpdateGroup;
|
||||
|
||||
/// <summary>Имя, описание и правило набора. <paramref name="Filter"/> = null снимает правило;
|
||||
/// состав группы при этом не меняется.</summary>
|
||||
public sealed record UpdateGroupCommand(
|
||||
Guid GroupId,
|
||||
string Name,
|
||||
string? Description,
|
||||
GroupFilter? Filter
|
||||
) : 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.UpdateGroup;
|
||||
|
||||
public sealed class UpdateGroupCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateGroupCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(UpdateGroupCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var group = await dbContext.Groups.FirstOrDefaultAsync(
|
||||
g => g.Id == command.GroupId,
|
||||
cancellationToken
|
||||
);
|
||||
if (group is null)
|
||||
return Result.Failure(GroupErrors.NotFound);
|
||||
|
||||
group.Rename(command.Name, command.Description);
|
||||
group.SetFilter(command.Filter?.ToJson());
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Programming.Groups.UpdateGroup;
|
||||
|
||||
public sealed class UpdateGroupCommandValidator : AbstractValidator<UpdateGroupCommand>
|
||||
{
|
||||
public UpdateGroupCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.Description).MaximumLength(2048);
|
||||
|
||||
When(
|
||||
x => x.Filter is not null,
|
||||
() =>
|
||||
{
|
||||
RuleFor(x => x.Filter!.YearMin).InclusiveBetween(1870, 2200).When(x => x.Filter!.YearMin is not null);
|
||||
RuleFor(x => x.Filter!.YearMax).InclusiveBetween(1870, 2200).When(x => x.Filter!.YearMax is not null);
|
||||
RuleFor(x => x.Filter!.UnitMinutesMin).GreaterThanOrEqualTo(0).When(x => x.Filter!.UnitMinutesMin is not null);
|
||||
RuleFor(x => x.Filter!.UnitMinutesMax).GreaterThanOrEqualTo(0).When(x => x.Filter!.UnitMinutesMax is not null);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user