Compare commits

...

3 Commits

Author SHA1 Message Date
Felix
ff90f149f0 chore: update .gitignore to exclude vscode settings
Add .vscode/settings.json to the ignore list to prevent committing local editor configurations.
2026-04-04 12:17:53 +03:00
Felix
b1a7ce510d feat(uafilmme): add UafilmME streaming plugin with APN support
Integrate a new online streaming source for UafilmME, including API invocation, search, and playback functionality. Adds APN proxy helper for Ashdi streams, module initialization, and related models and controllers to extend the existing online framework.
2026-04-04 12:09:18 +03:00
Felix
0aed459fab perf(uaflix): implement lazy season parsing for serials
Refactor season selection logic to use lazy loading instead of full aggregation, improving performance when choosing seasons. Added GetSeasonIndex and GetSeasonEpisodes methods, and SeasonUrls property to PaginationInfo for efficient season URL management.
2026-04-04 08:48:37 +03:00
14 changed files with 2065 additions and 137 deletions

1
.gitignore vendored
View File

@ -14,3 +14,4 @@ AGENTS.md
.vs
bin
obj
.vscode/settings.json

99
UafilmME/ApnHelper.cs Normal file
View 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
View 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
View 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
View 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);
}

View 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
View 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
View 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
View 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
View File

@ -0,0 +1,6 @@
{
"enable": true,
"version": 3,
"initspace": "UafilmME.ModInit",
"online": "UafilmME.OnlineApi"
}

View File

@ -163,97 +163,50 @@ namespace Uaflix.Controllers
if (serial == 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: Вибір сезону
// s == -1: швидкий вибір сезону без повної агрегації серіалу
if (s == -1)
{
List<int> allSeasons;
VoiceInfo tVoice = null;
bool restrictByVoice = !string.IsNullOrEmpty(t) && structure.Voices.TryGetValue(t, out tVoice) && IsAshdiVoice(tVoice);
if (restrictByVoice)
{
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();
}
var seasonIndex = await invoke.GetSeasonIndex(filmUrl);
var seasons = seasonIndex?.Seasons?.Keys
.Distinct()
.OrderBy(sn => sn)
.ToList();
OnLog($"Found {allSeasons.Count} seasons in structure: {string.Join(", ", allSeasons)}");
// Перевіряємо чи сезони містять валідні епізоди з файлами
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)
if (seasons == null || seasons.Count == 0)
{
var episodesInSeason = structure.Voices.Values
.Where(v => v.Seasons.ContainsKey(season))
.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 ===");
OnLog("No seasons found in season index");
OnLog("=== RETURN: no seasons OnError ===");
return OnError("uaflix", refresh_proxy: true);
}
var season_tpl = new SeasonTpl(seasonsWithValidEpisodes.Count);
foreach (var season in seasonsWithValidEpisodes)
var season_tpl = new SeasonTpl(seasons.Count);
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)}";
if (restrictByVoice)
if (!string.IsNullOrWhiteSpace(t))
link += $"&t={HttpUtility.UrlEncode(t)}";
season_tpl.Append($"{season}", link, season.ToString());
OnLog($"Added season {season} to template");
}
OnLog($"Returning season template with {seasonsWithValidEpisodes.Count} seasons");
var htmlContent = rjson ? season_tpl.ToJson() : season_tpl.ToHtml();
OnLog($"Season template response length: {htmlContent.Length}");
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");
OnLog($"=== RETURN: season template ({seasons.Count} seasons) ===");
return Content(
rjson ? season_tpl.ToJson() : season_tpl.ToHtml(),
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
.Where(v => v.Value.Seasons.ContainsKey(s))
.Select(v => new { DisplayName = v.Key, Info = v.Value })
.ToList();
@ -276,60 +229,48 @@ namespace Uaflix.Controllers
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 з усіма озвучками
var voice_tpl = new VoiceTpl();
var selectedVoiceInfo = structure.Voices[t];
var selectedSeasonSet = GetSeasonSet(selectedVoiceInfo);
bool selectedIsAshdi = IsAshdiVoice(selectedVoiceInfo);
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)}";
if (needSeasonReset)
voiceLink += $"&s=-1&t={HttpUtility.UrlEncode(voice.DisplayName)}";
else
voiceLink += $"&s={s}&t={HttpUtility.UrlEncode(voice.DisplayName)}";
voiceLink += $"&s={s}&t={HttpUtility.UrlEncode(voice.DisplayName)}";
bool isActive = voice.DisplayName == t;
voice_tpl.Append(voice.DisplayName, isActive, voiceLink);
}
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))
{
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 episodes = selectedVoice.Seasons[s];
var episode_tpl = new EpisodeTpl();
int appendedEpisodes = 0;
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
// Для ashdi/zetvideo-serial повертаємо готове посилання з play
var voice = structure.Voices[t];
var voice = selectedVoice;
if (voice.PlayerType == "zetvideo-vod" || voice.PlayerType == "ashdi-vod")
{
@ -337,7 +278,7 @@ namespace Uaflix.Controllers
// Потрібно передати 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}";
episode_tpl.Append(
name: ep.Title,
name: episodeTitle,
title: title,
s: s.ToString(),
e: ep.Number.ToString(),
@ -351,16 +292,25 @@ namespace Uaflix.Controllers
// Для багатосерійних плеєрів (ashdi-serial, zetvideo-serial) - пряме відтворення
string playUrl = BuildStreamUrl(init, ep.File);
episode_tpl.Append(
name: ep.Title,
name: episodeTitle,
title: title,
s: s.ToString(),
e: ep.Number.ToString(),
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 разом
episode_tpl.Append(voice_tpl);
@ -470,25 +420,6 @@ namespace Uaflix.Controllers
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()
{
try

View File

@ -19,7 +19,7 @@ namespace Uaflix
{
public class ModInit : IModuleLoaded
{
public static double Version => 5.0;
public static double Version => 5.1;
public static UaflixSettings UaFlix;

View File

@ -8,6 +8,9 @@ namespace Uaflix.Models
// Словник сезонів, де ключ - номер сезону, значення - кількість сторінок
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; }

View File

@ -821,6 +821,440 @@ namespace Uaflix
#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)
{
bool allowAnime = IsAnimeRequest(title, original_title, original_language, source);