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:
+6
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.AddCollectionShow;
|
||||
|
||||
public sealed record AddCollectionShowCommand(Guid CollectionId, Guid ShowId) : ICommand<Result>;
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.AddCollectionShow;
|
||||
|
||||
public sealed class AddCollectionShowCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<AddCollectionShowCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
AddCollectionShowCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var collection = await dbContext
|
||||
.Collections.Include(c => c.Items)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.CollectionId, cancellationToken);
|
||||
if (collection is null)
|
||||
return Result.Failure(CollectionErrors.NotFound);
|
||||
|
||||
if (!await dbContext.Shows.AnyAsync(s => s.Id == command.ShowId, cancellationToken))
|
||||
return Result.Failure(ShowErrors.NotFound);
|
||||
|
||||
return collection.AddShow(command.ShowId) is null
|
||||
? Result.Failure(CollectionErrors.ShowAlreadyAdded)
|
||||
: Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections;
|
||||
|
||||
/// <summary>Коллекция в списке: без состава, но со сводкой — сколько частей и сколько в них единиц
|
||||
/// воспроизведения (у сериала внутри коллекции их больше одной).</summary>
|
||||
public sealed record CollectionSummaryDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
Guid? PosterImageId,
|
||||
int ItemCount,
|
||||
int UnitCount,
|
||||
DateTimeOffset CreatedAt
|
||||
);
|
||||
|
||||
/// <summary>Позиция коллекции с данными шоу — чтобы список правился без второго запроса.</summary>
|
||||
public sealed record CollectionItemDto(
|
||||
Guid ShowId,
|
||||
int Position,
|
||||
string ShowName,
|
||||
ShowKind ShowKind,
|
||||
ShowAudience ShowAudience,
|
||||
int EpisodeCount,
|
||||
int? Year,
|
||||
Guid? PosterImageId
|
||||
);
|
||||
|
||||
public sealed record CollectionDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
Guid? PosterImageId,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<CollectionItemDto> Items
|
||||
);
|
||||
|
||||
/// <summary>Коллекция, в которую входит шоу — для блока «входит в коллекции» на экране шоу.</summary>
|
||||
public sealed record ShowCollectionRefDto(Guid Id, string Name, int Position);
|
||||
@@ -0,0 +1,21 @@
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections;
|
||||
|
||||
public static class CollectionErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound(
|
||||
"Collections.NotFound",
|
||||
"Коллекция не найдена."
|
||||
);
|
||||
|
||||
public static readonly Error ShowAlreadyAdded = Error.Conflict(
|
||||
"Collections.ShowAlreadyAdded",
|
||||
"Это шоу уже входит в коллекцию."
|
||||
);
|
||||
|
||||
public static readonly Error ShowNotInCollection = Error.NotFound(
|
||||
"Collections.ShowNotInCollection",
|
||||
"Шоу не входит в коллекцию."
|
||||
);
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.CreateCollection;
|
||||
|
||||
public sealed record CreateCollectionCommand(string Name, string? Description = null)
|
||||
: ICommand<Result<Guid>>;
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.CreateCollection;
|
||||
|
||||
public sealed class CreateCollectionCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<CreateCollectionCommand, Result<Guid>>
|
||||
{
|
||||
public Task<Result<Guid>> Handle(
|
||||
CreateCollectionCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var collection = Collection.Create(command.Name, command.Description);
|
||||
dbContext.Collections.Add(collection);
|
||||
return Task.FromResult(Result.Success(collection.Id));
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.CreateCollection;
|
||||
|
||||
public sealed class CreateCollectionCommandValidator : AbstractValidator<CreateCollectionCommand>
|
||||
{
|
||||
public CreateCollectionCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.Description).MaximumLength(2048);
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.DeleteCollection;
|
||||
|
||||
public sealed record DeleteCollectionCommand(Guid CollectionId) : ICommand<Result>;
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.DeleteCollection;
|
||||
|
||||
public sealed class DeleteCollectionCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
GroupMembershipCleaner groupCleaner
|
||||
) : ICommandHandler<DeleteCollectionCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
DeleteCollectionCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var collection = await dbContext.Collections.FirstOrDefaultAsync(
|
||||
c => c.Id == command.CollectionId,
|
||||
cancellationToken
|
||||
);
|
||||
if (collection is null)
|
||||
return Result.Failure(CollectionErrors.NotFound);
|
||||
|
||||
// Ссылка группы полиморфна — каскад БД её не снимет.
|
||||
await groupCleaner.RemoveElementAsync(
|
||||
GroupElementKind.Collection,
|
||||
command.CollectionId,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
dbContext.Collections.Remove(collection);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.GetCollection;
|
||||
|
||||
public sealed record GetCollectionQuery(Guid Id) : IQuery<Result<CollectionDto>>;
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.GetCollection;
|
||||
|
||||
public sealed class GetCollectionQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<GetCollectionQuery, Result<CollectionDto>>
|
||||
{
|
||||
public async Task<Result<CollectionDto>> Handle(
|
||||
GetCollectionQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var collection = await dbContext
|
||||
.Collections.AsNoTracking()
|
||||
.Include(c => c.Items)
|
||||
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
|
||||
if (collection is null)
|
||||
return Result.Failure<CollectionDto>(CollectionErrors.NotFound);
|
||||
|
||||
var showIds = collection.Items.Select(i => i.ShowId).ToList();
|
||||
var shows = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.Kind,
|
||||
s.Audience,
|
||||
s.Year,
|
||||
s.PosterImageId,
|
||||
EpisodeCount = s.Episodes.Count,
|
||||
})
|
||||
.ToDictionaryAsync(s => s.Id, cancellationToken);
|
||||
|
||||
var items = collection
|
||||
.Items.OrderBy(i => i.Position)
|
||||
.Select(i =>
|
||||
{
|
||||
shows.TryGetValue(i.ShowId, out var show);
|
||||
return new CollectionItemDto(
|
||||
i.ShowId,
|
||||
i.Position,
|
||||
show?.Name ?? "—",
|
||||
show?.Kind ?? default,
|
||||
show?.Audience ?? default,
|
||||
show?.EpisodeCount ?? 0,
|
||||
show?.Year,
|
||||
show?.PosterImageId
|
||||
);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Result.Success(
|
||||
new CollectionDto(
|
||||
collection.Id,
|
||||
collection.Name,
|
||||
collection.Description,
|
||||
collection.PosterImageId,
|
||||
collection.CreatedAt,
|
||||
items
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
using LiteCqrs;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.ListCollections;
|
||||
|
||||
public sealed record ListCollectionsQuery : IQuery<IReadOnlyList<CollectionSummaryDto>>;
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.ListCollections;
|
||||
|
||||
public sealed class ListCollectionsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListCollectionsQuery, IReadOnlyList<CollectionSummaryDto>>
|
||||
{
|
||||
public async Task<IReadOnlyList<CollectionSummaryDto>> Handle(
|
||||
ListCollectionsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var collections = await dbContext
|
||||
.Collections.AsNoTracking()
|
||||
.Include(c => c.Items)
|
||||
.OrderBy(c => c.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Единиц воспроизведения может быть больше, чем частей: сериал внутри коллекции
|
||||
// разворачивается в свои серии.
|
||||
var showIds = collections.SelectMany(c => c.Items.Select(i => i.ShowId)).Distinct().ToList();
|
||||
var episodeCounts = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.Select(s => new { s.Id, Count = s.Episodes.Count })
|
||||
.ToDictionaryAsync(x => x.Id, x => x.Count, cancellationToken);
|
||||
|
||||
return collections
|
||||
.Select(c => new CollectionSummaryDto(
|
||||
c.Id,
|
||||
c.Name,
|
||||
c.Description,
|
||||
c.PosterImageId,
|
||||
c.Items.Count,
|
||||
c.Items.Sum(i => episodeCounts.TryGetValue(i.ShowId, out var n) ? n : 0),
|
||||
c.CreatedAt
|
||||
))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.RemoveCollectionShow;
|
||||
|
||||
public sealed record RemoveCollectionShowCommand(Guid CollectionId, Guid ShowId) : ICommand<Result>;
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.RemoveCollectionShow;
|
||||
|
||||
public sealed class RemoveCollectionShowCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<RemoveCollectionShowCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
RemoveCollectionShowCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var collection = await dbContext
|
||||
.Collections.Include(c => c.Items)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.CollectionId, cancellationToken);
|
||||
if (collection is null)
|
||||
return Result.Failure(CollectionErrors.NotFound);
|
||||
|
||||
return collection.RemoveShow(command.ShowId)
|
||||
? Result.Success()
|
||||
: Result.Failure(CollectionErrors.ShowNotInCollection);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.ReorderCollection;
|
||||
|
||||
/// <summary>Переставляет части коллекции в порядке <paramref name="ShowIdsInOrder"/>. Не упомянутые
|
||||
/// остаются после них, сохраняя относительный порядок.</summary>
|
||||
public sealed record ReorderCollectionCommand(Guid CollectionId, IReadOnlyList<Guid> ShowIdsInOrder)
|
||||
: ICommand<Result>;
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.ReorderCollection;
|
||||
|
||||
public sealed class ReorderCollectionCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<ReorderCollectionCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
ReorderCollectionCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var collection = await dbContext
|
||||
.Collections.Include(c => c.Items)
|
||||
.FirstOrDefaultAsync(c => c.Id == command.CollectionId, cancellationToken);
|
||||
if (collection is null)
|
||||
return Result.Failure(CollectionErrors.NotFound);
|
||||
|
||||
collection.Reorder(command.ShowIdsInOrder);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.SetCollectionPoster;
|
||||
|
||||
/// <summary>Привязать/снять постер коллекции (<paramref name="ImageId"/> = null — отвязать).</summary>
|
||||
public sealed record SetCollectionPosterCommand(Guid CollectionId, Guid? ImageId) : ICommand<Result>;
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Images;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.SetCollectionPoster;
|
||||
|
||||
public sealed class SetCollectionPosterCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<SetCollectionPosterCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
SetCollectionPosterCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var collection = await dbContext.Collections.FirstOrDefaultAsync(
|
||||
c => c.Id == command.CollectionId,
|
||||
cancellationToken
|
||||
);
|
||||
if (collection is null)
|
||||
return Result.Failure(CollectionErrors.NotFound);
|
||||
|
||||
if (
|
||||
command.ImageId is { } imageId
|
||||
&& !await dbContext.Images.AnyAsync(i => i.Id == imageId, cancellationToken)
|
||||
)
|
||||
return Result.Failure(ImageErrors.NotFound);
|
||||
|
||||
collection.SetPosterImage(command.ImageId);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.UpdateCollection;
|
||||
|
||||
public sealed record UpdateCollectionCommand(Guid CollectionId, string Name, string? Description)
|
||||
: ICommand<Result>;
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.UpdateCollection;
|
||||
|
||||
public sealed class UpdateCollectionCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateCollectionCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateCollectionCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var collection = await dbContext.Collections.FirstOrDefaultAsync(
|
||||
c => c.Id == command.CollectionId,
|
||||
cancellationToken
|
||||
);
|
||||
if (collection is null)
|
||||
return Result.Failure(CollectionErrors.NotFound);
|
||||
|
||||
collection.Rename(command.Name, command.Description);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.UpdateCollection;
|
||||
|
||||
public sealed class UpdateCollectionCommandValidator : AbstractValidator<UpdateCollectionCommand>
|
||||
{
|
||||
public UpdateCollectionCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.Description).MaximumLength(2048);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,15 @@ using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Library.DeleteShow;
|
||||
|
||||
public sealed class DeleteShowCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<DeleteShowCommand, Result>
|
||||
public sealed class DeleteShowCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
GroupMembershipCleaner groupCleaner
|
||||
) : ICommandHandler<DeleteShowCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(DeleteShowCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -17,6 +21,13 @@ public sealed class DeleteShowCommandHandler(IAppDbContext dbContext)
|
||||
if (show is null)
|
||||
return Result.Failure(ShowErrors.NotFound);
|
||||
|
||||
// Позиции коллекций уходят каскадом БД, позиции групп — вручную: ссылка группы полиморфна.
|
||||
await groupCleaner.RemoveElementAsync(
|
||||
GroupElementKind.Show,
|
||||
command.ShowId,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
// TODO(этап 2+): запретить удаление шоу, пока оно привязано к каналу или будущему расписанию.
|
||||
dbContext.Shows.Remove(show);
|
||||
return Result.Success();
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres.CreateGenre;
|
||||
|
||||
/// <summary><paramref name="Aliases"/> — варианты написания для сопоставления с метаданными
|
||||
/// провайдеров; само название и ключ добавляются автоматически.</summary>
|
||||
public sealed record CreateGenreCommand(
|
||||
string Name,
|
||||
string Slug,
|
||||
IReadOnlyList<string>? Aliases = null
|
||||
) : ICommand<Result<Guid>>;
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres.CreateGenre;
|
||||
|
||||
public sealed class CreateGenreCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<CreateGenreCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
CreateGenreCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var slug = GenreAlias.Normalize(command.Slug);
|
||||
if (await dbContext.Genres.AnyAsync(g => g.Slug == slug, cancellationToken))
|
||||
return Result.Failure<Guid>(GenreErrors.SlugTaken);
|
||||
|
||||
var nextSortOrder = await dbContext.Genres.AnyAsync(cancellationToken)
|
||||
? await dbContext.Genres.MaxAsync(g => g.SortOrder, cancellationToken) + 1
|
||||
: 0;
|
||||
|
||||
var genre = Genre.Create(command.Name, slug, nextSortOrder);
|
||||
foreach (var alias in GenreAliasInput.Collect(command.Name, slug, command.Aliases))
|
||||
genre.AddAlias(alias);
|
||||
|
||||
var values = genre.Aliases.Select(a => a.Value).ToList();
|
||||
if (await dbContext.GenreAliases.AnyAsync(a => values.Contains(a.Value), cancellationToken))
|
||||
return Result.Failure<Guid>(GenreErrors.AliasTaken);
|
||||
|
||||
dbContext.Genres.Add(genre);
|
||||
return Result.Success(genre.Id);
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres.CreateGenre;
|
||||
|
||||
public sealed class CreateGenreCommandValidator : AbstractValidator<CreateGenreCommand>
|
||||
{
|
||||
public CreateGenreCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
||||
RuleFor(x => x.Slug).NotEmpty().MaximumLength(64);
|
||||
RuleForEach(x => x.Aliases).NotEmpty().MaximumLength(128);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres.DeleteGenre;
|
||||
|
||||
public sealed record DeleteGenreCommand(Guid GenreId) : ICommand<Result>;
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres.DeleteGenre;
|
||||
|
||||
public sealed class DeleteGenreCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<DeleteGenreCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
DeleteGenreCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var genre = await dbContext.Genres.FirstOrDefaultAsync(
|
||||
g => g.Id == command.GenreId,
|
||||
cancellationToken
|
||||
);
|
||||
if (genre is null)
|
||||
return Result.Failure(GenreErrors.NotFound);
|
||||
|
||||
if (genre.IsSystem)
|
||||
return Result.Failure(GenreErrors.SystemCannotBeDeleted);
|
||||
|
||||
// Связь ShowGenre→Genre настроена как Restrict: без этой проверки удаление упало бы
|
||||
// исключением БД вместо управляемой ошибки.
|
||||
if (await dbContext.ShowGenres.AnyAsync(sg => sg.GenreId == genre.Id, cancellationToken))
|
||||
return Result.Failure(GenreErrors.InUse);
|
||||
|
||||
dbContext.Genres.Remove(genre);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres;
|
||||
|
||||
/// <summary>Сборка набора вариантов написания из пользовательского ввода: к явно заданным всегда
|
||||
/// добавляются само название и ключ жанра — по ним провайдеры отдают жанр чаще всего.</summary>
|
||||
public static class GenreAliasInput
|
||||
{
|
||||
public static IReadOnlyList<string> Collect(
|
||||
string name,
|
||||
string slug,
|
||||
IReadOnlyList<string>? aliases
|
||||
) =>
|
||||
(aliases ?? [])
|
||||
.Append(name)
|
||||
.Append(slug)
|
||||
.Select(GenreAlias.Normalize)
|
||||
.Where(value => value.Length > 0)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TeleWave.Application.Library.Genres;
|
||||
|
||||
/// <summary>Жанр справочника для админки. <paramref name="ShowCount"/> — сколько шоу его используют
|
||||
/// (нужен, чтобы админ видел, что удаление заблокировано, ещё до попытки удалить).</summary>
|
||||
public sealed record GenreDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Slug,
|
||||
int SortOrder,
|
||||
bool IsSystem,
|
||||
IReadOnlyList<string> Aliases,
|
||||
int ShowCount
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres;
|
||||
|
||||
public static class GenreErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound("Genres.NotFound", "Жанр не найден.");
|
||||
|
||||
public static readonly Error SlugTaken = Error.Conflict(
|
||||
"Genres.SlugTaken",
|
||||
"Жанр с таким ключом уже существует."
|
||||
);
|
||||
|
||||
public static readonly Error AliasTaken = Error.Conflict(
|
||||
"Genres.AliasTaken",
|
||||
"Один из вариантов написания уже закреплён за другим жанром."
|
||||
);
|
||||
|
||||
public static readonly Error InUse = Error.Conflict(
|
||||
"Genres.InUse",
|
||||
"Жанр проставлен у шоу — сначала снимите его."
|
||||
);
|
||||
|
||||
public static readonly Error SystemCannotBeDeleted = Error.Conflict(
|
||||
"Genres.SystemCannotBeDeleted",
|
||||
"Системный жанр нельзя удалить."
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres;
|
||||
|
||||
/// <summary>
|
||||
/// Сопоставляет сырые обозначения жанров от внешних источников со справочником. Порядок исходного
|
||||
/// списка сохраняется: первый распознанный жанр становится основным у шоу, а провайдеры отдают
|
||||
/// жанры по убыванию значимости.
|
||||
///
|
||||
/// Нераспознанные обозначения молча отбрасываются: у шоу останутся те жанры, которые справочник
|
||||
/// знает, а расширить справочник — задача администратора (см. псевдонимы жанра).
|
||||
/// </summary>
|
||||
public sealed class GenreMatcher(IAppDbContext dbContext)
|
||||
{
|
||||
public async Task<IReadOnlyList<Guid>> MatchAsync(
|
||||
IEnumerable<string>? rawGenres,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var normalized = (rawGenres ?? [])
|
||||
.Select(GenreAlias.Normalize)
|
||||
.Where(value => value.Length > 0)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count == 0)
|
||||
return [];
|
||||
|
||||
var byValue = await dbContext
|
||||
.GenreAliases.AsNoTracking()
|
||||
.Where(a => normalized.Contains(a.Value))
|
||||
.ToDictionaryAsync(a => a.Value, a => a.GenreId, cancellationToken);
|
||||
|
||||
var result = new List<Guid>();
|
||||
foreach (var value in normalized)
|
||||
{
|
||||
// Один жанр приходит несколькими обозначениями (идентификатор + название) — берём первое
|
||||
// распознанное, дубликаты пропускаем.
|
||||
if (byValue.TryGetValue(value, out var genreId) && !result.Contains(genreId))
|
||||
result.Add(genreId);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using LiteCqrs;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres.ListGenres;
|
||||
|
||||
public sealed record ListGenresQuery : IQuery<IReadOnlyList<GenreDto>>;
|
||||
@@ -0,0 +1,40 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres.ListGenres;
|
||||
|
||||
public sealed class ListGenresQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListGenresQuery, IReadOnlyList<GenreDto>>
|
||||
{
|
||||
public async Task<IReadOnlyList<GenreDto>> Handle(
|
||||
ListGenresQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var genres = await dbContext
|
||||
.Genres.AsNoTracking()
|
||||
.Include(g => g.Aliases)
|
||||
.OrderBy(g => g.SortOrder)
|
||||
.ThenBy(g => g.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var usage = await dbContext
|
||||
.ShowGenres.AsNoTracking()
|
||||
.GroupBy(sg => sg.GenreId)
|
||||
.Select(g => new { GenreId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.GenreId, x => x.Count, cancellationToken);
|
||||
|
||||
return genres
|
||||
.Select(g => new GenreDto(
|
||||
g.Id,
|
||||
g.Name,
|
||||
g.Slug,
|
||||
g.SortOrder,
|
||||
g.IsSystem,
|
||||
g.Aliases.Select(a => a.Value).OrderBy(v => v, StringComparer.Ordinal).ToList(),
|
||||
usage.TryGetValue(g.Id, out var count) ? count : 0
|
||||
))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres.UpdateGenre;
|
||||
|
||||
public sealed record UpdateGenreCommand(
|
||||
Guid GenreId,
|
||||
string Name,
|
||||
int SortOrder,
|
||||
IReadOnlyList<string>? Aliases = null
|
||||
) : ICommand<Result>;
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres.UpdateGenre;
|
||||
|
||||
public sealed class UpdateGenreCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdateGenreCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
UpdateGenreCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var genre = await dbContext
|
||||
.Genres.Include(g => g.Aliases)
|
||||
.FirstOrDefaultAsync(g => g.Id == command.GenreId, cancellationToken);
|
||||
if (genre is null)
|
||||
return Result.Failure(GenreErrors.NotFound);
|
||||
|
||||
var aliases = GenreAliasInput.Collect(command.Name, genre.Slug, command.Aliases);
|
||||
|
||||
// Псевдоним уникален по всему справочнику — проверяем, не занят ли он другим жанром.
|
||||
var taken = await dbContext
|
||||
.GenreAliases.Where(a => aliases.Contains(a.Value) && a.GenreId != genre.Id)
|
||||
.AnyAsync(cancellationToken);
|
||||
if (taken)
|
||||
return Result.Failure(GenreErrors.AliasTaken);
|
||||
|
||||
genre.Rename(command.Name);
|
||||
genre.SetSortOrder(command.SortOrder);
|
||||
genre.ReplaceAliases(aliases);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Library.Genres.UpdateGenre;
|
||||
|
||||
public sealed class UpdateGenreCommandValidator : AbstractValidator<UpdateGenreCommand>
|
||||
{
|
||||
public UpdateGenreCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
||||
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
|
||||
RuleForEach(x => x.Aliases).NotEmpty().MaximumLength(128);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Library.Collections;
|
||||
|
||||
namespace TeleWave.Application.Library.GetShow;
|
||||
|
||||
@@ -16,10 +17,28 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
|
||||
var show = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Include(s => s.Episodes)
|
||||
.Include(s => s.Genres)
|
||||
.FirstOrDefaultAsync(s => s.Id == query.Id, cancellationToken);
|
||||
if (show is null)
|
||||
return Result.Failure<ShowDto>(ShowErrors.NotFound);
|
||||
|
||||
var genreIds = show.Genres.Select(g => g.GenreId).ToList();
|
||||
var genreNames = await dbContext
|
||||
.Genres.AsNoTracking()
|
||||
.Where(g => genreIds.Contains(g.Id))
|
||||
.Select(g => new { g.Id, g.Name, g.SortOrder })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var genreDtos = genreNames
|
||||
.OrderByDescending(g => show.Genres.First(sg => sg.GenreId == g.Id).IsPrimary)
|
||||
.ThenBy(g => g.SortOrder)
|
||||
.Select(g => new ShowGenreDto(
|
||||
g.Id,
|
||||
g.Name,
|
||||
show.Genres.First(sg => sg.GenreId == g.Id).IsPrimary
|
||||
))
|
||||
.ToList();
|
||||
|
||||
var episodes = show.Episodes.OrderBy(e => e.Position).ToList();
|
||||
var assetIds = episodes.Select(e => e.MediaAssetId).ToList();
|
||||
var assets = await dbContext
|
||||
@@ -55,6 +74,19 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var collections = await dbContext
|
||||
.CollectionItems.AsNoTracking()
|
||||
.Where(i => i.ShowId == show.Id)
|
||||
.Join(
|
||||
dbContext.Collections.AsNoTracking(),
|
||||
item => item.CollectionId,
|
||||
collection => collection.Id,
|
||||
(item, collection) =>
|
||||
new ShowCollectionRefDto(collection.Id, collection.Name, item.Position)
|
||||
)
|
||||
.OrderBy(c => c.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Success(
|
||||
new ShowDto(
|
||||
show.Id,
|
||||
@@ -67,7 +99,9 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
|
||||
show.MetadataExternalId,
|
||||
show.Year,
|
||||
show.PosterImageId,
|
||||
episodeDtos
|
||||
episodeDtos,
|
||||
genreDtos,
|
||||
collections
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,4 +2,10 @@ using LiteCqrs;
|
||||
|
||||
namespace TeleWave.Application.Library.ListShows;
|
||||
|
||||
public sealed record ListShowsQuery : IQuery<IReadOnlyList<ShowSummaryDto>>;
|
||||
/// <summary>
|
||||
/// <paramref name="GenreId"/> — оставить только шоу с этим жанром (основным или нет).
|
||||
/// <paramref name="Interstitials"/> — вернуть ролики-врезки вместо контента: у них своя страница,
|
||||
/// и в общей библиотеке они только мешали бы.
|
||||
/// </summary>
|
||||
public sealed record ListShowsQuery(Guid? GenreId = null, bool Interstitials = false)
|
||||
: IQuery<IReadOnlyList<ShowSummaryDto>>;
|
||||
|
||||
@@ -1,59 +1,85 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Application.Library.ListShows;
|
||||
|
||||
public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListShowsQuery, IReadOnlyList<ShowSummaryDto>>
|
||||
{
|
||||
public async Task<IReadOnlyList<ShowSummaryDto>> Handle(
|
||||
ListShowsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var shows = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Include(s => s.Episodes)
|
||||
.OrderBy(s => s.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Имена ассетов нужны, чтобы распознать сезоны (номера в модели не хранятся).
|
||||
var assetIds = shows
|
||||
.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var names = await dbContext
|
||||
.MediaAssets.AsNoTracking()
|
||||
.Where(a => assetIds.Contains(a.Id))
|
||||
.Select(a => new { a.Id, a.OriginalFileName })
|
||||
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
|
||||
|
||||
return shows
|
||||
.Select(s =>
|
||||
{
|
||||
var seasons = s
|
||||
.Episodes.Select(e =>
|
||||
names.TryGetValue(e.MediaAssetId, out var n)
|
||||
? EpisodeName.ParseSeason(n)
|
||||
: null
|
||||
)
|
||||
.Where(season => season is not null)
|
||||
.Distinct()
|
||||
.Count();
|
||||
return new ShowSummaryDto(
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.OriginalName,
|
||||
s.Kind,
|
||||
s.Audience,
|
||||
s.Episodes.Count,
|
||||
seasons,
|
||||
s.Year,
|
||||
s.PosterImageId is not null,
|
||||
s.CreatedAt
|
||||
);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Library.ListShows;
|
||||
|
||||
public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<ListShowsQuery, IReadOnlyList<ShowSummaryDto>>
|
||||
{
|
||||
public async Task<IReadOnlyList<ShowSummaryDto>> Handle(
|
||||
ListShowsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var source = dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Include(s => s.Episodes)
|
||||
.Include(s => s.Genres)
|
||||
.Where(s =>
|
||||
query.Interstitials
|
||||
? s.Kind == ShowKind.Interstitial
|
||||
: s.Kind != ShowKind.Interstitial
|
||||
);
|
||||
|
||||
var filtered = query.GenreId is { } genreId
|
||||
? source.Where(s => s.Genres.Any(g => g.GenreId == genreId))
|
||||
: source;
|
||||
|
||||
var shows = await filtered.OrderBy(s => s.Name).ToListAsync(cancellationToken);
|
||||
|
||||
// Названия только для основных жанров — в списке показывается один.
|
||||
var primaryIds = shows
|
||||
.Select(s => s.PrimaryGenreId)
|
||||
.Where(id => id is not null)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var genreNames = await dbContext
|
||||
.Genres.AsNoTracking()
|
||||
.Where(g => primaryIds.Contains(g.Id))
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
|
||||
|
||||
// Имена ассетов нужны, чтобы распознать сезоны (номера в модели не хранятся).
|
||||
var assetIds = shows
|
||||
.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var names = await dbContext
|
||||
.MediaAssets.AsNoTracking()
|
||||
.Where(a => assetIds.Contains(a.Id))
|
||||
.Select(a => new { a.Id, a.OriginalFileName })
|
||||
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
|
||||
|
||||
return shows
|
||||
.Select(s =>
|
||||
{
|
||||
var seasons = s
|
||||
.Episodes.Select(e =>
|
||||
names.TryGetValue(e.MediaAssetId, out var n)
|
||||
? EpisodeName.ParseSeason(n)
|
||||
: null
|
||||
)
|
||||
.Where(season => season is not null)
|
||||
.Distinct()
|
||||
.Count();
|
||||
return new ShowSummaryDto(
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.OriginalName,
|
||||
s.Kind,
|
||||
s.Audience,
|
||||
s.Episodes.Count,
|
||||
seasons,
|
||||
s.Year,
|
||||
s.PosterImageId is not null,
|
||||
s.CreatedAt,
|
||||
s.PrimaryGenreId is { } primaryId && genreNames.TryGetValue(primaryId, out var g)
|
||||
? g
|
||||
: null
|
||||
);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.SetShowGenres;
|
||||
|
||||
/// <summary>Полностью заменяет набор жанров шоу. <paramref name="PrimaryGenreId"/> — какой считать
|
||||
/// основным; если он не входит в набор, основным станет первый из списка.</summary>
|
||||
public sealed record SetShowGenresCommand(
|
||||
Guid ShowId,
|
||||
IReadOnlyList<Guid> GenreIds,
|
||||
Guid? PrimaryGenreId = null
|
||||
) : ICommand<Result>;
|
||||
@@ -0,0 +1,37 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Library.Genres;
|
||||
|
||||
namespace TeleWave.Application.Library.SetShowGenres;
|
||||
|
||||
public sealed class SetShowGenresCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<SetShowGenresCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
SetShowGenresCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var show = await dbContext
|
||||
.Shows.Include(s => s.Genres)
|
||||
.FirstOrDefaultAsync(s => s.Id == command.ShowId, cancellationToken);
|
||||
if (show is null)
|
||||
return Result.Failure(ShowErrors.NotFound);
|
||||
|
||||
var ids = command.GenreIds.Distinct().ToList();
|
||||
if (ids.Count > 0)
|
||||
{
|
||||
// Ссылка на несуществующий жанр упала бы нарушением внешнего ключа — проверяем заранее.
|
||||
var known = await dbContext
|
||||
.Genres.Where(g => ids.Contains(g.Id))
|
||||
.CountAsync(cancellationToken);
|
||||
if (known != ids.Count)
|
||||
return Result.Failure(GenreErrors.NotFound);
|
||||
}
|
||||
|
||||
show.SetGenres(ids, command.PrimaryGenreId);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using TeleWave.Application.Library.Collections;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
@@ -13,9 +14,14 @@ public sealed record ShowSummaryDto(
|
||||
int SeasonCount,
|
||||
int? Year,
|
||||
bool HasPoster,
|
||||
DateTimeOffset CreatedAt
|
||||
DateTimeOffset CreatedAt,
|
||||
/// <summary>Название основного жанра — в списке показываем только его, остальные видны в карточке шоу.</summary>
|
||||
string? PrimaryGenre = null
|
||||
);
|
||||
|
||||
/// <summary>Жанр, проставленный шоу.</summary>
|
||||
public sealed record ShowGenreDto(Guid Id, string Name, bool IsPrimary);
|
||||
|
||||
public sealed record EpisodeDto(
|
||||
Guid Id,
|
||||
Guid MediaAssetId,
|
||||
@@ -42,5 +48,8 @@ public sealed record ShowDto(
|
||||
string? MetadataExternalId,
|
||||
int? Year,
|
||||
Guid? PosterImageId,
|
||||
IReadOnlyList<EpisodeDto> Episodes
|
||||
IReadOnlyList<EpisodeDto> Episodes,
|
||||
IReadOnlyList<ShowGenreDto> Genres,
|
||||
/// <summary>Коллекции (франшизы), в которые входит шоу, с его позицией в каждой.</summary>
|
||||
IReadOnlyList<ShowCollectionRefDto> Collections
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user