diff --git a/server/RdtClient.Service.Test/Services/TorrentClients/PremiumizeDebridClientTest.cs b/server/RdtClient.Service.Test/Services/TorrentClients/PremiumizeDebridClientTest.cs new file mode 100644 index 0000000..6d6d0ba --- /dev/null +++ b/server/RdtClient.Service.Test/Services/TorrentClients/PremiumizeDebridClientTest.cs @@ -0,0 +1,235 @@ +using System.Net; +using System.Text; +using Microsoft.Extensions.Logging; +using Moq; +using RdtClient.Data.Models.Internal; +using RdtClient.Service.Services; +using RdtClient.Service.Services.DebridClients; + +namespace RdtClient.Service.Test.Services.TorrentClients; + +[CollectionDefinition(nameof(PremiumizeSettingsCollection), DisableParallelization = true)] +public class PremiumizeSettingsCollection; + +[Collection(nameof(PremiumizeSettingsCollection))] +public class PremiumizeDebridClientTest : IDisposable +{ + private readonly Mock _fileFilterMock; + private readonly Mock _httpClientFactoryMock; + private readonly Mock> _loggerMock; + private readonly String _originalApiKey; + + public PremiumizeDebridClientTest() + { + _loggerMock = new(); + _httpClientFactoryMock = new(); + _fileFilterMock = new(); + _originalApiKey = Settings.Get.Provider.ApiKey ?? String.Empty; + + Settings.Get.Provider.ApiKey = "test-api-key"; + } + + public void Dispose() + { + Settings.Get.Provider.ApiKey = _originalApiKey; + } + + [Fact] + public async Task AddNzbLink_UsesPremiumizeNetTransferCreate_ReturnsTransferId() + { + // Arrange + var handler = new RecordingHttpMessageHandler(_ => JsonResponse("""{"status":"success","id":"transfer-id","name":"test.nzb"}""")); + var client = CreateClient(handler); + + // Act + var result = await client.AddNzbLink("http://example.com/test.nzb"); + + // Assert + Assert.Equal("transfer-id", result); + Assert.Equal(HttpMethod.Post, handler.Request!.Method); + Assert.Equal("https://www.premiumize.me/api/transfer/create?apikey=test-api-key", handler.Request.RequestUri!.ToString()); + Assert.Null(handler.Request.Headers.Authorization); + Assert.Equal("application/x-www-form-urlencoded", handler.Request.Content!.Headers.ContentType!.MediaType); + Assert.Equal("src=http%3A%2F%2Fexample.com%2Ftest.nzb&folder_id=", handler.RequestBody); + } + + [Fact] + public async Task AddNzbFile_SubmitsMultipartTransferCreateRequest_ReturnsTransferId() + { + // Arrange + var handler = new RecordingHttpMessageHandler(_ => JsonResponse("""{"status":"success","id":"file-transfer-id","name":"test.nzb"}""")); + var client = CreateClient(handler); + + // Act + var result = await client.AddNzbFile(Encoding.UTF8.GetBytes("nzb body"), "test.nzb"); + + // Assert + Assert.Equal("file-transfer-id", result); + Assert.Equal(HttpMethod.Post, handler.Request!.Method); + Assert.Equal("https://www.premiumize.me/api/transfer/create", handler.Request.RequestUri!.ToString()); + Assert.Equal("Bearer", handler.Request.Headers.Authorization!.Scheme); + Assert.Equal("test-api-key", handler.Request.Headers.Authorization.Parameter); + Assert.Equal("multipart/form-data", handler.Request.Content!.Headers.ContentType!.MediaType); + Assert.Contains("Content-Disposition: form-data", handler.RequestBody); + Assert.Contains("src", handler.RequestBody); + Assert.Contains("test.nzb", handler.RequestBody); + Assert.Contains("nzb body", handler.RequestBody); + } + + [Fact] + public async Task AddNzbFile_WhenNameIsMissing_UsesDefaultFilename() + { + // Arrange + var handler = new RecordingHttpMessageHandler(_ => JsonResponse("""{"status":"success","id":"file-transfer-id","name":"upload.nzb"}""")); + var client = CreateClient(handler); + + // Act + await client.AddNzbFile(Encoding.UTF8.GetBytes("nzb body"), null); + + // Assert + Assert.Contains("upload.nzb", handler.RequestBody); + } + + [Fact] + public async Task AddNzbFile_WhenNameHasNoNzbExtension_AppendsNzbExtension() + { + // Arrange + var handler = new RecordingHttpMessageHandler(_ => JsonResponse("""{"status":"success","id":"file-transfer-id","name":"release-title.nzb"}""")); + var client = CreateClient(handler); + + // Act + await client.AddNzbFile(Encoding.UTF8.GetBytes("nzb body"), "release-title"); + + // Assert + Assert.Contains("release-title.nzb", handler.RequestBody); + } + + [Fact] + public async Task AddNzbLink_WhenSuccessResponseHasNoId_Throws() + { + // Arrange + var handler = new RecordingHttpMessageHandler(_ => JsonResponse("""{"status":"success","name":"test.nzb"}""")); + var client = CreateClient(handler); + + // Act + var ex = await Assert.ThrowsAsync(() => client.AddNzbLink("http://example.com/test.nzb")); + + // Assert + Assert.Contains("Unable to add NZB link", ex.Message); + } + + [Fact] + public async Task AddNzbLink_WhenPremiumizeReturnsRateLimitError_ThrowsRateLimitException() + { + // Arrange + var handler = new RecordingHttpMessageHandler(_ => JsonResponse("""{"status":"error","code":"slow_down","message":"rate limit exceeded"}""")); + var client = CreateClient(handler); + + // Act + var ex = await Assert.ThrowsAsync(() => client.AddNzbLink("http://example.com/test.nzb")); + + // Assert + Assert.Equal(TimeSpan.FromMinutes(2), ex.RetryAfter); + } + + [Theory] + [InlineData("You've made too many API requests too quickly.")] + [InlineData("Your fair-use points, booster points, or active-job count is exhausted.")] + [InlineData("This account's usage limit for this service has been reached.")] + [InlineData("The target service is unreachable right now. Retry after a delay.")] + public async Task AddNzbLink_WhenPremiumizeNetReturnsDocumentedRetryMessage_ThrowsRateLimitException(String message) + { + // Arrange + var handler = new RecordingHttpMessageHandler(_ => JsonResponse($$"""{"status":"error","message":"{{message}}","code":"rate_limit_reached"}""")); + var client = CreateClient(handler); + + // Act + var ex = await Assert.ThrowsAsync(() => client.AddNzbLink("http://example.com/test.nzb")); + + // Assert + Assert.Equal(TimeSpan.FromMinutes(2), ex.RetryAfter); + } + + [Theory] + [InlineData("rate_limit_reached")] + [InlineData("account_limit_reached")] + [InlineData("service_limit_reached")] + [InlineData("service_down")] + [InlineData("semi_permanent_error")] + public async Task AddNzbFile_WhenPremiumizeReturnsDocumentedRetryCode_ThrowsRateLimitException(String code) + { + // Arrange + var handler = new RecordingHttpMessageHandler(_ => JsonResponse($$"""{"status":"error","code":"{{code}}","message":"retry later"}""")); + var client = CreateClient(handler); + + // Act + var ex = await Assert.ThrowsAsync(() => client.AddNzbFile(Encoding.UTF8.GetBytes("nzb body"), "test.nzb")); + + // Assert + Assert.Equal(TimeSpan.FromMinutes(2), ex.RetryAfter); + } + + [Fact] + public async Task AddTorrentMagnet_UsesPremiumizeNetTransferCreate_ReturnsTransferId() + { + // Arrange + var handler = new RecordingHttpMessageHandler(_ => JsonResponse("""{"status":"success","id":"magnet-transfer-id","name":"test"}""")); + var client = CreateClient(handler); + + // Act + var result = await client.AddTorrentMagnet("magnet:?xt=urn:btih:test"); + + // Assert + Assert.Equal("magnet-transfer-id", result); + Assert.Equal("https://www.premiumize.me/api/transfer/create?apikey=test-api-key", handler.Request!.RequestUri!.ToString()); + Assert.Equal("src=magnet%3A%3Fxt%3Durn%3Abtih%3Atest&folder_id=", handler.RequestBody); + } + + [Fact] + public async Task AddTorrentFile_UsesPremiumizeNetFileUpload_ReturnsTransferId() + { + // Arrange + var handler = new RecordingHttpMessageHandler(_ => JsonResponse("""{"status":"success","id":"torrent-transfer-id","name":"test"}""")); + var client = CreateClient(handler); + + // Act + var result = await client.AddTorrentFile(Encoding.UTF8.GetBytes("torrent body")); + + // Assert + Assert.Equal("torrent-transfer-id", result); + Assert.Equal("https://www.premiumize.me/api/transfer/create?apikey=test-api-key", handler.Request!.RequestUri!.ToString()); + Assert.Contains("name=file", handler.RequestBody); + Assert.Contains("filename=1.torrent", handler.RequestBody); + Assert.Contains("application/x-bittorrent", handler.RequestBody); + Assert.Contains("torrent body", handler.RequestBody); + } + + private PremiumizeDebridClient CreateClient(RecordingHttpMessageHandler handler) + { + _httpClientFactoryMock.Setup(m => m.CreateClient(It.IsAny())).Returns(new HttpClient(handler)); + + return new(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object); + } + + private static HttpResponseMessage JsonResponse(String json, HttpStatusCode statusCode = HttpStatusCode.OK) + { + return new(statusCode) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + } + + private class RecordingHttpMessageHandler(Func handler) : HttpMessageHandler + { + public HttpRequestMessage? Request { get; private set; } + public String? RequestBody { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Request = request; + RequestBody = request.Content == null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + + return handler(request); + } + } +} diff --git a/server/RdtClient.Service/Services/DebridClients/PremiumizeDebridClient.cs b/server/RdtClient.Service/Services/DebridClients/PremiumizeDebridClient.cs index 44863f9..d23661c 100644 --- a/server/RdtClient.Service/Services/DebridClients/PremiumizeDebridClient.cs +++ b/server/RdtClient.Service/Services/DebridClients/PremiumizeDebridClient.cs @@ -1,4 +1,6 @@ using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using PremiumizeNET; @@ -13,6 +15,8 @@ namespace RdtClient.Service.Services.DebridClients; public class PremiumizeDebridClient(ILogger logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : IDebridClient { + private const String TransferCreateUrl = "https://www.premiumize.me/api/transfer/create"; + public async Task> GetDownloads() { var results = await GetClient().Transfers.ListAsync(); @@ -33,56 +37,22 @@ public class PremiumizeDebridClient(ILogger logger, IHtt public async Task AddTorrentMagnet(String magnetLink) { - try - { - var result = await GetClient().Transfers.CreateAsync(magnetLink, ""); - - if (result?.Id == null) - { - throw new("Unable to add magnet link"); - } - - var resultId = result.Id ?? throw new($"Invalid responseID {result.Id}"); - - return resultId; - } - catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase) || - ex.Message.Contains("rate limit exceeded", StringComparison.OrdinalIgnoreCase)) - { - throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); - } + return await CreatePremiumizeNetTransfer(() => GetClient().Transfers.CreateAsync(magnetLink, ""), "magnet link"); } public async Task AddTorrentFile(Byte[] bytes) { - try - { - var result = await GetClient().Transfers.CreateAsync(bytes, ""); - - if (result?.Id == null) - { - throw new("Unable to add torrent file"); - } - - var resultId = result.Id ?? throw new($"Invalid responseID {result.Id}"); - - return resultId; - } - catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase) || - ex.Message.Contains("rate limit exceeded", StringComparison.OrdinalIgnoreCase)) - { - throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); - } + return await CreatePremiumizeNetTransfer(() => GetClient().Transfers.CreateAsync(bytes, ""), "torrent file"); } - public Task AddNzbLink(String nzbLink) + public async Task AddNzbLink(String nzbLink) { - throw new NotSupportedException(); + return await CreatePremiumizeNetTransfer(() => GetClient().Transfers.CreateAsync(nzbLink, ""), "NZB link"); } - public Task AddNzbFile(Byte[] bytes, String? name) + public async Task AddNzbFile(Byte[] bytes, String? name) { - throw new NotSupportedException(); + return await CreateTransferFromNzbFile(bytes, GetNzbFileName(name)); } public Task> GetAvailableFiles(String hash) @@ -225,13 +195,7 @@ public class PremiumizeDebridClient(ILogger logger, IHtt { try { - var apiKey = Settings.Get.Provider.ApiKey; - - if (String.IsNullOrWhiteSpace(apiKey)) - { - throw new("Premiumize API Key not set in the settings"); - } - + var apiKey = GetApiKey(); var httpClient = httpClientFactory.CreateClient(); httpClient.Timeout = TimeSpan.FromSeconds(10); @@ -253,6 +217,146 @@ public class PremiumizeDebridClient(ILogger logger, IHtt } } + private static String GetApiKey() + { + var apiKey = Settings.Get.Provider.ApiKey; + + if (String.IsNullOrWhiteSpace(apiKey)) + { + throw new("Premiumize API Key not set in the settings"); + } + + return apiKey; + } + + private async Task CreatePremiumizeNetTransfer(Func> createTransfer, String description) + { + try + { + var result = await createTransfer(); + + if (String.IsNullOrWhiteSpace(result?.Id)) + { + throw new($"Unable to add {description}"); + } + + return result.Id; + } + catch (Exception ex) when (IsRateLimitMessage(ex.Message)) + { + throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); + } + } + + private async Task CreateTransferFromNzbFile(Byte[] bytes, String fileName) + { + var content = new MultipartFormDataContent(); + var fileContent = new ByteArrayContent(bytes); + content.Add(fileContent, "src", fileName); + + return await CreateTransfer(content, "NZB file"); + } + + private static String GetNzbFileName(String? name) + { + if (String.IsNullOrWhiteSpace(name)) + { + return "upload.nzb"; + } + + return name.EndsWith(".nzb", StringComparison.OrdinalIgnoreCase) ? name : $"{name}.nzb"; + } + + private async Task CreateTransfer(HttpContent content, String description) + { + try + { + using (content) + { + var httpClient = httpClientFactory.CreateClient(); + httpClient.Timeout = TimeSpan.FromSeconds(10); + + using var request = new HttpRequestMessage(HttpMethod.Post, TransferCreateUrl) + { + Content = content + }; + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", GetApiKey()); + + using var response = await httpClient.SendAsync(request); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (response.StatusCode == HttpStatusCode.TooManyRequests) + { + var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromMinutes(2); + + throw new RateLimitException($"Unable to add {description}: Premiumize rate limit exceeded", retryAfter); + } + + if (!response.IsSuccessStatusCode) + { + throw new($"Unable to add {description}: Premiumize returned {(Int32)response.StatusCode} {response.ReasonPhrase}. {responseBody}"); + } + + var result = JsonConvert.DeserializeObject(responseBody) ?? throw new($"Unable to add {description}: invalid Premiumize response"); + + if (!String.Equals(result.Status, "success", StringComparison.OrdinalIgnoreCase)) + { + var error = FormatPremiumizeError(result); + + if (IsRateLimitMessage(error)) + { + throw new RateLimitException(error, TimeSpan.FromMinutes(2)); + } + + throw new($"Unable to add {description}: {error}"); + } + + if (String.IsNullOrWhiteSpace(result.Id)) + { + throw new($"Unable to add {description}: Premiumize did not return a transfer id"); + } + + return result.Id; + } + } + catch (RateLimitException) + { + throw; + } + catch (Exception ex) when (IsRateLimitMessage(ex.Message)) + { + throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); + } + } + + private static String FormatPremiumizeError(RawTransferCreateResponse result) + { + return String.Join(": ", new[] + { + result.Code, + result.Message + }.Where(m => !String.IsNullOrWhiteSpace(m))); + } + + private static Boolean IsRateLimitMessage(String message) + { + return message.Contains("slow_down", StringComparison.OrdinalIgnoreCase) || + message.Contains("rate limit exceeded", StringComparison.OrdinalIgnoreCase) || + message.Contains("rate_limit_reached", StringComparison.OrdinalIgnoreCase) || + message.Contains("account_limit_reached", StringComparison.OrdinalIgnoreCase) || + message.Contains("service_limit_reached", StringComparison.OrdinalIgnoreCase) || + message.Contains("service_down", StringComparison.OrdinalIgnoreCase) || + message.Contains("semi_permanent_error", StringComparison.OrdinalIgnoreCase) || + message.Contains("too many API requests", StringComparison.OrdinalIgnoreCase) || + message.Contains("fair-use points", StringComparison.OrdinalIgnoreCase) || + message.Contains("booster points", StringComparison.OrdinalIgnoreCase) || + message.Contains("active-job count", StringComparison.OrdinalIgnoreCase) || + message.Contains("usage limit for this service", StringComparison.OrdinalIgnoreCase) || + message.Contains("target service is unreachable", StringComparison.OrdinalIgnoreCase) || + message.Contains("retry after a delay", StringComparison.OrdinalIgnoreCase); + } + private static DebridClientTorrent Map(Transfer transfer) { return new() @@ -363,4 +467,19 @@ public class PremiumizeDebridClient(ILogger logger, IHtt logger.LogDebug(message); } + + private class RawTransferCreateResponse + { + [JsonProperty("status")] + public String? Status { get; set; } + + [JsonProperty("id")] + public String? Id { get; set; } + + [JsonProperty("message")] + public String? Message { get; set; } + + [JsonProperty("code")] + public String? Code { get; set; } + } }