Implement collection suggestions and bulk show addition features
ci / build-backend (push) Successful in 2m41s
ci / build-frontend (push) Successful in 44s
ci / tests (push) Successful in 2m33s
ci / sonar (push) Successful in 5m12s

Added new API endpoints for suggesting collections based on existing shows and for bulk adding shows to collections. Enhanced the backend with necessary logic and DTOs to support these features. Updated the frontend to include new components for displaying collection suggestions and managing bulk additions, improving the user experience for collection management. Localization updates were made to support these new features in both English and Russian.
This commit is contained in:
Leonid Pershin
2026-07-27 05:39:23 +03:00
parent 1a7f73a5bd
commit 5ad28b2966
39 changed files with 2710 additions and 10 deletions
@@ -0,0 +1,22 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.AddShowsToCollection;
/// <summary>
/// Отправляет несколько шоу во франшизу разом. Коллекция либо уже есть
/// (<paramref name="CollectionId"/>), либо заводится по имени
/// (<paramref name="NewCollectionName"/>) — иначе на сборку франшизы уходило бы два экрана.
///
/// Порядок добавления — по году выпуска: сиквелы почти всегда идут так, а исключения правятся
/// перетаскиванием в карточке коллекции.
/// </summary>
public sealed record AddShowsToCollectionCommand(
IReadOnlyList<Guid> ShowIds,
Guid? CollectionId = null,
string? NewCollectionName = null
) : ICommand<Result<AddShowsToCollectionResultDto>>;
/// <param name="CollectionId">Куда в итоге сложили — новая коллекция или переданная.</param>
/// <param name="Added">Сколько шоу реально добавлено (уже входящие пропускаются).</param>
public sealed record AddShowsToCollectionResultDto(Guid CollectionId, string Name, int Added);
@@ -0,0 +1,62 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.Collections.AddShowsToCollection;
public sealed class AddShowsToCollectionCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddShowsToCollectionCommand, Result<AddShowsToCollectionResultDto>>
{
public async Task<Result<AddShowsToCollectionResultDto>> Handle(
AddShowsToCollectionCommand command,
CancellationToken cancellationToken
)
{
var showIds = command.ShowIds.Distinct().ToList();
var shows = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
s.Name,
s.Year,
})
.ToListAsync(cancellationToken);
if (shows.Count == 0)
return Result.Failure<AddShowsToCollectionResultDto>(ShowErrors.NotFound);
var collection = await ResolveCollectionAsync(command, cancellationToken);
if (collection is null)
return Result.Failure<AddShowsToCollectionResultDto>(CollectionErrors.NotFound);
// Год — главный порядок франшизы; без года часть уходит в конец, там её и переставят руками.
var ordered = shows
.OrderBy(s => s.Year is null)
.ThenBy(s => s.Year)
.ThenBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase);
var added = ordered.Count(show => collection.AddShow(show.Id) is not null);
return Result.Success(
new AddShowsToCollectionResultDto(collection.Id, collection.Name, added)
);
}
private async Task<Collection?> ResolveCollectionAsync(
AddShowsToCollectionCommand command,
CancellationToken cancellationToken
)
{
if (command.CollectionId is { } id)
return await dbContext
.Collections.Include(c => c.Items)
.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
var created = Collection.Create(command.NewCollectionName!);
dbContext.Collections.Add(created);
return created;
}
}
@@ -0,0 +1,19 @@
using FluentValidation;
namespace TeleWave.Application.Library.Collections.AddShowsToCollection;
public sealed class AddShowsToCollectionCommandValidator
: AbstractValidator<AddShowsToCollectionCommand>
{
public AddShowsToCollectionCommandValidator()
{
RuleFor(x => x.ShowIds).NotEmpty();
// Либо кладём в существующую, либо заводим новую — «ни то ни другое» указывает на ошибку UI.
RuleFor(x => x)
.Must(x =>
x.CollectionId is not null || !string.IsNullOrWhiteSpace(x.NewCollectionName)
)
.WithMessage("Не указано, в какую коллекцию складывать.");
RuleFor(x => x.NewCollectionName).MaximumLength(256);
}
}
@@ -18,4 +18,14 @@ public static class CollectionErrors
"Collections.ShowNotInCollection",
"Шоу не входит в коллекцию."
);
public static readonly Error SuggestionNotFound = Error.NotFound(
"Collections.SuggestionNotFound",
"Предложение устарело — библиотека изменилась. Обновите список предложений."
);
public static readonly Error SuggestionAlreadyCreated = Error.Conflict(
"Collections.SuggestionAlreadyCreated",
"Коллекция с таким названием уже есть."
);
}
@@ -0,0 +1,239 @@
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.Collections.Suggest;
/// <summary>Предложение франшизы: имя и части в предлагаемом порядке.</summary>
public sealed record CollectionSuggestion(
string Key,
CollectionSuggestionSource Source,
string Name,
IReadOnlyList<CollectionSuggestionPartDto> Parts
);
/// <summary>
/// Ищет, что стоит собрать в коллекцию, среди полнометражек, ещё не входящих ни в одну.
///
/// Два источника, и они не равны по надёжности. Франшиза из метаданных — это факт: TMDb сам считает
/// эти фильмы одним целым. Догадка по названиям — эвристика для библиотеки без метаданных, и она
/// намеренно осторожна: совпадать должно осмысленное начало названия, а не первое слово, иначе
/// «Человек-паук» и «Человек дождя» уедут в одну франшизу.
///
/// Решение всё равно за человеком: предложение показывает состав целиком, а создаётся коллекция
/// кнопкой. Сериалы сюда не попадают — у них своя форма продолжения, сезоны.
/// </summary>
public sealed class CollectionSuggestionBuilder(IAppDbContext dbContext)
{
/// <summary>Меньше двух частей — не франшиза.</summary>
private const int MinParts = 2;
/// <summary>
/// Со скольких общих слов начало названия само по себе считается признаком франшизы:
/// «Harry Potter and the …» случайным совпадением не бывает, а «Человек …» — бывает.
/// </summary>
private const int StrongCommonWords = 3;
/// <summary>
/// Чем продолжение отличается от однофамильца: номер («Рокки 2», «Рокки III»), слово «часть»
/// либо подзаголовок через двоеточие или тире («Матрица: Перезагрузка»). У «Человек дождя»
/// и «Человек-слон» хвосты — обычные слова, и в одну франшизу они не попадут.
/// </summary>
private static readonly Regex SequelTail = new(
@"^(?:\d{1,2}\b|[IVXivx]{1,4}\b|part\b|часть\b|[:–—-]\s*\S)",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
TimeSpan.FromSeconds(1)
);
public async Task<IReadOnlyList<CollectionSuggestion>> BuildAsync(
CancellationToken cancellationToken
)
{
var inCollections = await dbContext
.CollectionItems.AsNoTracking()
.Select(i => i.ShowId)
.Distinct()
.ToListAsync(cancellationToken);
var movies = await dbContext
.Shows.AsNoTracking()
.Where(s => s.Kind == ShowKind.Single && !inCollections.Contains(s.Id))
.Select(s => new MovieRow(
s.Id,
s.Name,
s.OriginalName,
s.Year,
s.FranchiseExternalId,
s.FranchiseName
))
.ToListAsync(cancellationToken);
if (movies.Count < MinParts)
return [];
var suggestions = new List<CollectionSuggestion>();
var taken = new HashSet<Guid>();
foreach (var franchise in ByMetadata(movies))
{
suggestions.Add(franchise);
foreach (var part in franchise.Parts)
taken.Add(part.ShowId);
}
// По названиям добираем только то, что не разобрала метадата: иначе одни и те же фильмы
// попали бы в два предложения сразу и админ выбирал бы между ними вслепую.
suggestions.AddRange(ByName(movies.Where(m => !taken.Contains(m.Id)).ToList()));
return
[
.. suggestions
.OrderBy(s => s.Source)
.ThenByDescending(s => s.Parts.Count)
.ThenBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase),
];
}
private static IEnumerable<CollectionSuggestion> ByMetadata(List<MovieRow> movies)
{
var groups = movies
.Where(m => m.FranchiseExternalId is not null && m.FranchiseName is not null)
.GroupBy(m => m.FranchiseExternalId!)
.Where(g => g.Count() >= MinParts);
foreach (var group in groups)
yield return new CollectionSuggestion(
$"franchise:{group.Key}",
CollectionSuggestionSource.Metadata,
group.First().FranchiseName!,
Parts(group)
);
}
/// <summary>
/// Догадка по названиям для библиотеки без метаданных. Сравниваются целые слова, а не символы:
/// посимвольное совпадение слишком легко ловит однофамильцев («Человек дождя» и «Человек-слон»).
/// Пара считается франшизой, если общее начало длинное либо хвосты обеих частей выглядят
/// продолжением. Имя предложения — общее начало, очищенное от хвостовых предлогов и знаков.
/// </summary>
private static IEnumerable<CollectionSuggestion> ByName(List<MovieRow> movies)
{
var used = new HashSet<Guid>();
// От длинных названий к коротким: так более специфичная франшиза собирается первой.
foreach (var movie in movies.OrderByDescending(m => Title(m).Length))
{
if (used.Contains(movie.Id))
continue;
var words = Words(Title(movie));
var family = movies
.Where(other =>
other.Id != movie.Id
&& !used.Contains(other.Id)
&& SameFranchise(words, Words(Title(other)))
)
.ToList();
if (family.Count == 0)
continue;
var common = family.Aggregate(
words,
(acc, other) => acc[..CommonWordCount(acc, Words(Title(other)))]
);
var name = Clean(string.Join(' ', common));
if (name.Length < 3)
continue;
family.Add(movie);
foreach (var part in family)
used.Add(part.Id);
yield return new CollectionSuggestion(
$"name:{name.ToLowerInvariant()}",
CollectionSuggestionSource.NameGuess,
name,
Parts(family)
);
}
}
/// <summary>Части идут по году: у франшизы это и есть порядок просмотра.</summary>
private static IReadOnlyList<CollectionSuggestionPartDto> Parts(IEnumerable<MovieRow> movies) =>
[
.. movies
.OrderBy(m => m.Year is null)
.ThenBy(m => m.Year)
.ThenBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase)
.Select(m => new CollectionSuggestionPartDto(m.Id, m.Name, m.Year)),
];
/// <summary>Сравниваем по оригинальному названию, если оно есть: у релизов оно стабильнее.</summary>
private static string Title(MovieRow movie) =>
string.IsNullOrWhiteSpace(movie.OriginalName) ? movie.Name : movie.OriginalName;
private static bool SameFranchise(string[] first, string[] second)
{
var common = CommonWordCount(first, second);
if (common == 0)
return false;
if (common >= StrongCommonWords)
return true;
return IsSequelTail(first, common) && IsSequelTail(second, common);
}
/// <summary>Хвост после общего начала: пусто — это первая часть, иначе должен выглядеть продолжением.</summary>
private static bool IsSequelTail(string[] words, int common)
{
var tail = string.Join(' ', words.Skip(common)).Trim();
return tail.Length == 0 || SequelTail.IsMatch(tail);
}
private static int CommonWordCount(string[] first, string[] second)
{
var count = 0;
while (
count < first.Length
&& count < second.Length
&& string.Equals(first[count], second[count], StringComparison.CurrentCultureIgnoreCase)
)
count++;
return count;
}
private static string[] Words(string title) =>
title.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
/// <summary>
/// Общее начало обрывается на союзе или знаке: «Harry Potter and the» — это ещё не имя франшизы.
/// Чистим, пока есть что убирать: за один проход «and the» не снять.
/// </summary>
private static string Clean(string prefix)
{
var trimmed = prefix.Trim();
bool changed;
do
{
changed = false;
trimmed = trimmed.TrimEnd(':', '-', '', '—', '.', ',', ' ');
foreach (var tail in (string[])[" и", " and", " the", " part", " часть"])
{
if (!trimmed.EndsWith(tail, StringComparison.CurrentCultureIgnoreCase))
continue;
trimmed = trimmed[..^tail.Length];
changed = true;
}
} while (changed);
return trimmed.Trim();
}
private sealed record MovieRow(
Guid Id,
string Name,
string? OriginalName,
int? Year,
string? FranchiseExternalId,
string? FranchiseName
);
}
@@ -0,0 +1,11 @@
namespace TeleWave.Application.Library.Collections.Suggest;
/// <summary>Насколько предложению можно верить — по этому UI подписывает происхождение.</summary>
public enum CollectionSuggestionSource
{
/// <summary>Франшиза пришла из метаданных (TMDb знает её как единое целое).</summary>
Metadata = 0,
/// <summary>Догадка по названиям: общее начало и нумерация. Требует проверки глазами.</summary>
NameGuess = 1,
}
@@ -0,0 +1,11 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.Collections.Suggest;
/// <summary>
/// Создаёт коллекцию по предложению: имя и состав сервер пересобирает по ключу, а не берёт
/// с клиента. Между показом и нажатием библиотека могла измениться — во франшизу должно попасть
/// актуальное.
/// </summary>
public sealed record CreateCollectionFromSuggestionCommand(string Key) : ICommand<Result<Guid>>;
@@ -0,0 +1,38 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.Collections.Suggest;
public sealed class CreateCollectionFromSuggestionCommandHandler(
IAppDbContext dbContext,
CollectionSuggestionBuilder builder
) : ICommandHandler<CreateCollectionFromSuggestionCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
CreateCollectionFromSuggestionCommand command,
CancellationToken cancellationToken
)
{
var suggestions = await builder.BuildAsync(cancellationToken);
var suggestion = suggestions.FirstOrDefault(s => s.Key == command.Key);
if (suggestion is null)
return Result.Failure<Guid>(CollectionErrors.SuggestionNotFound);
var taken = await dbContext.Collections.AnyAsync(
c => c.Name.ToLower() == suggestion.Name.ToLower(),
cancellationToken
);
if (taken)
return Result.Failure<Guid>(CollectionErrors.SuggestionAlreadyCreated);
var collection = Collection.Create(suggestion.Name);
foreach (var part in suggestion.Parts)
collection.AddShow(part.ShowId);
dbContext.Collections.Add(collection);
return Result.Success(collection.Id);
}
}
@@ -0,0 +1,20 @@
using LiteCqrs;
namespace TeleWave.Application.Library.Collections.Suggest;
/// <summary>Какие франшизы имеет смысл собрать из уже загруженных полнометражек.</summary>
public sealed record SuggestCollectionsQuery : IQuery<IReadOnlyList<CollectionSuggestionDto>>;
/// <param name="Key">Ключ предложения — с ним же приходит команда создания.</param>
/// <param name="Source">Откуда взялось: франшиза источника либо догадка по названиям.</param>
/// <param name="Parts">Части в предлагаемом порядке.</param>
/// <param name="AlreadyExists">Коллекция с таким названием уже есть — создавать нечего.</param>
public sealed record CollectionSuggestionDto(
string Key,
CollectionSuggestionSource Source,
string Name,
IReadOnlyList<CollectionSuggestionPartDto> Parts,
bool AlreadyExists
);
public sealed record CollectionSuggestionPartDto(Guid ShowId, string Name, int? Year);
@@ -0,0 +1,40 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Library.Collections.Suggest;
public sealed class SuggestCollectionsQueryHandler(
IAppDbContext dbContext,
CollectionSuggestionBuilder builder
) : IQueryHandler<SuggestCollectionsQuery, IReadOnlyList<CollectionSuggestionDto>>
{
public async Task<IReadOnlyList<CollectionSuggestionDto>> Handle(
SuggestCollectionsQuery query,
CancellationToken cancellationToken
)
{
var suggestions = await builder.BuildAsync(cancellationToken);
if (suggestions.Count == 0)
return [];
var names = await dbContext
.Collections.AsNoTracking()
.Select(c => c.Name)
.ToListAsync(cancellationToken);
var existing = names
.Select(n => n.Trim())
.ToHashSet(StringComparer.CurrentCultureIgnoreCase);
return
[
.. suggestions.Select(s => new CollectionSuggestionDto(
s.Key,
s.Source,
s.Name,
s.Parts,
existing.Contains(s.Name)
)),
];
}
}
@@ -0,0 +1,22 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Library.GetShowUsage;
/// <summary>
/// Где шоу задействовано: группы, коллекции и каналы. Нужно до удаления — сейчас оно просто
/// отбивается ошибкой «используется», не говоря где, и приходится обходить экраны вручную.
/// </summary>
public sealed record GetShowUsageQuery(Guid ShowId) : IQuery<Result<ShowUsageDto>>;
/// <param name="Groups">Группы, в состав которых шоу входит (в том числе по правилу).</param>
/// <param name="Collections">Франшизы, частью которых оно является.</param>
/// <param name="Channels">Каналы, в чьём расписании оно уже стоит.</param>
public sealed record ShowUsageDto(
IReadOnlyList<ShowUsageRefDto> Groups,
IReadOnlyList<ShowUsageRefDto> Collections,
IReadOnlyList<ShowUsageRefDto> Channels
);
/// <param name="ViaRule">Шоу попало в группу правилом, а не явной позицией.</param>
public sealed record ShowUsageRefDto(Guid Id, string Name, bool ViaRule = false);
@@ -0,0 +1,90 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Groups;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Library.GetShowUsage;
public sealed class GetShowUsageQueryHandler(
IAppDbContext dbContext,
DynamicGroupResolver dynamicResolver
) : IQueryHandler<GetShowUsageQuery, Result<ShowUsageDto>>
{
public async Task<Result<ShowUsageDto>> Handle(
GetShowUsageQuery query,
CancellationToken cancellationToken
)
{
if (!await dbContext.Shows.AnyAsync(s => s.Id == query.ShowId, cancellationToken))
return Result.Failure<ShowUsageDto>(ShowErrors.NotFound);
var collections = await dbContext
.Collections.AsNoTracking()
.Where(c => c.Items.Any(i => i.ShowId == query.ShowId))
.Select(c => new ShowUsageRefDto(c.Id, c.Name, false))
.ToListAsync(cancellationToken);
var channels = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e => e.ShowId == query.ShowId)
.Select(e => e.ChannelId)
.Distinct()
.Join(
dbContext.Channels.AsNoTracking(),
id => id,
channel => channel.Id,
(_, channel) => new ShowUsageRefDto(channel.Id, channel.Name, false)
)
.ToListAsync(cancellationToken);
return Result.Success(
new ShowUsageDto(
await GroupsAsync(query.ShowId, collections, cancellationToken),
collections,
channels
)
);
}
/// <summary>
/// Группы считаем по вычисленному составу: у динамической шоу может входить правилом, без своей
/// позиции, и такое участие для «можно ли удалять» ничем не отличается от явного. Коллекции
/// учитываются заодно — шоу попадает в группу и через франшизу, целиком.
/// </summary>
private async Task<IReadOnlyList<ShowUsageRefDto>> GroupsAsync(
Guid showId,
IReadOnlyList<ShowUsageRefDto> collections,
CancellationToken cancellationToken
)
{
var groups = await dbContext
.Groups.AsNoTracking()
.Include(g => g.Items)
.ToListAsync(cancellationToken);
var collectionIds = collections.Select(c => c.Id).ToHashSet();
var result = new List<ShowUsageRefDto>();
foreach (var group in groups)
{
var composition = await dynamicResolver.ResolveAsync(group, cancellationToken);
var element = composition.FirstOrDefault(e =>
(e.Kind == GroupElementKind.Show && e.Id == showId)
|| (e.Kind == GroupElementKind.Collection && collectionIds.Contains(e.Id))
);
if (element is null)
continue;
result.Add(
new ShowUsageRefDto(group.Id, group.Name, !element.Pinned && HasRule(group))
);
}
return result;
}
private static bool HasRule(Domain.Programming.Group group) =>
group.Mode == GroupMode.Dynamic && group.FilterJson is not null;
}