From a54bc0e435abb18dccacd35ce1a855c2fa57ae31 Mon Sep 17 00:00:00 2001 From: Felix Date: Fri, 15 May 2026 19:05:01 +0300 Subject: [PATCH] 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. --- LME.UAKino/Controller.cs | 82 ++++++++++++--- LME.UAKino/Models/UAKinoModels.cs | 11 +- LME.UAKino/UAKinoInvoke.cs | 169 +++++++++++++++++++++++++----- 3 files changed, 216 insertions(+), 46 deletions(-) diff --git a/LME.UAKino/Controller.cs b/LME.UAKino/Controller.cs index 0374701..afa10e5 100644 --- a/LME.UAKino/Controller.cs +++ b/LME.UAKino/Controller.cs @@ -51,30 +51,46 @@ namespace LME.UAKino.Controllers if (string.IsNullOrEmpty(itemUrl)) { + // === ПЕРШИЙ ЗАПИТ: пошук === var searchResults = await invoke.Search(title, original_title, year, imdb_id); if (searchResults == null || searchResults.Count == 0) return OnError("lme_uakino", refresh_proxy: true); - // Якщо кілька результатів — дозволяємо обрати - if (searchResults.Count > 1) + if (serial == 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)}"; - similar_tpl.Append(res.Title, res.Year?.ToString() ?? "", res.OriginalTitle ?? "", link, res.Poster); + var sr = searchResults[0]; + 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"); } - - itemUrl = searchResults[0].Url; - newsId = searchResults[0].NewsId; + else + { + // Фільм + 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 { + // Повторний запит (з селектора сезонів або озвучок) newsId = UAKinoInvoke.ExtractNewsId(itemUrl); } @@ -87,7 +103,7 @@ namespace LME.UAKino.Controllers 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 { @@ -95,7 +111,40 @@ namespace LME.UAKino.Controllers } } - private ActionResult HandleSerial(OnlinesSettings init, List voices, string title, string original_title, int year, string imdb_id, long kinopoisk_id, string itemUrl, string t, bool rjson) + /// Вибір сезону для багатосезонного серіалу + 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"); + } + + /// Вибір між різними шоу/фільмами + private ActionResult ShowSimilarTpl(List 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"); + } + + /// Серіал: озвучки + епізоди + private ActionResult HandleSerial(OnlinesSettings init, List 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 episode_tpl = new EpisodeTpl(); @@ -105,7 +154,7 @@ namespace LME.UAKino.Controllers 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); } @@ -128,6 +177,7 @@ namespace LME.UAKino.Controllers : Content(episode_tpl.ToHtml(), "text/html; charset=utf-8"); } + /// Фільм: список стрімів private ActionResult HandleMovie(OnlinesSettings init, List voices, string title, string original_title, bool rjson) { var movie_tpl = new MovieTpl(title, original_title); diff --git a/LME.UAKino/Models/UAKinoModels.cs b/LME.UAKino/Models/UAKinoModels.cs index bb5943a..4efb767 100644 --- a/LME.UAKino/Models/UAKinoModels.cs +++ b/LME.UAKino/Models/UAKinoModels.cs @@ -6,10 +6,19 @@ namespace LME.UAKino.Models { public string Title { get; set; } public string OriginalTitle { get; set; } - public string Url { get; set; } public string Poster { get; set; } + /// Сезони серіалу. Для фільмів — один елемент без SeasonNumber + public List Seasons { get; set; } = new(); + /// Рік фільму (тільки для не-сезонних результатів) public int? Year { get; set; } + } + + public class SeasonEntry + { + public int SeasonNumber { get; set; } public string NewsId { get; set; } + public string Url { get; set; } + public int? Year { get; set; } } public class VoiceGroup diff --git a/LME.UAKino/UAKinoInvoke.cs b/LME.UAKino/UAKinoInvoke.cs index cf3c2de..ef4d760 100644 --- a/LME.UAKino/UAKinoInvoke.cs +++ b/LME.UAKino/UAKinoInvoke.cs @@ -39,7 +39,7 @@ namespace LME.UAKino string memKey = $"UAKino:search:{query}"; if (_hybridCache.TryGetValue(memKey, out List cached)) - return FilterByYear(cached, year); + return cached; try { @@ -73,11 +73,13 @@ namespace LME.UAKino var htmlDoc = new HtmlDocument(); htmlDoc.LoadHtml(html); - var results = ParseSearchResults(htmlDoc); + var rawItems = ParseRawItems(htmlDoc); + var results = GroupByShow(rawItems); + if (results.Count > 0) _hybridCache.Set(memKey, results, cacheTime(20)); - return FilterByYear(results, year); + return results; } catch (Exception ex) { @@ -152,12 +154,25 @@ namespace LME.UAKino return null; } - private List ParseSearchResults(HtmlDocument doc) + // ===================== Парсинг результатів пошуку ===================== + + /// Сирий елемент з HTML пошуку, до групування + private class RawSearchItem { - var results = new List(); + 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 ParseRawItems(HtmlDocument doc) + { + var items = new List(); var nodes = doc.DocumentNode.SelectNodes("//a[@class='search-result-link']"); if (nodes == null) - return results; + return items; foreach (var node in nodes) { @@ -186,9 +201,13 @@ namespace LME.UAKino year = parsedYear; } + // Фільтр: пропускаємо новини/трейлери — без року та без оригінальної назви + if (!IsRealContent(title, origTitle, year)) + continue; + string newsId = ExtractNewsId(href); - results.Add(new SearchResult + items.Add(new RawSearchItem { Title = title, OriginalTitle = origTitle, @@ -204,9 +223,121 @@ namespace LME.UAKino } } + return items; + } + + /// Фільтр: реальний контент (не новина/трейлер) + 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; + } + + /// Групування сирих елементів по назві шоу. Кожна група = один SearchResult зі списком сезонів + private List GroupByShow(List rawItems) + { + if (rawItems.Count == 0) + return new List(); + + var groups = new Dictionary>(); + + 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(); + + groups[key].Add(item); + } + + var results = new List(); + + 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; } + /// Витягти чисту назву шоу (без "N сезон" суфіксу) + private static string CleanShowTitle(string title) + { + if (string.IsNullOrEmpty(title)) + return title; + + return Regex.Replace(title, @"\s*\d+\s*сезон\s*$", "", RegexOptions.IgnoreCase).Trim(); + } + + /// Витягти номер сезону з назви + 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 ParsePlaylistHtml(string html) { var voices = new List(); @@ -217,7 +348,6 @@ namespace LME.UAKino var playerDiv = doc.DocumentNode.SelectSingleNode("//div[@class='playlists-player']"); if (playerDiv == null) { - // спроба знайти епізоди без обгортки playlists-player return ParseEpisodesFlat(doc.DocumentNode); } @@ -229,7 +359,6 @@ namespace LME.UAKino { string dataId = li.GetAttributeValue("data-id", ""); string text = CleanText(li.InnerText); - // Прибираємо "(X-Y)" з кінця назви озвучки string voiceName = Regex.Replace(text, @"\s*\(\d+[\d,\s-]*\)\s*$", "").Trim(); if (string.IsNullOrEmpty(voiceName)) voiceName = text; @@ -257,19 +386,15 @@ namespace LME.UAKino string voiceAttr = li.GetAttributeValue("data-voice", ""); string text = CleanText(li.InnerText); - // Визначаємо до якого voice групи належить VoiceGroup targetVoice = null; - // Спершу за data-id if (!string.IsNullOrEmpty(dataId)) targetVoice = voices.FirstOrDefault(v => v.DataId == dataId); - // Якщо не знайшли, то за data-voice (назвою) if (targetVoice == null && !string.IsNullOrEmpty(voiceAttr)) targetVoice = voices.FirstOrDefault(v => v.Name.Equals(voiceAttr, StringComparison.OrdinalIgnoreCase)); - // Якщо досі не знайшли, беремо перший голос targetVoice ??= voices.FirstOrDefault(); int? epNum = ExtractEpisodeNumber(text); @@ -289,9 +414,6 @@ namespace LME.UAKino return voices; } - /// - /// Парсинг коли немає структури playlists-player (наприклад для фільмів) - /// private List ParseEpisodesFlat(HtmlNode scope) { var voices = new List(); @@ -309,7 +431,6 @@ namespace LME.UAKino foreach (var li in items) { string fileUrl = li.GetAttributeValue("data-file", ""); - string voiceAttr = li.GetAttributeValue("data-voice", ""); string text = CleanText(li.InnerText); int? epNum = ExtractEpisodeNumber(text); @@ -327,6 +448,8 @@ namespace LME.UAKino return voices; } + // ===================== Допоміжні методи ===================== + private static string BuildSearchQuery(string title, string original_title, string imdb_id) { if (!string.IsNullOrEmpty(imdb_id) && imdb_id.StartsWith("tt")) @@ -341,18 +464,6 @@ namespace LME.UAKino return null; } - private static List FilterByYear(List 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) { if (string.IsNullOrEmpty(url))