using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.Collections.Suggest;
/// Предложение франшизы: имя и части в предлагаемом порядке.
public sealed record CollectionSuggestion(
string Key,
CollectionSuggestionSource Source,
string Name,
IReadOnlyList Parts
);
///
/// Ищет, что стоит собрать в коллекцию, среди полнометражек, ещё не входящих ни в одну.
///
/// Два источника, и они не равны по надёжности. Франшиза из метаданных — это факт: TMDb сам считает
/// эти фильмы одним целым. Догадка по названиям — эвристика для библиотеки без метаданных, и она
/// намеренно осторожна: совпадать должно осмысленное начало названия, а не первое слово, иначе
/// «Человек-паук» и «Человек дождя» уедут в одну франшизу.
///
/// Решение всё равно за человеком: предложение показывает состав целиком, а создаётся коллекция
/// кнопкой. Сериалы сюда не попадают — у них своя форма продолжения, сезоны.
///
public sealed class CollectionSuggestionBuilder(IAppDbContext dbContext)
{
/// Меньше двух частей — не франшиза.
private const int MinParts = 2;
///
/// Со скольких общих слов начало названия само по себе считается признаком франшизы:
/// «Harry Potter and the …» случайным совпадением не бывает, а «Человек …» — бывает.
///
private const int StrongCommonWords = 3;
///
/// Чем продолжение отличается от однофамильца: номер («Рокки 2», «Рокки III»), слово «часть»
/// либо подзаголовок через двоеточие или тире («Матрица: Перезагрузка»). У «Человек дождя»
/// и «Человек-слон» хвосты — обычные слова, и в одну франшизу они не попадут.
///
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> 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();
var taken = new HashSet();
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 ByMetadata(List 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)
);
}
///
/// Догадка по названиям для библиотеки без метаданных. Сравниваются целые слова, а не символы:
/// посимвольное совпадение слишком легко ловит однофамильцев («Человек дождя» и «Человек-слон»).
/// Пара считается франшизой, если общее начало длинное либо хвосты обеих частей выглядят
/// продолжением. Имя предложения — общее начало, очищенное от хвостовых предлогов и знаков.
///
private static IEnumerable ByName(List movies)
{
var used = new HashSet();
// От длинных названий к коротким: так более специфичная франшиза собирается первой.
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)
);
}
}
/// Части идут по году: у франшизы это и есть порядок просмотра.
private static IReadOnlyList Parts(IEnumerable 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)),
];
/// Сравниваем по оригинальному названию, если оно есть: у релизов оно стабильнее.
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);
}
/// Хвост после общего начала: пусто — это первая часть, иначе должен выглядеть продолжением.
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);
///
/// Общее начало обрывается на союзе или знаке: «Harry Potter and the» — это ещё не имя франшизы.
/// Чистим, пока есть что убирать: за один проход «and the» не снять.
///
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
);
}