diff --git a/Bamboo/Bamboo.csproj b/Bamboo/Bamboo.csproj
new file mode 100644
index 0000000..1fbe365
--- /dev/null
+++ b/Bamboo/Bamboo.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net9.0
+ library
+ true
+
+
+
+
+ ..\..\Shared.dll
+
+
+
+
diff --git a/Bamboo/BambooInvoke.cs b/Bamboo/BambooInvoke.cs
new file mode 100644
index 0000000..b8ada27
--- /dev/null
+++ b/Bamboo/BambooInvoke.cs
@@ -0,0 +1,324 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using System.Web;
+using Bamboo.Models;
+using HtmlAgilityPack;
+using Shared;
+using Shared.Engine;
+using Shared.Models;
+using Shared.Models.Online.Settings;
+
+namespace Bamboo
+{
+ public class BambooInvoke
+ {
+ private readonly OnlinesSettings _init;
+ private readonly HybridCache _hybridCache;
+ private readonly Action _onLog;
+ private readonly ProxyManager _proxyManager;
+
+ public BambooInvoke(OnlinesSettings init, HybridCache hybridCache, Action onLog, ProxyManager proxyManager)
+ {
+ _init = init;
+ _hybridCache = hybridCache;
+ _onLog = onLog;
+ _proxyManager = proxyManager;
+ }
+
+ public async Task> Search(string title, string original_title)
+ {
+ string query = !string.IsNullOrEmpty(title) ? title : original_title;
+ if (string.IsNullOrEmpty(query))
+ return null;
+
+ string memKey = $"Bamboo:search:{query}";
+ if (_hybridCache.TryGetValue(memKey, out List cached))
+ return cached;
+
+ try
+ {
+ string searchUrl = $"{_init.host}/index.php?do=search&subaction=search&story={HttpUtility.UrlEncode(query)}";
+ var headers = new List()
+ {
+ new HeadersModel("User-Agent", "Mozilla/5.0"),
+ new HeadersModel("Referer", _init.host)
+ };
+
+ _onLog?.Invoke($"Bamboo search: {searchUrl}");
+ string html = await Http.Get(searchUrl, headers: headers, proxy: _proxyManager.Get());
+ if (string.IsNullOrEmpty(html))
+ return null;
+
+ var doc = new HtmlDocument();
+ doc.LoadHtml(html);
+
+ var results = new List();
+ var nodes = doc.DocumentNode.SelectNodes("//li[contains(@class,'slide-item')]");
+ if (nodes != null)
+ {
+ foreach (var node in nodes)
+ {
+ string itemTitle = CleanText(node.SelectSingleNode(".//h6")?.InnerText);
+ string href = ExtractHref(node);
+ string poster = ExtractPoster(node);
+
+ if (string.IsNullOrEmpty(itemTitle) || string.IsNullOrEmpty(href))
+ continue;
+
+ results.Add(new SearchResult
+ {
+ Title = itemTitle,
+ Url = href,
+ Poster = poster
+ });
+ }
+ }
+
+ if (results.Count > 0)
+ _hybridCache.Set(memKey, results, cacheTime(20, init: _init));
+
+ return results;
+ }
+ catch (Exception ex)
+ {
+ _onLog?.Invoke($"Bamboo search error: {ex.Message}");
+ return null;
+ }
+ }
+
+ public async Task GetSeriesEpisodes(string href)
+ {
+ if (string.IsNullOrEmpty(href))
+ return null;
+
+ string memKey = $"Bamboo:series:{href}";
+ if (_hybridCache.TryGetValue(memKey, out SeriesEpisodes cached))
+ return cached;
+
+ try
+ {
+ var headers = new List()
+ {
+ new HeadersModel("User-Agent", "Mozilla/5.0"),
+ new HeadersModel("Referer", _init.host)
+ };
+
+ _onLog?.Invoke($"Bamboo series page: {href}");
+ string html = await Http.Get(href, headers: headers, proxy: _proxyManager.Get());
+ if (string.IsNullOrEmpty(html))
+ return null;
+
+ var doc = new HtmlDocument();
+ doc.LoadHtml(html);
+
+ var result = new SeriesEpisodes();
+ bool foundBlocks = false;
+
+ var blocks = doc.DocumentNode.SelectNodes("//div[contains(@class,'mt-4')]");
+ if (blocks != null)
+ {
+ foreach (var block in blocks)
+ {
+ string header = CleanText(block.SelectSingleNode(".//h3[contains(@class,'my-4')]")?.InnerText);
+ var episodes = ParseEpisodeSpans(block);
+ if (episodes.Count == 0)
+ continue;
+
+ foundBlocks = true;
+ if (!string.IsNullOrEmpty(header) && header.Contains("Субтитри", StringComparison.OrdinalIgnoreCase))
+ {
+ result.Sub.AddRange(episodes);
+ }
+ else if (!string.IsNullOrEmpty(header) && header.Contains("Озвучення", StringComparison.OrdinalIgnoreCase))
+ {
+ result.Dub.AddRange(episodes);
+ }
+ }
+ }
+
+ if (!foundBlocks || (result.Sub.Count == 0 && result.Dub.Count == 0))
+ {
+ var fallback = ParseEpisodeSpans(doc.DocumentNode);
+ if (fallback.Count > 0)
+ result.Dub.AddRange(fallback);
+ }
+
+ if (result.Sub.Count == 0)
+ {
+ var fallback = ParseEpisodeSpans(doc.DocumentNode);
+ if (fallback.Count > 0)
+ result.Sub.AddRange(fallback);
+ }
+
+ _hybridCache.Set(memKey, result, cacheTime(30, init: _init));
+ return result;
+ }
+ catch (Exception ex)
+ {
+ _onLog?.Invoke($"Bamboo series error: {ex.Message}");
+ return null;
+ }
+ }
+
+ public async Task> GetMovieStreams(string href)
+ {
+ if (string.IsNullOrEmpty(href))
+ return null;
+
+ string memKey = $"Bamboo:movie:{href}";
+ if (_hybridCache.TryGetValue(memKey, out List cached))
+ return cached;
+
+ try
+ {
+ var headers = new List()
+ {
+ new HeadersModel("User-Agent", "Mozilla/5.0"),
+ new HeadersModel("Referer", _init.host)
+ };
+
+ _onLog?.Invoke($"Bamboo movie page: {href}");
+ string html = await Http.Get(href, headers: headers, proxy: _proxyManager.Get());
+ if (string.IsNullOrEmpty(html))
+ return null;
+
+ var doc = new HtmlDocument();
+ doc.LoadHtml(html);
+
+ var streams = new List();
+ var nodes = doc.DocumentNode.SelectNodes("//span[contains(@class,'mr-3') and @data-file]");
+ if (nodes != null)
+ {
+ foreach (var node in nodes)
+ {
+ string dataFile = node.GetAttributeValue("data-file", "");
+ if (string.IsNullOrEmpty(dataFile))
+ continue;
+
+ string title = node.GetAttributeValue("data-title", "");
+ title = string.IsNullOrEmpty(title) ? CleanText(node.InnerText) : title;
+
+ streams.Add(new StreamInfo
+ {
+ Title = title,
+ Url = NormalizeUrl(dataFile)
+ });
+ }
+ }
+
+ if (streams.Count > 0)
+ _hybridCache.Set(memKey, streams, cacheTime(30, init: _init));
+
+ return streams;
+ }
+ catch (Exception ex)
+ {
+ _onLog?.Invoke($"Bamboo movie error: {ex.Message}");
+ return null;
+ }
+ }
+
+ private List ParseEpisodeSpans(HtmlNode scope)
+ {
+ var episodes = new List();
+ var nodes = scope.SelectNodes(".//span[@data-file]");
+ if (nodes == null)
+ return episodes;
+
+ foreach (var node in nodes)
+ {
+ string dataFile = node.GetAttributeValue("data-file", "");
+ if (string.IsNullOrEmpty(dataFile))
+ continue;
+
+ string title = node.GetAttributeValue("data-title", "");
+ if (string.IsNullOrEmpty(title))
+ title = CleanText(node.InnerText);
+
+ int? episodeNum = ExtractEpisodeNumber(title);
+
+ episodes.Add(new EpisodeInfo
+ {
+ Title = string.IsNullOrEmpty(title) ? "Episode" : title,
+ Url = NormalizeUrl(dataFile),
+ Episode = episodeNum
+ });
+ }
+
+ return episodes;
+ }
+
+ private string ExtractHref(HtmlNode node)
+ {
+ var link = node.SelectSingleNode(".//a[contains(@class,'hover-buttons')]")
+ ?? node.SelectSingleNode(".//a[@href]");
+ if (link == null)
+ return string.Empty;
+
+ string href = link.GetAttributeValue("href", "");
+ return NormalizeUrl(href);
+ }
+
+ private string ExtractPoster(HtmlNode node)
+ {
+ var img = node.SelectSingleNode(".//img");
+ if (img == null)
+ return string.Empty;
+
+ string src = img.GetAttributeValue("src", "");
+ if (string.IsNullOrEmpty(src))
+ src = img.GetAttributeValue("data-src", "");
+
+ return NormalizeUrl(src);
+ }
+
+ private string NormalizeUrl(string url)
+ {
+ if (string.IsNullOrEmpty(url))
+ return string.Empty;
+
+ if (url.StartsWith("//"))
+ return $"https:{url}";
+
+ if (url.StartsWith("/"))
+ return $"{_init.host}{url}";
+
+ return url;
+ }
+
+ private static int? ExtractEpisodeNumber(string title)
+ {
+ if (string.IsNullOrEmpty(title))
+ return null;
+
+ var match = Regex.Match(title, @"(\d+)");
+ if (match.Success && int.TryParse(match.Groups[1].Value, out int value))
+ return value;
+
+ return null;
+ }
+
+ private static string CleanText(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ return string.Empty;
+
+ return HtmlEntity.DeEntitize(value).Trim();
+ }
+
+ 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 = AppInit.conf.mikrotik ? mikrotik : AppInit.conf.multiaccess ? init != null && init.cache_time > 0 ? init.cache_time : multiaccess : home;
+ if (ctime > multiaccess)
+ ctime = multiaccess;
+
+ return TimeSpan.FromMinutes(ctime);
+ }
+ }
+}
diff --git a/Bamboo/Controller.cs b/Bamboo/Controller.cs
new file mode 100644
index 0000000..34365af
--- /dev/null
+++ b/Bamboo/Controller.cs
@@ -0,0 +1,119 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using System.Web;
+using Bamboo.Models;
+using Microsoft.AspNetCore.Mvc;
+using Shared;
+using Shared.Engine;
+using Shared.Models;
+using Shared.Models.Online.Settings;
+using Shared.Models.Templates;
+
+namespace Bamboo.Controllers
+{
+ public class Controller : BaseOnlineController
+ {
+ ProxyManager proxyManager;
+
+ public Controller()
+ {
+ proxyManager = new ProxyManager(ModInit.Bamboo);
+ }
+
+ [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)
+ {
+ var init = await loadKit(ModInit.Bamboo);
+ if (!init.enable)
+ return Forbid();
+
+ var invoke = new BambooInvoke(init, hybridCache, OnLog, proxyManager);
+
+ string itemUrl = href;
+ if (string.IsNullOrEmpty(itemUrl))
+ {
+ var searchResults = await invoke.Search(title, original_title);
+ if (searchResults == null || searchResults.Count == 0)
+ return OnError("bamboo", proxyManager);
+
+ if (searchResults.Count > 1)
+ {
+ var similar_tpl = new SimilarTpl(searchResults.Count);
+ foreach (var res in searchResults)
+ {
+ string link = $"{host}/bamboo?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial={serial}&href={HttpUtility.UrlEncode(res.Url)}";
+ similar_tpl.Append(res.Title, string.Empty, string.Empty, link, res.Poster);
+ }
+
+ return rjson ? Content(similar_tpl.ToJson(), "application/json; charset=utf-8") : Content(similar_tpl.ToHtml(), "text/html; charset=utf-8");
+ }
+
+ itemUrl = searchResults[0].Url;
+ }
+
+ if (serial == 1)
+ {
+ var series = await invoke.GetSeriesEpisodes(itemUrl);
+ if (series == null || (series.Sub.Count == 0 && series.Dub.Count == 0))
+ return OnError("bamboo", proxyManager);
+
+ var voice_tpl = new VoiceTpl();
+ var episode_tpl = new EpisodeTpl();
+
+ var availableVoices = new List<(string key, string name, List episodes)>();
+ if (series.Sub.Count > 0)
+ availableVoices.Add(("sub", "Субтитри", series.Sub));
+ if (series.Dub.Count > 0)
+ availableVoices.Add(("dub", "Озвучення", series.Dub));
+
+ if (string.IsNullOrEmpty(t))
+ t = availableVoices.First().key;
+
+ foreach (var voice in availableVoices)
+ {
+ string voiceLink = $"{host}/bamboo?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&t={voice.key}&href={HttpUtility.UrlEncode(itemUrl)}";
+ voice_tpl.Append(voice.name, voice.key == t, voiceLink);
+ }
+
+ var selected = availableVoices.FirstOrDefault(v => v.key == t);
+ if (selected.episodes == null || selected.episodes.Count == 0)
+ return OnError("bamboo", proxyManager);
+
+ int index = 1;
+ foreach (var ep in selected.episodes.OrderBy(e => e.Episode ?? int.MaxValue))
+ {
+ int episodeNumber = ep.Episode ?? index;
+ string episodeName = string.IsNullOrEmpty(ep.Title) ? $"Епізод {episodeNumber}" : ep.Title;
+ string streamUrl = HostStreamProxy(init, accsArgs(ep.Url));
+ episode_tpl.Append(episodeName, title ?? original_title, "1", episodeNumber.ToString("D2"), streamUrl);
+ index++;
+ }
+
+ if (rjson)
+ return Content(episode_tpl.ToJson(voice_tpl), "application/json; charset=utf-8");
+
+ return Content(voice_tpl.ToHtml() + episode_tpl.ToHtml(), "text/html; charset=utf-8");
+ }
+ else
+ {
+ var streams = await invoke.GetMovieStreams(itemUrl);
+ if (streams == null || streams.Count == 0)
+ return OnError("bamboo", proxyManager);
+
+ var movie_tpl = new MovieTpl(title, original_title);
+ for (int i = 0; i < streams.Count; i++)
+ {
+ var stream = streams[i];
+ string label = !string.IsNullOrEmpty(stream.Title) ? stream.Title : $"Варіант {i + 1}";
+ string streamUrl = HostStreamProxy(init, accsArgs(stream.Url));
+ movie_tpl.Append(label, streamUrl);
+ }
+
+ return rjson ? Content(movie_tpl.ToJson(), "application/json; charset=utf-8") : Content(movie_tpl.ToHtml(), "text/html; charset=utf-8");
+ }
+ }
+ }
+}
diff --git a/Bamboo/ModInit.cs b/Bamboo/ModInit.cs
new file mode 100644
index 0000000..d7994d5
--- /dev/null
+++ b/Bamboo/ModInit.cs
@@ -0,0 +1,35 @@
+using Shared;
+using Shared.Engine;
+using Shared.Models.Online.Settings;
+using Shared.Models.Module;
+
+namespace Bamboo
+{
+ public class ModInit
+ {
+ public static OnlinesSettings Bamboo;
+
+ ///
+ /// модуль загружен
+ ///
+ public static void loaded(InitspaceModel initspace)
+ {
+ Bamboo = new OnlinesSettings("Bamboo", "https://bambooua.com", streamproxy: false, useproxy: false)
+ {
+ displayname = "BambooUA",
+ displayindex = 0,
+ proxy = new Shared.Models.Base.ProxySettings()
+ {
+ useAuth = true,
+ username = "",
+ password = "",
+ list = new string[] { "socks5://ip:port" }
+ }
+ };
+ Bamboo = ModuleInvoke.Conf("Bamboo", Bamboo).ToObject();
+
+ // Виводити "уточнити пошук"
+ AppInit.conf.online.with_search.Add("bamboo");
+ }
+ }
+}
diff --git a/Bamboo/Models/BambooModels.cs b/Bamboo/Models/BambooModels.cs
new file mode 100644
index 0000000..af99cb7
--- /dev/null
+++ b/Bamboo/Models/BambooModels.cs
@@ -0,0 +1,30 @@
+using System.Collections.Generic;
+
+namespace Bamboo.Models
+{
+ public class SearchResult
+ {
+ public string Title { get; set; }
+ public string Url { get; set; }
+ public string Poster { get; set; }
+ }
+
+ public class EpisodeInfo
+ {
+ public string Title { get; set; }
+ public string Url { get; set; }
+ public int? Episode { get; set; }
+ }
+
+ public class StreamInfo
+ {
+ public string Title { get; set; }
+ public string Url { get; set; }
+ }
+
+ public class SeriesEpisodes
+ {
+ public List Sub { get; set; } = new();
+ public List Dub { get; set; } = new();
+ }
+}
diff --git a/Bamboo/OnlineApi.cs b/Bamboo/OnlineApi.cs
new file mode 100644
index 0000000..5becaac
--- /dev/null
+++ b/Bamboo/OnlineApi.cs
@@ -0,0 +1,25 @@
+using Shared.Models.Base;
+using System.Collections.Generic;
+
+namespace Bamboo
+{
+ public class OnlineApi
+ {
+ 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.Bamboo;
+ if (init.enable && !init.rip)
+ {
+ string url = init.overridehost;
+ if (string.IsNullOrEmpty(url))
+ url = $"{host}/bamboo";
+
+ online.Add((init.displayname, url, "bamboo", init.displayindex));
+ }
+
+ return online;
+ }
+ }
+}
diff --git a/Bamboo/manifest.json b/Bamboo/manifest.json
new file mode 100644
index 0000000..933863e
--- /dev/null
+++ b/Bamboo/manifest.json
@@ -0,0 +1,6 @@
+{
+ "enable": true,
+ "version": 2,
+ "initspace": "Bamboo.ModInit",
+ "online": "Bamboo.OnlineApi"
+}