diff --git a/LME.StreamData/Controller.cs b/LME.StreamData/Controller.cs new file mode 100644 index 0000000..27970d7 --- /dev/null +++ b/LME.StreamData/Controller.cs @@ -0,0 +1,306 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Web; +using LME.StreamData.Models; +using Microsoft.AspNetCore.Mvc; +using Shared; +using Shared.Engine; +using Shared.Models; +using Shared.Models.Online.Settings; +using Shared.Models.Templates; + +namespace LME.StreamData.Controllers +{ + public class Controller : BaseOnlineController + { + ProxyManager proxyManager; + + public Controller() : base(ModInit.Settings) + { + proxyManager = new ProxyManager(ModInit.StreamDataSettings); + } + + /// + /// Головний ендпоінт модуля StreamData + /// Працює виключно через TMDB ID (параметр id) + /// + [HttpGet] + [Route("lite/lme_streamdata")] + 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, string href = null, bool checksearch = false) + { + await UpdateService.ConnectAsync(host); + + var init = loadKit(ModInit.StreamDataSettings); + if (!init.enable) + return Forbid(); + + var invoke = new StreamDataInvoke(init, hybridCache, OnLog, proxyManager, httpHydra); + + // checksearch — перевірка доступності + if (checksearch) + { + if (!IsCheckOnlineSearchEnabled()) + return OnError("lme_streamdata", refresh_proxy: true); + + if (id > 0) + return Content("data-json=", "text/plain; charset=utf-8"); + + return OnError("lme_streamdata", refresh_proxy: true); + } + + // play — повернути стрім для конкретного епізоду (call метод) + if (play) + { + return await HandlePlay(invoke, init, id, s, e, title, original_title, t); + } + + // Фільм + if (serial != 1) + { + return await HandleMovie(invoke, init, id, title, original_title, rjson); + } + + // Серіал + return await HandleSerial(invoke, init, id, title, original_title, s, e, t, rjson); + } + + /// + /// Обробка фільму: отримуємо всі stream_urls та показуємо їх + /// + private async Task HandleMovie(StreamDataInvoke invoke, OnlinesSettings init, long tmdbId, string title, string originalTitle, bool rjson) + { + var response = await invoke.GetMovie(tmdbId); + if (response?.data?.stream_urls == null || response.data.stream_urls.Count == 0) + return OnError("lme_streamdata", refresh_proxy: true); + + var streamUrls = response.data.stream_urls; + var subs = CollectSubtitles(response); + + var displayTitle = !string.IsNullOrEmpty(title) ? title : (!string.IsNullOrEmpty(originalTitle) ? originalTitle : response.data.title); + var tpl = new MovieTpl(displayTitle, originalTitle); + + int index = 1; + foreach (var streamUrl in streamUrls) + { + if (string.IsNullOrWhiteSpace(streamUrl)) + continue; + + string label = $"Джерело #{index}"; + string processedUrl = BuildStreamUrl(init, streamUrl); + tpl.Append(label, processedUrl, subtitles: subs); + index++; + } + + if (tpl.data == null || tpl.data.Count == 0) + return OnError("lme_streamdata", refresh_proxy: true); + + return Content( + rjson ? tpl.ToJson() : tpl.ToHtml(), + rjson ? "application/json; charset=utf-8" : "text/html; charset=utf-8" + ); + } + + /// + /// Обробка серіалу: eps → сезони → епізоди з voice-вкладками (джерела) + /// + private async Task HandleSerial(StreamDataInvoke invoke, OnlinesSettings init, long tmdbId, string title, string originalTitle, int s, int e, string t, bool rjson) + { + var response = await invoke.GetTvSeries(tmdbId); + if (response?.data?.eps == null || response.data.eps.Count == 0) + return OnError("lme_streamdata", refresh_proxy: true); + + var eps = response.data.eps; + var seasons = eps.Keys + .Select(k => int.TryParse(k, out int sn) ? sn : 0) + .Where(sn => sn > 0) + .OrderBy(sn => sn) + .ToList(); + + if (seasons.Count == 0) + return OnError("lme_streamdata", refresh_proxy: true); + + // Кількість джерел (CDN) з першого запиту + var sources = response.data?.stream_urls?.Where(u => !string.IsNullOrWhiteSpace(u)).ToList() ?? new List(); + int sourceCount = Math.Max(1, sources.Count); + + var displayTitle = !string.IsNullOrEmpty(title) ? title : (!string.IsNullOrEmpty(originalTitle) ? originalTitle : response.data.title); + + // Список сезонів + if (s <= 0) + { + var seasonTpl = new SeasonTpl(seasons.Count); + foreach (var season in seasons) + { + string seasonLink = $"{host}/lite/lme_streamdata?id={tmdbId}&serial=1&s={season}&title={HttpUtility.UrlEncode(displayTitle)}&original_title={HttpUtility.UrlEncode(originalTitle)}"; + seasonTpl.Append($"Сезон {season}", seasonLink, season.ToString()); + } + + return Content( + rjson ? seasonTpl.ToJson() : seasonTpl.ToHtml(), + rjson ? "application/json; charset=utf-8" : "text/html; charset=utf-8" + ); + } + + // Список епізодів з voice-вкладками для вибору джерела + string seasonKey = s.ToString(); + if (!eps.ContainsKey(seasonKey) || eps[seasonKey] == null || eps[seasonKey].Count == 0) + return OnError("lme_streamdata", refresh_proxy: true); + + var episodeNumbers = eps[seasonKey] + .Select(ep => int.TryParse(ep, out int en) ? en : 0) + .Where(en => en > 0) + .OrderBy(en => en) + .ToList(); + + if (episodeNumbers.Count == 0) + return OnError("lme_streamdata", refresh_proxy: true); + + // Voice-вкладки: кожне джерело як окрема "озвучка" + string selectedSource = string.IsNullOrEmpty(t) ? "1" : t; + int selectedIndex = int.TryParse(selectedSource, out int si) && si >= 1 && si <= sourceCount ? si : 1; + + var voiceTpl = new VoiceTpl(); + for (int i = 1; i <= sourceCount; i++) + { + string voiceLink = $"{host}/lite/lme_streamdata?id={tmdbId}&serial=1&s={s}&t={i}&title={HttpUtility.UrlEncode(displayTitle)}&original_title={HttpUtility.UrlEncode(originalTitle)}"; + voiceTpl.Append($"Джерело #{i}", i == selectedIndex, voiceLink); + } + + // Епізоди з посиланнями на вибране джерело + var episodeTpl = new EpisodeTpl(episodeNumbers.Count); + foreach (var epNum in episodeNumbers) + { + string episodeName = $"Епізод {epNum}"; + string callUrl = $"{host}/lite/lme_streamdata?id={tmdbId}&serial=1&s={s}&e={epNum}&play=true&t={selectedSource}&title={HttpUtility.UrlEncode(displayTitle)}&original_title={HttpUtility.UrlEncode(originalTitle)}"; + episodeTpl.Append(episodeName, displayTitle, s.ToString(), epNum.ToString("D2"), accsArgs(callUrl), "call"); + } + + episodeTpl.Append(voiceTpl); + + return Content( + rjson ? episodeTpl.ToJson() : episodeTpl.ToHtml(), + rjson ? "application/json; charset=utf-8" : "text/html; charset=utf-8" + ); + } + + /// + /// Обробка play-запиту: API з season/episode → JSON з вибраним джерелом + /// + private async Task HandlePlay(StreamDataInvoke invoke, OnlinesSettings init, long tmdbId, int season, int episode, string title, string originalTitle, string t) + { + if (tmdbId <= 0 || season <= 0 || episode <= 0) + return OnError("lme_streamdata", refresh_proxy: true); + + var response = await invoke.GetEpisode(tmdbId, season, episode); + if (response?.data?.stream_urls == null || response.data.stream_urls.Count == 0) + return OnError("lme_streamdata", refresh_proxy: true); + + var streamUrls = response.data.stream_urls.Where(u => !string.IsNullOrWhiteSpace(u)).ToList(); + if (streamUrls.Count == 0) + return OnError("lme_streamdata", refresh_proxy: true); + + // Вибираємо джерело за індексом з voice-вкладки t (1-based) + int sourceIndex = int.TryParse(t, out int si) && si >= 1 && si <= streamUrls.Count ? si - 1 : 0; + string streamUrl = BuildStreamUrl(init, streamUrls[sourceIndex]); + + var subs = CollectSubtitles(response); + string displayTitle = !string.IsNullOrEmpty(title) ? title : (!string.IsNullOrEmpty(originalTitle) ? originalTitle : response.data.title); + + return UpdateService.Validate(Content( + VideoTpl.ToJson("play", streamUrl, displayTitle, subtitles: subs), + "application/json; charset=utf-8" + )); + } + + /// + /// Зібрати субтитри з відповіді API + /// + private SubtitleTpl CollectSubtitles(StreamDataResponse response) + { + if (response?.default_subs == null || response.default_subs.Count == 0) + return null; + + var tpl = new SubtitleTpl(response.default_subs.Count); + foreach (var sub in response.default_subs) + { + if (!string.IsNullOrWhiteSpace(sub?.url) && !string.IsNullOrWhiteSpace(sub?.lang)) + { + tpl.Append(sub.lang, sub.url); + } + } + + return tpl; + } + + string BuildStreamUrl(OnlinesSettings init, string streamLink) + { + string link = StripLampacArgs(streamLink?.Trim()); + if (string.IsNullOrEmpty(link)) + return link; + + if (ApnHelper.IsEnabled(init)) + { + if (ModInit.ApnHostProvided) + 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 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/LME.StreamData/ModInit.cs b/LME.StreamData/ModInit.cs new file mode 100644 index 0000000..8d731b5 --- /dev/null +++ b/LME.StreamData/ModInit.cs @@ -0,0 +1,91 @@ +using Newtonsoft.Json.Linq; +using Shared; +using Shared.Engine; +using Shared.Models.Module; +using Shared.Models.Module.Interfaces; +using Microsoft.AspNetCore.Mvc; +using Shared.Models; +using Shared.Models.Events; +using Shared.Models.Online.Settings; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace LME.StreamData +{ + public class ModInit : IModuleLoaded + { + public static double Version => 1.5; + + public static OnlinesSettings StreamDataSettings; + public static bool ApnHostProvided; + + public static OnlinesSettings Settings + { + get => StreamDataSettings; + set => StreamDataSettings = value; + } + + /// + /// Модуль завантажено + /// + public void Loaded(InitspaceModel initspace) + { + StreamDataSettings = new OnlinesSettings("LME.StreamData", "https://streamdata.vaplayer.ru", streamproxy: false, useproxy: false) + { + displayname = "StreamData", + displayindex = 0, + proxy = new Shared.Models.Base.ProxySettings() + { + useAuth = true, + username = "", + password = "", + list = new string[] { "socks5://ip:port" } + } + }; + + var defaults = JObject.FromObject(StreamDataSettings); + defaults["enabled"] = true; + var conf = ModuleInvoke.Init("LME.StreamData", defaults); + bool hasApn = ApnHelper.TryGetInitConf(conf, out bool apnEnabled, out string apnHost); + conf.Remove("apn"); + conf.Remove("apn_host"); + StreamDataSettings = conf.ToObject(); + if (hasApn) + ApnHelper.ApplyInitConf(apnEnabled, apnHost, StreamDataSettings, useDefaultHostWhenEmpty: true); + ApnHostProvided = hasApn && apnEnabled && !string.IsNullOrWhiteSpace(apnHost); + if (hasApn && apnEnabled) + { + StreamDataSettings.streamproxy = false; + } + else if (StreamDataSettings.streamproxy) + { + StreamDataSettings.apnstream = false; + StreamDataSettings.apn = null; + } + + // Реєструємо плагін без пошуку — працюємо тільки через TMDB ID + OnlineRegistry.RegisterWithSearch("lme_streamdata"); + } + + public void Dispose() + { + } + } + + public static class UpdateService + { + private static readonly ModuleUpdateService _service = new( + () => ModInit.Settings?.plugin, + () => ModInit.Version); + + public static Task ConnectAsync(string host, CancellationToken cancellationToken = default) + => _service.ConnectAsync(host, cancellationToken); + + public static bool IsDisconnected() + => _service.IsDisconnected(); + + public static ActionResult Validate(ActionResult result) + => _service.Validate(result); + } +} diff --git a/LME.StreamData/Models/StreamDataModels.cs b/LME.StreamData/Models/StreamDataModels.cs new file mode 100644 index 0000000..8dcbd05 --- /dev/null +++ b/LME.StreamData/Models/StreamDataModels.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace LME.StreamData.Models +{ + public class SubtitleItem + { + public string lang { get; set; } + public string code { get; set; } + public string url { get; set; } + } + + public class StreamDataResponse + { + public string status_code { get; set; } + public StreamDataInfo data { get; set; } + public List default_subs { get; set; } + } + + public class StreamDataInfo + { + public string title { get; set; } + public string imdb_id { get; set; } + public int season { get; set; } + public string episode { get; set; } + public Dictionary> eps { get; set; } + public string file_name { get; set; } + public string backdrop { get; set; } + public List stream_urls { get; set; } + public string thumbnails_url { get; set; } + } +} diff --git a/LME.StreamData/OnlineApi.cs b/LME.StreamData/OnlineApi.cs new file mode 100644 index 0000000..eb4c2d8 --- /dev/null +++ b/LME.StreamData/OnlineApi.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Http; +using Shared.Models; +using Shared.Models.Module; +using Shared.Models.Module.Interfaces; +using System.Collections.Generic; + +namespace LME.StreamData +{ + public class OnlineApi : IModuleOnline + { + public List Invoke(HttpContext httpContext, RequestModel requestInfo, string host, OnlineEventsModel args) + { + var online = new List(); + + var init = ModInit.StreamDataSettings; + if (init.enable && !init.rip) + { + if (UpdateService.IsDisconnected()) + init.overridehost = null; + + // StreamData працює з TMDB ID — показуємо для всього контенту + online.Add(new ModuleOnlineItem(init, "lme_streamdata")); + } + + return online; + } + } +} diff --git a/LME.StreamData/StreamData.csproj b/LME.StreamData/StreamData.csproj new file mode 100644 index 0000000..c280999 --- /dev/null +++ b/LME.StreamData/StreamData.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + library + true + + + + + ..\..\Shared.dll + + + + diff --git a/LME.StreamData/StreamDataInvoke.cs b/LME.StreamData/StreamDataInvoke.cs new file mode 100644 index 0000000..432b96e --- /dev/null +++ b/LME.StreamData/StreamDataInvoke.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using LME.StreamData.Models; +using Newtonsoft.Json; +using Shared; +using Shared.Engine; +using Shared.Models; +using Shared.Models.Online.Settings; + +namespace LME.StreamData +{ + public class StreamDataInvoke + { + private readonly OnlinesSettings _init; + private readonly IHybridCache _hybridCache; + private readonly Action _onLog; + private readonly ProxyManager _proxyManager; + private readonly HttpHydra _httpHydra; + + private const string API_BASE = "https://streamdata.vaplayer.ru/api.php"; + + public StreamDataInvoke(OnlinesSettings init, IHybridCache hybridCache, Action onLog, ProxyManager proxyManager, HttpHydra httpHydra = null) + { + _init = init; + _hybridCache = hybridCache; + _onLog = onLog; + _proxyManager = proxyManager; + _httpHydra = httpHydra; + } + + /// + /// Отримати дані для фільму за TMDB ID + /// + public async Task GetMovie(long tmdbId) + { + string memKey = $"StreamData:movie:{tmdbId}"; + if (_hybridCache.TryGetValue(memKey, out StreamDataResponse cached)) + return cached; + + try + { + string url = $"{API_BASE}?tmdb={tmdbId}&type=movie"; + _onLog?.Invoke($"StreamData movie: {url}"); + + string json = await ApiGet(url); + if (string.IsNullOrEmpty(json)) + return null; + + var response = JsonConvert.DeserializeObject(json); + if (response?.status_code != "200" || response?.data?.stream_urls == null || response.data.stream_urls.Count == 0) + return null; + + _hybridCache.Set(memKey, response, cacheTime(30, init: _init)); + return response; + } + catch (Exception ex) + { + _onLog?.Invoke($"StreamData movie error: {ex.Message}"); + return null; + } + } + + /// + /// Отримати дані для серіалу (без season/episode - отримуємо eps структуру + S01E01) + /// + public async Task GetTvSeries(long tmdbId) + { + string memKey = $"StreamData:tv:{tmdbId}"; + if (_hybridCache.TryGetValue(memKey, out StreamDataResponse cached)) + return cached; + + try + { + string url = $"{API_BASE}?tmdb={tmdbId}&type=tv"; + _onLog?.Invoke($"StreamData tv: {url}"); + + string json = await ApiGet(url); + if (string.IsNullOrEmpty(json)) + return null; + + var response = JsonConvert.DeserializeObject(json); + if (response?.status_code != "200" || response?.data?.eps == null || response.data.eps.Count == 0) + return null; + + _hybridCache.Set(memKey, response, cacheTime(30, init: _init)); + return response; + } + catch (Exception ex) + { + _onLog?.Invoke($"StreamData tv error: {ex.Message}"); + return null; + } + } + + /// + /// Отримати стріми для конкретного епізоду + /// + public async Task GetEpisode(long tmdbId, int season, int episode) + { + string memKey = $"StreamData:ep:{tmdbId}:s{season}e{episode}"; + if (_hybridCache.TryGetValue(memKey, out StreamDataResponse cached)) + return cached; + + try + { + string url = $"{API_BASE}?tmdb={tmdbId}&type=tv&season={season}&episode={episode}"; + _onLog?.Invoke($"StreamData episode: {url}"); + + string json = await ApiGet(url); + if (string.IsNullOrEmpty(json)) + return null; + + var response = JsonConvert.DeserializeObject(json); + if (response?.status_code != "200" || response?.data?.stream_urls == null || response.data.stream_urls.Count == 0) + return null; + + _hybridCache.Set(memKey, response, cacheTime(30, init: _init)); + return response; + } + catch (Exception ex) + { + _onLog?.Invoke($"StreamData episode error: {ex.Message}"); + return null; + } + } + + private Task ApiGet(string url) + { + var headers = new List() + { + new HeadersModel("User-Agent", "Mozilla/5.0"), + new HeadersModel("Referer", "https://brightpathsignals.com/"), + new HeadersModel("X-Requested-With", "XMLHttpRequest") + }; + + if (_httpHydra != null) + return _httpHydra.Get(url, newheaders: headers); + + return Http.Get(_init.cors(url), headers: headers, proxy: _proxyManager.Get()); + } + + 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); + } + } +} diff --git a/LME.StreamData/manifest.json b/LME.StreamData/manifest.json new file mode 100644 index 0000000..7b2fd84 --- /dev/null +++ b/LME.StreamData/manifest.json @@ -0,0 +1,12 @@ +{ + "enable": true, + "version": 3, + "initspace": "LME.StreamData.ModInit", + "online": "LME.StreamData.OnlineApi", + "syntaxPaths": [ + "../LME.Shared/GlobalUsings.cs", + "../LME.Shared/Online/OnlineRegistry.cs", + "../LME.Shared/Update/ModuleUpdateService.cs", + "../LME.Shared/Apn/ApnHelper.cs" + ] +}