Implement functionality to add shows to existing collections from suggestions
ci / build-backend (push) Successful in 1m40s
ci / build-frontend (push) Successful in 53s
ci / tests (push) Successful in 1m46s
ci / sonar (push) Successful in 6m47s

Added a new endpoint for adding shows to already assembled franchises based on the same suggestion key. Updated the CollectionSuggestion and CollectionSuggestionDto records to include a CollectionId for tracking existing collections. Enhanced the CollectionSuggestions component in the frontend to support this new functionality, allowing users to add missing parts to existing collections. Updated localization strings to reflect the new addition feature in both English and Russian, ensuring clarity for users. Adjusted tests to verify the correct behavior of the addition process and its integration with existing collection logic.
This commit is contained in:
Leonid Pershin
2026-07-28 14:49:27 +03:00
parent 6c85db2d60
commit ac41111fa0
12 changed files with 413 additions and 39 deletions
@@ -49,6 +49,8 @@ public static class CollectionEndpoints
admin
.MapPost("/suggestions", CreateFromSuggestion)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
// Дополнение уже собранной франшизы: коллекция определяется тем же ключом предложения.
admin.MapPost("/suggestions/add", AddFromSuggestion).Produces<int>();
return app;
}
@@ -110,6 +112,16 @@ public static class CollectionEndpoints
: result.ToHttpResult();
}
private static async Task<IResult> AddFromSuggestion(
CreateCollectionFromSuggestionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new AddSuggestedShowsCommand(body.Key), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ListCollections(
ISender sender,
CancellationToken cancellationToken
@@ -0,0 +1,10 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.Suggest;
/// <summary>
/// Дополняет уже собранную франшизу по предложению: состав сервер пересобирает по ключу, а не берёт
/// с клиента. Между показом и нажатием библиотека могла измениться — добавиться должно актуальное.
/// </summary>
public sealed record AddSuggestedShowsCommand(string Key) : ICommand<Result<int>>;
@@ -0,0 +1,43 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.Suggest;
public sealed class AddSuggestedShowsCommandHandler(
IAppDbContext dbContext,
CollectionSuggestionBuilder builder,
CollectionPosterPicker posters
) : ICommandHandler<AddSuggestedShowsCommand, Result<int>>
{
public async Task<Result<int>> Handle(
AddSuggestedShowsCommand command,
CancellationToken cancellationToken
)
{
var suggestions = await builder.BuildAsync(cancellationToken);
var suggestion = suggestions.FirstOrDefault(s => s.Key == command.Key);
// Ключ создания сюда приходить не должен: у такого предложения нет коллекции, которую
// дополнять. Для админа это одно и то же — список предложений устарел.
if (suggestion?.CollectionId is not { } collectionId)
return Result.Failure<int>(CollectionErrors.SuggestionNotFound);
var collection = await dbContext
.Collections.Include(c => c.Items)
.FirstOrDefaultAsync(c => c.Id == collectionId, cancellationToken);
if (collection is null)
return Result.Failure<int>(CollectionErrors.NotFound);
// Части предложения уже упорядочены по году, а AddShow ставит в конец: франшиза пополняется
// в хронологии, и переставлять руками обычно не приходится.
var added = suggestion.Parts.Count(part => collection.AddShow(part.ShowId) is not null);
// Коллекция без постера могла остаться такой именно потому, что первой части не хватало.
// Уже выбранный постер подборщик не трогает — проверять это здесь незачем.
await posters.FillAsync([collection], cancellationToken);
return Result.Success(added);
}
}
@@ -5,12 +5,17 @@ using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.Collections.Suggest;
/// <summary>Предложение франшизы: имя и части в предлагаемом порядке.</summary>
/// <summary>
/// Предложение франшизы: имя и части в предлагаемом порядке. <paramref name="CollectionId"/>
/// задан — франшиза уже собрана, и предлагается дополнить её найденными частями, а не заводить
/// вторую такую же.
/// </summary>
public sealed record CollectionSuggestion(
string Key,
CollectionSuggestionSource Source,
string Name,
IReadOnlyList<CollectionSuggestionPartDto> Parts
IReadOnlyList<CollectionSuggestionPartDto> Parts,
Guid? CollectionId = null
);
/// <summary>
@@ -21,8 +26,12 @@ public sealed record CollectionSuggestion(
/// намеренно осторожна: совпадать должно осмысленное начало названия, а не первое слово, иначе
/// «Человек-паук» и «Человек дождя» уедут в одну франшизу.
///
/// Решение всё равно за человеком: предложение показывает состав целиком, а создаётся коллекция
/// кнопкой. Сериалы сюда не попадают — у них своя форма продолжения, сезоны.
/// Предложений тоже два вида: собрать новую франшизу и **дополнить уже собранную**. Второй важнее,
/// чем кажется: библиотека пополняется по одному фильму, и четвёртая часть, приехавшая после сборки
/// коллекции, сама по себе франшизы не образует — раньше она просто не показывалась нигде.
///
/// Решение всё равно за человеком: предложение показывает состав целиком, а применяется кнопкой.
/// Сериалы сюда не попадают — у них своя форма продолжения, сезоны.
/// </summary>
public sealed class CollectionSuggestionBuilder(IAppDbContext dbContext)
{
@@ -50,15 +59,14 @@ public sealed class CollectionSuggestionBuilder(IAppDbContext dbContext)
CancellationToken cancellationToken
)
{
var inCollections = await dbContext
var members = await dbContext
.CollectionItems.AsNoTracking()
.Select(i => i.ShowId)
.Distinct()
.Select(i => new MemberRow(i.CollectionId, i.ShowId))
.ToListAsync(cancellationToken);
var movies = await dbContext
var allMovies = await dbContext
.Shows.AsNoTracking()
.Where(s => s.Kind == ShowKind.Single && !inCollections.Contains(s.Id))
.Where(s => s.Kind == ShowKind.Single)
.Select(s => new MovieRow(
s.Id,
s.Name,
@@ -68,13 +76,29 @@ public sealed class CollectionSuggestionBuilder(IAppDbContext dbContext)
s.FranchiseName
))
.ToListAsync(cancellationToken);
if (movies.Count < MinParts)
var inCollections = members.Select(m => m.ShowId).ToHashSet();
var movies = allMovies.Where(m => !inCollections.Contains(m.Id)).ToList();
if (movies.Count == 0)
return [];
var suggestions = new List<CollectionSuggestion>();
var taken = new HashSet<Guid>();
foreach (var franchise in ByMetadata(movies))
// Дополнение существующих идёт первым: если франшиза уже собрана, вторую такую же заводить
// нельзя (имя занято), и предложить админу «создать» вместо «добавить» значило бы завести
// его в конфликт.
var additions = await ToExistingAsync(movies, members, allMovies, cancellationToken);
foreach (var addition in additions)
{
suggestions.Add(addition);
foreach (var part in addition.Parts)
taken.Add(part.ShowId);
}
var free = movies.Where(m => !taken.Contains(m.Id)).ToList();
foreach (var franchise in ByMetadata(free))
{
suggestions.Add(franchise);
foreach (var part in franchise.Parts)
@@ -83,17 +107,99 @@ public sealed class CollectionSuggestionBuilder(IAppDbContext dbContext)
// По названиям добираем только то, что не разобрала метадата: иначе одни и те же фильмы
// попали бы в два предложения сразу и админ выбирал бы между ними вслепую.
suggestions.AddRange(ByName(movies.Where(m => !taken.Contains(m.Id)).ToList()));
suggestions.AddRange(ByName(free.Where(m => !taken.Contains(m.Id)).ToList()));
return
[
.. suggestions
.OrderBy(s => s.Source)
// Дополнения выше новых франшиз: это точечное действие на один-два фильма, и оно
// не должно теряться под списком крупных предложений.
.OrderByDescending(s => s.CollectionId is not null)
.ThenBy(s => s.Source)
.ThenByDescending(s => s.Parts.Count)
.ThenBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase),
];
}
/// <summary>
/// Чего не хватает уже собранным франшизам. Фильм признаётся частью коллекции по тем же двум
/// признакам, что и при сборке новой: франшиза из метаданных совпала с франшизой любого её
/// участника либо название подошло по правилам догадки.
///
/// Метадата проверяется по всем коллекциям раньше догадки: точное совпадение не должно
/// проигрывать похожему названию из соседней франшизы.
/// </summary>
private async Task<IReadOnlyList<CollectionSuggestion>> ToExistingAsync(
List<MovieRow> orphans,
List<MemberRow> members,
List<MovieRow> allMovies,
CancellationToken cancellationToken
)
{
var collections = await dbContext
.Collections.AsNoTracking()
.Select(c => new { c.Id, c.Name })
.ToListAsync(cancellationToken);
if (collections.Count == 0)
return [];
var byId = allMovies.ToDictionary(m => m.Id);
var membersByCollection = members
.GroupBy(m => m.CollectionId)
.ToDictionary(
g => g.Key,
// Сериал в коллекции — не ориентир для франшизы полнометражек: в справочнике
// фильмов его просто нет, поэтому он и отсеивается по словарю.
g => g.Where(m => byId.ContainsKey(m.ShowId)).Select(m => byId[m.ShowId]).ToList()
);
var hits = new Dictionary<Guid, List<MovieRow>>();
foreach (var orphan in orphans)
{
var target = collections.FirstOrDefault(c =>
Members(membersByCollection, c.Id).Any(m => SameMetadataFranchise(orphan, m))
);
var byMetadata = target is not null;
target ??= collections.FirstOrDefault(c =>
Members(membersByCollection, c.Id)
.Any(m => SameFranchise(Words(Title(orphan)), Words(Title(m))))
);
if (target is null)
continue;
if (!hits.TryGetValue(target.Id, out var found))
hits[target.Id] = found = [];
found.Add(orphan with { MatchedByMetadata = byMetadata });
}
return
[
.. hits.Select(pair => new CollectionSuggestion(
$"existing:{pair.Key:N}",
// Источник — по самому слабому попаданию: одна догадка в наборе означает, что
// весь набор надо проверить глазами.
pair.Value.All(m => m.MatchedByMetadata)
? CollectionSuggestionSource.Metadata
: CollectionSuggestionSource.NameGuess,
collections.First(c => c.Id == pair.Key).Name,
Parts(pair.Value),
pair.Key
)),
];
}
private static List<MovieRow> Members(
Dictionary<Guid, List<MovieRow>> membersByCollection,
Guid collectionId
) => membersByCollection.TryGetValue(collectionId, out var list) ? list : [];
/// <summary>Обе части знают свою франшизу из метаданных, и это одна и та же франшиза.</summary>
private static bool SameMetadataFranchise(MovieRow first, MovieRow second) =>
first.FranchiseExternalId is not null
&& first.FranchiseExternalId == second.FranchiseExternalId;
private static IEnumerable<CollectionSuggestion> ByMetadata(List<MovieRow> movies)
{
var groups = movies
@@ -228,12 +334,20 @@ public sealed class CollectionSuggestionBuilder(IAppDbContext dbContext)
return trimmed.Trim();
}
/// <param name="MatchedByMetadata">
/// Как фильм попал в дополнение существующей коллекции: по франшизе из метаданных или догадкой
/// по названию. Живёт здесь, а не отдельным списком, чтобы признак не разъезжался с самим
/// фильмом при группировке.
/// </param>
private sealed record MovieRow(
Guid Id,
string Name,
string? OriginalName,
int? Year,
string? FranchiseExternalId,
string? FranchiseName
string? FranchiseName,
bool MatchedByMetadata = false
);
private sealed record MemberRow(Guid CollectionId, Guid ShowId);
}
@@ -5,16 +5,20 @@ namespace TeleWave.Application.Library.Collections.Suggest;
/// <summary>Какие франшизы имеет смысл собрать из уже загруженных полнометражек.</summary>
public sealed record SuggestCollectionsQuery : IQuery<IReadOnlyList<CollectionSuggestionDto>>;
/// <param name="Key">Ключ предложения — с ним же приходит команда создания.</param>
/// <param name="Key">Ключ предложения — с ним же приходит команда создания или дополнения.</param>
/// <param name="Source">Откуда взялось: франшиза источника либо догадка по названиям.</param>
/// <param name="Parts">Части в предлагаемом порядке.</param>
/// <param name="Parts">Части в предлагаемом порядке; у дополнения — только те, которых не хватает.</param>
/// <param name="AlreadyExists">Коллекция с таким названием уже есть — создавать нечего.</param>
/// <param name="CollectionId">
/// Задан — франшиза уже собрана, и предлагается дополнить её, а не заводить вторую такую же.
/// </param>
public sealed record CollectionSuggestionDto(
string Key,
CollectionSuggestionSource Source,
string Name,
IReadOnlyList<CollectionSuggestionPartDto> Parts,
bool AlreadyExists
bool AlreadyExists,
Guid? CollectionId = null
);
public sealed record CollectionSuggestionPartDto(Guid ShowId, string Name, int? Year);
@@ -26,6 +26,11 @@ public sealed class SuggestCollectionsQueryHandler(
.Select(n => n.Trim())
.ToHashSet(StringComparer.CurrentCultureIgnoreCase);
// У дополнения имя — это имя самой коллекции, и совпадение с ней ничего не значит: такое
// предложение и живёт ровно потому, что франшиза уже собрана.
bool AlreadyCreated(CollectionSuggestion s) =>
s.CollectionId is null && existing.Contains(s.Name);
return
[
.. suggestions.Select(s => new CollectionSuggestionDto(
@@ -33,7 +38,8 @@ public sealed class SuggestCollectionsQueryHandler(
s.Source,
s.Name,
s.Parts,
existing.Contains(s.Name)
AlreadyCreated(s),
s.CollectionId
)),
];
}