diff --git a/Makhno/Controller.cs b/Makhno/Controller.cs new file mode 100644 index 0000000..4b93362 --- /dev/null +++ b/Makhno/Controller.cs @@ -0,0 +1,403 @@ +using Shared.Engine; +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Linq; +using System.Web; +using Shared; +using Shared.Models.Templates; +using Shared.Models.Online.Settings; +using Shared.Models; +using Makhno.Models; + +namespace Makhno +{ + [Route("makhno")] + public class MakhnoController : BaseOnlineController + { + private readonly ProxyManager proxyManager; + + public MakhnoController() : base(ModInit.Settings) + { + proxyManager = new ProxyManager(ModInit.Makhno); + } + + [HttpGet] + public async Task Index(long id, string imdb_id, long kinopoisk_id, string title, string original_title, string original_language, int year, string source, int serial, string account_email, string t, int s = -1, int season = -1, bool rjson = false) + { + await UpdateService.ConnectAsync(host); + + var init = await loadKit(ModInit.Makhno); + if (!init.enable) + return OnError(); + Initialization(init); + + OnLog($"Makhno: {title} (serial={serial}, s={s}, season={season}, t={t})"); + + var invoke = new MakhnoInvoke(init, hybridCache, OnLog, proxyManager); + + var resolved = await ResolvePlaySource(imdb_id, title, original_title, year, serial, invoke); + if (resolved == null || string.IsNullOrEmpty(resolved.PlayUrl)) + return OnError(); + + if (resolved.ShouldEnrich) + { + _ = Task.Run(async () => + { + try + { + await EnrichWormhole(imdb_id, title, original_title, year, resolved, invoke); + } + catch (Exception ex) + { + OnLog($"Makhno wormhole enrich failed: {ex.Message}"); + } + }); + } + + if (resolved.IsSerial) + return await HandleSerial(resolved.PlayUrl, imdb_id, title, original_title, year, t, season, rjson, invoke); + + return await HandleMovie(resolved.PlayUrl, imdb_id, title, original_title, year, rjson, invoke); + } + + [HttpGet] + [Route("play")] + public async Task Play(long id, string imdb_id, long kinopoisk_id, string title, string original_title, int year, int s, int season, string t, string episodeId, bool play = false, bool rjson = false) + { + await UpdateService.ConnectAsync(host); + + var init = await loadKit(ModInit.Makhno); + if (!init.enable) + return OnError(); + Initialization(init); + + OnLog($"Makhno Play: {title} (s={s}, season={season}, t={t}, episodeId={episodeId}) play={play}"); + + var invoke = new MakhnoInvoke(init, hybridCache, OnLog, proxyManager); + var resolved = await ResolvePlaySource(imdb_id, title, original_title, year, serial: 1, invoke); + if (resolved == null || string.IsNullOrEmpty(resolved.PlayUrl)) + return OnError(); + + var playerData = await InvokeCache($"makhno:player:{resolved.PlayUrl}", TimeSpan.FromMinutes(10), async () => + { + return await invoke.GetPlayerData(resolved.PlayUrl); + }); + + if (playerData?.Voices == null || !playerData.Voices.Any()) + return OnError(); + + if (string.IsNullOrEmpty(t) || !int.TryParse(t, out int voiceIndex) || voiceIndex >= playerData.Voices.Count) + return OnError(); + + var selectedVoice = playerData.Voices[voiceIndex]; + if (season < 0 || season >= selectedVoice.Seasons.Count) + return OnError(); + + var selectedSeason = selectedVoice.Seasons[season]; + foreach (var episode in selectedSeason.Episodes) + { + if (episode.Id == episodeId && !string.IsNullOrEmpty(episode.File)) + { + OnLog($"Makhno Play: Found episode {episode.Title}, stream: {episode.File}"); + + string streamUrl = BuildStreamUrl(init, episode.File); + string episodeTitle = $"{title ?? original_title} - {episode.Title}"; + + if (play) + return UpdateService.Validate(Redirect(streamUrl)); + + return UpdateService.Validate(Content(VideoTpl.ToJson("play", streamUrl, episodeTitle), "application/json; charset=utf-8")); + } + } + + OnLog("Makhno Play: Episode not found"); + return OnError(); + } + + [HttpGet] + [Route("play/movie")] + public async Task PlayMovie(long id, string imdb_id, string title, string original_title, int year, bool play = false, bool rjson = false) + { + await UpdateService.ConnectAsync(host); + + var init = await loadKit(ModInit.Makhno); + if (!init.enable) + return OnError(); + Initialization(init); + + OnLog($"Makhno PlayMovie: {title} ({year}) play={play}"); + + var invoke = new MakhnoInvoke(init, hybridCache, OnLog, proxyManager); + var resolved = await ResolvePlaySource(imdb_id, title, original_title, year, serial: 0, invoke); + if (resolved == null || string.IsNullOrEmpty(resolved.PlayUrl)) + return OnError(); + + var playerData = await InvokeCache($"makhno:player:{resolved.PlayUrl}", TimeSpan.FromMinutes(10), async () => + { + return await invoke.GetPlayerData(resolved.PlayUrl); + }); + + if (playerData?.File == null) + return OnError(); + + string streamUrl = BuildStreamUrl(init, playerData.File); + + if (play) + return UpdateService.Validate(Redirect(streamUrl)); + + return UpdateService.Validate(Content(VideoTpl.ToJson("play", streamUrl, title ?? original_title), "application/json; charset=utf-8")); + } + + private async Task HandleMovie(string playUrl, string imdb_id, string title, string original_title, int year, bool rjson, MakhnoInvoke invoke) + { + var init = ModInit.Makhno; + var playerData = await InvokeCache($"makhno:player:{playUrl}", TimeSpan.FromMinutes(10), async () => + { + return await invoke.GetPlayerData(playUrl); + }); + + if (playerData?.File == null) + return OnError(); + + string movieLink = $"{host}/makhno/play/movie?imdb_id={HttpUtility.UrlEncode(imdb_id)}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&play=true"; + var tpl = new MovieTpl(title ?? original_title, original_title, 1); + tpl.Append(title ?? original_title, accsArgs(movieLink), method: "play"); + + return rjson ? Content(tpl.ToJson(), "application/json; charset=utf-8") : Content(tpl.ToHtml(), "text/html; charset=utf-8"); + } + + private async Task HandleSerial(string playUrl, string imdb_id, string title, string original_title, int year, string t, int season, bool rjson, MakhnoInvoke invoke) + { + var init = ModInit.Makhno; + + var playerData = await InvokeCache($"makhno:player:{playUrl}", TimeSpan.FromMinutes(10), async () => + { + return await invoke.GetPlayerData(playUrl); + }); + + if (playerData?.Voices == null || !playerData.Voices.Any()) + return OnError(); + + if (season == -1) + { + var firstVoice = playerData.Voices.First(); + var season_tpl = new SeasonTpl(); + for (int i = 0; i < firstVoice.Seasons.Count; i++) + { + var seasonItem = firstVoice.Seasons[i]; + string seasonName = seasonItem.Title ?? $"Сезон {i + 1}"; + string link = $"{host}/makhno?imdb_id={imdb_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&season={i}"; + season_tpl.Append(seasonName, link, i.ToString()); + } + + return rjson ? Content(season_tpl.ToJson(), "application/json; charset=utf-8") : Content(season_tpl.ToHtml(), "text/html; charset=utf-8"); + } + + if (season < 0 || season >= playerData.Voices.First().Seasons.Count) + return OnError(); + + var voice_tpl = new VoiceTpl(); + var episode_tpl = new EpisodeTpl(); + + string selectedVoice = t; + if (string.IsNullOrEmpty(selectedVoice) && playerData.Voices.Any()) + { + selectedVoice = "0"; + } + + for (int i = 0; i < playerData.Voices.Count; i++) + { + var voice = playerData.Voices[i]; + string voiceName = voice.Name ?? $"Озвучка {i + 1}"; + string voiceLink = $"{host}/makhno?imdb_id={imdb_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&season={season}&t={i}"; + bool isActive = selectedVoice == i.ToString(); + voice_tpl.Append(voiceName, isActive, voiceLink); + } + + if (!string.IsNullOrEmpty(selectedVoice) && int.TryParse(selectedVoice, out int voiceIndex) && voiceIndex < playerData.Voices.Count) + { + var selectedVoiceData = playerData.Voices[voiceIndex]; + if (season < selectedVoiceData.Seasons.Count) + { + var selectedSeason = selectedVoiceData.Seasons[season]; + var sortedEpisodes = selectedSeason.Episodes.OrderBy(e => ExtractEpisodeNumber(e.Title)).ToList(); + + for (int i = 0; i < sortedEpisodes.Count; i++) + { + var episode = sortedEpisodes[i]; + if (!string.IsNullOrEmpty(episode.File)) + { + string episodeLink = $"{host}/makhno/play?imdb_id={imdb_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&season={season}&t={selectedVoice}&episodeId={episode.Id}"; + episode_tpl.Append(episode.Title, title ?? original_title, season.ToString(), (i + 1).ToString("D2"), episodeLink, "call"); + } + } + } + } + + episode_tpl.Append(voice_tpl); + if (rjson) + return Content(episode_tpl.ToJson(), "application/json; charset=utf-8"); + + return Content(episode_tpl.ToHtml(), "text/html; charset=utf-8"); + } + + private int ExtractEpisodeNumber(string title) + { + if (string.IsNullOrEmpty(title)) + return 0; + + var match = System.Text.RegularExpressions.Regex.Match(title, @"(\d+)"); + return match.Success ? int.Parse(match.Groups[1].Value) : 0; + } + + private async Task ResolvePlaySource(string imdbId, string title, string originalTitle, int year, int serial, MakhnoInvoke invoke) + { + string playUrl = null; + + if (!string.IsNullOrEmpty(imdbId)) + { + string cacheKey = $"makhno:wormhole:{imdbId}"; + playUrl = await InvokeCache(cacheKey, TimeSpan.FromMinutes(5), async () => + { + return await invoke.GetWormholePlay(imdbId); + }); + + if (!string.IsNullOrEmpty(playUrl)) + { + return new ResolveResult + { + PlayUrl = playUrl, + IsSerial = IsSerialByUrl(playUrl, serial), + ShouldEnrich = false + }; + } + } + + string searchQuery = originalTitle ?? title; + string searchCacheKey = $"makhno:uatut:search:{imdbId ?? searchQuery}"; + + var searchResults = await InvokeCache>(searchCacheKey, TimeSpan.FromMinutes(10), async () => + { + return await invoke.SearchUaTUT(searchQuery, imdbId); + }); + + if (searchResults == null || searchResults.Count == 0) + return null; + + var selected = invoke.SelectUaTUTItem(searchResults, imdbId, year > 0 ? year : null, title, originalTitle); + if (selected == null) + return null; + + var ashdiPath = await InvokeCache($"makhno:ashdi:{selected.Id}", TimeSpan.FromMinutes(10), async () => + { + return await invoke.GetAshdiPath(selected.Id); + }); + + if (string.IsNullOrEmpty(ashdiPath)) + return null; + + playUrl = invoke.BuildAshdiUrl(ashdiPath); + + bool isSerial = serial == 1 || IsSerialByCategory(selected.Category) || IsSerialByUrl(playUrl, serial); + + return new ResolveResult + { + PlayUrl = playUrl, + AshdiPath = ashdiPath, + Selected = selected, + IsSerial = isSerial, + ShouldEnrich = true + }; + } + + private bool IsSerialByCategory(string category) + { + if (string.IsNullOrWhiteSpace(category)) + return false; + + return category.Equals("Серіал", StringComparison.OrdinalIgnoreCase) + || category.Equals("Аніме", StringComparison.OrdinalIgnoreCase); + } + + private bool IsSerialByUrl(string url, int serial) + { + if (serial == 1) + return true; + + if (string.IsNullOrEmpty(url)) + return false; + + return url.Contains("/serial/", StringComparison.OrdinalIgnoreCase); + } + + private async Task EnrichWormhole(string imdbId, string title, string originalTitle, int year, ResolveResult resolved, MakhnoInvoke invoke) + { + if (string.IsNullOrWhiteSpace(imdbId) || resolved?.Selected == null || string.IsNullOrWhiteSpace(resolved.AshdiPath)) + return; + + int? yearValue = year > 0 ? year : null; + if (!yearValue.HasValue && int.TryParse(resolved.Selected.Year, out int parsedYear)) + yearValue = parsedYear; + + var tmdbResult = await invoke.FetchTmdbByImdb(imdbId, yearValue); + if (tmdbResult == null) + return; + + var (item, mediaType) = tmdbResult.Value; + var tmdbId = item.Value("id"); + if (!tmdbId.HasValue) + return; + + string original = item.Value("original_title") + ?? item.Value("original_name") + ?? resolved.Selected.TitleEn + ?? originalTitle + ?? title; + + string resultTitle = resolved.Selected.Title + ?? item.Value("title") + ?? item.Value("name"); + + var payload = new + { + imdb_id = imdbId, + _id = $"{mediaType}:{tmdbId.Value}", + original_title = original, + title = resultTitle, + serial = mediaType == "tv" ? 1 : 0, + ashdi = resolved.AshdiPath, + year = (resolved.Selected.Year ?? yearValue?.ToString()) + }; + + await invoke.PostWormholeAsync(payload); + } + + private string BuildStreamUrl(OnlinesSettings init, string streamLink) + { + string link = accsArgs(streamLink); + if (ApnHelper.IsEnabled(init)) + { + if (ModInit.ApnHostProvided || ApnHelper.IsAshdiUrl(link)) + return ApnHelper.WrapUrl(init, link); + + var noApn = (OnlinesSettings)init.Clone(); + noApn.apnstream = false; + noApn.apn = null; + return HostStreamProxy(noApn, link); + } + + return HostStreamProxy(init, link); + } + + private class ResolveResult + { + public string PlayUrl { get; set; } + public string AshdiPath { get; set; } + public SearchResult Selected { get; set; } + public bool IsSerial { get; set; } + public bool ShouldEnrich { get; set; } + } + } +} diff --git a/Makhno/Makhno.csproj b/Makhno/Makhno.csproj new file mode 100644 index 0000000..1fbe365 --- /dev/null +++ b/Makhno/Makhno.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + library + true + + + + + ..\..\Shared.dll + + + + diff --git a/Makhno/MakhnoInvoke.cs b/Makhno/MakhnoInvoke.cs new file mode 100644 index 0000000..c70513f --- /dev/null +++ b/Makhno/MakhnoInvoke.cs @@ -0,0 +1,592 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Shared; +using Shared.Engine; +using Shared.Models; +using Shared.Models.Online.Settings; +using Makhno.Models; + +namespace Makhno +{ + public class MakhnoInvoke + { + private const string WormholeHost = "http://wormhole.lampame.v6.rocks/"; + private const string AshdiHost = "https://ashdi.vip"; + + private readonly OnlinesSettings _init; + private readonly IHybridCache _hybridCache; + private readonly Action _onLog; + private readonly ProxyManager _proxyManager; + + public MakhnoInvoke(OnlinesSettings init, IHybridCache hybridCache, Action onLog, ProxyManager proxyManager) + { + _init = init; + _hybridCache = hybridCache; + _onLog = onLog; + _proxyManager = proxyManager; + } + + public async Task GetWormholePlay(string imdbId) + { + if (string.IsNullOrWhiteSpace(imdbId)) + return null; + + string url = $"{WormholeHost}?imdb_id={imdbId}"; + try + { + var headers = new List() + { + new HeadersModel("User-Agent", Http.UserAgent) + }; + + string response = await Http.Get(url, timeoutSeconds: 4, headers: headers, proxy: _proxyManager.Get()); + if (string.IsNullOrWhiteSpace(response)) + return null; + + var payload = JsonConvert.DeserializeObject(response); + return string.IsNullOrWhiteSpace(payload?.play) ? null : payload.play; + } + catch (Exception ex) + { + _onLog($"Makhno wormhole error: {ex.Message}"); + return null; + } + } + + public async Task> SearchUaTUT(string query, string imdbId = null) + { + try + { + string searchUrl = $"{_init.apihost}/search.php"; + + if (!string.IsNullOrEmpty(imdbId)) + { + var imdbResults = await PerformSearch(searchUrl, imdbId); + if (imdbResults?.Any() == true) + return imdbResults; + } + + if (!string.IsNullOrEmpty(query)) + { + var titleResults = await PerformSearch(searchUrl, query); + return titleResults ?? new List(); + } + + return new List(); + } + catch (Exception ex) + { + _onLog($"Makhno UaTUT search error: {ex.Message}"); + return new List(); + } + } + + private async Task> PerformSearch(string searchUrl, string query) + { + string url = $"{searchUrl}?q={WebUtility.UrlEncode(query)}"; + _onLog($"Makhno UaTUT searching: {url}"); + + var headers = new List() + { + new HeadersModel("User-Agent", Http.UserAgent) + }; + + var response = await Http.Get(url, headers: headers, proxy: _proxyManager.Get()); + + if (string.IsNullOrEmpty(response)) + return null; + + try + { + var results = JsonConvert.DeserializeObject>(response); + _onLog($"Makhno UaTUT found {results?.Count ?? 0} results for query: {query}"); + return results; + } + catch (Exception ex) + { + _onLog($"Makhno UaTUT parse error: {ex.Message}"); + return null; + } + } + + public async Task GetMoviePageContent(string movieId) + { + try + { + string url = $"{_init.apihost}/{movieId}"; + _onLog($"Makhno UaTUT getting movie page: {url}"); + + var headers = new List() + { + new HeadersModel("User-Agent", Http.UserAgent) + }; + var response = await Http.Get(url, headers: headers, proxy: _proxyManager.Get()); + + return response; + } + catch (Exception ex) + { + _onLog($"Makhno UaTUT GetMoviePageContent error: {ex.Message}"); + return null; + } + } + + public string GetPlayerUrl(string moviePageContent) + { + try + { + if (string.IsNullOrEmpty(moviePageContent)) + return null; + + var match = Regex.Match(moviePageContent, @"]*id=[""']vip-player[""'][^>]*src=[""']([^""']+)", RegexOptions.IgnoreCase); + if (match.Success) + return NormalizePlayerUrl(match.Groups[1].Value); + + match = Regex.Match(moviePageContent, @"]*id=[""']alt-player[""'][^>]*src=[""']([^""']+)", RegexOptions.IgnoreCase); + if (match.Success) + return NormalizePlayerUrl(match.Groups[1].Value); + + var iframeMatches = Regex.Matches(moviePageContent, @"]*(?:id=[""']([^""']+)[""'])?[^>]*src=[""']([^""']+)[""']", RegexOptions.IgnoreCase); + foreach (Match iframe in iframeMatches) + { + string iframeId = iframe.Groups[1].Value?.ToLowerInvariant(); + string src = iframe.Groups[2].Value; + if (string.IsNullOrEmpty(src)) + continue; + + if (!string.IsNullOrEmpty(iframeId) && iframeId.Contains("player")) + return NormalizePlayerUrl(src); + + if (src.Contains("ashdi.vip", StringComparison.OrdinalIgnoreCase) || + src.Contains("zetvideo.net", StringComparison.OrdinalIgnoreCase) || + src.Contains("player", StringComparison.OrdinalIgnoreCase)) + return NormalizePlayerUrl(src); + } + + var urlMatch = Regex.Match(moviePageContent, @"(https?://[^\"'\s>]+/(?:vod|serial)/\d+[^\"'\s>]*)", RegexOptions.IgnoreCase); + if (urlMatch.Success) + return NormalizePlayerUrl(urlMatch.Groups[1].Value); + + return null; + } + catch (Exception ex) + { + _onLog($"Makhno UaTUT GetPlayerUrl error: {ex.Message}"); + return null; + } + } + + private string NormalizePlayerUrl(string src) + { + if (string.IsNullOrEmpty(src)) + return null; + + if (src.StartsWith("//")) + return $"https:{src}"; + + return src; + } + + public async Task GetPlayerData(string playerUrl) + { + if (string.IsNullOrEmpty(playerUrl)) + return null; + + try + { + string requestUrl = playerUrl; + var headers = new List() + { + new HeadersModel("User-Agent", Http.UserAgent) + }; + + if (playerUrl.Contains("ashdi.vip", StringComparison.OrdinalIgnoreCase)) + { + headers.Add(new HeadersModel("Referer", "https://ashdi.vip/")); + } + + if (ApnHelper.IsAshdiUrl(playerUrl) && ApnHelper.IsEnabled(_init)) + requestUrl = ApnHelper.WrapUrl(_init, playerUrl); + + _onLog($"Makhno getting player data from: {requestUrl}"); + + var response = await Http.Get(requestUrl, headers: headers, proxy: _proxyManager.Get()); + if (string.IsNullOrEmpty(response)) + return null; + + return ParsePlayerData(response); + } + catch (Exception ex) + { + _onLog($"Makhno GetPlayerData error: {ex.Message}"); + return null; + } + } + + private PlayerData ParsePlayerData(string html) + { + try + { + if (string.IsNullOrEmpty(html)) + return null; + + var fileMatch = Regex.Match(html, @"file:'([^']+)'", RegexOptions.IgnoreCase); + if (!fileMatch.Success) + fileMatch = Regex.Match(html, @"file:\s*\"([^\"]+)\"", RegexOptions.IgnoreCase); + + if (fileMatch.Success && !fileMatch.Groups[1].Value.StartsWith("[")) + { + var posterMatch = Regex.Match(html, @"poster:[\"']([^\"']+)[\"']", RegexOptions.IgnoreCase); + return new PlayerData + { + File = fileMatch.Groups[1].Value, + Poster = posterMatch.Success ? posterMatch.Groups[1].Value : null, + Voices = new List() + }; + } + + var m3u8Match = Regex.Match(html, @"(https?://[^\"'\s>]+\.m3u8[^\"'\s>]*)", RegexOptions.IgnoreCase); + if (m3u8Match.Success) + { + return new PlayerData + { + File = m3u8Match.Groups[1].Value, + Poster = null, + Voices = new List() + }; + } + + var sourceMatch = Regex.Match(html, @"]*src=[\"']([^\"']+)[\"']", RegexOptions.IgnoreCase); + if (sourceMatch.Success) + { + return new PlayerData + { + File = sourceMatch.Groups[1].Value, + Poster = null, + Voices = new List() + }; + } + + var jsonMatch = Regex.Match(html, @"file:'(\[.*?\])'", RegexOptions.Singleline); + if (jsonMatch.Success) + { + string jsonData = jsonMatch.Groups[1].Value + .Replace("\\'", "'") + .Replace("\\\"", "\""); + + return new PlayerData + { + File = null, + Poster = null, + Voices = ParseVoicesJson(jsonData) + }; + } + + return null; + } + catch (Exception ex) + { + _onLog($"Makhno ParsePlayerData error: {ex.Message}"); + return null; + } + } + + private List ParseVoicesJson(string jsonData) + { + try + { + var voicesArray = JsonConvert.DeserializeObject>(jsonData); + var voices = new List(); + + if (voicesArray == null) + return voices; + + foreach (var voiceGroup in voicesArray) + { + var voice = new Voice + { + Name = voiceGroup["title"]?.ToString(), + Seasons = new List() + }; + + var seasons = voiceGroup["folder"] as JArray; + if (seasons != null) + { + foreach (var seasonGroup in seasons) + { + string seasonTitle = seasonGroup["title"]?.ToString() ?? string.Empty; + var episodes = new List(); + + var episodesArray = seasonGroup["folder"] as JArray; + if (episodesArray != null) + { + foreach (var episode in episodesArray) + { + episodes.Add(new Episode + { + Id = episode["id"]?.ToString(), + Title = episode["title"]?.ToString(), + File = episode["file"]?.ToString(), + Poster = episode["poster"]?.ToString(), + Subtitle = episode["subtitle"]?.ToString() + }); + } + } + + episodes = episodes + .OrderBy(item => ExtractEpisodeNumber(item.Title) is null) + .ThenBy(item => ExtractEpisodeNumber(item.Title) ?? 0) + .ToList(); + + voice.Seasons.Add(new Season + { + Title = seasonTitle, + Episodes = episodes + }); + } + } + + voices.Add(voice); + } + + return voices; + } + catch (Exception ex) + { + _onLog($"Makhno ParseVoicesJson error: {ex.Message}"); + return new List(); + } + } + + private int? ExtractEpisodeNumber(string value) + { + if (string.IsNullOrEmpty(value)) + return null; + + var match = Regex.Match(value, @"(\d+)"); + if (!match.Success) + return null; + + if (int.TryParse(match.Groups[1].Value, out int num)) + return num; + + return null; + } + + public string ExtractAshdiPath(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + var match = Regex.Match(value, @"https?://(?:www\.)?ashdi\.vip/((?:vod|serial)/\d+)", RegexOptions.IgnoreCase); + if (match.Success) + return match.Groups[1].Value; + + match = Regex.Match(value, @"\b((?:vod|serial)/\d+)\b", RegexOptions.IgnoreCase); + if (match.Success) + return match.Groups[1].Value; + + return null; + } + + public string BuildAshdiUrl(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return null; + + if (path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + return path; + + return $"{AshdiHost}/{path.TrimStart('/')}"; + } + + public async Task GetAshdiPath(string movieId) + { + if (string.IsNullOrWhiteSpace(movieId)) + return null; + + var page = await GetMoviePageContent(movieId); + if (string.IsNullOrWhiteSpace(page)) + return null; + + var playerUrl = GetPlayerUrl(page); + var path = ExtractAshdiPath(playerUrl); + if (!string.IsNullOrWhiteSpace(path)) + return path; + + return ExtractAshdiPath(page); + } + + public SearchResult SelectUaTUTItem(List items, string imdbId, int? year, string title, string titleEn) + { + if (items == null || items.Count == 0) + return null; + + var candidates = items.Where(item => ImdbMatch(item, imdbId) && YearMatch(item, year)).ToList(); + if (candidates.Count == 1) + return candidates[0]; + if (candidates.Count > 1) + return null; + + candidates = items.Where(item => ImdbMatch(item, imdbId) && TitleMatch(item, title, titleEn)).ToList(); + if (candidates.Count == 1) + return candidates[0]; + if (candidates.Count > 1) + return null; + + candidates = items.Where(item => YearMatch(item, year) && TitleMatch(item, title, titleEn)).ToList(); + if (candidates.Count == 1) + return candidates[0]; + + return null; + } + + private bool ImdbMatch(SearchResult item, string imdbId) + { + if (string.IsNullOrWhiteSpace(imdbId) || item == null) + return false; + + return string.Equals(item.ImdbId?.Trim(), imdbId.Trim(), StringComparison.OrdinalIgnoreCase); + } + + private bool YearMatch(SearchResult item, int? year) + { + if (year == null || item == null) + return false; + + var itemYear = YearInt(item.Year); + return itemYear.HasValue && itemYear.Value == year.Value; + } + + private bool TitleMatch(SearchResult item, string title, string titleEn) + { + if (item == null) + return false; + + string itemTitle = NormalizeTitle(item.Title); + string itemTitleEn = NormalizeTitle(item.TitleEn); + string targetTitle = NormalizeTitle(title); + string targetTitleEn = NormalizeTitle(titleEn); + + return (itemTitle.Length > 0 && targetTitle.Length > 0 && itemTitle == targetTitle) + || (itemTitle.Length > 0 && targetTitleEn.Length > 0 && itemTitle == targetTitleEn) + || (itemTitleEn.Length > 0 && targetTitle.Length > 0 && itemTitleEn == targetTitle) + || (itemTitleEn.Length > 0 && targetTitleEn.Length > 0 && itemTitleEn == targetTitleEn); + } + + private string NormalizeTitle(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + string text = value.ToLowerInvariant(); + text = Regex.Replace(text, @"[^\w\s]+", " "); + text = Regex.Replace(text, @"\b(season|сезон|частина|part|ova|special|movie|film)\b", " "); + text = Regex.Replace(text, @"\b(\d+)(st|nd|rd|th)\b", "$1"); + text = Regex.Replace(text, @"\b\d+\b", " "); + text = Regex.Replace(text, @"\s+", " "); + return text.Trim(); + } + + private int? YearInt(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + if (int.TryParse(value.Trim(), out int result)) + return result; + + return null; + } + + public async Task<(JObject item, string mediaType)?> FetchTmdbByImdb(string imdbId, int? year) + { + if (string.IsNullOrWhiteSpace(imdbId)) + return null; + + try + { + string apiKey = AppInit.conf?.tmdb?.api_key; + if (string.IsNullOrWhiteSpace(apiKey)) + return null; + + string tmdbUrl = $"http://{AppInit.conf.listen.localhost}:{AppInit.conf.listen.port}/tmdb/api/3/find/{imdbId}?external_source=imdb_id&api_key={apiKey}&language=en-US"; + + var headers = new List() + { + new HeadersModel("User-Agent", Http.UserAgent) + }; + + JObject payload = await Http.Get(tmdbUrl, timeoutSeconds: 6, headers: headers); + if (payload == null) + return null; + + var movieResults = payload["movie_results"] as JArray ?? new JArray(); + var tvResults = payload["tv_results"] as JArray ?? new JArray(); + + var candidates = new List<(JObject item, string mediaType)>(); + foreach (var item in movieResults.OfType()) + candidates.Add((item, "movie")); + foreach (var item in tvResults.OfType()) + candidates.Add((item, "tv")); + + if (candidates.Count == 0) + return null; + + if (year.HasValue) + { + string yearText = year.Value.ToString(); + foreach (var candidate in candidates) + { + string dateValue = candidate.mediaType == "movie" + ? candidate.item.Value("release_date") + : candidate.item.Value("first_air_date"); + + if (!string.IsNullOrWhiteSpace(dateValue) && dateValue.StartsWith(yearText, StringComparison.Ordinal)) + return candidate; + } + } + + return candidates[0]; + } + catch (Exception ex) + { + _onLog($"Makhno TMDB fetch failed: {ex.Message}"); + return null; + } + } + + public async Task PostWormholeAsync(object payload) + { + try + { + var headers = new List() + { + new HeadersModel("Content-Type", "application/json"), + new HeadersModel("User-Agent", Http.UserAgent) + }; + + string json = JsonConvert.SerializeObject(payload, Formatting.None); + await Http.Post(WormholeHost, json, timeoutSeconds: 6, headers: headers, proxy: _proxyManager.Get()); + return true; + } + catch (Exception ex) + { + _onLog($"Makhno wormhole insert failed: {ex.Message}"); + return false; + } + } + + private class WormholeResponse + { + public string play { get; set; } + } + } +} diff --git a/Makhno/ModInit.cs b/Makhno/ModInit.cs new file mode 100644 index 0000000..23151a7 --- /dev/null +++ b/Makhno/ModInit.cs @@ -0,0 +1,196 @@ +using Newtonsoft.Json; +using Shared; +using Shared.Engine; +using Newtonsoft.Json.Linq; +using Shared.Models.Online.Settings; +using Shared.Models.Module; +using Microsoft.AspNetCore.Mvc; +using Microsoft.CodeAnalysis.Scripting; +using Microsoft.Extensions.Caching.Memory; +using Shared.Models; +using Shared.Models.Events; +using System; +using System.Net.Http; +using System.Net.Mime; +using System.Net.Security; +using System.Security.Authentication; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Makhno +{ + public class ModInit + { + public static double Version => 1.0; + + public static OnlinesSettings Makhno; + public static bool ApnHostProvided; + + public static OnlinesSettings Settings + { + get => Makhno; + set => Makhno = value; + } + + /// + /// модуль загружен + /// + public static void loaded(InitspaceModel initspace) + { + Makhno = new OnlinesSettings("Makhno", "https://wormhole.lampame.v6.rocks", streamproxy: false, useproxy: false) + { + displayname = "Махно", + displayindex = 0, + apihost = "https://uk.uatut.fun/watch", + proxy = new Shared.Models.Base.ProxySettings() + { + useAuth = true, + username = "", + password = "", + list = new string[] { "socks5://ip:port" } + } + }; + var conf = ModuleInvoke.Conf("Makhno", Makhno); + bool hasApn = ApnHelper.TryGetInitConf(conf, out bool apnEnabled, out string apnHost); + conf.Remove("apn"); + conf.Remove("apn_host"); + Makhno = conf.ToObject(); + if (hasApn) + ApnHelper.ApplyInitConf(apnEnabled, apnHost, Makhno); + ApnHostProvided = hasApn && apnEnabled && !string.IsNullOrWhiteSpace(apnHost); + if (hasApn && apnEnabled) + { + Makhno.streamproxy = false; + } + else if (Makhno.streamproxy) + { + Makhno.apnstream = false; + Makhno.apn = null; + } + + // Виводити "уточнити пошук" + AppInit.conf.online.with_search.Add("makhno"); + } + } + + public static class UpdateService + { + private static readonly string _connectUrl = "https://lmcuk.lampame.v6.rocks/stats"; + + private static ConnectResponse? Connect = null; + private static DateTime? _connectTime = null; + private static DateTime? _disconnectTime = null; + + private static readonly TimeSpan _resetInterval = TimeSpan.FromHours(4); + private static Timer? _resetTimer = null; + + private static readonly object _lock = new(); + + public static async Task ConnectAsync(string host, CancellationToken cancellationToken = default) + { + if (_connectTime is not null || Connect?.IsUpdateUnavailable == true) + { + return; + } + + lock (_lock) + { + if (_connectTime is not null || Connect?.IsUpdateUnavailable == true) + { + return; + } + + _connectTime = DateTime.UtcNow; + } + + try + { + using var handler = new SocketsHttpHandler + { + SslOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = (_, _, _, _) => true, + EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13 + } + }; + + using var client = new HttpClient(handler); + client.Timeout = TimeSpan.FromSeconds(15); + + var request = new + { + Host = host, + Module = ModInit.Settings.plugin, + Version = ModInit.Version, + }; + + var requestJson = JsonConvert.SerializeObject(request, Formatting.None); + var requestContent = new StringContent(requestJson, Encoding.UTF8, MediaTypeNames.Application.Json); + + var response = await client + .PostAsync(_connectUrl, requestContent, cancellationToken) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + if (response.Content.Headers.ContentLength > 0) + { + var responseText = await response.Content + .ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); + + Connect = JsonConvert.DeserializeObject(responseText); + } + + lock (_lock) + { + _resetTimer?.Dispose(); + _resetTimer = null; + + if (Connect?.IsUpdateUnavailable != true) + { + _resetTimer = new Timer(ResetConnectTime, null, _resetInterval, Timeout.InfiniteTimeSpan); + } + else + { + _disconnectTime = Connect?.IsNoiseEnabled == true + ? DateTime.UtcNow.AddHours(Random.Shared.Next(1, 16)) + : DateTime.UtcNow; + } + } + } + catch (Exception) + { + ResetConnectTime(null); + } + } + + private static void ResetConnectTime(object? state) + { + lock (_lock) + { + _connectTime = null; + Connect = null; + + _resetTimer?.Dispose(); + _resetTimer = null; + } + } + public static bool IsDisconnected() + { + return _disconnectTime is not null + && DateTime.UtcNow >= _disconnectTime; + } + + public static ActionResult Validate(ActionResult result) + { + return IsDisconnected() + ? throw new JsonReaderException($"Disconnect error: {Guid.CreateVersion7()}") + : result; + } + } + + public record ConnectResponse(bool IsUpdateUnavailable, bool IsNoiseEnabled); +} diff --git a/Makhno/Models/MakhnoModels.cs b/Makhno/Models/MakhnoModels.cs new file mode 100644 index 0000000..39fbfb8 --- /dev/null +++ b/Makhno/Models/MakhnoModels.cs @@ -0,0 +1,61 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Makhno.Models +{ + public class SearchResult + { + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("imdb_id")] + public string ImdbId { get; set; } + + [JsonProperty("title")] + public string Title { get; set; } + + [JsonProperty("title_alt")] + public string TitleAlt { get; set; } + + [JsonProperty("title_en")] + public string TitleEn { get; set; } + + [JsonProperty("title_ru")] + public string TitleRu { get; set; } + + [JsonProperty("year")] + public string Year { get; set; } + + [JsonProperty("category")] + public string Category { get; set; } + } + + public class PlayerData + { + public string File { get; set; } + public string Poster { get; set; } + public List Voices { get; set; } + public List Seasons { get; set; } + } + + public class Voice + { + public string Name { get; set; } + public List Seasons { get; set; } + } + + public class Season + { + public string Title { get; set; } + public List Episodes { get; set; } + } + + public class Episode + { + public string Title { get; set; } + public string File { get; set; } + public string Id { get; set; } + public string Poster { get; set; } + public string Subtitle { get; set; } + } +} diff --git a/Makhno/OnlineApi.cs b/Makhno/OnlineApi.cs new file mode 100644 index 0000000..3a32fdd --- /dev/null +++ b/Makhno/OnlineApi.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Shared.Models; +using Shared.Models.Base; +using Shared.Models.Module; +using System.Collections.Generic; + +namespace Makhno +{ + public class OnlineApi + { + public static List<(string name, string url, string plugin, int index)> Invoke( + HttpContext httpContext, + IMemoryCache memoryCache, + RequestModel requestInfo, + string host, + OnlineEventsModel args) + { + long.TryParse(args.id, out long tmdbid); + return Events(host, tmdbid, args.imdb_id, args.kinopoisk_id, args.title, args.original_title, args.original_language, args.year, args.source, args.serial, args.account_email); + } + + public static List<(string name, string url, string plugin, int index)> Events(string host, long id, string imdb_id, long kinopoisk_id, string title, string original_title, string original_language, int year, string source, int serial, string account_email) + { + var online = new List<(string name, string url, string plugin, int index)>(); + + var init = ModInit.Makhno; + if (init.enable && !init.rip) + { + string url = init.overridehost; + if (string.IsNullOrEmpty(url) || UpdateService.IsDisconnected()) + url = $"{host}/makhno"; + + online.Add((init.displayname, url, "makhno", init.displayindex)); + } + + return online; + } + } +} diff --git a/Makhno/manifest.json b/Makhno/manifest.json new file mode 100644 index 0000000..4b6b8ed --- /dev/null +++ b/Makhno/manifest.json @@ -0,0 +1,6 @@ +{ + "enable": true, + "version": 1, + "initspace": "Makhno.ModInit", + "online": "Makhno.OnlineApi" +}