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}})',