diff --git a/.gitignore b/.gitignore index aa69327..1032ed8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ /.clinerules/moduls.md /.clinerules/uaflix-optimization.md /.clinerules/ -/.qodo/ \ No newline at end of file +/.qodo/ +.DS_Store diff --git a/AnimeON/Controller.cs b/AnimeON/Controller.cs index 6715fa1..b125a9d 100644 --- a/AnimeON/Controller.cs +++ b/AnimeON/Controller.cs @@ -28,7 +28,7 @@ namespace AnimeON.Controllers [HttpGet] [Route("animeon")] - async public 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, bool rjson = false) + async public 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, bool rjson = false, bool checksearch = false) { await UpdateService.ConnectAsync(host); @@ -37,6 +37,19 @@ namespace AnimeON.Controllers return Forbid(); var invoke = new AnimeONInvoke(init, hybridCache, OnLog, proxyManager); + + if (checksearch) + { + if (AppInit.conf?.online?.checkOnlineSearch != true) + return OnError("animeon", proxyManager); + + var checkSeasons = await invoke.Search(imdb_id, kinopoisk_id, title, original_title, year, serial); + if (checkSeasons != null && checkSeasons.Count > 0) + return Content("data-json=", "text/plain; charset=utf-8"); + + return OnError("animeon", proxyManager); + } + OnLog($"AnimeON Index: title={title}, original_title={original_title}, serial={serial}, s={s}, t={t}, year={year}, imdb_id={imdb_id}, kp={kinopoisk_id}"); var seasons = await invoke.Search(imdb_id, kinopoisk_id, title, original_title, year, serial); @@ -161,7 +174,7 @@ namespace AnimeON.Controllers } else { - string playUrl = HostStreamProxy(init, accsArgs(streamLink)); + string playUrl = BuildStreamUrl(init, streamLink, headers: null, forceProxy: false); episode_tpl.Append(episodeName, title ?? original_title, seasonStr, episodeStr, playUrl); } } @@ -217,7 +230,7 @@ namespace AnimeON.Controllers } else { - tpl.Append(translationName, HostStreamProxy(init, accsArgs(streamLink))); + tpl.Append(translationName, BuildStreamUrl(init, streamLink, headers: null, forceProxy: false)); } } } @@ -376,9 +389,30 @@ namespace AnimeON.Controllers return UpdateService.Validate(Content(jsonResult, "application/json; charset=utf-8")); } + private static string StripLampacArgs(string url) + { + if (string.IsNullOrEmpty(url)) + return url; + + string cleaned = System.Text.RegularExpressions.Regex.Replace( + url, + @"([?&])(account_email|uid|nws_id)=[^&]*", + "$1", + System.Text.RegularExpressions.RegexOptions.IgnoreCase + ); + + cleaned = cleaned.Replace("?&", "?").Replace("&&", "&").TrimEnd('?', '&'); + return cleaned; + } + string BuildStreamUrl(OnlinesSettings init, string streamLink, List headers, bool forceProxy) { - string link = accsArgs(streamLink); + string link = streamLink?.Trim(); + if (string.IsNullOrEmpty(link)) + return link; + + link = StripLampacArgs(link); + if (ApnHelper.IsEnabled(init)) { if (ModInit.ApnHostProvided || ApnHelper.IsAshdiUrl(link)) diff --git a/AnimeON/ModInit.cs b/AnimeON/ModInit.cs index fdb23df..29c135c 100644 --- a/AnimeON/ModInit.cs +++ b/AnimeON/ModInit.cs @@ -25,7 +25,7 @@ namespace AnimeON { public class ModInit { - public static double Version => 3.3; + public static double Version => 3.4; public static OnlinesSettings AnimeON; public static bool ApnHostProvided; diff --git a/Bamboo/Controller.cs b/Bamboo/Controller.cs index 19057d1..f2a993b 100644 --- a/Bamboo/Controller.cs +++ b/Bamboo/Controller.cs @@ -24,7 +24,7 @@ namespace Bamboo.Controllers [HttpGet] [Route("bamboo")] - async public 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, bool rjson = false, string href = null) + async public 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, bool rjson = false, string href = null, bool checksearch = false) { await UpdateService.ConnectAsync(host); @@ -34,6 +34,18 @@ namespace Bamboo.Controllers var invoke = new BambooInvoke(init, hybridCache, OnLog, proxyManager); + if (checksearch) + { + if (AppInit.conf?.online?.checkOnlineSearch != true) + return OnError("bamboo", proxyManager); + + var searchResults = await invoke.Search(title, original_title); + if (searchResults != null && searchResults.Count > 0) + return Content("data-json=", "text/plain; charset=utf-8"); + + return OnError("bamboo", proxyManager); + } + string itemUrl = href; if (string.IsNullOrEmpty(itemUrl)) { @@ -121,7 +133,10 @@ namespace Bamboo.Controllers string BuildStreamUrl(OnlinesSettings init, string streamLink) { - string link = accsArgs(streamLink); + string link = StripLampacArgs(streamLink?.Trim()); + if (string.IsNullOrEmpty(link)) + return link; + if (ApnHelper.IsEnabled(init)) { if (ModInit.ApnHostProvided || ApnHelper.IsAshdiUrl(link)) @@ -135,5 +150,21 @@ namespace Bamboo.Controllers return HostStreamProxy(init, link); } + + private static string StripLampacArgs(string url) + { + if (string.IsNullOrEmpty(url)) + return url; + + string cleaned = System.Text.RegularExpressions.Regex.Replace( + url, + @"([?&])(account_email|uid|nws_id)=[^&]*", + "$1", + System.Text.RegularExpressions.RegexOptions.IgnoreCase + ); + + cleaned = cleaned.Replace("?&", "?").Replace("&&", "&").TrimEnd('?', '&'); + return cleaned; + } } } diff --git a/Bamboo/ModInit.cs b/Bamboo/ModInit.cs index 9da828b..658479d 100644 --- a/Bamboo/ModInit.cs +++ b/Bamboo/ModInit.cs @@ -24,7 +24,7 @@ namespace Bamboo { public class ModInit { - public static double Version => 3.4; + public static double Version => 3.5; public static OnlinesSettings Bamboo; public static bool ApnHostProvided; diff --git a/CikavaIdeya/Controller.cs b/CikavaIdeya/Controller.cs index e757b9e..b1fcf63 100644 --- a/CikavaIdeya/Controller.cs +++ b/CikavaIdeya/Controller.cs @@ -27,7 +27,7 @@ namespace CikavaIdeya.Controllers [HttpGet] [Route("cikavaideya")] - async public 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 e = -1, bool play = false, bool rjson = false) + async public 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 e = -1, bool play = false, bool rjson = false, bool checksearch = false) { await UpdateService.ConnectAsync(host); @@ -37,6 +37,18 @@ namespace CikavaIdeya.Controllers var invoke = new CikavaIdeyaInvoke(init, hybridCache, OnLog, proxyManager); + if (checksearch) + { + if (AppInit.conf?.online?.checkOnlineSearch != true) + return OnError("cikavaideya", proxyManager); + + var checkEpisodes = await invoke.Search(imdb_id, kinopoisk_id, title, original_title, year, serial == 0); + if (checkEpisodes != null && checkEpisodes.Count > 0) + return Content("data-json=", "text/plain; charset=utf-8"); + + return OnError("cikavaideya", proxyManager); + } + var episodesInfo = await invoke.Search(imdb_id, kinopoisk_id, title, original_title, year, serial == 0); if (episodesInfo == null) return Content("CikavaIdeya", "text/html; charset=utf-8"); @@ -388,7 +400,10 @@ namespace CikavaIdeya.Controllers string BuildStreamUrl(OnlinesSettings init, string streamLink) { - string link = accsArgs(streamLink); + string link = StripLampacArgs(streamLink?.Trim()); + if (string.IsNullOrEmpty(link)) + return link; + if (ApnHelper.IsEnabled(init)) { if (ModInit.ApnHostProvided || ApnHelper.IsAshdiUrl(link)) @@ -402,5 +417,21 @@ namespace CikavaIdeya.Controllers return HostStreamProxy(init, link); } + + private static string StripLampacArgs(string url) + { + if (string.IsNullOrEmpty(url)) + return url; + + string cleaned = System.Text.RegularExpressions.Regex.Replace( + url, + @"([?&])(account_email|uid|nws_id)=[^&]*", + "$1", + System.Text.RegularExpressions.RegexOptions.IgnoreCase + ); + + cleaned = cleaned.Replace("?&", "?").Replace("&&", "&").TrimEnd('?', '&'); + return cleaned; + } } } diff --git a/CikavaIdeya/ModInit.cs b/CikavaIdeya/ModInit.cs index e411b1b..d436297 100644 --- a/CikavaIdeya/ModInit.cs +++ b/CikavaIdeya/ModInit.cs @@ -30,7 +30,7 @@ namespace CikavaIdeya { public class ModInit { - public static double Version => 3.2; + public static double Version => 010100100100100101010000; public static OnlinesSettings CikavaIdeya; public static bool ApnHostProvided; diff --git a/Makhno/ApnHelper.cs b/Makhno/ApnHelper.cs new file mode 100644 index 0000000..13e9176 --- /dev/null +++ b/Makhno/ApnHelper.cs @@ -0,0 +1,98 @@ +using Newtonsoft.Json.Linq; +using Shared.Models.Base; +using System; +using System.Web; + +namespace Shared.Engine +{ + public static class ApnHelper + { + public const string DefaultHost = "https://tut.im/proxy.php?url={encodeurl}"; + + public static bool TryGetInitConf(JObject conf, out bool enabled, out string host) + { + enabled = false; + host = null; + + if (conf == null) + return false; + + if (!conf.TryGetValue("apn", out var apnToken) || apnToken == null) + return false; + + if (apnToken.Type == JTokenType.Boolean) + { + enabled = apnToken.Value(); + host = conf.Value("apn_host"); + return true; + } + + if (apnToken.Type == JTokenType.String) + { + host = apnToken.Value(); + enabled = !string.IsNullOrWhiteSpace(host); + return true; + } + + return false; + } + + public static void ApplyInitConf(bool enabled, string host, BaseSettings init) + { + if (init == null) + return; + + if (!enabled) + { + init.apnstream = false; + init.apn = null; + return; + } + + if (string.IsNullOrWhiteSpace(host)) + host = DefaultHost; + + if (init.apn == null) + init.apn = new ApnConf(); + + init.apn.host = host; + init.apnstream = true; + } + + public static bool IsEnabled(BaseSettings init) + { + return init?.apnstream == true && !string.IsNullOrWhiteSpace(init?.apn?.host); + } + + public static bool IsAshdiUrl(string url) + { + return !string.IsNullOrEmpty(url) && + url.IndexOf("ashdi.vip", StringComparison.OrdinalIgnoreCase) >= 0; + } + + public static string WrapUrl(BaseSettings init, string url) + { + if (!IsEnabled(init)) + return url; + + return BuildUrl(init.apn.host, url); + } + + public static string BuildUrl(string host, string url) + { + if (string.IsNullOrEmpty(host) || string.IsNullOrEmpty(url)) + return url; + + if (host.Contains("{encodeurl}")) + return host.Replace("{encodeurl}", HttpUtility.UrlEncode(url)); + + if (host.Contains("{encode_uri}")) + return host.Replace("{encode_uri}", HttpUtility.UrlEncode(url)); + + if (host.Contains("{uri}")) + return host.Replace("{uri}", url); + + return $"{host.TrimEnd('/')}/{url}"; + } + } +} diff --git a/Makhno/Controller.cs b/Makhno/Controller.cs new file mode 100644 index 0000000..c320836 --- /dev/null +++ b/Makhno/Controller.cs @@ -0,0 +1,616 @@ +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, bool checksearch = false) + { + if (checksearch) + { + if (AppInit.conf?.online?.checkOnlineSearch != true) + return OnError(); + + return Content("data-json=", "text/plain; charset=utf-8"); + } + + 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()) + { + OnLog("Makhno Play: no voices parsed"); + return OnError(); + } + + if (string.IsNullOrEmpty(t) || !int.TryParse(t, out int voiceIndex) || voiceIndex >= playerData.Voices.Count) + return OnError(); + + var selectedVoice = playerData.Voices[voiceIndex]; + int seasonIndex = season > 0 ? season - 1 : season; + if (seasonIndex < 0 || seasonIndex >= selectedVoice.Seasons.Count) + return OnError(); + + var selectedSeason = selectedVoice.Seasons[seasonIndex]; + 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) + { + OnLog("Makhno PlayMovie: no file parsed"); + 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) + { + OnLog("Makhno HandleMovie: no file parsed"); + 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()) + { + OnLog("Makhno HandleSerial: no voices parsed"); + return OnError(); + } + + var voiceSeasons = playerData.Voices + .Select((voice, index) => new + { + Voice = voice, + Index = index, + Seasons = GetSeasonsWithNumbers(voice) + }) + .Where(v => v.Seasons.Count > 0) + .ToList(); + + // Debug logging disabled to avoid noisy output in production. + + var seasonNumbers = voiceSeasons + .SelectMany(v => v.Seasons.Select(s => s.Number)) + .Distinct() + .OrderBy(n => n) + .ToList(); + + if (seasonNumbers.Count == 0) + return OnError(); + + if (season == -1) + { + int? seasonVoiceIndex = null; + if (int.TryParse(t, out int tIndex) && tIndex >= 0 && tIndex < playerData.Voices.Count) + seasonVoiceIndex = tIndex; + + if (seasonVoiceIndex.HasValue) + { + var seasonsForVoice = GetSeasonsWithNumbers(playerData.Voices[seasonVoiceIndex.Value]) + .Select(s => s.Number) + .Distinct() + .OrderBy(n => n) + .ToList(); + + if (seasonsForVoice.Count > 0) + seasonNumbers = seasonsForVoice; + } + + var season_tpl = new SeasonTpl(); + foreach (var seasonNumber in seasonNumbers) + { + (Season Season, int Number)? seasonItem = null; + if (seasonVoiceIndex.HasValue) + { + var voiceSeasonsForT = GetSeasonsWithNumbers(playerData.Voices[seasonVoiceIndex.Value]); + var match = voiceSeasonsForT.FirstOrDefault(s => s.Number == seasonNumber); + seasonItem = match.Season != null ? match : ((Season Season, int Number)?)null; + } + else + { + var match = voiceSeasons + .SelectMany(v => v.Seasons) + .FirstOrDefault(s => s.Number == seasonNumber); + seasonItem = match.Season != null ? match : ((Season Season, int Number)?)null; + } + + string voiceParam = seasonVoiceIndex.HasValue ? $"&t={seasonVoiceIndex.Value}" : string.Empty; + string seasonName = seasonItem.HasValue ? seasonItem.Value.Season?.Title ?? $"Сезон {seasonNumber}" : $"Сезон {seasonNumber}"; + string link = $"{host}/makhno?imdb_id={imdb_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&season={seasonNumber}{voiceParam}"; + season_tpl.Append(seasonName, link, seasonNumber.ToString()); + } + + return rjson ? Content(season_tpl.ToJson(), "application/json; charset=utf-8") : Content(season_tpl.ToHtml(), "text/html; charset=utf-8"); + } + + var voice_tpl = new VoiceTpl(); + var episode_tpl = new EpisodeTpl(); + + int requestedSeason = seasonNumbers.Contains(season) ? season : seasonNumbers.First(); + + int? seasonVoiceIndexForTpl = null; + string selectedVoice = t; + if (string.IsNullOrEmpty(selectedVoice) || !int.TryParse(selectedVoice, out int selectedVoiceIndex)) + { + var voiceWithSeason = voiceSeasons.FirstOrDefault(v => v.Seasons.Any(s => s.Number == requestedSeason)); + selectedVoice = voiceWithSeason != null ? voiceWithSeason.Index.ToString() : voiceSeasons.First().Index.ToString(); + } + else if (selectedVoiceIndex >= 0 && selectedVoiceIndex < playerData.Voices.Count) + { + seasonVoiceIndexForTpl = selectedVoiceIndex; + } + + HashSet selectedVoiceSeasonSet = null; + if (seasonVoiceIndexForTpl.HasValue) + { + selectedVoiceSeasonSet = GetSeasonsWithNumbers(playerData.Voices[seasonVoiceIndexForTpl.Value]) + .Select(s => s.Number) + .ToHashSet(); + } + else + { + selectedVoiceSeasonSet = seasonNumbers.ToHashSet(); + } + + // Build season template for selected voice (if valid) to keep season list in sync when switching voices. + var seasonTplForVoice = new SeasonTpl(); + List seasonNumbersForTpl = seasonNumbers; + if (seasonVoiceIndexForTpl.HasValue) + { + var seasonsForVoiceTpl = GetSeasonsWithNumbers(playerData.Voices[seasonVoiceIndexForTpl.Value]) + .Select(s => s.Number) + .Distinct() + .OrderBy(n => n) + .ToList(); + + if (seasonsForVoiceTpl.Count > 0) + seasonNumbersForTpl = seasonsForVoiceTpl; + } + + foreach (var seasonNumber in seasonNumbersForTpl) + { + (Season Season, int Number)? seasonItem = null; + if (seasonVoiceIndexForTpl.HasValue) + { + var voiceSeasonsForT = GetSeasonsWithNumbers(playerData.Voices[seasonVoiceIndexForTpl.Value]); + var match = voiceSeasonsForT.FirstOrDefault(s => s.Number == seasonNumber); + seasonItem = match.Season != null ? match : ((Season Season, int Number)?)null; + } + else + { + var match = voiceSeasons + .SelectMany(v => v.Seasons) + .FirstOrDefault(s => s.Number == seasonNumber); + seasonItem = match.Season != null ? match : ((Season Season, int Number)?)null; + } + + string voiceParam = seasonVoiceIndexForTpl.HasValue ? $"&t={seasonVoiceIndexForTpl.Value}" : string.Empty; + string seasonName = seasonItem.HasValue ? seasonItem.Value.Season?.Title ?? $"Сезон {seasonNumber}" : $"Сезон {seasonNumber}"; + string link = $"{host}/makhno?imdb_id={imdb_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&season={seasonNumber}{voiceParam}"; + seasonTplForVoice.Append(seasonName, link, seasonNumber.ToString()); + } + + for (int i = 0; i < playerData.Voices.Count; i++) + { + var voice = playerData.Voices[i]; + string voiceName = voice.Name ?? $"Озвучка {i + 1}"; + var seasonsForVoice = GetSeasonsWithNumbers(voice); + if (seasonsForVoice.Count == 0) + continue; + + string voiceLink; + bool hasRequestedSeason = seasonsForVoice.Any(s => s.Number == requestedSeason); + bool sameSeasonSet = seasonsForVoice.Select(s => s.Number).ToHashSet().SetEquals(selectedVoiceSeasonSet); + if (hasRequestedSeason && sameSeasonSet) + { + voiceLink = $"{host}/makhno?imdb_id={imdb_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&season={requestedSeason}&t={i}"; + } + else + { + voiceLink = $"{host}/makhno?imdb_id={imdb_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&season=-1&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]; + var seasonsForVoice = GetSeasonsWithNumbers(selectedVoiceData); + if (seasonsForVoice.Count > 0) + { + bool hasRequestedSeason = seasonsForVoice.Any(s => s.Number == requestedSeason); + if (!hasRequestedSeason) + { + string redirectUrl = $"{host}/makhno?imdb_id={imdb_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&season=-1&t={voiceIndex}"; + return UpdateService.Validate(Redirect(redirectUrl)); + } + + var selectedSeason = seasonsForVoice.First(s => s.Number == requestedSeason).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 streamUrl = BuildStreamUrl(init, episode.File); + episode_tpl.Append( + episode.Title, + title ?? original_title, + requestedSeason.ToString(), + (i + 1).ToString("D2"), + streamUrl + ); + } + } + } + } + + episode_tpl.Append(voice_tpl); + if (rjson) + return Content(episode_tpl.ToJson(), "application/json; charset=utf-8"); + + return Content(seasonTplForVoice.ToHtml() + 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 int? ExtractSeasonNumber(string title) + { + if (string.IsNullOrEmpty(title)) + return null; + + var match = System.Text.RegularExpressions.Regex.Match(title, @"(\d+)"); + return match.Success ? int.Parse(match.Groups[1].Value) : (int?)null; + } + + private List<(Season Season, int Number)> GetSeasonsWithNumbers(Voice voice) + { + var result = new List<(Season Season, int Number)>(); + if (voice?.Seasons == null || voice.Seasons.Count == 0) + return result; + + for (int i = 0; i < voice.Seasons.Count; i++) + { + var season = voice.Seasons[i]; + int number = ExtractSeasonNumber(season?.Title) ?? (i + 1); + result.Add((season, number)); + } + + return result; + } + + 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, serial) || IsSerialByUrl(playUrl, serial); + + return new ResolveResult + { + PlayUrl = playUrl, + AshdiPath = ashdiPath, + Selected = selected, + IsSerial = isSerial, + ShouldEnrich = true + }; + } + + private bool IsSerialByCategory(string category, int serial) + { + if (string.IsNullOrWhiteSpace(category)) + return false; + + if (category.Equals("Аніме", StringComparison.OrdinalIgnoreCase) + || category.Equals("Аниме", StringComparison.OrdinalIgnoreCase)) + { + return serial == 1; + } + + return category.Equals("Серіал", StringComparison.OrdinalIgnoreCase) + || category.Equals("Сериал", StringComparison.OrdinalIgnoreCase) + || category.Equals("Аніме", StringComparison.OrdinalIgnoreCase) + || category.Equals("Аниме", StringComparison.OrdinalIgnoreCase) + || category.Equals("Мультсеріал", StringComparison.OrdinalIgnoreCase) + || category.Equals("Мультсериал", StringComparison.OrdinalIgnoreCase) + || category.Equals("TV", 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 static string StripLampacArgs(string url) + { + if (string.IsNullOrEmpty(url)) + return url; + + string cleaned = System.Text.RegularExpressions.Regex.Replace( + url, + @"([?&])(account_email|uid|nws_id)=[^&]*", + "$1", + System.Text.RegularExpressions.RegexOptions.IgnoreCase + ); + + cleaned = cleaned.Replace("?&", "?").Replace("&&", "&").TrimEnd('?', '&'); + return cleaned; + } + + private string BuildStreamUrl(OnlinesSettings init, string streamLink) + { + string link = streamLink?.Trim(); + if (string.IsNullOrEmpty(link)) + return link; + + link = StripLampacArgs(link); + + 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..a1f5393 --- /dev/null +++ b/Makhno/MakhnoInvoke.cs @@ -0,0 +1,743 @@ +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() + }; + } + + string jsonData = ExtractPlayerJson(html); + if (jsonData == null) + _onLog("Makhno ParsePlayerData: file array not found"); + else + _onLog($"Makhno ParsePlayerData: file array length={jsonData.Length}"); + if (!string.IsNullOrEmpty(jsonData)) + { + var voices = ParseVoicesJson(jsonData); + _onLog($"Makhno ParsePlayerData: voices={voices?.Count ?? 0}"); + return new PlayerData + { + File = null, + Poster = null, + Voices = voices + }; + } + + var m3u8Match = Regex.Match(html, @"(https?://[^""'\s>]+\.m3u8[^""'\s>]*)", RegexOptions.IgnoreCase); + if (m3u8Match.Success) + { + _onLog("Makhno ParsePlayerData: fallback m3u8 match"); + return new PlayerData + { + File = m3u8Match.Groups[1].Value, + Poster = null, + Voices = new List() + }; + } + + var sourceMatch = Regex.Match(html, @"]*src=[""']([^""']+)[""']", RegexOptions.IgnoreCase); + if (sourceMatch.Success) + { + _onLog("Makhno ParsePlayerData: fallback source match"); + return new PlayerData + { + File = sourceMatch.Groups[1].Value, + Poster = null, + Voices = new List() + }; + } + + 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 string ExtractPlayerJson(string html) + { + if (string.IsNullOrEmpty(html)) + return null; + + var startIndex = FindFileArrayStart(html); + if (startIndex < 0) + return null; + + string jsonArray = ExtractBracketArray(html, startIndex); + if (string.IsNullOrEmpty(jsonArray)) + return null; + + return jsonArray + .Replace("\\'", "'") + .Replace("\\\"", "\""); + } + + private int FindFileArrayStart(string html) + { + int playerStart = html.IndexOf("Playerjs", StringComparison.OrdinalIgnoreCase); + if (playerStart >= 0) + { + int playerIndex = FindFileArrayStartInRange(html, playerStart); + if (playerIndex >= 0) + return playerIndex; + } + + int index = FindFileArrayIndex(html, "file:'["); + if (index >= 0) + return index; + + index = FindFileArrayIndex(html, "file:\"["); + if (index >= 0) + return index; + + var match = Regex.Match(html, @"file\s*:\s*'?\[", RegexOptions.IgnoreCase); + if (match.Success) + return match.Index + match.Value.LastIndexOf('['); + + return -1; + } + + private int FindFileArrayStartInRange(string html, int startIndex) + { + int searchStart = startIndex; + int searchEnd = Math.Min(html.Length, startIndex + 200000); + + int tokenIndex = IndexOfIgnoreCase(html, "file:'[", searchStart, searchEnd); + if (tokenIndex >= 0) + return html.IndexOf('[', tokenIndex); + + tokenIndex = IndexOfIgnoreCase(html, "file:\"[", searchStart, searchEnd); + if (tokenIndex >= 0) + return html.IndexOf('[', tokenIndex); + + tokenIndex = IndexOfIgnoreCase(html, "file", searchStart, searchEnd); + if (tokenIndex >= 0) + { + int bracketIndex = html.IndexOf('[', tokenIndex); + if (bracketIndex >= 0 && bracketIndex < searchEnd) + return bracketIndex; + } + + return -1; + } + + private int IndexOfIgnoreCase(string text, string value, int startIndex, int endIndex) + { + int index = text.IndexOf(value, startIndex, StringComparison.OrdinalIgnoreCase); + if (index >= 0 && index < endIndex) + return index; + + return -1; + } + + private int FindFileArrayIndex(string html, string token) + { + int tokenIndex = html.IndexOf(token, StringComparison.OrdinalIgnoreCase); + if (tokenIndex < 0) + return -1; + + int bracketIndex = html.IndexOf('[', tokenIndex); + return bracketIndex; + } + + private string ExtractBracketArray(string text, int startIndex) + { + if (startIndex < 0 || startIndex >= text.Length || text[startIndex] != '[') + return null; + + int depth = 0; + bool inString = false; + bool escape = false; + char quoteChar = '\0'; + + for (int i = startIndex; i < text.Length; i++) + { + char ch = text[i]; + + if (inString) + { + if (escape) + { + escape = false; + continue; + } + + if (ch == '\\') + { + escape = true; + continue; + } + + if (ch == quoteChar) + { + inString = false; + quoteChar = '\0'; + } + + continue; + } + + if (ch == '"' || ch == '\'') + { + inString = true; + quoteChar = ch; + continue; + } + + if (ch == '[') + { + depth++; + continue; + } + + if (ch == ']') + { + depth--; + if (depth == 0) + return text.Substring(startIndex, i - startIndex + 1); + } + } + + return null; + } + + 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..1e173c8 --- /dev/null +++ b/Makhno/ModInit.cs @@ -0,0 +1,199 @@ +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.7; + + 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); + if (hasApn) + { + 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..bd0de41 --- /dev/null +++ b/Makhno/manifest.json @@ -0,0 +1,6 @@ +{ + "enable": true, + "version": 3, + "initspace": "Makhno.ModInit", + "online": "Makhno.OnlineApi" +} diff --git a/Mikai/Controller.cs b/Mikai/Controller.cs index 51cc11c..87489db 100644 --- a/Mikai/Controller.cs +++ b/Mikai/Controller.cs @@ -24,7 +24,7 @@ namespace Mikai.Controllers [HttpGet] [Route("mikai")] - 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, bool rjson = false) + 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, bool rjson = false, bool checksearch = false) { await UpdateService.ConnectAsync(host); @@ -33,6 +33,19 @@ namespace Mikai.Controllers return Forbid(); var invoke = new MikaiInvoke(init, hybridCache, OnLog, _proxyManager); + + if (checksearch) + { + if (AppInit.conf?.online?.checkOnlineSearch != true) + return OnError("mikai", _proxyManager); + + var checkResults = await invoke.Search(title, original_title, year); + if (checkResults != null && checkResults.Count > 0) + return Content("data-json=", "text/plain; charset=utf-8"); + + return OnError("mikai", _proxyManager); + } + OnLog($"Mikai Index: title={title}, original_title={original_title}, serial={serial}, s={s}, t={t}, year={year}"); var searchResults = await invoke.Search(title, original_title, year); @@ -57,11 +70,15 @@ namespace Mikai.Controllers if (isSerial) { - var seasonNumbers = voices.Values - .SelectMany(v => v.Seasons.Keys) - .Distinct() - .OrderBy(n => n) - .ToList(); + MikaiVoiceInfo voiceForSeasons = null; + bool restrictByVoice = !string.IsNullOrEmpty(t) && voices.TryGetValue(t, out voiceForSeasons); + var seasonNumbers = restrictByVoice + ? GetSeasonSet(voiceForSeasons).OrderBy(n => n).ToList() + : voices.Values + .SelectMany(v => GetSeasonSet(v)) + .Distinct() + .OrderBy(n => n) + .ToList(); if (seasonNumbers.Count == 0) return OnError("mikai", _proxyManager); @@ -72,6 +89,8 @@ namespace Mikai.Controllers foreach (var seasonNumber in seasonNumbers) { string link = $"{host}/mikai?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={seasonNumber}"; + if (restrictByVoice) + link += $"&t={HttpUtility.UrlEncode(t)}"; seasonTpl.Append($"{seasonNumber}", link, seasonNumber.ToString()); } @@ -89,16 +108,29 @@ namespace Mikai.Controllers if (string.IsNullOrEmpty(t)) t = voicesForSeason[0].Key; + else if (!voices.ContainsKey(t)) + t = voicesForSeason[0].Key; var voiceTpl = new VoiceTpl(); + var selectedVoiceInfo = voices[t]; + var selectedSeasonSet = GetSeasonSet(selectedVoiceInfo); foreach (var voice in voicesForSeason) { - string voiceLink = $"{host}/mikai?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={s}&t={HttpUtility.UrlEncode(voice.Key)}"; + var targetSeasonSet = GetSeasonSet(voice.Value); + bool sameSeasonSet = targetSeasonSet.SetEquals(selectedSeasonSet); + string voiceLink = $"{host}/mikai?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1"; + if (sameSeasonSet) + voiceLink += $"&s={s}&t={HttpUtility.UrlEncode(voice.Key)}"; + else + voiceLink += $"&s=-1&t={HttpUtility.UrlEncode(voice.Key)}"; voiceTpl.Append(voice.Key, voice.Key == t, voiceLink); } if (!voices.ContainsKey(t) || !voices[t].Seasons.ContainsKey(s)) - return OnError("mikai", _proxyManager); + { + string redirectUrl = $"{host}/mikai?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s=-1&t={HttpUtility.UrlEncode(t)}"; + return Redirect(redirectUrl); + } var episodeTpl = new EpisodeTpl(); foreach (var ep in voices[t].Seasons[s].OrderBy(e => e.Number)) @@ -116,7 +148,7 @@ namespace Mikai.Controllers } else { - string playUrl = HostStreamProxy(init, accsArgs(streamLink)); + string playUrl = BuildStreamUrl(init, streamLink, headers: null, forceProxy: false); episodeTpl.Append(episodeName, displayTitle, s.ToString(), ep.Number.ToString(), playUrl); } } @@ -142,7 +174,7 @@ namespace Mikai.Controllers } else { - string playUrl = HostStreamProxy(init, accsArgs(episode.Url)); + string playUrl = BuildStreamUrl(init, episode.Url, headers: null, forceProxy: false); movieTpl.Append(voice.DisplayName, playUrl); } } @@ -367,9 +399,41 @@ namespace Mikai.Controllers streamLink.Contains("moonanime.art", StringComparison.OrdinalIgnoreCase); } + private static HashSet GetSeasonSet(MikaiVoiceInfo voice) + { + if (voice?.Seasons == null || voice.Seasons.Count == 0) + return new HashSet(); + + return voice.Seasons + .Where(kv => kv.Value != null && kv.Value.Any(ep => !string.IsNullOrEmpty(ep.Url))) + .Select(kv => kv.Key) + .ToHashSet(); + } + + private static string StripLampacArgs(string url) + { + if (string.IsNullOrEmpty(url)) + return url; + + string cleaned = System.Text.RegularExpressions.Regex.Replace( + url, + @"([?&])(account_email|uid|nws_id)=[^&]*", + "$1", + System.Text.RegularExpressions.RegexOptions.IgnoreCase + ); + + cleaned = cleaned.Replace("?&", "?").Replace("&&", "&").TrimEnd('?', '&'); + return cleaned; + } + private string BuildStreamUrl(OnlinesSettings init, string streamLink, List headers, bool forceProxy) { - string link = accsArgs(streamLink); + string link = streamLink?.Trim(); + if (string.IsNullOrEmpty(link)) + return link; + + link = StripLampacArgs(link); + if (ApnHelper.IsEnabled(init)) { if (ModInit.ApnHostProvided || ApnHelper.IsAshdiUrl(link)) diff --git a/Mikai/ModInit.cs b/Mikai/ModInit.cs index eb2a898..e3cba11 100644 --- a/Mikai/ModInit.cs +++ b/Mikai/ModInit.cs @@ -24,7 +24,7 @@ namespace Mikai { public class ModInit { - public static double Version => 3.3; + public static double Version => 3.5; public static OnlinesSettings Mikai; public static bool ApnHostProvided; diff --git a/StarLight/Controller.cs b/StarLight/Controller.cs index 32bddf4..cc15189 100644 --- a/StarLight/Controller.cs +++ b/StarLight/Controller.cs @@ -25,7 +25,7 @@ namespace StarLight.Controllers [HttpGet] [Route("starlight")] - async public 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, int s = -1, bool rjson = false, string href = null) + async public 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, int s = -1, bool rjson = false, string href = null, bool checksearch = false) { await UpdateService.ConnectAsync(host); @@ -35,6 +35,18 @@ namespace StarLight.Controllers var invoke = new StarLightInvoke(init, hybridCache, OnLog, proxyManager); + if (checksearch) + { + if (AppInit.conf?.online?.checkOnlineSearch != true) + return OnError("starlight", proxyManager); + + var searchResults = await invoke.Search(title, original_title); + if (searchResults != null && searchResults.Count > 0) + return Content("data-json=", "text/plain; charset=utf-8"); + + return OnError("starlight", proxyManager); + } + string itemUrl = href; if (string.IsNullOrEmpty(itemUrl)) { @@ -169,7 +181,10 @@ namespace StarLight.Controllers string BuildStreamUrl(OnlinesSettings init, string streamLink) { - string link = accsArgs(streamLink); + string link = StripLampacArgs(streamLink?.Trim()); + if (string.IsNullOrEmpty(link)) + return link; + if (ApnHelper.IsEnabled(init)) { if (ModInit.ApnHostProvided || ApnHelper.IsAshdiUrl(link)) @@ -184,6 +199,22 @@ namespace StarLight.Controllers return HostStreamProxy(init, link, proxy: proxyManager.Get()); } + private static string StripLampacArgs(string url) + { + if (string.IsNullOrEmpty(url)) + return url; + + string cleaned = System.Text.RegularExpressions.Regex.Replace( + url, + @"([?&])(account_email|uid|nws_id)=[^&]*", + "$1", + System.Text.RegularExpressions.RegexOptions.IgnoreCase + ); + + cleaned = cleaned.Replace("?&", "?").Replace("&&", "&").TrimEnd('?', '&'); + return cleaned; + } + private static string GetSeasonNumber(SeasonInfo season, int fallbackIndex) { if (season?.Title == null) diff --git a/UAKino/Controller.cs b/UAKino/Controller.cs index 71d1b3e..3df6ad4 100644 --- a/UAKino/Controller.cs +++ b/UAKino/Controller.cs @@ -23,7 +23,7 @@ namespace UAKino.Controllers [HttpGet] [Route("uakino")] - async public 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, bool rjson = false, string href = null) + async public 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, bool rjson = false, string href = null, bool checksearch = false) { await UpdateService.ConnectAsync(host); @@ -33,6 +33,18 @@ namespace UAKino.Controllers var invoke = new UAKinoInvoke(init, hybridCache, OnLog, proxyManager); + if (checksearch) + { + if (AppInit.conf?.online?.checkOnlineSearch != true) + return OnError("uakino", proxyManager); + + var searchResults = await invoke.Search(title, original_title, serial); + if (searchResults != null && searchResults.Count > 0) + return Content("data-json=", "text/plain; charset=utf-8"); + + return OnError("uakino", proxyManager); + } + string itemUrl = href; if (string.IsNullOrEmpty(itemUrl)) { @@ -90,7 +102,29 @@ namespace UAKino.Controllers int episodeNumber = UAKinoInvoke.TryParseEpisodeNumber(ep.Title) ?? index; string episodeName = string.IsNullOrEmpty(ep.Title) ? $"Епізод {episodeNumber}" : ep.Title; string callUrl = $"{host}/uakino/play?url={HttpUtility.UrlEncode(ep.Url)}&title={HttpUtility.UrlEncode(title ?? original_title)}"; - episode_tpl.Append(episodeName, title ?? original_title, "1", episodeNumber.ToString("D2"), accsArgs(callUrl), "call"); + if (!string.IsNullOrEmpty(ep.Url) && ep.Url.Contains("ashdi.vip", StringComparison.OrdinalIgnoreCase)) + { + string playUrl = BuildStreamUrl(init, ep.Url); + episode_tpl.Append( + episodeName, + title ?? original_title, + "1", + episodeNumber.ToString("D2"), + playUrl + ); + } + else + { + episode_tpl.Append( + episodeName, + title ?? original_title, + "1", + episodeNumber.ToString("D2"), + accsArgs(callUrl), + "call", + streamlink: accsArgs($"{callUrl}&play=true") + ); + } index++; } @@ -143,9 +177,30 @@ namespace UAKino.Controllers return UpdateService.Validate(Content(jsonResult, "application/json; charset=utf-8")); } + private static string StripLampacArgs(string url) + { + if (string.IsNullOrEmpty(url)) + return url; + + string cleaned = System.Text.RegularExpressions.Regex.Replace( + url, + @"([?&])(account_email|uid|nws_id)=[^&]*", + "$1", + System.Text.RegularExpressions.RegexOptions.IgnoreCase + ); + + cleaned = cleaned.Replace("?&", "?").Replace("&&", "&").TrimEnd('?', '&'); + return cleaned; + } + string BuildStreamUrl(OnlinesSettings init, string streamLink) { - string link = accsArgs(streamLink); + string link = streamLink?.Trim(); + if (string.IsNullOrEmpty(link)) + return link; + + link = StripLampacArgs(link); + if (ApnHelper.IsEnabled(init)) { if (ModInit.ApnHostProvided || ApnHelper.IsAshdiUrl(link)) diff --git a/UAKino/ModInit.cs b/UAKino/ModInit.cs index 17dd74b..4ddd5a5 100644 --- a/UAKino/ModInit.cs +++ b/UAKino/ModInit.cs @@ -23,7 +23,7 @@ namespace UAKino { public class ModInit { - public static double Version => 3.3; + public static double Version => 010100100100100101010000; public static OnlinesSettings UAKino; public static bool ApnHostProvided; diff --git a/UaTUT/Controller.cs b/UaTUT/Controller.cs index f2e35fd..ecb64f3 100644 --- a/UaTUT/Controller.cs +++ b/UaTUT/Controller.cs @@ -25,7 +25,7 @@ namespace UaTUT } [HttpGet] - async public 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) + async public 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, bool checksearch = false) { await UpdateService.ConnectAsync(host); @@ -45,6 +45,17 @@ namespace UaTUT return await invoke.Search(original_title ?? title, imdb_id); }); + if (checksearch) + { + if (AppInit.conf?.online?.checkOnlineSearch != true) + return OnError(); + + if (searchResults != null && searchResults.Any()) + return Content("data-json=", "text/plain; charset=utf-8"); + + return OnError(); + } + if (searchResults == null || !searchResults.Any()) { OnLog("UaTUT: No search results found"); @@ -53,20 +64,20 @@ namespace UaTUT if (serial == 1) { - return await HandleSeries(searchResults, imdb_id, kinopoisk_id, title, original_title, year, s, season, t, rjson, invoke); + return await HandleSeries(searchResults, imdb_id, kinopoisk_id, title, original_title, year, s, season, t, rjson, invoke, preferSeries: true); } else { - return await HandleMovie(searchResults, rjson, invoke); + return await HandleMovie(searchResults, rjson, invoke, preferSeries: false); } } - private async Task HandleSeries(List searchResults, string imdb_id, long kinopoisk_id, string title, string original_title, int year, int s, int season, string t, bool rjson, UaTUTInvoke invoke) + private async Task HandleSeries(List searchResults, string imdb_id, long kinopoisk_id, string title, string original_title, int year, int s, int season, string t, bool rjson, UaTUTInvoke invoke, bool preferSeries) { var init = ModInit.UaTUT; // Фільтруємо тільки серіали та аніме - var seriesResults = searchResults.Where(r => r.Category == "Серіал" || r.Category == "Аніме").ToList(); + var seriesResults = searchResults.Where(r => IsSeriesCategory(r.Category, preferSeries)).ToList(); if (!seriesResults.Any()) { @@ -113,8 +124,9 @@ namespace UaTUT { var seasonItem = firstVoice.Seasons[i]; string seasonName = seasonItem.Title ?? $"Сезон {i + 1}"; - string link = $"{host}/uatut?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={s}&season={i}"; - season_tpl.Append(seasonName, link, i.ToString()); + int seasonNumber = i + 1; + string link = $"{host}/uatut?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={s}&season={seasonNumber}"; + season_tpl.Append(seasonName, link, seasonNumber.ToString()); } OnLog($"UaTUT: found {firstVoice.Seasons.Count} seasons"); @@ -137,8 +149,10 @@ namespace UaTUT if (playerData?.Voices == null || !playerData.Voices.Any()) return OnError(); + int seasonIndex = season > 0 ? season - 1 : season; + // Перевіряємо чи існує вибраний сезон - if (season >= playerData.Voices.First().Seasons.Count) + if (seasonIndex >= playerData.Voices.First().Seasons.Count || seasonIndex < 0) return OnError(); var voice_tpl = new VoiceTpl(); @@ -156,7 +170,8 @@ namespace UaTUT { var voice = playerData.Voices[i]; string voiceName = voice.Name ?? $"Озвучка {i + 1}"; - string voiceLink = $"{host}/uatut?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={s}&season={season}&t={i}"; + int seasonNumber = seasonIndex + 1; + string voiceLink = $"{host}/uatut?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={s}&season={seasonNumber}&t={i}"; bool isActive = selectedVoice == i.ToString(); voice_tpl.Append(voiceName, isActive, voiceLink); } @@ -166,9 +181,9 @@ namespace UaTUT { var selectedVoiceData = playerData.Voices[voiceIndex]; - if (season < selectedVoiceData.Seasons.Count) + if (seasonIndex < selectedVoiceData.Seasons.Count) { - var selectedSeason = selectedVoiceData.Seasons[season]; + var selectedSeason = selectedVoiceData.Seasons[seasonIndex]; // Сортуємо епізоди та додаємо правильну нумерацію var sortedEpisodes = selectedSeason.Episodes.OrderBy(e => ExtractEpisodeNumber(e.Title)).ToList(); @@ -181,11 +196,15 @@ namespace UaTUT if (!string.IsNullOrEmpty(episodeFile)) { - // Створюємо прямий лінк на епізод через play action - string episodeLink = $"{host}/uatut/play?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&s={s}&season={season}&t={selectedVoice}&episodeId={episode.Id}"; - - // Використовуємо правильний синтаксис EpisodeTpl.Append без poster параметра - episode_tpl.Append(episodeName, title ?? original_title, season.ToString(), (i + 1).ToString("D2"), episodeLink, "call"); + string streamUrl = BuildStreamUrl(init, episodeFile); + int seasonNumber = seasonIndex + 1; + episode_tpl.Append( + episodeName, + title ?? original_title, + seasonNumber.ToString(), + (i + 1).ToString("D2"), + streamUrl + ); } } } @@ -236,12 +255,12 @@ namespace UaTUT return match.Success ? int.Parse(match.Groups[1].Value) : 0; } - private async Task HandleMovie(List searchResults, bool rjson, UaTUTInvoke invoke) + private async Task HandleMovie(List searchResults, bool rjson, UaTUTInvoke invoke, bool preferSeries) { var init = ModInit.UaTUT; // Фільтруємо тільки фільми - var movieResults = searchResults.Where(r => r.Category == "Фільм").ToList(); + var movieResults = searchResults.Where(r => IsMovieCategory(r.Category, preferSeries)).ToList(); if (!movieResults.Any()) { @@ -396,9 +415,10 @@ namespace UaTUT { var selectedVoice = playerData.Voices[voiceIndex]; - if (season >= 0 && season < selectedVoice.Seasons.Count) + int seasonIndex = season > 0 ? season - 1 : season; + if (seasonIndex >= 0 && seasonIndex < selectedVoice.Seasons.Count) { - var selectedSeasonData = selectedVoice.Seasons[season]; + var selectedSeasonData = selectedVoice.Seasons[seasonIndex]; foreach (var episode in selectedSeasonData.Episodes) { @@ -431,9 +451,65 @@ namespace UaTUT return OnError(); } + private static string StripLampacArgs(string url) + { + if (string.IsNullOrEmpty(url)) + return url; + + string cleaned = System.Text.RegularExpressions.Regex.Replace( + url, + @"([?&])(account_email|uid|nws_id)=[^&]*", + "$1", + System.Text.RegularExpressions.RegexOptions.IgnoreCase + ); + + cleaned = cleaned.Replace("?&", "?").Replace("&&", "&").TrimEnd('?', '&'); + return cleaned; + } + + private static bool IsMovieCategory(string category, bool preferSeries) + { + if (string.IsNullOrWhiteSpace(category)) + return false; + + var value = category.Trim().ToLowerInvariant(); + if (IsAnimeCategory(value)) + return !preferSeries; + + return value == "фільм" || value == "фильм" || value == "мультфільм" || value == "мультфильм" || value == "movie"; + } + + private static bool IsSeriesCategory(string category, bool preferSeries) + { + if (string.IsNullOrWhiteSpace(category)) + return false; + + var value = category.Trim().ToLowerInvariant(); + if (IsAnimeCategory(value)) + return preferSeries; + + return value == "серіал" || value == "сериал" + || value == "аніме" || value == "аниме" + || value == "мультсеріал" || value == "мультсериал" + || value == "tv"; + } + + private static bool IsAnimeCategory(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + return value == "аніме" || value == "аниме"; + } + string BuildStreamUrl(OnlinesSettings init, string streamLink) { - string link = accsArgs(streamLink); + string link = streamLink?.Trim(); + if (string.IsNullOrEmpty(link)) + return link; + + link = StripLampacArgs(link); + if (ApnHelper.IsEnabled(init)) { if (ModInit.ApnHostProvided || ApnHelper.IsAshdiUrl(link)) diff --git a/UaTUT/ModInit.cs b/UaTUT/ModInit.cs index f2a12ed..db955f9 100644 --- a/UaTUT/ModInit.cs +++ b/UaTUT/ModInit.cs @@ -24,7 +24,7 @@ namespace UaTUT { public class ModInit { - public static double Version => 3.3; + public static double Version => 3.5; public static OnlinesSettings UaTUT; public static bool ApnHostProvided; diff --git a/Uaflix/Controller.cs b/Uaflix/Controller.cs index 9676d63..7512907 100644 --- a/Uaflix/Controller.cs +++ b/Uaflix/Controller.cs @@ -47,6 +47,9 @@ namespace Uaflix.Controllers // Обробка параметра checksearch - повертаємо спеціальну відповідь для валідації if (checksearch) { + if (AppInit.conf?.online?.checkOnlineSearch != true) + return OnError("uaflix", proxyManager); + try { string filmTitle = !string.IsNullOrEmpty(title) ? title : original_title; @@ -167,11 +170,22 @@ namespace Uaflix.Controllers // s == -1: Вибір сезону if (s == -1) { - var allSeasons = structure.Voices - .SelectMany(v => v.Value.Seasons.Keys) - .Distinct() - .OrderBy(sn => sn) - .ToList(); + List allSeasons; + VoiceInfo tVoice = null; + bool restrictByVoice = !string.IsNullOrEmpty(t) && structure.Voices.TryGetValue(t, out tVoice) && IsAshdiVoice(tVoice); + if (restrictByVoice) + { + allSeasons = GetSeasonSet(tVoice).OrderBy(sn => sn).ToList(); + OnLog($"Ashdi voice selected (t='{t}'), seasons count={allSeasons.Count}"); + } + else + { + allSeasons = structure.Voices + .SelectMany(v => GetSeasonSet(v.Value)) + .Distinct() + .OrderBy(sn => sn) + .ToList(); + } OnLog($"Found {allSeasons.Count} seasons in structure: {string.Join(", ", allSeasons)}"); @@ -205,6 +219,8 @@ namespace Uaflix.Controllers foreach (var season in seasonsWithValidEpisodes) { string link = $"{host}/uaflix?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={season}&href={HttpUtility.UrlEncode(filmUrl)}"; + if (restrictByVoice) + link += $"&t={HttpUtility.UrlEncode(t)}"; season_tpl.Append($"{season}", link, season.ToString()); OnLog($"Added season {season} to template"); } @@ -239,12 +255,31 @@ namespace Uaflix.Controllers t = voicesForSeason[0].DisplayName; OnLog($"Auto-selected first voice: {t}"); } - + else if (!structure.Voices.ContainsKey(t)) + { + t = voicesForSeason[0].DisplayName; + OnLog($"Voice '{t}' not found, fallback to first voice: {t}"); + } + // Створюємо VoiceTpl з усіма озвучками var voice_tpl = new VoiceTpl(); + var selectedVoiceInfo = structure.Voices[t]; + var selectedSeasonSet = GetSeasonSet(selectedVoiceInfo); + bool selectedIsAshdi = IsAshdiVoice(selectedVoiceInfo); + foreach (var voice in voicesForSeason) { - string voiceLink = $"{host}/uaflix?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={s}&t={HttpUtility.UrlEncode(voice.DisplayName)}&href={HttpUtility.UrlEncode(filmUrl)}"; + bool targetIsAshdi = IsAshdiVoice(voice.Info); + var targetSeasonSet = GetSeasonSet(voice.Info); + bool sameSeasonSet = targetSeasonSet.SetEquals(selectedSeasonSet); + bool needSeasonReset = (selectedIsAshdi || targetIsAshdi) && !sameSeasonSet; + + string voiceLink = $"{host}/uaflix?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&href={HttpUtility.UrlEncode(filmUrl)}"; + if (needSeasonReset) + voiceLink += $"&s=-1&t={HttpUtility.UrlEncode(voice.DisplayName)}"; + else + voiceLink += $"&s={s}&t={HttpUtility.UrlEncode(voice.DisplayName)}"; + bool isActive = voice.DisplayName == t; voice_tpl.Append(voice.DisplayName, isActive, voiceLink); } @@ -261,6 +296,13 @@ namespace Uaflix.Controllers if (!structure.Voices[t].Seasons.ContainsKey(s)) { OnLog($"Season {s} not found for voice '{t}'"); + if (IsAshdiVoice(structure.Voices[t])) + { + string redirectUrl = $"{host}/uaflix?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s=-1&t={HttpUtility.UrlEncode(t)}&href={HttpUtility.UrlEncode(filmUrl)}"; + OnLog($"Ashdi voice missing season, redirect to season selector: {redirectUrl}"); + return Redirect(redirectUrl); + } + OnLog("=== RETURN: season not found for voice OnError ==="); return OnError("uaflix", proxyManager); } @@ -336,7 +378,10 @@ namespace Uaflix.Controllers string BuildStreamUrl(OnlinesSettings init, string streamLink) { - string link = accsArgs(streamLink); + string link = StripLampacArgs(streamLink?.Trim()); + if (string.IsNullOrEmpty(link)) + return link; + if (ApnHelper.IsEnabled(init)) { if (ModInit.ApnHostProvided || ApnHelper.IsAshdiUrl(link)) @@ -350,5 +395,40 @@ namespace Uaflix.Controllers return HostStreamProxy(init, link); } + + private static string StripLampacArgs(string url) + { + if (string.IsNullOrEmpty(url)) + return url; + + string cleaned = System.Text.RegularExpressions.Regex.Replace( + url, + @"([?&])(account_email|uid|nws_id)=[^&]*", + "$1", + System.Text.RegularExpressions.RegexOptions.IgnoreCase + ); + + cleaned = cleaned.Replace("?&", "?").Replace("&&", "&").TrimEnd('?', '&'); + return cleaned; + } + + private static bool IsAshdiVoice(VoiceInfo voice) + { + if (voice == null || string.IsNullOrEmpty(voice.PlayerType)) + return false; + + return voice.PlayerType == "ashdi-serial" || voice.PlayerType == "ashdi-vod"; + } + + private static HashSet GetSeasonSet(VoiceInfo voice) + { + if (voice?.Seasons == null || voice.Seasons.Count == 0) + return new HashSet(); + + return voice.Seasons + .Where(kv => kv.Value != null && kv.Value.Any(ep => !string.IsNullOrEmpty(ep.File))) + .Select(kv => kv.Key) + .ToHashSet(); + } } } diff --git a/Uaflix/ModInit.cs b/Uaflix/ModInit.cs index 50dbf56..87b62c1 100644 --- a/Uaflix/ModInit.cs +++ b/Uaflix/ModInit.cs @@ -25,7 +25,7 @@ namespace Uaflix { public class ModInit { - public static double Version => 3.3; + public static double Version => 3.5; public static OnlinesSettings UaFlix; public static bool ApnHostProvided; diff --git a/Unimay/Controllers/Controller.cs b/Unimay/Controllers/Controller.cs index 49d34e0..5242090 100644 --- a/Unimay/Controllers/Controller.cs +++ b/Unimay/Controllers/Controller.cs @@ -23,7 +23,7 @@ namespace Unimay.Controllers [HttpGet] [Route("unimay")] - async public ValueTask Index(string title, string original_title, string code, int serial = -1, int s = -1, int e = -1, bool play = false, bool rjson = false) + async public ValueTask Index(string title, string original_title, string code, int serial = -1, int s = -1, int e = -1, bool play = false, bool rjson = false, bool checksearch = false) { await UpdateService.ConnectAsync(host); @@ -33,6 +33,18 @@ namespace Unimay.Controllers var invoke = new UnimayInvoke(init, hybridCache, OnLog, proxyManager); + if (checksearch) + { + if (AppInit.conf?.online?.checkOnlineSearch != true) + return OnError("unimay"); + + var searchResults = await invoke.Search(title, original_title, serial); + if (searchResults?.Content != null && searchResults.Content.Count > 0) + return Content("data-json=", "text/plain; charset=utf-8"); + + return OnError("unimay"); + } + if (!string.IsNullOrEmpty(code)) { // Fetch release details @@ -104,7 +116,8 @@ namespace Unimay.Controllers if (string.IsNullOrEmpty(masterUrl)) return OnError("no stream"); - return UpdateService.Validate(Redirect(HostStreamProxy(init, accsArgs(masterUrl), proxy: proxyManager.Get()))); + string cleaned = StripLampacArgs(masterUrl?.Trim()); + return UpdateService.Validate(Redirect(HostStreamProxy(init, cleaned, proxy: proxyManager.Get()))); } if (itemType == "Фільм") @@ -140,5 +153,21 @@ namespace Unimay.Controllers return OnError("unsupported type"); }); } + + private static string StripLampacArgs(string url) + { + if (string.IsNullOrEmpty(url)) + return url; + + string cleaned = System.Text.RegularExpressions.Regex.Replace( + url, + @"([?&])(account_email|uid|nws_id)=[^&]*", + "$1", + System.Text.RegularExpressions.RegexOptions.IgnoreCase + ); + + cleaned = cleaned.Replace("?&", "?").Replace("&&", "&").TrimEnd('?', '&'); + return cleaned; + } } } diff --git a/Unimay/ModInit.cs b/Unimay/ModInit.cs index 2984448..4f15197 100644 --- a/Unimay/ModInit.cs +++ b/Unimay/ModInit.cs @@ -30,7 +30,7 @@ namespace Unimay { public class ModInit { - public static double Version => 3.2; + public static double Version => 3.3; public static OnlinesSettings Unimay;