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 admin
.MapPost("/suggestions", CreateFromSuggestion) .MapPost("/suggestions", CreateFromSuggestion)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created); .Produces<CreatedIdResponse>(StatusCodes.Status201Created);
// Дополнение уже собранной франшизы: коллекция определяется тем же ключом предложения.
admin.MapPost("/suggestions/add", AddFromSuggestion).Produces<int>();
return app; return app;
} }
@@ -110,6 +112,16 @@ public static class CollectionEndpoints
: result.ToHttpResult(); : 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( private static async Task<IResult> ListCollections(
ISender sender, ISender sender,
CancellationToken cancellationToken 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; namespace TeleWave.Application.Library.Collections.Suggest;
/// <summary>Предложение франшизы: имя и части в предлагаемом порядке.</summary> /// <summary>
/// Предложение франшизы: имя и части в предлагаемом порядке. <paramref name="CollectionId"/>
/// задан — франшиза уже собрана, и предлагается дополнить её найденными частями, а не заводить
/// вторую такую же.
/// </summary>
public sealed record CollectionSuggestion( public sealed record CollectionSuggestion(
string Key, string Key,
CollectionSuggestionSource Source, CollectionSuggestionSource Source,
string Name, string Name,
IReadOnlyList<CollectionSuggestionPartDto> Parts IReadOnlyList<CollectionSuggestionPartDto> Parts,
Guid? CollectionId = null
); );
/// <summary> /// <summary>
@@ -21,8 +26,12 @@ public sealed record CollectionSuggestion(
/// намеренно осторожна: совпадать должно осмысленное начало названия, а не первое слово, иначе /// намеренно осторожна: совпадать должно осмысленное начало названия, а не первое слово, иначе
/// «Человек-паук» и «Человек дождя» уедут в одну франшизу. /// «Человек-паук» и «Человек дождя» уедут в одну франшизу.
/// ///
/// Решение всё равно за человеком: предложение показывает состав целиком, а создаётся коллекция /// Предложений тоже два вида: собрать новую франшизу и **дополнить уже собранную**. Второй важнее,
/// кнопкой. Сериалы сюда не попадают — у них своя форма продолжения, сезоны. /// чем кажется: библиотека пополняется по одному фильму, и четвёртая часть, приехавшая после сборки
/// коллекции, сама по себе франшизы не образует — раньше она просто не показывалась нигде.
///
/// Решение всё равно за человеком: предложение показывает состав целиком, а применяется кнопкой.
/// Сериалы сюда не попадают — у них своя форма продолжения, сезоны.
/// </summary> /// </summary>
public sealed class CollectionSuggestionBuilder(IAppDbContext dbContext) public sealed class CollectionSuggestionBuilder(IAppDbContext dbContext)
{ {
@@ -50,15 +59,14 @@ public sealed class CollectionSuggestionBuilder(IAppDbContext dbContext)
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var inCollections = await dbContext var members = await dbContext
.CollectionItems.AsNoTracking() .CollectionItems.AsNoTracking()
.Select(i => i.ShowId) .Select(i => new MemberRow(i.CollectionId, i.ShowId))
.Distinct()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var movies = await dbContext var allMovies = await dbContext
.Shows.AsNoTracking() .Shows.AsNoTracking()
.Where(s => s.Kind == ShowKind.Single && !inCollections.Contains(s.Id)) .Where(s => s.Kind == ShowKind.Single)
.Select(s => new MovieRow( .Select(s => new MovieRow(
s.Id, s.Id,
s.Name, s.Name,
@@ -68,13 +76,29 @@ public sealed class CollectionSuggestionBuilder(IAppDbContext dbContext)
s.FranchiseName s.FranchiseName
)) ))
.ToListAsync(cancellationToken); .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 []; return [];
var suggestions = new List<CollectionSuggestion>(); var suggestions = new List<CollectionSuggestion>();
var taken = new HashSet<Guid>(); 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); suggestions.Add(franchise);
foreach (var part in franchise.Parts) 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 return
[ [
.. suggestions .. suggestions
.OrderBy(s => s.Source) // Дополнения выше новых франшиз: это точечное действие на один-два фильма, и оно
// не должно теряться под списком крупных предложений.
.OrderByDescending(s => s.CollectionId is not null)
.ThenBy(s => s.Source)
.ThenByDescending(s => s.Parts.Count) .ThenByDescending(s => s.Parts.Count)
.ThenBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase), .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) private static IEnumerable<CollectionSuggestion> ByMetadata(List<MovieRow> movies)
{ {
var groups = movies var groups = movies
@@ -228,12 +334,20 @@ public sealed class CollectionSuggestionBuilder(IAppDbContext dbContext)
return trimmed.Trim(); return trimmed.Trim();
} }
/// <param name="MatchedByMetadata">
/// Как фильм попал в дополнение существующей коллекции: по франшизе из метаданных или догадкой
/// по названию. Живёт здесь, а не отдельным списком, чтобы признак не разъезжался с самим
/// фильмом при группировке.
/// </param>
private sealed record MovieRow( private sealed record MovieRow(
Guid Id, Guid Id,
string Name, string Name,
string? OriginalName, string? OriginalName,
int? Year, int? Year,
string? FranchiseExternalId, 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> /// <summary>Какие франшизы имеет смысл собрать из уже загруженных полнометражек.</summary>
public sealed record SuggestCollectionsQuery : IQuery<IReadOnlyList<CollectionSuggestionDto>>; public sealed record SuggestCollectionsQuery : IQuery<IReadOnlyList<CollectionSuggestionDto>>;
/// <param name="Key">Ключ предложения — с ним же приходит команда создания.</param> /// <param name="Key">Ключ предложения — с ним же приходит команда создания или дополнения.</param>
/// <param name="Source">Откуда взялось: франшиза источника либо догадка по названиям.</param> /// <param name="Source">Откуда взялось: франшиза источника либо догадка по названиям.</param>
/// <param name="Parts">Части в предлагаемом порядке.</param> /// <param name="Parts">Части в предлагаемом порядке; у дополнения — только те, которых не хватает.</param>
/// <param name="AlreadyExists">Коллекция с таким названием уже есть — создавать нечего.</param> /// <param name="AlreadyExists">Коллекция с таким названием уже есть — создавать нечего.</param>
/// <param name="CollectionId">
/// Задан — франшиза уже собрана, и предлагается дополнить её, а не заводить вторую такую же.
/// </param>
public sealed record CollectionSuggestionDto( public sealed record CollectionSuggestionDto(
string Key, string Key,
CollectionSuggestionSource Source, CollectionSuggestionSource Source,
string Name, string Name,
IReadOnlyList<CollectionSuggestionPartDto> Parts, IReadOnlyList<CollectionSuggestionPartDto> Parts,
bool AlreadyExists bool AlreadyExists,
Guid? CollectionId = null
); );
public sealed record CollectionSuggestionPartDto(Guid ShowId, string Name, int? Year); public sealed record CollectionSuggestionPartDto(Guid ShowId, string Name, int? Year);
@@ -26,6 +26,11 @@ public sealed class SuggestCollectionsQueryHandler(
.Select(n => n.Trim()) .Select(n => n.Trim())
.ToHashSet(StringComparer.CurrentCultureIgnoreCase); .ToHashSet(StringComparer.CurrentCultureIgnoreCase);
// У дополнения имя — это имя самой коллекции, и совпадение с ней ничего не значит: такое
// предложение и живёт ровно потому, что франшиза уже собрана.
bool AlreadyCreated(CollectionSuggestion s) =>
s.CollectionId is null && existing.Contains(s.Name);
return return
[ [
.. suggestions.Select(s => new CollectionSuggestionDto( .. suggestions.Select(s => new CollectionSuggestionDto(
@@ -33,7 +38,8 @@ public sealed class SuggestCollectionsQueryHandler(
s.Source, s.Source,
s.Name, s.Name,
s.Parts, s.Parts,
existing.Contains(s.Name) AlreadyCreated(s),
s.CollectionId
)), )),
]; ];
} }
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Library.Collections;
using TeleWave.Application.Library.Collections.AddShowsToCollection; using TeleWave.Application.Library.Collections.AddShowsToCollection;
using TeleWave.Application.Library.Collections.Suggest; using TeleWave.Application.Library.Collections.Suggest;
using TeleWave.Application.Tests.Support; using TeleWave.Application.Tests.Support;
@@ -63,8 +64,13 @@ public class CollectionSuggestionsTests
Assert.DoesNotContain(suggestions, s => s.Source == CollectionSuggestionSource.NameGuess); Assert.DoesNotContain(suggestions, s => s.Source == CollectionSuggestionSource.NameGuess);
} }
/// <summary>
/// Часть, приехавшая после сборки коллекции, сама франшизы не образует — её предлагают
/// добавить в существующую. Иначе она не показывалась бы нигде: создавать вторую франшизу
/// с тем же именем нельзя.
/// </summary>
[Fact] [Fact]
public async Task SkipsShows_AlreadyInCollections() public async Task SuggestsAddition_ToExistingCollection_ByMetadata()
{ {
var fixture = new TestDb(); var fixture = new TestDb();
var first = Movie("Рокки", 1976, franchise: "1241"); var first = Movie("Рокки", 1976, franchise: "1241");
@@ -72,17 +78,129 @@ public class CollectionSuggestionsTests
var collection = Collection.Create("Рокки"); var collection = Collection.Create("Рокки");
collection.AddShow(first.Id); collection.AddShow(first.Id);
await using (var seed = fixture.New()) await SeedWithCollectionAsync(fixture, collection, first, second);
{
seed.Shows.AddRange(first, second); var suggestion = Assert.Single(await BuildAsync(fixture));
seed.Collections.Add(collection);
await seed.SaveChangesAsync(CancellationToken.None); 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));
} }
var suggestions = await BuildAsync(fixture); /// <summary>Без метаданных дополнение опирается на догадку — и подписано как догадка.</summary>
[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);
Assert.Empty(suggestions);
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));
}
/// <summary>Чужой фильм в существующую франшизу не уезжает — правила те же, что при сборке.</summary>
[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));
}
/// <summary>
/// Фильм, попавший в дополнение, из предложений «создать» уходит: два предложения на один
/// фильм заставили бы выбирать вслепую, а создание к тому же упёрлось бы в занятое имя.
/// </summary>
[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())
{
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);
}
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(await BuildAsync(fixture));
}
/// <summary>Ключ предложения «создать» дополнением не применяется — коллекции у него нет.</summary>
[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] [Fact]
@@ -134,6 +252,18 @@ public class CollectionSuggestionsTests
await seed.SaveChangesAsync(CancellationToken.None); 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<IReadOnlyList<CollectionSuggestion>> BuildAsync(TestDb fixture) private static async Task<IReadOnlyList<CollectionSuggestion>> BuildAsync(TestDb fixture)
{ {
await using var db = fixture.New(); await using var db = fixture.New();
@@ -8,7 +8,7 @@ import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { toast } from '@/shared/ui/toast-store' 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, 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({ const create = useMutation({
mutationFn: (suggestion: CollectionSuggestionDto) => mutationFn: (suggestion: CollectionSuggestionDto) =>
createCollectionFromSuggestion(suggestion.key), createCollectionFromSuggestion(suggestion.key),
onSuccess: () => { onSuccess: () => {
toast.success(t('admin.collections.suggestions.created')) toast.success(t('admin.collections.suggestions.created'))
void queryClient.invalidateQueries({ queryKey: qk.collections.all }) invalidate()
void queryClient.invalidateQueries({ queryKey: qk.shows.all }) },
onError,
})
/** Дополнение уже собранной франшизы: коллекцию сервер находит по тому же ключу предложения. */
const add = useMutation({
mutationFn: (suggestion: CollectionSuggestionDto) => addShowsFromSuggestion(suggestion.key),
onSuccess: (added) => {
toast.success(t('admin.collections.suggestions.added', { count: added }))
invalidate()
}, },
onError, onError,
}) })
@@ -78,12 +93,31 @@ export function CollectionSuggestions() {
<li key={suggestion.key} className="flex flex-col gap-1 px-4 py-2"> <li key={suggestion.key} className="flex flex-col gap-1 px-4 py-2">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium">{suggestion.name}</span> <span className="min-w-0 flex-1 truncate font-medium">{suggestion.name}</span>
{/* Дополнение и создание в одном списке: без пометки строки неотличимы, а
действия у них разные — одна заводит франшизу, другая правит собранную. */}
{suggestion.collectionId && (
<Badge variant="muted">
{t('admin.collections.suggestions.additionBadge')}
</Badge>
)}
<Badge variant={suggestion.source === 'Metadata' ? 'default' : 'muted'}> <Badge variant={suggestion.source === 'Metadata' ? 'default' : 'muted'}>
{t(`admin.collections.suggestions.sources.${suggestion.source}`)} {t(`admin.collections.suggestions.sources.${suggestion.source}`)}
</Badge> </Badge>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{t('admin.collections.suggestions.parts', { count: suggestion.parts.length })} {t('admin.collections.suggestions.parts', { count: suggestion.parts.length })}
</span> </span>
{suggestion.collectionId ? (
<Button
size="sm"
variant="outline"
disabled={add.isPending}
onClick={() => add.mutate(suggestion)}
>
<Plus className="h-4 w-4" />
{t('admin.collections.suggestions.add')}
</Button>
) : (
<>
{suggestion.alreadyExists ? ( {suggestion.alreadyExists ? (
<Badge variant="muted">{t('admin.collections.suggestions.exists')}</Badge> <Badge variant="muted">{t('admin.collections.suggestions.exists')}</Badge>
) : ( ) : (
@@ -97,8 +131,11 @@ export function CollectionSuggestions() {
{t('common.create')} {t('common.create')}
</Button> </Button>
)} )}
</>
)}
</div> </div>
{/* Состав в предлагаемом порядке: по нему и видно, промахнулась ли догадка. */} {/* Состав в предлагаемом порядке: по нему и видно, промахнулась ли догадка.
У дополнения это только недостающие части — их и добавит кнопка. */}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{suggestion.parts {suggestion.parts
.map((part) => (part.year ? `${part.name} (${part.year})` : part.name)) .map((part) => (part.year ? `${part.name} (${part.year})` : part.name))
@@ -61,6 +61,14 @@ export function createCollectionFromSuggestion(key: string) {
}) })
} }
/** Дополняет уже собранную франшизу по тому же ключу предложения; возвращает, сколько добавлено. */
export function addShowsFromSuggestion(key: string) {
return apiRequest<number>('/admin/collections/suggestions/add', {
method: 'POST',
body: { key },
})
}
export function addCollectionShow(id: string, showId: string) { export function addCollectionShow(id: string, showId: string) {
return apiRequest<void>(`/admin/collections/${id}/shows`, { method: 'POST', body: { showId } }) return apiRequest<void>(`/admin/collections/${id}/shows`, { method: 'POST', body: { showId } })
} }
+5 -1
View File
@@ -275,13 +275,17 @@ export type CollectionDto = {
/** Откуда взялось предложение франшизы: данные источника либо догадка по названиям. */ /** Откуда взялось предложение франшизы: данные источника либо догадка по названиям. */
export type CollectionSuggestionSource = 'Metadata' | 'NameGuess' export type CollectionSuggestionSource = 'Metadata' | 'NameGuess'
/** Предложение собрать франшизу: имя и части в предлагаемом порядке. */ /**
* Предложение собрать франшизу: имя и части в предлагаемом порядке. `collectionId` задан —
* франшиза уже собрана, и предлагается дополнить её недостающими частями.
*/
export type CollectionSuggestionDto = { export type CollectionSuggestionDto = {
key: string key: string
source: CollectionSuggestionSource source: CollectionSuggestionSource
name: string name: string
parts: { showId: string; name: string; year: number | null }[] parts: { showId: string; name: string; year: number | null }[]
alreadyExists: boolean alreadyExists: boolean
collectionId: string | null
} }
/** Коллекция, в которую входит шоу — для блока на экране шоу. */ /** Коллекция, в которую входит шоу — для блока на экране шоу. */
+4 -1
View File
@@ -175,9 +175,12 @@ export const en = {
orderHint: 'Drag to set the order of the parts — that is the order they air in.', orderHint: 'Drag to set the order of the parts — that is the order they air in.',
suggestions: { suggestions: {
title: 'Franchise 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', parts: '{{count}} parts',
exists: 'already exists', exists: 'already exists',
additionBadge: 'addition',
add: 'Add',
added: 'Added to the collection: {{count}}',
created: 'Collection created', created: 'Collection created',
allCreated: 'Every suggestion has been created already.', allCreated: 'Every suggestion has been created already.',
showExisting: 'Show already created ({{count}})', showExisting: 'Show already created ({{count}})',
+4 -1
View File
@@ -175,9 +175,12 @@ export const ru = {
orderHint: 'Порядок частей задаётся перетаскиванием — в нём они и пойдут в эфир.', orderHint: 'Порядок частей задаётся перетаскиванием — в нём они и пойдут в эфир.',
suggestions: { suggestions: {
title: 'Предложения франшиз', title: 'Предложения франшиз',
hint: 'Собрано из полнометражек, не входящих ни в одну коллекцию. Проверьте состав — по названиям догадка иногда промахивается.', hint: 'Собрано из полнометражек, не входящих ни в одну коллекцию: что собрать в новую франшизу и чем дополнить уже собранную. Проверьте состав — по названиям догадка иногда промахивается.',
parts: '{{count}} частей', parts: '{{count}} частей',
exists: 'уже есть', exists: 'уже есть',
additionBadge: 'дополнение',
add: 'Добавить',
added: 'Добавлено в коллекцию: {{count}}',
created: 'Коллекция создана', created: 'Коллекция создана',
allCreated: 'Все предложения уже созданы.', allCreated: 'Все предложения уже созданы.',
showExisting: 'Показать уже созданные ({{count}})', showExisting: 'Показать уже созданные ({{count}})',