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"
+ ]
+}