refactor(uakino): restructure search results into grouped season entries

Replace flat search result handling with a grouped model where each show
contains a list of season entries, enabling deterministic serial flow for
single-show and multi-season matches.

Update controller logic to branch serial/movie processing against grouped
results, add explicit season selection handling, and reuse selected season
URLs on follow-up requests.

Adjust search parsing to collect raw items, filter non-content entries, and
group by normalized show identity before caching, removing early year-based
filtering from cached returns.
This commit is contained in:
Felix 2026-05-15 19:05:01 +03:00
parent 317cb6292c
commit a54bc0e435
3 changed files with 216 additions and 46 deletions

View File

@ -51,30 +51,46 @@ namespace LME.UAKino.Controllers
if (string.IsNullOrEmpty(itemUrl)) if (string.IsNullOrEmpty(itemUrl))
{ {
// === ПЕРШИЙ ЗАПИТ: пошук ===
var searchResults = await invoke.Search(title, original_title, year, imdb_id); var searchResults = await invoke.Search(title, original_title, year, imdb_id);
if (searchResults == null || searchResults.Count == 0) if (searchResults == null || searchResults.Count == 0)
return OnError("lme_uakino", refresh_proxy: true); return OnError("lme_uakino", refresh_proxy: true);
// Якщо кілька результатів — дозволяємо обрати if (serial == 1)
if (searchResults.Count > 1)
{ {
var similar_tpl = new SimilarTpl(searchResults.Count); // Серіал
foreach (var res in searchResults) if (searchResults.Count == 1)
{ {
string link = $"{host}/lite/lme_uakino?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial={serial}&href={HttpUtility.UrlEncode(res.Url)}"; var sr = searchResults[0];
similar_tpl.Append(res.Title, res.Year?.ToString() ?? "", res.OriginalTitle ?? "", link, res.Poster); if (sr.Seasons.Count > 1 && s == -1)
{
// Кілька сезонів — показуємо SeasonTpl для вибору
return HandleSeasonSelection(sr, id, imdb_id, kinopoisk_id, title, original_title, year, rjson);
}
// Один сезон — використовуємо його
itemUrl = sr.Seasons[0].Url;
newsId = sr.Seasons[0].NewsId;
}
else
{
// Кілька різних шоу — обирає
return ShowSimilarTpl(searchResults, id, imdb_id, kinopoisk_id, title, original_title, year, serial, rjson);
} }
return rjson
? Content(similar_tpl.ToJson(), "application/json; charset=utf-8")
: Content(similar_tpl.ToHtml(), "text/html; charset=utf-8");
} }
else
itemUrl = searchResults[0].Url; {
newsId = searchResults[0].NewsId; // Фільм
if (searchResults.Count > 1)
{
return ShowSimilarTpl(searchResults, id, imdb_id, kinopoisk_id, title, original_title, year, serial, rjson);
}
itemUrl = searchResults[0].Seasons[0].Url;
newsId = searchResults[0].Seasons[0].NewsId;
}
} }
else else
{ {
// Повторний запит (з селектора сезонів або озвучок)
newsId = UAKinoInvoke.ExtractNewsId(itemUrl); newsId = UAKinoInvoke.ExtractNewsId(itemUrl);
} }
@ -87,7 +103,7 @@ namespace LME.UAKino.Controllers
if (serial == 1) if (serial == 1)
{ {
return HandleSerial(init, voices, title, original_title, year, imdb_id, kinopoisk_id, itemUrl, t, rjson); return HandleSerial(init, voices, title, original_title, imdb_id, kinopoisk_id, itemUrl, s, t, rjson);
} }
else else
{ {
@ -95,7 +111,40 @@ namespace LME.UAKino.Controllers
} }
} }
private ActionResult HandleSerial(OnlinesSettings init, List<VoiceGroup> voices, string title, string original_title, int year, string imdb_id, long kinopoisk_id, string itemUrl, string t, bool rjson) /// <summary>Вибір сезону для багатосезонного серіалу</summary>
private ActionResult HandleSeasonSelection(SearchResult sr, long id, string imdb_id, long kinopoisk_id, string title, string original_title, int year, bool rjson)
{
var season_tpl = new SeasonTpl(sr.Seasons.Count);
foreach (var season in sr.Seasons)
{
string link = $"{host}/lite/lme_uakino?id={id}&imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={season.SeasonNumber}&href={HttpUtility.UrlEncode(season.Url)}";
season_tpl.Append($"Сезон {season.SeasonNumber}", link, season.SeasonNumber.ToString());
}
return rjson
? Content(season_tpl.ToJson(), "application/json; charset=utf-8")
: Content(season_tpl.ToHtml(), "text/html; charset=utf-8");
}
/// <summary>Вибір між різними шоу/фільмами</summary>
private ActionResult ShowSimilarTpl(List<SearchResult> searchResults, long id, string imdb_id, long kinopoisk_id, string title, string original_title, int year, int serial, bool rjson)
{
var similar_tpl = new SimilarTpl(searchResults.Count);
foreach (var res in searchResults)
{
string seasonUrl = res.Seasons.Count > 0 ? res.Seasons[0].Url : "";
string yearStr = res.Seasons.Count > 0 ? (res.Seasons[0].Year?.ToString() ?? "") : (res.Year?.ToString() ?? "");
string link = $"{host}/lite/lme_uakino?id={id}&imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial={serial}&href={HttpUtility.UrlEncode(seasonUrl)}";
similar_tpl.Append(res.Title, yearStr, res.OriginalTitle ?? "", link, res.Poster);
}
return rjson
? Content(similar_tpl.ToJson(), "application/json; charset=utf-8")
: Content(similar_tpl.ToHtml(), "text/html; charset=utf-8");
}
/// <summary>Серіал: озвучки + епізоди</summary>
private ActionResult HandleSerial(OnlinesSettings init, List<VoiceGroup> voices, string title, string original_title, string imdb_id, long kinopoisk_id, string itemUrl, int s, string t, bool rjson)
{ {
var voice_tpl = new VoiceTpl(); var voice_tpl = new VoiceTpl();
var episode_tpl = new EpisodeTpl(); var episode_tpl = new EpisodeTpl();
@ -105,7 +154,7 @@ namespace LME.UAKino.Controllers
foreach (var voice in voices) foreach (var voice in voices)
{ {
string voiceLink = $"{host}/lite/lme_uakino?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&t={voice.DataId}&href={HttpUtility.UrlEncode(itemUrl)}"; string voiceLink = $"{host}/lite/lme_uakino?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&serial=1&s={s}&t={voice.DataId}&href={HttpUtility.UrlEncode(itemUrl)}";
voice_tpl.Append(voice.Name, voice.DataId == t, voiceLink); voice_tpl.Append(voice.Name, voice.DataId == t, voiceLink);
} }
@ -128,6 +177,7 @@ namespace LME.UAKino.Controllers
: Content(episode_tpl.ToHtml(), "text/html; charset=utf-8"); : Content(episode_tpl.ToHtml(), "text/html; charset=utf-8");
} }
/// <summary>Фільм: список стрімів</summary>
private ActionResult HandleMovie(OnlinesSettings init, List<VoiceGroup> voices, string title, string original_title, bool rjson) private ActionResult HandleMovie(OnlinesSettings init, List<VoiceGroup> voices, string title, string original_title, bool rjson)
{ {
var movie_tpl = new MovieTpl(title, original_title); var movie_tpl = new MovieTpl(title, original_title);

View File

@ -6,10 +6,19 @@ namespace LME.UAKino.Models
{ {
public string Title { get; set; } public string Title { get; set; }
public string OriginalTitle { get; set; } public string OriginalTitle { get; set; }
public string Url { get; set; }
public string Poster { get; set; } public string Poster { get; set; }
/// <summary>Сезони серіалу. Для фільмів — один елемент без SeasonNumber</summary>
public List<SeasonEntry> Seasons { get; set; } = new();
/// <summary>Рік фільму (тільки для не-сезонних результатів)</summary>
public int? Year { get; set; } public int? Year { get; set; }
}
public class SeasonEntry
{
public int SeasonNumber { get; set; }
public string NewsId { get; set; } public string NewsId { get; set; }
public string Url { get; set; }
public int? Year { get; set; }
} }
public class VoiceGroup public class VoiceGroup

View File

@ -39,7 +39,7 @@ namespace LME.UAKino
string memKey = $"UAKino:search:{query}"; string memKey = $"UAKino:search:{query}";
if (_hybridCache.TryGetValue(memKey, out List<SearchResult> cached)) if (_hybridCache.TryGetValue(memKey, out List<SearchResult> cached))
return FilterByYear(cached, year); return cached;
try try
{ {
@ -73,11 +73,13 @@ namespace LME.UAKino
var htmlDoc = new HtmlDocument(); var htmlDoc = new HtmlDocument();
htmlDoc.LoadHtml(html); htmlDoc.LoadHtml(html);
var results = ParseSearchResults(htmlDoc); var rawItems = ParseRawItems(htmlDoc);
var results = GroupByShow(rawItems);
if (results.Count > 0) if (results.Count > 0)
_hybridCache.Set(memKey, results, cacheTime(20)); _hybridCache.Set(memKey, results, cacheTime(20));
return FilterByYear(results, year); return results;
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -152,12 +154,25 @@ namespace LME.UAKino
return null; return null;
} }
private List<SearchResult> ParseSearchResults(HtmlDocument doc) // ===================== Парсинг результатів пошуку =====================
/// <summary>Сирий елемент з HTML пошуку, до групування</summary>
private class RawSearchItem
{ {
var results = new List<SearchResult>(); public string Title { get; set; }
public string OriginalTitle { get; set; }
public string Url { get; set; }
public string Poster { get; set; }
public int? Year { get; set; }
public string NewsId { get; set; }
}
private List<RawSearchItem> ParseRawItems(HtmlDocument doc)
{
var items = new List<RawSearchItem>();
var nodes = doc.DocumentNode.SelectNodes("//a[@class='search-result-link']"); var nodes = doc.DocumentNode.SelectNodes("//a[@class='search-result-link']");
if (nodes == null) if (nodes == null)
return results; return items;
foreach (var node in nodes) foreach (var node in nodes)
{ {
@ -186,9 +201,13 @@ namespace LME.UAKino
year = parsedYear; year = parsedYear;
} }
// Фільтр: пропускаємо новини/трейлери — без року та без оригінальної назви
if (!IsRealContent(title, origTitle, year))
continue;
string newsId = ExtractNewsId(href); string newsId = ExtractNewsId(href);
results.Add(new SearchResult items.Add(new RawSearchItem
{ {
Title = title, Title = title,
OriginalTitle = origTitle, OriginalTitle = origTitle,
@ -204,9 +223,121 @@ namespace LME.UAKino
} }
} }
return items;
}
/// <summary>Фільтр: реальний контент (не новина/трейлер)</summary>
private static bool IsRealContent(string title, string origTitle, int? year)
{
// Є рік — контент
if (year.HasValue)
return true;
// Є оригінальна назва — контент
if (!string.IsNullOrEmpty(origTitle))
return true;
// Дуже довга назва без року — скоріше новина
if (!string.IsNullOrEmpty(title) && title.Length > 50)
return false;
return false;
}
/// <summary>Групування сирих елементів по назві шоу. Кожна група = один SearchResult зі списком сезонів</summary>
private List<SearchResult> GroupByShow(List<RawSearchItem> rawItems)
{
if (rawItems.Count == 0)
return new List<SearchResult>();
var groups = new Dictionary<string, List<RawSearchItem>>();
foreach (var item in rawItems)
{
string cleanTitle = CleanShowTitle(item.Title);
string key = $"{cleanTitle.ToLowerInvariant()}|{(item.OriginalTitle ?? "").ToLowerInvariant()}";
if (!groups.ContainsKey(key))
groups[key] = new List<RawSearchItem>();
groups[key].Add(item);
}
var results = new List<SearchResult>();
foreach (var kvp in groups)
{
var items = kvp.Value;
var first = items[0];
string showTitle = CleanShowTitle(first.Title);
var sr = new SearchResult
{
Title = showTitle,
OriginalTitle = first.OriginalTitle,
Poster = first.Poster
};
foreach (var item in items)
{
int? seasonNum = ExtractSeasonNumber(item.Title);
if (seasonNum.HasValue)
{
sr.Seasons.Add(new SeasonEntry
{
SeasonNumber = seasonNum.Value,
NewsId = item.NewsId,
Url = item.Url,
Year = item.Year
});
}
else
{
// Фільм або контент без сезону
sr.Seasons.Add(new SeasonEntry
{
SeasonNumber = 1,
NewsId = item.NewsId,
Url = item.Url,
Year = item.Year
});
sr.Year = item.Year;
}
}
// Сортуємо сезони за номером
sr.Seasons = sr.Seasons.OrderBy(s => s.SeasonNumber).ToList();
results.Add(sr);
}
return results; return results;
} }
/// <summary>Витягти чисту назву шоу (без "N сезон" суфіксу)</summary>
private static string CleanShowTitle(string title)
{
if (string.IsNullOrEmpty(title))
return title;
return Regex.Replace(title, @"\s*\d+\s*сезон\s*$", "", RegexOptions.IgnoreCase).Trim();
}
/// <summary>Витягти номер сезону з назви</summary>
private static int? ExtractSeasonNumber(string title)
{
if (string.IsNullOrEmpty(title))
return null;
var match = Regex.Match(title, @"(\d+)\s*сезон", RegexOptions.IgnoreCase);
if (match.Success && int.TryParse(match.Groups[1].Value, out int num))
return num;
return null;
}
// ===================== Парсинг плейлиста =====================
private List<VoiceGroup> ParsePlaylistHtml(string html) private List<VoiceGroup> ParsePlaylistHtml(string html)
{ {
var voices = new List<VoiceGroup>(); var voices = new List<VoiceGroup>();
@ -217,7 +348,6 @@ namespace LME.UAKino
var playerDiv = doc.DocumentNode.SelectSingleNode("//div[@class='playlists-player']"); var playerDiv = doc.DocumentNode.SelectSingleNode("//div[@class='playlists-player']");
if (playerDiv == null) if (playerDiv == null)
{ {
// спроба знайти епізоди без обгортки playlists-player
return ParseEpisodesFlat(doc.DocumentNode); return ParseEpisodesFlat(doc.DocumentNode);
} }
@ -229,7 +359,6 @@ namespace LME.UAKino
{ {
string dataId = li.GetAttributeValue("data-id", ""); string dataId = li.GetAttributeValue("data-id", "");
string text = CleanText(li.InnerText); string text = CleanText(li.InnerText);
// Прибираємо "(X-Y)" з кінця назви озвучки
string voiceName = Regex.Replace(text, @"\s*\(\d+[\d,\s-]*\)\s*$", "").Trim(); string voiceName = Regex.Replace(text, @"\s*\(\d+[\d,\s-]*\)\s*$", "").Trim();
if (string.IsNullOrEmpty(voiceName)) if (string.IsNullOrEmpty(voiceName))
voiceName = text; voiceName = text;
@ -257,19 +386,15 @@ namespace LME.UAKino
string voiceAttr = li.GetAttributeValue("data-voice", ""); string voiceAttr = li.GetAttributeValue("data-voice", "");
string text = CleanText(li.InnerText); string text = CleanText(li.InnerText);
// Визначаємо до якого voice групи належить
VoiceGroup targetVoice = null; VoiceGroup targetVoice = null;
// Спершу за data-id
if (!string.IsNullOrEmpty(dataId)) if (!string.IsNullOrEmpty(dataId))
targetVoice = voices.FirstOrDefault(v => v.DataId == dataId); targetVoice = voices.FirstOrDefault(v => v.DataId == dataId);
// Якщо не знайшли, то за data-voice (назвою)
if (targetVoice == null && !string.IsNullOrEmpty(voiceAttr)) if (targetVoice == null && !string.IsNullOrEmpty(voiceAttr))
targetVoice = voices.FirstOrDefault(v => targetVoice = voices.FirstOrDefault(v =>
v.Name.Equals(voiceAttr, StringComparison.OrdinalIgnoreCase)); v.Name.Equals(voiceAttr, StringComparison.OrdinalIgnoreCase));
// Якщо досі не знайшли, беремо перший голос
targetVoice ??= voices.FirstOrDefault(); targetVoice ??= voices.FirstOrDefault();
int? epNum = ExtractEpisodeNumber(text); int? epNum = ExtractEpisodeNumber(text);
@ -289,9 +414,6 @@ namespace LME.UAKino
return voices; return voices;
} }
/// <summary>
/// Парсинг коли немає структури playlists-player (наприклад для фільмів)
/// </summary>
private List<VoiceGroup> ParseEpisodesFlat(HtmlNode scope) private List<VoiceGroup> ParseEpisodesFlat(HtmlNode scope)
{ {
var voices = new List<VoiceGroup>(); var voices = new List<VoiceGroup>();
@ -309,7 +431,6 @@ namespace LME.UAKino
foreach (var li in items) foreach (var li in items)
{ {
string fileUrl = li.GetAttributeValue("data-file", ""); string fileUrl = li.GetAttributeValue("data-file", "");
string voiceAttr = li.GetAttributeValue("data-voice", "");
string text = CleanText(li.InnerText); string text = CleanText(li.InnerText);
int? epNum = ExtractEpisodeNumber(text); int? epNum = ExtractEpisodeNumber(text);
@ -327,6 +448,8 @@ namespace LME.UAKino
return voices; return voices;
} }
// ===================== Допоміжні методи =====================
private static string BuildSearchQuery(string title, string original_title, string imdb_id) private static string BuildSearchQuery(string title, string original_title, string imdb_id)
{ {
if (!string.IsNullOrEmpty(imdb_id) && imdb_id.StartsWith("tt")) if (!string.IsNullOrEmpty(imdb_id) && imdb_id.StartsWith("tt"))
@ -341,18 +464,6 @@ namespace LME.UAKino
return null; return null;
} }
private static List<SearchResult> FilterByYear(List<SearchResult> results, int year)
{
if (results == null || results.Count <= 1 || year <= 0)
return results;
var yearMatch = results.Where(r => r.Year == year).ToList();
if (yearMatch.Count > 0)
return yearMatch;
return results;
}
private string NormalizeUrl(string url) private string NormalizeUrl(string url)
{ {
if (string.IsNullOrEmpty(url)) if (string.IsNullOrEmpty(url))