From ed07bba8415cf3944a7f4853a7adb989aed912c9 Mon Sep 17 00:00:00 2001 From: glax Date: Mon, 6 Oct 2025 22:48:46 +0200 Subject: [PATCH] Use DelegatingHandler for RateLimits --- API/Controllers/SettingsController.cs | 61 +---- API/MangaConnectors/MangaConnector.cs | 10 +- API/MangaConnectors/MangaDex.cs | 24 +- API/MangaConnectors/MangaPark.cs | 18 +- API/MangaConnectors/Mangaworld.cs | 30 +-- API/MangaDownloadClients/DownloadClient.cs | 56 +---- .../FlareSolverrDownloadClient.cs | 54 ++--- .../HttpDownloadClient.cs | 100 +++----- API/MangaDownloadClients/RateLimitHandler.cs | 31 +++ API/MangaDownloadClients/RequestResult.cs | 33 --- API/Tranga.cs | 3 + API/TrangaSettings.cs | 24 -- ...DownloadChapterFromMangaconnectorWorker.cs | 8 +- API/openapi/API_v2.json | 226 ------------------ docker-compose.local.yaml | 2 +- 15 files changed, 142 insertions(+), 538 deletions(-) create mode 100644 API/MangaDownloadClients/RateLimitHandler.cs delete mode 100644 API/MangaDownloadClients/RequestResult.cs diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index fce6d47..4233ff8 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -60,17 +60,6 @@ public class SettingsController() : Controller return TypedResults.Ok(); } - /// - /// Get all Request-Limits - /// - /// - [HttpGet("RequestLimits")] - [ProducesResponseType>(Status200OK, "application/json")] - public Ok> GetRequestLimits() - { - return TypedResults.Ok(Tranga.Settings.RequestLimits); - } - /// /// Update all Request-Limits to new values /// @@ -81,48 +70,6 @@ public class SettingsController() : Controller { return TypedResults.StatusCode(Status501NotImplemented); } - - /// - /// Updates a Request-Limit value - /// - /// Type of Request - /// New limit in Requests/Minute - /// - /// Limit needs to be greater than 0 - [HttpPatch("RequestLimits/{RequestType}")] - [ProducesResponseType(Status200OK)] - [ProducesResponseType(Status400BadRequest)] - public Results SetRequestLimit(RequestType RequestType, [FromBody]int requestLimit) - { - if (requestLimit <= 0) - return TypedResults.BadRequest(); - Tranga.Settings.SetRequestLimit(RequestType, requestLimit); - return TypedResults.Ok(); - } - - /// - /// Reset Request-Limit - /// - /// - [HttpDelete("RequestLimits/{RequestType}")] - [ProducesResponseType(Status200OK)] - public Ok ResetRequestLimits(RequestType RequestType) - { - Tranga.Settings.SetRequestLimit(RequestType, TrangaSettings.DefaultRequestLimits[RequestType]); - return TypedResults.Ok(); - } - - /// - /// Reset Request-Limit - /// - /// - [HttpDelete("RequestLimits")] - [ProducesResponseType(Status200OK)] - public Ok ResetRequestLimits() - { - Tranga.Settings.ResetRequestLimits(); - return TypedResults.Ok(); - } /// /// Returns Level of Image-Compression for Images @@ -260,12 +207,12 @@ public class SettingsController() : Controller [HttpPost("FlareSolverr/Test")] [ProducesResponseType(Status200OK)] [ProducesResponseType(Status500InternalServerError)] - public Results TestFlareSolverrReachable() + public async Task> TestFlareSolverrReachable() { const string knownProtectedUrl = "https://prowlarr.servarr.com/v1/ping"; - FlareSolverrDownloadClient client = new(); - RequestResult result = client.MakeRequestInternal(knownProtectedUrl); - return (int)result.statusCode >= 200 && (int)result.statusCode < 300 ? TypedResults.Ok() : TypedResults.InternalServerError(); + FlareSolverrDownloadClient client = new(new ()); + HttpResponseMessage result = await client.MakeRequest(knownProtectedUrl, RequestType.Default); + return (int)result.StatusCode >= 200 && (int)result.StatusCode < 300 ? TypedResults.Ok() : TypedResults.InternalServerError(); } /// diff --git a/API/MangaConnectors/MangaConnector.cs b/API/MangaConnectors/MangaConnector.cs index c462856..5514c0f 100644 --- a/API/MangaConnectors/MangaConnector.cs +++ b/API/MangaConnectors/MangaConnector.cs @@ -5,9 +5,7 @@ using API.MangaDownloadClients; using API.Schema.MangaContext; using log4net; using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json; using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Formats.Jpeg; using SixLabors.ImageSharp.Processing; namespace API.MangaConnectors; @@ -15,7 +13,7 @@ namespace API.MangaConnectors; [PrimaryKey("Name")] public abstract class MangaConnector(string name, string[] supportedLanguages, string[] baseUris, string iconUrl) { - [NotMapped] internal DownloadClient downloadClient { get; init; } = null!; + [NotMapped] internal IDownloadClient downloadClient { get; init; } = null!; [NotMapped] protected ILog Log { get; init; } = LogManager.GetLogger(name); [StringLength(32)] public string Name { get; init; } = name; [StringLength(8)] public string[] SupportedLanguages { get; init; } = supportedLanguages; @@ -50,14 +48,14 @@ public abstract class MangaConnector(string name, string[] supportedLanguages, s if (File.Exists(saveImagePath)) return filename; - RequestResult coverResult = downloadClient.MakeRequest(mangaId.Obj.CoverUrl, RequestType.MangaCover, $"https://{match.Groups[1].Value}"); - if ((int)coverResult.statusCode < 200 || (int)coverResult.statusCode >= 300) + HttpResponseMessage coverResult = downloadClient.MakeRequest(mangaId.Obj.CoverUrl, RequestType.MangaCover, $"https://{match.Groups[1].Value}").Result; + if ((int)coverResult.StatusCode < 200 || (int)coverResult.StatusCode >= 300) return SaveCoverImageToCache(mangaId, --retries); try { using MemoryStream ms = new(); - coverResult.result.CopyTo(ms); + coverResult.Content.ReadAsStream().CopyTo(ms); byte[] imageBytes = ms.ToArray(); Directory.CreateDirectory(TrangaSettings.CoverImageCacheOriginal); File.WriteAllBytes(saveImagePath, imageBytes); diff --git a/API/MangaConnectors/MangaDex.cs b/API/MangaConnectors/MangaDex.cs index e6f4188..b88905b 100644 --- a/API/MangaConnectors/MangaDex.cs +++ b/API/MangaConnectors/MangaDex.cs @@ -35,14 +35,14 @@ public class MangaDex : MangaConnector $"&includes%5B%5D=manga&includes%5B%5D=cover_art&includes%5B%5D=author&includes%5B%5D=artist&includes%5B%5D=tag'"; offset += Limit; - RequestResult result = downloadClient.MakeRequest(requestUrl, RequestType.MangaDexFeed); - if ((int)result.statusCode < 200 || (int)result.statusCode >= 300) + HttpResponseMessage result = downloadClient.MakeRequest(requestUrl, RequestType.MangaDexFeed).Result; + if ((int)result.StatusCode < 200 || (int)result.StatusCode >= 300) { Log.Error("Request failed"); return []; } - using StreamReader sr = new (result.result); + using StreamReader sr = new (result.Content.ReadAsStream()); JObject jObject = JObject.Parse(sr.ReadToEnd()); if (jObject.Value("result") != "ok") @@ -96,14 +96,14 @@ public class MangaDex : MangaConnector $"https://api.mangadex.org/manga/{mangaIdOnSite}" + $"?includes%5B%5D=manga&includes%5B%5D=cover_art&includes%5B%5D=author&includes%5B%5D=artist&includes%5B%5D=tag'"; - RequestResult result = downloadClient.MakeRequest(requestUrl, RequestType.MangaDexFeed); - if ((int)result.statusCode < 200 || (int)result.statusCode >= 300) + HttpResponseMessage result = downloadClient.MakeRequest(requestUrl, RequestType.MangaDexFeed).Result; + if ((int)result.StatusCode < 200 || (int)result.StatusCode >= 300) { Log.Error("Request failed"); return null; } - using StreamReader sr = new (result.result); + using StreamReader sr = new (result.Content.ReadAsStream()); JObject jObject = JObject.Parse(sr.ReadToEnd()); if (jObject.Value("result") != "ok") @@ -138,14 +138,14 @@ public class MangaDex : MangaConnector $"contentRating%5B%5D=safe&contentRating%5B%5D=suggestive&contentRating%5B%5D=erotica&includeFutureUpdates=0&includes%5B%5D="; offset += Limit; - RequestResult result = downloadClient.MakeRequest(requestUrl, RequestType.MangaDexFeed); - if ((int)result.statusCode < 200 || (int)result.statusCode >= 300) + HttpResponseMessage result = downloadClient.MakeRequest(requestUrl, RequestType.MangaDexFeed).Result; + if ((int)result.StatusCode < 200 || (int)result.StatusCode >= 300) { Log.Error("Request failed"); return []; } - using StreamReader sr = new (result.result); + using StreamReader sr = new (result.Content.ReadAsStream()); JObject jObject = JObject.Parse(sr.ReadToEnd()); if (jObject.Value("result") != "ok") @@ -191,14 +191,14 @@ public class MangaDex : MangaConnector string id = match.Groups[1].Value; string requestUrl = $"https://api.mangadex.org/at-home/server/{id}"; - RequestResult result = downloadClient.MakeRequest(requestUrl, RequestType.Default); - if ((int)result.statusCode < 200 || (int)result.statusCode >= 300) + HttpResponseMessage result = downloadClient.MakeRequest(requestUrl, RequestType.Default).Result; + if ((int)result.StatusCode < 200 || (int)result.StatusCode >= 300) { Log.Error("Request failed"); return []; } - using StreamReader sr = new (result.result); + using StreamReader sr = new (result.Content.ReadAsStream()); JObject jObject = JObject.Parse(sr.ReadToEnd()); if (jObject.Value("result") != "ok") diff --git a/API/MangaConnectors/MangaPark.cs b/API/MangaConnectors/MangaPark.cs index 51bc614..091d01d 100644 --- a/API/MangaConnectors/MangaPark.cs +++ b/API/MangaConnectors/MangaPark.cs @@ -36,7 +36,7 @@ public class MangaPark : MangaConnector for (int page = 1;; page++) // break; in loop { Uri searchUri = new(baseUri, $"search?word={HttpUtility.UrlEncode(mangaSearchName)}&lang={Tranga.Settings.DownloadLanguage}&page={page}"); - if (downloadClient.MakeRequest(searchUri.ToString(), RequestType.Default) is { statusCode: >= HttpStatusCode.OK and < HttpStatusCode.Ambiguous } result) + if (downloadClient.MakeRequest(searchUri.ToString(), RequestType.Default).Result is { StatusCode: >= HttpStatusCode.OK and < HttpStatusCode.Ambiguous } result) { HtmlDocument document = result.CreateDocument(); // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract HAP sucks with nullable types @@ -73,8 +73,8 @@ public class MangaPark : MangaConnector public override (Manga, MangaConnectorId)? GetMangaFromUrl(string url) { - if (downloadClient.MakeRequest(url, RequestType.Default) is - { statusCode: >= HttpStatusCode.OK and < HttpStatusCode.Ambiguous } result) + if (downloadClient.MakeRequest(url, RequestType.Default).Result is + { StatusCode: >= HttpStatusCode.OK and < HttpStatusCode.Ambiguous } result) { HtmlDocument document= result.CreateDocument(); @@ -145,8 +145,8 @@ public class MangaPark : MangaConnector List<(Chapter, MangaConnectorId)> ret = []; - if (downloadClient.MakeRequest(requestUri.ToString(), RequestType.Default) is - { statusCode: >= HttpStatusCode.OK and < HttpStatusCode.Ambiguous } result) + if (downloadClient.MakeRequest(requestUri.ToString(), RequestType.Default).Result is + { StatusCode: >= HttpStatusCode.OK and < HttpStatusCode.Ambiguous } result) { HtmlDocument document= result.CreateDocument(); @@ -220,8 +220,8 @@ public class MangaPark : MangaConnector Log.Debug($"Using domain {domain}"); Uri baseUri = new ($"https://{domain}/"); Uri requestUri = new (baseUri, $"title/{chapterId.IdOnConnectorSite}"); - if (downloadClient.MakeRequest(requestUri.ToString(), RequestType.Default) is - { statusCode: >= HttpStatusCode.OK and < HttpStatusCode.Ambiguous } result) + if (downloadClient.MakeRequest(requestUri.ToString(), RequestType.Default).Result is + { StatusCode: >= HttpStatusCode.OK and < HttpStatusCode.Ambiguous } result) { HtmlDocument document = result.CreateDocument(); @@ -240,10 +240,10 @@ public class MangaPark : MangaConnector internal static class MangaParkHelper { - internal static HtmlDocument CreateDocument(this RequestResult result) + internal static HtmlDocument CreateDocument(this HttpResponseMessage result) { HtmlDocument document = new(); - StreamReader sr = new (result.result); + StreamReader sr = new (result.Content.ReadAsStream()); string htmlStr = sr.ReadToEnd().Replace("q:key", "qkey"); document.LoadHtml(htmlStr); diff --git a/API/MangaConnectors/Mangaworld.cs b/API/MangaConnectors/Mangaworld.cs index e8b84cf..9def2c1 100644 --- a/API/MangaConnectors/Mangaworld.cs +++ b/API/MangaConnectors/Mangaworld.cs @@ -30,11 +30,11 @@ public sealed class Mangaworld : MangaConnector Uri baseUri = new ("https://www.mangaworld.cx/"); Uri searchUrl = new (baseUri, "archive?keyword=" + HttpUtility.UrlEncode(mangaSearchName)); - RequestResult res = downloadClient.MakeRequest(searchUrl.ToString(), RequestType.Default); - if ((int)res.statusCode < 200 || (int)res.statusCode >= 300) + HttpResponseMessage res = downloadClient.MakeRequest(searchUrl.ToString(), RequestType.Default).Result; + if ((int)res.StatusCode < 200 || (int)res.StatusCode >= 300) return []; - using StreamReader sr = new (res.result); + using StreamReader sr = new (res.Content.ReadAsStream()); string html = sr.ReadToEnd(); HtmlDocument doc = new (); @@ -85,11 +85,11 @@ public sealed class Mangaworld : MangaConnector string slug = parts[1]; string url = $"https://www.mangaworld.cx/manga/{id}/{slug}/"; - RequestResult res = downloadClient.MakeRequest(url, RequestType.MangaInfo); - if ((int)res.statusCode < 200 || (int)res.statusCode >= 300) + HttpResponseMessage res = downloadClient.MakeRequest(url, RequestType.MangaInfo).Result; + if ((int)res.StatusCode < 200 || (int)res.StatusCode >= 300) return null; - using StreamReader sr = new (res.result); + using StreamReader sr = new (res.Content.ReadAsStream()); string html = sr.ReadToEnd(); HtmlDocument doc = new (); @@ -175,11 +175,11 @@ public sealed class Mangaworld : MangaConnector { string url = EnsureListStyle(chapterId.WebsiteUrl ?? $"https://www.mangaworld.cx/manga/{chapterId.IdOnConnectorSite}"); - RequestResult res = downloadClient.MakeRequest(url, RequestType.MangaInfo); - if ((int)res.statusCode < 200 || (int)res.statusCode >= 300) + HttpResponseMessage res = downloadClient.MakeRequest(url, RequestType.MangaInfo).Result; + if ((int)res.StatusCode < 200 || (int)res.StatusCode >= 300) return []; - using StreamReader sr = new (res.result); + using StreamReader sr = new (res.Content.ReadAsStream()); string html = sr.ReadToEnd(); Uri baseUri = new (url); @@ -354,20 +354,20 @@ public sealed class Mangaworld : MangaConnector baseUri = new (seriesUrl); // 1) tenta client "Default" - RequestResult res = downloadClient.MakeRequest(seriesUrl, RequestType.Default); - if ((int)res.statusCode >= 200 && (int)res.statusCode < 300) + HttpResponseMessage res = downloadClient.MakeRequest(seriesUrl, RequestType.Default).Result; + if ((int)res.StatusCode >= 200 && (int)res.StatusCode < 300) { - using StreamReader sr = new (res.result); + using StreamReader sr = new (res.Content.ReadAsStream()); string html = sr.ReadToEnd(); if (!LooksLikeChallenge(html)) return html; } // 2) fallback: client “MangaInfo” (proxy/Flare se configurato) - RequestResult res2 = downloadClient.MakeRequest(seriesUrl, RequestType.MangaInfo); - if ((int)res2.statusCode >= 200 && (int)res2.statusCode < 300) + HttpResponseMessage res2 = downloadClient.MakeRequest(seriesUrl, RequestType.MangaInfo).Result; + if ((int)res2.StatusCode >= 200 && (int)res2.StatusCode < 300) { - using StreamReader sr2 = new StreamReader(res2.result); + using StreamReader sr2 = new StreamReader(res2.Content.ReadAsStream()); return sr2.ReadToEnd(); } diff --git a/API/MangaDownloadClients/DownloadClient.cs b/API/MangaDownloadClients/DownloadClient.cs index 0df3f51..6cce650 100644 --- a/API/MangaDownloadClients/DownloadClient.cs +++ b/API/MangaDownloadClients/DownloadClient.cs @@ -1,56 +1,6 @@ -using System.Collections.Concurrent; -using System.Net; -using log4net; +namespace API.MangaDownloadClients; -namespace API.MangaDownloadClients; - -public abstract class DownloadClient +public interface IDownloadClient { - private static readonly ConcurrentDictionary LastExecutedRateLimit = new(); - protected ILog Log { get; init; } - - protected DownloadClient() - { - this.Log = LogManager.GetLogger(GetType()); - } - - // TODO Requests still go too fast across threads! - public RequestResult MakeRequest(string url, RequestType requestType, string? referrer = null, string? clickButton = null) - { - Log.Debug($"Requesting {requestType} {url}"); - - // If we don't have a RequestLimit set for a Type, use the default one - if (!Tranga.Settings.RequestLimits.ContainsKey(requestType)) - requestType = RequestType.Default; - - int rateLimit = Tranga.Settings.RequestLimits[requestType]; - // TODO this probably needs a better check whether the useragent matches... - // If the UserAgent is the default one, do not exceed the default request-limits. - if (Tranga.Settings.UserAgent == TrangaSettings.DefaultUserAgent && rateLimit > TrangaSettings.DefaultRequestLimits[requestType]) - rateLimit = TrangaSettings.DefaultRequestLimits[requestType]; - - // Apply the delay - TimeSpan timeBetweenRequests = TimeSpan.FromMinutes(1).Divide(rateLimit); - DateTime now = DateTime.UtcNow; - LastExecutedRateLimit.TryAdd(requestType, now.Subtract(timeBetweenRequests)); - - TimeSpan rateLimitTimeout = timeBetweenRequests.Subtract(now.Subtract(LastExecutedRateLimit[requestType])); - Log.Debug($"Request limit {requestType} {rateLimit}/Minute timeBetweenRequests: {timeBetweenRequests:ss'.'fffff}"); - - if (rateLimitTimeout > TimeSpan.Zero) - { - Log.Info($"Waiting {rateLimitTimeout} for {url}"); - Thread.Sleep(rateLimitTimeout); - } - - // Make the request - RequestResult result = MakeRequestInternal(url, referrer, clickButton); - - // Update the time the last request was made - LastExecutedRateLimit[requestType] = DateTime.UtcNow; - Log.Debug($"Result {url}: {result}"); - return result; - } - - internal abstract RequestResult MakeRequestInternal(string url, string? referrer = null, string? clickButton = null); + internal Task MakeRequest(string url, RequestType requestType, string? referrer = null); } \ No newline at end of file diff --git a/API/MangaDownloadClients/FlareSolverrDownloadClient.cs b/API/MangaDownloadClients/FlareSolverrDownloadClient.cs index f4861c8..86dd1c5 100644 --- a/API/MangaDownloadClients/FlareSolverrDownloadClient.cs +++ b/API/MangaDownloadClients/FlareSolverrDownloadClient.cs @@ -1,25 +1,26 @@ using System.Diagnostics.CodeAnalysis; using System.Net; -using System.Text; using System.Text.Json; using HtmlAgilityPack; +using log4net; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace API.MangaDownloadClients; -public class FlareSolverrDownloadClient : DownloadClient +public class FlareSolverrDownloadClient(HttpClient client) : IDownloadClient { - internal override RequestResult MakeRequestInternal(string url, string? referrer = null, string? clickButton = null) + private ILog Log { get; } = LogManager.GetLogger(typeof(FlareSolverrDownloadClient)); + + public async Task MakeRequest(string url, RequestType requestType, string? referrer = null) { - if (clickButton is not null) - Log.Warn("Client can not click button"); + Log.Debug($"Using {typeof(FlareSolverrDownloadClient).FullName} for {url}"); if(referrer is not null) Log.Warn("Client can not set referrer"); if (Tranga.Settings.FlareSolverrUrl == string.Empty) { Log.Error("FlareSolverr URL is empty"); - return new(HttpStatusCode.InternalServerError, null, Stream.Null); + return new(HttpStatusCode.InternalServerError); } Uri flareSolverrUri = new (Tranga.Settings.FlareSolverrUrl); @@ -29,13 +30,6 @@ public class FlareSolverrDownloadClient : DownloadClient Path = "v1" }.Uri; - HttpClient client = new() - { - Timeout = TimeSpan.FromSeconds(10), - DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher, - DefaultRequestHeaders = { { "User-Agent", Tranga.Settings.UserAgent } } - }; - JObject requestObj = new() { ["cmd"] = "request.get", @@ -52,12 +46,12 @@ public class FlareSolverrDownloadClient : DownloadClient HttpResponseMessage? response; try { - response = client.Send(requestMessage); + response = await client.SendAsync(requestMessage); } catch (HttpRequestException e) { Log.Error(e); - return new (HttpStatusCode.Unused, null, Stream.Null); + return new (HttpStatusCode.InternalServerError); } if (!response.IsSuccessStatusCode) @@ -74,7 +68,7 @@ public class FlareSolverrDownloadClient : DownloadClient $"{response.Version}\n" + $"Headers:\n\t{string.Join("\n\t", response.Headers.Select(h => $"{h.Key}: <{string.Join(">, <", h.Value)}"))}>\n" + $"{response.Content.ReadAsStringAsync().Result}"); - return new (response.StatusCode, null, Stream.Null); + return response; } string responseString = response.Content.ReadAsStringAsync().Result; @@ -82,51 +76,45 @@ public class FlareSolverrDownloadClient : DownloadClient if (!IsInCorrectFormat(responseObj, out string? reason)) { Log.Error($"Wrong format: {reason}"); - return new(HttpStatusCode.Unused, null, Stream.Null); + return new(HttpStatusCode.InternalServerError); } string statusResponse = responseObj["status"]!.Value()!; if (statusResponse != "ok") { Log.Debug($"Status is not ok: {statusResponse}"); - return new(HttpStatusCode.Unused, null, Stream.Null); + return new(HttpStatusCode.InternalServerError); } JObject solution = (responseObj["solution"] as JObject)!; if (!Enum.TryParse(solution["status"]!.Value().ToString(), out HttpStatusCode statusCode)) { Log.Error($"Wrong format: Cant parse status code: {solution["status"]!.Value()}"); - return new(HttpStatusCode.Unused, null, Stream.Null); + return new(HttpStatusCode.InternalServerError); } if (statusCode < HttpStatusCode.OK || statusCode >= HttpStatusCode.MultipleChoices) { Log.Debug($"Status is: {statusCode}"); - return new(statusCode, null, Stream.Null); + return new (statusCode); } if (solution["response"]!.Value() is not { } htmlString) { Log.Error("Wrong format: Cant find response in solution"); - return new(HttpStatusCode.Unused, null, Stream.Null); + return new(HttpStatusCode.InternalServerError); } - if (IsJson(htmlString, out HtmlDocument document, out string? json)) + if (IsJson(htmlString, out string? json)) { - MemoryStream ms = new(); - ms.Write(Encoding.UTF8.GetBytes(json)); - ms.Position = 0; - return new(statusCode, document, ms); + return new(statusCode) { Content = new StringContent(json) }; } else { - MemoryStream ms = new(); - ms.Write(Encoding.UTF8.GetBytes(htmlString)); - ms.Position = 0; - return new(statusCode, document, ms); + return new(statusCode) { Content = new StringContent(htmlString) }; } } - private bool IsInCorrectFormat(JObject responseObj, [NotNullWhen(false)]out string? reason) + private static bool IsInCorrectFormat(JObject responseObj, [NotNullWhen(false)]out string? reason) { reason = null; if (!responseObj.ContainsKey("status")) @@ -157,10 +145,10 @@ public class FlareSolverrDownloadClient : DownloadClient return true; } - private bool IsJson(string htmlString, out HtmlDocument document, [NotNullWhen(true)]out string? jsonString) + private static bool IsJson(string htmlString, [NotNullWhen(true)]out string? jsonString) { jsonString = null; - document = new(); + HtmlDocument document = new(); document.LoadHtml(htmlString); HtmlNode pre = document.DocumentNode.SelectSingleNode("//pre"); diff --git a/API/MangaDownloadClients/HttpDownloadClient.cs b/API/MangaDownloadClients/HttpDownloadClient.cs index d0babb0..e4b5eea 100644 --- a/API/MangaDownloadClients/HttpDownloadClient.cs +++ b/API/MangaDownloadClients/HttpDownloadClient.cs @@ -1,89 +1,59 @@ using System.Net; -using HtmlAgilityPack; +using log4net; namespace API.MangaDownloadClients; -internal class HttpDownloadClient : DownloadClient +internal class HttpDownloadClient : IDownloadClient { - private static readonly FlareSolverrDownloadClient FlareSolverrDownloadClient = new(); - internal override RequestResult MakeRequestInternal(string url, string? referrer = null, string? clickButton = null) + private static readonly HttpClient Client = new(handler: Tranga.RateLimitHandler) + { + Timeout = TimeSpan.FromSeconds(10), + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher, + DefaultRequestHeaders = { { "User-Agent", Tranga.Settings.UserAgent } } + }; + private static readonly FlareSolverrDownloadClient FlareSolverrDownloadClient = new(Client); + private ILog Log { get; } = LogManager.GetLogger(typeof(HttpDownloadClient)); + + public async Task MakeRequest(string url, RequestType requestType, string? referrer = null) { Log.Debug($"Using {typeof(HttpDownloadClient).FullName} for {url}"); - if (clickButton is not null) - Log.Warn("Client can not click button"); - HttpClient client = new(); - client.Timeout = TimeSpan.FromSeconds(10); - client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher; - client.DefaultRequestHeaders.Add("User-Agent", Tranga.Settings.UserAgent); - HttpResponseMessage? response; - Uri uri = new(url); - HttpRequestMessage requestMessage = new(HttpMethod.Get, uri); + HttpRequestMessage requestMessage = new(HttpMethod.Get, url); if (referrer is not null) requestMessage.Headers.Referrer = new (referrer); Log.Debug($"Requesting {url}"); + try { - response = client.Send(requestMessage); - } - catch (HttpRequestException e) - { - Log.Error(e); - return new (HttpStatusCode.Unused, null, Stream.Null); - } + HttpResponseMessage response = await Client.SendAsync(requestMessage); + Log.Debug($"Request {url} returned {(int)response.StatusCode} {response.StatusCode}"); + if(response.IsSuccessStatusCode) + return response; - if (!response.IsSuccessStatusCode) - { - Log.Debug($"Request returned status code {(int)response.StatusCode} {response.StatusCode}"); if (response.Headers.Server.Any(s => (s.Product?.Name ?? "").Contains("cloudflare", StringComparison.InvariantCultureIgnoreCase))) { Log.Debug("Retrying with FlareSolverr!"); - return FlareSolverrDownloadClient.MakeRequestInternal(url, referrer, clickButton); - } - else - { - Log.Debug($"Request returned status code {(int)response.StatusCode} {response.StatusCode}:\n" + - $"=====\n" + - $"Request:\n" + - $"{requestMessage.Method} {requestMessage.RequestUri}\n" + - $"{requestMessage.Version} {requestMessage.VersionPolicy}\n" + - $"Headers:\n\t{string.Join("\n\t", requestMessage.Headers.Select(h => $"{h.Key}: <{string.Join(">, <", h.Value)}"))}>\n" + - $"{requestMessage.Content?.ReadAsStringAsync().Result}" + - $"=====\n" + - $"Response:\n" + - $"{response.Version}\n" + - $"Headers:\n\t{string.Join("\n\t", response.Headers.Select(h => $"{h.Key}: <{string.Join(">, <", h.Value)}"))}>\n" + - $"{response.Content.ReadAsStringAsync().Result}"); + return await FlareSolverrDownloadClient.MakeRequest(url, requestType, referrer); } + + Log.Debug($"Request returned status code {(int)response.StatusCode} {response.StatusCode}:\n" + + $"=====\n" + + $"Request:\n" + + $"{requestMessage.Method} {requestMessage.RequestUri}\n" + + $"{requestMessage.Version} {requestMessage.VersionPolicy}\n" + + $"Headers:\n\t{string.Join("\n\t", requestMessage.Headers.Select(h => $"{h.Key}: <{string.Join(">, <", h.Value)}"))}>\n" + + $"{requestMessage.Content?.ReadAsStringAsync().Result}" + + $"=====\n" + + $"Response:\n" + + $"{response.Version}\n" + + $"Headers:\n\t{string.Join("\n\t", response.Headers.Select(h => $"{h.Key}: <{string.Join(">, <", h.Value)}"))}>\n" + + $"{response.Content.ReadAsStringAsync().Result}"); + return new(HttpStatusCode.InternalServerError); } - - Stream stream; - try - { - stream = response.Content.ReadAsStream(); - } - catch (Exception e) + catch (HttpRequestException e) { Log.Error(e); - return new (HttpStatusCode.Unused, null, Stream.Null); + return new(HttpStatusCode.InternalServerError); } - - HtmlDocument? document = null; - - if (response.Content.Headers.ContentType?.MediaType == "text/html") - { - StreamReader reader = new (stream); - document = new (); - document.LoadHtml(reader.ReadToEnd()); - stream.Position = 0; - } - - // Request has been redirected to another page. For example, it redirects directly to the results when there is only 1 result - if (response.RequestMessage is not null && response.RequestMessage.RequestUri is not null && response.RequestMessage.RequestUri != uri) - { - return new (response.StatusCode, document, stream, true, response.RequestMessage.RequestUri.AbsoluteUri); - } - - return new (response.StatusCode, document, stream); } } \ No newline at end of file diff --git a/API/MangaDownloadClients/RateLimitHandler.cs b/API/MangaDownloadClients/RateLimitHandler.cs new file mode 100644 index 0000000..3213e74 --- /dev/null +++ b/API/MangaDownloadClients/RateLimitHandler.cs @@ -0,0 +1,31 @@ +using System.Net; +using System.Threading.RateLimiting; +using log4net; + +namespace API.MangaDownloadClients; + +public class RateLimitHandler() : DelegatingHandler(new HttpClientHandler()) +{ + private ILog Log { get; init; } = LogManager.GetLogger(typeof(RateLimitHandler)); + + private readonly RateLimiter _limiter = new SlidingWindowRateLimiter(new () + { + AutoReplenishment = true, + PermitLimit = 240, + QueueLimit = 120, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + SegmentsPerWindow = 60, + Window = TimeSpan.FromSeconds(60) + }); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Log.Debug($"Requesting lease {request.RequestUri}"); + using RateLimitLease lease = await _limiter.AcquireAsync(permitCount: 1, cancellationToken); + Log.Debug($"lease {lease.IsAcquired} {request.RequestUri}"); + + return lease.IsAcquired + ? await base.SendAsync(request, cancellationToken) + : new (HttpStatusCode.TooManyRequests); + } +} \ No newline at end of file diff --git a/API/MangaDownloadClients/RequestResult.cs b/API/MangaDownloadClients/RequestResult.cs deleted file mode 100644 index ae9cec1..0000000 --- a/API/MangaDownloadClients/RequestResult.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Net; -using HtmlAgilityPack; - -namespace API.MangaDownloadClients; - -public struct RequestResult -{ - public HttpStatusCode statusCode { get; } - public Stream result { get; } - public bool hasBeenRedirected { get; } - public string? redirectedToUrl { get; } - public HtmlDocument? htmlDocument { get; } - - public RequestResult(HttpStatusCode statusCode, HtmlDocument? htmlDocument, Stream result) - { - this.statusCode = statusCode; - this.htmlDocument = htmlDocument; - this.result = result; - } - - public RequestResult(HttpStatusCode statusCode, HtmlDocument? htmlDocument, Stream result, bool hasBeenRedirected, string redirectedTo) - : this(statusCode, htmlDocument, result) - { - this.hasBeenRedirected = hasBeenRedirected; - redirectedToUrl = redirectedTo; - } - - public override string ToString() - { - return - $"{(int)statusCode} {statusCode.ToString()} {(hasBeenRedirected ? "Redirected: " : "")} {redirectedToUrl}"; - } -} \ No newline at end of file diff --git a/API/Tranga.cs b/API/Tranga.cs index 9eb0aac..a05eb02 100644 --- a/API/Tranga.cs +++ b/API/Tranga.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using API.MangaConnectors; +using API.MangaDownloadClients; using API.Schema.LibraryContext; using API.Schema.MangaContext; using API.Schema.MangaContext.MetadataFetchers; @@ -35,6 +36,8 @@ public static class Tranga internal static readonly CleanupMangaconnectorIdsWithoutConnector CleanupMangaconnectorIdsWithoutConnector = new(); // ReSharper restore MemberCanBePrivate.Global + internal static readonly RateLimitHandler RateLimitHandler = new(); + internal static void StartupTasks() { AddWorker(SendNotificationsWorker); diff --git a/API/TrangaSettings.cs b/API/TrangaSettings.cs index 7b205e4..0cbe2ff 100644 --- a/API/TrangaSettings.cs +++ b/API/TrangaSettings.cs @@ -1,5 +1,4 @@ using System.Runtime.InteropServices; -using API.MangaDownloadClients; using API.Workers; using Newtonsoft.Json; using Newtonsoft.Json.Converters; @@ -38,17 +37,6 @@ public struct TrangaSettings() /// public string ChapterNamingScheme { get; set; } = "%M - ?V(Vol.%V )Ch.%C?T( - %T)"; public int WorkCycleTimeoutMs { get; set; } = 20000; - [JsonIgnore] - internal static readonly Dictionary DefaultRequestLimits = new () - { - {RequestType.MangaInfo, 360}, - {RequestType.MangaDexFeed, 360}, - {RequestType.MangaDexImage, 60}, - {RequestType.MangaImage, 240}, - {RequestType.MangaCover, 60}, - {RequestType.Default, 360} - }; - public Dictionary RequestLimits { get; set; } = DefaultRequestLimits; public string DownloadLanguage { get; set; } = "en"; @@ -78,18 +66,6 @@ public struct TrangaSettings() Save(); } - public void SetRequestLimit(RequestType type, int value) - { - this.RequestLimits[type] = value; - Save(); - } - - public void ResetRequestLimits() - { - this.RequestLimits = DefaultRequestLimits; - Save(); - } - public void UpdateImageCompression(int value) { this.ImageCompression = value; diff --git a/API/Workers/MangaDownloadWorkers/DownloadChapterFromMangaconnectorWorker.cs b/API/Workers/MangaDownloadWorkers/DownloadChapterFromMangaconnectorWorker.cs index 99c2beb..55dc59f 100644 --- a/API/Workers/MangaDownloadWorkers/DownloadChapterFromMangaconnectorWorker.cs +++ b/API/Workers/MangaDownloadWorkers/DownloadChapterFromMangaconnectorWorker.cs @@ -286,14 +286,14 @@ public class DownloadChapterFromMangaconnectorWorker(MangaConnectorId c private Stream? DownloadImage(string imageUrl) { HttpDownloadClient downloadClient = new(); - RequestResult requestResult = downloadClient.MakeRequest(imageUrl, RequestType.MangaImage); + HttpResponseMessage requestResult = downloadClient.MakeRequest(imageUrl, RequestType.MangaImage).Result; - if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) + if ((int)requestResult.StatusCode < 200 || (int)requestResult.StatusCode >= 300) return null; - if (requestResult.result == Stream.Null) + if (requestResult.Content.ReadAsStream() == Stream.Null) return null; - return ProcessImage(requestResult.result, out Stream processedImage) ? processedImage : null; + return ProcessImage(requestResult.Content.ReadAsStream(), out Stream processedImage) ? processedImage : null; } public override string ToString() => $"{base.ToString()} {_mangaConnectorIdId}"; diff --git a/API/openapi/API_v2.json b/API/openapi/API_v2.json index 506142d..d725b20 100644 --- a/API/openapi/API_v2.json +++ b/API/openapi/API_v2.json @@ -2764,51 +2764,6 @@ } }, "/v2/Settings/RequestLimits": { - "get": { - "tags": [ - "Settings" - ], - "summary": "Get all Request-Limits", - "responses": { - "200": { - "description": "", - "content": { - "application/json; x-version=2.0": { - "schema": { - "type": "object", - "properties": { - "Default": { - "type": "integer", - "format": "int32" - }, - "MangaDexFeed": { - "type": "integer", - "format": "int32" - }, - "MangaImage": { - "type": "integer", - "format": "int32" - }, - "MangaCover": { - "type": "integer", - "format": "int32" - }, - "MangaDexImage": { - "type": "integer", - "format": "int32" - }, - "MangaInfo": { - "type": "integer", - "format": "int32" - } - }, - "additionalProperties": false - } - } - } - } - } - }, "patch": { "tags": [ "Settings" @@ -2820,145 +2775,6 @@ "description": "Not Implemented" } } - }, - "delete": { - "tags": [ - "Settings" - ], - "summary": "Reset Request-Limit", - "responses": { - "200": { - "description": "", - "content": { - "text/plain; x-version=2.0": { - "schema": { - "type": "string" - } - }, - "application/json; x-version=2.0": { - "schema": { - "type": "string" - } - }, - "text/json; x-version=2.0": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/v2/Settings/RequestLimits/{RequestType}": { - "patch": { - "tags": [ - "Settings" - ], - "summary": "Updates a Request-Limit value", - "parameters": [ - { - "name": "RequestType", - "in": "path", - "description": "Type of Request", - "required": true, - "schema": { - "$ref": "#/components/schemas/RequestType" - } - } - ], - "requestBody": { - "description": "New limit in Requests/Minute", - "content": { - "application/json-patch+json; x-version=2.0": { - "schema": { - "type": "integer", - "format": "int32" - } - }, - "application/json; x-version=2.0": { - "schema": { - "type": "integer", - "format": "int32" - } - }, - "text/json; x-version=2.0": { - "schema": { - "type": "integer", - "format": "int32" - } - }, - "application/*+json; x-version=2.0": { - "schema": { - "type": "integer", - "format": "int32" - } - } - } - }, - "responses": { - "200": { - "description": "" - }, - "400": { - "description": "Limit needs to be greater than 0", - "content": { - "text/plain; x-version=2.0": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "application/json; x-version=2.0": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - }, - "text/json; x-version=2.0": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - }, - "delete": { - "tags": [ - "Settings" - ], - "summary": "Reset Request-Limit", - "parameters": [ - { - "name": "RequestType", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/RequestType" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "text/plain; x-version=2.0": { - "schema": { - "type": "string" - } - }, - "application/json; x-version=2.0": { - "schema": { - "type": "string" - } - }, - "text/json; x-version=2.0": { - "schema": { - "type": "string" - } - } - } - } - } } }, "/v2/Settings/ImageCompressionLevel": { @@ -4313,17 +4129,6 @@ }, "additionalProperties": { } }, - "RequestType": { - "enum": [ - "Default", - "MangaDexFeed", - "MangaImage", - "MangaCover", - "MangaDexImage", - "MangaInfo" - ], - "type": "string" - }, "TrangaSettings": { "type": "object", "properties": { @@ -4356,37 +4161,6 @@ "type": "integer", "format": "int32" }, - "requestLimits": { - "type": "object", - "properties": { - "Default": { - "type": "integer", - "format": "int32" - }, - "MangaDexFeed": { - "type": "integer", - "format": "int32" - }, - "MangaImage": { - "type": "integer", - "format": "int32" - }, - "MangaCover": { - "type": "integer", - "format": "int32" - }, - "MangaDexImage": { - "type": "integer", - "format": "int32" - }, - "MangaInfo": { - "type": "integer", - "format": "int32" - } - }, - "additionalProperties": false, - "nullable": true - }, "downloadLanguage": { "type": "string", "nullable": true diff --git a/docker-compose.local.yaml b/docker-compose.local.yaml index 2fc3a8c..80c61dd 100644 --- a/docker-compose.local.yaml +++ b/docker-compose.local.yaml @@ -22,7 +22,7 @@ services: max-size: "10m" max-file: "5" tranga-pg: - image: postgres:latest + image: postgres:17 container_name: tranga-pg ports: - "5432:5432"