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.
build / backend (push) Successful in 7m40s
build / frontend (push) Failing after 39s
tests / backend-tests (push) Successful in 6m9s

This commit is contained in:
Leonid Pershin
2026-07-26 13:32:13 +03:00
parent c4ef954dea
commit 66040a8841
272 changed files with 27944 additions and 8699 deletions
@@ -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>;
@@ -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",
"Шоу не входит в коллекцию."
);
}
@@ -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>>;
@@ -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));
}
}
@@ -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);
}
}
@@ -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>;
@@ -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();
}
}
@@ -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>>;
@@ -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
)
);
}
}
@@ -0,0 +1,5 @@
using LiteCqrs;
namespace TeleWave.Application.Library.Collections.ListCollections;
public sealed record ListCollectionsQuery : IQuery<IReadOnlyList<CollectionSummaryDto>>;
@@ -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();
}
}
@@ -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>;
@@ -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);
}
}
@@ -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>;
@@ -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();
}
}
@@ -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>;
@@ -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();
}
}
@@ -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>;
@@ -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();
}
}
@@ -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>>;
@@ -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);
}
}
@@ -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>;
@@ -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>;
@@ -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();
}
}
@@ -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
);