Implement collection suggestions and bulk show addition features
Added new API endpoints for suggesting collections based on existing shows and for bulk adding shows to collections. Enhanced the backend with necessary logic and DTOs to support these features. Updated the frontend to include new components for displaying collection suggestions and managing bulk additions, improving the user experience for collection management. Localization updates were made to support these new features in both English and Russian.
This commit is contained in:
@@ -2,6 +2,7 @@ using LiteCqrs;
|
||||
using TeleWave.Api.Common;
|
||||
using TeleWave.Application.Library.Collections;
|
||||
using TeleWave.Application.Library.Collections.AddCollectionShow;
|
||||
using TeleWave.Application.Library.Collections.AddShowsToCollection;
|
||||
using TeleWave.Application.Library.Collections.CreateCollection;
|
||||
using TeleWave.Application.Library.Collections.DeleteCollection;
|
||||
using TeleWave.Application.Library.Collections.GetCollection;
|
||||
@@ -9,6 +10,7 @@ using TeleWave.Application.Library.Collections.ListCollections;
|
||||
using TeleWave.Application.Library.Collections.RemoveCollectionShow;
|
||||
using TeleWave.Application.Library.Collections.ReorderCollection;
|
||||
using TeleWave.Application.Library.Collections.SetCollectionPoster;
|
||||
using TeleWave.Application.Library.Collections.Suggest;
|
||||
using TeleWave.Application.Library.Collections.UpdateCollection;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
|
||||
@@ -36,9 +38,62 @@ public static class CollectionEndpoints
|
||||
admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPut("/{id:guid}/poster-image", SetPoster).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
// Сборка франшиз: массовое добавление отмеченных шоу и предложения по библиотеке.
|
||||
admin.MapPost("/bulk/shows", AddShows).Produces<AddShowsToCollectionResultDto>();
|
||||
admin
|
||||
.MapGet("/suggestions", Suggestions)
|
||||
.Produces<IReadOnlyList<CollectionSuggestionDto>>();
|
||||
admin
|
||||
.MapPost("/suggestions", CreateFromSuggestion)
|
||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddShows(
|
||||
AddShowsToCollectionBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new AddShowsToCollectionCommand(
|
||||
body.ShowIds,
|
||||
body.CollectionId,
|
||||
body.NewCollectionName
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Suggestions(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new SuggestCollectionsQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateFromSuggestion(
|
||||
CreateCollectionFromSuggestionBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new CreateCollectionFromSuggestionCommand(body.Key),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess
|
||||
? Results.Created(
|
||||
$"/api/admin/collections/{result.Value}",
|
||||
new CreatedIdResponse(result.Value)
|
||||
)
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListCollections(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
@@ -161,3 +216,12 @@ public sealed record CollectionShowBody(Guid ShowId);
|
||||
public sealed record ReorderCollectionBody(IReadOnlyList<Guid> ShowIdsInOrder);
|
||||
|
||||
public sealed record CollectionPosterBody(Guid? ImageId);
|
||||
|
||||
/// <summary>Куда складывать: существующая коллекция либо новая по имени.</summary>
|
||||
public sealed record AddShowsToCollectionBody(
|
||||
IReadOnlyList<Guid> ShowIds,
|
||||
Guid? CollectionId = null,
|
||||
string? NewCollectionName = null
|
||||
);
|
||||
|
||||
public sealed record CreateCollectionFromSuggestionBody(string Key);
|
||||
|
||||
@@ -6,6 +6,7 @@ using TeleWave.Application.Library.BulkTagShows;
|
||||
using TeleWave.Application.Library.CreateShow;
|
||||
using TeleWave.Application.Library.DeleteShow;
|
||||
using TeleWave.Application.Library.GetShow;
|
||||
using TeleWave.Application.Library.GetShowUsage;
|
||||
using TeleWave.Application.Library.ListShows;
|
||||
using TeleWave.Application.Library.RemoveEpisode;
|
||||
using TeleWave.Application.Library.RenameShow;
|
||||
@@ -35,6 +36,7 @@ public static class ShowEndpoints
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapGet("/{id:guid}/usage", GetUsage).Produces<ShowUsageDto>();
|
||||
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
// Массовые операции над отмеченными шоу: разметка руками и обогащение из источника.
|
||||
@@ -179,6 +181,16 @@ public static class ShowEndpoints
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetUsage(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new GetShowUsageQuery(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> BulkTag(
|
||||
BulkTagShowsBody body,
|
||||
ISender sender,
|
||||
|
||||
@@ -5,6 +5,7 @@ using LiteCqrs.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Common.Behaviors;
|
||||
using TeleWave.Application.Library.Collections.Suggest;
|
||||
using TeleWave.Application.Library.Genres;
|
||||
using TeleWave.Application.Metadata;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
@@ -38,6 +39,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<BumperSpecLoader>();
|
||||
services.AddScoped<GenreMatcher>();
|
||||
services.AddScoped<ShowMetadataApplier>();
|
||||
services.AddScoped<CollectionSuggestionBuilder>();
|
||||
services.AddScoped<GroupElementResolver>();
|
||||
services.AddScoped<GroupFilterMatcher>();
|
||||
services.AddScoped<DynamicGroupResolver>();
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.AddShowsToCollection;
|
||||
|
||||
/// <summary>
|
||||
/// Отправляет несколько шоу во франшизу разом. Коллекция либо уже есть
|
||||
/// (<paramref name="CollectionId"/>), либо заводится по имени
|
||||
/// (<paramref name="NewCollectionName"/>) — иначе на сборку франшизы уходило бы два экрана.
|
||||
///
|
||||
/// Порядок добавления — по году выпуска: сиквелы почти всегда идут так, а исключения правятся
|
||||
/// перетаскиванием в карточке коллекции.
|
||||
/// </summary>
|
||||
public sealed record AddShowsToCollectionCommand(
|
||||
IReadOnlyList<Guid> ShowIds,
|
||||
Guid? CollectionId = null,
|
||||
string? NewCollectionName = null
|
||||
) : ICommand<Result<AddShowsToCollectionResultDto>>;
|
||||
|
||||
/// <param name="CollectionId">Куда в итоге сложили — новая коллекция или переданная.</param>
|
||||
/// <param name="Added">Сколько шоу реально добавлено (уже входящие пропускаются).</param>
|
||||
public sealed record AddShowsToCollectionResultDto(Guid CollectionId, string Name, int Added);
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.AddShowsToCollection;
|
||||
|
||||
public sealed class AddShowsToCollectionCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<AddShowsToCollectionCommand, Result<AddShowsToCollectionResultDto>>
|
||||
{
|
||||
public async Task<Result<AddShowsToCollectionResultDto>> Handle(
|
||||
AddShowsToCollectionCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var showIds = command.ShowIds.Distinct().ToList();
|
||||
var shows = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.Id))
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.Year,
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
if (shows.Count == 0)
|
||||
return Result.Failure<AddShowsToCollectionResultDto>(ShowErrors.NotFound);
|
||||
|
||||
var collection = await ResolveCollectionAsync(command, cancellationToken);
|
||||
if (collection is null)
|
||||
return Result.Failure<AddShowsToCollectionResultDto>(CollectionErrors.NotFound);
|
||||
|
||||
// Год — главный порядок франшизы; без года часть уходит в конец, там её и переставят руками.
|
||||
var ordered = shows
|
||||
.OrderBy(s => s.Year is null)
|
||||
.ThenBy(s => s.Year)
|
||||
.ThenBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase);
|
||||
|
||||
var added = ordered.Count(show => collection.AddShow(show.Id) is not null);
|
||||
|
||||
return Result.Success(
|
||||
new AddShowsToCollectionResultDto(collection.Id, collection.Name, added)
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<Collection?> ResolveCollectionAsync(
|
||||
AddShowsToCollectionCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (command.CollectionId is { } id)
|
||||
return await dbContext
|
||||
.Collections.Include(c => c.Items)
|
||||
.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
|
||||
|
||||
var created = Collection.Create(command.NewCollectionName!);
|
||||
dbContext.Collections.Add(created);
|
||||
return created;
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.AddShowsToCollection;
|
||||
|
||||
public sealed class AddShowsToCollectionCommandValidator
|
||||
: AbstractValidator<AddShowsToCollectionCommand>
|
||||
{
|
||||
public AddShowsToCollectionCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ShowIds).NotEmpty();
|
||||
// Либо кладём в существующую, либо заводим новую — «ни то ни другое» указывает на ошибку UI.
|
||||
RuleFor(x => x)
|
||||
.Must(x =>
|
||||
x.CollectionId is not null || !string.IsNullOrWhiteSpace(x.NewCollectionName)
|
||||
)
|
||||
.WithMessage("Не указано, в какую коллекцию складывать.");
|
||||
RuleFor(x => x.NewCollectionName).MaximumLength(256);
|
||||
}
|
||||
}
|
||||
@@ -18,4 +18,14 @@ public static class CollectionErrors
|
||||
"Collections.ShowNotInCollection",
|
||||
"Шоу не входит в коллекцию."
|
||||
);
|
||||
|
||||
public static readonly Error SuggestionNotFound = Error.NotFound(
|
||||
"Collections.SuggestionNotFound",
|
||||
"Предложение устарело — библиотека изменилась. Обновите список предложений."
|
||||
);
|
||||
|
||||
public static readonly Error SuggestionAlreadyCreated = Error.Conflict(
|
||||
"Collections.SuggestionAlreadyCreated",
|
||||
"Коллекция с таким названием уже есть."
|
||||
);
|
||||
}
|
||||
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.Suggest;
|
||||
|
||||
/// <summary>Предложение франшизы: имя и части в предлагаемом порядке.</summary>
|
||||
public sealed record CollectionSuggestion(
|
||||
string Key,
|
||||
CollectionSuggestionSource Source,
|
||||
string Name,
|
||||
IReadOnlyList<CollectionSuggestionPartDto> Parts
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Ищет, что стоит собрать в коллекцию, среди полнометражек, ещё не входящих ни в одну.
|
||||
///
|
||||
/// Два источника, и они не равны по надёжности. Франшиза из метаданных — это факт: TMDb сам считает
|
||||
/// эти фильмы одним целым. Догадка по названиям — эвристика для библиотеки без метаданных, и она
|
||||
/// намеренно осторожна: совпадать должно осмысленное начало названия, а не первое слово, иначе
|
||||
/// «Человек-паук» и «Человек дождя» уедут в одну франшизу.
|
||||
///
|
||||
/// Решение всё равно за человеком: предложение показывает состав целиком, а создаётся коллекция
|
||||
/// кнопкой. Сериалы сюда не попадают — у них своя форма продолжения, сезоны.
|
||||
/// </summary>
|
||||
public sealed class CollectionSuggestionBuilder(IAppDbContext dbContext)
|
||||
{
|
||||
/// <summary>Меньше двух частей — не франшиза.</summary>
|
||||
private const int MinParts = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Со скольких общих слов начало названия само по себе считается признаком франшизы:
|
||||
/// «Harry Potter and the …» случайным совпадением не бывает, а «Человек …» — бывает.
|
||||
/// </summary>
|
||||
private const int StrongCommonWords = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Чем продолжение отличается от однофамильца: номер («Рокки 2», «Рокки III»), слово «часть»
|
||||
/// либо подзаголовок через двоеточие или тире («Матрица: Перезагрузка»). У «Человек дождя»
|
||||
/// и «Человек-слон» хвосты — обычные слова, и в одну франшизу они не попадут.
|
||||
/// </summary>
|
||||
private static readonly Regex SequelTail = new(
|
||||
@"^(?:\d{1,2}\b|[IVXivx]{1,4}\b|part\b|часть\b|[:–—-]\s*\S)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
|
||||
TimeSpan.FromSeconds(1)
|
||||
);
|
||||
|
||||
public async Task<IReadOnlyList<CollectionSuggestion>> BuildAsync(
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var inCollections = await dbContext
|
||||
.CollectionItems.AsNoTracking()
|
||||
.Select(i => i.ShowId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var movies = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.Where(s => s.Kind == ShowKind.Single && !inCollections.Contains(s.Id))
|
||||
.Select(s => new MovieRow(
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.OriginalName,
|
||||
s.Year,
|
||||
s.FranchiseExternalId,
|
||||
s.FranchiseName
|
||||
))
|
||||
.ToListAsync(cancellationToken);
|
||||
if (movies.Count < MinParts)
|
||||
return [];
|
||||
|
||||
var suggestions = new List<CollectionSuggestion>();
|
||||
var taken = new HashSet<Guid>();
|
||||
|
||||
foreach (var franchise in ByMetadata(movies))
|
||||
{
|
||||
suggestions.Add(franchise);
|
||||
foreach (var part in franchise.Parts)
|
||||
taken.Add(part.ShowId);
|
||||
}
|
||||
|
||||
// По названиям добираем только то, что не разобрала метадата: иначе одни и те же фильмы
|
||||
// попали бы в два предложения сразу и админ выбирал бы между ними вслепую.
|
||||
suggestions.AddRange(ByName(movies.Where(m => !taken.Contains(m.Id)).ToList()));
|
||||
|
||||
return
|
||||
[
|
||||
.. suggestions
|
||||
.OrderBy(s => s.Source)
|
||||
.ThenByDescending(s => s.Parts.Count)
|
||||
.ThenBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase),
|
||||
];
|
||||
}
|
||||
|
||||
private static IEnumerable<CollectionSuggestion> ByMetadata(List<MovieRow> movies)
|
||||
{
|
||||
var groups = movies
|
||||
.Where(m => m.FranchiseExternalId is not null && m.FranchiseName is not null)
|
||||
.GroupBy(m => m.FranchiseExternalId!)
|
||||
.Where(g => g.Count() >= MinParts);
|
||||
|
||||
foreach (var group in groups)
|
||||
yield return new CollectionSuggestion(
|
||||
$"franchise:{group.Key}",
|
||||
CollectionSuggestionSource.Metadata,
|
||||
group.First().FranchiseName!,
|
||||
Parts(group)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Догадка по названиям для библиотеки без метаданных. Сравниваются целые слова, а не символы:
|
||||
/// посимвольное совпадение слишком легко ловит однофамильцев («Человек дождя» и «Человек-слон»).
|
||||
/// Пара считается франшизой, если общее начало длинное либо хвосты обеих частей выглядят
|
||||
/// продолжением. Имя предложения — общее начало, очищенное от хвостовых предлогов и знаков.
|
||||
/// </summary>
|
||||
private static IEnumerable<CollectionSuggestion> ByName(List<MovieRow> movies)
|
||||
{
|
||||
var used = new HashSet<Guid>();
|
||||
|
||||
// От длинных названий к коротким: так более специфичная франшиза собирается первой.
|
||||
foreach (var movie in movies.OrderByDescending(m => Title(m).Length))
|
||||
{
|
||||
if (used.Contains(movie.Id))
|
||||
continue;
|
||||
|
||||
var words = Words(Title(movie));
|
||||
var family = movies
|
||||
.Where(other =>
|
||||
other.Id != movie.Id
|
||||
&& !used.Contains(other.Id)
|
||||
&& SameFranchise(words, Words(Title(other)))
|
||||
)
|
||||
.ToList();
|
||||
if (family.Count == 0)
|
||||
continue;
|
||||
|
||||
var common = family.Aggregate(
|
||||
words,
|
||||
(acc, other) => acc[..CommonWordCount(acc, Words(Title(other)))]
|
||||
);
|
||||
var name = Clean(string.Join(' ', common));
|
||||
if (name.Length < 3)
|
||||
continue;
|
||||
|
||||
family.Add(movie);
|
||||
foreach (var part in family)
|
||||
used.Add(part.Id);
|
||||
|
||||
yield return new CollectionSuggestion(
|
||||
$"name:{name.ToLowerInvariant()}",
|
||||
CollectionSuggestionSource.NameGuess,
|
||||
name,
|
||||
Parts(family)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Части идут по году: у франшизы это и есть порядок просмотра.</summary>
|
||||
private static IReadOnlyList<CollectionSuggestionPartDto> Parts(IEnumerable<MovieRow> movies) =>
|
||||
[
|
||||
.. movies
|
||||
.OrderBy(m => m.Year is null)
|
||||
.ThenBy(m => m.Year)
|
||||
.ThenBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase)
|
||||
.Select(m => new CollectionSuggestionPartDto(m.Id, m.Name, m.Year)),
|
||||
];
|
||||
|
||||
/// <summary>Сравниваем по оригинальному названию, если оно есть: у релизов оно стабильнее.</summary>
|
||||
private static string Title(MovieRow movie) =>
|
||||
string.IsNullOrWhiteSpace(movie.OriginalName) ? movie.Name : movie.OriginalName;
|
||||
|
||||
private static bool SameFranchise(string[] first, string[] second)
|
||||
{
|
||||
var common = CommonWordCount(first, second);
|
||||
if (common == 0)
|
||||
return false;
|
||||
if (common >= StrongCommonWords)
|
||||
return true;
|
||||
return IsSequelTail(first, common) && IsSequelTail(second, common);
|
||||
}
|
||||
|
||||
/// <summary>Хвост после общего начала: пусто — это первая часть, иначе должен выглядеть продолжением.</summary>
|
||||
private static bool IsSequelTail(string[] words, int common)
|
||||
{
|
||||
var tail = string.Join(' ', words.Skip(common)).Trim();
|
||||
return tail.Length == 0 || SequelTail.IsMatch(tail);
|
||||
}
|
||||
|
||||
private static int CommonWordCount(string[] first, string[] second)
|
||||
{
|
||||
var count = 0;
|
||||
while (
|
||||
count < first.Length
|
||||
&& count < second.Length
|
||||
&& string.Equals(first[count], second[count], StringComparison.CurrentCultureIgnoreCase)
|
||||
)
|
||||
count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
private static string[] Words(string title) =>
|
||||
title.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
/// <summary>
|
||||
/// Общее начало обрывается на союзе или знаке: «Harry Potter and the» — это ещё не имя франшизы.
|
||||
/// Чистим, пока есть что убирать: за один проход «and the» не снять.
|
||||
/// </summary>
|
||||
private static string Clean(string prefix)
|
||||
{
|
||||
var trimmed = prefix.Trim();
|
||||
bool changed;
|
||||
do
|
||||
{
|
||||
changed = false;
|
||||
trimmed = trimmed.TrimEnd(':', '-', '–', '—', '.', ',', ' ');
|
||||
foreach (var tail in (string[])[" и", " and", " the", " part", " часть"])
|
||||
{
|
||||
if (!trimmed.EndsWith(tail, StringComparison.CurrentCultureIgnoreCase))
|
||||
continue;
|
||||
trimmed = trimmed[..^tail.Length];
|
||||
changed = true;
|
||||
}
|
||||
} while (changed);
|
||||
|
||||
return trimmed.Trim();
|
||||
}
|
||||
|
||||
private sealed record MovieRow(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? OriginalName,
|
||||
int? Year,
|
||||
string? FranchiseExternalId,
|
||||
string? FranchiseName
|
||||
);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
namespace TeleWave.Application.Library.Collections.Suggest;
|
||||
|
||||
/// <summary>Насколько предложению можно верить — по этому UI подписывает происхождение.</summary>
|
||||
public enum CollectionSuggestionSource
|
||||
{
|
||||
/// <summary>Франшиза пришла из метаданных (TMDb знает её как единое целое).</summary>
|
||||
Metadata = 0,
|
||||
|
||||
/// <summary>Догадка по названиям: общее начало и нумерация. Требует проверки глазами.</summary>
|
||||
NameGuess = 1,
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.Suggest;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт коллекцию по предложению: имя и состав сервер пересобирает по ключу, а не берёт
|
||||
/// с клиента. Между показом и нажатием библиотека могла измениться — во франшизу должно попасть
|
||||
/// актуальное.
|
||||
/// </summary>
|
||||
public sealed record CreateCollectionFromSuggestionCommand(string Key) : ICommand<Result<Guid>>;
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Library;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.Suggest;
|
||||
|
||||
public sealed class CreateCollectionFromSuggestionCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
CollectionSuggestionBuilder builder
|
||||
) : ICommandHandler<CreateCollectionFromSuggestionCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
CreateCollectionFromSuggestionCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var suggestions = await builder.BuildAsync(cancellationToken);
|
||||
var suggestion = suggestions.FirstOrDefault(s => s.Key == command.Key);
|
||||
if (suggestion is null)
|
||||
return Result.Failure<Guid>(CollectionErrors.SuggestionNotFound);
|
||||
|
||||
var taken = await dbContext.Collections.AnyAsync(
|
||||
c => c.Name.ToLower() == suggestion.Name.ToLower(),
|
||||
cancellationToken
|
||||
);
|
||||
if (taken)
|
||||
return Result.Failure<Guid>(CollectionErrors.SuggestionAlreadyCreated);
|
||||
|
||||
var collection = Collection.Create(suggestion.Name);
|
||||
foreach (var part in suggestion.Parts)
|
||||
collection.AddShow(part.ShowId);
|
||||
|
||||
dbContext.Collections.Add(collection);
|
||||
return Result.Success(collection.Id);
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
using LiteCqrs;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.Suggest;
|
||||
|
||||
/// <summary>Какие франшизы имеет смысл собрать из уже загруженных полнометражек.</summary>
|
||||
public sealed record SuggestCollectionsQuery : IQuery<IReadOnlyList<CollectionSuggestionDto>>;
|
||||
|
||||
/// <param name="Key">Ключ предложения — с ним же приходит команда создания.</param>
|
||||
/// <param name="Source">Откуда взялось: франшиза источника либо догадка по названиям.</param>
|
||||
/// <param name="Parts">Части в предлагаемом порядке.</param>
|
||||
/// <param name="AlreadyExists">Коллекция с таким названием уже есть — создавать нечего.</param>
|
||||
public sealed record CollectionSuggestionDto(
|
||||
string Key,
|
||||
CollectionSuggestionSource Source,
|
||||
string Name,
|
||||
IReadOnlyList<CollectionSuggestionPartDto> Parts,
|
||||
bool AlreadyExists
|
||||
);
|
||||
|
||||
public sealed record CollectionSuggestionPartDto(Guid ShowId, string Name, int? Year);
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
|
||||
namespace TeleWave.Application.Library.Collections.Suggest;
|
||||
|
||||
public sealed class SuggestCollectionsQueryHandler(
|
||||
IAppDbContext dbContext,
|
||||
CollectionSuggestionBuilder builder
|
||||
) : IQueryHandler<SuggestCollectionsQuery, IReadOnlyList<CollectionSuggestionDto>>
|
||||
{
|
||||
public async Task<IReadOnlyList<CollectionSuggestionDto>> Handle(
|
||||
SuggestCollectionsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var suggestions = await builder.BuildAsync(cancellationToken);
|
||||
if (suggestions.Count == 0)
|
||||
return [];
|
||||
|
||||
var names = await dbContext
|
||||
.Collections.AsNoTracking()
|
||||
.Select(c => c.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
var existing = names
|
||||
.Select(n => n.Trim())
|
||||
.ToHashSet(StringComparer.CurrentCultureIgnoreCase);
|
||||
|
||||
return
|
||||
[
|
||||
.. suggestions.Select(s => new CollectionSuggestionDto(
|
||||
s.Key,
|
||||
s.Source,
|
||||
s.Name,
|
||||
s.Parts,
|
||||
existing.Contains(s.Name)
|
||||
)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Library.GetShowUsage;
|
||||
|
||||
/// <summary>
|
||||
/// Где шоу задействовано: группы, коллекции и каналы. Нужно до удаления — сейчас оно просто
|
||||
/// отбивается ошибкой «используется», не говоря где, и приходится обходить экраны вручную.
|
||||
/// </summary>
|
||||
public sealed record GetShowUsageQuery(Guid ShowId) : IQuery<Result<ShowUsageDto>>;
|
||||
|
||||
/// <param name="Groups">Группы, в состав которых шоу входит (в том числе по правилу).</param>
|
||||
/// <param name="Collections">Франшизы, частью которых оно является.</param>
|
||||
/// <param name="Channels">Каналы, в чьём расписании оно уже стоит.</param>
|
||||
public sealed record ShowUsageDto(
|
||||
IReadOnlyList<ShowUsageRefDto> Groups,
|
||||
IReadOnlyList<ShowUsageRefDto> Collections,
|
||||
IReadOnlyList<ShowUsageRefDto> Channels
|
||||
);
|
||||
|
||||
/// <param name="ViaRule">Шоу попало в группу правилом, а не явной позицией.</param>
|
||||
public sealed record ShowUsageRefDto(Guid Id, string Name, bool ViaRule = false);
|
||||
@@ -0,0 +1,90 @@
|
||||
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.GetShowUsage;
|
||||
|
||||
public sealed class GetShowUsageQueryHandler(
|
||||
IAppDbContext dbContext,
|
||||
DynamicGroupResolver dynamicResolver
|
||||
) : IQueryHandler<GetShowUsageQuery, Result<ShowUsageDto>>
|
||||
{
|
||||
public async Task<Result<ShowUsageDto>> Handle(
|
||||
GetShowUsageQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (!await dbContext.Shows.AnyAsync(s => s.Id == query.ShowId, cancellationToken))
|
||||
return Result.Failure<ShowUsageDto>(ShowErrors.NotFound);
|
||||
|
||||
var collections = await dbContext
|
||||
.Collections.AsNoTracking()
|
||||
.Where(c => c.Items.Any(i => i.ShowId == query.ShowId))
|
||||
.Select(c => new ShowUsageRefDto(c.Id, c.Name, false))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var channels = await dbContext
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.Where(e => e.ShowId == query.ShowId)
|
||||
.Select(e => e.ChannelId)
|
||||
.Distinct()
|
||||
.Join(
|
||||
dbContext.Channels.AsNoTracking(),
|
||||
id => id,
|
||||
channel => channel.Id,
|
||||
(_, channel) => new ShowUsageRefDto(channel.Id, channel.Name, false)
|
||||
)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Success(
|
||||
new ShowUsageDto(
|
||||
await GroupsAsync(query.ShowId, collections, cancellationToken),
|
||||
collections,
|
||||
channels
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Группы считаем по вычисленному составу: у динамической шоу может входить правилом, без своей
|
||||
/// позиции, и такое участие для «можно ли удалять» ничем не отличается от явного. Коллекции
|
||||
/// учитываются заодно — шоу попадает в группу и через франшизу, целиком.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<ShowUsageRefDto>> GroupsAsync(
|
||||
Guid showId,
|
||||
IReadOnlyList<ShowUsageRefDto> collections,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var groups = await dbContext
|
||||
.Groups.AsNoTracking()
|
||||
.Include(g => g.Items)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var collectionIds = collections.Select(c => c.Id).ToHashSet();
|
||||
var result = new List<ShowUsageRefDto>();
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var composition = await dynamicResolver.ResolveAsync(group, cancellationToken);
|
||||
var element = composition.FirstOrDefault(e =>
|
||||
(e.Kind == GroupElementKind.Show && e.Id == showId)
|
||||
|| (e.Kind == GroupElementKind.Collection && collectionIds.Contains(e.Id))
|
||||
);
|
||||
if (element is null)
|
||||
continue;
|
||||
|
||||
result.Add(
|
||||
new ShowUsageRefDto(group.Id, group.Name, !element.Pinned && HasRule(group))
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool HasRule(Domain.Programming.Group group) =>
|
||||
group.Mode == GroupMode.Dynamic && group.FilterJson is not null;
|
||||
}
|
||||
@@ -32,7 +32,9 @@ public sealed record ShowMetadata(
|
||||
string? Overview,
|
||||
string? PosterUrl,
|
||||
IReadOnlyList<string>? Genres = null,
|
||||
string? ContentRating = null
|
||||
string? ContentRating = null,
|
||||
/// <summary>Франшиза по данным источника (у OMDb таких данных нет — null).</summary>
|
||||
MetadataFranchise? Franchise = null
|
||||
);
|
||||
|
||||
/// <summary>Метаданные серии (для этапа 2).</summary>
|
||||
|
||||
@@ -43,7 +43,14 @@ public sealed class ShowMetadataApplier(
|
||||
|
||||
var posterImageId = await SavePosterAsync(show, meta.PosterUrl, cancellationToken);
|
||||
|
||||
show.ApplyMetadata(providerKey, meta.ExternalId, meta.Overview, meta.Year, posterImageId);
|
||||
show.ApplyMetadata(
|
||||
providerKey,
|
||||
meta.ExternalId,
|
||||
meta.Overview,
|
||||
meta.Year,
|
||||
posterImageId,
|
||||
meta.Franchise
|
||||
);
|
||||
|
||||
// Жанры источника перезаписывают текущие, только если хоть один распознан справочником:
|
||||
// иначе применение метаданных к шоу с руками проставленными жанрами обнуляло бы их.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace TeleWave.Domain.Library;
|
||||
|
||||
/// <summary>
|
||||
/// Франшиза шоу по данным источника: «Гарри Поттер», «Терминатор». В домене это не сущность, а
|
||||
/// подсказка от источника — по ней в библиотеке предлагается собрать <see cref="Collection"/>,
|
||||
/// которую дальше правит человек. Само объединение остаётся его решением: у TMDb во франшизу
|
||||
/// попадают и спин-оффы, которые в эфир одним произведением не идут.
|
||||
/// </summary>
|
||||
public sealed record MetadataFranchise(string ExternalId, string Name);
|
||||
@@ -135,16 +135,30 @@ public class Show
|
||||
public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0;
|
||||
|
||||
/// <summary>Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null.</summary>
|
||||
/// <summary>Франшиза шоу по данным источника: её идентификатор и название.</summary>
|
||||
public string? FranchiseExternalId { get; private set; }
|
||||
|
||||
public string? FranchiseName { get; private set; }
|
||||
|
||||
public void ApplyMetadata(
|
||||
string provider,
|
||||
string externalId,
|
||||
string? description,
|
||||
int? year,
|
||||
Guid? posterImageId
|
||||
Guid? posterImageId,
|
||||
MetadataFranchise? franchise = null
|
||||
)
|
||||
{
|
||||
MetadataProvider = provider;
|
||||
MetadataExternalId = externalId;
|
||||
// Франшизу запоминаем, а не забываем сразу после применения: по ней потом предлагаются
|
||||
// коллекции, и перезапрашивать источник ради каждого фильма было бы расточительно.
|
||||
// Отсутствие в ответе — не повод стирать известное: у OMDb этих данных нет вовсе.
|
||||
if (franchise is not null)
|
||||
{
|
||||
FranchiseExternalId = franchise.ExternalId;
|
||||
FranchiseName = franchise.Name;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
Description = description;
|
||||
Year = year;
|
||||
|
||||
@@ -107,10 +107,30 @@ public sealed class TmdbMetadataProvider(
|
||||
GetString(root, "overview"),
|
||||
PosterUrl(GetString(root, "poster_path")),
|
||||
GenresFrom(root),
|
||||
movie ? MovieCertification(root) : TvCertification(root)
|
||||
movie ? MovieCertification(root) : TvCertification(root),
|
||||
FranchiseFrom(root)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Франшиза фильма: TMDb отдаёт её прямо в ответе по фильму (<c>belongs_to_collection</c>),
|
||||
/// отдельного запроса не нужно. У сериалов этого поля нет.
|
||||
/// </summary>
|
||||
private static MetadataFranchise? FranchiseFrom(JsonElement root)
|
||||
{
|
||||
if (
|
||||
!root.TryGetProperty("belongs_to_collection", out var block)
|
||||
|| block.ValueKind != JsonValueKind.Object
|
||||
)
|
||||
return null;
|
||||
|
||||
var id = GetInt(block, "id");
|
||||
var name = GetString(block, "name");
|
||||
return id is null || string.IsNullOrWhiteSpace(name)
|
||||
? null
|
||||
: new MetadataFranchise(id.Value.ToString(CultureInfo.InvariantCulture), name.Trim());
|
||||
}
|
||||
|
||||
/// <summary>Код прокатного релиза в справочнике типов релизов TMDb.</summary>
|
||||
private const int TheatricalReleaseType = 3;
|
||||
|
||||
|
||||
+1407
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeleWave.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ShowFranchise : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "FranchiseExternalId",
|
||||
table: "Shows",
|
||||
type: "character varying(64)",
|
||||
maxLength: 64,
|
||||
nullable: true
|
||||
);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "FranchiseName",
|
||||
table: "Shows",
|
||||
type: "character varying(256)",
|
||||
maxLength: 256,
|
||||
nullable: true
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(name: "FranchiseExternalId", table: "Shows");
|
||||
|
||||
migrationBuilder.DropColumn(name: "FranchiseName", table: "Shows");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -585,6 +585,14 @@ namespace TeleWave.Infrastructure.Migrations
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("FranchiseExternalId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("FranchiseName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("integer");
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ public class ShowConfiguration : IEntityTypeConfiguration<Show>
|
||||
builder.Property(x => x.MetadataProvider).HasMaxLength(16);
|
||||
builder.Property(x => x.MetadataExternalId).HasMaxLength(64);
|
||||
builder.Property(x => x.OriginalName).HasMaxLength(256);
|
||||
builder.Property(x => x.FranchiseExternalId).HasMaxLength(64);
|
||||
builder.Property(x => x.FranchiseName).HasMaxLength(256);
|
||||
|
||||
builder
|
||||
.HasMany(x => x.Episodes)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Library.Collections.AddShowsToCollection;
|
||||
using TeleWave.Application.Library.Collections.Suggest;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Library;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Library;
|
||||
|
||||
/// <summary>
|
||||
/// Сборка франшиз: предложения по метаданным и по названиям плюс массовое добавление в коллекцию.
|
||||
/// Ошибка здесь означает чужой фильм в середине франшизы, поэтому осторожность важнее полноты.
|
||||
/// </summary>
|
||||
public class CollectionSuggestionsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SuggestsFranchise_FromMetadata()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var first = Movie("Гарри Поттер и философский камень", 2001, franchise: "1241");
|
||||
var second = Movie("Гарри Поттер и тайная комната", 2002, franchise: "1241");
|
||||
var other = Movie("Совсем другое", 1999, franchise: "777");
|
||||
|
||||
await SeedAsync(fixture, first, second, other);
|
||||
|
||||
var suggestions = await BuildAsync(fixture);
|
||||
|
||||
var franchise = Assert.Single(
|
||||
suggestions,
|
||||
s => s.Source == CollectionSuggestionSource.Metadata
|
||||
);
|
||||
Assert.Equal("Франшиза 1241", franchise.Name);
|
||||
// Порядок — по году: это и есть порядок просмотра.
|
||||
Assert.Equal([first.Id, second.Id], franchise.Parts.Select(p => p.ShowId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestsByName_WhenMetadataMissing()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var first = Movie("Рокки", 1976);
|
||||
var second = Movie("Рокки 2", 1979);
|
||||
await SeedAsync(fixture, first, second);
|
||||
|
||||
var suggestions = await BuildAsync(fixture);
|
||||
|
||||
var guess = Assert.Single(
|
||||
suggestions,
|
||||
s => s.Source == CollectionSuggestionSource.NameGuess
|
||||
);
|
||||
Assert.Equal(2, guess.Parts.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DoesNotGuess_OnShortCommonStart()
|
||||
{
|
||||
// «Человек-паук» и «Человек дождя» — не франшиза, хотя начало совпадает.
|
||||
var fixture = new TestDb();
|
||||
await SeedAsync(fixture, Movie("Человек дождя", 1988), Movie("Человек-слон", 1980));
|
||||
|
||||
var suggestions = await BuildAsync(fixture);
|
||||
|
||||
Assert.DoesNotContain(suggestions, s => s.Source == CollectionSuggestionSource.NameGuess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipsShows_AlreadyInCollections()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var first = Movie("Рокки", 1976, franchise: "1241");
|
||||
var second = Movie("Рокки 2", 1979, franchise: "1241");
|
||||
var collection = Collection.Create("Рокки");
|
||||
collection.AddShow(first.Id);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Shows.AddRange(first, second);
|
||||
seed.Collections.Add(collection);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var suggestions = await BuildAsync(fixture);
|
||||
|
||||
// Одна часть уже разобрана — на франшизу оставшегося не хватает.
|
||||
Assert.Empty(suggestions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddShowsToCollection_CreatesNewAndOrdersByYear()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var later = Movie("Вторая", 1979);
|
||||
var earlier = Movie("Первая", 1976);
|
||||
await SeedAsync(fixture, later, earlier);
|
||||
|
||||
await using var db = fixture.New();
|
||||
var result = await new AddShowsToCollectionCommandHandler(db).Handle(
|
||||
new AddShowsToCollectionCommand([later.Id, earlier.Id], NewCollectionName: "Франшиза"),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(result.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await using var check = fixture.New();
|
||||
var collection = check.Collections.Include(c => c.Items).Single();
|
||||
Assert.Equal(2, result.Value.Added);
|
||||
Assert.Equal(
|
||||
[earlier.Id, later.Id],
|
||||
collection.Items.OrderBy(i => i.Position).Select(i => i.ShowId)
|
||||
);
|
||||
}
|
||||
|
||||
private static Show Movie(string name, int? year, string? franchise = null)
|
||||
{
|
||||
var show = Show.Create(name, ShowKind.Single);
|
||||
if (franchise is not null)
|
||||
show.ApplyMetadata(
|
||||
"tmdb",
|
||||
Guid.NewGuid().ToString("N"),
|
||||
null,
|
||||
year,
|
||||
null,
|
||||
new MetadataFranchise(franchise, $"Франшиза {franchise}")
|
||||
);
|
||||
else
|
||||
show.ApplyMetadata("manual", Guid.NewGuid().ToString("N"), null, year, null);
|
||||
return show;
|
||||
}
|
||||
|
||||
private static async Task SeedAsync(TestDb fixture, params Show[] shows)
|
||||
{
|
||||
await using var seed = fixture.New();
|
||||
seed.Shows.AddRange(shows);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<CollectionSuggestion>> BuildAsync(TestDb fixture)
|
||||
{
|
||||
await using var db = fixture.New();
|
||||
return await new CollectionSuggestionBuilder(db).BuildAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user