From ac41111fa0a3cb5cb27ec71ca74e79fa6e2d0cf2 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Tue, 28 Jul 2026 14:49:27 +0300 Subject: [PATCH] Implement functionality to add shows to existing collections from suggestions 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. --- .../Endpoints/CollectionEndpoints.cs | 12 ++ .../Suggest/AddSuggestedShowsCommand.cs | 10 ++ .../AddSuggestedShowsCommandHandler.cs | 43 ++++++ .../Suggest/CollectionSuggestionBuilder.cs | 142 +++++++++++++++-- .../Suggest/SuggestCollectionsQuery.cs | 10 +- .../Suggest/SuggestCollectionsQueryHandler.cs | 8 +- .../Library/CollectionSuggestionsTests.cs | 146 +++++++++++++++++- .../collections/CollectionSuggestions.tsx | 57 +++++-- .../src/features/admin/collections/api.ts | 8 + frontend/src/shared/api/types.ts | 6 +- frontend/src/shared/lib/locales/en.ts | 5 +- frontend/src/shared/lib/locales/ru.ts | 5 +- 12 files changed, 413 insertions(+), 39 deletions(-) create mode 100644 backend/src/TeleWave.Application/Library/Collections/Suggest/AddSuggestedShowsCommand.cs create mode 100644 backend/src/TeleWave.Application/Library/Collections/Suggest/AddSuggestedShowsCommandHandler.cs diff --git a/backend/src/TeleWave.Api/Endpoints/CollectionEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/CollectionEndpoints.cs index b708183..d64148e 100644 --- a/backend/src/TeleWave.Api/Endpoints/CollectionEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/CollectionEndpoints.cs @@ -49,6 +49,8 @@ public static class CollectionEndpoints admin .MapPost("/suggestions", CreateFromSuggestion) .Produces(StatusCodes.Status201Created); + // Дополнение уже собранной франшизы: коллекция определяется тем же ключом предложения. + admin.MapPost("/suggestions/add", AddFromSuggestion).Produces(); return app; } @@ -110,6 +112,16 @@ public static class CollectionEndpoints : result.ToHttpResult(); } + private static async Task AddFromSuggestion( + CreateCollectionFromSuggestionBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new AddSuggestedShowsCommand(body.Key), cancellationToken); + return result.ToHttpResult(); + } + private static async Task ListCollections( ISender sender, CancellationToken cancellationToken diff --git a/backend/src/TeleWave.Application/Library/Collections/Suggest/AddSuggestedShowsCommand.cs b/backend/src/TeleWave.Application/Library/Collections/Suggest/AddSuggestedShowsCommand.cs new file mode 100644 index 0000000..138f18f --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/Suggest/AddSuggestedShowsCommand.cs @@ -0,0 +1,10 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.Collections.Suggest; + +/// +/// Дополняет уже собранную франшизу по предложению: состав сервер пересобирает по ключу, а не берёт +/// с клиента. Между показом и нажатием библиотека могла измениться — добавиться должно актуальное. +/// +public sealed record AddSuggestedShowsCommand(string Key) : ICommand>; diff --git a/backend/src/TeleWave.Application/Library/Collections/Suggest/AddSuggestedShowsCommandHandler.cs b/backend/src/TeleWave.Application/Library/Collections/Suggest/AddSuggestedShowsCommandHandler.cs new file mode 100644 index 0000000..e1d08d2 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/Collections/Suggest/AddSuggestedShowsCommandHandler.cs @@ -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> +{ + public async Task> 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(CollectionErrors.SuggestionNotFound); + + var collection = await dbContext + .Collections.Include(c => c.Items) + .FirstOrDefaultAsync(c => c.Id == collectionId, cancellationToken); + if (collection is null) + return Result.Failure(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); + } +} diff --git a/backend/src/TeleWave.Application/Library/Collections/Suggest/CollectionSuggestionBuilder.cs b/backend/src/TeleWave.Application/Library/Collections/Suggest/CollectionSuggestionBuilder.cs index e415f66..7e9a11f 100644 --- a/backend/src/TeleWave.Application/Library/Collections/Suggest/CollectionSuggestionBuilder.cs +++ b/backend/src/TeleWave.Application/Library/Collections/Suggest/CollectionSuggestionBuilder.cs @@ -5,12 +5,17 @@ using TeleWave.Domain.Library; namespace TeleWave.Application.Library.Collections.Suggest; -/// Предложение франшизы: имя и части в предлагаемом порядке. +/// +/// Предложение франшизы: имя и части в предлагаемом порядке. +/// задан — франшиза уже собрана, и предлагается дополнить её найденными частями, а не заводить +/// вторую такую же. +/// public sealed record CollectionSuggestion( string Key, CollectionSuggestionSource Source, string Name, - IReadOnlyList Parts + IReadOnlyList Parts, + Guid? CollectionId = null ); /// @@ -21,8 +26,12 @@ public sealed record CollectionSuggestion( /// намеренно осторожна: совпадать должно осмысленное начало названия, а не первое слово, иначе /// «Человек-паук» и «Человек дождя» уедут в одну франшизу. /// -/// Решение всё равно за человеком: предложение показывает состав целиком, а создаётся коллекция -/// кнопкой. Сериалы сюда не попадают — у них своя форма продолжения, сезоны. +/// Предложений тоже два вида: собрать новую франшизу и **дополнить уже собранную**. Второй важнее, +/// чем кажется: библиотека пополняется по одному фильму, и четвёртая часть, приехавшая после сборки +/// коллекции, сама по себе франшизы не образует — раньше она просто не показывалась нигде. +/// +/// Решение всё равно за человеком: предложение показывает состав целиком, а применяется кнопкой. +/// Сериалы сюда не попадают — у них своя форма продолжения, сезоны. /// 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(); var taken = new HashSet(); - 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), ]; } + /// + /// Чего не хватает уже собранным франшизам. Фильм признаётся частью коллекции по тем же двум + /// признакам, что и при сборке новой: франшиза из метаданных совпала с франшизой любого её + /// участника либо название подошло по правилам догадки. + /// + /// Метадата проверяется по всем коллекциям раньше догадки: точное совпадение не должно + /// проигрывать похожему названию из соседней франшизы. + /// + private async Task> ToExistingAsync( + List orphans, + List members, + List 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>(); + + 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 Members( + Dictionary> membersByCollection, + Guid collectionId + ) => membersByCollection.TryGetValue(collectionId, out var list) ? list : []; + + /// Обе части знают свою франшизу из метаданных, и это одна и та же франшиза. + private static bool SameMetadataFranchise(MovieRow first, MovieRow second) => + first.FranchiseExternalId is not null + && first.FranchiseExternalId == second.FranchiseExternalId; + private static IEnumerable ByMetadata(List movies) { var groups = movies @@ -228,12 +334,20 @@ public sealed class CollectionSuggestionBuilder(IAppDbContext dbContext) return trimmed.Trim(); } + /// + /// Как фильм попал в дополнение существующей коллекции: по франшизе из метаданных или догадкой + /// по названию. Живёт здесь, а не отдельным списком, чтобы признак не разъезжался с самим + /// фильмом при группировке. + /// 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); } diff --git a/backend/src/TeleWave.Application/Library/Collections/Suggest/SuggestCollectionsQuery.cs b/backend/src/TeleWave.Application/Library/Collections/Suggest/SuggestCollectionsQuery.cs index f5710fa..8d34116 100644 --- a/backend/src/TeleWave.Application/Library/Collections/Suggest/SuggestCollectionsQuery.cs +++ b/backend/src/TeleWave.Application/Library/Collections/Suggest/SuggestCollectionsQuery.cs @@ -5,16 +5,20 @@ namespace TeleWave.Application.Library.Collections.Suggest; /// Какие франшизы имеет смысл собрать из уже загруженных полнометражек. public sealed record SuggestCollectionsQuery : IQuery>; -/// Ключ предложения — с ним же приходит команда создания. +/// Ключ предложения — с ним же приходит команда создания или дополнения. /// Откуда взялось: франшиза источника либо догадка по названиям. -/// Части в предлагаемом порядке. +/// Части в предлагаемом порядке; у дополнения — только те, которых не хватает. /// Коллекция с таким названием уже есть — создавать нечего. +/// +/// Задан — франшиза уже собрана, и предлагается дополнить её, а не заводить вторую такую же. +/// public sealed record CollectionSuggestionDto( string Key, CollectionSuggestionSource Source, string Name, IReadOnlyList Parts, - bool AlreadyExists + bool AlreadyExists, + Guid? CollectionId = null ); public sealed record CollectionSuggestionPartDto(Guid ShowId, string Name, int? Year); diff --git a/backend/src/TeleWave.Application/Library/Collections/Suggest/SuggestCollectionsQueryHandler.cs b/backend/src/TeleWave.Application/Library/Collections/Suggest/SuggestCollectionsQueryHandler.cs index efbce0b..b5d36d7 100644 --- a/backend/src/TeleWave.Application/Library/Collections/Suggest/SuggestCollectionsQueryHandler.cs +++ b/backend/src/TeleWave.Application/Library/Collections/Suggest/SuggestCollectionsQueryHandler.cs @@ -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 )), ]; } diff --git a/backend/tests/TeleWave.Application.Tests/Library/CollectionSuggestionsTests.cs b/backend/tests/TeleWave.Application.Tests/Library/CollectionSuggestionsTests.cs index 6ac06b5..8943281 100644 --- a/backend/tests/TeleWave.Application.Tests/Library/CollectionSuggestionsTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Library/CollectionSuggestionsTests.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Library.Collections; using TeleWave.Application.Library.Collections.AddShowsToCollection; using TeleWave.Application.Library.Collections.Suggest; using TeleWave.Application.Tests.Support; @@ -63,8 +64,13 @@ public class CollectionSuggestionsTests Assert.DoesNotContain(suggestions, s => s.Source == CollectionSuggestionSource.NameGuess); } + /// + /// Часть, приехавшая после сборки коллекции, сама франшизы не образует — её предлагают + /// добавить в существующую. Иначе она не показывалась бы нигде: создавать вторую франшизу + /// с тем же именем нельзя. + /// [Fact] - public async Task SkipsShows_AlreadyInCollections() + public async Task SuggestsAddition_ToExistingCollection_ByMetadata() { var fixture = new TestDb(); var first = Movie("Рокки", 1976, franchise: "1241"); @@ -72,17 +78,129 @@ public class CollectionSuggestionsTests var collection = Collection.Create("Рокки"); collection.AddShow(first.Id); - await using (var seed = fixture.New()) + await SeedWithCollectionAsync(fixture, collection, first, second); + + var suggestion = Assert.Single(await BuildAsync(fixture)); + + Assert.Equal(collection.Id, suggestion.CollectionId); + Assert.Equal(CollectionSuggestionSource.Metadata, suggestion.Source); + Assert.Equal("Рокки", suggestion.Name); + // В дополнении только недостающее: то, что уже в коллекции, предлагать нечего. + Assert.Equal([second.Id], suggestion.Parts.Select(p => p.ShowId)); + } + + /// Без метаданных дополнение опирается на догадку — и подписано как догадка. + [Fact] + public async Task SuggestsAddition_ToExistingCollection_ByName() + { + var fixture = new TestDb(); + var first = Movie("Рокки", 1976); + var second = Movie("Рокки 2", 1979); + var collection = Collection.Create("Рокки"); + collection.AddShow(first.Id); + + await SeedWithCollectionAsync(fixture, collection, first, second); + + var suggestion = Assert.Single(await BuildAsync(fixture)); + + Assert.Equal(collection.Id, suggestion.CollectionId); + Assert.Equal(CollectionSuggestionSource.NameGuess, suggestion.Source); + Assert.Equal([second.Id], suggestion.Parts.Select(p => p.ShowId)); + } + + /// Чужой фильм в существующую франшизу не уезжает — правила те же, что при сборке. + [Fact] + public async Task DoesNotSuggestAddition_ForUnrelatedMovie() + { + var fixture = new TestDb(); + var member = Movie("Рокки", 1976, franchise: "1241"); + var stranger = Movie("Человек дождя", 1988, franchise: "777"); + var collection = Collection.Create("Рокки"); + collection.AddShow(member.Id); + + await SeedWithCollectionAsync(fixture, collection, member, stranger); + + Assert.Empty(await BuildAsync(fixture)); + } + + /// + /// Фильм, попавший в дополнение, из предложений «создать» уходит: два предложения на один + /// фильм заставили бы выбирать вслепую, а создание к тому же упёрлось бы в занятое имя. + /// + [Fact] + public async Task AdditionWins_OverNewFranchise() + { + var fixture = new TestDb(); + var member = Movie("Рокки", 1976, franchise: "1241"); + var second = Movie("Рокки 2", 1979, franchise: "1241"); + var third = Movie("Рокки 3", 1982, franchise: "1241"); + var collection = Collection.Create("Рокки"); + collection.AddShow(member.Id); + + await SeedWithCollectionAsync(fixture, collection, member, second, third); + + var suggestion = Assert.Single(await BuildAsync(fixture)); + + Assert.Equal(collection.Id, suggestion.CollectionId); + Assert.Equal([second.Id, third.Id], suggestion.Parts.Select(p => p.ShowId)); + } + + [Fact] + public async Task AddSuggestedShows_AppendsToCollection() + { + 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 SeedWithCollectionAsync(fixture, collection, first, second); + + var key = (await BuildAsync(fixture)).Single().Key; + + await using (var db = fixture.New()) { - seed.Shows.AddRange(first, second); - seed.Collections.Add(collection); - await seed.SaveChangesAsync(CancellationToken.None); + var result = await new AddSuggestedShowsCommandHandler( + db, + new CollectionSuggestionBuilder(db), + new CollectionPosterPicker(db) + ).Handle(new AddSuggestedShowsCommand(key), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(1, result.Value); + await db.SaveChangesAsync(CancellationToken.None); } - var suggestions = await BuildAsync(fixture); + await using var check = fixture.New(); + var stored = check.Collections.Include(c => c.Items).Single(); + // Дополнение встаёт в конец в хронологии — франшиза остаётся в порядке просмотра. + Assert.Equal( + [first.Id, second.Id], + stored.Items.OrderBy(i => i.Position).Select(i => i.ShowId) + ); - // Одна часть уже разобрана — на франшизу оставшегося не хватает. - Assert.Empty(suggestions); + // Предложение исчерпано: добавлять больше нечего. + Assert.Empty(await BuildAsync(fixture)); + } + + /// Ключ предложения «создать» дополнением не применяется — коллекции у него нет. + [Fact] + public async Task AddSuggestedShows_RejectsCreationKey() + { + var fixture = new TestDb(); + await SeedAsync(fixture, Movie("Рокки", 1976), Movie("Рокки 2", 1979)); + + var key = (await BuildAsync(fixture)).Single().Key; + + await using var db = fixture.New(); + var result = await new AddSuggestedShowsCommandHandler( + db, + new CollectionSuggestionBuilder(db), + new CollectionPosterPicker(db) + ).Handle(new AddSuggestedShowsCommand(key), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(CollectionErrors.SuggestionNotFound.Code, result.Error.Code); } [Fact] @@ -134,6 +252,18 @@ public class CollectionSuggestionsTests await seed.SaveChangesAsync(CancellationToken.None); } + private static async Task SeedWithCollectionAsync( + TestDb fixture, + Collection collection, + params Show[] shows + ) + { + await using var seed = fixture.New(); + seed.Shows.AddRange(shows); + seed.Collections.Add(collection); + await seed.SaveChangesAsync(CancellationToken.None); + } + private static async Task> BuildAsync(TestDb fixture) { await using var db = fixture.New(); diff --git a/frontend/src/features/admin/collections/CollectionSuggestions.tsx b/frontend/src/features/admin/collections/CollectionSuggestions.tsx index bb8c24b..938487a 100644 --- a/frontend/src/features/admin/collections/CollectionSuggestions.tsx +++ b/frontend/src/features/admin/collections/CollectionSuggestions.tsx @@ -8,7 +8,7 @@ import { useApiError } from '@/shared/lib/use-api-error' import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' import { toast } from '@/shared/ui/toast-store' -import { createCollectionFromSuggestion, suggestCollections } from './api' +import { addShowsFromSuggestion, createCollectionFromSuggestion, suggestCollections } from './api' /** * Что стоит собрать во франшизу. Состав виден целиком до создания — по названиям иногда @@ -31,13 +31,28 @@ export function CollectionSuggestions() { queryFn: suggestCollections, }) + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: qk.collections.all }) + void queryClient.invalidateQueries({ queryKey: qk.collections.suggestions }) + void queryClient.invalidateQueries({ queryKey: qk.shows.all }) + } + const create = useMutation({ mutationFn: (suggestion: CollectionSuggestionDto) => createCollectionFromSuggestion(suggestion.key), onSuccess: () => { toast.success(t('admin.collections.suggestions.created')) - void queryClient.invalidateQueries({ queryKey: qk.collections.all }) - void queryClient.invalidateQueries({ queryKey: qk.shows.all }) + invalidate() + }, + onError, + }) + + /** Дополнение уже собранной франшизы: коллекцию сервер находит по тому же ключу предложения. */ + const add = useMutation({ + mutationFn: (suggestion: CollectionSuggestionDto) => addShowsFromSuggestion(suggestion.key), + onSuccess: (added) => { + toast.success(t('admin.collections.suggestions.added', { count: added })) + invalidate() }, onError, }) @@ -78,27 +93,49 @@ export function CollectionSuggestions() {
  • {suggestion.name} + {/* Дополнение и создание в одном списке: без пометки строки неотличимы, а + действия у них разные — одна заводит франшизу, другая правит собранную. */} + {suggestion.collectionId && ( + + {t('admin.collections.suggestions.additionBadge')} + + )} {t(`admin.collections.suggestions.sources.${suggestion.source}`)} {t('admin.collections.suggestions.parts', { count: suggestion.parts.length })} - {suggestion.alreadyExists ? ( - {t('admin.collections.suggestions.exists')} - ) : ( + {suggestion.collectionId ? ( + ) : ( + <> + {suggestion.alreadyExists ? ( + {t('admin.collections.suggestions.exists')} + ) : ( + + )} + )}
    - {/* Состав в предлагаемом порядке: по нему и видно, промахнулась ли догадка. */} + {/* Состав в предлагаемом порядке: по нему и видно, промахнулась ли догадка. + У дополнения это только недостающие части — их и добавит кнопка. */}

    {suggestion.parts .map((part) => (part.year ? `${part.name} (${part.year})` : part.name)) diff --git a/frontend/src/features/admin/collections/api.ts b/frontend/src/features/admin/collections/api.ts index 729dc06..b009092 100644 --- a/frontend/src/features/admin/collections/api.ts +++ b/frontend/src/features/admin/collections/api.ts @@ -61,6 +61,14 @@ export function createCollectionFromSuggestion(key: string) { }) } +/** Дополняет уже собранную франшизу по тому же ключу предложения; возвращает, сколько добавлено. */ +export function addShowsFromSuggestion(key: string) { + return apiRequest('/admin/collections/suggestions/add', { + method: 'POST', + body: { key }, + }) +} + export function addCollectionShow(id: string, showId: string) { return apiRequest(`/admin/collections/${id}/shows`, { method: 'POST', body: { showId } }) } diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 5e8edd7..493d8bc 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -275,13 +275,17 @@ export type CollectionDto = { /** Откуда взялось предложение франшизы: данные источника либо догадка по названиям. */ export type CollectionSuggestionSource = 'Metadata' | 'NameGuess' -/** Предложение собрать франшизу: имя и части в предлагаемом порядке. */ +/** + * Предложение собрать франшизу: имя и части в предлагаемом порядке. `collectionId` задан — + * франшиза уже собрана, и предлагается дополнить её недостающими частями. + */ export type CollectionSuggestionDto = { key: string source: CollectionSuggestionSource name: string parts: { showId: string; name: string; year: number | null }[] alreadyExists: boolean + collectionId: string | null } /** Коллекция, в которую входит шоу — для блока на экране шоу. */ diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index d1c6bf7..19e6400 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -175,9 +175,12 @@ export const en = { orderHint: 'Drag to set the order of the parts — that is the order they air in.', suggestions: { title: 'Franchise suggestions', - hint: 'Built from movies not in any collection yet. Check the parts — the name-based guess sometimes misses.', + hint: 'Built from movies not in any collection yet: what to assemble into a new franchise and what to add to an assembled one. Check the parts — the name-based guess sometimes misses.', parts: '{{count}} parts', exists: 'already exists', + additionBadge: 'addition', + add: 'Add', + added: 'Added to the collection: {{count}}', created: 'Collection created', allCreated: 'Every suggestion has been created already.', showExisting: 'Show already created ({{count}})', diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index b88d41c..bae4ae2 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -175,9 +175,12 @@ export const ru = { orderHint: 'Порядок частей задаётся перетаскиванием — в нём они и пойдут в эфир.', suggestions: { title: 'Предложения франшиз', - hint: 'Собрано из полнометражек, не входящих ни в одну коллекцию. Проверьте состав — по названиям догадка иногда промахивается.', + hint: 'Собрано из полнометражек, не входящих ни в одну коллекцию: что собрать в новую франшизу и чем дополнить уже собранную. Проверьте состав — по названиям догадка иногда промахивается.', parts: '{{count}} частей', exists: 'уже есть', + additionBadge: 'дополнение', + add: 'Добавить', + added: 'Добавлено в коллекцию: {{count}}', created: 'Коллекция создана', allCreated: 'Все предложения уже созданы.', showExisting: 'Показать уже созданные ({{count}})',