Files
TeleWave/backend/src/TeleWave.Application/Library/Collections/Suggest/CollectionSuggestionBuilder.cs
T
Leonid Pershin 5ad28b2966
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
Implement collection suggestions and bulk show addition features
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.
2026-07-27 05:39:23 +03:00

240 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
);
}