mirror of
https://github.com/lampame/lampac-ukraine.git
synced 2026-04-17 01:42:19 +00:00
Compare commits
3 Commits
31549455ee
...
ff90f149f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff90f149f0 | ||
|
|
b1a7ce510d | ||
|
|
0aed459fab |
3
.gitignore
vendored
3
.gitignore
vendored
@ -13,4 +13,5 @@ AGENTS.md
|
|||||||
/planing/
|
/planing/
|
||||||
.vs
|
.vs
|
||||||
bin
|
bin
|
||||||
obj
|
obj
|
||||||
|
.vscode/settings.json
|
||||||
|
|||||||
99
UafilmME/ApnHelper.cs
Normal file
99
UafilmME/ApnHelper.cs
Normal file
@ -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<bool>();
|
||||||
|
host = conf.Value<string>("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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
356
UafilmME/Controller.cs
Normal file
356
UafilmME/Controller.cs
Normal file
@ -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<ActionResult> 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<ActionResult> 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<HeadersModel>()
|
||||||
|
{
|
||||||
|
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<HeadersModel> 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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
UafilmME/GlobalUsings.cs
Normal file
4
UafilmME/GlobalUsings.cs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
global using Shared.Services;
|
||||||
|
global using Shared.Services.Hybrid;
|
||||||
|
global using Shared.Models.Base;
|
||||||
|
global using AppInit = Shared.CoreInit;
|
||||||
228
UafilmME/ModInit.cs
Normal file
228
UafilmME/ModInit.cs
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Модуль завантажено.
|
||||||
|
/// </summary>
|
||||||
|
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<OnlinesSettings>();
|
||||||
|
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<ConnectResponse>(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);
|
||||||
|
}
|
||||||
67
UafilmME/Models/UafilmModels.cs
Normal file
67
UafilmME/Models/UafilmModels.cs
Normal file
@ -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<UafilmVideoItem> AlternativeVideos { get; set; } = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
33
UafilmME/OnlineApi.cs
Normal file
33
UafilmME/OnlineApi.cs
Normal file
@ -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<ModuleOnlineItem> 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<ModuleOnlineItem> 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<ModuleOnlineItem>();
|
||||||
|
|
||||||
|
var init = ModInit.UafilmME;
|
||||||
|
if (init.enable && !init.rip)
|
||||||
|
{
|
||||||
|
if (UpdateService.IsDisconnected())
|
||||||
|
init.overridehost = null;
|
||||||
|
|
||||||
|
online.Add(new ModuleOnlineItem(init, "uafilmme"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return online;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
UafilmME/UafilmME.csproj
Normal file
15
UafilmME/UafilmME.csproj
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<OutputType>library</OutputType>
|
||||||
|
<IsPackable>true</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="Shared">
|
||||||
|
<HintPath>..\..\Shared.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
751
UafilmME/UafilmMEInvoke.cs
Normal file
751
UafilmME/UafilmMEInvoke.cs
Normal file
@ -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<string> _onLog;
|
||||||
|
private readonly ProxyManager _proxyManager;
|
||||||
|
private readonly HttpHydra _httpHydra;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public UafilmMEInvoke(OnlinesSettings init, IHybridCache hybridCache, Action<string> onLog, ProxyManager proxyManager, HttpHydra httpHydra = null)
|
||||||
|
{
|
||||||
|
_init = init;
|
||||||
|
_hybridCache = hybridCache;
|
||||||
|
_onLog = onLog;
|
||||||
|
_proxyManager = proxyManager;
|
||||||
|
_httpHydra = httpHydra;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<UafilmSearchItem>> Search(string title, string originalTitle, int year)
|
||||||
|
{
|
||||||
|
var queries = BuildSearchQueries(title, originalTitle, year).ToList();
|
||||||
|
if (queries.Count == 0)
|
||||||
|
return new List<UafilmSearchItem>();
|
||||||
|
|
||||||
|
var all = new Dictionary<long, UafilmSearchItem>();
|
||||||
|
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<UafilmSearchItem> 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<UafilmTitleDetails> 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<List<UafilmSeasonItem>> GetAllSeasons(long titleId)
|
||||||
|
{
|
||||||
|
string memKey = $"UafilmME:seasons:{titleId}";
|
||||||
|
if (_hybridCache.TryGetValue(memKey, out List<UafilmSeasonItem> cached))
|
||||||
|
return cached;
|
||||||
|
|
||||||
|
var all = new List<UafilmSeasonItem>();
|
||||||
|
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<List<UafilmEpisodeItem>> GetSeasonEpisodes(long titleId, int season)
|
||||||
|
{
|
||||||
|
string memKey = $"UafilmME:episodes:{titleId}:{season}";
|
||||||
|
if (_hybridCache.TryGetValue(memKey, out List<UafilmEpisodeItem> cached))
|
||||||
|
return cached;
|
||||||
|
|
||||||
|
var all = new List<UafilmEpisodeItem>();
|
||||||
|
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<List<UafilmVideoItem>> GetMovieVideos(long titleId)
|
||||||
|
{
|
||||||
|
var title = await GetTitleDetails(titleId);
|
||||||
|
if (title == null || title.PrimaryVideoId <= 0)
|
||||||
|
return new List<UafilmVideoItem>();
|
||||||
|
|
||||||
|
var watch = await GetWatch(title.PrimaryVideoId);
|
||||||
|
return CollectPlayableVideos(watch);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UafilmWatchInfo> 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<UafilmVideoItem> CollectPlayableVideos(UafilmWatchInfo watch)
|
||||||
|
{
|
||||||
|
var list = new List<UafilmVideoItem>();
|
||||||
|
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<List<UafilmSearchItem>> SearchByQuery(string query)
|
||||||
|
{
|
||||||
|
string memKey = $"UafilmME:search:{query}";
|
||||||
|
if (_hybridCache.TryGetValue(memKey, out List<UafilmSearchItem> 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<UafilmSeasonItem> Items, int? NextPage)> GetSeasonsPage(long titleId, int page)
|
||||||
|
{
|
||||||
|
string memKey = $"UafilmME:seasons-page:{titleId}:{page}";
|
||||||
|
if (_hybridCache.TryGetValue(memKey, out List<UafilmSeasonItem> 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<UafilmEpisodeItem> Items, int? NextPage)> GetEpisodesPage(long titleId, int season, int page)
|
||||||
|
{
|
||||||
|
string memKey = $"UafilmME:episodes-page:{titleId}:{season}:{page}";
|
||||||
|
if (_hybridCache.TryGetValue(memKey, out List<UafilmEpisodeItem> 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<string> 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<HeadersModel>()
|
||||||
|
{
|
||||||
|
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<string> BuildSearchQueries(string title, string originalTitle, int year)
|
||||||
|
{
|
||||||
|
var queries = new List<string>();
|
||||||
|
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<UafilmSearchItem> ParseSearchResults(string json)
|
||||||
|
{
|
||||||
|
var list = new List<UafilmSearchItem>();
|
||||||
|
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<UafilmSeasonItem> Items, int? NextPage) ParseSeasonsPage(string json)
|
||||||
|
{
|
||||||
|
var items = new List<UafilmSeasonItem>();
|
||||||
|
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<UafilmEpisodeItem> Items, int? NextPage) ParseEpisodesPage(string json)
|
||||||
|
{
|
||||||
|
var items = new List<UafilmEpisodeItem>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
UafilmME/manifest.json
Normal file
6
UafilmME/manifest.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"enable": true,
|
||||||
|
"version": 3,
|
||||||
|
"initspace": "UafilmME.ModInit",
|
||||||
|
"online": "UafilmME.OnlineApi"
|
||||||
|
}
|
||||||
@ -163,97 +163,50 @@ namespace Uaflix.Controllers
|
|||||||
|
|
||||||
if (serial == 1)
|
if (serial == 1)
|
||||||
{
|
{
|
||||||
// Агрегуємо всі озвучки з усіх плеєрів
|
// s == -1: швидкий вибір сезону без повної агрегації серіалу
|
||||||
var structure = await invoke.AggregateSerialStructure(filmUrl);
|
|
||||||
if (structure == null || !structure.Voices.Any())
|
|
||||||
{
|
|
||||||
OnLog("No voices found in aggregated structure");
|
|
||||||
OnLog("=== RETURN: no voices OnError ===");
|
|
||||||
return OnError("uaflix", refresh_proxy: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
OnLog($"Structure aggregated successfully: {structure.Voices.Count} voices, URL: {filmUrl}");
|
|
||||||
foreach (var voice in structure.Voices)
|
|
||||||
{
|
|
||||||
OnLog($"Voice: {voice.Key}, Type: {voice.Value.PlayerType}, Seasons: {voice.Value.Seasons.Count}");
|
|
||||||
foreach (var season in voice.Value.Seasons)
|
|
||||||
{
|
|
||||||
OnLog($" Season {season.Key}: {season.Value.Count} episodes");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// s == -1: Вибір сезону
|
|
||||||
if (s == -1)
|
if (s == -1)
|
||||||
{
|
{
|
||||||
List<int> allSeasons;
|
var seasonIndex = await invoke.GetSeasonIndex(filmUrl);
|
||||||
VoiceInfo tVoice = null;
|
var seasons = seasonIndex?.Seasons?.Keys
|
||||||
bool restrictByVoice = !string.IsNullOrEmpty(t) && structure.Voices.TryGetValue(t, out tVoice) && IsAshdiVoice(tVoice);
|
.Distinct()
|
||||||
if (restrictByVoice)
|
.OrderBy(sn => sn)
|
||||||
{
|
.ToList();
|
||||||
allSeasons = GetSeasonSet(tVoice).OrderBy(sn => sn).ToList();
|
|
||||||
OnLog($"Ashdi voice selected (t='{t}'), seasons count={allSeasons.Count}");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
allSeasons = structure.Voices
|
|
||||||
.SelectMany(v => GetSeasonSet(v.Value))
|
|
||||||
.Distinct()
|
|
||||||
.OrderBy(sn => sn)
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
OnLog($"Found {allSeasons.Count} seasons in structure: {string.Join(", ", allSeasons)}");
|
if (seasons == null || seasons.Count == 0)
|
||||||
|
|
||||||
// Перевіряємо чи сезони містять валідні епізоди з файлами
|
|
||||||
var seasonsWithValidEpisodes = allSeasons.Where(season =>
|
|
||||||
structure.Voices.Values.Any(v =>
|
|
||||||
v.Seasons.ContainsKey(season) &&
|
|
||||||
v.Seasons[season].Any(ep => !string.IsNullOrEmpty(ep.File))
|
|
||||||
)
|
|
||||||
).ToList();
|
|
||||||
|
|
||||||
OnLog($"Seasons with valid episodes: {seasonsWithValidEpisodes.Count}");
|
|
||||||
foreach (var season in allSeasons)
|
|
||||||
{
|
{
|
||||||
var episodesInSeason = structure.Voices.Values
|
OnLog("No seasons found in season index");
|
||||||
.Where(v => v.Seasons.ContainsKey(season))
|
OnLog("=== RETURN: no seasons OnError ===");
|
||||||
.SelectMany(v => v.Seasons[season])
|
|
||||||
.Where(ep => !string.IsNullOrEmpty(ep.File))
|
|
||||||
.ToList();
|
|
||||||
OnLog($"Season {season}: {episodesInSeason.Count} valid episodes");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!seasonsWithValidEpisodes.Any())
|
|
||||||
{
|
|
||||||
OnLog("No seasons with valid episodes found in structure");
|
|
||||||
OnLog("=== RETURN: no valid seasons OnError ===");
|
|
||||||
return OnError("uaflix", refresh_proxy: true);
|
return OnError("uaflix", refresh_proxy: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
var season_tpl = new SeasonTpl(seasonsWithValidEpisodes.Count);
|
var season_tpl = new SeasonTpl(seasons.Count);
|
||||||
foreach (var season in seasonsWithValidEpisodes)
|
foreach (int season in seasons)
|
||||||
{
|
{
|
||||||
string link = $"{host}/lite/uaflix?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={season}&href={HttpUtility.UrlEncode(filmUrl)}";
|
string link = $"{host}/lite/uaflix?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s={season}&href={HttpUtility.UrlEncode(filmUrl)}";
|
||||||
if (restrictByVoice)
|
if (!string.IsNullOrWhiteSpace(t))
|
||||||
link += $"&t={HttpUtility.UrlEncode(t)}";
|
link += $"&t={HttpUtility.UrlEncode(t)}";
|
||||||
|
|
||||||
season_tpl.Append($"{season}", link, season.ToString());
|
season_tpl.Append($"{season}", link, season.ToString());
|
||||||
OnLog($"Added season {season} to template");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
OnLog($"Returning season template with {seasonsWithValidEpisodes.Count} seasons");
|
OnLog($"=== RETURN: season template ({seasons.Count} seasons) ===");
|
||||||
|
return Content(
|
||||||
var htmlContent = rjson ? season_tpl.ToJson() : season_tpl.ToHtml();
|
rjson ? season_tpl.ToJson() : season_tpl.ToHtml(),
|
||||||
OnLog($"Season template response length: {htmlContent.Length}");
|
rjson ? "application/json; charset=utf-8" : "text/html; charset=utf-8"
|
||||||
OnLog($"Season template HTML (first 300): {htmlContent.Substring(0, Math.Min(300, htmlContent.Length))}");
|
);
|
||||||
OnLog($"=== RETURN: season template ({seasonsWithValidEpisodes.Count} seasons) ===");
|
|
||||||
|
|
||||||
return Content(htmlContent, rjson ? "application/json; charset=utf-8" : "text/html; charset=utf-8");
|
|
||||||
}
|
}
|
||||||
// s >= 0: Показуємо озвучки + епізоди
|
|
||||||
else if (s >= 0)
|
// s >= 0: завантажуємо тільки потрібний сезон
|
||||||
|
if (s >= 0)
|
||||||
{
|
{
|
||||||
|
var structure = await invoke.GetSeasonStructure(filmUrl, s);
|
||||||
|
if (structure == null || structure.Voices == null || structure.Voices.Count == 0)
|
||||||
|
{
|
||||||
|
OnLog($"No voices found for season {s}");
|
||||||
|
OnLog("=== RETURN: no voices for season OnError ===");
|
||||||
|
return OnError("uaflix", refresh_proxy: true);
|
||||||
|
}
|
||||||
var voicesForSeason = structure.Voices
|
var voicesForSeason = structure.Voices
|
||||||
.Where(v => v.Value.Seasons.ContainsKey(s))
|
|
||||||
.Select(v => new { DisplayName = v.Key, Info = v.Value })
|
.Select(v => new { DisplayName = v.Key, Info = v.Value })
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@ -276,60 +229,48 @@ namespace Uaflix.Controllers
|
|||||||
OnLog($"Voice '{t}' not found, fallback to first voice: {t}");
|
OnLog($"Voice '{t}' not found, fallback to first voice: {t}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VoiceInfo selectedVoice = null;
|
||||||
|
if (!structure.Voices.TryGetValue(t, out selectedVoice) || !selectedVoice.Seasons.ContainsKey(s) || selectedVoice.Seasons[s] == null || selectedVoice.Seasons[s].Count == 0)
|
||||||
|
{
|
||||||
|
var fallbackVoice = voicesForSeason.FirstOrDefault(v => v.Info.Seasons.ContainsKey(s) && v.Info.Seasons[s] != null && v.Info.Seasons[s].Count > 0);
|
||||||
|
if (fallbackVoice == null)
|
||||||
|
{
|
||||||
|
OnLog($"Season {s} not found for selected voice and fallback voice missing");
|
||||||
|
OnLog("=== RETURN: season not found for voice OnError ===");
|
||||||
|
return OnError("uaflix", refresh_proxy: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
t = fallbackVoice.DisplayName;
|
||||||
|
selectedVoice = fallbackVoice.Info;
|
||||||
|
OnLog($"Selected voice had no episodes, fallback to: {t}");
|
||||||
|
}
|
||||||
|
|
||||||
// Створюємо VoiceTpl з усіма озвучками
|
// Створюємо VoiceTpl з усіма озвучками
|
||||||
var voice_tpl = new VoiceTpl();
|
var voice_tpl = new VoiceTpl();
|
||||||
var selectedVoiceInfo = structure.Voices[t];
|
|
||||||
var selectedSeasonSet = GetSeasonSet(selectedVoiceInfo);
|
|
||||||
bool selectedIsAshdi = IsAshdiVoice(selectedVoiceInfo);
|
|
||||||
|
|
||||||
foreach (var voice in voicesForSeason)
|
foreach (var voice in voicesForSeason)
|
||||||
{
|
{
|
||||||
bool targetIsAshdi = IsAshdiVoice(voice.Info);
|
|
||||||
var targetSeasonSet = GetSeasonSet(voice.Info);
|
|
||||||
bool sameSeasonSet = targetSeasonSet.SetEquals(selectedSeasonSet);
|
|
||||||
bool needSeasonReset = (selectedIsAshdi || targetIsAshdi) && !sameSeasonSet;
|
|
||||||
|
|
||||||
string voiceLink = $"{host}/lite/uaflix?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&href={HttpUtility.UrlEncode(filmUrl)}";
|
string voiceLink = $"{host}/lite/uaflix?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&href={HttpUtility.UrlEncode(filmUrl)}";
|
||||||
if (needSeasonReset)
|
voiceLink += $"&s={s}&t={HttpUtility.UrlEncode(voice.DisplayName)}";
|
||||||
voiceLink += $"&s=-1&t={HttpUtility.UrlEncode(voice.DisplayName)}";
|
|
||||||
else
|
|
||||||
voiceLink += $"&s={s}&t={HttpUtility.UrlEncode(voice.DisplayName)}";
|
|
||||||
|
|
||||||
bool isActive = voice.DisplayName == t;
|
bool isActive = voice.DisplayName == t;
|
||||||
voice_tpl.Append(voice.DisplayName, isActive, voiceLink);
|
voice_tpl.Append(voice.DisplayName, isActive, voiceLink);
|
||||||
}
|
}
|
||||||
OnLog($"Created VoiceTpl with {voicesForSeason.Count} voices, active: {t}");
|
OnLog($"Created VoiceTpl with {voicesForSeason.Count} voices, active: {t}");
|
||||||
|
|
||||||
// Відображення епізодів для вибраної озвучки
|
|
||||||
if (!structure.Voices.ContainsKey(t))
|
|
||||||
{
|
|
||||||
OnLog($"Voice '{t}' not found in structure");
|
|
||||||
OnLog("=== RETURN: voice not found OnError ===");
|
|
||||||
return OnError("uaflix", refresh_proxy: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!structure.Voices[t].Seasons.ContainsKey(s))
|
var episodes = selectedVoice.Seasons[s];
|
||||||
{
|
|
||||||
OnLog($"Season {s} not found for voice '{t}'");
|
|
||||||
if (IsAshdiVoice(structure.Voices[t]))
|
|
||||||
{
|
|
||||||
string redirectUrl = $"{host}/lite/uaflix?imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial=1&s=-1&t={HttpUtility.UrlEncode(t)}&href={HttpUtility.UrlEncode(filmUrl)}";
|
|
||||||
OnLog($"Ashdi voice missing season, redirect to season selector: {redirectUrl}");
|
|
||||||
return Redirect(redirectUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
OnLog("=== RETURN: season not found for voice OnError ===");
|
|
||||||
return OnError("uaflix", refresh_proxy: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
var episodes = structure.Voices[t].Seasons[s];
|
|
||||||
var episode_tpl = new EpisodeTpl();
|
var episode_tpl = new EpisodeTpl();
|
||||||
|
int appendedEpisodes = 0;
|
||||||
|
|
||||||
foreach (var ep in episodes)
|
foreach (var ep in episodes)
|
||||||
{
|
{
|
||||||
|
if (ep == null || string.IsNullOrWhiteSpace(ep.File))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
string episodeTitle = !string.IsNullOrWhiteSpace(ep.Title) ? ep.Title : $"Епізод {ep.Number}";
|
||||||
|
|
||||||
// Для zetvideo-vod повертаємо URL епізоду з методом call
|
// Для zetvideo-vod повертаємо URL епізоду з методом call
|
||||||
// Для ashdi/zetvideo-serial повертаємо готове посилання з play
|
// Для ashdi/zetvideo-serial повертаємо готове посилання з play
|
||||||
var voice = structure.Voices[t];
|
var voice = selectedVoice;
|
||||||
|
|
||||||
if (voice.PlayerType == "zetvideo-vod" || voice.PlayerType == "ashdi-vod")
|
if (voice.PlayerType == "zetvideo-vod" || voice.PlayerType == "ashdi-vod")
|
||||||
{
|
{
|
||||||
@ -337,7 +278,7 @@ namespace Uaflix.Controllers
|
|||||||
// Потрібно передати URL епізоду в інший параметр, щоб не плутати з play=true
|
// Потрібно передати URL епізоду в інший параметр, щоб не плутати з play=true
|
||||||
string callUrl = $"{host}/lite/uaflix?episode_url={HttpUtility.UrlEncode(ep.File)}&imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial={serial}&s={s}&e={ep.Number}";
|
string callUrl = $"{host}/lite/uaflix?episode_url={HttpUtility.UrlEncode(ep.File)}&imdb_id={imdb_id}&kinopoisk_id={kinopoisk_id}&title={HttpUtility.UrlEncode(title)}&original_title={HttpUtility.UrlEncode(original_title)}&year={year}&serial={serial}&s={s}&e={ep.Number}";
|
||||||
episode_tpl.Append(
|
episode_tpl.Append(
|
||||||
name: ep.Title,
|
name: episodeTitle,
|
||||||
title: title,
|
title: title,
|
||||||
s: s.ToString(),
|
s: s.ToString(),
|
||||||
e: ep.Number.ToString(),
|
e: ep.Number.ToString(),
|
||||||
@ -351,16 +292,25 @@ namespace Uaflix.Controllers
|
|||||||
// Для багатосерійних плеєрів (ashdi-serial, zetvideo-serial) - пряме відтворення
|
// Для багатосерійних плеєрів (ashdi-serial, zetvideo-serial) - пряме відтворення
|
||||||
string playUrl = BuildStreamUrl(init, ep.File);
|
string playUrl = BuildStreamUrl(init, ep.File);
|
||||||
episode_tpl.Append(
|
episode_tpl.Append(
|
||||||
name: ep.Title,
|
name: episodeTitle,
|
||||||
title: title,
|
title: title,
|
||||||
s: s.ToString(),
|
s: s.ToString(),
|
||||||
e: ep.Number.ToString(),
|
e: ep.Number.ToString(),
|
||||||
link: playUrl
|
link: playUrl
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appendedEpisodes++;
|
||||||
}
|
}
|
||||||
|
|
||||||
OnLog($"Created EpisodeTpl with {episodes.Count} episodes");
|
if (appendedEpisodes == 0)
|
||||||
|
{
|
||||||
|
OnLog($"No valid episodes after filtering for season {s}, voice {t}");
|
||||||
|
OnLog("=== RETURN: no valid episodes OnError ===");
|
||||||
|
return OnError("uaflix", refresh_proxy: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
OnLog($"Created EpisodeTpl with {appendedEpisodes} episodes");
|
||||||
|
|
||||||
// Повертаємо VoiceTpl + EpisodeTpl разом
|
// Повертаємо VoiceTpl + EpisodeTpl разом
|
||||||
episode_tpl.Append(voice_tpl);
|
episode_tpl.Append(voice_tpl);
|
||||||
@ -470,25 +420,6 @@ namespace Uaflix.Controllers
|
|||||||
return cleaned;
|
return cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsAshdiVoice(VoiceInfo voice)
|
|
||||||
{
|
|
||||||
if (voice == null || string.IsNullOrEmpty(voice.PlayerType))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
return voice.PlayerType == "ashdi-serial" || voice.PlayerType == "ashdi-vod";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static HashSet<int> GetSeasonSet(VoiceInfo voice)
|
|
||||||
{
|
|
||||||
if (voice?.Seasons == null || voice.Seasons.Count == 0)
|
|
||||||
return new HashSet<int>();
|
|
||||||
|
|
||||||
return voice.Seasons
|
|
||||||
.Where(kv => kv.Value != null && kv.Value.Any(ep => !string.IsNullOrEmpty(ep.File)))
|
|
||||||
.Select(kv => kv.Key)
|
|
||||||
.ToHashSet();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsCheckOnlineSearchEnabled()
|
private static bool IsCheckOnlineSearchEnabled()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@ -19,7 +19,7 @@ namespace Uaflix
|
|||||||
{
|
{
|
||||||
public class ModInit : IModuleLoaded
|
public class ModInit : IModuleLoaded
|
||||||
{
|
{
|
||||||
public static double Version => 5.0;
|
public static double Version => 5.1;
|
||||||
|
|
||||||
public static UaflixSettings UaFlix;
|
public static UaflixSettings UaFlix;
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,9 @@ namespace Uaflix.Models
|
|||||||
{
|
{
|
||||||
// Словник сезонів, де ключ - номер сезону, значення - кількість сторінок
|
// Словник сезонів, де ключ - номер сезону, значення - кількість сторінок
|
||||||
public Dictionary<int, int> Seasons { get; set; } = new Dictionary<int, int>();
|
public Dictionary<int, int> Seasons { get; set; } = new Dictionary<int, int>();
|
||||||
|
|
||||||
|
// URL сторінки сезону: ключ - номер сезону, значення - абсолютний URL сторінки
|
||||||
|
public Dictionary<int, string> SeasonUrls { get; set; } = new Dictionary<int, string>();
|
||||||
|
|
||||||
// Загальна кількість сторінок (якщо потрібно)
|
// Загальна кількість сторінок (якщо потрібно)
|
||||||
public int TotalPages { get; set; }
|
public int TotalPages { get; set; }
|
||||||
@ -16,4 +19,4 @@ namespace Uaflix.Models
|
|||||||
|
|
||||||
public List<EpisodeLinkInfo> Episodes { get; set; } = new List<EpisodeLinkInfo>();
|
public List<EpisodeLinkInfo> Episodes { get; set; } = new List<EpisodeLinkInfo>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -821,6 +821,440 @@ namespace Uaflix
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region Сезонний (лінивий) парсинг серіалу
|
||||||
|
|
||||||
|
public async Task<PaginationInfo> GetSeasonIndex(string serialUrl)
|
||||||
|
{
|
||||||
|
string memKey = $"UaFlix:season-index:{serialUrl}";
|
||||||
|
if (_hybridCache.TryGetValue(memKey, out PaginationInfo cached))
|
||||||
|
return cached;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(serialUrl) || !Uri.IsWellFormedUriString(serialUrl, UriKind.Absolute))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var headers = new List<HeadersModel>()
|
||||||
|
{
|
||||||
|
new HeadersModel("User-Agent", "Mozilla/5.0"),
|
||||||
|
new HeadersModel("Referer", _init.host)
|
||||||
|
};
|
||||||
|
|
||||||
|
string html = await GetHtml(serialUrl, headers);
|
||||||
|
if (string.IsNullOrWhiteSpace(html))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var doc = new HtmlDocument();
|
||||||
|
doc.LoadHtml(html);
|
||||||
|
|
||||||
|
var result = new PaginationInfo
|
||||||
|
{
|
||||||
|
SerialUrl = serialUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
var seasonNodes = doc.DocumentNode.SelectNodes("//div[contains(@class, 'sez-wr')]//a");
|
||||||
|
if (seasonNodes == null)
|
||||||
|
seasonNodes = doc.DocumentNode.SelectNodes("//div[contains(@class, 'fss-box')]//a");
|
||||||
|
|
||||||
|
if (seasonNodes == null || seasonNodes.Count == 0)
|
||||||
|
{
|
||||||
|
// Якщо явного списку сезонів немає, вважаємо що є один сезон.
|
||||||
|
result.Seasons[1] = 1;
|
||||||
|
result.SeasonUrls[1] = serialUrl;
|
||||||
|
_hybridCache.Set(memKey, result, cacheTime(40));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var node in seasonNodes)
|
||||||
|
{
|
||||||
|
string href = node.GetAttributeValue("href", null);
|
||||||
|
string seasonUrl = ToAbsoluteUrl(href);
|
||||||
|
if (string.IsNullOrWhiteSpace(seasonUrl))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
string tabText = WebUtility.HtmlDecode(node.InnerText ?? string.Empty);
|
||||||
|
if (!IsSeasonTabLink(seasonUrl, tabText))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
int season = ExtractSeasonNumber(seasonUrl, tabText);
|
||||||
|
if (season <= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!result.SeasonUrls.TryGetValue(season, out string existing))
|
||||||
|
{
|
||||||
|
result.SeasonUrls[season] = seasonUrl;
|
||||||
|
result.Seasons[season] = 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsPreferableSeasonUrl(existing, seasonUrl, season))
|
||||||
|
result.SeasonUrls[season] = seasonUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.SeasonUrls.Count == 0)
|
||||||
|
{
|
||||||
|
result.Seasons[1] = 1;
|
||||||
|
result.SeasonUrls[1] = serialUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
_hybridCache.Set(memKey, result, cacheTime(40));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_onLog($"GetSeasonIndex error: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<EpisodeLinkInfo>> GetSeasonEpisodes(string serialUrl, int season)
|
||||||
|
{
|
||||||
|
if (season < 0)
|
||||||
|
return new List<EpisodeLinkInfo>();
|
||||||
|
|
||||||
|
string memKey = $"UaFlix:season-episodes:{serialUrl}:{season}";
|
||||||
|
if (_hybridCache.TryGetValue(memKey, out List<EpisodeLinkInfo> cached))
|
||||||
|
return cached;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var headers = new List<HeadersModel>()
|
||||||
|
{
|
||||||
|
new HeadersModel("User-Agent", "Mozilla/5.0"),
|
||||||
|
new HeadersModel("Referer", _init.host)
|
||||||
|
};
|
||||||
|
|
||||||
|
var index = await GetSeasonIndex(serialUrl);
|
||||||
|
string seasonUrl = index?.SeasonUrls != null && index.SeasonUrls.TryGetValue(season, out string mapped)
|
||||||
|
? mapped
|
||||||
|
: serialUrl;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(seasonUrl))
|
||||||
|
seasonUrl = serialUrl;
|
||||||
|
|
||||||
|
string html = await GetHtml(seasonUrl, headers);
|
||||||
|
if (string.IsNullOrWhiteSpace(html) && !string.Equals(seasonUrl, serialUrl, StringComparison.OrdinalIgnoreCase))
|
||||||
|
html = await GetHtml(serialUrl, headers);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(html))
|
||||||
|
return new List<EpisodeLinkInfo>();
|
||||||
|
|
||||||
|
var result = ParseSeasonEpisodesFromHtml(html, season);
|
||||||
|
if (result.Count == 0 && !string.Equals(seasonUrl, serialUrl, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
string serialHtml = await GetHtml(serialUrl, headers);
|
||||||
|
if (!string.IsNullOrWhiteSpace(serialHtml))
|
||||||
|
result = ParseSeasonEpisodesFromHtml(serialHtml, season);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.Count == 0 && season == 1 && string.Equals(seasonUrl, serialUrl, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Fallback для сторінок без окремих епізодів.
|
||||||
|
result.Add(new EpisodeLinkInfo
|
||||||
|
{
|
||||||
|
url = serialUrl,
|
||||||
|
title = "Епізод 1",
|
||||||
|
season = 1,
|
||||||
|
episode = 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_hybridCache.Set(memKey, result, cacheTime(20));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_onLog($"GetSeasonEpisodes error: {ex.Message}");
|
||||||
|
return new List<EpisodeLinkInfo>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<EpisodeLinkInfo> ParseSeasonEpisodesFromHtml(string html, int season)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(html))
|
||||||
|
return new List<EpisodeLinkInfo>();
|
||||||
|
|
||||||
|
var doc = new HtmlDocument();
|
||||||
|
doc.LoadHtml(html);
|
||||||
|
|
||||||
|
var episodeNodes = doc.DocumentNode.SelectNodes("//div[contains(@class, 'frels')]//a[contains(@class, 'vi-img')]");
|
||||||
|
if (episodeNodes == null || episodeNodes.Count == 0)
|
||||||
|
return new List<EpisodeLinkInfo>();
|
||||||
|
|
||||||
|
var episodes = new List<EpisodeLinkInfo>();
|
||||||
|
var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
int fallbackEpisode = 1;
|
||||||
|
|
||||||
|
foreach (var episodeNode in episodeNodes)
|
||||||
|
{
|
||||||
|
string episodeUrl = ToAbsoluteUrl(episodeNode.GetAttributeValue("href", null));
|
||||||
|
if (string.IsNullOrWhiteSpace(episodeUrl) || !used.Add(episodeUrl))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var match = Regex.Match(episodeUrl, @"season-(\d+).*?episode-(\d+)", RegexOptions.IgnoreCase);
|
||||||
|
int parsedSeason = season;
|
||||||
|
int parsedEpisode = fallbackEpisode;
|
||||||
|
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
if (int.TryParse(match.Groups[1].Value, out int seasonFromUrl))
|
||||||
|
parsedSeason = seasonFromUrl;
|
||||||
|
if (int.TryParse(match.Groups[2].Value, out int episodeFromUrl))
|
||||||
|
parsedEpisode = episodeFromUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
episodes.Add(new EpisodeLinkInfo
|
||||||
|
{
|
||||||
|
url = episodeUrl,
|
||||||
|
title = episodeNode.SelectSingleNode(".//div[@class='vi-rate']")?.InnerText.Trim() ?? $"Епізод {parsedEpisode}",
|
||||||
|
season = parsedSeason,
|
||||||
|
episode = parsedEpisode
|
||||||
|
});
|
||||||
|
|
||||||
|
fallbackEpisode = Math.Max(fallbackEpisode, parsedEpisode + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return episodes
|
||||||
|
.Where(e => e != null && !string.IsNullOrWhiteSpace(e.url))
|
||||||
|
.Where(e => e.season == season)
|
||||||
|
.OrderBy(e => e.episode)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SerialAggregatedStructure> GetSeasonStructure(string serialUrl, int season)
|
||||||
|
{
|
||||||
|
if (season < 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
string memKey = $"UaFlix:season-structure:{serialUrl}:{season}";
|
||||||
|
if (_hybridCache.TryGetValue(memKey, out SerialAggregatedStructure cached))
|
||||||
|
{
|
||||||
|
_onLog($"GetSeasonStructure: Using cached structure for season={season}, url={serialUrl}");
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var seasonEpisodes = await GetSeasonEpisodes(serialUrl, season);
|
||||||
|
if (seasonEpisodes == null || seasonEpisodes.Count == 0)
|
||||||
|
{
|
||||||
|
_onLog($"GetSeasonStructure: No episodes for season={season}, url={serialUrl}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var structure = new SerialAggregatedStructure
|
||||||
|
{
|
||||||
|
SerialUrl = serialUrl,
|
||||||
|
AllEpisodes = seasonEpisodes
|
||||||
|
};
|
||||||
|
|
||||||
|
var seasonProbe = await ProbeSeasonPlayer(seasonEpisodes);
|
||||||
|
if (string.IsNullOrWhiteSpace(seasonProbe.playerType))
|
||||||
|
{
|
||||||
|
// fallback: інколи плеєр є лише на головній сторінці
|
||||||
|
seasonProbe = await ProbeEpisodePlayer(serialUrl);
|
||||||
|
if (string.IsNullOrWhiteSpace(seasonProbe.playerType))
|
||||||
|
{
|
||||||
|
_onLog($"GetSeasonStructure: unsupported player for season={season}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seasonProbe.playerType == "ashdi-serial" || seasonProbe.playerType == "zetvideo-serial")
|
||||||
|
{
|
||||||
|
var voices = await ParseMultiEpisodePlayerCached(seasonProbe.iframeUrl, seasonProbe.playerType);
|
||||||
|
foreach (var voice in voices)
|
||||||
|
{
|
||||||
|
if (voice?.Seasons == null || !voice.Seasons.TryGetValue(season, out List<EpisodeInfo> seasonVoiceEpisodes) || seasonVoiceEpisodes == null || seasonVoiceEpisodes.Count == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
structure.Voices[voice.DisplayName] = new VoiceInfo
|
||||||
|
{
|
||||||
|
Name = voice.Name,
|
||||||
|
PlayerType = voice.PlayerType,
|
||||||
|
DisplayName = voice.DisplayName,
|
||||||
|
Seasons = new Dictionary<int, List<EpisodeInfo>>
|
||||||
|
{
|
||||||
|
[season] = seasonVoiceEpisodes
|
||||||
|
.Where(ep => ep != null && !string.IsNullOrWhiteSpace(ep.File))
|
||||||
|
.Select(ep => new EpisodeInfo
|
||||||
|
{
|
||||||
|
Number = ep.Number,
|
||||||
|
Title = ep.Title,
|
||||||
|
File = ep.File,
|
||||||
|
Id = ep.Id,
|
||||||
|
Poster = ep.Poster,
|
||||||
|
Subtitle = ep.Subtitle
|
||||||
|
})
|
||||||
|
.ToList()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (seasonProbe.playerType == "ashdi-vod" || seasonProbe.playerType == "zetvideo-vod")
|
||||||
|
{
|
||||||
|
AddVodSeasonEpisodes(structure, seasonProbe.playerType, season, seasonEpisodes);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_onLog($"GetSeasonStructure: player '{seasonProbe.playerType}' is not supported");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!structure.Voices.Any())
|
||||||
|
{
|
||||||
|
_onLog($"GetSeasonStructure: voices are empty for season={season}, url={serialUrl}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
NormalizeUaflixVoiceNames(structure);
|
||||||
|
_hybridCache.Set(memKey, structure, cacheTime(30));
|
||||||
|
return structure;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_onLog($"GetSeasonStructure error: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<List<VoiceInfo>> ParseMultiEpisodePlayerCached(string iframeUrl, string playerType)
|
||||||
|
{
|
||||||
|
string serialKey = NormalizeSerialPlayerKey(playerType, iframeUrl);
|
||||||
|
string memKey = $"UaFlix:player-voices:{playerType}:{serialKey}";
|
||||||
|
if (_hybridCache.TryGetValue(memKey, out List<VoiceInfo> cached))
|
||||||
|
return CloneVoices(cached);
|
||||||
|
|
||||||
|
var parsed = await ParseMultiEpisodePlayer(iframeUrl, playerType);
|
||||||
|
if (parsed == null || parsed.Count == 0)
|
||||||
|
return new List<VoiceInfo>();
|
||||||
|
|
||||||
|
_hybridCache.Set(memKey, parsed, cacheTime(40));
|
||||||
|
return CloneVoices(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<VoiceInfo> CloneVoices(List<VoiceInfo> voices)
|
||||||
|
{
|
||||||
|
if (voices == null || voices.Count == 0)
|
||||||
|
return new List<VoiceInfo>();
|
||||||
|
|
||||||
|
var result = new List<VoiceInfo>(voices.Count);
|
||||||
|
foreach (var voice in voices)
|
||||||
|
{
|
||||||
|
if (voice == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var clone = new VoiceInfo
|
||||||
|
{
|
||||||
|
Name = voice.Name,
|
||||||
|
PlayerType = voice.PlayerType,
|
||||||
|
DisplayName = voice.DisplayName,
|
||||||
|
Seasons = new Dictionary<int, List<EpisodeInfo>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (voice.Seasons != null)
|
||||||
|
{
|
||||||
|
foreach (var season in voice.Seasons)
|
||||||
|
{
|
||||||
|
clone.Seasons[season.Key] = season.Value?
|
||||||
|
.Where(ep => ep != null)
|
||||||
|
.Select(ep => new EpisodeInfo
|
||||||
|
{
|
||||||
|
Number = ep.Number,
|
||||||
|
Title = ep.Title,
|
||||||
|
File = ep.File,
|
||||||
|
Id = ep.Id,
|
||||||
|
Poster = ep.Poster,
|
||||||
|
Subtitle = ep.Subtitle
|
||||||
|
})
|
||||||
|
.ToList() ?? new List<EpisodeInfo>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(clone);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
string ToAbsoluteUrl(string url)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
string clean = WebUtility.HtmlDecode(url.Trim());
|
||||||
|
if (clean.StartsWith("//"))
|
||||||
|
clean = "https:" + clean;
|
||||||
|
|
||||||
|
if (clean.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || clean.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return clean;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_init?.host))
|
||||||
|
return clean;
|
||||||
|
|
||||||
|
return $"{_init.host.TrimEnd('/')}/{clean.TrimStart('/')}";
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool IsSeasonTabLink(string url, string text)
|
||||||
|
{
|
||||||
|
string u = (url ?? string.Empty).ToLowerInvariant();
|
||||||
|
string t = (text ?? string.Empty).ToLowerInvariant();
|
||||||
|
|
||||||
|
if (u.Contains("/date/") || t.Contains("графік") || t.Contains("дата виходу"))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (Regex.IsMatch(u, @"(?:sezon|season)[-_/ ]?\d+", RegexOptions.IgnoreCase))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (Regex.IsMatch(t, @"(?:сезон|season)\s*\d+", RegexOptions.IgnoreCase))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool IsPreferableSeasonUrl(string oldUrl, string newUrl, int season)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(newUrl))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(oldUrl))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
string marker = $"/sezon-{season}/";
|
||||||
|
bool oldHasMarker = oldUrl.IndexOf(marker, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
bool newHasMarker = newUrl.IndexOf(marker, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
|
||||||
|
if (!oldHasMarker && newHasMarker)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int ExtractSeasonNumber(string url, string text)
|
||||||
|
{
|
||||||
|
foreach (string source in new[] { url, text })
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(source))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var seasonBySlug = Regex.Match(source, @"(?:sezon|season)[-_/ ]?(\d+)", RegexOptions.IgnoreCase);
|
||||||
|
if (seasonBySlug.Success && int.TryParse(seasonBySlug.Groups[1].Value, out int seasonSlug) && seasonSlug > 0)
|
||||||
|
return seasonSlug;
|
||||||
|
|
||||||
|
var seasonByWordUa = Regex.Match(source, @"сезон\s*(\d+)", RegexOptions.IgnoreCase);
|
||||||
|
if (seasonByWordUa.Success && int.TryParse(seasonByWordUa.Groups[1].Value, out int seasonWordUa) && seasonWordUa > 0)
|
||||||
|
return seasonWordUa;
|
||||||
|
|
||||||
|
var seasonByWordEn = Regex.Match(source, @"season\s*(\d+)", RegexOptions.IgnoreCase);
|
||||||
|
if (seasonByWordEn.Success && int.TryParse(seasonByWordEn.Groups[1].Value, out int seasonWordEn) && seasonWordEn > 0)
|
||||||
|
return seasonWordEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
public async Task<List<SearchResult>> Search(string imdb_id, long kinopoisk_id, string title, string original_title, int year, int serial, string original_language, string source, string search_query)
|
public async Task<List<SearchResult>> Search(string imdb_id, long kinopoisk_id, string title, string original_title, int year, int serial, string original_language, string source, string search_query)
|
||||||
{
|
{
|
||||||
bool allowAnime = IsAnimeRequest(title, original_title, original_language, source);
|
bool allowAnime = IsAnimeRequest(title, original_title, original_language, source);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user