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 ); }