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);
}
}