diff --git a/UafilmME/ApnHelper.cs b/UafilmME/ApnHelper.cs new file mode 100644 index 0000000..2274b2b --- /dev/null +++ b/UafilmME/ApnHelper.cs @@ -0,0 +1,99 @@ +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?.Type != JTokenType.Boolean) + return false; + + enabled = apnToken.Value(); + host = conf.Value("apn_host"); + return true; + } + + public static void ApplyInitConf(bool enabled, string host, BaseSettings init) + { + if (init == null) + return; + + if (!enabled) + { + init.apnstream = false; + init.apn = null; + return; + } + + host = NormalizeHost(host); + if (host == null) + { + init.apnstream = false; + init.apn = null; + return; + } + + 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}"; + } + + private static string NormalizeHost(string host) + { + if (string.IsNullOrWhiteSpace(host)) + return null; + + return host.Trim(); + } + } +} diff --git a/UafilmME/Controller.cs b/UafilmME/Controller.cs new file mode 100644 index 0000000..e03c30c --- /dev/null +++ b/UafilmME/Controller.cs @@ -0,0 +1,356 @@ +using Microsoft.AspNetCore.Mvc; +using Shared; +using Shared.Engine; +using Shared.Models; +using Shared.Models.Online.Settings; +using Shared.Models.Templates; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Web; +using UafilmME.Models; + +namespace UafilmME.Controllers +{ + public class Controller : BaseOnlineController + { + ProxyManager proxyManager; + + public Controller() : base(ModInit.Settings) + { + proxyManager = new ProxyManager(ModInit.UafilmME); + } + + [HttpGet] + [Route("lite/uafilmme")] + 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); + + var init = loadKit(ModInit.UafilmME); + if (!init.enable) + return Forbid(); + + var invoke = new UafilmMEInvoke(init, hybridCache, OnLog, proxyManager, httpHydra); + + if (checksearch) + { + if (!IsCheckOnlineSearchEnabled()) + return OnError("uafilmme", refresh_proxy: true); + + var searchResults = await invoke.Search(title, original_title, year); + if (searchResults != null && searchResults.Count > 0) + return Content("data-json=", "text/plain; charset=utf-8"); + + return OnError("uafilmme", refresh_proxy: true); + } + + long titleId = 0; + long.TryParse(href, out titleId); + + if (titleId <= 0) + { + var searchResults = await invoke.Search(title, original_title, year); + if (searchResults == null || searchResults.Count == 0) + { + OnLog("UafilmME: пошук нічого не повернув."); + return OnError("uafilmme", refresh_proxy: true); + } + + var best = invoke.SelectBestSearchResult(searchResults, id, imdb_id, title, original_title, year, serial); + var ordered = searchResults + .OrderByDescending(r => r.MatchScore) + .ThenByDescending(r => r.Year) + .ToList(); + + var second = ordered.Skip(1).FirstOrDefault(); + if (!IsConfidentMatch(best, second, id, imdb_id, serial)) + { + var similarTpl = new SimilarTpl(ordered.Count); + foreach (var item in ordered.Take(60)) + { + string details = item.IsSeries ? "Серіал" : "Фільм"; + string itemYear = item.Year > 1900 ? item.Year.ToString() : string.Empty; + string link = $"{host}/lite/uafilmme?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial={serial}&href={item.Id}"; + similarTpl.Append(item.Name, itemYear, details, link, item.Poster); + } + + OnLog($"UafilmME: кілька схожих збігів, повертаю SimilarTpl ({ordered.Count})."); + return rjson + ? Content(similarTpl.ToJson(), "application/json; charset=utf-8") + : Content(similarTpl.ToHtml(), "text/html; charset=utf-8"); + } + + titleId = best?.Id ?? 0; + } + + if (titleId <= 0) + { + OnLog("UafilmME: не вдалося визначити title_id."); + return OnError("uafilmme", refresh_proxy: true); + } + + if (serial == 1) + { + if (s == -1) + { + var seasons = await invoke.GetAllSeasons(titleId); + if (seasons == null || seasons.Count == 0) + { + OnLog($"UafilmME: сезони не знайдено для title_id={titleId}."); + return OnError("uafilmme", refresh_proxy: true); + } + + var seasonTpl = new SeasonTpl(seasons.Count); + foreach (var season in seasons) + { + string seasonName = season.EpisodesCount > 0 + ? $"Сезон {season.Number} ({season.EpisodesCount} еп.)" + : $"Сезон {season.Number}"; + + string link = $"{host}/lite/uafilmme?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={season.Number}&href={titleId}"; + seasonTpl.Append(seasonName, link, season.Number.ToString()); + } + + return rjson + ? Content(seasonTpl.ToJson(), "application/json; charset=utf-8") + : Content(seasonTpl.ToHtml(), "text/html; charset=utf-8"); + } + + if (s <= 0) + { + OnLog($"UafilmME: некоректний номер сезону s={s}."); + return OnError("uafilmme", refresh_proxy: true); + } + + var episodes = await invoke.GetSeasonEpisodes(titleId, s); + if (episodes == null || episodes.Count == 0) + { + OnLog($"UafilmME: епізоди не знайдено для title_id={titleId}, season={s}."); + return OnError("uafilmme", refresh_proxy: true); + } + + var episodeTpl = new EpisodeTpl(); + int appended = 0; + int fallbackEpisodeNumber = 1; + + foreach (var episode in episodes) + { + if (episode.PrimaryVideoId <= 0) + continue; + + int episodeNumber = episode.EpisodeNumber > 0 ? episode.EpisodeNumber : fallbackEpisodeNumber; + string episodeName = !string.IsNullOrWhiteSpace(episode.Name) + ? episode.Name + : $"Епізод {episodeNumber}"; + + string callUrl = $"{host}/lite/uafilmme/play?video_id={episode.PrimaryVideoId}&title_id={titleId}&s={s}&e={episodeNumber}&title={HttpUtility.UrlEncode(title ?? original_title)}"; + episodeTpl.Append(episodeName, title ?? original_title, s.ToString(), episodeNumber.ToString("D2"), accsArgs(callUrl), "call"); + + fallbackEpisodeNumber = Math.Max(fallbackEpisodeNumber, episodeNumber + 1); + appended++; + } + + if (appended == 0) + { + OnLog($"UafilmME: у сезоні {s} немає епізодів з playable video_id."); + return OnError("uafilmme", refresh_proxy: true); + } + + return rjson + ? Content(episodeTpl.ToJson(), "application/json; charset=utf-8") + : Content(episodeTpl.ToHtml(), "text/html; charset=utf-8"); + } + else + { + var videos = await invoke.GetMovieVideos(titleId); + if (videos == null || videos.Count == 0) + { + OnLog($"UafilmME: не знайдено відео для фільму title_id={titleId}."); + return OnError("uafilmme", refresh_proxy: true); + } + + var movieTpl = new MovieTpl(title, original_title, videos.Count); + int index = 1; + foreach (var video in videos) + { + string label = BuildVideoLabel(video, index); + string callUrl = $"{host}/lite/uafilmme/play?video_id={video.Id}&title_id={titleId}&title={HttpUtility.UrlEncode(title ?? original_title)}"; + movieTpl.Append(label, accsArgs(callUrl), "call"); + index++; + } + + return rjson + ? Content(movieTpl.ToJson(), "application/json; charset=utf-8") + : Content(movieTpl.ToHtml(), "text/html; charset=utf-8"); + } + } + + [HttpGet] + [Route("lite/uafilmme/play")] + async public Task Play(long video_id, long title_id = 0, int s = 0, int e = 0, string title = null) + { + await UpdateService.ConnectAsync(host); + + if (video_id <= 0) + return OnError("uafilmme", refresh_proxy: true); + + var init = loadKit(ModInit.UafilmME); + if (!init.enable) + return Forbid(); + + var invoke = new UafilmMEInvoke(init, hybridCache, OnLog, proxyManager, httpHydra); + var watch = await invoke.GetWatch(video_id); + var videos = invoke.CollectPlayableVideos(watch); + if (videos == null || videos.Count == 0) + { + OnLog($"UafilmME Play: watch/{video_id} не повернув playable stream."); + return OnError("uafilmme", refresh_proxy: true); + } + + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", init.host) + }; + + var streamQuality = new StreamQualityTpl(); + foreach (var video in videos) + { + string streamUrl = BuildStreamUrl(init, video.Src, headers, forceProxy: true); + if (string.IsNullOrWhiteSpace(streamUrl)) + continue; + + string label = BuildVideoLabel(video, 0); + streamQuality.Append(streamUrl, label); + } + + var first = streamQuality.Firts(); + if (string.IsNullOrWhiteSpace(first.link)) + { + OnLog($"UafilmME Play: не вдалося зібрати streamquality для video_id={video_id}."); + return OnError("uafilmme", refresh_proxy: true); + } + + string videoTitle = !string.IsNullOrWhiteSpace(title) + ? title + : videos.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v.Name))?.Name ?? string.Empty; + + return UpdateService.Validate( + Content( + VideoTpl.ToJson("play", first.link, videoTitle, streamquality: streamQuality), + "application/json; charset=utf-8" + ) + ); + } + + string BuildStreamUrl(OnlinesSettings init, string streamLink, List headers, bool forceProxy) + { + string link = StripLampacArgs(streamLink?.Trim()); + if (string.IsNullOrEmpty(link)) + return 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, headers: headers, force_streamproxy: forceProxy, proxy: proxyManager.Get()); + } + + return HostStreamProxy(init, link, headers: headers, force_streamproxy: forceProxy, proxy: proxyManager.Get()); + } + + private static bool IsConfidentMatch(UafilmSearchItem best, UafilmSearchItem second, long tmdbId, string imdbId, int serial) + { + if (best == null) + return false; + + bool sameTmdb = tmdbId > 0 && best.TmdbId == tmdbId; + bool sameImdb = !string.IsNullOrWhiteSpace(imdbId) + && !string.IsNullOrWhiteSpace(best.ImdbId) + && string.Equals(best.ImdbId.Trim(), imdbId.Trim(), StringComparison.OrdinalIgnoreCase); + + if (sameTmdb || sameImdb) + return true; + + if (serial == 1 && !best.IsSeries) + return false; + + int secondScore = second?.MatchScore ?? int.MinValue; + return best.MatchScore >= 65 && best.MatchScore - secondScore >= 10; + } + + private static string BuildVideoLabel(UafilmVideoItem video, int index) + { + var parts = new List(); + if (!string.IsNullOrWhiteSpace(video?.Name)) + parts.Add(video.Name.Trim()); + + if (!string.IsNullOrWhiteSpace(video?.Quality)) + parts.Add(video.Quality.Trim()); + + if (!string.IsNullOrWhiteSpace(video?.Language)) + parts.Add(video.Language.Trim()); + + if (parts.Count == 0) + return index > 0 ? $"Варіант {index}" : "Потік"; + + return string.Join(" • ", parts.Distinct(StringComparer.OrdinalIgnoreCase)); + } + + 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 IsCheckOnlineSearchEnabled() + { + try + { + var onlineType = Type.GetType("Online.ModInit"); + if (onlineType == null) + { + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + onlineType = asm.GetType("Online.ModInit"); + if (onlineType != null) + break; + } + } + + var confField = onlineType?.GetField("conf", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + var conf = confField?.GetValue(null); + var checkProp = conf?.GetType().GetProperty("checkOnlineSearch", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + + if (checkProp?.GetValue(conf) is bool enabled) + return enabled; + } + catch + { + } + + return true; + } + + private static void OnLog(string message) + { + System.Console.WriteLine(message); + } + } +} diff --git a/UafilmME/GlobalUsings.cs b/UafilmME/GlobalUsings.cs new file mode 100644 index 0000000..4c00409 --- /dev/null +++ b/UafilmME/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using Shared.Services; +global using Shared.Services.Hybrid; +global using Shared.Models.Base; +global using AppInit = Shared.CoreInit; diff --git a/UafilmME/ModInit.cs b/UafilmME/ModInit.cs new file mode 100644 index 0000000..daf6ebb --- /dev/null +++ b/UafilmME/ModInit.cs @@ -0,0 +1,228 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Shared; +using Shared.Engine; +using Shared.Models.Module; +using Shared.Models.Module.Interfaces; +using Shared.Models.Online.Settings; +using Microsoft.AspNetCore.Mvc; +using System; +using System.Net.Http; +using System.Net.Mime; +using System.Net.Security; +using System.Security.Authentication; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace UafilmME +{ + public class ModInit : IModuleLoaded + { + public static double Version => 1.0; + + public static OnlinesSettings UafilmME; + public static bool ApnHostProvided; + + public static OnlinesSettings Settings + { + get => UafilmME; + set => UafilmME = value; + } + + /// + /// Модуль завантажено. + /// + public void Loaded(InitspaceModel initspace) + { + UafilmME = new OnlinesSettings("UafilmME", "https://uafilm.me", streamproxy: false, useproxy: false) + { + displayname = "UAFilmME", + displayindex = 0, + proxy = new Shared.Models.Base.ProxySettings() + { + useAuth = true, + username = "", + password = "", + list = new string[] { "socks5://ip:port" } + } + }; + + var conf = ModuleInvoke.Init("UafilmME", JObject.FromObject(UafilmME)); + bool hasApn = ApnHelper.TryGetInitConf(conf, out bool apnEnabled, out string apnHost); + conf.Remove("apn"); + conf.Remove("apn_host"); + UafilmME = conf.ToObject(); + if (hasApn) + ApnHelper.ApplyInitConf(apnEnabled, apnHost, UafilmME); + + ApnHostProvided = hasApn && apnEnabled && !string.IsNullOrWhiteSpace(apnHost); + if (hasApn && apnEnabled) + { + UafilmME.streamproxy = false; + } + else if (UafilmME.streamproxy) + { + UafilmME.apnstream = false; + UafilmME.apn = null; + } + + RegisterWithSearch("uafilmme"); + } + + private static void RegisterWithSearch(string plugin) + { + try + { + var onlineType = Type.GetType("Online.ModInit"); + if (onlineType == null) + { + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + onlineType = asm.GetType("Online.ModInit"); + if (onlineType != null) + break; + } + } + + var confField = onlineType?.GetField("conf", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + var conf = confField?.GetValue(null); + var withSearchProp = conf?.GetType().GetProperty("with_search", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + + if (withSearchProp?.GetValue(conf) is System.Collections.IList list) + { + foreach (var item in list) + { + if (string.Equals(item?.ToString(), plugin, StringComparison.OrdinalIgnoreCase)) + return; + } + + list.Add(plugin); + } + } + catch + { + } + } + + public void Dispose() + { + } + } + + public static class UpdateService + { + private static readonly string _connectUrl = "https://lmcuk.lme.isroot.in/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, 4)) + : DateTime.UtcNow; + } + } + } + catch + { + 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/UafilmME/Models/UafilmModels.cs b/UafilmME/Models/UafilmModels.cs new file mode 100644 index 0000000..cc88d09 --- /dev/null +++ b/UafilmME/Models/UafilmModels.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; + +namespace UafilmME.Models +{ + public class UafilmSearchItem + { + public long Id { get; set; } + public string Name { get; set; } + public string OriginalTitle { get; set; } + public bool IsSeries { get; set; } + public int Year { get; set; } + public string ImdbId { get; set; } + public long TmdbId { get; set; } + public string Poster { get; set; } + public int MatchScore { get; set; } + } + + public class UafilmTitleDetails + { + public long Id { get; set; } + public string Name { get; set; } + public string OriginalTitle { get; set; } + public bool IsSeries { get; set; } + public int Year { get; set; } + public string ImdbId { get; set; } + public long TmdbId { get; set; } + public int SeasonsCount { get; set; } + public long PrimaryVideoId { get; set; } + } + + public class UafilmSeasonItem + { + public long Id { get; set; } + public int Number { get; set; } + public int EpisodesCount { get; set; } + } + + public class UafilmEpisodeItem + { + public long Id { get; set; } + public string Name { get; set; } + public int SeasonNumber { get; set; } + public int EpisodeNumber { get; set; } + public long PrimaryVideoId { get; set; } + public string PrimaryVideoName { get; set; } + } + + public class UafilmVideoItem + { + public long Id { get; set; } + public string Name { get; set; } + public string Src { get; set; } + public string Type { get; set; } + public string Quality { get; set; } + public string Origin { get; set; } + public string Language { get; set; } + public int? SeasonNum { get; set; } + public int? EpisodeNum { get; set; } + public long EpisodeId { get; set; } + } + + public class UafilmWatchInfo + { + public UafilmVideoItem Video { get; set; } + public List AlternativeVideos { get; set; } = new(); + } +} diff --git a/UafilmME/OnlineApi.cs b/UafilmME/OnlineApi.cs new file mode 100644 index 0000000..5cfdf42 --- /dev/null +++ b/UafilmME/OnlineApi.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Http; +using Shared.Models; +using Shared.Models.Module; +using Shared.Models.Module.Interfaces; +using System.Collections.Generic; + +namespace UafilmME +{ + public class OnlineApi : IModuleOnline + { + public List Invoke(HttpContext httpContext, 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); + } + + private static List 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(); + + var init = ModInit.UafilmME; + if (init.enable && !init.rip) + { + if (UpdateService.IsDisconnected()) + init.overridehost = null; + + online.Add(new ModuleOnlineItem(init, "uafilmme")); + } + + return online; + } + } +} diff --git a/UafilmME/UafilmME.csproj b/UafilmME/UafilmME.csproj new file mode 100644 index 0000000..c280999 --- /dev/null +++ b/UafilmME/UafilmME.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + library + true + + + + + ..\..\Shared.dll + + + + diff --git a/UafilmME/UafilmMEInvoke.cs b/UafilmME/UafilmMEInvoke.cs new file mode 100644 index 0000000..72aac15 --- /dev/null +++ b/UafilmME/UafilmMEInvoke.cs @@ -0,0 +1,751 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; +using System.Text.Json; +using System.Threading.Tasks; +using System.Web; +using Shared; +using Shared.Engine; +using Shared.Models; +using Shared.Models.Online.Settings; +using UafilmME.Models; + +namespace UafilmME +{ + public class UafilmMEInvoke + { + private readonly OnlinesSettings _init; + private readonly IHybridCache _hybridCache; + private readonly Action _onLog; + private readonly ProxyManager _proxyManager; + private readonly HttpHydra _httpHydra; + + + + public UafilmMEInvoke(OnlinesSettings init, IHybridCache hybridCache, Action onLog, ProxyManager proxyManager, HttpHydra httpHydra = null) + { + _init = init; + _hybridCache = hybridCache; + _onLog = onLog; + _proxyManager = proxyManager; + _httpHydra = httpHydra; + } + + public async Task> Search(string title, string originalTitle, int year) + { + var queries = BuildSearchQueries(title, originalTitle, year).ToList(); + if (queries.Count == 0) + return new List(); + + var all = new Dictionary(); + foreach (var query in queries) + { + var items = await SearchByQuery(query); + foreach (var item in items) + all[item.Id] = item; + } + + return all.Values.ToList(); + } + + public UafilmSearchItem SelectBestSearchResult(List results, long tmdbId, string imdbId, string title, string originalTitle, int year, int serial) + { + if (results == null || results.Count == 0) + return null; + + foreach (var item in results) + item.MatchScore = CalcMatchScore(item, tmdbId, imdbId, title, originalTitle, year, serial); + + return results + .OrderByDescending(r => r.MatchScore) + .ThenByDescending(r => r.Year) + .FirstOrDefault(); + } + + public async Task GetTitleDetails(long titleId) + { + string memKey = $"UafilmME:title:{titleId}"; + if (_hybridCache.TryGetValue(memKey, out UafilmTitleDetails cached)) + return cached; + + try + { + string json = await ApiGet($"titles/{titleId}?loader=titlePage", $"{_init.host}/titles/{titleId}"); + var title = ParseTitleDetails(json); + if (title != null) + _hybridCache.Set(memKey, title, cacheTime(30, init: _init)); + + return title; + } + catch (Exception ex) + { + _onLog?.Invoke($"UafilmME: помилка отримання title {titleId}: {ex.Message}"); + return null; + } + } + + public async Task> GetAllSeasons(long titleId) + { + string memKey = $"UafilmME:seasons:{titleId}"; + if (_hybridCache.TryGetValue(memKey, out List cached)) + return cached; + + var all = new List(); + int currentPage = 1; + int guard = 0; + + while (currentPage > 0 && guard < 100) + { + guard++; + var page = await GetSeasonsPage(titleId, currentPage); + if (page.Items.Count == 0) + break; + + all.AddRange(page.Items); + + if (page.NextPage.HasValue && page.NextPage.Value != currentPage) + currentPage = page.NextPage.Value; + else + break; + } + + var result = all + .GroupBy(s => s.Number) + .Select(g => g.OrderByDescending(x => x.EpisodesCount).First()) + .OrderBy(s => s.Number) + .ToList(); + + if (result.Count == 0) + { + var title = await GetTitleDetails(titleId); + if (title?.SeasonsCount > 0) + { + for (int i = 1; i <= title.SeasonsCount; i++) + { + result.Add(new UafilmSeasonItem() + { + Number = i, + EpisodesCount = 0 + }); + } + } + } + + if (result.Count > 0) + _hybridCache.Set(memKey, result, cacheTime(60, init: _init)); + + return result; + } + + public async Task> GetSeasonEpisodes(long titleId, int season) + { + string memKey = $"UafilmME:episodes:{titleId}:{season}"; + if (_hybridCache.TryGetValue(memKey, out List cached)) + return cached; + + var all = new List(); + int currentPage = 1; + int guard = 0; + + while (currentPage > 0 && guard < 200) + { + guard++; + var page = await GetEpisodesPage(titleId, season, currentPage); + if (page.Items.Count == 0) + break; + + all.AddRange(page.Items); + + if (page.NextPage.HasValue && page.NextPage.Value != currentPage) + currentPage = page.NextPage.Value; + else + break; + } + + var result = all + .GroupBy(e => e.Id) + .Select(g => g.First()) + .OrderBy(e => e.EpisodeNumber) + .ToList(); + + if (result.Count > 0) + _hybridCache.Set(memKey, result, cacheTime(30, init: _init)); + + return result; + } + + public async Task> GetMovieVideos(long titleId) + { + var title = await GetTitleDetails(titleId); + if (title == null || title.PrimaryVideoId <= 0) + return new List(); + + var watch = await GetWatch(title.PrimaryVideoId); + return CollectPlayableVideos(watch); + } + + public async Task GetWatch(long videoId) + { + if (videoId <= 0) + return null; + + string memKey = $"UafilmME:watch:{videoId}"; + if (_hybridCache.TryGetValue(memKey, out UafilmWatchInfo cached)) + return cached; + + try + { + string json = await ApiGet($"watch/{videoId}", _init.host); + var watch = ParseWatchInfo(json); + if (watch?.Video != null) + _hybridCache.Set(memKey, watch, cacheTime(7, init: _init)); + + return watch; + } + catch (Exception ex) + { + _onLog?.Invoke($"UafilmME: помилка отримання watch/{videoId}: {ex.Message}"); + return null; + } + } + + public List CollectPlayableVideos(UafilmWatchInfo watch) + { + var list = new List(); + if (watch == null) + return list; + + if (watch.Video != null) + list.Add(watch.Video); + + if (watch.AlternativeVideos != null && watch.AlternativeVideos.Count > 0) + list.AddRange(watch.AlternativeVideos); + + return list + .Where(v => v != null && v.Id > 0) + .Select(v => + { + v.Src = NormalizeVideoSource(v.Src); + return v; + }) + .Where(v => !string.IsNullOrWhiteSpace(v.Src)) + .Where(v => !string.Equals(v.Type, "embed", StringComparison.OrdinalIgnoreCase)) + .Where(v => v.Src.IndexOf("youtube.com", StringComparison.OrdinalIgnoreCase) < 0) + .GroupBy(v => v.Id) + .Select(g => g.First()) + .ToList(); + } + + private async Task> SearchByQuery(string query) + { + string memKey = $"UafilmME:search:{query}"; + if (_hybridCache.TryGetValue(memKey, out List cached)) + return cached; + + string encoded = HttpUtility.UrlEncode(query); + string json = await ApiGet($"search/{encoded}?loader=searchPage", $"{_init.host}/search/{encoded}"); + var items = ParseSearchResults(json); + + if (items.Count > 0) + _hybridCache.Set(memKey, items, cacheTime(20, init: _init)); + + return items; + } + + private async Task<(List Items, int? NextPage)> GetSeasonsPage(long titleId, int page) + { + string memKey = $"UafilmME:seasons-page:{titleId}:{page}"; + if (_hybridCache.TryGetValue(memKey, out List cachedItems) && + _hybridCache.TryGetValue(memKey + ":next", out int? cachedNext)) + { + return (cachedItems, cachedNext); + } + + string suffix = page > 1 ? $"?page={page}" : string.Empty; + string json = await ApiGet($"titles/{titleId}/seasons{suffix}", $"{_init.host}/titles/{titleId}"); + var parsed = ParseSeasonsPage(json); + + _hybridCache.Set(memKey, parsed.Items, cacheTime(30, init: _init)); + _hybridCache.Set(memKey + ":next", parsed.NextPage, cacheTime(30, init: _init)); + return parsed; + } + + private async Task<(List Items, int? NextPage)> GetEpisodesPage(long titleId, int season, int page) + { + string memKey = $"UafilmME:episodes-page:{titleId}:{season}:{page}"; + if (_hybridCache.TryGetValue(memKey, out List cachedItems) && + _hybridCache.TryGetValue(memKey + ":next", out int? cachedNext)) + { + return (cachedItems, cachedNext); + } + + string suffix = page > 1 ? $"?page={page}" : string.Empty; + string json = await ApiGet($"titles/{titleId}/seasons/{season}/episodes{suffix}", $"{_init.host}/titles/{titleId}"); + var parsed = ParseEpisodesPage(json); + + _hybridCache.Set(memKey, parsed.Items, cacheTime(20, init: _init)); + _hybridCache.Set(memKey + ":next", parsed.NextPage, cacheTime(20, init: _init)); + return parsed; + } + + private async Task ApiGet(string pathAndQuery, string referer) + { + string url = $"{_init.host.TrimEnd('/')}/api/v1/{pathAndQuery.TrimStart('/')}"; + string reqReferer = string.IsNullOrWhiteSpace(referer) ? $"{_init.host}/" : referer; + + var headers = new List() + { + new HeadersModel("User-Agent", "EchoapiRuntime/1.1.0"), + new HeadersModel("Referer", reqReferer), + new HeadersModel("Accept", "*/*") + }; + + if (_httpHydra != null) + return await _httpHydra.Get(url, newheaders: headers); + + return await Http.Get(url, headers: headers, proxy: _proxyManager.Get()); + } + + private string NormalizeVideoSource(string src) + { + if (string.IsNullOrWhiteSpace(src)) + return null; + + src = src.Trim(); + if (src.StartsWith("//")) + return "https:" + src; + + if (src.StartsWith("/")) + return _init.host.TrimEnd('/') + src; + + return src; + } + + private static IEnumerable BuildSearchQueries(string title, string originalTitle, int year) + { + var queries = new List(); + void Add(string value) + { + if (!string.IsNullOrWhiteSpace(value)) + queries.Add(value.Trim()); + } + + Add(title); + Add(originalTitle); + + if (year > 1900) + { + if (!string.IsNullOrWhiteSpace(title)) + Add($"{title} {year}"); + + if (!string.IsNullOrWhiteSpace(originalTitle)) + Add($"{originalTitle} {year}"); + } + + return queries + .Where(q => !string.IsNullOrWhiteSpace(q)) + .Distinct(StringComparer.OrdinalIgnoreCase); + } + + private List ParseSearchResults(string json) + { + var list = new List(); + if (string.IsNullOrWhiteSpace(json)) + return list; + + using var doc = JsonDocument.Parse(json); + if (!TryGetArray(doc.RootElement, "results", out var results)) + return list; + + foreach (var item in results.EnumerateArray()) + { + if (!TryReadLong(item, "id", out long id) || id <= 0) + continue; + + list.Add(new UafilmSearchItem() + { + Id = id, + Name = ReadString(item, "name"), + OriginalTitle = ReadString(item, "original_title"), + IsSeries = ReadBool(item, "is_series"), + Year = ReadInt(item, "year"), + ImdbId = ReadString(item, "imdb_id"), + TmdbId = ReadLong(item, "tmdb_id"), + Poster = ReadString(item, "poster") + }); + } + + return list; + } + + private UafilmTitleDetails ParseTitleDetails(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + using var doc = JsonDocument.Parse(json); + if (!TryGetObject(doc.RootElement, "title", out var titleObj)) + return null; + + var info = new UafilmTitleDetails() + { + Id = ReadLong(titleObj, "id"), + Name = ReadString(titleObj, "name"), + OriginalTitle = ReadString(titleObj, "original_title"), + IsSeries = ReadBool(titleObj, "is_series"), + Year = ReadInt(titleObj, "year"), + ImdbId = ReadString(titleObj, "imdb_id"), + TmdbId = ReadLong(titleObj, "tmdb_id"), + SeasonsCount = ReadInt(titleObj, "seasons_count") + }; + + if (TryGetObject(titleObj, "primary_video", out var primaryVideo)) + info.PrimaryVideoId = ReadLong(primaryVideo, "id"); + + return info; + } + + private (List Items, int? NextPage) ParseSeasonsPage(string json) + { + var items = new List(); + int? next = null; + if (string.IsNullOrWhiteSpace(json)) + return (items, next); + + using var doc = JsonDocument.Parse(json); + if (!TryGetObject(doc.RootElement, "pagination", out var pagination)) + return (items, next); + + next = ReadNullableInt(pagination, "next_page"); + + if (!TryGetArray(pagination, "data", out var data)) + return (items, next); + + foreach (var item in data.EnumerateArray()) + { + int number = ReadInt(item, "number"); + if (number <= 0) + continue; + + items.Add(new UafilmSeasonItem() + { + Id = ReadLong(item, "id"), + Number = number, + EpisodesCount = ReadInt(item, "episodes_count") + }); + } + + return (items, next); + } + + private (List Items, int? NextPage) ParseEpisodesPage(string json) + { + var items = new List(); + int? next = null; + if (string.IsNullOrWhiteSpace(json)) + return (items, next); + + using var doc = JsonDocument.Parse(json); + if (!TryGetObject(doc.RootElement, "pagination", out var pagination)) + return (items, next); + + next = ReadNullableInt(pagination, "next_page"); + + if (!TryGetArray(pagination, "data", out var data)) + return (items, next); + + foreach (var item in data.EnumerateArray()) + { + long episodeId = ReadLong(item, "id"); + if (episodeId <= 0) + continue; + + long primaryVideoId = 0; + string primaryVideoName = null; + if (TryGetObject(item, "primary_video", out var primaryVideoObj)) + { + primaryVideoId = ReadLong(primaryVideoObj, "id"); + primaryVideoName = ReadString(primaryVideoObj, "name"); + } + + items.Add(new UafilmEpisodeItem() + { + Id = episodeId, + Name = ReadString(item, "name"), + SeasonNumber = ReadInt(item, "season_number"), + EpisodeNumber = ReadInt(item, "episode_number"), + PrimaryVideoId = primaryVideoId, + PrimaryVideoName = primaryVideoName + }); + } + + return (items, next); + } + + private UafilmWatchInfo ParseWatchInfo(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return null; + + var watch = new UafilmWatchInfo(); + + if (TryGetObject(doc.RootElement, "video", out var videoObj)) + watch.Video = ParseVideo(videoObj); + + if (TryGetArray(doc.RootElement, "alternative_videos", out var alternatives)) + { + foreach (var alt in alternatives.EnumerateArray()) + { + var parsed = ParseVideo(alt); + if (parsed != null) + watch.AlternativeVideos.Add(parsed); + } + } + + return watch; + } + + private static UafilmVideoItem ParseVideo(JsonElement obj) + { + long id = ReadLong(obj, "id"); + if (id <= 0) + return null; + + return new UafilmVideoItem() + { + Id = id, + Name = ReadString(obj, "name"), + Src = ReadString(obj, "src"), + Type = ReadString(obj, "type"), + Quality = ReadString(obj, "quality"), + Origin = ReadString(obj, "origin"), + Language = ReadString(obj, "language"), + SeasonNum = ReadNullableInt(obj, "season_num"), + EpisodeNum = ReadNullableInt(obj, "episode_num"), + EpisodeId = ReadLong(obj, "episode_id") + }; + } + + private int CalcMatchScore(UafilmSearchItem item, long tmdbId, string imdbId, string title, string originalTitle, int year, int serial) + { + int score = 0; + + if (item == null) + return score; + + if (tmdbId > 0 && item.TmdbId == tmdbId) + score += 120; + + if (!string.IsNullOrWhiteSpace(imdbId) && !string.IsNullOrWhiteSpace(item.ImdbId) && string.Equals(item.ImdbId.Trim(), imdbId.Trim(), StringComparison.OrdinalIgnoreCase)) + score += 120; + + if (serial == 1) + score += item.IsSeries ? 25 : -25; + else + score += item.IsSeries ? -15 : 15; + + if (year > 1900 && item.Year > 1900) + { + int diff = Math.Abs(item.Year - year); + if (diff == 0) + score += 20; + else if (diff == 1) + score += 10; + else if (diff == 2) + score += 5; + else + score -= 6; + } + + score += ScoreTitle(item.Name, title); + score += ScoreTitle(item.Name, originalTitle); + score += ScoreTitle(item.OriginalTitle, title); + score += ScoreTitle(item.OriginalTitle, originalTitle); + + return score; + } + + private static int ScoreTitle(string candidate, string expected) + { + if (string.IsNullOrWhiteSpace(candidate) || string.IsNullOrWhiteSpace(expected)) + return 0; + + string left = NormalizeTitle(candidate); + string right = NormalizeTitle(expected); + if (string.IsNullOrEmpty(left) || string.IsNullOrEmpty(right)) + return 0; + + if (left == right) + return 35; + + if (left.Contains(right) || right.Contains(left)) + return 20; + + var leftWords = left.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var rightWords = right.Split(' ', StringSplitOptions.RemoveEmptyEntries); + int overlap = leftWords.Intersect(rightWords).Count(); + if (overlap >= 2) + return 12; + if (overlap == 1) + return 6; + + return 0; + } + + private static string NormalizeTitle(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + string normalized = value.ToLowerInvariant(); + normalized = Regex.Replace(normalized, "[^\\p{L}\\p{Nd}]+", " ", RegexOptions.CultureInvariant); + normalized = Regex.Replace(normalized, "\\s+", " ", RegexOptions.CultureInvariant).Trim(); + return normalized; + } + + private static bool TryGetObject(JsonElement source, string property, out JsonElement value) + { + value = default; + if (!source.TryGetProperty(property, out var prop) || prop.ValueKind != JsonValueKind.Object) + return false; + + value = prop; + return true; + } + + private static bool TryGetArray(JsonElement source, string property, out JsonElement value) + { + value = default; + if (!source.TryGetProperty(property, out var prop) || prop.ValueKind != JsonValueKind.Array) + return false; + + value = prop; + return true; + } + + private static string ReadString(JsonElement source, string property) + { + if (!source.TryGetProperty(property, out var value)) + return null; + + if (value.ValueKind == JsonValueKind.String) + return value.GetString(); + + if (value.ValueKind == JsonValueKind.Number) + return value.GetRawText(); + + if (value.ValueKind == JsonValueKind.True) + return bool.TrueString; + + if (value.ValueKind == JsonValueKind.False) + return bool.FalseString; + + return null; + } + + private static bool ReadBool(JsonElement source, string property) + { + if (!source.TryGetProperty(property, out var value)) + return false; + + if (value.ValueKind == JsonValueKind.True) + return true; + + if (value.ValueKind == JsonValueKind.False) + return false; + + if (value.ValueKind == JsonValueKind.Number) + return value.GetInt32() != 0; + + if (value.ValueKind == JsonValueKind.String) + { + string text = value.GetString(); + if (bool.TryParse(text, out bool parsedBool)) + return parsedBool; + + if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedInt)) + return parsedInt != 0; + } + + return false; + } + + private static int ReadInt(JsonElement source, string property) + { + if (!source.TryGetProperty(property, out var value)) + return 0; + + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out int number)) + return number; + + if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed)) + return parsed; + + return 0; + } + + private static int? ReadNullableInt(JsonElement source, string property) + { + if (!source.TryGetProperty(property, out var value)) + return null; + + if (value.ValueKind == JsonValueKind.Null) + return null; + + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out int number)) + return number; + + if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed)) + return parsed; + + return null; + } + + private static long ReadLong(JsonElement source, string property) + { + return TryReadLong(source, property, out long value) + ? value + : 0; + } + + public static TimeSpan cacheTime(int multiaccess, int home = 5, int mikrotik = 2, OnlinesSettings init = null, int rhub = -1) + { + if (init != null && init.rhub && rhub != -1) + return TimeSpan.FromMinutes(rhub); + + int ctime = init != null && init.cache_time > 0 ? init.cache_time : multiaccess; + + if (ctime > multiaccess) + ctime = multiaccess; + + return TimeSpan.FromMinutes(ctime); + } + + private static bool TryReadLong(JsonElement source, string property, out long value) + { + value = 0; + if (!source.TryGetProperty(property, out var element)) + return false; + + if (element.ValueKind == JsonValueKind.Number && element.TryGetInt64(out long number)) + { + value = number; + return true; + } + + if (element.ValueKind == JsonValueKind.String && long.TryParse(element.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long parsed)) + { + value = parsed; + return true; + } + + return false; + } + } +} diff --git a/UafilmME/manifest.json b/UafilmME/manifest.json new file mode 100644 index 0000000..4a1c1f6 --- /dev/null +++ b/UafilmME/manifest.json @@ -0,0 +1,6 @@ +{ + "enable": true, + "version": 3, + "initspace": "UafilmME.ModInit", + "online": "UafilmME.OnlineApi" +}